Compare commits

..

43 Commits

Author SHA1 Message Date
Constantin Graf
fde944a84f Updated extensions 2026-10-07 18:43:16 +02:00
Constantin Graf
34273a4863 Add audit owner through parent model
Models that belong to an organization via a parent model (for example
project members via their project) implement AuditableThroughParent and
return the parent relation. The owner organization of their audits is
taken from the loaded parent if it matches the foreign key, otherwise it
is queried. The declaration is also used to backfill the owner of
existing audits.
2026-10-07 18:43:16 +02:00
Constantin Graf
6fe721fc26 Updated extensions to audit improvement branches 2026-10-07 18:43:16 +02:00
Constantin Graf
f921452419 Add marker for auditable models without owner
Models implementing AuditableWithoutOwner intentionally have no audit
owner, either because they belong to neither an organization nor a user,
or because they and their audits have to be kept when the owner is
deleted (for example billing records). Adds helpers and a scope on the
audit model to find audits that are missing an owner although they
should have one.
2026-10-07 18:43:16 +02:00
Constantin Graf
30a90f80e0 Updated extensions to audit improvement branches 2026-10-07 18:43:16 +02:00
Constantin Graf
135984f9ef Add owner organization and owner user to audits
- Rename the audit actor columns user_type/user_id to actor_type/actor_id
- Add owner_organization_id and owner_user_id to the audits table as
  foreign keys with cascade on delete, so that the audits of an
  organization or user are deleted together with it. The indexes and
  foreign keys are created without blocking writes on the large table.
- Fill the owner columns for new audits via CustomAuditable. Organizations
  and users no longer record their own deletion audit, since it would
  reference the already deleted owner.
