Compare commits

...

79 Commits

Author SHA1 Message Date
Constantin Graf
e2fa45b107 Prevent non-primary mouse buttons from resizing events 2026-09-24 11:33:32 +02:00
Constantin Graf
fa0bbc8cfd Add permission config to npm-format-check GitHub action 2026-09-24 11:31:38 +02:00
Constantin Graf
fff3502e53 Updated UUID generation 2026-09-24 11:31:38 +02:00
Constantin Graf
e801c4311c Add new extension auditing 2026-09-24 11:31:38 +02:00
Constantin Graf
2dadf1ef02 Updated Laravel to v13 2026-09-24 10:45:18 +02:00
Constantin Graf
12cc3f27f8 Add check for premium to telemetry and update lookup 2026-09-24 10:45:18 +02:00
Constantin Graf
06a2048771 Add basic e2e tests for admin panel 2026-09-24 10:45:18 +02:00
Constantin Graf
a12ee1b029 Address review feedback on composer updates
- Default INERTIA_SSR_ENABLED to false to match .env.example, .env.ci and
  .env.production, so existing setups keep the previous behaviour.
- Stop tracking the published Filament assets under /public/fonts/filament and
  add them to .gitignore, as recommended by the Filament docs. They are
  regenerated by filament:assets via the filament:upgrade post-autoload-dump
  hook. /public/css and /public/js are already ignored.
- Drop the now dead theme.css filter from the Vite asset list in app.blade.php,
  since the custom Filament theme was removed along with viteTheme().
