mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 16:22:16 +01:00
Compare commits
243 Commits
feature/ta
...
feature/gi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c87645bcb2 | ||
|
|
15411ec0c8 | ||
|
|
48f09421d0 | ||
|
|
36caadeb14 | ||
|
|
b4edcaa2dc | ||
|
|
a3dda8b03c | ||
|
|
d64f0c52be | ||
|
|
c80d51c2e1 | ||
|
|
46dea00b34 | ||
|
|
16fed4a2b7 | ||
|
|
9a2af2e743 | ||
|
|
2e3a517502 | ||
|
|
a69fb9c551 | ||
|
|
62b5730fa8 | ||
|
|
098ead8da6 | ||
|
|
d49082d7f3 | ||
|
|
cc88f034c7 | ||
|
|
9620c89545 | ||
|
|
f9c3f42289 | ||
|
|
fca4c26cfc | ||
|
|
d8f4ba1517 | ||
|
|
284d8cd786 | ||
|
|
411fc6ea5e | ||
|
|
02a8367d16 | ||
|
|
68f636c8ff | ||
|
|
9c44abf7aa | ||
|
|
b1ff97a82f | ||
|
|
ed32c6b217 | ||
|
|
8b950d99d6 | ||
|
|
e374d8b3de | ||
|
|
301d09e830 | ||
|
|
49af3d4371 | ||
|
|
b4a6145f40 | ||
|
|
06c6c874eb | ||
|
|
b796d232f5 | ||
|
|
26c50867b3 | ||
|
|
b8110e222a | ||
|
|
7673b365ca | ||
|
|
da5fc3f113 | ||
|
|
8c66068663 | ||
|
|
dd0cc0d60b | ||
|
|
3a482c1e6a | ||
|
|
ef9f353047 | ||
|
|
f1a1d2a266 | ||
|
|
f5efbad703 | ||
|
|
17242188c2 | ||
|
|
0a376b1caa | ||
|
|
10a8310e37 | ||
|
|
89131b9e77 | ||
|
|
c17c5dc6c0 | ||
|
|
3444281703 | ||
|
|
84e2365a6d | ||
|
|
92ac9948a0 | ||
|
|
8da358dbe6 | ||
|
|
b7b9092e64 | ||
|
|
15ac3e9a43 | ||
|
|
d03dd60864 | ||
|
|
827e0fe377 | ||
|
|
e78a551098 | ||
|
|
ae00fdb0e9 | ||
|
|
3c9160a08a | ||
|
|
4fb744db1d | ||
|
|
bc9b104c3f | ||
|
|
880c363ae4 | ||
|
|
8e6d1abbf3 | ||
|
|
d202bd9c47 | ||
|
|
992d8945df | ||
|
|
df2fe1da1e | ||
|
|
7339b79e35 | ||
|
|
6deb281565 | ||
|
|
6ba0b19d40 | ||
|
|
01f6f0f5ea | ||
|
|
aa3c64e496 | ||
|
|
eee13897c9 | ||
|
|
ac6e2b8079 | ||
|
|
50cc7053e4 | ||
|
|
73ce5f793d | ||
|
|
02a716897d | ||
|
|
e5ec11af44 | ||
|
|
ab263e725f | ||
|
|
f93c5370bf | ||
|
|
9faa8fe6e1 | ||
|
|
9948cb1fc1 | ||
|
|
3026edd27b | ||
|
|
b6bbcd7097 | ||
|
|
0d4ffa1061 | ||
|
|
b7abe3738e | ||
|
|
128a21ba63 | ||
|
|
e25461a439 | ||
|
|
ba8751c7c4 | ||
|
|
21b33a0028 | ||
|
|
97585b5771 | ||
|
|
ae76135373 | ||
|
|
69a8c8bb2b | ||
|
|
4ea55e5867 | ||
|
|
bbed618fdc | ||
|
|
d924fa74ec | ||
|
|
adf0d35c11 | ||
|
|
4ed8f16ae3 | ||
|
|
0a956fd9e7 | ||
|
|
09b168cddb | ||
|
|
31b9659f7e | ||
|
|
db7111da44 | ||
|
|
18ab1f714b | ||
|
|
00e2518196 | ||
|
|
6f6e5fb4c3 | ||
|
|
68228bccb2 | ||
|
|
2dd80ba6cc | ||
|
|
b783ea9ecd | ||
|
|
dce608e403 | ||
|
|
84c9cfe2f2 | ||
|
|
f14bd6413a | ||
|
|
eb19199bc6 | ||
|
|
0252d984cb | ||
|
|
18162b0ff5 | ||
|
|
3dab7440dd | ||
|
|
713e12e54e | ||
|
|
fc0a840ded | ||
|
|
28904b650e | ||
|
|
1d34a77eb2 | ||
|
|
49e045809b | ||
|
|
e90fa8307f | ||
|
|
895540d0a9 | ||
|
|
62270382dc | ||
|
|
29929467f6 | ||
|
|
02fe89dfdf | ||
|
|
03550a0ca6 | ||
|
|
2f1056dddb | ||
|
|
6e226cd743 | ||
|
|
19ed966504 | ||
|
|
33818f10b3 | ||
|
|
ee9d818d75 | ||
|
|
e3d8457523 | ||
|
|
67e42a0a54 | ||
|
|
fdbf88a9a6 | ||
|
|
c4daca32c5 | ||
|
|
4e10f9538f | ||
|
|
959cad8f74 | ||
|
|
e308ca78b1 | ||
|
|
4281736a6d | ||
|
|
9b0cf37bc7 | ||
|
|
a4f3e014d9 | ||
|
|
32bce2f749 | ||
|
|
ae7f5a98e7 | ||
|
|
e3f981aac2 | ||
|
|
bcb298bd6d | ||
|
|
620c4c97dc | ||
|
|
05da595470 | ||
|
|
a4d8a02b80 | ||
|
|
0860aa9d24 | ||
|
|
9c82efdf07 | ||
|
|
2560619c15 | ||
|
|
c03aad1abd | ||
|
|
0ee0175f04 | ||
|
|
0c1f06face | ||
|
|
86d625b18a | ||
|
|
83e17d4a40 | ||
|
|
5b27853546 | ||
|
|
f49f7b2c9b | ||
|
|
9e77500d94 | ||
|
|
2cf9b3aa8f | ||
|
|
64b41e3018 | ||
|
|
31014c1e29 | ||
|
|
d880717749 | ||
|
|
df0f3b2680 | ||
|
|
4b0cb2e282 | ||
|
|
d5699da234 | ||
|
|
96f06bae1d | ||
|
|
e1243178fe | ||
|
|
cfbc98705a | ||
|
|
f0d6b234e5 | ||
|
|
4b622afcfc | ||
|
|
45daeead61 | ||
|
|
95c1bcd4cb | ||
|
|
3b3f593080 | ||
|
|
4224fdd57e | ||
|
|
f4cfeaa718 | ||
|
|
04fcc1e3ae | ||
|
|
f145e821a8 | ||
|
|
eaaa83406d | ||
|
|
9a60e2b911 | ||
|
|
5a1e05374c | ||
|
|
ab4dbd64df | ||
|
|
8712cfb9dc | ||
|
|
7c1fe35754 | ||
|
|
b0bcc4f330 | ||
|
|
5593d141ea | ||
|
|
d080b07e60 | ||
|
|
64535ceea6 | ||
|
|
e54df74d5d | ||
|
|
27b40d863e | ||
|
|
b41d20839e | ||
|
|
7acadda6d8 | ||
|
|
cd7573dcf1 | ||
|
|
eb4debe481 | ||
|
|
fd77e1e901 | ||
|
|
401cd4be0a | ||
|
|
548307336a | ||
|
|
f534f90ca7 | ||
|
|
0290013d19 | ||
|
|
85f4a3049c | ||
|
|
4c27f1a2de | ||
|
|
69d3ff4f7b | ||
|
|
2b1da883fb | ||
|
|
c291170d79 | ||
|
|
d9925d632e | ||
|
|
ddf11b394d | ||
|
|
129c132f97 | ||
|
|
26637e6f84 | ||
|
|
612f40a4b0 | ||
|
|
8f34fac0a6 | ||
|
|
a374a52474 | ||
|
|
09586de2d5 | ||
|
|
678d27c93a | ||
|
|
7af1990935 | ||
|
|
2372ee0622 | ||
|
|
f147fb9725 | ||
|
|
d5a4df738f | ||
|
|
b3b84db004 | ||
|
|
d3d3a98b08 | ||
|
|
9f2ac70549 | ||
|
|
071895791c | ||
|
|
9a50e144b3 | ||
|
|
a77b8a5ed2 | ||
|
|
fcba96fbf6 | ||
|
|
d200de54a8 | ||
|
|
a882ec6ca0 | ||
|
|
3ee7839ca9 | ||
|
|
165391861a | ||
|
|
8d950c6d45 | ||
|
|
6c7b1b3f21 | ||
|
|
51cd919db6 | ||
|
|
9d279d4980 | ||
|
|
32c7e55a15 | ||
|
|
084647c2a6 | ||
|
|
469f128604 | ||
|
|
c9c221de62 | ||
|
|
878bbd359d | ||
|
|
a6528102fe | ||
|
|
bff766d363 | ||
|
|
2e8da98287 | ||
|
|
a820d8540f | ||
|
|
78ea8a673b |
61
.env.ci
61
.env.ci
@@ -1,57 +1,58 @@
|
|||||||
|
# Application
|
||||||
APP_NAME=solidtime
|
APP_NAME=solidtime
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
APP_FORCE_HTTPS=false
|
APP_FORCE_HTTPS=false
|
||||||
SESSION_SECURE_COOKIE=false
|
APP_ENABLE_REGISTRATION=true
|
||||||
|
|
||||||
|
# Logging
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stack
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# Database
|
||||||
DB_CONNECTION=pgsql_test
|
DB_CONNECTION=pgsql_test
|
||||||
|
|
||||||
DB_TEST_HOST=127.0.0.1
|
DB_TEST_HOST=127.0.0.1
|
||||||
DB_TEST_PORT=5432
|
DB_TEST_PORT=5432
|
||||||
DB_TEST_DATABASE=laravel
|
DB_TEST_DATABASE=laravel
|
||||||
DB_TEST_USERNAME=root
|
DB_TEST_USERNAME=root
|
||||||
DB_TEST_PASSWORD=root
|
DB_TEST_PASSWORD=root
|
||||||
|
|
||||||
BROADCAST_DRIVER=log
|
# Broadcasting
|
||||||
|
BROADCAST_DRIVER=null
|
||||||
|
|
||||||
|
# Cache
|
||||||
CACHE_DRIVER=file
|
CACHE_DRIVER=file
|
||||||
FILESYSTEM_DISK=local
|
|
||||||
|
# Queue
|
||||||
QUEUE_CONNECTION=sync
|
QUEUE_CONNECTION=sync
|
||||||
|
|
||||||
|
# Session
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
# Mail
|
||||||
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PASSWORD=null
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=log
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="solidtime"
|
||||||
|
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
||||||
|
MAIL_REPLY_TO_NAME="solidtime"
|
||||||
|
|
||||||
S3_ACCESS_KEY_ID=
|
# Filesystems
|
||||||
S3_SECRET_ACCESS_KEY=
|
FILESYSTEM_DISK=local
|
||||||
S3_REGION=us-east-1
|
PUBLIC_FILESYSTEM_DISK=public
|
||||||
S3_BUCKET=
|
|
||||||
S3_USE_PATH_STYLE_ENDPOINT=false
|
|
||||||
|
|
||||||
PUSHER_APP_ID=
|
# Passport
|
||||||
PUSHER_APP_KEY=
|
PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92"
|
||||||
PUSHER_APP_SECRET=
|
PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf"
|
||||||
PUSHER_HOST=
|
|
||||||
PUSHER_PORT=443
|
|
||||||
PUSHER_SCHEME=https
|
|
||||||
PUSHER_APP_CLUSTER=mt1
|
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
# Auditing
|
||||||
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
AUDITING_ENABLED=true
|
||||||
VITE_PUSHER_HOST="${PUSHER_HOST}"
|
|
||||||
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
# Telescope
|
||||||
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
TELESCOPE_ENABLED=false
|
||||||
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
|
||||||
|
# Services
|
||||||
|
GOTENBERG_URL=http://0.0.0.0:3000
|
||||||
|
|||||||
65
.env.example
65
.env.example
@@ -1,18 +1,21 @@
|
|||||||
|
# Application
|
||||||
APP_NAME=solidtime
|
APP_NAME=solidtime
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=https://solidtime.test
|
APP_URL=https://solidtime.test
|
||||||
AUDITING_ENABLED=true
|
APP_FORCE_HTTPS=false
|
||||||
|
APP_ENABLE_REGISTRATION=true
|
||||||
SUPER_ADMINS=admin@example.com
|
SUPER_ADMINS=admin@example.com
|
||||||
|
PAGINATION_PER_PAGE_DEFAULT=500
|
||||||
|
|
||||||
|
# Logging
|
||||||
LOG_CHANNEL=single
|
LOG_CHANNEL=single
|
||||||
LOG_DEPRECATIONS_CHANNEL=deprecation
|
LOG_DEPRECATIONS_CHANNEL=deprecation
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# Database
|
||||||
DB_CONNECTION=pgsql
|
DB_CONNECTION=pgsql
|
||||||
|
|
||||||
DB_HOST=pgsql
|
DB_HOST=pgsql
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_DATABASE=laravel
|
DB_DATABASE=laravel
|
||||||
@@ -25,18 +28,20 @@ DB_TEST_DATABASE=laravel
|
|||||||
DB_TEST_USERNAME=root
|
DB_TEST_USERNAME=root
|
||||||
DB_TEST_PASSWORD=root
|
DB_TEST_PASSWORD=root
|
||||||
|
|
||||||
BROADCAST_DRIVER=log
|
# Broadcasting
|
||||||
|
BROADCAST_DRIVER=null
|
||||||
|
|
||||||
|
# Cache
|
||||||
CACHE_DRIVER=file
|
CACHE_DRIVER=file
|
||||||
|
|
||||||
|
# Queue
|
||||||
QUEUE_CONNECTION=sync
|
QUEUE_CONNECTION=sync
|
||||||
|
|
||||||
|
# Session
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
# Mail
|
||||||
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PASSWORD=null
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
MAIL_MAILER=smtp
|
MAIL_MAILER=smtp
|
||||||
MAIL_HOST=mailpit
|
MAIL_HOST=mailpit
|
||||||
MAIL_PORT=1025
|
MAIL_PORT=1025
|
||||||
@@ -44,17 +49,11 @@ MAIL_USERNAME=null
|
|||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_ENCRYPTION=null
|
MAIL_ENCRYPTION=null
|
||||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="solidtime"
|
||||||
|
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
||||||
|
MAIL_REPLY_TO_NAME="solidtime"
|
||||||
|
|
||||||
PUSHER_APP_ID=
|
# Filesystems
|
||||||
PUSHER_APP_KEY=
|
|
||||||
PUSHER_APP_SECRET=
|
|
||||||
PUSHER_HOST=
|
|
||||||
PUSHER_PORT=443
|
|
||||||
PUSHER_SCHEME=https
|
|
||||||
PUSHER_APP_CLUSTER=mt1
|
|
||||||
|
|
||||||
# Storage
|
|
||||||
FILESYSTEM_DISK=s3
|
FILESYSTEM_DISK=s3
|
||||||
PUBLIC_FILESYSTEM_DISK=s3
|
PUBLIC_FILESYSTEM_DISK=s3
|
||||||
S3_ACCESS_KEY_ID=sail
|
S3_ACCESS_KEY_ID=sail
|
||||||
@@ -65,18 +64,24 @@ S3_URL=http://storage.solidtime.test/local
|
|||||||
S3_ENDPOINT=http://storage.solidtime.test
|
S3_ENDPOINT=http://storage.solidtime.test
|
||||||
S3_USE_PATH_STYLE_ENDPOINT=true
|
S3_USE_PATH_STYLE_ENDPOINT=true
|
||||||
|
|
||||||
VITE_HOST_NAME=vite.solidtime.test
|
# Passport
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92"
|
||||||
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf"
|
||||||
VITE_PUSHER_HOST="${PUSHER_HOST}"
|
|
||||||
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
|
||||||
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
|
||||||
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
|
||||||
|
|
||||||
|
# Auditing
|
||||||
|
AUDITING_ENABLED=true
|
||||||
|
|
||||||
|
# Telescope
|
||||||
|
TELESCOPE_ENABLED=false
|
||||||
|
|
||||||
|
# Services
|
||||||
|
GOTENBERG_URL=http://gotenberg:3000
|
||||||
|
|
||||||
|
# Local setup
|
||||||
NGINX_HOST_NAME=solidtime.test
|
NGINX_HOST_NAME=solidtime.test
|
||||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||||
|
|
||||||
FORWARD_DB_PORT=5432
|
FORWARD_DB_PORT=5432
|
||||||
FORWARD_WEB_PORT=8083
|
FORWARD_WEB_PORT=8083
|
||||||
|
VITE_HOST_NAME=vite.solidtime.test
|
||||||
PAGINATION_PER_PAGE_DEFAULT=500
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
#SAIL_XDEBUG_MODE=develop,debug,coverage
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
APP_NAME=solidtime
|
APP_NAME=solidtime
|
||||||
|
APP_VERSION=0.0.0
|
||||||
|
APP_BUILD=0
|
||||||
VITE_APP_NAME=solidtime
|
VITE_APP_NAME=solidtime
|
||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
APP_FORCE_HTTPS=true
|
APP_FORCE_HTTPS=true
|
||||||
SESSION_SECURE_COOKIE=true
|
|
||||||
OCTANE_SERVER=frankenphp
|
OCTANE_SERVER=frankenphp
|
||||||
PAGINATION_PER_PAGE_DEFAULT=500
|
PAGINATION_PER_PAGE_DEFAULT=500
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
/* eslint-env node */
|
|
||||||
require("@rushstack/eslint-patch/modern-module-resolution")
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
extends: ['plugin:vue/vue3-essential', '@vue/eslint-config-typescript/recommended', '@vue/eslint-config-prettier'],
|
|
||||||
rules: {
|
|
||||||
'vue/multi-word-component-names': 'off',
|
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
|
||||||
"unused-imports/no-unused-imports": "error",
|
|
||||||
"unused-imports/no-unused-vars": "error",
|
|
||||||
},
|
|
||||||
plugins: ['unused-imports'],
|
|
||||||
}
|
|
||||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
github: solidtime-io
|
||||||
47
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
Normal file
47
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: "Report a bug"
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Before creating a new bug report, please check that there isn't already a similar issue.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: A clear and concise description of what the bug is.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: "Steps To Reproduce"
|
||||||
|
description: How do you trigger this bug? Please walk us through it step by step.
|
||||||
|
value: |
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: "Self-hosted or Cloud?"
|
||||||
|
options:
|
||||||
|
- Self-Hosted
|
||||||
|
- solidtime Cloud
|
||||||
|
- Both
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: "Version of solidtime: (for self-hosted)"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: "solidtime self-hosting guide: (for self-hosted)"
|
||||||
|
description: "Did you use the official guide to self-host solidtime? If yes, which one?"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 🚀 Feature Request
|
||||||
|
url: https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests
|
||||||
|
about: Share ideas for new features
|
||||||
|
- name: ❓ Ask a Question
|
||||||
|
url: https://github.com/solidtime-io/solidtime/discussions/new?category=general
|
||||||
|
about: Ask the community for help
|
||||||
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<!--
|
||||||
|
This project is early stage. The structure and APIs are still subject to change and not stable.
|
||||||
|
Therefore, we do not currently accept any contributions, unless you are a member of the team.
|
||||||
|
|
||||||
|
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
||||||
|
|
||||||
|
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||||
|
-->
|
||||||
70
.github/workflows/build-private.yml
vendored
70
.github/workflows/build-private.yml
vendored
@@ -10,6 +10,8 @@ on:
|
|||||||
- '.github/workflows/build-private.yml'
|
- '.github/workflows/build-private.yml'
|
||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
name: Build - Private
|
name: Build - Private
|
||||||
jobs:
|
jobs:
|
||||||
@@ -17,18 +19,59 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
|
- name: "Get build"
|
||||||
|
id: build
|
||||||
|
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: "Get Previous tag (normal push)"
|
||||||
|
id: previoustag
|
||||||
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
|
with:
|
||||||
|
prefix: "v"
|
||||||
|
|
||||||
|
- name: "Get version"
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
||||||
|
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
||||||
|
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
|
||||||
|
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "ERROR: No previous tag found";
|
||||||
|
exit 1;
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
version=$(echo "${{ github.ref }}" | cut -c 12-)
|
||||||
|
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: "Copy .env template for production"
|
||||||
|
run: |
|
||||||
|
cp .env.production .env
|
||||||
|
rm .env.production .env.ci .env.example
|
||||||
|
|
||||||
|
- name: "Add version to .env"
|
||||||
|
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
|
||||||
|
|
||||||
|
- name: "Add build to .env"
|
||||||
|
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
|
||||||
|
|
||||||
|
- name: "Output .env"
|
||||||
|
run: cat .env
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
- name: "Copy .env template for production"
|
|
||||||
run: cp .env.production .env && cat .env
|
|
||||||
|
|
||||||
- name: "Checkout billing extension"
|
- name: "Checkout billing extension"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -67,6 +110,24 @@ jobs:
|
|||||||
- name: "Install npm dependencies in services extension"
|
- name: "Install npm dependencies in services extension"
|
||||||
run: cd extensions/Services && npm ci
|
run: cd extensions/Services && npm ci
|
||||||
|
|
||||||
|
- name: "Checkout invoicing extension"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: solidtime-io/extension-invoicing
|
||||||
|
path: extensions/Invoicing
|
||||||
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||||
|
|
||||||
|
- name: "Install composer dependencies in invoicing extension"
|
||||||
|
uses: php-actions/composer@v6
|
||||||
|
with:
|
||||||
|
working_dir: "extensions/Invoicing"
|
||||||
|
command: install
|
||||||
|
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||||
|
php_version: 8.3
|
||||||
|
|
||||||
|
- name: "Install npm dependencies in invoicing extension"
|
||||||
|
run: cd extensions/Invoicing && npm ci
|
||||||
|
|
||||||
- name: "Setup PHP with PECL extension"
|
- name: "Setup PHP with PECL extension"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -87,6 +148,9 @@ jobs:
|
|||||||
- name: "Activate services extension"
|
- name: "Activate services extension"
|
||||||
run: php artisan module:enable Services
|
run: php artisan module:enable Services
|
||||||
|
|
||||||
|
- name: "Activate invoicing extension"
|
||||||
|
run: php artisan module:enable Invoicing
|
||||||
|
|
||||||
- name: "Install npm dependencies"
|
- name: "Install npm dependencies"
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
|||||||
90
.github/workflows/build-public-release.yml
vendored
90
.github/workflows/build-public-release.yml
vendored
@@ -1,90 +0,0 @@
|
|||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/build-public.yml'
|
|
||||||
- 'docker/prod/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
name: Build - Public (Release)
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
contents: read
|
|
||||||
attestations: write
|
|
||||||
id-token: write
|
|
||||||
timeout-minutes: 90
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: "Check out code"
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: "Copy .env template for production"
|
|
||||||
run: cp .env.production .env
|
|
||||||
|
|
||||||
- name: "Install dependencies"
|
|
||||||
uses: php-actions/composer@v6
|
|
||||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
|
||||||
with:
|
|
||||||
command: install
|
|
||||||
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
|
||||||
php_version: 8.3
|
|
||||||
|
|
||||||
- name: "Use Node.js"
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20.x'
|
|
||||||
|
|
||||||
- name: "Install npm dependencies"
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: "Build"
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
- name: "Login to GitHub Container Registry"
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: "Docker meta"
|
|
||||||
id: "meta"
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
solidtime/solidtime
|
|
||||||
ghcr.io/${{ github.repository }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
|
|
||||||
- name: "Set up QEMU"
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: "Build and push"
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/prod/Dockerfile
|
|
||||||
build-args: |
|
|
||||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
185
.github/workflows/build-public.yml
vendored
185
.github/workflows/build-public.yml
vendored
@@ -3,37 +3,93 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- develop
|
- develop
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-public.yml'
|
- '.github/workflows/build-public.yml'
|
||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKERHUB_REPO: solidtime/solidtime
|
||||||
|
GHCR_REPO: ghcr.io/solidtime-io/solidtime
|
||||||
|
|
||||||
name: Build - Public
|
name: Build - Public
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
permissions:
|
matrix:
|
||||||
packages: write
|
include:
|
||||||
contents: read
|
- runs-on: "ubuntu-24.04-arm"
|
||||||
attestations: write
|
platform: "linux/arm64"
|
||||||
id-token: write
|
- runs-on: "ubuntu-24.04"
|
||||||
|
platform: "linux/amd64"
|
||||||
|
runs-on: ${{ matrix.runs-on }}
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
|
- name: "Get build"
|
||||||
|
id: release-build
|
||||||
|
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: "Get Previous tag (normal push)"
|
||||||
|
id: previoustag
|
||||||
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
|
with:
|
||||||
|
prefix: "v"
|
||||||
|
|
||||||
|
- name: "Get version"
|
||||||
|
id: release-version
|
||||||
|
run: |
|
||||||
|
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
||||||
|
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
||||||
|
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
|
||||||
|
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "ERROR: No previous tag found";
|
||||||
|
exit 1;
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
version=$(echo "${{ github.ref }}" | cut -c 12-)
|
||||||
|
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: "Copy .env template for production"
|
- name: "Copy .env template for production"
|
||||||
run: cp .env.production .env
|
run: |
|
||||||
|
cp .env.production .env
|
||||||
|
rm .env.production .env.ci .env.example
|
||||||
|
|
||||||
|
- name: "Add version to .env"
|
||||||
|
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
|
||||||
|
|
||||||
|
- name: "Add build to .env"
|
||||||
|
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
|
||||||
|
|
||||||
|
- name: "Output .env"
|
||||||
|
run: cat .env
|
||||||
|
|
||||||
|
- name: "Setup PHP with PECL extension"
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
extensions: mbstring, dom, fileinfo, pgsql
|
||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
uses: php-actions/composer@v6
|
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
with:
|
|
||||||
command: install
|
|
||||||
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
|
||||||
php_version: 8.3
|
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -46,29 +102,31 @@ jobs:
|
|||||||
- name: "Build"
|
- name: "Build"
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Prepare"
|
||||||
|
run: |
|
||||||
|
platform=${{ matrix.platform }}
|
||||||
|
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: "Docker meta"
|
||||||
|
id: "meta"
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.DOCKERHUB_REPO }}
|
||||||
|
${{ env.GHCR_REPO }}
|
||||||
|
|
||||||
|
- name: "Login to Docker Hub Container Registry"
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: "Docker meta"
|
|
||||||
id: "meta"
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
solidtime/solidtime
|
|
||||||
ghcr.io/${{ github.repository }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
|
|
||||||
- name: "Set up QEMU"
|
- name: "Set up QEMU"
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -76,16 +134,85 @@ jobs:
|
|||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Build and push"
|
- name: "Build and push by digest"
|
||||||
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/prod/Dockerfile
|
file: docker/prod/Dockerfile
|
||||||
build-args: |
|
build-args: |
|
||||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: "Export digest"
|
||||||
|
run: |
|
||||||
|
mkdir -p ${{ runner.temp }}/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: "Upload digest"
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
|
path: ${{ runner.temp }}/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
needs:
|
||||||
|
- build
|
||||||
|
steps:
|
||||||
|
- name: "Download digests"
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: "Login to Docker Hub"
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: "Login to GHCR"
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: "Set up Docker Buildx"
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: "Docker meta"
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.DOCKERHUB_REPO }}
|
||||||
|
${{ env.GHCR_REPO }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
|
||||||
|
- name: "Create manifest list and push"
|
||||||
|
working-directory: ${{ runner.temp }}/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: "Inspect image"
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ env.DOCKERHUB_REPO }}:${{ steps.meta.outputs.version }}
|
||||||
|
docker buildx imagetools inspect ${{ env.GHCR_REPO }}:${{ steps.meta.outputs.version }}
|
||||||
|
|||||||
3
.github/workflows/generate-api-docs.yml
vendored
3
.github/workflows/generate-api-docs.yml
vendored
@@ -3,6 +3,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
api_docs:
|
api_docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
2
.github/workflows/npm-build.yml
vendored
2
.github/workflows/npm-build.yml
vendored
@@ -1,6 +1,8 @@
|
|||||||
name: NPM Build
|
name: NPM Build
|
||||||
|
|
||||||
on: [push]
|
on: [push]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|||||||
2
.github/workflows/npm-lint.yml
vendored
2
.github/workflows/npm-lint.yml
vendored
@@ -1,6 +1,8 @@
|
|||||||
name: NPM Lint
|
name: NPM Lint
|
||||||
|
|
||||||
on: [push]
|
on: [push]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|||||||
5
.github/workflows/npm-publish-api.yml
vendored
5
.github/workflows/npm-publish-api.yml
vendored
@@ -1,6 +1,8 @@
|
|||||||
name: Publish API package to NPM
|
name: Publish API package to NPM
|
||||||
on:
|
on:
|
||||||
workflow_dispatch
|
workflow_dispatch
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -8,7 +10,8 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: "Checkout code"
|
||||||
|
uses: actions/checkout@v4
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- name: Install root project dependencies
|
- name: Install root project dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
5
.github/workflows/npm-publish-ui.yml
vendored
5
.github/workflows/npm-publish-ui.yml
vendored
@@ -1,6 +1,8 @@
|
|||||||
name: Publish UI package to NPM
|
name: Publish UI package to NPM
|
||||||
on:
|
on:
|
||||||
workflow_dispatch
|
workflow_dispatch
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -8,7 +10,8 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: "Checkout code"
|
||||||
|
uses: actions/checkout@v4
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
3
.github/workflows/npm-typecheck.yml
vendored
3
.github/workflows/npm-typecheck.yml
vendored
@@ -1,7 +1,8 @@
|
|||||||
name: NPM Typecheck
|
name: NPM Typecheck
|
||||||
|
|
||||||
on: [push]
|
on: [push]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
2
.github/workflows/phpstan.yml
vendored
2
.github/workflows/phpstan.yml
vendored
@@ -1,5 +1,7 @@
|
|||||||
name: Static code analysis (PHPStan)
|
name: Static code analysis (PHPStan)
|
||||||
on: push
|
on: push
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
phpstan:
|
phpstan:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
14
.github/workflows/phpunit.yml
vendored
14
.github/workflows/phpunit.yml
vendored
@@ -1,5 +1,7 @@
|
|||||||
name: PHPUnit Tests
|
name: PHPUnit Tests
|
||||||
on: push
|
on: push
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
phpunit:
|
phpunit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -20,7 +22,15 @@ jobs:
|
|||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
gotenberg:
|
||||||
|
image: gotenberg/gotenberg:8
|
||||||
|
ports:
|
||||||
|
- 3000:3000
|
||||||
|
options: >-
|
||||||
|
--health-cmd "curl --silent --fail http://localhost:3000/health"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -55,7 +65,7 @@ jobs:
|
|||||||
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||||
|
|
||||||
- name: "Upload coverage reports to Codecov"
|
- name: "Upload coverage reports to Codecov"
|
||||||
uses: codecov/codecov-action@v4.5.0
|
uses: codecov/codecov-action@v5.4.2
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
slug: solidtime-io/solidtime
|
slug: solidtime-io/solidtime
|
||||||
|
|||||||
4
.github/workflows/pint.yml
vendored
4
.github/workflows/pint.yml
vendored
@@ -1,5 +1,7 @@
|
|||||||
name: PHP Linting
|
name: PHP Linting
|
||||||
on: push
|
on: push
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
pint:
|
pint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -10,6 +12,6 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Check code style"
|
- name: "Check code style"
|
||||||
uses: aglipanci/laravel-pint-action@2.4
|
uses: aglipanci/laravel-pint-action@2.5
|
||||||
with:
|
with:
|
||||||
configPath: "pint.json"
|
configPath: "pint.json"
|
||||||
|
|||||||
26
.github/workflows/playwright.yml
vendored
26
.github/workflows/playwright.yml
vendored
@@ -1,5 +1,7 @@
|
|||||||
name: Playwright Tests
|
name: Playwright Tests
|
||||||
on: [push]
|
on: [push]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -27,45 +29,47 @@ jobs:
|
|||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- name: "Setup node"
|
||||||
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: '8.3'
|
php-version: '8.3'
|
||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- name: Run composer install
|
- name: "Run composer install"
|
||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- name: Prepare Laravel Application
|
- name: "Prepare Laravel Application"
|
||||||
run: |
|
run: |
|
||||||
cp .env.ci .env
|
cp .env.ci .env
|
||||||
php artisan key:generate
|
php artisan key:generate
|
||||||
php artisan migrate --seed
|
|
||||||
php artisan passport:keys
|
php artisan passport:keys
|
||||||
|
php artisan migrate --seed
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: "Install dependencies"
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build Frontend
|
- name: "Build Frontend"
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Run Laravel Server
|
- name: "Run Laravel Server"
|
||||||
run: php artisan serve > /dev/null 2>&1 &
|
run: php artisan serve > /dev/null 2>&1 &
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: "Install Playwright Browsers"
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: "Run Playwright tests"
|
||||||
run: npx playwright test
|
run: npx playwright test
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
|
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- name: "Upload test results"
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ solidtime is a modern open-source time tracking application for Freelancers and
|
|||||||
|
|
||||||
- Time tracking: Track your time with a modern and easy-to-use interface
|
- Time tracking: Track your time with a modern and easy-to-use interface
|
||||||
- Projects: Create and manage projects and assign project members
|
- Projects: Create and manage projects and assign project members
|
||||||
- Tasks: Create and manage tasks and assign tasks to project members
|
- Tasks: Create and manage tasks and assign tasks to projects
|
||||||
- Clients: Create and manage clients and assign clients to projects
|
- Clients: Create and manage clients and assign clients to projects
|
||||||
- Billable rates: Set billable rates for projects, project members, organization members and organizations
|
- Billable rates: Set billable rates for projects, project members, organization members and organizations
|
||||||
- Multiple organizations: Create and manage multiple organizations with one account
|
- Multiple organizations: Create and manage multiple organizations with one account
|
||||||
@@ -28,6 +28,11 @@ We also have an examples repository [here](https://github.com/solidtime-io/self-
|
|||||||
|
|
||||||
If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/)
|
If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/)
|
||||||
|
|
||||||
|
## Issues & Feature Requests
|
||||||
|
|
||||||
|
If you find any **bugs in solidtime**, please feel free to [**open an issue**](https://github.com/solidtime-io/solidtime/issues/new) in this repository, with instructions on how to reproduce the bug.
|
||||||
|
If you have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
This project is in a very early stage. The structure and APIs are still subject to change and not stable.
|
This project is in a very early stage. The structure and APIs are still subject to change and not stable.
|
||||||
@@ -35,6 +40,8 @@ Therefore, we do not currently accept any contributions, unless you are a member
|
|||||||
|
|
||||||
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
||||||
|
|
||||||
|
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.
|
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.
|
||||||
|
|||||||
@@ -4,16 +4,14 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Events\NewsletterRegistered;
|
use App\Events\NewsletterRegistered;
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\IpLookup\IpLookupServiceContract;
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
|
use App\Service\UserService;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||||
@@ -34,6 +32,12 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
*/
|
*/
|
||||||
public function create(array $input): User
|
public function create(array $input): User
|
||||||
{
|
{
|
||||||
|
if (! config('app.enable_registration')) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [__('Registration is disabled.')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
Validator::make($input, [
|
Validator::make($input, [
|
||||||
'name' => [
|
'name' => [
|
||||||
'required',
|
'required',
|
||||||
@@ -43,9 +47,9 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
'email',
|
'email:rfc,strict',
|
||||||
'max:255',
|
'max:255',
|
||||||
new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder {
|
UniqueEloquent::make(User::class, 'email', function (Builder $builder): Builder {
|
||||||
/** @var Builder<User> $builder */
|
/** @var Builder<User> $builder */
|
||||||
return $builder->where('is_placeholder', '=', false);
|
return $builder->where('is_placeholder', '=', false);
|
||||||
}),
|
}),
|
||||||
@@ -72,6 +76,11 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
|
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
|
||||||
|
|
||||||
$startOfWeek = Weekday::Monday;
|
$startOfWeek = Weekday::Monday;
|
||||||
|
$numberFormat = null;
|
||||||
|
$currencyFormat = null;
|
||||||
|
$dateFormat = null;
|
||||||
|
$intervalFormat = null;
|
||||||
|
$timeFormat = null;
|
||||||
$currency = null;
|
$currency = null;
|
||||||
if ($ipLookupResponse !== null) {
|
if ($ipLookupResponse !== null) {
|
||||||
$startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday;
|
$startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday;
|
||||||
@@ -81,30 +90,21 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
$currency = $ipLookupResponse->currency;
|
$currency = $ipLookupResponse->currency;
|
||||||
}
|
}
|
||||||
$user = null;
|
$user = null;
|
||||||
$organization = null;
|
DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency, $numberFormat, $currencyFormat, $dateFormat, $intervalFormat, $timeFormat): void {
|
||||||
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency) {
|
$userService = app(UserService::class);
|
||||||
$user = User::create([
|
$user = $userService->createUser(
|
||||||
'name' => $input['name'],
|
$input['name'],
|
||||||
'email' => $input['email'],
|
$input['email'],
|
||||||
'password' => Hash::make($input['password']),
|
$input['password'],
|
||||||
'timezone' => $timezone ?? 'UTC',
|
$timezone ?? 'UTC',
|
||||||
'week_start' => $startOfWeek,
|
$startOfWeek,
|
||||||
]);
|
$currency,
|
||||||
|
$numberFormat,
|
||||||
$organization = new Organization;
|
$currencyFormat,
|
||||||
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
|
$dateFormat,
|
||||||
$organization->personal_team = true;
|
$intervalFormat,
|
||||||
$organization->currency = $currency ?? 'EUR';
|
$timeFormat
|
||||||
$organization->owner()->associate($user);
|
|
||||||
$organization->save();
|
|
||||||
|
|
||||||
$organization->users()->attach(
|
|
||||||
$user, [
|
|
||||||
'role' => Role::Owner->value,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$user->ownedTeams()->save($organization);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];
|
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
'required',
|
'required',
|
||||||
'email',
|
'email',
|
||||||
'max:255',
|
'max:255',
|
||||||
(new UniqueEloquent(User::class, 'email'))->ignore($user->id)->query(function (Builder $query) {
|
UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) {
|
||||||
/** @var Builder<User> $query */
|
/** @var Builder<User> $query */
|
||||||
return $query->where('is_placeholder', '=', false);
|
return $query->where('is_placeholder', '=', false);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,18 +7,16 @@ namespace App\Actions\Jetstream;
|
|||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\MemberService;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Rules\In;
|
use Illuminate\Validation\Rules\In;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||||
use Laravel\Jetstream\Events\AddingTeamMember;
|
|
||||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
|
||||||
|
|
||||||
class AddOrganizationMember implements AddsTeamMembers
|
class AddOrganizationMember implements AddsTeamMembers
|
||||||
{
|
{
|
||||||
@@ -36,15 +34,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
->where('is_placeholder', '=', false)
|
->where('is_placeholder', '=', false)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
AddingTeamMember::dispatch($organization, $newOrganizationMember);
|
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
|
||||||
|
|
||||||
DB::transaction(function () use ($organization, $newOrganizationMember, $role) {
|
|
||||||
$organization->users()->attach(
|
|
||||||
$newOrganizationMember, ['role' => $role]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,10 +61,10 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email',
|
'email',
|
||||||
(new ExistsEloquent(User::class, 'email', function (Builder $builder) {
|
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
|
||||||
/** @var Builder<User> $builder */
|
/** @var Builder<User> $builder */
|
||||||
return $builder->where('is_placeholder', '=', false);
|
return $builder->where('is_placeholder', '=', false);
|
||||||
}))->withMessage(__('We were unable to find a registered user with this email address.')),
|
})->withMessage(__('We were unable to find a registered user with this email address.')),
|
||||||
],
|
],
|
||||||
'role' => [
|
'role' => [
|
||||||
'required',
|
'required',
|
||||||
@@ -93,7 +83,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
*/
|
*/
|
||||||
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
|
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
|
||||||
{
|
{
|
||||||
return function ($validator) use ($team, $email) {
|
return function ($validator) use ($team, $email): void {
|
||||||
$validator->errors()->addIf(
|
$validator->errors()->addIf(
|
||||||
$team->hasRealUserWithEmail($email),
|
$team->hasRealUserWithEmail($email),
|
||||||
'email',
|
'email',
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Events\AfterCreateOrganization;
|
use App\Events\AfterCreateOrganization;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
|
use App\Service\OrganizationService;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
@@ -33,16 +34,18 @@ class CreateOrganization implements CreatesTeams
|
|||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
])->validateWithBag('createTeam');
|
])->validateWithBag('createTeam');
|
||||||
|
|
||||||
$organization = new Organization;
|
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
|
||||||
$organization->name = $input['name'];
|
|
||||||
$organization->personal_team = false;
|
|
||||||
$organization->owner()->associate($user);
|
|
||||||
$organization->save();
|
|
||||||
|
|
||||||
$organization->users()->attach(
|
$currency = null;
|
||||||
$user, [
|
if ($ipLookupResponse !== null) {
|
||||||
'role' => Role::Owner->value,
|
$currency = $ipLookupResponse->currency;
|
||||||
]
|
}
|
||||||
|
|
||||||
|
$organization = app(OrganizationService::class)->createOrganization(
|
||||||
|
$input['name'],
|
||||||
|
$user,
|
||||||
|
false,
|
||||||
|
$currency
|
||||||
);
|
);
|
||||||
|
|
||||||
$user->switchTeam($organization);
|
$user->switchTeam($organization);
|
||||||
|
|||||||
92
app/Console/Commands/Admin/UserCreateCommand.php
Normal file
92
app/Console/Commands/Admin/UserCreateCommand.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Admin;
|
||||||
|
|
||||||
|
use App\Enums\Weekday;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\UserService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
class UserCreateCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'admin:user:create
|
||||||
|
{ name : The name of the user }
|
||||||
|
{ email : The email of the user }
|
||||||
|
{ --ask-for-password : Ask for the password, otherwise the command will generate a random one }
|
||||||
|
{ --verify-email : Verify the email address of the user }';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Create a new user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$name = $this->argument('name');
|
||||||
|
$email = $this->argument('email');
|
||||||
|
$askForPassword = (bool) $this->option('ask-for-password');
|
||||||
|
$verifyEmail = (bool) $this->option('verify-email');
|
||||||
|
|
||||||
|
if (User::query()->where('email', $email)->where('is_placeholder', '=', false)->exists()) {
|
||||||
|
$this->error('User with email "'.$email.'" already exists.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($askForPassword) {
|
||||||
|
$outputPassword = false;
|
||||||
|
$password = $this->secret('Enter the password');
|
||||||
|
} else {
|
||||||
|
$outputPassword = true;
|
||||||
|
$password = bin2hex(random_bytes(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = null;
|
||||||
|
DB::transaction(function () use (&$user, $name, $email, $password, $verifyEmail): void {
|
||||||
|
$user = app(UserService::class)->createUser(
|
||||||
|
$name,
|
||||||
|
$email,
|
||||||
|
$password,
|
||||||
|
'UTC',
|
||||||
|
Weekday::Monday,
|
||||||
|
null,
|
||||||
|
verifyEmail: $verifyEmail
|
||||||
|
);
|
||||||
|
});
|
||||||
|
/** @var Organization|null $organization */
|
||||||
|
$organization = $user->ownedTeams->first();
|
||||||
|
if ($organization === null) {
|
||||||
|
throw new LogicException('User does not have an organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Created user "'.$name.'" ("'.$email.'")');
|
||||||
|
$this->line('ID: '.$user->getKey());
|
||||||
|
$this->line('Name: '.$name);
|
||||||
|
$this->line('Email: '.$email);
|
||||||
|
if ($outputPassword) {
|
||||||
|
$this->line('Password: '.$password);
|
||||||
|
}
|
||||||
|
$this->line('Timezone: '.$user->timezone);
|
||||||
|
$this->line('Week start: '.$user->week_start->value);
|
||||||
|
|
||||||
|
// Organization
|
||||||
|
$this->line('Currency: '.$organization->currency);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,9 @@ class UserVerifyCommand extends Command
|
|||||||
$this->info('Start verifying user with email "'.$email.'"');
|
$this->info('Start verifying user with email "'.$email.'"');
|
||||||
|
|
||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
$user = User::where('email', $email)->first();
|
$user = User::query()->where('email', $email)
|
||||||
|
->where('is_placeholder', '=', false)
|
||||||
|
->first();
|
||||||
|
|
||||||
if ($user === null) {
|
if ($user === null) {
|
||||||
$this->error('User with email "'.$email.'" not found.');
|
$this->error('User with email "'.$email.'" not found.');
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Correction;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class CorrectionPlaceholderMembersCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'correction:placeholder-members '.
|
||||||
|
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Sets all members who belong to a placeholder user to role placeholder';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$this->comment('Sets all members who belong to a placeholder user to role placeholder...');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$members = Member::query()
|
||||||
|
->where('role', '!=', Role::Placeholder->value)
|
||||||
|
->whereHas('user', function (Builder $builder): void {
|
||||||
|
/** @var Builder<User> $builder */
|
||||||
|
$builder->where('is_placeholder', '=', true);
|
||||||
|
})
|
||||||
|
->get();
|
||||||
|
foreach ($members as $member) {
|
||||||
|
/** @var Member $member */
|
||||||
|
$member->role = Role::Placeholder->value;
|
||||||
|
if (! $dryRun) {
|
||||||
|
$member->save();
|
||||||
|
}
|
||||||
|
$this->line('Set role of member (id='.$member->getKey().') to placeholder');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Report;
|
||||||
|
|
||||||
|
use App\Models\Report;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
class ReportSetExpiredToPrivateCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'report:set-expired-to-private '.
|
||||||
|
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Makes public reports private if the public_until date has passed.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$this->comment('Makes public reports private if the public_until date has passed...');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$resetReports = 0;
|
||||||
|
Report::query()
|
||||||
|
->where('public_until', '<', Carbon::now())
|
||||||
|
->orderBy('created_at', 'asc')
|
||||||
|
->chunk(500, function (Collection $reports) use ($dryRun, &$resetReports): void {
|
||||||
|
/** @var Collection<int, Report> $reports */
|
||||||
|
foreach ($reports as $report) {
|
||||||
|
$publicUntil = $report->public_until;
|
||||||
|
if ($publicUntil === null) {
|
||||||
|
throw new LogicException('public_until should not be null');
|
||||||
|
}
|
||||||
|
$this->info('Make report "'.$report->name.'" ('.$report->getKey().') private, expired: '.
|
||||||
|
$publicUntil->toIso8601ZuluString().' ('.$publicUntil->diffForHumans().')');
|
||||||
|
$resetReports++;
|
||||||
|
if (! $dryRun) {
|
||||||
|
$report->is_public = false;
|
||||||
|
$report->share_secret = null;
|
||||||
|
$report->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->comment('Finished setting '.$resetReports.' expired reports to private...');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands\SelfHost;
|
||||||
|
|
||||||
|
use App\Service\ApiService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class SelfHostCheckForUpdateCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'self-host:check-for-update';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$apiService = app(ApiService::class);
|
||||||
|
|
||||||
|
$latestVersion = $apiService->checkForUpdate();
|
||||||
|
if ($latestVersion === null) {
|
||||||
|
$this->error('Failed to check for update, check the logs for more information.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Cache for 13 hours, because the command runs twice daily (every 12 hours).
|
||||||
|
Cache::put('latest_version', $latestVersion, 60 * 60 * 12);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
app/Console/Commands/SelfHost/SelfHostDatabaseConsistency.php
Normal file
123
app/Console/Commands/SelfHost/SelfHostDatabaseConsistency.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands\SelfHost;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Query\Builder;
|
||||||
|
use Illuminate\Database\Query\JoinClause;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class SelfHostDatabaseConsistency extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'self-host:database-consistency';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$hadAProblem = false;
|
||||||
|
|
||||||
|
// Task need to be part of project in time entries
|
||||||
|
$problems = DB::table('time_entries')
|
||||||
|
->select(['time_entries.id as id'])
|
||||||
|
->join('tasks', 'time_entries.task_id', '=', 'tasks.id')
|
||||||
|
->where('tasks.project_id', '!=', DB::raw('time_entries.project_id'))
|
||||||
|
->get();
|
||||||
|
$this->logProblems($problems, 'Time entries have a task that does not belong to the project of the time entry', $hadAProblem);
|
||||||
|
|
||||||
|
// Client id is the client id of the project
|
||||||
|
$problems = DB::table('time_entries')
|
||||||
|
->select(['time_entries.id as id'])
|
||||||
|
->join('projects', 'time_entries.project_id', '=', 'projects.id')
|
||||||
|
->where(DB::raw('coalesce(projects.client_id::varchar, \'\')'), '!=', DB::raw('coalesce(time_entries.client_id::varchar, \'\')'))
|
||||||
|
->get();
|
||||||
|
$this->logProblems($problems, 'Time entries have a client that does not match the client of the project', $hadAProblem);
|
||||||
|
|
||||||
|
// Client id can only be not null if the project id is not null
|
||||||
|
$problems = DB::table('time_entries')
|
||||||
|
->select(['time_entries.id as id'])
|
||||||
|
->whereNotNull('client_id')
|
||||||
|
->whereNull('project_id')
|
||||||
|
->get();
|
||||||
|
$this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);
|
||||||
|
|
||||||
|
// Every user needs to be a member of at least one organization
|
||||||
|
$problems = DB::table('users')
|
||||||
|
->select(['users.id as id'])
|
||||||
|
->leftJoin('members', 'users.id', '=', 'members.user_id')
|
||||||
|
->whereNull('members.id')
|
||||||
|
->get();
|
||||||
|
$this->logProblems($problems, 'Users are not member of any organization', $hadAProblem);
|
||||||
|
|
||||||
|
// Every organization needs at least an owner
|
||||||
|
$problems = DB::table('organizations')
|
||||||
|
->select(['organizations.id as id'])
|
||||||
|
->leftJoin('members', function (JoinClause $join): void {
|
||||||
|
$join->on('organizations.id', '=', 'members.organization_id')
|
||||||
|
->where('members.role', '=', 'owner');
|
||||||
|
})
|
||||||
|
->whereNull('members.id')
|
||||||
|
->get();
|
||||||
|
$this->logProblems($problems, 'Organizations without an owner', $hadAProblem);
|
||||||
|
|
||||||
|
// Every member can only have one running time entry
|
||||||
|
$problems = DB::table('time_entries')
|
||||||
|
->select(['user_id as id'])
|
||||||
|
->whereNull('end')
|
||||||
|
->groupBy('user_id')
|
||||||
|
->havingRaw('count(*) > 1')
|
||||||
|
->get(['user_id', DB::raw('count(*) as count')]);
|
||||||
|
$this->logProblems($problems, 'Users with more than one running time entry', $hadAProblem);
|
||||||
|
|
||||||
|
// Users have a current organization that they are not a member of
|
||||||
|
$problems = DB::table('users')
|
||||||
|
->select(['users.id as id'])
|
||||||
|
->whereNotNull('current_team_id')
|
||||||
|
->whereNotIn('current_team_id', function (Builder $query): void {
|
||||||
|
$query->select('organization_id')
|
||||||
|
->from('members')
|
||||||
|
->whereColumn('members.user_id', 'users.id');
|
||||||
|
})->get();
|
||||||
|
$this->logProblems($problems, 'Users have a current organization that they are not a member of', $hadAProblem);
|
||||||
|
|
||||||
|
return $hadAProblem ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, \stdClass> $problems
|
||||||
|
*/
|
||||||
|
private function logProblems(Collection $problems, string $message, bool &$hadAProblem): void
|
||||||
|
{
|
||||||
|
$message = 'Consistency problem: '.$message;
|
||||||
|
if ($problems->isNotEmpty()) {
|
||||||
|
$ids = $problems->pluck('id');
|
||||||
|
$hadAProblem = true;
|
||||||
|
Log::error($message, [
|
||||||
|
'ids' => $ids,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$error = $message;
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$error .= "\n - ".$id;
|
||||||
|
}
|
||||||
|
$this->error($error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ class SelfHostGenerateKeysCommand extends Command
|
|||||||
*/
|
*/
|
||||||
protected $signature = 'self-host:generate-keys
|
protected $signature = 'self-host:generate-keys
|
||||||
{ --length=4096 : The length of the passport private key }
|
{ --length=4096 : The length of the passport private key }
|
||||||
|
{ --multi-line : Whether to output the keys in multiple lines }
|
||||||
{ --format=env : The format of the output (env, yaml) }';
|
{ --format=env : The format of the output (env, yaml) }';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +35,7 @@ class SelfHostGenerateKeysCommand extends Command
|
|||||||
{
|
{
|
||||||
$format = $this->option('format');
|
$format = $this->option('format');
|
||||||
$key = RSA::createKey((int) $this->option('length'));
|
$key = RSA::createKey((int) $this->option('length'));
|
||||||
|
$multiLine = (bool) $this->option('multi-line');
|
||||||
|
|
||||||
$publicKey = (string) $key->getPublicKey();
|
$publicKey = (string) $key->getPublicKey();
|
||||||
$privateKey = (string) $key;
|
$privateKey = (string) $key;
|
||||||
@@ -41,12 +43,17 @@ class SelfHostGenerateKeysCommand extends Command
|
|||||||
|
|
||||||
if ($format === 'env') {
|
if ($format === 'env') {
|
||||||
$this->line('APP_KEY="'.$appKey.'"');
|
$this->line('APP_KEY="'.$appKey.'"');
|
||||||
$this->line('PASSPORT_PRIVATE_KEY="'.$privateKey.'"');
|
if ($multiLine) {
|
||||||
$this->line('PASSPORT_PUBLIC_KEY="'.$publicKey.'"');
|
$this->line('PASSPORT_PRIVATE_KEY="'.Str::replace("\r\n", "\n", $privateKey).'"');
|
||||||
|
$this->line('PASSPORT_PUBLIC_KEY="'.Str::replace("\r\n", "\n", $publicKey).'"');
|
||||||
|
} else {
|
||||||
|
$this->line('PASSPORT_PRIVATE_KEY="'.Str::replace("\r\n", '\n', $privateKey).'"');
|
||||||
|
$this->line('PASSPORT_PUBLIC_KEY="'.Str::replace("\r\n", '\n', $publicKey).'"');
|
||||||
|
}
|
||||||
} elseif ($format === 'yaml') {
|
} elseif ($format === 'yaml') {
|
||||||
$this->line('APP_KEY: "'.$appKey.'"');
|
$this->line('APP_KEY: "'.$appKey.'"');
|
||||||
$this->line("PASSPORT_PRIVATE_KEY: |\n ".Str::replace("\n", "\n ", $privateKey));
|
$this->line("PASSPORT_PRIVATE_KEY: |\n ".Str::replace("\r\n", "\n ", $privateKey));
|
||||||
$this->line("PASSPORT_PUBLIC_KEY: |\n ".Str::replace("\n", "\n ", $publicKey));
|
$this->line("PASSPORT_PUBLIC_KEY: |\n ".Str::replace("\r\n", "\n ", $publicKey));
|
||||||
} else {
|
} else {
|
||||||
$this->error('Invalid format');
|
$this->error('Invalid format');
|
||||||
|
|
||||||
|
|||||||
44
app/Console/Commands/SelfHost/SelfHostTelemetryCommand.php
Normal file
44
app/Console/Commands/SelfHost/SelfHostTelemetryCommand.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands\SelfHost;
|
||||||
|
|
||||||
|
use App\Service\ApiService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class SelfHostTelemetryCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'self-host:telemetry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$apiService = app(ApiService::class);
|
||||||
|
|
||||||
|
$success = $apiService->telemetry();
|
||||||
|
|
||||||
|
if (! $success) {
|
||||||
|
$this->error('Failed to send telemetry data, check the logs for more information.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ class TimeEntrySendStillRunningMailsCommand extends Command
|
|||||||
$query->where('is_placeholder', '=', false);
|
$query->where('is_placeholder', '=', false);
|
||||||
})
|
})
|
||||||
->orderBy('created_at', 'asc')
|
->orderBy('created_at', 'asc')
|
||||||
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails) {
|
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
|
||||||
/** @var Collection<int, TimeEntry> $timeEntries */
|
/** @var Collection<int, TimeEntry> $timeEntries */
|
||||||
foreach ($timeEntries as $timeEntry) {
|
foreach ($timeEntries as $timeEntry) {
|
||||||
$user = $timeEntry->user;
|
$user = $timeEntry->user;
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ class Kernel extends ConsoleKernel
|
|||||||
$schedule->command('time-entry:send-still-running-mails')
|
$schedule->command('time-entry:send-still-running-mails')
|
||||||
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
|
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
|
||||||
->everyTenMinutes();
|
->everyTenMinutes();
|
||||||
|
|
||||||
|
$schedule->command('self-host:check-for-update')
|
||||||
|
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
||||||
|
->twiceDaily();
|
||||||
|
|
||||||
|
$schedule->command('self-host:telemetry')
|
||||||
|
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
|
||||||
|
->twiceDaily();
|
||||||
|
|
||||||
|
$schedule->command('self-host:database-consistency')
|
||||||
|
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
|
||||||
|
->twiceDaily();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
36
app/Enums/CurrencyFormat.php
Normal file
36
app/Enums/CurrencyFormat.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||||
|
|
||||||
|
enum CurrencyFormat: string
|
||||||
|
{
|
||||||
|
use LaravelEnumHelper;
|
||||||
|
|
||||||
|
case ISOCodeBeforeWithSpace = 'iso-code-before-with-space';
|
||||||
|
case ISOCodeAfterWithSpace = 'iso-code-after-with-space';
|
||||||
|
|
||||||
|
case SymbolBefore = 'symbol-before';
|
||||||
|
|
||||||
|
case SymbolAfter = 'symbol-after';
|
||||||
|
|
||||||
|
case SymbolBeforeWithSpace = 'symbol-before-with-space';
|
||||||
|
|
||||||
|
case SymbolAfterWithSpace = 'symbol-after-with-space';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function toSelectArray(): array
|
||||||
|
{
|
||||||
|
$selectArray = [];
|
||||||
|
foreach (self::values() as $value) {
|
||||||
|
$selectArray[(string) $value] = (string) __('enum.currency_format.'.$value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $selectArray;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Enums/DateFormat.php
Normal file
48
app/Enums/DateFormat.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||||
|
|
||||||
|
enum DateFormat: string
|
||||||
|
{
|
||||||
|
use LaravelEnumHelper;
|
||||||
|
|
||||||
|
case PointSeparatedDMYYYY = 'point-separated-d-m-yyyy';
|
||||||
|
case SlashSeparatedMMDDYYYY = 'slash-separated-mm-dd-yyyy';
|
||||||
|
|
||||||
|
case SlashSeparatedDDMMYYYY = 'slash-separated-dd-mm-yyyy';
|
||||||
|
|
||||||
|
case HyphenSeparatedDDMMYYY = 'hyphen-separated-dd-mm-yyyy';
|
||||||
|
|
||||||
|
case HyphenSeparatedMMDDDYYYY = 'hyphen-separated-mm-dd-yyyy';
|
||||||
|
|
||||||
|
case HyphenSeparatedYYYYMMDD = 'hyphen-separated-yyyy-mm-dd';
|
||||||
|
|
||||||
|
public function toCarbonFormat(): string
|
||||||
|
{
|
||||||
|
return match ($this->value) {
|
||||||
|
self::PointSeparatedDMYYYY->value => 'j.n.Y',
|
||||||
|
self::SlashSeparatedMMDDYYYY->value => 'm/d/Y',
|
||||||
|
self::SlashSeparatedDDMMYYYY->value => 'd/m/Y',
|
||||||
|
self::HyphenSeparatedDDMMYYY->value => 'd-m-Y',
|
||||||
|
self::HyphenSeparatedMMDDDYYYY->value => 'm-d-Y',
|
||||||
|
self::HyphenSeparatedYYYYMMDD->value => 'Y-m-d',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function toSelectArray(): array
|
||||||
|
{
|
||||||
|
$selectArray = [];
|
||||||
|
foreach (self::values() as $value) {
|
||||||
|
$selectArray[(string) $value] = (string) __('enum.date_format.'.$value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $selectArray;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Enums/ExportFormat.php
Normal file
35
app/Enums/ExportFormat.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Maatwebsite\Excel\Excel;
|
||||||
|
|
||||||
|
enum ExportFormat: string
|
||||||
|
{
|
||||||
|
case CSV = 'csv';
|
||||||
|
case PDF = 'pdf';
|
||||||
|
case XLSX = 'xlsx';
|
||||||
|
case ODS = 'ods';
|
||||||
|
|
||||||
|
public function getFileExtension(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::CSV => 'csv',
|
||||||
|
self::PDF => 'pdf',
|
||||||
|
self::XLSX => 'xlsx',
|
||||||
|
self::ODS => 'ods',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExportPackageType(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::CSV => Excel::CSV,
|
||||||
|
self::PDF => Excel::MPDF,
|
||||||
|
self::XLSX => Excel::XLSX,
|
||||||
|
self::ODS => Excel::ODS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Enums/IntervalFormat.php
Normal file
32
app/Enums/IntervalFormat.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||||
|
|
||||||
|
enum IntervalFormat: string
|
||||||
|
{
|
||||||
|
use LaravelEnumHelper;
|
||||||
|
|
||||||
|
case Decimal = 'decimal';
|
||||||
|
case HoursMinutes = 'hours-minutes';
|
||||||
|
|
||||||
|
case HoursMinutesColonSeparated = 'hours-minutes-colon-separated';
|
||||||
|
|
||||||
|
case HoursMinutesSecondsColonSeparated = 'hours-minutes-seconds-colon-separated';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function toSelectArray(): array
|
||||||
|
{
|
||||||
|
$selectArray = [];
|
||||||
|
foreach (self::values() as $value) {
|
||||||
|
$selectArray[(string) $value] = (string) __('enum.interval_format.'.$value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $selectArray;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Enums/NumberFormat.php
Normal file
37
app/Enums/NumberFormat.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @info https://en.wikipedia.org/wiki/Decimal_separator
|
||||||
|
*/
|
||||||
|
enum NumberFormat: string
|
||||||
|
{
|
||||||
|
use LaravelEnumHelper;
|
||||||
|
|
||||||
|
case ThousandsPointDecimalComma = 'point-comma';
|
||||||
|
|
||||||
|
case ThousandsCommaDecimalPoint = 'comma-point';
|
||||||
|
case ThousandsSpaceDecimalComma = 'space-comma';
|
||||||
|
|
||||||
|
case ThousandsSpaceDecimalPoint = 'space-point';
|
||||||
|
|
||||||
|
case ThousandsApostropheDecimalPoint = 'apostrophe-point';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function toSelectArray(): array
|
||||||
|
{
|
||||||
|
$selectArray = [];
|
||||||
|
foreach (self::values() as $value) {
|
||||||
|
$selectArray[(string) $value] = (string) __('enum.number_format.'.$value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $selectArray;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Enums;
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||||
|
|
||||||
enum TimeEntryAggregationType: string
|
enum TimeEntryAggregationType: string
|
||||||
{
|
{
|
||||||
|
use LaravelEnumHelper;
|
||||||
|
|
||||||
case Day = 'day';
|
case Day = 'day';
|
||||||
case Week = 'week';
|
case Week = 'week';
|
||||||
case Month = 'month';
|
case Month = 'month';
|
||||||
@@ -17,6 +21,16 @@ enum TimeEntryAggregationType: string
|
|||||||
case Billable = 'billable';
|
case Billable = 'billable';
|
||||||
case Description = 'description';
|
case Description = 'description';
|
||||||
|
|
||||||
|
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
||||||
|
{
|
||||||
|
return match ($timeEntryAggregationTypeInterval) {
|
||||||
|
TimeEntryAggregationTypeInterval::Day => TimeEntryAggregationType::Day,
|
||||||
|
TimeEntryAggregationTypeInterval::Week => TimeEntryAggregationType::Week,
|
||||||
|
TimeEntryAggregationTypeInterval::Month => TimeEntryAggregationType::Month,
|
||||||
|
TimeEntryAggregationTypeInterval::Year => TimeEntryAggregationType::Year,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public function toInterval(): ?TimeEntryAggregationTypeInterval
|
public function toInterval(): ?TimeEntryAggregationTypeInterval
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
|
|||||||
28
app/Enums/TimeFormat.php
Normal file
28
app/Enums/TimeFormat.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||||
|
|
||||||
|
enum TimeFormat: string
|
||||||
|
{
|
||||||
|
use LaravelEnumHelper;
|
||||||
|
|
||||||
|
case TwelveHours = '12-hours';
|
||||||
|
case TwentyFourHours = '24-hours';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function toSelectArray(): array
|
||||||
|
{
|
||||||
|
$selectArray = [];
|
||||||
|
foreach (self::values() as $value) {
|
||||||
|
$selectArray[(string) $value] = (string) __('enum.time_format.'.$value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $selectArray;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,13 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Enums;
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
enum Weekday: string
|
enum Weekday: string
|
||||||
{
|
{
|
||||||
|
use LaravelEnumHelper;
|
||||||
|
|
||||||
case Monday = 'monday';
|
case Monday = 'monday';
|
||||||
case Tuesday = 'tuesday';
|
case Tuesday = 'tuesday';
|
||||||
case Wednesday = 'wednesday';
|
case Wednesday = 'wednesday';
|
||||||
|
|||||||
14
app/Events/DatabaseSeederAfterSeed.php
Normal file
14
app/Events/DatabaseSeederAfterSeed.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
class DatabaseSeederAfterSeed
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
public function __construct() {}
|
||||||
|
}
|
||||||
14
app/Events/DatabaseSeederBeforeDelete.php
Normal file
14
app/Events/DatabaseSeederBeforeDelete.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
class DatabaseSeederBeforeDelete
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
public function __construct() {}
|
||||||
|
}
|
||||||
10
app/Exceptions/Api/ChangingRoleOfPlaceholderIsNotAllowed.php
Normal file
10
app/Exceptions/Api/ChangingRoleOfPlaceholderIsNotAllowed.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
|
||||||
|
{
|
||||||
|
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class FeatureIsNotAvailableInFreePlanApiException extends ApiException
|
||||||
|
{
|
||||||
|
public const string KEY = 'feature_is_not_available_in_free_plan';
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
|
||||||
|
{
|
||||||
|
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
|
||||||
|
}
|
||||||
10
app/Exceptions/Api/PdfRendererIsNotConfiguredException.php
Normal file
10
app/Exceptions/Api/PdfRendererIsNotConfiguredException.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class PdfRendererIsNotConfiguredException extends ApiException
|
||||||
|
{
|
||||||
|
public const string KEY = 'pdf_renderer_is_not_configured';
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class PersonalAccessClientIsNotConfiguredException extends ApiException
|
||||||
|
{
|
||||||
|
public const string KEY = 'personal_access_client_is_not_configured';
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
|
||||||
|
{
|
||||||
|
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ class Handler extends ExceptionHandler
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
$this->reportable(function (Throwable $e) {
|
$this->reportable(function (Throwable $e): void {
|
||||||
//
|
//
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Extensions\Scramble;
|
namespace App\Extensions\Scramble;
|
||||||
|
|
||||||
use App\Http\Resources\PaginatedResourceCollection;
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
|
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
|
||||||
use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
|
use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
|
||||||
use Dedoc\Scramble\Support\Generator\Response;
|
use Dedoc\Scramble\Support\Generator\Response;
|
||||||
use Dedoc\Scramble\Support\Generator\Schema;
|
use Dedoc\Scramble\Support\Generator\Schema;
|
||||||
@@ -44,39 +45,49 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$type = new OpenApiObjectType;
|
$newType = new OpenApiObjectType;
|
||||||
$type->addProperty('data', (new ArrayType)->setItems($collectingType));
|
$newType->addProperty('data', (new ArrayType)->setItems($collectingType));
|
||||||
$type->addProperty(
|
if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) {
|
||||||
'links',
|
$newType->addProperty(
|
||||||
(new OpenApiObjectType)
|
'meta',
|
||||||
->addProperty('first', (new StringType)->nullable(true))
|
(new OpenApiObjectType)
|
||||||
->addProperty('last', (new StringType)->nullable(true))
|
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
||||||
->addProperty('prev', (new StringType)->nullable(true))
|
->setRequired(['total'])
|
||||||
->addProperty('next', (new StringType)->nullable(true))
|
);
|
||||||
->setRequired(['first', 'last', 'prev', 'next'])
|
$newType->setRequired(['data', 'meta']);
|
||||||
);
|
} else {
|
||||||
$type->addProperty(
|
$newType->addProperty(
|
||||||
'meta',
|
'links',
|
||||||
(new OpenApiObjectType)
|
(new OpenApiObjectType)
|
||||||
->addProperty('current_page', new IntegerType)
|
->addProperty('first', (new StringType)->nullable(true))
|
||||||
->addProperty('from', (new IntegerType)->nullable(true))
|
->addProperty('last', (new StringType)->nullable(true))
|
||||||
->addProperty('last_page', new IntegerType)
|
->addProperty('prev', (new StringType)->nullable(true))
|
||||||
->addProperty('links', (new ArrayType)->setItems(
|
->addProperty('next', (new StringType)->nullable(true))
|
||||||
(new OpenApiObjectType)
|
->setRequired(['first', 'last', 'prev', 'next'])
|
||||||
->addProperty('url', (new StringType)->nullable(true))
|
);
|
||||||
->addProperty('label', new StringType)
|
$newType->addProperty(
|
||||||
->addProperty('active', new BooleanType)
|
'meta',
|
||||||
->setRequired(['url', 'label', 'active'])
|
(new OpenApiObjectType)
|
||||||
)->setDescription('Generated paginator links.'))
|
->addProperty('current_page', new IntegerType)
|
||||||
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
|
->addProperty('from', (new IntegerType)->nullable(true))
|
||||||
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
|
->addProperty('last_page', new IntegerType)
|
||||||
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
|
->addProperty('links', (new ArrayType)->setItems(
|
||||||
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
(new OpenApiObjectType)
|
||||||
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
|
->addProperty('url', (new StringType)->nullable(true))
|
||||||
);
|
->addProperty('label', new StringType)
|
||||||
$type->setRequired(['data', 'links', 'meta']);
|
->addProperty('active', new BooleanType)
|
||||||
|
->setRequired(['url', 'label', 'active'])
|
||||||
|
)->setDescription('Generated paginator links.'))
|
||||||
|
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
|
||||||
|
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
|
||||||
|
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
|
||||||
|
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
||||||
|
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
|
||||||
|
);
|
||||||
|
$newType->setRequired(['data', 'links', 'meta']);
|
||||||
|
}
|
||||||
|
|
||||||
return $type;
|
return $newType;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use Filament\Tables;
|
|||||||
use Filament\Tables\Columns\IconColumn;
|
use Filament\Tables\Columns\IconColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Novadaemon\FilamentPrettyJson\PrettyJson;
|
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
|
||||||
|
|
||||||
class AuditResource extends Resource
|
class AuditResource extends Resource
|
||||||
{
|
{
|
||||||
@@ -38,8 +38,8 @@ class AuditResource extends Resource
|
|||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
Forms\Components\TextInput::make('auditable_id')
|
Forms\Components\TextInput::make('auditable_id')
|
||||||
->required(),
|
->required(),
|
||||||
PrettyJson::make('old_values'),
|
PrettyJsonField::make('old_values'),
|
||||||
PrettyJson::make('new_values'),
|
PrettyJsonField::make('new_values'),
|
||||||
Forms\Components\Textarea::make('url'),
|
Forms\Components\Textarea::make('url'),
|
||||||
Forms\Components\TextInput::make('ip_address'),
|
Forms\Components\TextInput::make('ip_address'),
|
||||||
Forms\Components\TextInput::make('user_agent')
|
Forms\Components\TextInput::make('user_agent')
|
||||||
|
|||||||
@@ -60,8 +60,13 @@ class ClientResource extends Resource
|
|||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('organization')
|
SelectFilter::make('organization')
|
||||||
|
->label('Organization')
|
||||||
->relationship('organization', 'name')
|
->relationship('organization', 'name')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
|
SelectFilter::make('organization_id')
|
||||||
|
->label('Organization ID')
|
||||||
|
->relationship('organization', 'id')
|
||||||
|
->searchable(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Tables\Actions\EditAction::make(),
|
Tables\Actions\EditAction::make(),
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class EditClient extends EditRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make()
|
||||||
|
->icon('heroicon-m-trash'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class ListClients extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->icon('heroicon-s-plus'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use Filament\Tables\Columns\TextColumn;
|
|||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Novadaemon\FilamentPrettyJson\PrettyJson;
|
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @source https://gitlab.com/amvisor/filament-failed-jobs
|
* @source https://gitlab.com/amvisor/filament-failed-jobs
|
||||||
@@ -50,7 +50,7 @@ class FailedJobResource extends Resource
|
|||||||
|
|
||||||
// make text a little bit smaller because often a complete Stack Trace is shown:
|
// make text a little bit smaller because often a complete Stack Trace is shown:
|
||||||
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
|
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
|
||||||
PrettyJson::make('payload')->disabled()->columnSpan(4),
|
PrettyJsonField::make('payload')->disabled()->columnSpan(4),
|
||||||
])->columns(4);
|
])->columns(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
114
app/Filament/Resources/OrganizationInvitationResource.php
Normal file
114
app/Filament/Resources/OrganizationInvitationResource.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Filament\Resources\OrganizationInvitationResource\Pages;
|
||||||
|
use App\Models\OrganizationInvitation;
|
||||||
|
use App\Service\OrganizationInvitationService;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class OrganizationInvitationResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = OrganizationInvitation::class;
|
||||||
|
|
||||||
|
protected static ?string $label = 'Invitations';
|
||||||
|
|
||||||
|
protected static ?string $navigationIcon = 'heroicon-o-user-plus';
|
||||||
|
|
||||||
|
protected static ?string $navigationGroup = 'Users';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 9;
|
||||||
|
|
||||||
|
public static function form(Form $form): Form
|
||||||
|
{
|
||||||
|
return $form
|
||||||
|
->columns(1)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('email')
|
||||||
|
->label('Email')
|
||||||
|
->disabledOn(['edit'])
|
||||||
|
->required(),
|
||||||
|
Select::make('role')
|
||||||
|
->options(Role::class),
|
||||||
|
Forms\Components\Select::make('organization_id')
|
||||||
|
->label('Organization')
|
||||||
|
->relationship(name: 'organization', titleAttribute: 'name')
|
||||||
|
->searchable(['name'])
|
||||||
|
->disabledOn(['edit'])
|
||||||
|
->required(),
|
||||||
|
Forms\Components\DateTimePicker::make('created_at')
|
||||||
|
->label('Created At')
|
||||||
|
->hiddenOn(['create'])
|
||||||
|
->disabled(),
|
||||||
|
Forms\Components\DateTimePicker::make('updated_at')
|
||||||
|
->label('Updated At')
|
||||||
|
->hiddenOn(['create'])
|
||||||
|
->disabled(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('organization.name')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('email')
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('role'),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
|
->label('Created At')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('updated_at')
|
||||||
|
->label('Updated At')
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->filters([
|
||||||
|
//
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Tables\Actions\EditAction::make(),
|
||||||
|
Tables\Actions\DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
Tables\Actions\BulkActionGroup::make([
|
||||||
|
Tables\Actions\BulkAction::make('resend')
|
||||||
|
->label('Resend')
|
||||||
|
->action(function (Collection $records): void {
|
||||||
|
foreach ($records as $organizationInvite) {
|
||||||
|
app(OrganizationInvitationService::class)->resend($organizationInvite);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListOrganizationInvitations::route('/'),
|
||||||
|
'edit' => Pages\EditOrganizationInvitation::route('/{record}/edit'),
|
||||||
|
'view' => Pages\ViewOrganizationInvitation::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OrganizationInvitationResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditOrganizationInvitation extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = OrganizationInvitationResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\DeleteAction::make()
|
||||||
|
->icon('heroicon-m-trash'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OrganizationInvitationResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListOrganizationInvitations extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = OrganizationInvitationResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OrganizationInvitationResource;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewOrganizationInvitation extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = OrganizationInvitationResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
EditAction::make('edit')
|
||||||
|
->icon('heroicon-s-pencil'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,16 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Enums\CurrencyFormat;
|
||||||
|
use App\Enums\DateFormat;
|
||||||
|
use App\Enums\IntervalFormat;
|
||||||
|
use App\Enums\NumberFormat;
|
||||||
|
use App\Enums\TimeFormat;
|
||||||
use App\Filament\Resources\OrganizationResource\Pages;
|
use App\Filament\Resources\OrganizationResource\Pages;
|
||||||
|
use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager;
|
||||||
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
|
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Service\DeletionService;
|
||||||
use App\Service\Export\ExportService;
|
use App\Service\Export\ExportService;
|
||||||
use App\Service\Import\Importers\ImporterProvider;
|
use App\Service\Import\Importers\ImporterProvider;
|
||||||
use App\Service\Import\Importers\ImportException;
|
use App\Service\Import\Importers\ImportException;
|
||||||
@@ -46,10 +53,28 @@ class OrganizationResource extends Resource
|
|||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
Forms\Components\Toggle::make('personal_team')
|
Forms\Components\Toggle::make('personal_team')
|
||||||
->label('Is personal?')
|
->label('Is personal?')
|
||||||
|
->hiddenOn(['create'])
|
||||||
->required(),
|
->required(),
|
||||||
Forms\Components\Select::make('user_id')
|
Forms\Components\Select::make('user_id')
|
||||||
|
->label('Owner')
|
||||||
->relationship(name: 'owner', titleAttribute: 'email')
|
->relationship(name: 'owner', titleAttribute: 'email')
|
||||||
->searchable(['name', 'email'])
|
->searchable(['name', 'email'])
|
||||||
|
->disabledOn(['edit'])
|
||||||
|
->required(),
|
||||||
|
Select::make('date_format')
|
||||||
|
->options(DateFormat::toSelectArray())
|
||||||
|
->required(),
|
||||||
|
Select::make('currency_format')
|
||||||
|
->options(CurrencyFormat::toSelectArray())
|
||||||
|
->required(),
|
||||||
|
Select::make('interval_format')
|
||||||
|
->options(IntervalFormat::toSelectArray())
|
||||||
|
->required(),
|
||||||
|
Select::make('number_format')
|
||||||
|
->options(NumberFormat::toSelectArray())
|
||||||
|
->required(),
|
||||||
|
Select::make('time_format')
|
||||||
|
->options(TimeFormat::toSelectArray())
|
||||||
->required(),
|
->required(),
|
||||||
Forms\Components\Select::make('currency')
|
Forms\Components\Select::make('currency')
|
||||||
->label('Currency')
|
->label('Currency')
|
||||||
@@ -62,6 +87,7 @@ class OrganizationResource extends Resource
|
|||||||
|
|
||||||
return $select;
|
return $select;
|
||||||
})
|
})
|
||||||
|
->required()
|
||||||
->searchable(),
|
->searchable(),
|
||||||
Forms\Components\TextInput::make('billable_rate')
|
Forms\Components\TextInput::make('billable_rate')
|
||||||
->label('Billable rate (in Cents)')
|
->label('Billable rate (in Cents)')
|
||||||
@@ -70,13 +96,16 @@ class OrganizationResource extends Resource
|
|||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
'gt:0',
|
'gt:0',
|
||||||
|
'max:2147483647',
|
||||||
])
|
])
|
||||||
->numeric(),
|
->numeric(),
|
||||||
Forms\Components\DateTimePicker::make('created_at')
|
Forms\Components\DateTimePicker::make('created_at')
|
||||||
->label('Created At')
|
->label('Created At')
|
||||||
|
->hiddenOn(['create'])
|
||||||
->disabled(),
|
->disabled(),
|
||||||
Forms\Components\DateTimePicker::make('updated_at')
|
Forms\Components\DateTimePicker::make('updated_at')
|
||||||
->label('Updated At')
|
->label('Updated At')
|
||||||
|
->hiddenOn(['create'])
|
||||||
->disabled(),
|
->disabled(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -96,7 +125,7 @@ class OrganizationResource extends Resource
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('currency'),
|
Tables\Columns\TextColumn::make('currency'),
|
||||||
TextColumn::make('billable_rate')
|
TextColumn::make('billable_rate')
|
||||||
->money(fn (Organization $resource) => $resource->currency ?? 'EUR', divideBy: 100),
|
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
@@ -111,6 +140,10 @@ class OrganizationResource extends Resource
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Tables\Actions\EditAction::make(),
|
Tables\Actions\EditAction::make(),
|
||||||
|
Tables\Actions\DeleteAction::make()
|
||||||
|
->using(function (Organization $record): void {
|
||||||
|
app(DeletionService::class)->deleteOrganization($record);
|
||||||
|
}),
|
||||||
Action::make('Export')
|
Action::make('Export')
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
->action(function (Organization $record) {
|
->action(function (Organization $record) {
|
||||||
@@ -122,7 +155,7 @@ class OrganizationResource extends Resource
|
|||||||
->persistent()
|
->persistent()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return response()->streamDownload(function () use ($file) {
|
return response()->streamDownload(function () use ($file): void {
|
||||||
echo Storage::disk(config('filesystems.private'))->get($file);
|
echo Storage::disk(config('filesystems.private'))->get($file);
|
||||||
}, 'export.zip');
|
}, 'export.zip');
|
||||||
} catch (\Exception $exception) {
|
} catch (\Exception $exception) {
|
||||||
@@ -137,7 +170,7 @@ class OrganizationResource extends Resource
|
|||||||
}),
|
}),
|
||||||
Action::make('Import')
|
Action::make('Import')
|
||||||
->icon('heroicon-o-inbox-arrow-down')
|
->icon('heroicon-o-inbox-arrow-down')
|
||||||
->action(function (Organization $record, array $data) {
|
->action(function (Organization $record, array $data): void {
|
||||||
try {
|
try {
|
||||||
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
|
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
|
||||||
if ($file === null) {
|
if ($file === null) {
|
||||||
@@ -198,8 +231,6 @@ class OrganizationResource extends Resource
|
|||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Tables\Actions\BulkActionGroup::make([
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +238,7 @@ class OrganizationResource extends Resource
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
UsersRelationManager::class,
|
UsersRelationManager::class,
|
||||||
|
InvitationsRelationManager::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ class DeleteOrganization extends DeleteAction
|
|||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
// TODO: check why setting the icon is necessary
|
|
||||||
$this->icon('heroicon-m-trash');
|
$this->icon('heroicon-m-trash');
|
||||||
$this->action(function (): void {
|
$this->action(function (): void {
|
||||||
$result = $this->process(function (Organization $record): bool {
|
$result = $this->process(function (Organization $record): bool {
|
||||||
|
|||||||
@@ -4,10 +4,33 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Filament\Resources\OrganizationResource\Pages;
|
namespace App\Filament\Resources\OrganizationResource\Pages;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
use App\Filament\Resources\OrganizationResource;
|
use App\Filament\Resources\OrganizationResource;
|
||||||
|
use App\Models\Organization;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
class CreateOrganization extends CreateRecord
|
class CreateOrganization extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = OrganizationResource::class;
|
protected static string $resource = OrganizationResource::class;
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$data['personal_team'] = false;
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterCreate(): void
|
||||||
|
{
|
||||||
|
/** @var Organization $organization */
|
||||||
|
$organization = $this->record;
|
||||||
|
|
||||||
|
$user = $organization->owner;
|
||||||
|
|
||||||
|
$organization->users()->attach(
|
||||||
|
$user, [
|
||||||
|
'role' => Role::Owner->value,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class ListOrganizations extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->icon('heroicon-s-plus'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\OrganizationResource\RelationManagers;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Filament\Resources\OrganizationInvitationResource;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\OrganizationInvitation;
|
||||||
|
use App\Service\InvitationService;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Actions\Action;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class InvitationsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'teamInvitations';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Invitations';
|
||||||
|
|
||||||
|
public function form(Form $form): Form
|
||||||
|
{
|
||||||
|
return $form
|
||||||
|
->schema([
|
||||||
|
TextInput::make('email')
|
||||||
|
->label('Email')
|
||||||
|
->disabledOn(['edit'])
|
||||||
|
->required(),
|
||||||
|
Select::make('role')
|
||||||
|
->options(Role::class)
|
||||||
|
->label('Role')
|
||||||
|
->rules([
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
Rule::enum(Role::class)
|
||||||
|
->except([Role::Owner, Role::Placeholder]),
|
||||||
|
])
|
||||||
|
->required(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->recordTitleAttribute('email')
|
||||||
|
->modelLabel('Invitation')
|
||||||
|
->pluralModelLabel('Invitations')
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('email'),
|
||||||
|
Tables\Columns\TextColumn::make('role'),
|
||||||
|
])
|
||||||
|
->headerActions([
|
||||||
|
Tables\Actions\CreateAction::make()
|
||||||
|
->icon('heroicon-s-plus')
|
||||||
|
->using(function (array $data, string $model): Model {
|
||||||
|
/** @var Organization $ownerRecord */
|
||||||
|
$ownerRecord = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
return app(InvitationService::class)
|
||||||
|
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']));
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Action::make('view')
|
||||||
|
->icon('heroicon-o-eye')
|
||||||
|
->color('gray')
|
||||||
|
->url(fn (OrganizationInvitation $record): string => OrganizationInvitationResource::getUrl('view', [
|
||||||
|
'record' => $record->getKey(),
|
||||||
|
])),
|
||||||
|
Tables\Actions\EditAction::make(),
|
||||||
|
Tables\Actions\DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
Tables\Actions\BulkActionGroup::make([
|
||||||
|
Tables\Actions\DetachBulkAction::make(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,17 +5,24 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\OrganizationResource\RelationManagers;
|
namespace App\Filament\Resources\OrganizationResource\RelationManagers;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Exceptions\Api\ApiException;
|
||||||
use App\Filament\Resources\UserResource;
|
use App\Filament\Resources\UserResource;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\BillableRateService;
|
||||||
|
use App\Service\MemberService;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Form;
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Actions\Action;
|
use Filament\Tables\Actions\Action;
|
||||||
use Filament\Tables\Actions\AttachAction;
|
use Filament\Tables\Actions\AttachAction;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class UsersRelationManager extends RelationManager
|
class UsersRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
@@ -36,20 +43,40 @@ class UsersRelationManager extends RelationManager
|
|||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
|
/** @var Organization $organization */
|
||||||
|
$organization = $this->getOwnerRecord();
|
||||||
|
|
||||||
return $table
|
return $table
|
||||||
->recordTitleAttribute('name')
|
->recordTitleAttribute('name')
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('name'),
|
Tables\Columns\TextColumn::make('name'),
|
||||||
Tables\Columns\TextColumn::make('role'),
|
Tables\Columns\TextColumn::make('role'),
|
||||||
TextColumn::make('billable_rate')
|
TextColumn::make('billable_rate')
|
||||||
->money($this->getOwnerRecord()->currency ?? 'EUR', divideBy: 100),
|
->money($organization->currency, divideBy: 100),
|
||||||
])
|
])
|
||||||
->headerActions([
|
->headerActions([
|
||||||
Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [
|
Tables\Actions\AttachAction::make()
|
||||||
$action->getRecordSelect(),
|
->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
|
||||||
Select::make('role')
|
->form(fn (AttachAction $action): array => [
|
||||||
->options(Role::class),
|
$action->getRecordSelect(),
|
||||||
]),
|
Select::make('role')
|
||||||
|
->required()
|
||||||
|
->options(Role::class)
|
||||||
|
->rule([
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
Rule::enum(Role::class)
|
||||||
|
->except([Role::Owner, Role::Placeholder]),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->label('Add user')
|
||||||
|
->modalHeading('Add user')
|
||||||
|
->icon('heroicon-s-plus')
|
||||||
|
->using(function (User $record, array $data): void {
|
||||||
|
/** @var Organization $organization */
|
||||||
|
$organization = $this->getOwnerRecord();
|
||||||
|
app(MemberService::class)->addMember($record, $organization, Role::from($data['role']), true);
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view')
|
Action::make('view')
|
||||||
@@ -58,13 +85,55 @@ class UsersRelationManager extends RelationManager
|
|||||||
->url(fn (User $record): string => UserResource::getUrl('view', [
|
->url(fn (User $record): string => UserResource::getUrl('view', [
|
||||||
'record' => $record->getKey(),
|
'record' => $record->getKey(),
|
||||||
])),
|
])),
|
||||||
Tables\Actions\EditAction::make(),
|
Tables\Actions\EditAction::make()
|
||||||
Tables\Actions\DetachAction::make(),
|
->using(function (User $record, array $data): User {
|
||||||
|
/** @var Organization $organization */
|
||||||
|
$organization = $this->getOwnerRecord();
|
||||||
|
/** @var Member $member */
|
||||||
|
$member = $record->getRelation('membership');
|
||||||
|
|
||||||
|
if ($data['billable_rate'] !== $member->billable_rate) {
|
||||||
|
$member->billable_rate = $data['billable_rate'];
|
||||||
|
app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data['role'] !== $member->role) {
|
||||||
|
try {
|
||||||
|
app(MemberService::class)->changeRole($member, $organization, Role::from($data['role']), true);
|
||||||
|
} catch (ApiException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->danger()
|
||||||
|
->title('Update failed')
|
||||||
|
->body($exception->getTranslatedMessage())
|
||||||
|
->persistent()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$member->save();
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}),
|
||||||
|
Tables\Actions\DetachAction::make()
|
||||||
|
->using(function (User $record): void {
|
||||||
|
/** @var Organization $organization */
|
||||||
|
$organization = $this->getOwnerRecord();
|
||||||
|
$member = Member::query()
|
||||||
|
->whereBelongsTo($record, 'user')
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->firstOrFail();
|
||||||
|
try {
|
||||||
|
app(MemberService::class)->removeMember($member, $organization);
|
||||||
|
} catch (ApiException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->danger()
|
||||||
|
->title('Delete failed')
|
||||||
|
->body($exception->getTranslatedMessage())
|
||||||
|
->persistent()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Tables\Actions\BulkActionGroup::make([
|
|
||||||
Tables\Actions\DetachBulkAction::make(),
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class ProjectMemberResource extends Resource
|
|||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
'gt:0',
|
'gt:0',
|
||||||
|
'max:2147483647',
|
||||||
])
|
])
|
||||||
->numeric(),
|
->numeric(),
|
||||||
Forms\Components\Select::make('user_id')
|
Forms\Components\Select::make('user_id')
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class EditProjectMember extends EditRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make()
|
||||||
|
->icon('heroicon-m-trash'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class ListProjectMembers extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->icon('heroicon-s-plus'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class ProjectResource extends Resource
|
|||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
'gt:0',
|
'gt:0',
|
||||||
|
'max:2147483647',
|
||||||
])
|
])
|
||||||
->numeric(),
|
->numeric(),
|
||||||
Forms\Components\Select::make('organization_id')
|
Forms\Components\Select::make('organization_id')
|
||||||
@@ -71,8 +72,13 @@ class ProjectResource extends Resource
|
|||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('organization')
|
SelectFilter::make('organization')
|
||||||
|
->label('Organization')
|
||||||
->relationship('organization', 'name')
|
->relationship('organization', 'name')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
|
SelectFilter::make('organization_id')
|
||||||
|
->label('Organization ID')
|
||||||
|
->relationship('organization', 'id')
|
||||||
|
->searchable(),
|
||||||
])
|
])
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->actions([
|
->actions([
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class EditProject extends EditRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make()
|
||||||
|
->icon('heroicon-m-trash'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class ListProjects extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->icon('heroicon-s-plus'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
141
app/Filament/Resources/ReportResource.php
Normal file
141
app/Filament/Resources/ReportResource.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ReportResource\Pages;
|
||||||
|
use App\Models\Report;
|
||||||
|
use App\Service\Dto\ReportPropertiesDto;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Actions\Action;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Columns\ToggleColumn;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
|
||||||
|
|
||||||
|
class ReportResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Report::class;
|
||||||
|
|
||||||
|
protected static ?string $navigationIcon = 'heroicon-o-document-chart-bar';
|
||||||
|
|
||||||
|
protected static ?string $navigationGroup = 'Timetracking';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 7;
|
||||||
|
|
||||||
|
public static function form(Form $form): Form
|
||||||
|
{
|
||||||
|
return $form
|
||||||
|
->columns(1)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('name')
|
||||||
|
->label('Name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\TextInput::make('description')
|
||||||
|
->label('Description')
|
||||||
|
->nullable()
|
||||||
|
->maxLength(255),
|
||||||
|
Toggle::make('is_public')
|
||||||
|
->label('Is public?')
|
||||||
|
->required(),
|
||||||
|
DateTimePicker::make('public_until')
|
||||||
|
->label('Public until')
|
||||||
|
->nullable(),
|
||||||
|
Forms\Components\Select::make('organization_id')
|
||||||
|
->label('Organization')
|
||||||
|
->relationship(name: 'organization', titleAttribute: 'name')
|
||||||
|
->searchable(['name'])
|
||||||
|
->disabled()
|
||||||
|
->required(),
|
||||||
|
Forms\Components\TextInput::make('share_secret')
|
||||||
|
->label('Share Secret')
|
||||||
|
->nullable(),
|
||||||
|
PrettyJsonField::make('properties')
|
||||||
|
->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string {
|
||||||
|
return $record->getRawOriginal('properties');
|
||||||
|
})
|
||||||
|
->disabled(),
|
||||||
|
Forms\Components\DateTimePicker::make('created_at')
|
||||||
|
->label('Created At')
|
||||||
|
->hiddenOn(['create'])
|
||||||
|
->disabled(),
|
||||||
|
Forms\Components\DateTimePicker::make('updated_at')
|
||||||
|
->label('Updated At')
|
||||||
|
->hiddenOn(['create'])
|
||||||
|
->disabled(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('description')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
ToggleColumn::make('is_public')
|
||||||
|
->label('Is public?')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('organization.name')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('updated_at')
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('organization')
|
||||||
|
->label('Organization')
|
||||||
|
->relationship('organization', 'name')
|
||||||
|
->searchable(),
|
||||||
|
SelectFilter::make('organization_id')
|
||||||
|
->label('Organization ID')
|
||||||
|
->relationship('organization', 'id')
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Action::make('public-view')
|
||||||
|
->label('Public')
|
||||||
|
->icon('heroicon-o-eye')
|
||||||
|
->color('gray')
|
||||||
|
->hidden(fn (Report $record): bool => $record->getShareableLink() === null)
|
||||||
|
->url(fn (Report $record): string => $record->getShareableLink(), true),
|
||||||
|
Tables\Actions\ViewAction::make(),
|
||||||
|
Tables\Actions\EditAction::make(),
|
||||||
|
Tables\Actions\DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListReports::route('/'),
|
||||||
|
'edit' => Pages\EditReport::route('/{record}/edit'),
|
||||||
|
'view' => Pages\ViewReport::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Filament/Resources/ReportResource/Pages/EditReport.php
Normal file
22
app/Filament/Resources/ReportResource/Pages/EditReport.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ReportResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ReportResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditReport extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = ReportResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\DeleteAction::make()
|
||||||
|
->icon('heroicon-m-trash'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/ReportResource/Pages/ListReports.php
Normal file
19
app/Filament/Resources/ReportResource/Pages/ListReports.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ReportResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ReportResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListReports extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = ReportResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Filament/Resources/ReportResource/Pages/ViewReport.php
Normal file
22
app/Filament/Resources/ReportResource/Pages/ViewReport.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ReportResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ReportResource;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewReport extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = ReportResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
EditAction::make('edit')
|
||||||
|
->icon('heroicon-s-pencil'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,8 +60,13 @@ class TagResource extends Resource
|
|||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('organization')
|
SelectFilter::make('organization')
|
||||||
|
->label('Organization')
|
||||||
->relationship('organization', 'name')
|
->relationship('organization', 'name')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
|
SelectFilter::make('organization_id')
|
||||||
|
->label('Organization ID')
|
||||||
|
->relationship('organization', 'id')
|
||||||
|
->searchable(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Tables\Actions\EditAction::make(),
|
Tables\Actions\EditAction::make(),
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class EditTag extends EditRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make()
|
||||||
|
->icon('heroicon-m-trash'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class ListTags extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->icon('heroicon-s-plus'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,8 +61,13 @@ class TaskResource extends Resource
|
|||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('organization')
|
SelectFilter::make('organization')
|
||||||
|
->label('Organization')
|
||||||
->relationship('organization', 'name')
|
->relationship('organization', 'name')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
|
SelectFilter::make('organization_id')
|
||||||
|
->label('Organization ID')
|
||||||
|
->relationship('organization', 'id')
|
||||||
|
->searchable(),
|
||||||
])
|
])
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->actions([
|
->actions([
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class EditTask extends EditRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make()
|
||||||
|
->icon('heroicon-m-trash'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class ListTasks extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->icon('heroicon-s-plus'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,8 +92,13 @@ class TimeEntryResource extends Resource
|
|||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('organization')
|
SelectFilter::make('organization')
|
||||||
|
->label('Organization')
|
||||||
->relationship('organization', 'name')
|
->relationship('organization', 'name')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
|
SelectFilter::make('organization_id')
|
||||||
|
->label('Organization ID')
|
||||||
|
->relationship('organization', 'id')
|
||||||
|
->searchable(),
|
||||||
])
|
])
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->actions([
|
->actions([
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class EditTimeEntry extends EditRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make()
|
||||||
|
->icon('heroicon-m-trash'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class ListTimeEntries extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->icon('heroicon-s-plus'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
148
app/Filament/Resources/TokenResource.php
Normal file
148
app/Filament/Resources/TokenResource.php
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\TokenResource\Pages;
|
||||||
|
use App\Models\Passport\Client;
|
||||||
|
use App\Models\Passport\Token;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Filters\TernaryFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class TokenResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Token::class;
|
||||||
|
|
||||||
|
protected static ?string $navigationIcon = 'heroicon-o-key';
|
||||||
|
|
||||||
|
protected static ?string $navigationGroup = 'Auth';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 6;
|
||||||
|
|
||||||
|
public static function form(Form $form): Form
|
||||||
|
{
|
||||||
|
return $form
|
||||||
|
->columns(1)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('id')
|
||||||
|
->label('ID')
|
||||||
|
->disabled()
|
||||||
|
->visibleOn(['update', 'show'])
|
||||||
|
->readOnly()
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\TextInput::make('name')
|
||||||
|
->label('Name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\Select::make('user_id')
|
||||||
|
->label('User')
|
||||||
|
->relationship(name: 'user', titleAttribute: 'name')
|
||||||
|
->searchable(['name'])
|
||||||
|
->disabled()
|
||||||
|
->required(),
|
||||||
|
Forms\Components\Select::make('client_id')
|
||||||
|
->label('Client')
|
||||||
|
->relationship(name: 'client', titleAttribute: 'name')
|
||||||
|
->searchable(['name'])
|
||||||
|
->required(),
|
||||||
|
Forms\Components\Toggle::make('revoked')
|
||||||
|
->label('Revoked')
|
||||||
|
->required(),
|
||||||
|
Forms\Components\DateTimePicker::make('expires_at')
|
||||||
|
->label('Expires At')
|
||||||
|
->disabled(),
|
||||||
|
Forms\Components\DateTimePicker::make('created_at')
|
||||||
|
->label('Created At')
|
||||||
|
->disabled(),
|
||||||
|
Forms\Components\DateTimePicker::make('updated_at')
|
||||||
|
->label('Updated At')
|
||||||
|
->disabled(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('user.name')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('client.name')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\IconColumn::make('client.personal_access_client')
|
||||||
|
->boolean()
|
||||||
|
->label('API token?')
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\IconColumn::make('revoked')
|
||||||
|
->boolean()
|
||||||
|
->label('Revoked?')
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('expires_at')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('updated_at')
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->filters([
|
||||||
|
TernaryFilter::make('is_personal_access_client')
|
||||||
|
->queries(
|
||||||
|
true: function (Builder $query) {
|
||||||
|
/** @var Builder<Token> $query */
|
||||||
|
return $query->whereHas('client', function (Builder $query) {
|
||||||
|
/** @var Builder<Client> $query */
|
||||||
|
return $query->where('personal_access_client', true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
false: function (Builder $query) {
|
||||||
|
/** @var Builder<Token> $query */
|
||||||
|
return $query->whereHas('client', function (Builder $query) {
|
||||||
|
/** @var Builder<Client> $query */
|
||||||
|
return $query->where('personal_access_client', false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
blank: function (Builder $query) {
|
||||||
|
/** @var Builder<Token> $query */
|
||||||
|
return $query;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
->label('API token?'),
|
||||||
|
TernaryFilter::make('revoked')
|
||||||
|
->label('Revoked?'),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Tables\Actions\ViewAction::make(),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListTokens::route('/'),
|
||||||
|
'view' => Pages\ViewToken::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/TokenResource/Pages/ListTokens.php
Normal file
19
app/Filament/Resources/TokenResource/Pages/ListTokens.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\TokenResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\TokenResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListTokens extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = TokenResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/TokenResource/Pages/ViewToken.php
Normal file
19
app/Filament/Resources/TokenResource/Pages/ViewToken.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\TokenResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\TokenResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewToken extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = TokenResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,21 +5,28 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
|
use App\Exceptions\Api\ApiException;
|
||||||
use App\Filament\Resources\UserResource\Pages;
|
use App\Filament\Resources\UserResource\Pages;
|
||||||
use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager;
|
use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager;
|
||||||
use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager;
|
use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\DeletionService;
|
||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
|
use Brick\Money\ISOCurrencyProvider;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Form;
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Filters\TernaryFilter;
|
use Filament\Tables\Filters\TernaryFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||||
use STS\FilamentImpersonate\Tables\Actions\Impersonate;
|
use STS\FilamentImpersonate\Tables\Actions\Impersonate;
|
||||||
|
|
||||||
class UserResource extends Resource
|
class UserResource extends Resource
|
||||||
@@ -34,6 +41,9 @@ class UserResource extends Resource
|
|||||||
|
|
||||||
public static function form(Form $form): Form
|
public static function form(Form $form): Form
|
||||||
{
|
{
|
||||||
|
/** @var User|null $record */
|
||||||
|
$record = $form->getRecord();
|
||||||
|
|
||||||
return $form
|
return $form
|
||||||
->columns(1)
|
->columns(1)
|
||||||
->schema([
|
->schema([
|
||||||
@@ -50,12 +60,25 @@ class UserResource extends Resource
|
|||||||
Forms\Components\TextInput::make('email')
|
Forms\Components\TextInput::make('email')
|
||||||
->label('Email')
|
->label('Email')
|
||||||
->required()
|
->required()
|
||||||
|
->rules($record?->is_placeholder ? [] : [
|
||||||
|
UniqueEloquent::make(User::class, 'email')
|
||||||
|
->ignore($record?->getKey()),
|
||||||
|
])
|
||||||
|
->rule([
|
||||||
|
'email',
|
||||||
|
])
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
Forms\Components\Toggle::make('is_placeholder')
|
Forms\Components\Toggle::make('is_placeholder')
|
||||||
->label('Is Placeholder'),
|
->label('Is Placeholder?')
|
||||||
|
->hiddenOn(['create'])
|
||||||
|
->disabledOn(['edit']),
|
||||||
Forms\Components\DateTimePicker::make('email_verified_at')
|
Forms\Components\DateTimePicker::make('email_verified_at')
|
||||||
->label('Email Verified At')
|
->label('Email Verified At')
|
||||||
|
->hiddenOn(['create'])
|
||||||
->nullable(),
|
->nullable(),
|
||||||
|
Forms\Components\Toggle::make('is_email_verified')
|
||||||
|
->label('Email Verified?')
|
||||||
|
->visibleOn(['create']),
|
||||||
Forms\Components\Select::make('timezone')
|
Forms\Components\Select::make('timezone')
|
||||||
->label('Timezone')
|
->label('Timezone')
|
||||||
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
|
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
|
||||||
@@ -67,15 +90,39 @@ class UserResource extends Resource
|
|||||||
->required(),
|
->required(),
|
||||||
TextInput::make('password')
|
TextInput::make('password')
|
||||||
->password()
|
->password()
|
||||||
|
->label('Password')
|
||||||
->dehydrateStateUsing(fn ($state) => Hash::make($state))
|
->dehydrateStateUsing(fn ($state) => Hash::make($state))
|
||||||
->dehydrated(fn ($state) => filled($state))
|
->dehydrated(fn ($state) => filled($state))
|
||||||
|
->hiddenOn(['create'])
|
||||||
->required(fn (string $context): bool => $context === 'create')
|
->required(fn (string $context): bool => $context === 'create')
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
|
TextInput::make('password_create')
|
||||||
|
->password()
|
||||||
|
->label('Password')
|
||||||
|
->visibleOn(['create'])
|
||||||
|
->required(fn (string $context): bool => $context === 'create')
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\Select::make('currency')
|
||||||
|
->label('Currency (Personal Organization)')
|
||||||
|
->options(function (): array {
|
||||||
|
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
|
||||||
|
$select = [];
|
||||||
|
foreach ($currencies as $currency) {
|
||||||
|
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $select;
|
||||||
|
})
|
||||||
|
->required()
|
||||||
|
->visibleOn(['create'])
|
||||||
|
->searchable(),
|
||||||
Forms\Components\DateTimePicker::make('created_at')
|
Forms\Components\DateTimePicker::make('created_at')
|
||||||
->label('Created At')
|
->label('Created At')
|
||||||
|
->hiddenOn(['create'])
|
||||||
->disabled(),
|
->disabled(),
|
||||||
Forms\Components\DateTimePicker::make('updated_at')
|
Forms\Components\DateTimePicker::make('updated_at')
|
||||||
->label('Updated At')
|
->label('Updated At')
|
||||||
|
->hiddenOn(['create'])
|
||||||
->disabled(),
|
->disabled(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -145,11 +192,30 @@ class UserResource extends Resource
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Tables\Actions\EditAction::make(),
|
Tables\Actions\EditAction::make(),
|
||||||
|
Tables\Actions\DeleteAction::make()
|
||||||
|
->hidden(fn (User $record) => $record->is(Auth::user()))
|
||||||
|
->using(function (User $record): void {
|
||||||
|
try {
|
||||||
|
app(DeletionService::class)->deleteUser($record);
|
||||||
|
} catch (ApiException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->danger()
|
||||||
|
->title('Delete failed')
|
||||||
|
->body($exception->getTranslatedMessage())
|
||||||
|
->persistent()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Tables\Actions\BulkActionGroup::make([
|
Tables\Actions\BulkAction::make('Resend verification email')
|
||||||
Tables\Actions\DeleteBulkAction::make(),
|
->icon('heroicon-o-paper-airplane')
|
||||||
]),
|
->action(function (Collection $records): void {
|
||||||
|
foreach ($records as $user) {
|
||||||
|
/** @var User $user */
|
||||||
|
$user->sendEmailVerificationNotification();
|
||||||
|
}
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,24 +4,29 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Filament\Resources\UserResource\Pages;
|
namespace App\Filament\Resources\UserResource\Pages;
|
||||||
|
|
||||||
|
use App\Enums\Weekday;
|
||||||
use App\Filament\Resources\UserResource;
|
use App\Filament\Resources\UserResource;
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\UserService;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
class CreateUser extends CreateRecord
|
class CreateUser extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = UserResource::class;
|
protected static string $resource = UserResource::class;
|
||||||
|
|
||||||
protected function afterCreate(): void
|
protected function handleRecordCreation(array $data): User
|
||||||
{
|
{
|
||||||
/** @var User $user */
|
$userService = app(UserService::class);
|
||||||
$user = $this->record;
|
$user = $userService->createUser(
|
||||||
|
$data['name'],
|
||||||
|
$data['email'],
|
||||||
|
$data['password_create'],
|
||||||
|
$data['timezone'],
|
||||||
|
Weekday::from($data['week_start']),
|
||||||
|
$data['currency'],
|
||||||
|
verifyEmail: (bool) $data['is_email_verified']
|
||||||
|
);
|
||||||
|
|
||||||
$user->ownedTeams()->save(Organization::forceCreate([
|
return $user;
|
||||||
'user_id' => $user->id,
|
|
||||||
'name' => explode(' ', $user->name, 2)[0]."'s Organization",
|
|
||||||
'personal_team' => true,
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class ListUsers extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->icon('heroicon-s-plus'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Filament\Resources\UserResource\Pages;
|
|||||||
use App\Filament\Resources\UserResource;
|
use App\Filament\Resources\UserResource;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
|
||||||
|
|
||||||
class ViewUser extends ViewRecord
|
class ViewUser extends ViewRecord
|
||||||
{
|
{
|
||||||
@@ -15,6 +16,7 @@ class ViewUser extends ViewRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
Impersonate::make()->record($this->getRecord()),
|
||||||
EditAction::make('edit')
|
EditAction::make('edit')
|
||||||
->icon('heroicon-s-pencil'),
|
->icon('heroicon-s-pencil'),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\UserResource\RelationManagers;
|
namespace App\Filament\Resources\UserResource\RelationManagers;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Exceptions\Api\ApiException;
|
||||||
use App\Filament\Resources\OrganizationResource;
|
use App\Filament\Resources\OrganizationResource;
|
||||||
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\MemberService;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Form;
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Actions\Action;
|
use Filament\Tables\Actions\Action;
|
||||||
use Filament\Tables\Actions\AttachAction;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
@@ -27,10 +30,6 @@ class OrganizationsRelationManager extends RelationManager
|
|||||||
->schema([
|
->schema([
|
||||||
Select::make('role')
|
Select::make('role')
|
||||||
->options(Role::class),
|
->options(Role::class),
|
||||||
TextInput::make('billable_rate')
|
|
||||||
->label('Billable rate (in Cents)')
|
|
||||||
->nullable()
|
|
||||||
->numeric(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,15 +40,11 @@ class OrganizationsRelationManager extends RelationManager
|
|||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('name'),
|
TextColumn::make('name'),
|
||||||
TextColumn::make('role'),
|
TextColumn::make('role'),
|
||||||
TextColumn::make('billable_rate')
|
TextColumn::make('membership.billable_rate')
|
||||||
->money(fn (Organization $resource) => $resource->currency ?? 'EUR', divideBy: 100),
|
->label('Billable rate')
|
||||||
|
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
|
||||||
])
|
])
|
||||||
->headerActions([
|
->headerActions([
|
||||||
Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [
|
|
||||||
$action->getRecordSelect(),
|
|
||||||
Select::make('role')
|
|
||||||
->options(Role::class),
|
|
||||||
]),
|
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view')
|
Action::make('view')
|
||||||
@@ -58,13 +53,48 @@ class OrganizationsRelationManager extends RelationManager
|
|||||||
->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [
|
->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [
|
||||||
'record' => $record->getKey(),
|
'record' => $record->getKey(),
|
||||||
])),
|
])),
|
||||||
Tables\Actions\EditAction::make(),
|
Tables\Actions\EditAction::make()
|
||||||
Tables\Actions\DetachAction::make(),
|
->using(function (Organization $record, array $data): Organization {
|
||||||
|
/** @var Member $member */
|
||||||
|
$member = $record->getRelation('membership');
|
||||||
|
|
||||||
|
if ($data['role'] !== $member->role) {
|
||||||
|
try {
|
||||||
|
app(MemberService::class)->changeRole($member, $record, Role::from($data['role']), true);
|
||||||
|
} catch (ApiException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->danger()
|
||||||
|
->title('Update failed')
|
||||||
|
->body($exception->getTranslatedMessage())
|
||||||
|
->persistent()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$member->save();
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}),
|
||||||
|
Tables\Actions\DetachAction::make()
|
||||||
|
->using(function (Organization $record): void {
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getOwnerRecord();
|
||||||
|
$member = Member::query()
|
||||||
|
->whereBelongsTo($user, 'user')
|
||||||
|
->whereBelongsTo($record, 'organization')
|
||||||
|
->firstOrFail();
|
||||||
|
try {
|
||||||
|
app(MemberService::class)->removeMember($member, $record);
|
||||||
|
} catch (ApiException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->danger()
|
||||||
|
->title('Delete failed')
|
||||||
|
->body($exception->getTranslatedMessage())
|
||||||
|
->persistent()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Tables\Actions\BulkActionGroup::make([
|
|
||||||
Tables\Actions\DetachBulkAction::make(),
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class ActiveUserOverview extends BaseWidget
|
|||||||
{
|
{
|
||||||
protected static ?int $sort = 1;
|
protected static ?int $sort = 1;
|
||||||
|
|
||||||
protected static ?string $heading = 'A Registrations';
|
protected ?string $heading = 'A Registrations';
|
||||||
|
|
||||||
protected function getCards(): array
|
protected function getCards(): array
|
||||||
{
|
{
|
||||||
|
|||||||
38
app/Filament/Widgets/ServerOverview.php
Normal file
38
app/Filament/Widgets/ServerOverview.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets;
|
||||||
|
|
||||||
|
use Filament\Widgets\Widget;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class ServerOverview extends Widget
|
||||||
|
{
|
||||||
|
protected static string $view = 'filament.widgets.server-overview';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
/** @var string|null $currentVersion */
|
||||||
|
$currentVersion = config('app.version');
|
||||||
|
/** @var string|null $build */
|
||||||
|
$build = config('app.build');
|
||||||
|
$latestVersion = Cache::get('latest_version', null);
|
||||||
|
|
||||||
|
$needsUpdate = false;
|
||||||
|
if ($latestVersion !== null && $currentVersion !== null && version_compare($latestVersion, $currentVersion) > 0) {
|
||||||
|
$needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'version' => $currentVersion,
|
||||||
|
'build' => $build,
|
||||||
|
'environment' => config('app.env'),
|
||||||
|
'currentVersion' => $latestVersion,
|
||||||
|
'needsUpdate' => $needsUpdate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
114
app/Http/Controllers/Api/V1/ApiTokenController.php
Normal file
114
app/Http/Controllers/Api/V1/ApiTokenController.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||||
|
use App\Http\Requests\V1\ApiToken\ApiTokenStoreRequest;
|
||||||
|
use App\Http\Resources\V1\ApiToken\ApiTokenCollection;
|
||||||
|
use App\Http\Resources\V1\ApiToken\ApiTokenWithAccessTokenResource;
|
||||||
|
use App\Models\Passport\Token;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class ApiTokenController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* List all api token of the currently authenticated user
|
||||||
|
*
|
||||||
|
* This endpoint is independent of organization.
|
||||||
|
*
|
||||||
|
* @operationId getApiTokens
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
public function index(): ApiTokenCollection
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$tokens = $user->tokens()
|
||||||
|
->where('client_id', '=', config('passport.personal_access_client.id'))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return new ApiTokenCollection($tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new api token for the currently authenticated user
|
||||||
|
*
|
||||||
|
* The response will contain the access token that can be used to send authenticated API requests.
|
||||||
|
* Please note that the access token is only shown in this response and cannot be retrieved later.
|
||||||
|
*
|
||||||
|
* @operationId createApiToken
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException
|
||||||
|
*/
|
||||||
|
public function store(ApiTokenStoreRequest $request): ApiTokenWithAccessTokenResource
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
|
||||||
|
throw new PersonalAccessClientIsNotConfiguredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $user->createToken($request->getName(), ['*']);
|
||||||
|
/** @var Token $tokenModel */
|
||||||
|
$tokenModel = $token->token;
|
||||||
|
|
||||||
|
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke an api token
|
||||||
|
*
|
||||||
|
* @operationId revokeApiToken
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException
|
||||||
|
* @throws PersonalAccessClientIsNotConfiguredException
|
||||||
|
*/
|
||||||
|
public function revoke(Token $apiToken): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
|
||||||
|
throw new PersonalAccessClientIsNotConfiguredException;
|
||||||
|
}
|
||||||
|
if ($apiToken->user_id !== $user->getKey()) {
|
||||||
|
throw new AuthorizationException('API token does not belong to user');
|
||||||
|
}
|
||||||
|
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
|
||||||
|
throw new AuthorizationException('API token is not a personal access token');
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiToken->revoke();
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an api token
|
||||||
|
*
|
||||||
|
* @operationId deleteApiToken
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException
|
||||||
|
*/
|
||||||
|
public function destroy(Token $apiToken): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
|
||||||
|
throw new PersonalAccessClientIsNotConfiguredException;
|
||||||
|
}
|
||||||
|
if ($apiToken->user_id !== $user->getKey()) {
|
||||||
|
throw new AuthorizationException('API token does not belong to user');
|
||||||
|
}
|
||||||
|
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
|
||||||
|
throw new AuthorizationException('API token is not a personal access token');
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiToken->delete();
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
|
}
|
||||||
172
app/Http/Controllers/Api/V1/ChartController.php
Normal file
172
app/Http/Controllers/Api/V1/ChartController.php
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Service\DashboardService;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class ChartController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId weeklyProjectOverview
|
||||||
|
*
|
||||||
|
* @response array<int, array{value: int, name: string, color: string}>
|
||||||
|
*/
|
||||||
|
public function weeklyProjectOverview(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
|
||||||
|
|
||||||
|
return response()->json($weeklyProjectOverview);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId latestTasks
|
||||||
|
*
|
||||||
|
* @response array<int, array{task_id: string, name: string, description: string|null, status: bool, time_entry_id: string|null}>
|
||||||
|
*/
|
||||||
|
public function latestTasks(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$latestTasks = $dashboardService->latestTasks($user, $organization);
|
||||||
|
|
||||||
|
return response()->json($latestTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId lastSevenDays
|
||||||
|
*
|
||||||
|
* @response array<int, array{ date: string, duration: int, history: array<int> }>
|
||||||
|
*/
|
||||||
|
public function lastSevenDays(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$lastSevenDays = $dashboardService->lastSevenDays($user, $organization);
|
||||||
|
|
||||||
|
return response()->json($lastSevenDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId latestTeamActivity
|
||||||
|
*
|
||||||
|
* @response array<int, array{member_id: string, name: string, description: string|null, time_entry_id: string, task_id: string|null, status: bool }>
|
||||||
|
*/
|
||||||
|
public function latestTeamActivity(Organization $organization, DashboardService $dashboardService, PermissionStore $permissionStore): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:all');
|
||||||
|
|
||||||
|
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
||||||
|
|
||||||
|
return response()->json($latestTeamActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId dailyTrackedHours
|
||||||
|
*
|
||||||
|
* @response array<int, array{date: string, duration: int}>
|
||||||
|
*/
|
||||||
|
public function dailyTrackedHours(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
||||||
|
|
||||||
|
return response()->json($dailyTrackedHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId totalWeeklyTime
|
||||||
|
*
|
||||||
|
* @response int
|
||||||
|
*/
|
||||||
|
public function totalWeeklyTime(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
|
||||||
|
|
||||||
|
return response()->json($totalWeeklyTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId totalWeeklyBillableTime
|
||||||
|
*
|
||||||
|
* @response int
|
||||||
|
*/
|
||||||
|
public function totalWeeklyBillableTime(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
|
||||||
|
|
||||||
|
return response()->json($totalWeeklyBillableTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId totalWeeklyBillableAmount
|
||||||
|
*
|
||||||
|
* @response array{value: int, currency: string}
|
||||||
|
*/
|
||||||
|
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
if (! $showBillableRate) {
|
||||||
|
throw new AuthorizationException('You do not have permission to view billable rates.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
|
||||||
|
|
||||||
|
return response()->json($totalWeeklyBillableAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @operationId weeklyHistory
|
||||||
|
*
|
||||||
|
* @response array<int, array{date: string, duration: int}>
|
||||||
|
*/
|
||||||
|
public function weeklyHistory(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
|
||||||
|
|
||||||
|
return response()->json($weeklyHistory);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user