2026-10-07 18:43:16 +02:00
Constantin Graf
af54b0db73 Updated auditing extension 2026-10-07 18:43:16 +02:00
Constantin Graf
3fc2cec751 Updated services extension 2026-10-07 18:43:16 +02:00
Constantin Graf
32ac8841bc Remove debug output from TrustHostsTest 2026-10-07 18:28:29 +02:00
Constantin Graf
1582e6f4c3 Generate profile photo in user factory locally
Faker's image() downloads the image from via.placeholder.com, which no
longer exists. Since the domain resolves again but does not respond,
the download (without timeout) hangs and the PHPUnit runs time out.
The profile photo is now generated locally with GD, which also stores a
real image instead of the temporary file path returned by image().
2026-10-07 18:28:29 +02:00
Constantin Graf
01281d80d0 Rename DB_SSLMODE env to DB_SSL_MODE and add DATABASE_URL fallback
The Laravel 13 config update made sslmode read DB_SSLMODE, which the
self-hosting examples set to require, breaking instances whose database
does not support SSL. Use DB_SSL_MODE instead so existing values are
ignored again, and fall back to DATABASE_URL when DB_URL is not set.
2026-10-07 16:23:11 +02:00
Gregor Vostrak
bb5a7fb9f9 improve API docs 2026-10-05 17:27:04 +02:00
Constantin Graf
349623d537 Reverted “Updated UUID generation” 2026-09-24 13:31:26 +02:00
Constantin Graf
a9d016d773 Added deletion of refresh tokens in DeletionService 2026-09-24 13:31:26 +02:00
Constantin Graf
2ebbbceefb Updated billing extension 2026-09-24 13:31:26 +02:00
Gregor Vostrak
e85058aefd add 1password ignore attributes for time tracker component inputs 2026-09-24 13:28:48 +02:00
Constantin Graf
a86c18ad2d Prevent non-primary mouse buttons from resizing events 2026-09-24 11:55:32 +02:00
dependabot[bot]
f683c03ff9 Bump the minor-updates group across 1 directory with 5 updates
Bumps the minor-updates group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [laravel/fortify](https://github.com/laravel/fortify) | `1.39.0` | `1.40.0` |
| [laravel/framework](https://github.com/laravel/framework) | `13.32.0` | `13.33.0` |
| [laravel/octane](https://github.com/laravel/octane) | `2.19.1` | `2.20.0` |
| [laravel/sail](https://github.com/laravel/sail) | `1.67.0` | `1.68.0` |
| [laravel/telescope](https://github.com/laravel/telescope) | `5.24.0` | `5.25.0` |



Updates `laravel/fortify` from 1.39.0 to 1.40.0
- [Release notes](https://github.com/laravel/fortify/releases)
- [Changelog](https://github.com/laravel/fortify/blob/1.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/fortify/compare/v1.39.0...v1.40.0)

Updates `laravel/framework` from 13.32.0 to 13.33.0
- [Release notes](https://github.com/laravel/framework/releases)
- [Changelog](https://github.com/laravel/framework/blob/13.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/framework/compare/v13.32.0...v13.33.0)

Updates `laravel/octane` from 2.19.1 to 2.20.0
- [Release notes](https://github.com/laravel/octane/releases)
- [Changelog](https://github.com/laravel/octane/blob/2.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/octane/compare/v2.19.1...v2.20.0)

Updates `laravel/sail` from 1.67.0 to 1.68.0
- [Release notes](https://github.com/laravel/sail/releases)
- [Changelog](https://github.com/laravel/sail/blob/1.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/sail/compare/v1.67.0...v1.68.0)

Updates `laravel/telescope` from 5.24.0 to 5.25.0
- [Release notes](https://github.com/laravel/telescope/releases)
- [Changelog](https://github.com/laravel/telescope/blob/5.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/telescope/compare/v5.24.0...v5.25.0)

---
updated-dependencies:
- dependency-name: laravel/fortify
  dependency-version: 1.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/framework
  dependency-version: 13.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/octane
  dependency-version: 2.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/sail
  dependency-version: 1.68.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/telescope
  dependency-version: 5.25.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-24 11:35:35 +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
221 changed files with 6868 additions and 2886 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

@@ -88,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

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.5.2
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.5.2
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.5.2
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.5.2
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.5.2
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.5.2
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.5.2
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

@@ -7,6 +7,7 @@ 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.
@@ -26,7 +27,7 @@ class ActiveUserProvider extends EloquentUserProvider
* @param Model|null $model
* @return Builder<Model>
*/
#[\Override]
#[Override]
protected function newModelQuery($model = null): Builder
{
$query = parent::newModelQuery($model);

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

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Exceptions\Api;
use Override;
class EntityStillInUseApiException extends ApiException
{
private string $modelToDelete;
@@ -32,7 +34,7 @@ class EntityStillInUseApiException extends ApiException
/**
* Get the translated message for the exception.
*/
#[\Override]
#[Override]
public function getTranslatedMessage(): string
{
return __('exceptions.api.'.$this->getKey(), [

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

@@ -88,6 +88,8 @@ class InvitationController extends Controller
/**
* Remove a pending invitation
*
* This revokes the invitation: the link in the invitation email stops working. Find the invitation ID with `GET /organizations/{organization}/invitations`.
*
* @throws AuthorizationException
*
* @operationId removeInvitation

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
{
@@ -144,9 +145,12 @@ class MemberController extends Controller
/**
* Merge one member into another
*
* Only placeholder members (for example people created by an import) can be merged. All time entries and other data of the placeholder
* are reassigned to the member given in `member_id`, and the placeholder is removed. Find both member IDs with `GET /organizations/{organization}/members`.
*
* @throws AuthorizationException
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws \Throwable
* @throws Throwable
*
* @operationId mergeMember
*/

View File

@@ -71,6 +71,8 @@ class ReportController extends Controller
/**
* Create report
*
* A report is a saved set of filters. Set `is_public` to `true` to share it: the response then contains the `shareable_link` that can be opened without logging in.
*
* @throws AuthorizationException
*
* @operationId createReport

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;
@@ -108,9 +109,14 @@ class TimeEntryController extends Controller
/**
* Get time entries in organization
*
* If you only need time entries for a specific user, you can filter by `member_id`.
* Without a member filter this returns the time entries of all members of the organization (for users who may view all time entries, such as owners and admins), not only your own.
* To get only your own time entries, pass your member ID as `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
*
* The `start` and `end` filters both apply to the start time of an entry, in UTC. Convert the user's local day boundaries to UTC first.
* Results are paginated with `limit` (default 100, max 500) and `offset`; check `meta.total` and fetch further pages when needed.
* To find the running timer, use `active=true` (or `GET /v1/users/me/time-entries/active`).
*
* @return TimeEntryCollection<TimeEntryResource>
*
* @throws AuthorizationException
@@ -190,8 +196,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 +272,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 +300,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) {
@@ -349,6 +356,11 @@ class TimeEntryController extends Controller
* The parameters `group` and `sub_group` allow you to group the time entries by different criteria.
* If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries.
*
* Durations are returned in `seconds` (divide by 3600 for hours) and amounts in `cost` as cents in the organization's currency (divide by 100 for money).
* Filter by member with `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID.
* Array filters use the query format `client_ids[]=<id>`.
* Example: billable hours per client for a period: `group=client&billable=true&start=...&end=...`.
*
* @operationId getAggregatedTimeEntries
*
* @return array{
@@ -495,7 +507,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,
@@ -512,7 +524,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) {
@@ -582,6 +594,12 @@ class TimeEntryController extends Controller
/**
* Create time entry
*
* `billable` is not taken from the project. To match the web app, set it to the project's `is_billable` value (or `false` without a project).
*
* A member can only have one running time entry (an entry with `end` set to `null`). Creating a running entry while another one runs fails with `time_entry_still_running`.
* To start a new timer, first stop the running entry by updating its `end` to the current time, then create the new entry.
* To log past work, send both `start` and `end` (UTC). Create one entry per block of work.
*
* @throws AuthorizationException
* @throws TimeEntryStillRunningApiException
*
@@ -632,6 +650,8 @@ class TimeEntryController extends Controller
/**
* Update time entry
*
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
*
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
*
* @operationId updateTimeEntry
@@ -700,6 +720,10 @@ class TimeEntryController extends Controller
/**
* Update multiple time entries
*
* Applies the same `changes` to every entry in `ids`. To find the IDs, list entries with `GET /organizations/{organization}/time-entries`
* (filtered by `member_id`, the project and the other criteria), then send their IDs here.
* When `changes.project_id` moves entries to another project, also set `changes.task_id` to a task of the new project or to `null`, because tasks belong to a project.
*
* @operationId updateMultipleTimeEntries
*
* @throws AuthorizationException

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

@@ -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,7 +40,7 @@ class TrustHosts extends BaseTrustHosts
}
/**
* @param \Closure(Request): Response $next
* @param Closure(Request):Response $next
*/
public function handle(Request $request, $next)
{

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 = [
//
];
}

View File

@@ -27,6 +27,7 @@ class MemberUpdateRequest extends BaseFormRequest
'string',
Rule::enum(Role::class),
],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge(
[
'nullable',

View File

@@ -36,6 +36,7 @@ class OrganizationUpdateRequest extends BaseFormRequest
'string',
new CurrencyRule,
],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge(
[
'nullable',

View File

@@ -55,6 +55,7 @@ class ProjectStoreRequest extends BaseFormRequest
'required',
'boolean',
],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge(
[
'nullable',

View File

@@ -68,6 +68,7 @@ class ProjectUpdateRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge([
'nullable',
],

View File

@@ -31,6 +31,7 @@ class ProjectMemberStoreRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge(
[
'nullable',

View File

@@ -21,6 +21,7 @@ class ProjectMemberUpdateRequest extends BaseFormRequest
public function rules(): array
{
return [
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge(
[
'nullable',

View File

@@ -13,11 +13,13 @@ use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Service\TimeEntryFilter;
use Closure;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use LogicException;
/**
* @property Organization $organization Organization from model binding
@@ -27,7 +29,7 @@ class ReportStoreRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
* @return array<string, array<string|ValidationRule|LegacyValidationRule|Closure>>
*/
public function rules(): array
{
@@ -85,7 +87,7 @@ class ReportStoreRequest extends BaseFormRequest
],
'properties.client_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -101,7 +103,7 @@ class ReportStoreRequest extends BaseFormRequest
],
'properties.project_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -117,7 +119,7 @@ class ReportStoreRequest extends BaseFormRequest
],
'properties.tag_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -137,7 +139,7 @@ class ReportStoreRequest extends BaseFormRequest
],
'properties.task_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -213,7 +215,7 @@ class ReportStoreRequest extends BaseFormRequest
{
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.start'));
if ($start === null) {
throw new \LogicException('Start date validation is not working');
throw new LogicException('Start date validation is not working');
}
return $start;
@@ -223,7 +225,7 @@ class ReportStoreRequest extends BaseFormRequest
{
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.end'));
if ($end === null) {
throw new \LogicException('End date validation is not working');
throw new LogicException('End date validation is not working');
}
return $end;

View File

@@ -19,11 +19,13 @@ use App\Models\Tag;
use App\Models\Task;
use App\Models\User;
use App\Service\TimeEntryFilter;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use LogicException;
/**
* @property Organization $organization
@@ -33,7 +35,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|Closure>>
*/
public function rules(): array
{
@@ -97,7 +99,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
],
'project_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -114,7 +116,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
],
'client_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -131,7 +133,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
],
'tag_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -152,7 +154,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
],
'task_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -236,7 +238,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
{
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
if ($start === null) {
throw new \LogicException('Start date validation is not working');
throw new LogicException('Start date validation is not working');
}
return $start;
@@ -246,7 +248,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
{
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
if ($end === null) {
throw new \LogicException('End date validation is not working');
throw new LogicException('End date validation is not working');
}
return $end;

View File

@@ -17,6 +17,7 @@ use App\Models\Tag;
use App\Models\Task;
use App\Models\User;
use App\Service\TimeEntryFilter;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
@@ -31,7 +32,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|Closure>>
*/
public function rules(): array
{
@@ -83,7 +84,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest
],
'project_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -100,7 +101,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest
],
'client_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -117,7 +118,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest
],
'tag_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -138,7 +139,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest
],
'task_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}

View File

@@ -15,11 +15,13 @@ use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\TimeEntryFilter;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use LogicException;
/**
* @property Organization $organization
@@ -29,7 +31,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|Closure>>
*/
public function rules(): array
{
@@ -68,7 +70,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
],
'client_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -85,7 +87,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
],
'project_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -102,7 +104,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
],
'tag_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -123,7 +125,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
],
'task_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -200,7 +202,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
{
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
if ($start === null) {
throw new \LogicException('Start date validation is not working');
throw new LogicException('Start date validation is not working');
}
return $start;
@@ -210,7 +212,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
{
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
if ($end === null) {
throw new \LogicException('End date validation is not working');
throw new LogicException('End date validation is not working');
}
return $end;

View File

@@ -15,6 +15,7 @@ use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\TimeEntryFilter;
use Closure;
use Illuminate\Contracts\Validation\Rule as RuleContract;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
@@ -29,12 +30,12 @@ class TimeEntryIndexRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|RuleContract|\Closure>>
* @return array<string, array<string|ValidationRule|RuleContract|Closure>>
*/
public function rules(): array
{
return [
// Filter by member ID
// Filter by member ID. Without it, users who may view all time entries (owners, admins) get the entries of every member; pass your own member ID (from GET /v1/users/me/memberships) to get only yours
'member_id' => [
'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
@@ -61,7 +62,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
],
'client_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -78,7 +79,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
],
'project_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -95,7 +96,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
],
'tag_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -116,7 +117,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
],
'task_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -154,7 +155,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string',
Rule::enum(TimeEntryType::class),
],
// Limit the number of returned time entries (default: 150)
// Limit the number of returned time entries (default: 100)
'limit' => [
'integer',
'min:1',

View File

@@ -12,6 +12,7 @@ use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\PermissionStore;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
@@ -26,12 +27,12 @@ class TimeEntryStoreRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|\Closure|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
* @return array<string, array<string|Closure|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
return [
// ID of the organization member that the time entry should belong to
// ID of the organization member that the time entry should belong to (a member ID from GET /v1/users/me/memberships or the members list, not a user ID)
'member_id' => [
'required',
'string',
@@ -85,7 +86,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'date_format:Y-m-d\TH:i:s\Z',
'after_or_equal:start',
],
// Whether time entry is billable
// Whether time entry is billable. Not derived from the project: set it to the project's is_billable value to match the web app
'billable' => [
'required',
'boolean',
@@ -94,7 +95,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
// Type of the time entry (work time or a break)
'type' => [
Rule::enum(TimeEntryType::class),
function (string $attribute, mixed $value, \Closure $fail): void {
function (string $attribute, mixed $value, Closure $fail): void {
if ($value === TimeEntryType::Break->value && ! $this->organization->breaks_enabled) {
$fail('Breaks are disabled for this organization.');
}

View File

@@ -13,6 +13,7 @@ use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\PermissionStore;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
@@ -29,7 +30,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|\Closure|ValidationRule|\Illuminate\Contracts\Validation\Rule|ProhibitedIf|ConditionalRules>>
* @return array<string, array<string|Closure|ValidationRule|\Illuminate\Contracts\Validation\Rule|ProhibitedIf|ConditionalRules>>
*/
public function rules(): array
{
@@ -105,7 +106,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
// Type of the time entry (work time or a break)
'type' => [
Rule::enum(TimeEntryType::class),
function (string $attribute, mixed $value, \Closure $fail) use ($timeEntry): void {
function (string $attribute, mixed $value, Closure $fail) use ($timeEntry): void {
// While breaks are disabled, entries that already are breaks may stay
// breaks, but converting a work entry to a break is not allowed.
if ($value === TimeEntryType::Break->value

View File

@@ -4,15 +4,19 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\AuditableWithoutOwner;
use Database\Factories\AuditFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Models\Audit as PackageAuditModel;
/**
* @property int $id
* @property string|null $user_type
* @property string|null $user_id
* @property string|null $actor_type
* @property string|null $actor_id
* @property string $event
* @property string $auditable_type
* @property string $auditable_id
@@ -22,13 +26,71 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
* @property string|null $ip_address
* @property string|null $user_agent
* @property string|null $tags
* @property string|null $owner_user_id
* @property string|null $owner_organization_id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read User|null $ownerUser
* @property-read Organization|null $ownerOrganization
*
* @method static AuditFactory factory()
* @method static Builder<Audit> whereMissingOwner()
*/
class Audit extends PackageAuditModel
{
/** @use HasFactory<AuditFactory> */
use HasFactory;
/**
* @return BelongsTo<User, $this>
*/
public function ownerUser(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
/**
* @return BelongsTo<Organization, $this>
*/
public function ownerOrganization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'owner_organization_id');
}
/**
* Whether audits of the given auditable type intentionally have no owner (see AuditableWithoutOwner).
*/
public static function isAuditableTypeWithoutOwner(string $auditableType): bool
{
$modelClass = Relation::getMorphedModel($auditableType) ?? $auditableType;
return is_subclass_of($modelClass, AuditableWithoutOwner::class);
}
/**
* Auditable types (morph aliases) whose audits intentionally have no owner (see AuditableWithoutOwner).
*
* @return array<int, string>
*/
public static function getAuditableTypesWithoutOwner(): array
{
return collect(Relation::morphMap())
->filter(fn (string $modelClass): bool => is_subclass_of($modelClass, AuditableWithoutOwner::class))
->keys()
->values()
->all();
}
/**
* Audits that have neither an owner organization nor an owner user, although their auditable type should have one.
* These are audits whose owner no longer exists or could not be determined (yet).
*
* @param Builder<Audit> $builder
*/
public function scopeWhereMissingOwner(Builder $builder): void
{
$builder->whereNull('owner_organization_id')
->whereNull('owner_user_id')
->whereNotIn('auditable_type', self::getAuditableTypesWithoutOwner());
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Marks an auditable model whose audits are owned by the owner of its parent model,
* because the model itself has no organization_id (for example a project member belongs to an organization via its project).
* The parent model has to have an organization_id column.
* This is used to set the owner of new audits (CustomAuditable) and to backfill the owner of existing audits.
*/
interface AuditableThroughParent
{
/**
* @return BelongsTo<covariant Model, covariant Model>
*/
public function getAuditParentRelation(): BelongsTo;
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
/**
* Marks an auditable model whose audits intentionally have no owner (neither an organization nor a user),
* either because the model belongs to neither of them, or because the model and its audits have to be kept
* when its organization or user is deleted (for example billing records).
* Audits of these models are not deleted together with an organization or user,
* and are not considered as missing an owner (for example by the audit backfill command).
*/
interface AuditableWithoutOwner {}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Auditable;
trait CustomAuditable
@@ -19,4 +21,92 @@ trait CustomAuditable
{
$this->auditEvents = [];
}
/**
* The organization that owns the audited model.
* The audits of the model are deleted (via foreign key cascade) when the organization is deleted.
*/
public function getAuditOwnerOrganizationId(): ?string
{
if ($this instanceof AuditableThroughParent) {
$relation = $this->getAuditParentRelation();
$parentId = $this->getAttributes()[$relation->getForeignKeyName()] ?? null;
if ($parentId === null) {
return null;
}
// Note: The loaded parent is only used if it still matches the foreign key and was loaded with the organization
$relationName = $relation->getRelationName();
if ($this->relationLoaded($relationName)) {
$parent = $this->getRelation($relationName);
if ($parent instanceof Model
&& $parent->getAttribute($relation->getOwnerKeyName()) === $parentId
&& array_key_exists('organization_id', $parent->getAttributes())) {
/** @var string|null $organizationId */
$organizationId = $parent->getAttributes()['organization_id'];
return $organizationId;
}
}
/** @var string|null $organizationId */
$organizationId = $relation->getRelated()->newQuery()
->toBase()
->where($relation->getOwnerKeyName(), $parentId)
->value('organization_id');
return $organizationId;
}
return $this->getAttributes()['organization_id'] ?? null;
}
/**
* The user that owns the audited model.
* The audits of the model are deleted (via foreign key cascade) when the user is deleted.
*/
public function getAuditOwnerUserId(): ?string
{
return null;
}
/**
* Models that are the owner of their own audits can not record the deletion audit,
* since the audit would reference the already deleted model and therefore violate the foreign key.
*/
protected function isAuditOwnerOfItself(): bool
{
return false;
}
/**
* @return array<int|string, string>
*/
public function getAuditEvents(): array
{
$events = $this->auditEvents ?? Config::get('audit.events', [
'created',
'updated',
'deleted',
'restored',
]);
if ($this->isAuditOwnerOfItself()) {
$events = array_filter($events, fn (string $value, int|string $key): bool => (is_int($key) ? $value : $key) !== 'deleted', ARRAY_FILTER_USE_BOTH);
}
return $events;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function transformAudit(array $data): array
{
$data['owner_organization_id'] = $this->getAuditOwnerOrganizationId();
$data['owner_user_id'] = $this->getAuditOwnerUserId();
return $data;
}
}

View File

@@ -19,7 +19,6 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
@@ -97,10 +96,20 @@ class Organization extends Model implements AuditableContract
protected $attributes = [
];
public function getAuditOwnerOrganizationId(): ?string
{
return $this->getKey();
}
protected function isAuditOwnerOfItself(): bool
{
return true;
}
/**
* Get all the users that belong to the team.
*
* @return BelongsToMany<User, $this, Pivot, 'membership'>
* @return BelongsToMany<User, $this, Member, 'membership'>
*/
public function users(): BelongsToMany
{
@@ -133,7 +142,7 @@ class Organization extends Model implements AuditableContract
}
/**
* @return BelongsToMany<User, $this, Pivot, 'membership'>
* @return BelongsToMany<User, $this, Member, 'membership'>
*/
public function realUsers(): BelongsToMany
{

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\AuditableThroughParent;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\ProjectMemberFactory;
@@ -29,7 +30,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
* @method static ProjectMemberFactory factory()
*/
class ProjectMember extends Model implements AuditableContract
class ProjectMember extends Model implements AuditableContract, AuditableThroughParent
{
use CustomAuditable;
@@ -82,4 +83,12 @@ class ProjectMember extends Model implements AuditableContract
$query->whereBelongsTo($organization, 'organization');
});
}
/**
* @return BelongsTo<Project, $this>
*/
public function getAuditParentRelation(): BelongsTo
{
return $this->project();
}
}

View File

@@ -20,7 +20,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
@@ -125,6 +124,16 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
'send_time_entry_still_running_email' => true,
];
public function getAuditOwnerUserId(): ?string
{
return $this->getKey();
}
protected function isAuditOwnerOfItself(): bool
{
return true;
}
/**
* Get the URL to the user's profile photo.
*
@@ -175,7 +184,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return BelongsToMany<Organization, $this, Pivot, 'membership'>
* @return BelongsToMany<Organization, $this, Member, 'membership'>
*/
public function organizations(): BelongsToMany
{
@@ -190,7 +199,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return BelongsToMany<Organization, $this, Pivot, 'membership'>
* @return BelongsToMany<Organization, $this, Member, 'membership'>
*/
public function ownedOrganizations(): BelongsToMany
{

View File

@@ -25,7 +25,7 @@ use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
use Dedoc\Scramble\Support\Generator\SecuritySchemes\OAuthFlow;
use Filament\Forms\Components\Section;
use Filament\Schemas\Components\Section;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;

View File

@@ -9,17 +9,17 @@ use App\Filament\Widgets\ServerOverview;
use App\Filament\Widgets\TimeEntriesCreated;
use App\Filament\Widgets\TimeEntriesImported;
use App\Filament\Widgets\UserRegistrations;
use Filament\Http\Middleware\Authenticate;
use App\Http\Middleware\AuthenticateFilamentPanel;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationGroup;
use Filament\Pages;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
@@ -41,7 +41,7 @@ class AdminPanelProvider extends PanelProvider
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->pages([
Pages\Dashboard::class,
Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([
@@ -51,7 +51,6 @@ class AdminPanelProvider extends PanelProvider
TimeEntriesCreated::class,
TimeEntriesImported::class,
])
->viteTheme('resources/css/filament/admin/theme.css')
->plugins([
EnvironmentIndicatorPlugin::make()
->color(fn () => match (App::environment()) {
@@ -72,6 +71,9 @@ class AdminPanelProvider extends PanelProvider
NavigationGroup::make()
->label('Auth')
->collapsed(),
NavigationGroup::make()
->label('Self Hosting')
->collapsed(),
])
->middleware([
EncryptCookies::class,
@@ -79,13 +81,13 @@ class AdminPanelProvider extends PanelProvider
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
PreventRequestForgery::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
AuthenticateFilamentPanel::class,
]);
$modules = Module::allEnabled();

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Rules;
use Brick\Money\ISOCurrencyProvider;
use Brick\Money\IsoCurrencyProvider;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
@@ -24,7 +24,7 @@ class CurrencyRule implements ValidationRule
return;
}
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
$currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies();
if (array_key_exists($value, $currencies)) {
return;
}

View File

@@ -15,11 +15,18 @@ use App\Models\User;
use Exception;
use Illuminate\Support\Facades\Http;
use Log;
use Nwidart\Modules\Facades\Module;
use Throwable;
class ApiService
{
private const string API_URL = 'https://app.solidtime.io/api/v1';
private function isPremium(): bool
{
return Module::has('Invoicing');
}
public function checkForUpdate(): ?string
{
try {
@@ -30,6 +37,7 @@ class ApiService
'version' => config('app.version'),
'build' => config('app.build'),
'url' => config('app.url'),
'is_premium' => $this->isPremium(),
]);
if ($response->status() === 200 && isset($response->json()['version']) && is_string($response->json()['version'])) {
@@ -42,7 +50,7 @@ class ApiService
return null;
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
Log::warning('Failed to check for update', [
'message' => $e->getMessage(),
]);
@@ -61,6 +69,7 @@ class ApiService
'version' => config('app.version'),
'build' => config('app.build'),
'url' => config('app.url'),
'is_premium' => $this->isPremium(),
// telemetry data
'user_count' => User::count(),
'organization_count' => Organization::count(),

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Service;
use Brick\Money\ISOCurrencyProvider;
use Brick\Money\IsoCurrencyProvider;
use Brick\Money\Money;
class CurrencyService
@@ -378,7 +378,7 @@ class CurrencyService
public function getRandomCurrencyCode(): string
{
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
$currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies();
$currencyCodes = array_keys($currencies);
return $currencyCodes[array_rand($currencyCodes)];

View File

@@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class DashboardService
{
@@ -101,7 +102,7 @@ class DashboardService
$value1 = Carbon::createFromFormat('Y-m-d', $possibleDates->first(), $timeZone);
$value2 = Carbon::createFromFormat('Y-m-d', $possibleDates->last(), $timeZone);
if ($value2 === null || $value1 === null) {
throw new \RuntimeException('Provided date is not valid');
throw new RuntimeException('Provided date is not valid');
}
if ($value1->gt($value2)) {
$last = $value1;
@@ -151,12 +152,13 @@ class DashboardService
$possibleDays = $this->lastDays($days, $timezone);
// The SQL fragment contains only a timezone offset calculated as an integer.
$query = TimeEntry::query()
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) // @phpstan-ignore argument.type
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) // @phpstan-ignore argument.type
->orderBy('date');
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
@@ -193,12 +195,13 @@ class DashboardService
}
$possibleDays = $this->daysOfThisWeek($timezone, $user->week_start);
// The SQL fragment contains only a timezone offset calculated as an integer.
$query = TimeEntry::query()
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) // @phpstan-ignore argument.type
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) // @phpstan-ignore argument.type
->orderBy('date');
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);

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