2026-09-24 10:45:18 +02:00
Constantin Graf
54dc0dbabc Add psysh to gitignore 2026-09-24 10:45:18 +02:00
Constantin Graf
08f9edbc96 Add additional tests for filament 2026-09-24 10:45:18 +02:00
Constantin Graf
0551c633a3 Add github action to prevent non-tag refs in manifest.json 2026-09-24 10:45:18 +02:00
Constantin Graf
37579b1e5c Updated compose dependencies 2026-09-24 10:45:18 +02:00
Constantin Graf
b33cf00788 Remove Group from ClockifyTimeEntriesImporter 2026-09-22 18:08:49 +02:00
Gregor Vostrak
c23f09fb2d add date range navigation, unify picker components 2026-09-22 15:53:50 +02:00
Gregor Vostrak
a0262addb7 fix e2e submit focus test behaviour 2026-09-22 13:43:42 +02:00
Gregor Vostrak
b4911ebddd move project reset button into mass update modal and fix
TimeTrackerProjectTaskDropdown trigger focus
2026-09-22 13:43:42 +02:00
Gregor Vostrak
3ade20887b fix cmd+enter submit shortcut on modals #1238 2026-09-22 13:43:42 +02:00
dependabot[bot]
4943a38ffe Bump docker/login-action from 4.5.2 to 4.6.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.2 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4.5.2...v4.6.0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 12:02:12 +02:00
dependabot[bot]
12e355ff26 Bump codecov/codecov-action from 7.0.0 to 7.1.1
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 7.0.0 to 7.1.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v7.0.0...v7.1.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 7.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 12:01:59 +02:00
Constantin Graf
01b60c0f6a Add validation for task name length 2026-09-22 12:01:48 +02:00
Constantin Graf
927da7dce9 Fixed type check 2026-09-22 12:01:48 +02:00
Constantin Graf
c89fa87b0f Fix clockfiy importer, allow project imports without Billability 2026-09-22 12:01:48 +02:00
Constantin Graf
02cf600f43 Add placeholder paddle api key 2026-09-21 22:07:14 +02:00
Constantin Graf
98a460725f Add GitHub action for phpunit tests with extensions 2026-09-21 22:07:14 +02:00
Constantin Graf
95645ddd91 Fixed composer auth username 2026-09-21 18:28:02 +02:00
Constantin Graf
1de985b577 Update docker image 2026-09-21 16:08:13 +02:00
Gregor Vostrak
d54296e66a fix live timer restarting while duration input is paused 2026-09-16 15:34:16 +02:00
Gregor Vostrak
95ddbf9ead fix placeholder users being resolved by authentication flows 2026-09-16 15:25:08 +02:00
Constantin Graf
70646a0dd4 Add additional validation for import, Enhanced ZIP extraction in importer 2026-09-16 15:02:54 +02:00
Gregor Vostrak
5b12c09747 bump invoicing extension to v0.0.6 for pagination ui package change 2026-09-07 17:13:20 +02:00
Gregor Vostrak
24023353f2 bump ui package version 2026-09-07 16:40:55 +02:00
Gregor Vostrak
720d20c10e move pagination component to ui package 2026-09-07 16:40:55 +02:00
Gregor Vostrak
82ea9af8b5 fix radix dialog focus restore behaviour 2026-09-04 14:55:09 +02:00
Gregor Vostrak
169d522da0 fix activity graph border color 2026-09-03 17:15:19 +02:00
Gregor Vostrak
45c7377802 bump invoicing extension to v0.0.5 2026-08-31 16:38:25 +02:00
Gregor Vostrak
8e57275cef bump invoicing extension version 2026-08-31 16:03:26 +02:00
Gregor Vostrak
efc6b55628 add helper functions for tanstack form and cent conversions 2026-08-31 16:03:26 +02:00
Gregor Vostrak
c12789376d fix type error and adapt formatting to new prettier version changes 2026-08-31 16:03:26 +02:00
Gregor Vostrak
4795812b60 align alter dialog positioning with other modals 2026-08-31 16:03:26 +02:00
Gregor Vostrak
0e00979ab8 make sure all invoices routes show active state in the navigation 2026-08-31 16:03:26 +02:00
Gregor Vostrak
f1426fcb5e add combobox to ui package 2026-08-31 16:03:26 +02:00
Constantin Graf
23f512d4a4 Add permissions and types for invoice recipients 2026-08-31 16:03:26 +02:00
Constantin Graf
637475e669 Add invite-only registration mode 2026-08-31 13:36:25 +02:00
Constantin Graf
3ec2abb309 Add invite-only registration mode
Support configurable on, invite-only, and off registration modes, including case-insensitive invitation checks and test coverage.
2026-08-31 13:36:25 +02:00
Gregor Vostrak
3e36b1cc01 replace TimezoneModalMismatch unit test with e2e test 2026-08-31 12:48:22 +02:00
Andrew Herron
7831bc697e Fix timezone mismatch modal posting to the removed Jetstream route
Use useUpdateUserMutation, matching UpdateProfileInformationForm.
2026-08-31 12:48:22 +02:00
Constantin Graf
602a8daa1f Suppress expected OAuth access denial reports 2026-08-31 12:42:00 +02:00
Constantin Graf
38b448a729 Suppress reporting for expected API exceptions 2026-08-31 12:42:00 +02:00
Constantin Graf
e6f071f87f Fixed formatting 2026-08-31 12:26:02 +02:00
Constantin Graf
693a1fa7e0 Test still-running email preference 2026-08-31 12:26:02 +02:00
Constantin Graf
77f14b696e Move email preference to notifications section 2026-08-31 12:26:02 +02:00
Constantin Graf
593372bae5 Add still-running email preference 2026-08-31 12:26:02 +02:00
dependabot[bot]
db9ca51fc4 Bump docker/login-action from 4 to 4.5.2
Bumps [docker/login-action](https://github.com/docker/login-action) from 4 to 4.5.2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4...v4.5.2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 12:14:38 +02:00
Andrew Herron
28aba7b27a Stopped downloads opening a new tab now that it's guaranteed to be an attachment. 2026-08-28 17:30:27 +02:00
Andrew Herron
453d5ed066 Set content-disposition 'attachment' for both local and S3 downloads. Added a bunch of tests. Fixes #1148. 2026-08-28 17:30:27 +02:00
Gregor Vostrak
ed3ba0135b fix undefined table-state helper in projects search e2e test 2026-08-28 13:10:36 +02:00
Gregor Vostrak
fb7d945c50 polish projects search ui, fix empty states 2026-08-27 18:50:34 +02:00
AKolenda
9958e1c09c add search input to projects overview page
Filters the project table by name client-side, reusing the existing
filteredProjects computed and the dropdown search input styling.
Search is deliberately not persisted in the table state so a reload
never leaves the table silently filtered.
2026-08-27 18:50:34 +02:00
github-actions[bot]
f00d48f1c3 Update VOUCHED list
https://github.com/solidtime-io/solidtime/issues/1217#issuecomment-5442102245
2026-08-27 16:28:04 +00:00
Constantin Graf
7edbfd5d76 Updated extension billing to 0.0.6 2026-08-27 15:22:31 +02:00
Gregor Vostrak
1edb940557 fix empty-value sorting edge cases and simplify sort components 2026-08-26 18:17:34 +02:00
Gregor Vostrak
c9330e6cb8 fix semantic task table role 2026-08-26 18:17:34 +02:00
Gregor Vostrak
0e9f652d84 refactor table sorting to composables and shared SortableTableHeaderCell 2026-08-26 18:17:34 +02:00
Niklaus Bucher
d29f5706ce Add sorting to tasks table 2026-08-26 18:17:34 +02:00
Constantin Graf
b2849ec04a Fixed code formatting 2026-08-25 13:35:32 +02:00
Constantin Graf
bd69cf478c Bump billing extension to v0.0.5 2026-08-25 13:35:32 +02:00
Constantin Graf
5197135d00 Updated service extension 2026-08-25 13:35:32 +02:00
Constantin Graf
b09b8649b8 Fixed typo in import 2026-08-25 12:32:30 +02:00
Constantin Graf
99fafdb8d6 Add database indexes 2026-08-25 12:32:30 +02:00
Gregor Vostrak
b02e49c7e0 improve modal scrolling behaviour on mobile 2026-08-24 21:46:46 +02:00
Gregor Vostrak
8609635d74 fix modal overflow on smaller viewports with dynamic viewport height 2026-08-22 17:43:33 +02:00
github-actions[bot]
5a07f798b3 Update VOUCHED list
https://github.com/solidtime-io/solidtime/discussions/1188#discussioncomment-DC_kwDOLFnGEc4BFEiq
2026-08-21 13:22:28 +00:00
Gregor Vostrak
3af69830ee move api rate limit to app config, add fallback for empty env key 2026-08-20 17:40:33 +02:00
Darwin Correa
a050153bcd feat: make API rate limits configurable via env vars (#1204)
* feat: make API rate limits configurable via env vars
2026-08-20 17:22:24 +02:00
Gregor Vostrak
9d9731c7ce add formatting support for months grouping 2026-08-20 17:11:35 +02:00
Andrew Herron
dfe3206614 Add "Week" grouping option to reporting 2026-08-20 17:11:35 +02:00
Andrew Herron
97fd882878 Format date group labels in all aggregate exports 2026-08-20 17:11:35 +02:00
Andrew Herron
111e12df12 Add "Date" grouping option to reporting 2026-08-20 17:11:35 +02:00
Constantin Graf
de13c07855 Fixed return type in TrustHosts 2026-08-06 17:49:52 +02:00
351 changed files with 11880 additions and 4765 deletions

View File

@@ -6,6 +6,7 @@ APP_DEBUG=true
APP_URL=http://localhost
APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=true
SUPER_ADMINS=admin@example.com
# Logging
LOG_CHANNEL=stack
@@ -64,3 +65,6 @@ GOTENBERG_URL=http://localhost:3000
# Octane
OCTANE_SERVER=frankenphp
# Inertia
INERTIA_SSR_ENABLED=false

View File

@@ -5,7 +5,8 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true
APP_URL=https://solidtime.test
APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=true
# Supported values: on, off, invite-only (true/false are supported for backwards compatibility)
APP_ENABLE_REGISTRATION=on
SUPER_ADMINS=admin@example.com
PAGINATION_PER_PAGE_DEFAULT=500
@@ -87,3 +88,6 @@ FORWARD_DB_PORT=54329
VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}"
#SAIL_XDEBUG_MODE=develop,debug,coverage
# This app does not ship a Node SSR bundle, so Inertia SSR must stay disabled.
INERTIA_SSR_ENABLED=false

View File

@@ -13,3 +13,5 @@ LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=pgsql
INERTIA_SSR_ENABLED=false

2
.github/VOUCHED.td vendored
View File

@@ -14,10 +14,12 @@
# Seeded 2026-07-25 from the authors of every merged pull request.
agross
akolenda
bufferhead-code
candideu
kasparrosin
korridor
nikbucher tasks table sorting
onatcer
shrootbuck
smilebeda

View File

@@ -103,6 +103,8 @@ jobs:
{
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout invoicing extension"
@@ -119,9 +121,26 @@ jobs:
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Checkout auditing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
path: extensions/Auditing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
- name: "Install composer dependencies in auditing extension"
run: cd extensions/Auditing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
- name: "Install npm dependencies in auditing extension"
run: cd extensions/Auditing && npm ci
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Activate auditing extension"
run: php artisan module:enable Auditing
- name: "Install npm dependencies"
run: npm ci
@@ -141,7 +160,7 @@ jobs:
${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4
uses: docker/login-action@v4.6.0
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
@@ -195,7 +214,7 @@ jobs:
merge-multiple: true
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4
uses: docker/login-action@v4.6.0
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}

View File

@@ -84,6 +84,8 @@ jobs:
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension"
@@ -97,7 +99,7 @@ jobs:
- name: "Install dependencies in billing extension"
uses: php-actions/composer@v6
env:
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "gregor@vostrak.at", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "${{ secrets.LARAVEL_SPARK_USERNAME }}", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
with:
working_dir: "extensions/Billing"
command: install
@@ -145,6 +147,25 @@ jobs:
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Checkout auditing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
path: extensions/Auditing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
- name: "Install composer dependencies in auditing extension"
uses: php-actions/composer@v6
with:
working_dir: "extensions/Auditing"
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 auditing extension"
run: cd extensions/Auditing && npm ci
- name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2
with:
@@ -168,6 +189,9 @@ jobs:
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Activate auditing extension"
run: php artisan module:enable Auditing
- name: "Install npm dependencies"
run: npm ci
@@ -177,7 +201,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v4
uses: docker/login-action@v4.6.0
with:
registry: rg.fr-par.scw.cloud/solidtime
username: nologin

View File

@@ -117,13 +117,13 @@ jobs:
${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v4
uses: docker/login-action@v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v4
uses: docker/login-action@v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -177,13 +177,13 @@ jobs:
merge-multiple: true
- name: "Login to Docker Hub"
uses: docker/login-action@v4
uses: docker/login-action@v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR"
uses: docker/login-action@v4
uses: docker/login-action@v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -0,0 +1,31 @@
name: Extension Manifest Check
on:
pull_request:
paths:
- extensions/manifest.json
- .github/workflows/extension-manifest-check.yml
permissions:
contents: read
jobs:
check-refs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
# Extension refs must be pinned to a release tag (vX.Y.Z) or a full commit
# SHA. Branch names (main, feature/foo, ...) are only acceptable while
# developing and must be replaced before the pull request is merged.
- name: "Check extension refs are pinned"
run: |
failed=0
while IFS=$'\t' read -r name ref; do
if [[ "$ref" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.]+)?$ ]] || [[ "$ref" =~ ^[0-9a-f]{40}$ ]]; then
echo "✅ $name: $ref"
else
echo "::error file=extensions/manifest.json::Extension \"$name\" uses ref \"$ref\", which is not a release tag or commit SHA. Pin it before merging."
failed=1
fi
done < <(jq -r 'to_entries[] | [.key, .value.ref] | @tsv' extensions/manifest.json)
exit $failed

View File

@@ -1,6 +1,8 @@
name: NPM Format Check
on: [push]
permissions:
contents: read
jobs:
format-check:

136
.github/workflows/phpunit-extensions.yml vendored Normal file
View File

@@ -0,0 +1,136 @@
name: PHPUnit Tests - Extensions
on: push
permissions:
contents: read
jobs:
phpunit-extensions:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
postgres_version: [ 15, 16, 17 ]
services:
pgsql_test:
image: postgres:${{ matrix.postgres_version }}
env:
PGPASSWORD: 'root'
POSTGRES_DB: 'laravel'
POSTGRES_USER: 'root'
POSTGRES_PASSWORD: 'root'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--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:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: pcov
- uses: actions/setup-node@v7
with:
node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
- name: "Install composer dependencies in billing extension"
working-directory: extensions/Billing
env:
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "contact@solidtime.io", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in billing extension"
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
- name: "Install composer dependencies in services extension"
working-directory: extensions/Services
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in services extension"
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
working-directory: extensions/Invoicing
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Run composer install"
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Activate billing extension"
run: php artisan module:enable Billing
- name: "Activate services extension"
run: php artisan module:enable Services
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install dependencies"
run: npm ci
- name: "Build Frontend"
run: npm run build
- name: "Prepare Laravel Application"
run: |
cp .env.ci .env
php artisan key:generate
php artisan passport:keys
- name: "Run PHPUnit"
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure

View File

@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v7.0.0
uses: codecov/codecov-action@v7.1.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

2
.gitignore vendored
View File

@@ -6,6 +6,7 @@ dist
/public/storage
/public/css
/public/js
/public/fonts/filament
/public/vendor
/lang/vendor
/storage/*.key
@@ -43,4 +44,5 @@ yarn-error.log
/data
/config/caddy
/config/composer
/config/psysh
/AGENTS.md

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Enums\RegistrationMode;
use App\Enums\Weekday;
use App\Events\NewsletterRegistered;
use App\Models\User;
use App\Service\InvitationService;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\TimezoneService;
use App\Service\UserService;
@@ -31,13 +33,14 @@ class CreateNewUser implements CreatesNewUsers
*/
public function create(array $input): User
{
if (! config('app.enable_registration')) {
$registrationMode = RegistrationMode::fromConfig(config('app.enable_registration'));
if ($registrationMode === RegistrationMode::Off) {
throw ValidationException::withMessages([
'email' => [__('Registration is disabled.')],
]);
}
Validator::make($input, [
$validated = Validator::make($input, [
'name' => [
'required',
'string',
@@ -60,6 +63,20 @@ class CreateNewUser implements CreatesNewUsers
],
])->validate();
if ($registrationMode === RegistrationMode::InviteOnly) {
$invitationService = app(InvitationService::class);
$email = (string) $validated['email'];
if (! $invitationService->hasAcceptedInvitationForEmail($email)) {
$message = $invitationService->hasPendingInvitationForEmail($email)
? __('Please accept the organization invitation sent to your email address before registering.')
: __('Registration is only available to invited users.');
throw ValidationException::withMessages([
'email' => [$message],
]);
}
}
$timezone = null;
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
if (app(TimezoneService::class)->isValid($input['timezone'])) {

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Override;
/**
* User provider that only resolves non-placeholder users.
*
* Placeholder users are created by imports and when members are removed from an
* organization. They can share an email address with a real user, so resolving a user by
* email can return a placeholder instead of the real account. The login flow filters them
* out explicitly, but the password broker and the guard credential checks (for example the
* password confirmation) resolve users through the configured user provider.
*
* Registered as the "eloquent" provider driver in the AuthServiceProvider, so it replaces the
* built-in one for every provider in config/auth.php.
*/
class ActiveUserProvider extends EloquentUserProvider
{
/**
* @param Model|null $model
* @return Builder<Model>
*/
#[Override]
protected function newModelQuery($model = null): Builder
{
$query = parent::newModelQuery($model);
$query->getQuery()->where('is_placeholder', '=', false);
return $query;
}
}

View File

@@ -10,6 +10,7 @@ use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use stdClass;
class SelfHostDatabaseConsistency extends Command
{
@@ -101,7 +102,7 @@ class SelfHostDatabaseConsistency extends Command
}
/**
* @param Collection<int, \stdClass> $problems
* @param Collection<int, stdClass> $problems
*/
private function logProblems(Collection $problems, string $message, bool &$hadAProblem): void
{

View File

@@ -7,7 +7,7 @@ namespace App\Console\Commands\SelfHost;
use Illuminate\Console\Command;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Str;
use phpseclib3\Crypt\RSA;
use phpseclib4\Crypt\RSA;
class SelfHostGenerateKeysCommand extends Command
{

View File

@@ -51,7 +51,8 @@ class TimeEntrySendStillRunningMailsCommand extends Command
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false);
$query->where('is_placeholder', '=', false)
->where('send_time_entry_still_running_email', '=', true);
})
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum RegistrationMode: string
{
case On = 'on';
case InviteOnly = 'invite-only';
case Off = 'off';
public static function fromConfig(mixed $value): self
{
if ($value === true) {
return self::On;
}
if ($value === false || $value === null) {
return self::Off;
}
return match (strtolower(trim((string) $value))) {
'1', 'on', 'true' => self::On,
'invite-only' => self::InviteOnly,
default => self::Off,
};
}
}

View File

@@ -60,7 +60,6 @@ abstract class ApiException extends Exception
*/
public function report(): bool
{
// TODO: temporary activated
return false;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
{
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class CanNotRemoveOwnerFromOrganization extends ApiException
{
public const string KEY = 'can_not_remove_owner_from_organization';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
{
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
{
public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
public function report(): bool
{
return true;
}
}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Exceptions\Api;
use Override;
class EntityStillInUseApiException extends ApiException
{
private string $modelToDelete;
@@ -19,10 +21,20 @@ class EntityStillInUseApiException extends ApiException
public const string KEY = 'entity_still_in_use';
/**
* Report the exception.
*
* @return bool true means the exception handler will not report it again
*/
public function report(): bool
{
return true;
}
/**
* Get the translated message for the exception.
*/
#[\Override]
#[Override]
public function getTranslatedMessage(): string
{
return __('exceptions.api.'.$this->getKey(), [

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class FeatureIsNotAvailableInFreePlanApiException extends ApiException
{
public const string KEY = 'feature_is_not_available_in_free_plan';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class InactiveUserCanNotBeUsedApiException extends ApiException
{
public const string KEY = 'inactive_user_can_not_be_used';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
{
public const string KEY = 'invitation_for_the_email_already_exists';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OnlyOwnerCanChangeOwnership extends ApiException
{
public const string KEY = 'only_owner_can_change_ownership';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
{
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
{
public const string KEY = 'organization_has_no_subscription_but_multiple_members';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OrganizationNeedsAtLeastOneOwner extends ApiException
{
public const string KEY = 'organization_needs_at_least_one_owner';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OverlappingTimeEntryApiException extends ApiException
{
public const string KEY = 'overlapping_time_entry';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ 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';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class TimeEntryCanNotBeRestartedApiException extends ApiException
{
public const string KEY = 'time_entry_can_not_be_restarted';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,14 @@ namespace App\Exceptions\Api;
class TimeEntryStillRunningApiException extends ApiException
{
public const string KEY = 'time_entry_still_running';
/**
* Report the exception.
*
* @return bool true means the exception handler will not report it again
*/
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
{
public const string KEY = 'user_is_already_member_of_organization';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfProjectApiException extends ApiException
{
public const string KEY = 'user_is_already_member_of_project';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserNotPlaceholderApiException extends ApiException
{
public const string KEY = 'user_not_placeholder';
public function report(): bool
{
return true;
}
}

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
{
public const string KEY = 'user_resend_email_verification_no_pending_email';
public function report(): bool
{
return true;
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use League\OAuth2\Server\Exception\OAuthServerException;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -34,6 +35,10 @@ class Handler extends ExceptionHandler
//
});
$this->dontReportWhen(fn (Throwable $e): bool => $e instanceof OAuthServerException
&& $e->getErrorType() === 'access_denied'
&& $e->getHttpStatusCode() === 401);
// A request on an untrusted host (see App\Http\Middleware\TrustHosts)
// otherwise renders as a bare "Bad request." 400. Show a message that
// says how to fix it instead. The framework has already converted the

View File

@@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\AuditResource\Pages;
use App\Models\Audit;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
class AuditResource extends Resource
{
protected static ?string $model = Audit::class;
protected static ?string $navigationIcon = 'heroicon-o-archive-box';
protected static ?string $navigationGroup = 'System';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('user_type')
->maxLength(255),
Forms\Components\TextInput::make('user_id'),
Forms\Components\TextInput::make('event')
->required()
->maxLength(255),
Forms\Components\TextInput::make('auditable_type')
->required()
->maxLength(255),
Forms\Components\TextInput::make('auditable_id')
->required(),
PrettyJsonField::make('old_values'),
PrettyJsonField::make('new_values'),
Forms\Components\Textarea::make('url'),
Forms\Components\TextInput::make('ip_address'),
Forms\Components\TextInput::make('user_agent')
->maxLength(1023),
Forms\Components\TextInput::make('tags')
->maxLength(255),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('event'),
Tables\Columns\TextColumn::make('auditable_type'),
Tables\Columns\TextColumn::make('auditable_id'),
IconColumn::make('was_command')
->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan '))
->boolean(),
Tables\Columns\TextColumn::make('created_at')
->sortable()
->dateTime(),
Tables\Columns\TextColumn::make('updated_at')
->sortable()
->dateTime(),
])
->filters([
//
])
->actions([
Tables\Actions\ViewAction::make(),
])
->bulkActions([
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListAudits::route('/'),
'create' => Pages\CreateAudit::route('/create'),
'view' => Pages\ViewAudit::route('/{record}'),
];
}
}

View File

@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\CreateRecord;
class CreateAudit extends CreateRecord
{
protected static string $resource = AuditResource::class;
}

View File

@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ListRecords;
class ListAudits extends ListRecords
{
protected static string $resource = AuditResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ViewRecord;
class ViewAudit extends ViewRecord
{
protected static string $resource = AuditResource::class;
}

View File

@@ -4,13 +4,18 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ClientResource\Pages;
use App\Filament\Resources\ClientResource\Pages\CreateClient;
use App\Filament\Resources\ClientResource\Pages\EditClient;
use App\Filament\Resources\ClientResource\Pages\ListClients;
use App\Models\Client;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
@@ -18,16 +23,16 @@ class ClientResource extends Resource
{
protected static ?string $model = Client::class;
protected static ?string $navigationIcon = 'heroicon-o-briefcase';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-briefcase';
protected static ?string $navigationGroup = 'Timetracking';
protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 4;
public static function form(Form $form): Form
public static function form(Schema $schema): Schema
{
return $form
->schema([
return $schema
->components([
TextInput::make('name')
->label('Name')
->required(),
@@ -43,17 +48,17 @@ class ClientResource extends Resource
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
TextColumn::make('name')
->label('Name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('organization.name')
TextColumn::make('organization.name')
->sortable()
->label('Organization'),
Tables\Columns\TextColumn::make('created_at')
TextColumn::make('created_at')
->label('Created at')
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
TextColumn::make('updated_at')
->label('Updated at')
->sortable(),
])
@@ -68,12 +73,12 @@ class ClientResource extends Resource
->relationship('organization', 'id')
->searchable(),
])
->actions([
Tables\Actions\EditAction::make(),
->recordActions([
EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
@@ -88,9 +93,9 @@ class ClientResource extends Resource
public static function getPages(): array
{
return [
'index' => Pages\ListClients::route('/'),
'create' => Pages\CreateClient::route('/create'),
'edit' => Pages\EditClient::route('/{record}/edit'),
'index' => ListClients::route('/'),
'create' => CreateClient::route('/create'),
'edit' => EditClient::route('/{record}/edit'),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ClientResource\Pages;
use App\Filament\Resources\ClientResource;
use Filament\Actions;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditClient extends EditRecord
@@ -15,7 +15,7 @@ class EditClient extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ClientResource\Pages;
use App\Filament\Resources\ClientResource;
use Filament\Actions;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListClients extends ListRecords
@@ -15,7 +15,7 @@ class ListClients extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -7,16 +7,16 @@ namespace App\Filament\Resources;
use App\Filament\Resources\FailedJobResource\Pages\ListFailedJobs;
use App\Filament\Resources\FailedJobResource\Pages\ViewFailedJobs;
use App\Models\FailedJob;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
@@ -30,19 +30,19 @@ class FailedJobResource extends Resource
{
protected static ?string $model = FailedJob::class;
protected static ?string $navigationIcon = 'heroicon-o-exclamation-circle';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-circle';
protected static ?string $navigationGroup = 'System';
protected static string|\UnitEnum|null $navigationGroup = 'System';
public static function getNavigationBadge(): ?string
{
return (string) FailedJob::query()->count();
}
public static function form(Form $form): Form
public static function form(Schema $schema): Schema
{
return $form
->schema([
return $schema
->components([
TextInput::make('uuid')->disabled()->columnSpan(4),
TextInput::make('failed_at')->disabled(),
TextInput::make('id')->disabled(),
@@ -74,7 +74,7 @@ class FailedJobResource extends Resource
TextColumn::make('queue')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),
])
->filters([])
->bulkActions([
->toolbarActions([
BulkAction::make('retry')
->icon('heroicon-o-arrow-path')
->label('Retry selected')
@@ -91,7 +91,7 @@ class FailedJobResource extends Resource
}),
DeleteBulkAction::make(),
])
->actions([
->recordActions([
DeleteAction::make(),
ViewAction::make(),
Action::make('retry')

View File

@@ -5,14 +5,21 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Enums\Role;
use App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Filament\Resources\OrganizationInvitationResource\Pages\EditOrganizationInvitation;
use App\Filament\Resources\OrganizationInvitationResource\Pages\ListOrganizationInvitations;
use App\Filament\Resources\OrganizationInvitationResource\Pages\ViewOrganizationInvitation;
use App\Models\OrganizationInvitation;
use App\Service\OrganizationInvitationService;
use Filament\Forms;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
@@ -22,18 +29,18 @@ class OrganizationInvitationResource extends Resource
protected static ?string $label = 'Invitations';
protected static ?string $navigationIcon = 'heroicon-o-user-plus';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-user-plus';
protected static ?string $navigationGroup = 'Users';
protected static string|\UnitEnum|null $navigationGroup = 'Users';
protected static ?int $navigationSort = 9;
public static function form(Form $form): Form
public static function form(Schema $schema): Schema
{
return $form
return $schema
->columns(1)
->schema([
Forms\Components\TextInput::make('email')
->components([
TextInput::make('email')
->label('Email')
->disabledOn(['edit'])
->required(),
@@ -45,11 +52,11 @@ class OrganizationInvitationResource extends Resource
->searchable(['name'])
->disabledOn(['edit'])
->required(),
Forms\Components\DateTimePicker::make('created_at')
DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
@@ -60,17 +67,17 @@ class OrganizationInvitationResource extends Resource
{
return $table
->columns([
Tables\Columns\TextColumn::make('organization.name')
TextColumn::make('organization.name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('email')
TextColumn::make('email')
->sortable(),
Tables\Columns\TextColumn::make('role'),
Tables\Columns\TextColumn::make('created_at')
TextColumn::make('role'),
TextColumn::make('created_at')
->label('Created At')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
TextColumn::make('updated_at')
->label('Updated At')
->dateTime()
->sortable()
@@ -80,13 +87,13 @@ class OrganizationInvitationResource extends Resource
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
->recordActions([
EditAction::make(),
DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\BulkAction::make('resend')
->toolbarActions([
BulkActionGroup::make([
BulkAction::make('resend')
->label('Resend')
->action(function (Collection $records): void {
foreach ($records as $organizationInvite) {
@@ -106,9 +113,9 @@ class OrganizationInvitationResource extends Resource
public static function getPages(): array
{
return [
'index' => Pages\ListOrganizationInvitations::route('/'),
'edit' => Pages\EditOrganizationInvitation::route('/{record}/edit'),
'view' => Pages\ViewOrganizationInvitation::route('/{record}'),
'index' => ListOrganizationInvitations::route('/'),
'edit' => EditOrganizationInvitation::route('/{record}/edit'),
'view' => ViewOrganizationInvitation::route('/{record}'),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Filament\Resources\OrganizationInvitationResource;
use Filament\Actions;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditOrganizationInvitation extends EditRecord
@@ -15,7 +15,7 @@ class EditOrganizationInvitation extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -9,7 +9,10 @@ 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\CreateOrganization;
use App\Filament\Resources\OrganizationResource\Pages\EditOrganization;
use App\Filament\Resources\OrganizationResource\Pages\ListOrganizations;
use App\Filament\Resources\OrganizationResource\Pages\ViewOrganization;
use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager;
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
use App\Models\Organization;
@@ -20,38 +23,45 @@ use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto;
use App\Service\Import\ImportService;
use App\Service\TimezoneService;
use Brick\Money\ISOCurrencyProvider;
use Filament\Forms;
use Brick\Money\IsoCurrencyProvider;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Storage;
use League\Flysystem\UnableToReadFile;
class OrganizationResource extends Resource
{
protected static ?string $model = Organization::class;
protected static ?string $navigationIcon = 'heroicon-o-building-office-2';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static ?string $navigationGroup = 'Users';
protected static string|\UnitEnum|null $navigationGroup = 'Users';
protected static ?int $navigationSort = 7;
public static function form(Form $form): Form
public static function form(Schema $schema): Schema
{
return $form
return $schema
->columns(1)
->schema([
Forms\Components\TextInput::make('name')
->components([
TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\Toggle::make('personal_team')
Toggle::make('personal_team')
->label('Is personal?')
->hiddenOn(['create'])
->required(),
@@ -79,7 +89,7 @@ class OrganizationResource extends Resource
Select::make('currency')
->label('Currency')
->options(function (): array {
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
$currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies();
$select = [];
foreach ($currencies as $currency) {
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';
@@ -89,7 +99,7 @@ class OrganizationResource extends Resource
})
->required()
->searchable(),
Forms\Components\TextInput::make('billable_rate')
TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
@@ -99,11 +109,11 @@ class OrganizationResource extends Resource
'max:2147483647',
])
->numeric(),
Forms\Components\DateTimePicker::make('created_at')
DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
@@ -117,7 +127,7 @@ class OrganizationResource extends Resource
TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('personal_team')
IconColumn::make('personal_team')
->boolean()
->label('Is personal?')
->sortable(),
@@ -138,9 +148,9 @@ class OrganizationResource extends Resource
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->recordActions([
EditAction::make(),
DeleteAction::make()
->using(function (Organization $record): void {
app(DeletionService::class)->deleteOrganization($record);
}),
@@ -158,7 +168,7 @@ class OrganizationResource extends Resource
return response()->streamDownload(function () use ($file): void {
echo Storage::disk(config('filesystems.private'))->get($file);
}, 'export.zip');
} catch (\Exception $exception) {
} catch (Exception $exception) {
report($exception);
Notification::make()
->title('Export failed')
@@ -172,9 +182,13 @@ class OrganizationResource extends Resource
->icon('heroicon-o-inbox-arrow-down')
->action(function (Organization $record, array $data): void {
try {
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
try {
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
} catch (UnableToReadFile) {
$file = null;
}
if ($file === null) {
throw new \Exception('File not found');
throw new ImportException('File not found');
}
/** @var string $timezone */
$timezone = $data['timezone'];
@@ -209,8 +223,8 @@ class OrganizationResource extends Resource
}
})
->tooltip(fn (Organization $record): string => 'Import into '.$record->name)
->form([
Forms\Components\FileUpload::make('file')
->schema([
FileUpload::make('file')
->label('File')
->required(),
Select::make('type')
@@ -230,7 +244,7 @@ class OrganizationResource extends Resource
->required(),
]),
])
->bulkActions([
->toolbarActions([
]);
}
@@ -245,10 +259,10 @@ class OrganizationResource extends Resource
public static function getPages(): array
{
return [
'index' => Pages\ListOrganizations::route('/'),
'create' => Pages\CreateOrganization::route('/create'),
'edit' => Pages\EditOrganization::route('/{record}/edit'),
'view' => Pages\ViewOrganization::route('/{record}'),
'index' => ListOrganizations::route('/'),
'create' => CreateOrganization::route('/create'),
'edit' => EditOrganization::route('/{record}/edit'),
'view' => ViewOrganization::route('/{record}'),
];
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use App\Filament\Resources\OrganizationResource\Actions\DeleteOrganization;
use Filament\Resources\Pages\EditRecord;
class EditOrganization extends EditRecord
@@ -14,7 +15,7 @@ class EditOrganization extends EditRecord
protected function getHeaderActions(): array
{
return [
OrganizationResource\Actions\DeleteOrganization::make(),
DeleteOrganization::make(),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Actions;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListOrganizations extends ListRecords
@@ -15,7 +15,7 @@ class ListOrganizations extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -9,12 +9,17 @@ use App\Filament\Resources\OrganizationInvitationResource;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Service\InvitationService;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\DetachBulkAction;
use Filament\Actions\EditAction;
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\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
@@ -25,10 +30,10 @@ class InvitationsRelationManager extends RelationManager
protected static ?string $title = 'Invitations';
public function form(Form $form): Form
public function form(Schema $schema): Schema
{
return $form
->schema([
return $schema
->components([
TextInput::make('email')
->label('Email')
->disabledOn(['edit'])
@@ -53,33 +58,33 @@ class InvitationsRelationManager extends RelationManager
->modelLabel('Invitation')
->pluralModelLabel('Invitations')
->columns([
Tables\Columns\TextColumn::make('email'),
Tables\Columns\TextColumn::make('role'),
TextColumn::make('email'),
TextColumn::make('role'),
])
->headerActions([
Tables\Actions\CreateAction::make()
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']), auth()->user());
->inviteUser($ownerRecord, $data['email'], ($data['role'] instanceof Role ? $data['role'] : Role::from($data['role'])), auth()->user());
}),
])
->actions([
->recordActions([
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(),
EditAction::make(),
DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(),
->toolbarActions([
BulkActionGroup::make([
DetachBulkAction::make(),
]),
]);
}

View File

@@ -12,14 +12,15 @@ use App\Models\Organization;
use App\Models\User;
use App\Service\BillableRateService;
use App\Service\MemberService;
use Filament\Actions\Action;
use Filament\Actions\AttachAction;
use Filament\Actions\DetachAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AttachAction;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Validation\Rule;
@@ -28,10 +29,10 @@ class UsersRelationManager extends RelationManager
{
protected static string $relationship = 'users';
public function form(Form $form): Form
public function form(Schema $schema): Schema
{
return $form
->schema([
return $schema
->components([
Select::make('role')
->options(Role::class),
TextInput::make('billable_rate')
@@ -75,17 +76,17 @@ class UsersRelationManager extends RelationManager
->using(function (User $record, array $data): void {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
app(MemberService::class)->addMember($record, $organization, Role::from($data['role']), true);
app(MemberService::class)->addMember($record, $organization, ($data['role'] instanceof Role ? $data['role'] : Role::from($data['role'])), true);
}),
])
->actions([
->recordActions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (User $record): string => UserResource::getUrl('view', [
'record' => $record->getKey(),
])),
Tables\Actions\EditAction::make()
EditAction::make()
->using(function (User $record, array $data): User {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
@@ -97,9 +98,10 @@ class UsersRelationManager extends RelationManager
app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member);
}
if ($data['role'] !== $member->role) {
$newRole = $data['role'] instanceof Role ? $data['role'] : Role::from($data['role']);
if ($newRole->value !== $member->role) {
try {
app(MemberService::class)->changeRole($member, $organization, Role::from($data['role']), true);
app(MemberService::class)->changeRole($member, $organization, $newRole, true);
} catch (ApiException $exception) {
Notification::make()
->danger()
@@ -113,7 +115,7 @@ class UsersRelationManager extends RelationManager
return $record;
}),
Tables\Actions\DetachAction::make()
DetachAction::make()
->using(function (User $record): void {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
@@ -133,7 +135,7 @@ class UsersRelationManager extends RelationManager
}
}),
])
->bulkActions([
->toolbarActions([
]);
}
}

View File

@@ -4,12 +4,19 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ProjectMemberResource\Pages;
use App\Filament\Resources\ProjectMemberResource\Pages\CreateProjectMember;
use App\Filament\Resources\ProjectMemberResource\Pages\EditProjectMember;
use App\Filament\Resources\ProjectMemberResource\Pages\ListProjectMembers;
use App\Filament\Resources\ProjectMemberResource\Pages\ViewProjectMembers;
use App\Models\ProjectMember;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ProjectMemberResource extends Resource
@@ -18,11 +25,11 @@ class ProjectMemberResource extends Resource
protected static bool $shouldRegisterNavigation = false;
public static function form(Form $form): Form
public static function form(Schema $schema): Schema
{
return $form
->schema([
Forms\Components\TextInput::make('billable_rate')
return $schema
->components([
TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
@@ -32,10 +39,10 @@ class ProjectMemberResource extends Resource
'max:2147483647',
])
->numeric(),
Forms\Components\Select::make('user_id')
Select::make('user_id')
->relationship('user', 'name')
->required(),
Forms\Components\Select::make('member_id')
Select::make('member_id')
->relationship('member', 'id')
->required(),
]);
@@ -45,17 +52,17 @@ class ProjectMemberResource extends Resource
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')
TextColumn::make('id')
->label('ID'),
Tables\Columns\TextColumn::make('billable_rate')
TextColumn::make('billable_rate')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('project.name'),
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('created_at')
TextColumn::make('project.name'),
TextColumn::make('user.name'),
TextColumn::make('created_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
@@ -63,12 +70,12 @@ class ProjectMemberResource extends Resource
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
->recordActions([
EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
@@ -83,10 +90,10 @@ class ProjectMemberResource extends Resource
public static function getPages(): array
{
return [
'index' => Pages\ListProjectMembers::route('/'),
'create' => Pages\CreateProjectMember::route('/create'),
'edit' => Pages\EditProjectMember::route('/{record}/edit'),
'view' => Pages\ViewProjectMembers::route('/{record}'),
'index' => ListProjectMembers::route('/'),
'create' => CreateProjectMember::route('/create'),
'edit' => EditProjectMember::route('/{record}/edit'),
'view' => ViewProjectMembers::route('/{record}'),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ProjectMemberResource\Pages;
use App\Filament\Resources\ProjectMemberResource;
use Filament\Actions;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditProjectMember extends EditRecord
@@ -15,7 +15,7 @@ class EditProjectMember extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ProjectMemberResource\Pages;
use App\Filament\Resources\ProjectMemberResource;
use Filament\Actions;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListProjectMembers extends ListRecords
@@ -15,7 +15,7 @@ class ListProjectMembers extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -4,14 +4,19 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource\Pages\CreateProject;
use App\Filament\Resources\ProjectResource\Pages\EditProject;
use App\Filament\Resources\ProjectResource\Pages\ListProjects;
use App\Filament\Resources\ProjectResource\RelationManagers\ProjectMembersRelationManager;
use App\Models\Project;
use Filament\Forms;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Form;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\ColorColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
@@ -21,24 +26,24 @@ class ProjectResource extends Resource
{
protected static ?string $model = Project::class;
protected static ?string $navigationIcon = 'heroicon-o-folder';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-folder';
protected static ?string $navigationGroup = 'Timetracking';
protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 2;
public static function form(Form $form): Form
public static function form(Schema $schema): Schema
{
return $form
->schema([
Forms\Components\TextInput::make('name')
return $schema
->components([
TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
ColorPicker::make('color')
->label('Color')
->required(),
Forms\Components\TextInput::make('billable_rate')
TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
@@ -48,7 +53,7 @@ class ProjectResource extends Resource
'max:2147483647',
])
->numeric(),
Forms\Components\Select::make('organization_id')
Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->required(),
@@ -81,12 +86,12 @@ class ProjectResource extends Resource
->searchable(),
])
->defaultSort('created_at', 'desc')
->actions([
Tables\Actions\EditAction::make(),
->recordActions([
EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
@@ -101,9 +106,9 @@ class ProjectResource extends Resource
public static function getPages(): array
{
return [
'index' => Pages\ListProjects::route('/'),
'create' => Pages\CreateProject::route('/create'),
'edit' => Pages\EditProject::route('/{record}/edit'),
'index' => ListProjects::route('/'),
'create' => CreateProject::route('/create'),
'edit' => EditProject::route('/{record}/edit'),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource;
use Filament\Actions;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditProject extends EditRecord
@@ -15,7 +15,7 @@ class EditProject extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource;
use Filament\Actions;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListProjects extends ListRecords
@@ -15,7 +15,7 @@ class ListProjects extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -6,10 +6,10 @@ namespace App\Filament\Resources\ProjectResource\RelationManagers;
use App\Filament\Resources\ProjectMemberResource;
use App\Models\ProjectMember;
use Filament\Forms\Form;
use Filament\Actions\Action;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ProjectMembersRelationManager extends RelationManager
@@ -18,10 +18,10 @@ class ProjectMembersRelationManager extends RelationManager
protected static string $relationship = 'members';
public function form(Form $form): Form
public function form(Schema $schema): Schema
{
return $form
->schema([
return $schema
->components([
]);
}
@@ -30,8 +30,8 @@ class ProjectMembersRelationManager extends RelationManager
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('billable_rate')
TextColumn::make('user.name'),
TextColumn::make('billable_rate')
->numeric()
->sortable(),
])
@@ -40,7 +40,7 @@ class ProjectMembersRelationManager extends RelationManager
])
->headerActions([
])
->actions([
->recordActions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
@@ -54,7 +54,7 @@ class ProjectMembersRelationManager extends RelationManager
]))
->openUrlInNewTab(),
])
->bulkActions([
->toolbarActions([
]);
}
}

View File

@@ -4,16 +4,21 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ReportResource\Pages;
use App\Filament\Resources\ReportResource\Pages\EditReport;
use App\Filament\Resources\ReportResource\Pages\ListReports;
use App\Filament\Resources\ReportResource\Pages\ViewReport;
use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto;
use Filament\Forms;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter;
@@ -24,22 +29,22 @@ class ReportResource extends Resource
{
protected static ?string $model = Report::class;
protected static ?string $navigationIcon = 'heroicon-o-document-chart-bar';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-document-chart-bar';
protected static ?string $navigationGroup = 'Timetracking';
protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 7;
public static function form(Form $form): Form
public static function form(Schema $schema): Schema
{
return $form
return $schema
->columns(1)
->schema([
Forms\Components\TextInput::make('name')
->components([
TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('description')
TextInput::make('description')
->label('Description')
->nullable()
->maxLength(255),
@@ -49,13 +54,13 @@ class ReportResource extends Resource
DateTimePicker::make('public_until')
->label('Public until')
->nullable(),
Forms\Components\Select::make('organization_id')
Select::make('organization_id')
->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->disabled()
->required(),
Forms\Components\TextInput::make('share_secret')
TextInput::make('share_secret')
->label('Share Secret')
->nullable(),
PrettyJsonField::make('properties')
@@ -109,18 +114,18 @@ class ReportResource extends Resource
->relationship('organization', 'id')
->searchable(),
])
->actions([
->recordActions([
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(),
ViewAction::make(),
EditAction::make(),
DeleteAction::make(),
])
->bulkActions([
->toolbarActions([
]);
}
@@ -133,9 +138,9 @@ class ReportResource extends Resource
public static function getPages(): array
{
return [
'index' => Pages\ListReports::route('/'),
'edit' => Pages\EditReport::route('/{record}/edit'),
'view' => Pages\ViewReport::route('/{record}'),
'index' => ListReports::route('/'),
'edit' => EditReport::route('/{record}/edit'),
'view' => ViewReport::route('/{record}'),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ReportResource\Pages;
use App\Filament\Resources\ReportResource;
use Filament\Actions;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditReport extends EditRecord
@@ -15,7 +15,7 @@ class EditReport extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -4,13 +4,18 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TagResource\Pages;
use App\Filament\Resources\TagResource\Pages\CreateTag;
use App\Filament\Resources\TagResource\Pages\EditTag;
use App\Filament\Resources\TagResource\Pages\ListTags;
use App\Models\Tag;
use Filament\Forms;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
@@ -18,20 +23,20 @@ class TagResource extends Resource
{
protected static ?string $model = Tag::class;
protected static ?string $navigationIcon = 'heroicon-o-tag';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-tag';
protected static ?string $navigationGroup = 'Timetracking';
protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 5;
public static function form(Form $form): Form
public static function form(Schema $schema): Schema
{
return $form
->schema([
return $schema
->components([
TextInput::make('name')
->label('Name')
->required(),
Forms\Components\Select::make('organization_id')
Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name')
->label('Organization')
->searchable(['name'])
@@ -43,17 +48,17 @@ class TagResource extends Resource
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
TextColumn::make('name')
->label('Name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('organization.name')
TextColumn::make('organization.name')
->sortable()
->label('Organization'),
Tables\Columns\TextColumn::make('created_at')
TextColumn::make('created_at')
->label('Created at')
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
TextColumn::make('updated_at')
->label('Updated at')
->sortable(),
])
@@ -68,12 +73,12 @@ class TagResource extends Resource
->relationship('organization', 'id')
->searchable(),
])
->actions([
Tables\Actions\EditAction::make(),
->recordActions([
EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
@@ -88,9 +93,9 @@ class TagResource extends Resource
public static function getPages(): array
{
return [
'index' => Pages\ListTags::route('/'),
'create' => Pages\CreateTag::route('/create'),
'edit' => Pages\EditTag::route('/{record}/edit'),
'index' => ListTags::route('/'),
'create' => CreateTag::route('/create'),
'edit' => EditTag::route('/{record}/edit'),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TagResource\Pages;
use App\Filament\Resources\TagResource;
use Filament\Actions;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditTag extends EditRecord
@@ -15,7 +15,7 @@ class EditTag extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TagResource\Pages;
use App\Filament\Resources\TagResource;
use Filament\Actions;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListTags extends ListRecords
@@ -15,7 +15,7 @@ class ListTags extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -4,13 +4,18 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TaskResource\Pages;
use App\Filament\Resources\TaskResource\Pages\CreateTask;
use App\Filament\Resources\TaskResource\Pages\EditTask;
use App\Filament\Resources\TaskResource\Pages\ListTasks;
use App\Models\Task;
use Filament\Forms;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
@@ -18,17 +23,17 @@ class TaskResource extends Resource
{
protected static ?string $model = Task::class;
protected static ?string $navigationIcon = 'heroicon-o-list-bullet';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
protected static ?string $navigationGroup = 'Timetracking';
protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 3;
public static function form(Form $form): Form
public static function form(Schema $schema): Schema
{
return $form
->schema([
Forms\Components\TextInput::make('name')
return $schema
->components([
TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
@@ -47,16 +52,16 @@ class TaskResource extends Resource
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('project.name')
TextColumn::make('project.name')
->sortable(),
Tables\Columns\TextColumn::make('organization.name')
TextColumn::make('organization.name')
->sortable(),
Tables\Columns\TextColumn::make('created_at')
TextColumn::make('created_at')
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
TextColumn::make('updated_at')
->sortable(),
])
->filters([
@@ -70,12 +75,12 @@ class TaskResource extends Resource
->searchable(),
])
->defaultSort('created_at', 'desc')
->actions([
Tables\Actions\EditAction::make(),
->recordActions([
EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
@@ -90,9 +95,9 @@ class TaskResource extends Resource
public static function getPages(): array
{
return [
'index' => Pages\ListTasks::route('/'),
'create' => Pages\CreateTask::route('/create'),
'edit' => Pages\EditTask::route('/{record}/edit'),
'index' => ListTasks::route('/'),
'create' => CreateTask::route('/create'),
'edit' => EditTask::route('/{record}/edit'),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TaskResource\Pages;
use App\Filament\Resources\TaskResource;
use Filament\Actions;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditTask extends EditRecord
@@ -15,7 +15,7 @@ class EditTask extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TaskResource\Pages;
use App\Filament\Resources\TaskResource;
use Filament\Actions;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListTasks extends ListRecords
@@ -15,7 +15,7 @@ class ListTasks extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -4,16 +4,20 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource\Pages\CreateTimeEntry;
use App\Filament\Resources\TimeEntryResource\Pages\EditTimeEntry;
use App\Filament\Resources\TimeEntryResource\Pages\ListTimeEntries;
use App\Models\Member;
use App\Models\TimeEntry;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
@@ -23,16 +27,16 @@ class TimeEntryResource extends Resource
{
protected static ?string $model = TimeEntry::class;
protected static ?string $navigationIcon = 'heroicon-o-clock';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static ?string $navigationGroup = 'Timetracking';
protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
public static function form(Schema $schema): Schema
{
return $form
->schema([
return $schema
->components([
TextInput::make('id')
->label('ID')
->readOnly()
@@ -111,12 +115,12 @@ class TimeEntryResource extends Resource
->searchable(),
])
->defaultSort('created_at', 'desc')
->actions([
Tables\Actions\EditAction::make(),
->recordActions([
EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
@@ -131,9 +135,9 @@ class TimeEntryResource extends Resource
public static function getPages(): array
{
return [
'index' => Pages\ListTimeEntries::route('/'),
'create' => Pages\CreateTimeEntry::route('/create'),
'edit' => Pages\EditTimeEntry::route('/{record}/edit'),
'index' => ListTimeEntries::route('/'),
'create' => CreateTimeEntry::route('/create'),
'edit' => EditTimeEntry::route('/{record}/edit'),
];
}
}

View File

@@ -6,7 +6,7 @@ namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use Filament\Actions;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditTimeEntry extends EditRecord
@@ -16,7 +16,7 @@ class EditTimeEntry extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource;
use Filament\Actions;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListTimeEntries extends ListRecords
@@ -15,7 +15,7 @@ class ListTimeEntries extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -4,12 +4,18 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TokenResource\Pages;
use App\Filament\Resources\TokenResource\Pages\ListTokens;
use App\Filament\Resources\TokenResource\Pages\ViewToken;
use App\Models\Passport\Token;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
@@ -18,48 +24,48 @@ class TokenResource extends Resource
{
protected static ?string $model = Token::class;
protected static ?string $navigationIcon = 'heroicon-o-key';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-key';
protected static ?string $navigationGroup = 'Auth';
protected static string|\UnitEnum|null $navigationGroup = 'Auth';
protected static ?int $navigationSort = 6;
public static function form(Form $form): Form
public static function form(Schema $schema): Schema
{
return $form
return $schema
->columns(1)
->schema([
Forms\Components\TextInput::make('id')
->components([
TextInput::make('id')
->label('ID')
->disabled()
->visibleOn(['update', 'show'])
->readOnly()
->maxLength(255),
Forms\Components\TextInput::make('name')
TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\Select::make('owner_id')
Select::make('owner_id')
->label('User')
->relationship(name: 'user', titleAttribute: 'name')
->searchable(['name'])
->disabled()
->required(),
Forms\Components\Select::make('client_id')
Select::make('client_id')
->label('Client')
->relationship(name: 'client', titleAttribute: 'name')
->searchable(['name'])
->required(),
Forms\Components\Toggle::make('revoked')
Toggle::make('revoked')
->label('Revoked')
->required(),
Forms\Components\DateTimePicker::make('expires_at')
DateTimePicker::make('expires_at')
->label('Expires At')
->disabled(),
Forms\Components\DateTimePicker::make('created_at')
DateTimePicker::make('created_at')
->label('Created At')
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
DateTimePicker::make('updated_at')
->label('Updated At')
->disabled(),
]);
@@ -69,32 +75,32 @@ class TokenResource extends Resource
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('user.name')
TextColumn::make('user.name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('client.name')
TextColumn::make('client.name')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('personal_access_client')
IconColumn::make('personal_access_client')
->state(function (Token $token): bool {
return in_array('personal_access', $token->client->grant_types ?? [], true);
})
->boolean()
->label('API token?'),
Tables\Columns\IconColumn::make('revoked')
IconColumn::make('revoked')
->boolean()
->label('Revoked?')
->sortable(),
Tables\Columns\TextColumn::make('expires_at')
TextColumn::make('expires_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
TextColumn::make('created_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
@@ -120,10 +126,10 @@ class TokenResource extends Resource
TernaryFilter::make('revoked')
->label('Revoked?'),
])
->actions([
Tables\Actions\ViewAction::make(),
->recordActions([
ViewAction::make(),
])
->bulkActions([
->toolbarActions([
]);
}
@@ -136,8 +142,8 @@ class TokenResource extends Resource
public static function getPages(): array
{
return [
'index' => Pages\ListTokens::route('/'),
'view' => Pages\ViewToken::route('/{record}'),
'index' => ListTokens::route('/'),
'view' => ViewToken::route('/{record}'),
];
}
}

View File

@@ -6,21 +6,30 @@ namespace App\Filament\Resources;
use App\Enums\Weekday;
use App\Exceptions\Api\ApiException;
use App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource\Pages\CreateUser;
use App\Filament\Resources\UserResource\Pages\EditUser;
use App\Filament\Resources\UserResource\Pages\ListUsers;
use App\Filament\Resources\UserResource\Pages\ViewUser;
use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager;
use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager;
use App\Models\User;
use App\Service\DeletionService;
use App\Service\TimezoneService;
use App\Service\UserService;
use Brick\Money\ISOCurrencyProvider;
use Brick\Money\IsoCurrencyProvider;
use Exception;
use Filament\Forms;
use Filament\Actions\BulkAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
@@ -28,26 +37,26 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use STS\FilamentImpersonate\Tables\Actions\Impersonate;
use STS\FilamentImpersonate\Actions\Impersonate;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static ?string $navigationIcon = 'heroicon-o-user';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-user';
protected static ?string $navigationGroup = 'Users';
protected static string|\UnitEnum|null $navigationGroup = 'Users';
protected static ?int $navigationSort = 6;
public static function form(Form $form): Form
public static function form(Schema $schema): Schema
{
/** @var User|null $record */
$record = $form->getRecord();
$record = $schema->getRecord();
return $form
return $schema
->columns(1)
->schema([
->components([
TextInput::make('id')
->label('ID')
->disabled()
@@ -69,23 +78,23 @@ class UserResource extends Resource
'email:rfc,strict',
])
->maxLength(255),
Forms\Components\Toggle::make('is_placeholder')
Toggle::make('is_placeholder')
->label('Is Placeholder?')
->hiddenOn(['create'])
->disabledOn(['edit']),
Forms\Components\DateTimePicker::make('email_verified_at')
DateTimePicker::make('email_verified_at')
->label('Email Verified At')
->hiddenOn(['create'])
->nullable(),
Forms\Components\Toggle::make('is_email_verified')
Toggle::make('is_email_verified')
->label('Email Verified?')
->visibleOn(['create']),
Forms\Components\Select::make('timezone')
Select::make('timezone')
->label('Timezone')
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
->searchable()
->required(),
Forms\Components\Select::make('week_start')
Select::make('week_start')
->label('Week Start')
->options(Weekday::class)
->required(),
@@ -103,10 +112,10 @@ class UserResource extends Resource
->visibleOn(['create'])
->required(fn (string $context): bool => $context === 'create')
->maxLength(255),
Forms\Components\Select::make('currency')
Select::make('currency')
->label('Currency (Personal Organization)')
->options(function (): array {
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
$currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies();
$select = [];
foreach ($currencies as $currency) {
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';
@@ -117,11 +126,11 @@ class UserResource extends Resource
->required()
->visibleOn(['create'])
->searchable(),
Forms\Components\DateTimePicker::make('created_at')
DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
@@ -132,25 +141,25 @@ class UserResource extends Resource
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('email')
TextColumn::make('email')
->icon('heroicon-m-envelope')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('is_real_user')
IconColumn::make('is_real_user')
->getStateUsing(fn (User $record): bool => ! $record->is_placeholder)
->label('Real user?')
->boolean(),
Tables\Columns\IconColumn::make('email_verified')
IconColumn::make('email_verified')
->getStateUsing(fn (User $record): bool => $record->email_verified_at !== null)
->label('Email verified?')
->boolean(),
Tables\Columns\TextColumn::make('created_at')
TextColumn::make('created_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
@@ -178,7 +187,7 @@ class UserResource extends Resource
->attribute('email_verified_at')
->nullable(),
])
->actions([
->recordActions([
Impersonate::make()->before(function (User $record): void {
if ($record->currentOrganization === null) {
$organization = $record->organizations()->where('personal_team', '=', true)->first();
@@ -191,8 +200,8 @@ class UserResource extends Resource
app(UserService::class)->switchCurrentOrganization($record, $organization);
}
}),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
EditAction::make(),
DeleteAction::make()
->hidden(fn (User $record) => $record->is(Auth::user()))
->using(function (User $record): void {
try {
@@ -207,8 +216,8 @@ class UserResource extends Resource
}
}),
])
->bulkActions([
Tables\Actions\BulkAction::make('Resend verification email')
->toolbarActions([
BulkAction::make('Resend verification email')
->icon('heroicon-o-paper-airplane')
->action(function (Collection $records): void {
foreach ($records as $user) {
@@ -230,10 +239,10 @@ class UserResource extends Resource
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'),
'view' => Pages\ViewUser::route('/{record}'),
'index' => ListUsers::route('/'),
'create' => CreateUser::route('/create'),
'edit' => EditUser::route('/{record}/edit'),
'view' => ViewUser::route('/{record}'),
];
}
}

View File

@@ -22,7 +22,7 @@ class CreateUser extends CreateRecord
$data['email'],
$data['password_create'],
$data['timezone'],
Weekday::from($data['week_start']),
$data['week_start'] instanceof Weekday ? $data['week_start'] : Weekday::from($data['week_start']),
$data['currency'],
verifyEmail: (bool) $data['is_email_verified']
);

View File

@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use App\Filament\Resources\UserResource\Actions\DeleteUser;
use Filament\Resources\Pages\EditRecord;
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
use STS\FilamentImpersonate\Actions\Impersonate;
class EditUser extends EditRecord
{
@@ -16,7 +17,7 @@ class EditUser extends EditRecord
{
return [
Impersonate::make()->record($this->getRecord()),
UserResource\Actions\DeleteUser::make(),
DeleteUser::make(),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
@@ -15,7 +15,7 @@ class ListUsers extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -7,7 +7,7 @@ namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
use STS\FilamentImpersonate\Actions\Impersonate;
class ViewUser extends ViewRecord
{

View File

@@ -11,12 +11,13 @@ use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Service\MemberService;
use Filament\Actions\Action;
use Filament\Actions\DetachAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -24,10 +25,10 @@ class OrganizationsRelationManager extends RelationManager
{
protected static string $relationship = 'organizations';
public function form(Form $form): Form
public function form(Schema $schema): Schema
{
return $form
->schema([
return $schema
->components([
Select::make('role')
->options(Role::class),
]);
@@ -46,21 +47,22 @@ class OrganizationsRelationManager extends RelationManager
])
->headerActions([
])
->actions([
->recordActions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [
'record' => $record->getKey(),
])),
Tables\Actions\EditAction::make()
EditAction::make()
->using(function (Organization $record, array $data): Organization {
/** @var Member $member */
$member = $record->getRelation('membership');
if ($data['role'] !== $member->role) {
$newRole = $data['role'] instanceof Role ? $data['role'] : Role::from($data['role']);
if ($newRole->value !== $member->role) {
try {
app(MemberService::class)->changeRole($member, $record, Role::from($data['role']), true);
app(MemberService::class)->changeRole($member, $record, $newRole, true);
} catch (ApiException $exception) {
Notification::make()
->danger()
@@ -74,7 +76,7 @@ class OrganizationsRelationManager extends RelationManager
return $record;
}),
Tables\Actions\DetachAction::make()
DetachAction::make()
->using(function (Organization $record): void {
/** @var User $user */
$user = $this->getOwnerRecord();
@@ -94,7 +96,7 @@ class OrganizationsRelationManager extends RelationManager
}
}),
])
->bulkActions([
->toolbarActions([
]);
}
}

View File

@@ -6,10 +6,10 @@ namespace App\Filament\Resources\UserResource\RelationManagers;
use App\Filament\Resources\OrganizationResource;
use App\Models\Organization;
use Filament\Forms\Form;
use Filament\Actions\Action;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class OwnedOrganizationsRelationManager extends RelationManager
@@ -18,10 +18,10 @@ class OwnedOrganizationsRelationManager extends RelationManager
protected static string $relationship = 'ownedOrganizations';
public function form(Form $form): Form
public function form(Schema $schema): Schema
{
return $form
->schema([
return $schema
->components([
]);
}
@@ -30,14 +30,14 @@ class OwnedOrganizationsRelationManager extends RelationManager
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('name'),
TextColumn::make('name'),
])
->filters([
//
])
->headerActions([
])
->actions([
->recordActions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
@@ -51,7 +51,7 @@ class OwnedOrganizationsRelationManager extends RelationManager
]))
->openUrlInNewTab(),
])
->bulkActions([
->toolbarActions([
]);
}
}

View File

@@ -9,7 +9,7 @@ use Illuminate\Support\Facades\Cache;
class ServerOverview extends Widget
{
protected static string $view = 'filament.widgets.server-overview';
protected string $view = 'filament.widgets.server-overview';
/**
* @return array<string, mixed>

View File

@@ -11,7 +11,7 @@ use Flowframe\Trend\TrendValue;
class TimeEntriesCreated extends ChartWidget
{
protected static ?string $heading = 'Time Entries Created';
protected ?string $heading = 'Time Entries Created';
public ?string $filter = 'week';
@@ -53,7 +53,7 @@ class TimeEntriesCreated extends ChartWidget
return [
'datasets' => [
[
'label' => self::$heading,
'label' => $this->heading,
'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
],
],

View File

@@ -11,7 +11,7 @@ use Flowframe\Trend\TrendValue;
class TimeEntriesImported extends ChartWidget
{
protected static ?string $heading = 'Time Entries Imported';
protected ?string $heading = 'Time Entries Imported';
public ?string $filter = 'week';
@@ -53,7 +53,7 @@ class TimeEntriesImported extends ChartWidget
return [
'datasets' => [
[
'label' => self::$heading,
'label' => $this->heading,
'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
],
],

View File

@@ -11,7 +11,7 @@ use Flowframe\Trend\TrendValue;
class UserRegistrations extends ChartWidget
{
protected static ?string $heading = 'User Registrations';
protected ?string $heading = 'User Registrations';
public ?string $filter = 'week';
@@ -54,7 +54,7 @@ class UserRegistrations extends ChartWidget
return [
'datasets' => [
[
'label' => self::$heading,
'label' => $this->heading,
'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
],
],

View File

@@ -14,6 +14,7 @@ use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
use RuntimeException;
class ApiTokenController extends Controller
{
@@ -62,7 +63,7 @@ class ApiTokenController extends Controller
$tokenModel = $token->getToken();
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
} catch (\RuntimeException $exception) {
} catch (RuntimeException $exception) {
report($exception);
if (Str::contains($exception->getMessage(), ['Personal access client not found'])) {
throw new PersonalAccessClientIsNotConfiguredException;

View File

@@ -7,7 +7,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Service\CurrencyService;
use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider;
use Brick\Money\IsoCurrencyProvider;
use Illuminate\Http\JsonResponse;
class CurrencyController extends Controller
@@ -29,7 +29,7 @@ class CurrencyController extends Controller
'name' => $currency->getName(),
'symbol' => $currencyService->getCurrencySymbol($currency->getCurrencyCode()),
],
ISOCurrencyProvider::getInstance()->getAvailableCurrencies()
IsoCurrencyProvider::getInstance()->getAvailableCurrencies()
));
return response()->json($currencies);

View File

@@ -28,7 +28,9 @@ class ExportController extends Controller
$filepath = $exportService->export($organization);
$downloadUrl = Storage::disk(config('filesystems.private'))
->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
->temporaryUrl($filepath, Carbon::now()->addMinutes(10), [
'ResponseContentDisposition' => 'attachment; filename="'.basename($filepath).'"',
]);
return new JsonResponse([
'success' => true,

View File

@@ -33,6 +33,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
class MemberController extends Controller
{
@@ -146,7 +147,7 @@ class MemberController extends Controller
*
* @throws AuthorizationException
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws \Throwable
* @throws Throwable
*
* @operationId mergeMember
*/

View File

@@ -55,6 +55,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use LogicException;
use Maatwebsite\Excel\Facades\Excel;
use Spatie\TemporaryDirectory\TemporaryDirectory;
@@ -190,8 +191,9 @@ class TimeEntryController extends Controller
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
if ($roundingType !== null && $roundingMinutes !== null) {
$select = array_diff($select, ['start', 'end']);
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start');
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end');
// These SQL fragments are generated from a rounding enum and validated minute count.
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start'); // @phpstan-ignore argument.type
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end'); // @phpstan-ignore argument.type
}
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
@@ -265,7 +267,7 @@ class TimeEntryController extends Controller
}
$viewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf.blade.php'));
if ($viewFile === false) {
throw new \LogicException('View file not found');
throw new LogicException('View file not found');
}
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
@@ -293,7 +295,7 @@ class TimeEntryController extends Controller
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));
if ($footerViewFile === false) {
throw new \LogicException('View file not found');
throw new LogicException('View file not found');
}
$footerHtml = Blade::render($footerViewFile);
if ($debug) {
@@ -336,7 +338,9 @@ class TimeEntryController extends Controller
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)),
->temporaryUrl($path, now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
]),
]);
}
@@ -493,7 +497,7 @@ class TimeEntryController extends Controller
]);
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf.blade.php'));
if ($viewFile === false) {
throw new \LogicException('View file not found');
throw new LogicException('View file not found');
}
$html = Blade::render($viewFile, [
'aggregatedData' => $aggregatedData,
@@ -510,7 +514,7 @@ class TimeEntryController extends Controller
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));
if ($footerViewFile === false) {
throw new \LogicException('View file not found');
throw new LogicException('View file not found');
}
$footerHtml = Blade::render($footerViewFile);
if ($debug) {
@@ -535,7 +539,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate, $localizationService),
$path,
config('filesystems.private'),
$format->getExportPackageType(),
@@ -547,7 +551,9 @@ class TimeEntryController extends Controller
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)),
->temporaryUrl($path, now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
]),
]);
}

View File

@@ -124,6 +124,10 @@ class UserController extends Controller
$user->week_start = $request->getWeekStart();
}
if ($request->getSendTimeEntryStillRunningEmail() !== null) {
$user->send_time_entry_still_running_email = $request->getSendTimeEntryStillRunningEmail();
}
$user->save();
if ($emailToVerify !== null) {

View File

@@ -6,7 +6,7 @@ namespace App\Http\Controllers\Web;
use App\Models\Organization;
use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider;
use Brick\Money\IsoCurrencyProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@@ -53,7 +53,7 @@ class OrganizationController extends Controller
],
'currencies' => array_map(function (Currency $currency): string {
return $currency->getName();
}, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()),
}, IsoCurrencyProvider::getInstance()->getAvailableCurrencies()),
'permissions' => [
'canDeleteTeam' => $this->hasPermission($organization, 'organizations:delete'),
'canUpdateTeam' => $this->hasPermission($organization, 'organizations:update'),

View File

@@ -36,6 +36,7 @@ class OrganizationInvitationController extends Controller
}
return redirect(route('register'))
->with('registration_email', $email)
->with('bannerText', __('Please create an account to finish joining the :organization organization.', [
'organization' => $organization->name,
]))

View File

@@ -16,6 +16,7 @@ use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Features;
use stdClass;
class UserProfileController extends Controller
{
@@ -103,7 +104,7 @@ class UserProfileController extends Controller
/**
* Get the current sessions.
*
* @return array<int, object{agent: array{is_desktop: bool, platform: string|null, browser: string|null}, ip_address: string, is_current_device: bool, last_active: string}&\stdClass>
* @return array<int, object{agent: array{is_desktop: bool, platform: string|null, browser: string|null}, ip_address: string, is_current_device: bool, last_active: string}&stdClass>
*/
public function sessions(Request $request): array
{

View File

@@ -11,6 +11,7 @@ use App\Http\Middleware\EnsureEmailIsVerified;
use App\Http\Middleware\ForceHttps;
use App\Http\Middleware\ForceJsonResponse;
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\PreventRequestForgery;
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\ShareInertiaData;
@@ -18,7 +19,6 @@ use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts;
use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\ValidateSignature;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Auth\Middleware\RequirePassword;
@@ -68,7 +68,7 @@ class Kernel extends HttpKernel
AddQueuedCookiesToResponse::class,
StartSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
PreventRequestForgery::class,
SubstituteBindings::class,
HandleInertiaRequests::class,
ShareInertiaData::class,

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Filament\Http\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
class AuthenticateFilamentPanel extends Middleware
{
/**
* The admin panel has no login page of its own (it shares the app's
* session-based login), so Filament::getLoginUrl() always returns null.
* Fall back to the app's login route so guests are redirected instead
* of receiving an empty 401 response.
*/
protected function redirectTo($request): ?string
{
return parent::redirectTo($request) ?? ($request->expectsJson() ? null : route('login'));
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\Organization;
use App\Service\BillingContract;
use Closure;
use Illuminate\Http\Request;
use LogicException;
use Symfony\Component\HttpFoundation\Response;
class CheckOrganizationBlocked
@@ -25,7 +26,7 @@ class CheckOrganizationBlocked
$organization = $request->route('organization');
if (! ($organization instanceof Organization)) {
throw new \LogicException('The organization must be loaded before this middleware.');
throw new LogicException('The organization must be loaded before this middleware.');
}
/** @var BillingContract $billing */

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery as Middleware;
class PreventRequestForgery extends Middleware
{
/**
* The URIs that should be excluded.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Middleware\TrustHosts as BaseTrustHosts;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@@ -39,9 +40,9 @@ class TrustHosts extends BaseTrustHosts
}
/**
* @param \Closure(Request): Response $next
* @param Closure(Request):Response $next
*/
public function handle(Request $request, $next): Response
public function handle(Request $request, $next)
{
// Exempt health checks (probed by IP). Also reset the trusted hosts,
// since Octane leaks the static state across requests.

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

Some files were not shown because too many files have changed in this diff Show More