Compare commits

..

11 Commits

Author SHA1 Message Date
Gregor Vostrak
7c3f7e2b67 make sure dropdown/combobox lists stay visible during close animation to
avoid layout shifts
2026-07-09 12:46:11 +02:00
Gregor Vostrak
65fbb43aa6 add stable secondary sorting based on id to the tests to avoid flakyness 2026-07-08 18:24:04 +02:00
Gregor Vostrak
4a5ba9ff28 use pinned selection instead of fix items on dropdown open so async
loaded collections update the dropdown properly
2026-07-08 18:06:58 +02:00
Gregor Vostrak
56c45adc1a add validationa for project names in toggl importer 2026-07-08 17:19:57 +02:00
Gregor Vostrak
fa8d350c4a add secondary name sorting as tie breaker in client and project tables 2026-07-08 15:20:00 +02:00
Gregor Vostrak
27f5d4a200 add unique id tiebreaker to paginated index endpoints to make pagination
stable (#1138)
2026-07-08 14:48:54 +02:00
Gregor Vostrak
c0f5baace1 Add virtualizer to ProjectDropdown, ClientDropdown and Reporting
Comboboxes; Remove redundant focus loop on Project/ClientDropdown
2026-06-30 19:04:30 +02:00
Gregor Vostrak
fddc9abf05 remove the measure row logic and rely on static values only for the
virtualizer and remove duplicated focus trap to avoid infinite loop in
project task dropdown
2026-06-30 14:24:28 +02:00
Gregor Vostrak
2da0146651 keep tasks visible when search term matches project or client name 2026-06-30 13:23:17 +02:00
Gregor Vostrak
1f7679145f add virtualizer to ProjectTaskDropdown component to handle bigger
project lists
2026-06-29 15:56:56 +02:00
Gregor Vostrak
7d9db18063 add pagination to client and project table 2026-06-28 17:39:54 +02:00
232 changed files with 1188 additions and 11300 deletions

View File

@@ -1,54 +0,0 @@
.git
**/.git
.gitmodules
**/.gitmodules
.github
.DS_Store
.fleet
.idea
.vscode
*.log
npm-debug.log
yarn-error.log
k8s
docs
e2e
tests
docker-compose.yml
docker/local
.phpunit.cache
.phpunit.result.cache
coverage
test-results
playwright-report
blob-report
playwright/.cache
openapi.json
playwright
playwright.config.ts
vitest.config.ts
phpunit.xml
phpstan.neon
pint.json
eslint.config.mjs
tsconfig.json
jsconfig.json
postcss.config.js
tailwind.config.js
node_modules
extensions/*/node_modules
Homestead.json
Homestead.yaml
auth.json
.env.backup
.rnd
_ide_helper.php
.phpstorm.meta.php
storage/logs/*
storage/*.key

27
.github/VOUCHED.td vendored
View File

@@ -1,27 +0,0 @@
# Vouched contributors for solidtime.
#
# One handle per line, without the leading @, sorted alphabetically.
# Prefix a handle with - to denounce them, optionally followed by a reason.
# Format reference: https://github.com/mitchellh/vouch
#
# Maintainers do not need to edit this file by hand. Comment "vouch @user",
# "unvouch @user" or "denounce @user <reason>" on any issue, pull request or
# discussion and the vouch workflows will update this file.
#
# Collaborators with write access and bots are always allowed and do not need
# an entry here.
#
# Seeded 2026-07-25 from the authors of every merged pull request.
agross
akolenda
bufferhead-code
candideu
kasparrosin
korridor
nikbucher tasks table sorting
onatcer
shrootbuck
smilebeda
thespyder
utlark

View File

@@ -8,8 +8,6 @@ on:
pull_request:
paths:
- '.github/workflows/build-onpremise.yml'
- '.dockerignore'
- 'extensions/manifest.json'
- 'docker/prod/**'
workflow_dispatch:
@@ -37,7 +35,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -93,23 +91,14 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout invoicing extension"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}

View File

@@ -8,8 +8,6 @@ on:
pull_request:
paths:
- '.github/workflows/build-private.yml'
- '.dockerignore'
- 'extensions/manifest.json'
- 'docker/prod/**'
workflow_dispatch:
permissions:
@@ -24,7 +22,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -70,27 +68,14 @@ jobs:
run: cat .env
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
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
uses: actions/checkout@v6
with:
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
repository: solidtime-io/extension-billing
path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
@@ -108,10 +93,9 @@ jobs:
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
repository: solidtime-io/extension-services
path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
@@ -127,10 +111,9 @@ jobs:
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}

View File

@@ -8,7 +8,6 @@ on:
pull_request:
paths:
- '.github/workflows/build-public.yml'
- '.dockerignore'
- 'docker/prod/**'
workflow_dispatch:
@@ -37,7 +36,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -93,7 +92,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -29,7 +29,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup PHP"
uses: shivammathur/setup-php@v2

View File

@@ -11,7 +11,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2
@@ -24,7 +24,7 @@ jobs:
run: composer install -n --prefer-dist
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -9,10 +9,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -11,10 +11,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -11,11 +11,11 @@ jobs:
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
# Setup .npmrc file to publish to npm
- name: Install root project dependencies
run: npm ci
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'

View File

@@ -11,9 +11,9 @@ jobs:
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'

View File

@@ -13,10 +13,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -10,7 +10,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2
@@ -23,7 +23,7 @@ jobs:
run: composer install -n --prefer-dist
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -9,7 +9,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup PHP"
uses: shivammathur/setup-php@v2

View File

@@ -36,7 +36,7 @@ jobs:
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
@@ -48,7 +48,7 @@ jobs:
- name: "Run composer install"
run: composer install -n --prefer-dist
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -9,7 +9,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Check code style"
uses: aglipanci/laravel-pint-action@2.6

View File

@@ -35,10 +35,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup node"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -99,10 +99,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup node"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -1,75 +0,0 @@
name: Vouch (check PR)
on:
pull_request_target:
types: [opened, reopened, synchronize]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
if: >-
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '/recheck'))
steps:
# Pull requests of 50 changed lines or fewer skip the vouch requirement.
- name: "Measure diff size"
id: size
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number || github.event.issue.number }}
# Changes to these files do not count towards the 50-line limit.
# One extended regex per line, matched against the whole repo-relative
# path, so use a leading .* to match a file in any directory.
IGNORED: |
package-lock\.json
composer\.lock
tests/.*
e2e/.*
.*\.(test|spec)\.(ts|js|vue)
run: |
set -euo pipefail
# An empty list yields "^()$", which matches no filename. grep exits
# 1 on an empty list, so swallow that rather than fail the step.
join() { { grep -vE '^[[:space:]]*$' || true; } | paste -sd'|' -; }
ignored="^($(join <<<"$IGNORED"))$"
total=$(gh api --paginate "repos/$REPO/pulls/$PR/files" \
--jq '.[] | [.filename, .additions + .deletions] | @tsv' |
awk -F'\t' -v ignored="$ignored" '
$1 ~ ignored { next }
{ n += $2 }
END { print n+0 }')
echo "total=$total" >> "$GITHUB_OUTPUT"
echo "Countable diff size: $total line(s)"
- name: "Small patch (denounced users still blocked)"
if: fromJSON(steps.size.outputs.total) <= 50
uses: mitchellh/vouch/action/check-pr@v1.5.0
with:
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
auto-close: true
require-vouch: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Full vouch required"
if: fromJSON(steps.size.outputs.total) > 50
uses: mitchellh/vouch/action/check-pr@v1.5.0
with:
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
auto-close: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,33 +0,0 @@
name: Vouch (manage by discussion)
# Same commands as vouch-manage-by-issue.yml, but for discussion comments.
on:
discussion_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write
discussions: write
jobs:
manage:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Apply vouch command"
uses: mitchellh/vouch/action/manage-by-discussion@v1.5.0
with:
discussion-number: ${{ github.event.discussion.number }}
comment-node-id: ${{ github.event.comment.node_id }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,35 +0,0 @@
name: Vouch (manage by issue)
# Maintainers comment "vouch @user", "unvouch @user" or "denounce @user <reason>"
# on any issue or pull request, and this workflow updates .github/VOUCHED.td.
on:
issue_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write
issues: write
pull-requests: write
jobs:
manage:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Apply vouch command"
uses: mitchellh/vouch/action/manage-by-issue@v1.5.0
with:
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@@ -26,10 +26,9 @@ yarn-error.log
/blob-report/
/playwright/.cache/
/coverage
/extensions/*
/extensions
!/extensions/.gitkeep
!/extensions/extensions_autoload.php
!/extensions/manifest.json
/auth.json
/modules_statuses.json
/k8s

View File

@@ -12,22 +12,6 @@ In order to keep the issues of the repository clean we decided to only use them
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
### Vouched contributors
Pull requests from authors who are not vouched are closed automatically. This lets us keep up with the volume of AI slop pull requests without a maintainer having to triage every one of them by hand.
Your pull request is not affected if any of the following applies:
- You have write access to this repository.
- Someone with write access has vouched for you. The list lives in [.github/VOUCHED.td](.github/VOUCHED.td).
- Your pull request changes 50 lines or fewer. Test files and lockfiles do not count towards that number, so a small fix that comes with tests still qualifies.
To get vouched, open an issue or discussion before you start and explain how you intend to implement the change. We will discuss the approach with you, and only once we have agreed on the implementation does a maintainer comment `vouch @your-handle`, which puts you on the list from then on.
Being vouched only stops your pull requests from being closed automatically. [Only work on approved issues](#only-work-on-approved-issues) still applies to every pull request you send.
Contributors who abuse this are denounced, and their pull requests are closed regardless of size.
### Contributor License Agreement
You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.

View File

@@ -39,8 +39,6 @@ Please open an issue or start a discussion and wait for approval before submitti
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
To keep that manageable, pull requests from authors who are not vouched are closed automatically, unless they change 50 lines or fewer. To get vouched, open an issue or discussion first and explain how you intend to implement the change. Once we have agreed on the approach, we vouch for you. See [Vouched contributors](./CONTRIBUTING.md#vouched-contributors).
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.

View File

@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TagMatchType: string
{
use LaravelEnumHelper;
case Contains = 'contains';
case NotContains = 'not_contains';
}

View File

@@ -21,7 +21,6 @@ enum TimeEntryAggregationType: string
case Billable = 'billable';
case Description = 'description';
case Tag = 'tag';
case Type = 'type';
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
{

View File

@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeEntryType: string
{
use LaravelEnumHelper;
case Work = 'work';
case Break = 'break';
}

View File

@@ -6,10 +6,7 @@ namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Throwable;
class Handler extends ExceptionHandler
@@ -33,29 +30,6 @@ class Handler extends ExceptionHandler
$this->reportable(function (Throwable $e): void {
//
});
// A request on an untrusted host (see App\Http\Middleware\TrustHosts)
// otherwise renders as a bare "Bad request." 400. Show a message that
// says how to fix it instead. The framework has already converted the
// SuspiciousOperationException into a BadRequestHttpException by the time
// renderables run, so we match that and inspect the original.
$this->renderable(function (BadRequestHttpException $e, Request $request): ?Response {
$previous = $e->getPrevious();
if (! $previous instanceof SuspiciousOperationException
|| ! str_starts_with($previous->getMessage(), 'Untrusted Host')) {
return null; // any other bad request keeps the default response
}
$message = 'This hostname is not configured for this instance. '
.'Set APP_URL, or add the host to TRUSTED_HOSTS.';
if ($request->expectsJson()) {
return response()->json(['message' => $message], 400);
}
return response()->view('errors.untrusted-host', ['message' => $message], 400);
});
}
public function render($request, Throwable $e): Response|RedirectResponse

View File

@@ -66,7 +66,7 @@ class UserResource extends Resource
->ignore($record?->getKey()),
])
->rule([
'email:rfc,strict',
'email',
])
->maxLength(255),
Forms\Components\Toggle::make('is_placeholder')

View File

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

View File

@@ -78,9 +78,6 @@ class OrganizationController extends Controller
if ($request->getPreventOverlappingTimeEntries() !== null) {
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
}
if ($request->getBreaksEnabled() !== null) {
$organization->breaks_enabled = $request->getBreaksEnabled();
}
$hasBillableRate = $request->has('billable_rate');
if ($hasBillableRate) {
$oldBillableRate = $organization->billable_rate;

View File

@@ -57,10 +57,9 @@ class ReportController extends Controller
$filter->addEnd($properties->end);
$filter->addActive($properties->active);
$filter->addBillable($properties->billable);
$filter->addType($properties->timeEntryType);
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
$filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagMatchType);
$filter->addTagIdsFilter($properties->tagIds?->toArray());
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
$filter->addClientIdsFilter($properties->clientIds?->toArray());
$timeEntriesQuery = $filter->get();

View File

@@ -97,7 +97,6 @@ class ReportController extends Controller
$properties->setClientIds($request->input('properties.client_ids', null));
$properties->setProjectIds($request->input('properties.project_ids', null));
$properties->setTagIds($request->input('properties.tag_ids', null));
$properties->setTagMatchType($request->getPropertyTagMatchType());
$properties->setTaskIds($request->input('properties.task_ids', null));
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
$timezone = $user->timezone;
@@ -112,7 +111,6 @@ class ReportController extends Controller
$properties->timezone = $timezone;
$properties->roundingType = $request->getPropertyRoundingType();
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
$properties->timeEntryType = $request->getPropertyTimeEntryType();
$report->properties = $properties;
if ($isPublic) {
$report->share_secret = $reportService->generateSecret();

View File

@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
@@ -68,7 +67,7 @@ class TimeEntryController extends Controller
$query = TimeEntry::query()
->where('organization_id', $organization->getKey())
->where('member_id', $member->getKey())
->where('user_id', $member->user_id)
->when($exclude !== null, function (Builder $q) use ($exclude): void {
$q->where('id', '!=', $exclude->getKey());
})
@@ -108,8 +107,8 @@ 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`.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
* If you only need time entries for a specific user, you can filter by `user_id`.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
*
* @return TimeEntryCollection<TimeEntryResource>
*
@@ -119,17 +118,16 @@ class TimeEntryController extends Controller
*/
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$totalCount = $timeEntriesQuery->count();
@@ -160,7 +158,7 @@ class TimeEntryController extends Controller
if ($timeEntries->count() === 0) {
Log::warning('User has has more than '.$limit.' time entries on one date', [
'date' => $lastDate->toDateString(),
'member_id' => $request->input('member_id'),
'user_id' => $request->input('user_id'),
'auth_user_id' => Auth::id(),
'limit' => $limit,
]);
@@ -206,11 +204,10 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
$filter->addTypeFilter($request->input('type'));
return $filter->get();
}
@@ -224,10 +221,9 @@ class TimeEntryController extends Controller
*/
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
@@ -244,7 +240,7 @@ class TimeEntryController extends Controller
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$timeEntriesQuery->with([
'task',
'client',
@@ -267,7 +263,7 @@ class TimeEntryController extends Controller
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery,
null,
@@ -336,9 +332,7 @@ class TimeEntryController extends Controller
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
]),
->temporaryUrl($path, now()->addMinutes(5)),
]);
}
@@ -376,10 +370,9 @@ class TimeEntryController extends Controller
*/
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
@@ -390,7 +383,7 @@ class TimeEntryController extends Controller
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -426,10 +419,9 @@ class TimeEntryController extends Controller
*/
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
@@ -445,7 +437,7 @@ class TimeEntryController extends Controller
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -537,7 +529,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate, $localizationService),
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),
$path,
config('filesystems.private'),
$format->getExportPackageType(),
@@ -549,9 +541,7 @@ class TimeEntryController extends Controller
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
]),
->temporaryUrl($path, now()->addMinutes(5)),
]);
}
@@ -570,11 +560,10 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
$filter->addTypeFilter($request->input('type'));
return $filter->get();
}
@@ -591,7 +580,7 @@ class TimeEntryController extends Controller
{
/** @var Member $member */
$member = Member::query()->findOrFail($request->input('member_id'));
if ($member->getKey() === $this->member($organization)->getKey()) {
if ($member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:create:own');
} else {
$this->checkPermission($organization, 'time-entries:create:all');
@@ -638,10 +627,9 @@ class TimeEntryController extends Controller
*/
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
{
$member = $this->member($organization);
/** @var Member|null $newMember */
$newMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($timeEntry->member_id === $member->getKey() && ($newMember === null || $newMember->getKey() === $member->getKey())) {
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
} else {
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
@@ -673,10 +661,6 @@ class TimeEntryController extends Controller
}
$timeEntry->fill($request->validated());
if ($newMember !== null) {
$timeEntry->member()->associate($newMember);
$timeEntry->user()->associate($newMember->user);
}
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save();
@@ -706,7 +690,6 @@ class TimeEntryController extends Controller
*/
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
{
$member = $this->member($organization);
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
@@ -731,9 +714,6 @@ class TimeEntryController extends Controller
throw new AuthorizationException;
}
/** @var Member|null $newMember */
$newMember = isset($changes['member_id']) ? Member::query()->findOrFail($changes['member_id']) : null;
$project = null;
$client = null;
$overwriteClient = false;
@@ -760,33 +740,16 @@ class TimeEntryController extends Controller
continue;
}
if (! $canAccessAll && $timeEntry->member_id !== $member->getKey()) {
if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) {
$error->push($id);
continue;
}
// Changing time entries to Break entries is only allowed when breaks are enabled in the org settings
$resultingType = isset($changes['type']) ? TimeEntryType::from($changes['type']) : $timeEntry->type;
if ($resultingType === TimeEntryType::Break && $timeEntry->type !== TimeEntryType::Break && ! $organization->breaks_enabled) {
$error->push($id);
continue;
}
// Break entries can not be billable, have tags or belong to a project/task (see TimeEntry::booted)
if ($resultingType === TimeEntryType::Break && ($project !== null || $task !== null || $request->boolean('changes.billable') || count($changes['tags'] ?? []) > 0)) {
$error->push($id);
continue;
}
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;
$timeEntry->fill($changes);
if ($newMember !== null) {
$timeEntry->member()->associate($newMember);
$timeEntry->user_id = $newMember->user_id;
}
// If project is changed, but task is not, we remove the old task from the time entry
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
$timeEntry->task()->disassociate();
@@ -827,8 +790,7 @@ class TimeEntryController extends Controller
*/
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
{
$member = $this->member($organization);
if ($timeEntry->member_id === $member->getKey()) {
if ($timeEntry->member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
} else {
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
@@ -885,7 +847,7 @@ class TimeEntryController extends Controller
continue;
}
if (! $canDeleteAll && $timeEntry->member_id !== $this->member($organization)->getKey()) {
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
$error->push($id);
continue;

View File

@@ -15,7 +15,6 @@ use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\ShareInertiaData;
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;
@@ -48,7 +47,6 @@ class Kernel extends HttpKernel
*/
protected $middleware = [
ForceHttps::class,
TrustHosts::class,
TrustProxies::class,
HandleCors::class,
PreventRequestsDuringMaintenance::class,

View File

@@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as BaseTrustHosts;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* Rejects requests whose Host is not trusted, preventing Host-header poisoning of
* generated URLs (password reset, SSO callback, invitations). Trusted = the
* APP_URL host and its subdomains, plus TRUSTED_HOSTS (for multi-host access such
* as a Tailscale name). Health-check endpoints are exempt (probed by IP).
*/
class TrustHosts extends BaseTrustHosts
{
/**
* @return array<int, string|null>
*/
public function hosts(): array
{
/** @var array<int, string> $configured */
$configured = config('app.trusted_hosts', []);
$extra = array_map(function (string $host): string {
$host = trim($host);
// "*.example.com" matches any subdomain, not the apex.
if (str_starts_with($host, '*.')) {
return '^.+\.'.preg_quote(substr($host, 2), '#').'$';
}
return '^'.preg_quote($host, '#').'$';
}, $configured);
return array_merge([$this->allSubdomainsOfApplicationUrl()], $extra);
}
/**
* @param \Closure(Request): Response $next
*/
public function handle(Request $request, $next)
{
// Exempt health checks (probed by IP). Also reset the trusted hosts,
// since Octane leaks the static state across requests.
if ($request->is('health-check/*')) {
Request::setTrustedHosts([]);
return $next($request);
}
return parent::handle($request, $next);
}
}

View File

@@ -25,7 +25,7 @@ class InvitationStoreRequest extends BaseFormRequest
return [
'email' => [
'required',
'email:rfc,strict',
'email',
],
'role' => [
'required',

View File

@@ -51,9 +51,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
'prevent_overlapping_time_entries' => [
'boolean',
],
'breaks_enabled' => [
'boolean',
],
'number_format' => [
Rule::enum(NumberFormat::class),
],
@@ -128,9 +125,4 @@ class OrganizationUpdateRequest extends BaseFormRequest
{
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
}
public function getBreaksEnabled(): ?bool
{
return $this->has('breaks_enabled') ? $this->boolean('breaks_enabled') : null;
}
}

View File

@@ -4,11 +4,9 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
@@ -126,11 +124,6 @@ class ReportStoreRequest extends BaseFormRequest
}
},
],
'properties.tag_match_type' => [
'nullable',
'string',
Rule::enum(TagMatchType::class),
],
'properties.task_ids' => [
'nullable',
'array',
@@ -178,12 +171,6 @@ class ReportStoreRequest extends BaseFormRequest
'numeric',
'integer',
],
// Filter by time entry type
'properties.time_entry_type' => [
'nullable',
'string',
Rule::enum(TimeEntryType::class),
],
];
}
@@ -247,15 +234,6 @@ class ReportStoreRequest extends BaseFormRequest
return null;
}
public function getPropertyTimeEntryType(): ?TimeEntryType
{
if (! $this->has('properties.time_entry_type') || $this->input('properties.time_entry_type') === null) {
return null;
}
return TimeEntryType::from($this->input('properties.time_entry_type'));
}
public function getPropertyGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('properties.group'));
@@ -271,15 +249,6 @@ class ReportStoreRequest extends BaseFormRequest
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
}
public function getPropertyTagMatchType(): ?TagMatchType
{
if (! $this->has('properties.tag_match_type') || $this->input('properties.tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->input('properties.tag_match_type'));
}
public function getPropertyRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {

View File

@@ -5,11 +5,9 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -141,10 +139,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
@@ -184,11 +178,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
'string',
'in:true,false',
],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
'fill_gaps_in_time_groups' => [
'string',
'in:true,false',
@@ -257,15 +246,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
return ExportFormat::from($this->validated('format'));
}
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -4,10 +4,8 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -127,10 +125,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
@@ -170,11 +164,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
'string',
'in:true,false',
],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
'fill_gaps_in_time_groups' => [
'string',
'in:true,false',
@@ -219,15 +208,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
}
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -5,9 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
@@ -112,10 +110,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
@@ -156,11 +150,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
'string',
'in:true,false',
],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
// Limit the number of returned time entries (default: 150)
'limit' => [
'integer',
@@ -226,15 +215,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
return ExportFormat::from($this->validated('format'));
}
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -4,9 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -105,10 +103,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
@@ -149,11 +143,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string',
'in:true,false',
],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
// Limit the number of returned time entries (default: 150)
'limit' => [
'integer',
@@ -201,15 +190,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
}
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
@@ -15,7 +14,6 @@ use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -26,7 +24,7 @@ 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|ValidationRule>>
*/
public function rules(): array
{
@@ -44,7 +42,6 @@ class TimeEntryStoreRequest extends BaseFormRequest
'nullable',
'string',
'required_with:task_id',
'prohibited_if:type,break',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization');
@@ -63,7 +60,6 @@ class TimeEntryStoreRequest extends BaseFormRequest
'task_id' => [
'nullable',
'string',
'prohibited_if:type,break',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
@@ -89,16 +85,6 @@ class TimeEntryStoreRequest extends BaseFormRequest
'billable' => [
'required',
'boolean',
'declined_if:type,break',
],
// Type of the time entry (work time or a break)
'type' => [
Rule::enum(TimeEntryType::class),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryType::Break->value && ! $this->organization->breaks_enabled) {
$fail('Breaks are disabled for this organization.');
}
},
],
// Description of time entry
'description' => [
@@ -110,7 +96,6 @@ class TimeEntryStoreRequest extends BaseFormRequest
'tags' => [
'nullable',
'array',
'prohibited_if:type,break',
],
'tags.*' => [
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
@@ -15,7 +14,6 @@ use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -26,7 +24,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
@@ -56,7 +54,6 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'nullable',
'string',
'required_with:task_id',
'prohibited_if:changes.type,break',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization');
@@ -75,7 +72,6 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'changes.task_id' => [
'nullable',
'string',
'prohibited_if:changes.type,break',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
@@ -88,13 +84,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
],
// Whether time entry is billable
'changes.billable' => [
'sometimes',
'boolean',
'declined_if:changes.type,break',
],
// Type of the time entry (work time or a break)
'changes.type' => [
Rule::enum(TimeEntryType::class),
],
// Description of time entry
'changes.description' => [
@@ -106,7 +96,6 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'changes.tags' => [
'nullable',
'array',
'prohibited_if:changes.type,break',
],
'changes.tags.*' => [
'string',

View File

@@ -4,21 +4,16 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ConditionalRules;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\ProhibitedIf;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -29,19 +24,10 @@ 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|ValidationRule>>
*/
public function rules(): array
{
// Break restrictions need to apply based on the type the entry will have after the
// update, not only when the payload itself contains type=break.
$timeEntry = $this->route('timeEntry');
$timeEntry = $timeEntry instanceof TimeEntry ? $timeEntry : null;
$resultingType = $this->has('type')
? TimeEntryType::tryFrom((string) $this->input('type'))
: $timeEntry?->type;
$isBreak = $resultingType === TimeEntryType::Break;
return [
// ID of the organization member that the time entry should belong to
'member_id' => [
@@ -56,7 +42,6 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'nullable',
'string',
'required_with:task_id',
Rule::prohibitedIf($isBreak),
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization');
@@ -75,7 +60,6 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'task_id' => [
'nullable',
'string',
Rule::prohibitedIf($isBreak),
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
@@ -98,22 +82,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
],
// Whether time entry is billable
'billable' => [
'sometimes',
'boolean',
Rule::when($isBreak, ['declined']),
],
// 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 {
// 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
&& ! $this->organization->breaks_enabled
&& $timeEntry?->type !== TimeEntryType::Break) {
$fail('Breaks are disabled for this organization.');
}
},
],
// Description of time entry
'description' => [
@@ -125,7 +94,6 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'tags' => [
'nullable',
'array',
Rule::prohibitedIf($isBreak),
],
'tags.*' => [
'string',

View File

@@ -41,7 +41,7 @@ class UserUpdateRequest extends BaseFormRequest
'max:255',
],
'email' => [
'email:rfc,strict',
'email',
'max:255',
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */

View File

@@ -57,8 +57,6 @@ class OrganizationResource extends BaseResource
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** @var bool $breaks_enabled Whether members of the organization can track breaks */
'breaks_enabled' => $this->resource->breaks_enabled,
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency,
/** @var string $currency_symbol Currency symbol */

View File

@@ -50,16 +50,12 @@ class DetailedReportResource extends BaseResource
'member_ids' => $this->resource->properties->memberIds?->toArray(),
/** @var bool|null $billable Filter by billable status */
'billable' => $this->resource->properties->billable,
/** @var string|null $time_entry_type Filter by time entry type */
'time_entry_type' => $this->resource->properties->timeEntryType?->value,
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
'client_ids' => $this->resource->properties->clientIds?->toArray(),
/** @var array<string>|null $project_ids Filter by project IDs, project IDs are OR combined */
'project_ids' => $this->resource->properties->projectIds?->toArray(),
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var string|null $tag_match_type Tag match type */
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** @var string|null $rounding_type Rounding type for time entries */

View File

@@ -47,8 +47,6 @@ class TimeEntryResource extends BaseResource
'tags' => $this->resource->tags ?? [],
/** @var bool $billable Whether time entry is billable */
'billable' => $this->resource->billable,
/** @var string $type Type of the time entry (`work` time or a `break`) */
'type' => $this->resource->type->value,
];
}
}

View File

@@ -20,11 +20,6 @@ class RecalculateSpentTimeForProject implements ShouldDispatchAfterCommit, Shoul
use Queueable;
use SerializesModels;
/**
* Delete the job if its models no longer exist.
*/
public bool $deleteWhenMissingModels = true;
public Project $project;
/**

View File

@@ -20,11 +20,6 @@ class RecalculateSpentTimeForTask implements ShouldDispatchAfterCommit, ShouldQu
use Queueable;
use SerializesModels;
/**
* Delete the job if its models no longer exist.
*/
public bool $deleteWhenMissingModels = true;
public Task $task;
/**

View File

@@ -34,7 +34,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property bool $employees_can_see_billable_rates
* @property bool $employees_can_manage_tasks
* @property bool $prevent_overlapping_time_entries
* @property bool $breaks_enabled
* @property User $owner
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
@@ -71,7 +70,6 @@ class Organization extends Model implements AuditableContract
'employees_can_see_billable_rates' => 'boolean',
'employees_can_manage_tasks' => 'boolean',
'prevent_overlapping_time_entries' => 'boolean',
'breaks_enabled' => 'boolean',
'number_format' => NumberFormat::class,
'currency_format' => CurrencyFormat::class,
'date_format' => DateFormat::class,

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Models;
use App\Enums\TimeEntryType;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use App\Service\BillableRateService;
@@ -29,7 +28,6 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
* @property Carbon|null $end
* @property int|null $billable_rate Billable rate per hour in cents
* @property bool $billable
* @property TimeEntryType $type
* @property array<string> $tags
* @property string $user_id
* @property string $member_id
@@ -73,20 +71,12 @@ class TimeEntry extends Model implements AuditableContract
'start' => 'datetime',
'end' => 'datetime',
'billable' => 'bool',
'type' => TimeEntryType::class,
'tags' => 'array',
'billable_rate' => 'int',
'is_imported' => 'bool',
'still_active_email_sent_at' => 'datetime',
];
/**
* @var array<string, string>
*/
protected $attributes = [
'type' => 'work',
];
public const array SELECT_COLUMNS = [
'id',
'description',
@@ -94,7 +84,6 @@ class TimeEntry extends Model implements AuditableContract
'end',
'billable_rate',
'billable',
'type',
'user_id',
'organization_id',
'project_id',
@@ -128,21 +117,6 @@ class TimeEntry extends Model implements AuditableContract
'billable_rate',
];
protected static function booted(): void
{
// Break entries can never be billable, have tags or belong to a project/task.
static::saving(function (TimeEntry $timeEntry): void {
if ($timeEntry->type === TimeEntryType::Break) {
$timeEntry->billable = false;
$timeEntry->billable_rate = null;
$timeEntry->project_id = null;
$timeEntry->task_id = null;
$timeEntry->client_id = null;
$timeEntry->tags = [];
}
});
}
public function getBillableRateComputed(): ?int
{
return app(BillableRateService::class)->getBillableRateForTimeEntry($this);
@@ -199,16 +173,6 @@ class TimeEntry extends Model implements AuditableContract
$builder->whereJsonContains('tags', $tag->getKey());
}
/**
* Only work entries — breaks do not count toward tracked/billable time.
*
* @param Builder<TimeEntry> $builder
*/
public function scopeWorkTime(Builder $builder): void
{
$builder->where('type', '=', TimeEntryType::Work);
}
/**
* @return BelongsTo<User, $this>
*/

View File

@@ -20,7 +20,6 @@ use App\Service\BillingContract;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\IpLookup\NoIpLookupService;
use App\Service\PermissionStore;
use DateTimeInterface;
use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
@@ -30,13 +29,8 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AppServiceProvider extends ServiceProvider
{
@@ -104,27 +98,6 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
$this->app->bind(BillingContract::class);
// Storage
// The local driver ignores the ResponseContentDisposition option of temporaryUrl,
// so mirror it through the signed query parameters of the storage route.
$privateDisk = config('filesystems.private');
if (config('filesystems.disks.'.$privateDisk.'.driver') === 'local') {
$disk = Storage::disk($privateDisk);
$disk->serveUsing(function (Request $request, string $path, array $headers) use ($disk): StreamedResponse {
return $disk->response($path, null, $headers, $request->query('disposition', 'inline'));
});
$disk->buildTemporaryUrlsUsing(function (string $path, DateTimeInterface $expiration, array $options) use ($privateDisk): string {
$parameters = array_filter([
'path' => $path,
'disposition' => isset($options['ResponseContentDisposition'])
? Str::before($options['ResponseContentDisposition'], ';')
: null,
]);
return url(URL::temporarySignedRoute('storage.'.$privateDisk, $expiration, $parameters, absolute: false));
});
}
// Routing
Route::model('member', Member::class);
Route::model('invitation', OrganizationInvitation::class);

View File

@@ -33,8 +33,8 @@ class RouteServiceProvider extends ServiceProvider
}
return $request->user()
? Limit::perMinute(config('app.api_rate_limit_authenticated_per_minute'))->by($request->user()->id)
: Limit::perMinute(config('app.api_rate_limit_guest_per_minute'))->by($request->ip());
? Limit::perMinute(200)->by($request->user()->id)
: Limit::perMinute(60)->by($request->ip());
});
$this->routes(function (): void {

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\TimeEntryType;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\Project;
@@ -155,7 +154,6 @@ class DashboardService
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
->orderBy('date');
@@ -197,7 +195,6 @@ class DashboardService
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
->orderBy('date');
@@ -225,8 +222,7 @@ class DashboardService
$query = TimeEntry::query()
->select(DB::raw('round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->workTime();
->where('organization_id', '=', $organization->getKey());
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
/** @var Collection<int, object{aggregate: int}> $resultDb */
@@ -294,7 +290,6 @@ class DashboardService
->select(DB::raw('project_id, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy('project_id');
$query = $this->constrainDateByCurrentWeek($query, $timezone, $user->week_start);
@@ -438,8 +433,7 @@ class DashboardService
JOIN time_entries ON time_entries.start < time_ranges."end"
AND coalesce(time_entries."end", :now::timestamp) > time_ranges.start
WHERE time_entries.user_id = :user_id and
time_entries.organization_id = :organization_id and
time_entries.type = :work_type
time_entries.organization_id = :organization_id
GROUP BY time_ranges.start
ORDER BY time_ranges.start
', [
@@ -448,7 +442,6 @@ class DashboardService
'user_id' => $user->getKey(),
'organization_id' => $organization->getKey(),
'now' => Carbon::now()->toDateTimeString(),
'work_type' => TimeEntryType::Work->value,
]))->pluck('aggregate', 'start');
$response = [];

View File

@@ -4,11 +4,9 @@ declare(strict_types=1);
namespace App\Service\Dto;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Enums\Weekday;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Database\Eloquent\Castable;
@@ -58,8 +56,6 @@ class ReportPropertiesDto implements Castable
*/
public ?Collection $tagIds = null;
public ?TagMatchType $tagMatchType = null;
/**
* @var Collection<int, string>|null
*/
@@ -69,8 +65,6 @@ class ReportPropertiesDto implements Castable
public ?int $roundingMinutes = null;
public ?TimeEntryType $timeEntryType = null;
/**
* Get the caster class to use when casting from / to this cast target.
*
@@ -121,7 +115,6 @@ class ReportPropertiesDto implements Castable
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
$dto->group = TimeEntryAggregationType::from($data->group);
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
@@ -132,12 +125,6 @@ class ReportPropertiesDto implements Castable
$dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null;
// Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB
$dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null;
// Note: timeEntryType was added later, reports persisted before that are missing the value and default to "work"
if (property_exists($data, 'timeEntryType')) {
$dto->timeEntryType = $data->timeEntryType !== null ? TimeEntryType::from($data->timeEntryType) : null;
} else {
$dto->timeEntryType = TimeEntryType::Work;
}
return $dto;
}
@@ -157,7 +144,6 @@ class ReportPropertiesDto implements Castable
'clientIds' => $value->clientIds?->toArray(),
'projectIds' => $value->projectIds?->toArray(),
'tagIds' => $value->tagIds?->toArray(),
'tagMatchType' => $value->tagMatchType?->value,
'taskIds' => $value->taskIds?->toArray(),
'group' => $value->group->value,
'subGroup' => $value->subGroup->value,
@@ -166,7 +152,6 @@ class ReportPropertiesDto implements Castable
'timezone' => $value->timezone,
'roundingType' => $value->roundingType?->value,
'roundingMinutes' => $value->roundingMinutes,
'timeEntryType' => $value->timeEntryType?->value,
];
$jsonString = json_encode($data);
@@ -231,11 +216,6 @@ class ReportPropertiesDto implements Castable
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
}
public function setTagMatchType(?TagMatchType $tagMatchType): void
{
$this->tagMatchType = $tagMatchType;
}
/**
* @param array<mixed>|null $taskIds
*/

View File

@@ -107,7 +107,6 @@ class ExportService
'end',
'billable_rate',
'billable',
'type',
'member_id',
'user_id',
'organization_id',
@@ -132,7 +131,6 @@ class ExportService
$timeEntry->end?->toIso8601ZuluString() ?? '',
$timeEntry->billable_rate ?? '',
$timeEntry->billable ? 'true' : 'false',
$timeEntry->type->value,
$timeEntry->member_id,
$timeEntry->user_id,
$timeEntry->organization_id,

View File

@@ -29,8 +29,7 @@ class ClockifyProjectsImporter extends DefaultImporter
$records = $reader->getRecords();
foreach ($records as $record) {
$clientId = null;
// Newer Clockify exports no longer contain a "Client" column.
if (($record['Client'] ?? '') !== '') {
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
@@ -46,7 +45,7 @@ class ClockifyProjectsImporter extends DefaultImporter
'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
]);
}
@@ -81,6 +80,7 @@ class ClockifyProjectsImporter extends DefaultImporter
{
$requiredFields = [
'Project',
'Client',
'Status',
'Visibility',
'Billability',

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
@@ -72,12 +71,8 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'role' => Role::Placeholder->value,
]);
$member = $this->memberImportHelper->getModelById($memberId);
// Clockify allows a project/task/client/tags/billable on breaks, but those are
// meaningless for non-work time. Detect breaks up front and skip creating any of
// that so a break can't spawn an orphan project/tag or inflate the import counts.
$isBreak = isset($record['Type']) && strtolower($record['Type']) === 'break';
$clientId = null;
if (! $isBreak && ($record['Client'] ?? '') !== '') {
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
@@ -86,7 +81,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$projectId = null;
$project = null;
$projectMember = null;
if (! $isBreak && $record['Project'] !== '') {
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'client_id' => $clientId,
@@ -102,7 +97,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]);
}
$taskId = null;
if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') {
if ($taskKey !== null && $record[$taskKey] !== '') {
$taskId = $this->taskImportHelper->getKey([
'name' => $record[$taskKey],
'project_id' => $projectId,
@@ -128,12 +123,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
}
$timeEntry->billable = $record['Billable'] === 'Yes';
}
if ($isBreak) {
// Breaks can not be billable or belong to a project/task (already skipped above)
$timeEntry->type = TimeEntryType::Break;
$timeEntry->billable = false;
}
$timeEntry->tags = $isBreak ? [] : $this->getTags($record['Tags']);
$timeEntry->tags = $this->getTags($record['Tags']);
$timeEntry->is_imported = true;
// Start
@@ -225,6 +215,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
{
$requiredFields = [
'Project',
'Client',
'Description',
'User',
'Group',

View File

@@ -171,7 +171,7 @@ abstract class DefaultImporter implements ImporterContract
}, validate: [
'email' => [
'required',
'email:rfc,strict',
'email',
'max:255',
],
]);

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
@@ -256,14 +255,6 @@ class SolidtimeImporter extends DefaultImporter
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $timeEntryRow['billable'] === 'true';
// The type column does not exist in old exports
if (($timeEntryRow['type'] ?? '') !== '') {
$type = TimeEntryType::tryFrom($timeEntryRow['type']);
if ($type === null) {
throw new ImportException('Invalid type value');
}
$timeEntry->type = $type;
}
$timeEntry->tags = $this->getTags($timeEntryRow['tags']);
$timeEntry->is_imported = true;

View File

@@ -23,10 +23,6 @@ class InvitationService
*/
public function inviteUser(Organization $organization, string $email, Role $role, User $inviter): OrganizationInvitation
{
// Normalize the email so it matches how user emails are stored (see UserService::createUser),
// otherwise a mixed-case invite silently fails to link on registration.
$email = strtolower($email);
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
throw new UserIsAlreadyMemberOfOrganizationApiException;
}
@@ -59,7 +55,7 @@ class InvitationService
$organizations = new Collection;
$invitations = OrganizationInvitation::query()
->whereRaw('lower(email) = ?', [strtolower($user->email)])
->where('email', $user->email)
->whereNotNull('accepted_at')
->get();

View File

@@ -8,14 +8,12 @@ use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeFormat;
use App\Models\Organization;
use Brick\Math\BigDecimal;
use Brick\Money\Money;
use Carbon\CarbonInterface;
use Carbon\CarbonInterval;
use Illuminate\Support\Carbon;
class LocalizationService
{
@@ -154,38 +152,6 @@ class LocalizationService
return $date->format($this->dateFormat->toCarbonFormat());
}
/**
* Time group types have no server-side descriptor; their keys are ISO dates and are
* formatted here instead. A Week key is the first day of that week, so it renders as the
* range it covers. A Year key is already a bare year, so it is returned unchanged - it must
* not be parsed, Carbon reads a four digit string as a time of day.
*/
public function formatTimeGroupKey(?string $key, TimeEntryAggregationType $groupType): ?string
{
if ($key === null) {
return null;
}
if ($groupType === TimeEntryAggregationType::Day) {
return $this->formatDate(Carbon::parse($key));
}
if ($groupType === TimeEntryAggregationType::Week) {
$weekStart = Carbon::parse($key);
return $this->formatDate($weekStart).' - '.$this->formatDate($weekStart->copy()->addDays(6));
}
if ($groupType === TimeEntryAggregationType::Month) {
// Note: the leading "!" resets all fields the format does not name. Without it the
// day of the month is taken from today, and a day that the parsed month does not
// have overflows the date into the next month.
return Carbon::createFromFormat('!Y-m', $key)->format('F Y');
}
return $key;
}
public function setDateFormat(DateFormat $dateFormat): void
{
$this->dateFormat = $dateFormat;

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Enums\TimeEntryType;
use App\Models\TimeEntry;
use App\Service\IntervalService;
use Illuminate\Database\Eloquent\Builder;
@@ -26,7 +25,6 @@ class TimeEntriesDetailedCsvExport extends CsvExport
'Duration',
'Duration (decimal)',
'Billable',
'Break',
'Tags',
];
@@ -60,7 +58,6 @@ class TimeEntriesDetailedCsvExport extends CsvExport
'Duration' => $duration !== null ? $interval->format($model->getDuration()) : null,
'Duration (decimal)' => $duration?->totalHours,
'Billable' => $model->billable ? 'Yes' : 'No',
'Break' => $model->type === TimeEntryType::Break ? 'Yes' : 'No',
'Tags' => $model->tagsRelation->pluck('name')->implode(', '),
];
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryType;
use App\Models\TimeEntry;
use App\Service\LocalizationService;
use Illuminate\Database\Eloquent\Builder;
@@ -107,7 +106,6 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
'Duration',
'Duration (decimal)',
'Billable',
'Break',
'Tags',
];
}
@@ -132,7 +130,6 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
$duration !== null ? $this->localizationService->formatInterval($duration) : null,
$duration?->totalHours,
$model->billable ? 'Yes' : 'No',
$model->type === TimeEntryType::Break ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),
];
} elseif ($this->exportFormat === ExportFormat::ODS) {
@@ -147,7 +144,6 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
$duration !== null ? $this->localizationService->formatInterval($duration) : null,
$duration?->totalHours,
$model->billable ? 'Yes' : 'No',
$model->type === TimeEntryType::Break ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),
];
} else {

View File

@@ -6,7 +6,6 @@ namespace App\Service\ReportExport;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
use App\Service\LocalizationService;
use Illuminate\View\View;
use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromView;
@@ -49,8 +48,6 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
private bool $showBillableRate;
private LocalizationService $localization;
/**
* @param array{
* grouped_type: string|null,
@@ -71,7 +68,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* cost: int|null
* } $data
*/
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate, LocalizationService $localization)
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate)
{
$this->data = $data;
$this->exportFormat = $exportFormat;
@@ -79,7 +76,6 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
$this->group = $group;
$this->subGroup = $subGroup;
$this->showBillableRate = $showBillableRate;
$this->localization = $localization;
}
public function view(): View
@@ -91,7 +87,6 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
'subGroup' => $this->subGroup,
'exportFormat' => $this->exportFormat,
'showBillableRate' => $this->showBillableRate,
'localization' => $this->localization,
]);
}

View File

@@ -353,13 +353,6 @@ class TimeEntryAggregationService
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Type) {
foreach ($keys as $key) {
$descriptorMap[$key] = [
'description' => $key === 'break' ? 'Break' : 'Work time',
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Tag) {
$tags = Tag::query()
->whereIn('id', $keys)
@@ -511,8 +504,6 @@ class TimeEntryAggregationService
return 'client_id';
} elseif ($group === TimeEntryAggregationType::Billable) {
return 'billable';
} elseif ($group === TimeEntryAggregationType::Type) {
return 'type';
} elseif ($group === TimeEntryAggregationType::Description) {
return 'description';
} elseif ($group === TimeEntryAggregationType::Tag) {

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryType;
use App\Models\Member;
use App\Models\TimeEntry;
use Illuminate\Database\Eloquent\Builder;
@@ -145,32 +143,6 @@ class TimeEntryFilter
return $this;
}
public function addTypeFilter(?string $type): self
{
if ($type === null) {
return $this;
}
$typeEnum = TimeEntryType::tryFrom($type);
if ($typeEnum === null) {
Log::warning('Invalid type filter value', ['value' => $type]);
return $this;
}
$this->addType($typeEnum);
return $this;
}
public function addType(?TimeEntryType $type): self
{
if ($type === null) {
return $this;
}
$this->builder->where('type', '=', $type->value);
return $this;
}
/**
* @param array<string>|null $clientIds
*/
@@ -220,21 +192,15 @@ class TimeEntryFilter
/**
* @param array<string>|null $tagIds
*/
public function addTagIdsFilter(?array $tagIds, ?TagMatchType $tagMatchType = TagMatchType::Contains): self
public function addTagIdsFilter(?array $tagIds): self
{
if ($tagIds === null) {
return $this;
}
$tagMatchType ??= TagMatchType::Contains;
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
// An empty selection (no tag IDs and not filtering for "none") is no constraint, so apply nothing.
// This also prevents the not-contains branch from collapsing into "only entries with null tags".
if (count($tagIds) === 0 && ! $includeNone) {
return $this;
}
$tagCondition = function (Builder $builder) use ($tagIds, $includeNone): void {
$this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {
foreach ($tagIds as $tagId) {
$builder->orWhereJsonContains('tags', $tagId);
}
@@ -243,18 +209,7 @@ class TimeEntryFilter
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
});
}
};
if ($tagMatchType === TagMatchType::NotContains) {
$this->builder->where(function (Builder $builder) use ($tagCondition, $includeNone): void {
$builder->whereNot($tagCondition);
if (! $includeNone) {
$builder->orWhereNull('tags');
}
});
} else {
$this->builder->where($tagCondition);
}
});
return $this;
}

View File

@@ -75,27 +75,6 @@ return [
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Trusted Hosts
|--------------------------------------------------------------------------
|
| Additional hostnames (besides the APP_URL host and its subdomains) that
| the application is allowed to respond on. This is needed for multi-host
| setups, e.g. reaching the instance over both a public domain and a
| Tailscale name. A request arriving on any host that is neither APP_URL
| (nor a subdomain of it) nor listed here is rejected, which prevents
| Host-header poisoning of password reset and other out-of-band links.
|
| See App\Http\Middleware\TrustHosts.
|
*/
'trusted_hosts' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('TRUSTED_HOSTS', ''))
))),
'asset_url' => env('ASSET_URL'),
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
@@ -158,21 +137,6 @@ return [
'pagination_per_page_default' => (int) env('PAGINATION_PER_PAGE_DEFAULT', 15),
/*
|--------------------------------------------------------------------------
| API Rate Limiting
|--------------------------------------------------------------------------
|
| The number of API requests allowed per minute, counted per user for
| authenticated requests and per IP address for guest requests. These
| limits are only enforced when the application runs in production.
|
*/
'api_rate_limit_authenticated_per_minute' => (int) (env('API_RATE_LIMIT_AUTH_PER_MINUTE') ?: 200),
'api_rate_limit_guest_per_minute' => (int) (env('API_RATE_LIMIT_GUEST_PER_MINUTE') ?: 60),
/*
|--------------------------------------------------------------------------
| Encryption Key

View File

@@ -33,7 +33,6 @@ class OrganizationFactory extends Factory
'user_id' => User::factory(),
'personal_team' => true,
'employees_can_see_billable_rates' => false,
'breaks_enabled' => false,
'number_format' => $this->faker->randomElement(NumberFormat::values()),
'currency_format' => $this->faker->randomElement(CurrencyFormat::values()),
'date_format' => $this->faker->randomElement(DateFormat::values()),
@@ -56,13 +55,6 @@ class OrganizationFactory extends Factory
]);
}
public function withBreaksEnabled(): self
{
return $this->state(fn (array $attributes) => [
'breaks_enabled' => true,
]);
}
public function withOwner(?User $owner = null): self
{
return $this->state(fn (array $attributes) => [

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Database\Factories;
use App\Enums\TimeEntryType;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -34,7 +33,6 @@ class TimeEntryFactory extends Factory
'start' => $start,
'end' => $this->faker->dateTimeBetween($start, 'now'),
'billable' => $this->faker->boolean(),
'type' => TimeEntryType::Work,
'is_imported' => false,
'tags' => [],
'user_id' => User::factory(),
@@ -46,18 +44,6 @@ class TimeEntryFactory extends Factory
];
}
public function isBreak(): self
{
return $this->state(function (array $attributes): array {
return [
'type' => TimeEntryType::Break,
'billable' => false,
'project_id' => null,
'task_id' => null,
];
});
}
public function notBillable(): self
{
return $this->state(function (array $attributes): array {

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('time_entries', function (Blueprint $table): void {
$table->string('type')->default('work');
});
}
public function down(): void
{
Schema::table('time_entries', function (Blueprint $table): void {
$table->dropColumn('type');
});
}
};

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->boolean('breaks_enabled')->default(false)->after('prevent_overlapping_time_entries');
});
}
public function down(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->dropColumn('breaks_enabled');
});
}
};

View File

@@ -1,230 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use function Laravel\Prompts\info as consoleInfo;
return new class extends Migration
{
/**
* PostgreSQL cannot build or drop an index concurrently inside a transaction.
* Keeping this migration non-transactional prevents long write locks in production.
*
* @var bool
*/
public $withinTransaction = false;
/**
* Run the migrations.
*/
public function up(): void
{
// Tenant-scoped client pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'clients_organization_created_id_index', 'CREATE INDEX CONCURRENTLY clients_organization_created_id_index ON clients (organization_id, created_at DESC, id)');
// Tenant-scoped project pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'projects_organization_created_id_index', 'CREATE INDEX CONCURRENTLY projects_organization_created_id_index ON projects (organization_id, created_at DESC, id)');
// Speeds client relationship loads and the FK check when a client is deleted or its ID changes.
$this->runIndexOperation('create', 'projects_client_id_index', 'CREATE INDEX CONCURRENTLY projects_client_id_index ON projects (client_id)');
// Tenant-scoped task pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'tasks_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tasks_organization_created_id_index ON tasks (organization_id, created_at DESC, id)');
// Speeds project task lists and the FK check when a project is deleted or its ID changes.
$this->runIndexOperation('create', 'tasks_project_id_index', 'CREATE INDEX CONCURRENTLY tasks_project_id_index ON tasks (project_id)');
// Tenant-scoped tag pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'tags_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tags_organization_created_id_index ON tags (organization_id, created_at DESC, id)');
// Tenant-scoped report pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'reports_organization_created_id_index', 'CREATE INDEX CONCURRENTLY reports_organization_created_id_index ON reports (organization_id, created_at DESC, id)');
// Tenant-scoped member pagination; the existing (organization_id, user_id) unique index remains for membership lookup.
$this->runIndexOperation('create', 'members_organization_created_id_index', 'CREATE INDEX CONCURRENTLY members_organization_created_id_index ON members (organization_id, created_at DESC, id)');
// Supports reverse user-to-membership lookups and the FK check when a user is deleted or its ID changes.
$this->runIndexOperation('create', 'members_user_id_index', 'CREATE INDEX CONCURRENTLY members_user_id_index ON members (user_id)');
// The existing (project_id, user_id) unique index covers project_id, but member_id and legacy user_id need reverse indexes.
$this->runIndexOperation('create', 'project_members_member_id_index', 'CREATE INDEX CONCURRENTLY project_members_member_id_index ON project_members (member_id)');
$this->runIndexOperation('create', 'project_members_user_id_index', 'CREATE INDEX CONCURRENTLY project_members_user_id_index ON project_members (user_id)');
// Supports organization deletion/current-team cleanup and the FK check on users.current_team_id.
$this->runIndexOperation('create', 'users_current_team_id_index', 'CREATE INDEX CONCURRENTLY users_current_team_id_index ON users (current_team_id)');
// Filament loads the newest audits first; this avoids scanning and sorting the large append-only audit table.
$this->runIndexOperation('create', 'audits_created_at_index', 'CREATE INDEX CONCURRENTLY audits_created_at_index ON audits (created_at DESC)');
// Main tenant time-entry range/pagination path, including its start DESC, id ordering; also covers the organization FK.
$this->runIndexOperation('create', 'time_entries_organization_start_id_index', 'CREATE INDEX CONCURRENTLY time_entries_organization_start_id_index ON time_entries (organization_id, start DESC, id)');
// Filament lists time entries globally by creation time, so the tenant-prefixed index cannot provide this ordering.
$this->runIndexOperation('create', 'time_entries_created_at_index', 'CREATE INDEX CONCURRENTLY time_entries_created_at_index ON time_entries (created_at DESC)');
// Dashboard history is consistently filtered by user and organization, then bounded by start; user first also covers its FK.
$this->runIndexOperation('create', 'time_entries_user_organization_start_index', 'CREATE INDEX CONCURRENTLY time_entries_user_organization_start_index ON time_entries (user_id, organization_id, start)');
// Member timelines, overlap checks, and billable-rate updates start with member_id; also covers its FK.
$this->runIndexOperation('create', 'time_entries_member_start_index', 'CREATE INDEX CONCURRENTLY time_entries_member_start_index ON time_entries (member_id, start)');
// These relationship/filter indexes also prevent full scans for FK checks when parent rows change or are deleted.
$this->runIndexOperation('create', 'time_entries_project_id_index', 'CREATE INDEX CONCURRENTLY time_entries_project_id_index ON time_entries (project_id)');
$this->runIndexOperation('create', 'time_entries_task_id_index', 'CREATE INDEX CONCURRENTLY time_entries_task_id_index ON time_entries (task_id)');
$this->runIndexOperation('create', 'time_entries_client_id_index', 'CREATE INDEX CONCURRENTLY time_entries_client_id_index ON time_entries (client_id)');
// Active-timer checks touch only open entries, so a partial index stays small while serving the hot member_id lookup.
$this->runIndexOperation('create', 'time_entries_active_member_index', 'CREATE INDEX CONCURRENTLY time_entries_active_member_index ON time_entries (member_id) WHERE "end" IS NULL');
// whereJsonContains(tags, tag_id) compiles to JSONB containment, which is supported by a GIN index.
$this->runIndexOperation('create', 'time_entries_tags_gin_index', 'CREATE INDEX CONCURRENTLY time_entries_tags_gin_index ON time_entries USING GIN (tags)');
// Passport already indexes user_id; these indexes cover the other FK used during OAuth client deletion/update.
$this->runIndexOperation('create', 'oauth_access_tokens_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_access_tokens_client_id_index ON oauth_access_tokens (client_id)');
$this->runIndexOperation('create', 'oauth_auth_codes_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_auth_codes_client_id_index ON oauth_auth_codes (client_id)');
// owner_id is already the leading column of oauth_clients_owner_id_owner_type_index.
$this->runIndexOperation('drop', 'oauth_clients_user_id_index', 'DROP INDEX CONCURRENTLY oauth_clients_user_id_index');
// Public report lookup starts with the unique share_secret index; no query filters only by this boolean.
$this->runIndexOperation('drop', 'reports_is_public_index', 'DROP INDEX CONCURRENTLY reports_is_public_index');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$indexes = [
'clients_organization_created_id_index',
'projects_organization_created_id_index',
'projects_client_id_index',
'tasks_organization_created_id_index',
'tasks_project_id_index',
'tags_organization_created_id_index',
'reports_organization_created_id_index',
'members_organization_created_id_index',
'members_user_id_index',
'project_members_member_id_index',
'project_members_user_id_index',
'users_current_team_id_index',
'audits_created_at_index',
'time_entries_organization_start_id_index',
'time_entries_created_at_index',
'time_entries_user_organization_start_index',
'time_entries_member_start_index',
'time_entries_project_id_index',
'time_entries_task_id_index',
'time_entries_client_id_index',
'time_entries_active_member_index',
'time_entries_tags_gin_index',
'oauth_access_tokens_client_id_index',
'oauth_auth_codes_client_id_index',
];
$concurrently = DB::transactionLevel() === 0 ? ' CONCURRENTLY' : '';
foreach ($indexes as $index) {
$this->runIndexOperation('drop', $index, 'DROP INDEX'.$concurrently.' IF EXISTS '.$index);
}
$this->runIndexOperation('create', 'oauth_clients_user_id_index', 'CREATE INDEX'.$concurrently.' oauth_clients_user_id_index ON oauth_clients (owner_id)');
$this->runIndexOperation('create', 'reports_is_public_index', 'CREATE INDEX'.$concurrently.' reports_is_public_index ON reports (is_public)');
}
private function runIndexOperation(string $operation, string $index, string $statement): void
{
$indexState = $this->indexState($index);
if ($operation === 'create' && $indexState === ['valid' => true, 'ready' => true]) {
$this->writeProgress(sprintf('Skipping index [%s] because it already exists and is valid', $index));
return;
}
if ($operation === 'drop' && $indexState === null) {
$this->writeProgress(sprintf('Skipping index [%s] because it does not exist', $index));
return;
}
if ($operation === 'create' && $indexState !== null) {
$this->writeProgress(sprintf(
'Index [%s] exists but is incomplete (valid=%s, ready=%s); dropping it before rebuilding',
$index,
$indexState['valid'] ? 'true' : 'false',
$indexState['ready'] ? 'true' : 'false',
));
$this->executeIndexStatement(
'drop incomplete',
$index,
(DB::transactionLevel() === 0 ? 'DROP INDEX CONCURRENTLY ' : 'DROP INDEX ').$this->quoteIdentifier($index),
);
}
$this->executeIndexStatement($operation, $index, $statement);
}
private function executeIndexStatement(string $operation, string $index, string $statement): void
{
$startedAt = microtime(true);
$this->writeProgress(sprintf('Starting to %s index [%s]', $operation, $index));
try {
DB::statement($statement);
} catch (Throwable $exception) {
$this->writeProgress(sprintf(
'Failed to %s index [%s] after %.2f seconds: %s',
$operation,
$index,
microtime(true) - $startedAt,
$exception->getMessage(),
));
throw $exception;
}
$this->writeProgress(sprintf(
'Finished %s index [%s] in %.2f seconds',
$operation === 'create' ? 'creating' : 'dropping',
$index,
microtime(true) - $startedAt,
));
}
/**
* @return array{valid: bool, ready: bool}|null
*/
private function indexState(string $index): ?array
{
$state = DB::selectOne(
<<<'SQL'
SELECT pg_index.indisvalid::int AS valid, pg_index.indisready::int AS ready
FROM pg_index
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
WHERE pg_namespace.nspname = current_schema()
AND pg_class.relname = ?
SQL,
[$index],
);
if ($state === null) {
return null;
}
return [
'valid' => (bool) $state->valid,
'ready' => (bool) $state->ready,
];
}
private function quoteIdentifier(string $identifier): string
{
return DB::connection()->getQueryGrammar()->wrap($identifier);
}
private function writeProgress(string $message): void
{
if (app()->runningUnitTests()) {
return;
}
$message = sprintf('[%s] %s', date(DATE_ATOM), $message);
Log::info($message);
consoleInfo($message);
}
};

View File

@@ -189,9 +189,7 @@ ENV WITH_HORIZON=false \
WITH_SCHEDULER=false \
WITH_REVERB=false
COPY --link --chown=${WWWUSER}:${WWWUSER} . ./
RUN test -z "$(find . -name .git -print -quit)"
COPY --link --chown=${WWWUSER}:${WWWUSER} . .
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
RUN mkdir -p \

View File

@@ -1,278 +0,0 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import type { Page } from '@playwright/test';
import {
assertThatTimerHasStarted,
assertThatTimerIsStopped,
newTimeEntryResponse,
startOrStopTimerWithButton,
stoppedTimeEntryResponse,
} from './utils/currentTimeEntry';
import { createTimeEntryViaApi, updateOrganizationSettingViaApi } from './utils/api';
async function goToDashboard(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
}
function visibleBreakButton(page: Page) {
return page.getByRole('button', { name: 'Take a break' }).locator('visible=true').first();
}
// Breaks are disabled by default for new organizations, so enable them for the break flows.
// The tests that assert the disabled behaviour turn them back off explicitly.
test.beforeEach(async ({ ctx }) => {
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
});
test('test that switching to a break stops the work timer and starts a break entry', async ({
page,
}) => {
await goToDashboard(page);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
await page.getByTestId('time_entry_description').fill('Work before break');
await Promise.all([
newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
page.getByTestId('time_entry_description').press('Enter'),
]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
// Switch to break: stops the work entry and starts a break entry
await Promise.all([
newTimeEntryResponse(page, { description: '', type: 'break' }),
visibleBreakButton(page).click(),
]);
await expect(page.getByText('On break')).toBeVisible();
// The break bar offers a one-click resume that stops the break and restores
// the interrupted work context
await page.waitForTimeout(1500);
const resumeButton = page.getByRole('button', { name: 'Resume "Work before break"' });
await expect(resumeButton).toBeVisible();
await Promise.all([
stoppedTimeEntryResponse(page, { type: 'break' }),
newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
resumeButton.click(),
]);
await assertThatTimerHasStarted(page);
await expect(page.getByTestId('time_entry_description')).toHaveValue('Work before break');
// Cleanup: stop the running entry
await Promise.all([
stoppedTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that stopping a break returns to an idle tracker where a fresh entry starts normally', async ({
page,
}) => {
await goToDashboard(page);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
await page.getByTestId('time_entry_description').fill('Work before break');
await Promise.all([
newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
page.getByTestId('time_entry_description').press('Enter'),
]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
// Switch to a break
await Promise.all([
newTimeEntryResponse(page, { description: '', type: 'break' }),
visibleBreakButton(page).click(),
]);
await expect(page.getByText('On break')).toBeVisible();
// Stopping the break just ends it — no modal, the tracker returns to the
// empty idle input with focus so typing starts a fresh entry
await page.waitForTimeout(1500);
await Promise.all([
stoppedTimeEntryResponse(page, { type: 'break' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
await expect(page.getByTestId('time_entry_description')).toHaveValue('');
await expect(page.getByTestId('time_entry_description')).toBeFocused();
// A fresh entry is the normal start flow: type + Enter
await page.getByTestId('time_entry_description').fill('Fresh after break');
await Promise.all([
newTimeEntryResponse(page, { description: 'Fresh after break', type: 'work' }),
page.getByTestId('time_entry_description').press('Enter'),
]);
await assertThatTimerHasStarted(page);
// Cleanup: stop the running entry
await Promise.all([
stoppedTimeEntryResponse(page, { description: 'Fresh after break', type: 'work' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that the more options dropdown can start a break directly', async ({ page }) => {
await goToDashboard(page);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
// Start a break straight from the more options dropdown (no create modal)
await page.getByRole('button', { name: 'Time entry actions' }).click();
await Promise.all([
newTimeEntryResponse(page, { description: '', type: 'break' }),
page.getByRole('menuitem', { name: 'Start Break' }).click(),
]);
await expect(page.getByText('On break')).toBeVisible();
// Without interrupted work there is nothing to resume, so no resume button is offered
await expect(page.getByRole('button', { name: /^Resume/ })).toHaveCount(0);
// Cleanup: stop the break
await page.waitForTimeout(1500);
await Promise.all([
stoppedTimeEntryResponse(page, { type: 'break' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that disabling breaks hides every break-creation entry point', async ({ page, ctx }) => {
// Breaks disabled for the organization (delivered to the client via the organization endpoint)
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: false });
await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Regular work' });
// Calendar: the empty-slot context menu offers "Create Time Entry" but no "Add Break",
// and the edit modal drops the work-time/break type selector
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
await expect(page.locator('.fc')).toBeVisible();
const event = page.locator('.fc-event').filter({ hasText: 'Regular work' }).first();
await event.scrollIntoViewIfNeeded();
await expect(event).toBeVisible();
const box = await event.boundingBox();
expect(box).not.toBeNull();
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height + 40, { button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Create Time Entry' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Add Break' })).toHaveCount(0);
await page.keyboard.press('Escape');
await event.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Work time' })
).toHaveCount(0);
await page.keyboard.press('Escape');
// Timesheet: no break row is shown
await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet');
await expect(page.getByRole('button', { name: 'Add row' }).first()).toBeVisible();
await expect(page.getByText('Break', { exact: true })).toHaveCount(0);
// Dashboard tracker: no "Start Break" in the more options dropdown
await goToDashboard(page);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
await page.getByRole('button', { name: 'Time entry actions' }).click();
await expect(page.getByRole('menuitem', { name: 'Switch to simple mode' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Start Break' })).toHaveCount(0);
});
// The employee fixture registers a second user and accepts an invitation via Mailpit,
// which does not fit into the default per-test timeout.
test.describe('Org-level breaks setting', () => {
test.describe.configure({ timeout: 60000 });
test('test that the org-level breaks setting is respected for employees', async ({
ctx,
employee,
}) => {
const employeePage = employee.page;
// Breaks enabled (via beforeEach): the employee sees "Start Break" in the more options dropdown
await employeePage.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employeePage.getByTestId('dashboard_view')).toBeVisible();
await employeePage.getByRole('button', { name: 'Time entry actions' }).click();
await expect(
employeePage.getByRole('menuitem', { name: 'Switch to simple mode' })
).toBeVisible();
await expect(employeePage.getByRole('menuitem', { name: 'Start Break' })).toBeVisible();
await employeePage.keyboard.press('Escape');
// The owner disables breaks for the whole organization
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: false });
// The employee reloads: "Start Break" is gone from the dropdown
await employeePage.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employeePage.getByTestId('dashboard_view')).toBeVisible();
await employeePage.getByRole('button', { name: 'Time entry actions' }).click();
await expect(
employeePage.getByRole('menuitem', { name: 'Switch to simple mode' })
).toBeVisible();
await expect(employeePage.getByRole('menuitem', { name: 'Start Break' })).toHaveCount(0);
await employeePage.keyboard.press('Escape');
// With an active timer the break (coffee) button is not shown either
await employeePage.getByTestId('time_entry_description').fill('Employee work');
await Promise.all([
newTimeEntryResponse(employeePage, { description: 'Employee work', type: 'work' }),
employeePage.getByTestId('time_entry_description').press('Enter'),
]);
await assertThatTimerHasStarted(employeePage);
await expect(employeePage.getByRole('button', { name: 'Take a break' })).toHaveCount(0);
// Cleanup: stop the running entry
await Promise.all([
stoppedTimeEntryResponse(employeePage, { description: 'Employee work', type: 'work' }),
startOrStopTimerWithButton(employeePage),
]);
await assertThatTimerIsStopped(employeePage);
});
});
test('test that mass update warns about selected breaks and reports skipped entries instead of success', async ({
page,
ctx,
}) => {
// One work entry and one break: a billable mass update applies to the work
// entry but the server skips the break entirely — the UI must say so.
await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Mass update work entry' });
await createTimeEntryViaApi(ctx, { duration: '30min', type: 'break' });
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(page.locator('[data-testid="time_entry_row"]')).toHaveCount(2);
await page.getByLabel('Select All').click();
await expect(page.getByText('2 selected')).toBeVisible();
await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// No warning while the changeset is compatible with breaks
await expect(page.getByTestId('mass_update_break_warning')).not.toBeVisible();
// Making the entries billable is break-incompatible → warning appears
await page
.getByRole('dialog')
.getByRole('combobox')
.filter({ hasText: 'Set billable status' })
.click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
await expect(page.getByTestId('mass_update_break_warning')).toBeVisible();
await expect(page.getByTestId('mass_update_break_warning')).toContainText('skipped entirely');
// Submit: the work entry updates, the break is skipped, and the toast
// reports the skip instead of claiming success for all entries
const [massUpdateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'PATCH' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Time Entries' }).click(),
]);
const massUpdateBody = await massUpdateResponse.json();
expect(massUpdateBody.success.length).toBe(1);
expect(massUpdateBody.error.length).toBe(1);
await expect(page.getByText('1 of 2 time entries was skipped')).toBeVisible();
});

View File

@@ -2874,54 +2874,3 @@ test.describe('Daily Total After Create', () => {
}).toPass({ timeout: 5000 });
});
});
test('test that calendar context menu can add a break that fills the gap between two entries', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
// Two work entries today (09:00-10:00 and 11:00-12:00 UTC) with a one hour gap
const today = new Date().toISOString().slice(0, 10);
const gapStart = `${today}T10:00:00Z`;
const gapEnd = `${today}T11:00:00Z`;
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${today}T09:00:00Z`,
end: gapStart,
description: 'Gap work A',
});
await createTimeEntryWithTimestampsViaApi(ctx, {
start: gapEnd,
end: `${today}T12:00:00Z`,
description: 'Gap work B',
});
await goToCalendar(page);
const eventA = page.locator('.fc-event').filter({ hasText: 'Gap work A' }).first();
await eventA.scrollIntoViewIfNeeded();
await expect(eventA).toBeVisible();
// Right-click just below entry A (inside the gap, in the same day column)
const box = await eventA.boundingBox();
expect(box).not.toBeNull();
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height + 15, {
button: 'right',
});
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Add Break' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// The break is prefilled to fill the gap exactly
const [createResponse] = await Promise.all([
page.waitForResponse(
async (response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.type === 'break'
),
page.getByRole('button', { name: 'Add Break' }).click(),
]);
const body = await createResponse.json();
expect(body.data.start).toBe(gapStart);
expect(body.data.end).toBe(gapEnd);
});

View File

@@ -8,7 +8,7 @@ import {
createProjectViaApi,
createPublicProjectViaApi,
} from './utils/api';
import { clearTableState, getTableRowNames } from './utils/table';
import { getTableRowNames } from './utils/table';
async function goToClientsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
@@ -210,12 +210,18 @@ test('test that client context menu delete deletes the client', async ({ page, c
// Sorting Tests
// =============================================
async function clearClientTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('client-table-state');
});
}
test('test that sorting clients by name and status works', async ({ page, ctx }) => {
await createClientViaApi(ctx, { name: 'AAA SortClient' });
await createClientViaApi(ctx, { name: 'ZZZ SortClient' });
await goToClientsOverview(page);
await clearTableState(page, 'client-table-state');
await clearClientTableState(page);
await page.reload();
const table = page.getByTestId('client_table');
@@ -247,7 +253,7 @@ test('test that sorting clients by project count works', async ({ page, ctx }) =
await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id });
await goToClientsOverview(page);
await clearTableState(page, 'client-table-state');
await clearClientTableState(page);
await page.reload();
const table = page.getByTestId('client_table');
@@ -268,7 +274,7 @@ test('test that sorting clients by project count works', async ({ page, ctx }) =
test('test that client sort state persists after page reload', async ({ page }) => {
await goToClientsOverview(page);
await clearTableState(page, 'client-table-state');
await clearClientTableState(page);
await page.reload();
const table = page.getByTestId('client_table');
@@ -391,7 +397,7 @@ test.describe('Clients Pagination', () => {
);
await goToClientsOverview(page);
await clearTableState(page, 'client-table-state');
await clearClientTableState(page);
await page.reload();
// Default sort is name asc; first 15 clients (00–14) on page 1.
@@ -444,7 +450,7 @@ test.describe('Clients Pagination', () => {
);
await goToClientsOverview(page);
await clearTableState(page, 'client-table-state');
await clearClientTableState(page);
await page.reload();
await expect(page.getByTestId('client_table')).toBeVisible();
@@ -464,7 +470,7 @@ test.describe('Clients Pagination', () => {
);
await goToClientsOverview(page);
await clearTableState(page, 'client-table-state');
await clearClientTableState(page);
await page.reload();
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });

View File

@@ -11,7 +11,7 @@ import {
updateMemberBillableRateViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
import { clearTableState, getTableRowNames } from './utils/table';
import { getTableRowNames } from './utils/table';
// Tests that invite + accept members need more time
test.describe.configure({ timeout: 45000 });
@@ -779,18 +779,20 @@ test('test that accepted invitation disappears from invitations tab', async ({ p
// Sorting Tests
// =============================================
// Helper to clear localStorage before tests that check sorting
async function clearMemberTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('member-table-state');
});
}
test('test that sorting members by name, role, and status works', async ({ page, ctx }) => {
// Create two placeholder members with names that sort predictably around "John Doe".
// Seeded alphabetically a second apart: created_at only has second precision and
// same-second rows fall back to a random UUID order. The spacing is what makes the
// API order (created_at desc: ZZZ, AAA, John) deterministic, so the tie-break
// assertions below are testing the tie-break rather than a coin flip.
// Create two placeholder members with names that sort predictably around "John Doe"
await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst');
await page.waitForTimeout(1100);
await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast');
await goToMembersPage(page);
await clearTableState(page, 'member-table-state');
await clearMemberTableState(page);
await page.reload();
const table = page.getByTestId('member_table');
@@ -812,24 +814,20 @@ test('test that sorting members by name, role, and status works', async ({ page,
const ownerIdx = names.indexOf('John Doe');
const placeholderIdx = names.indexOf('AAA SortFirst');
expect(ownerIdx).toBeLessThan(placeholderIdx);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
await roleHeader.click(); // desc: Placeholder first
names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
// -- Status sorting --
const statusHeader = table.getByText('Status').first();
await statusHeader.click(); // asc: Active(0) < Inactive(1)
names = await getTableRowNames(table);
expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst'));
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
await statusHeader.click(); // desc: Inactive first
names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
// -- Email: just verify sort indicator appears --
const emailHeader = table.getByText('Email').first();
@@ -839,7 +837,7 @@ test('test that sorting members by name, role, and status works', async ({ page,
test('test that member sort state persists after page reload', async ({ page }) => {
await goToMembersPage(page);
await clearTableState(page, 'member-table-state');
await clearMemberTableState(page);
await page.reload();
const table = page.getByTestId('member_table');
@@ -877,7 +875,7 @@ test('test that sorting members by billable rate works', async ({ page, ctx }) =
await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000);
await goToMembersPage(page);
await clearTableState(page, 'member-table-state');
await clearMemberTableState(page);
await page.reload();
const table = page.getByTestId('member_table');

View File

@@ -4,7 +4,7 @@ import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
async function goToOrganizationSettings(page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await page.locator('[data-testid="organization_switcher"]:visible').click();
await page.getByRole('menuitem', { name: 'Organization Settings' }).click();
await page.getByText('Organization Settings').click();
}
async function createTimeEntry(page, duration: string) {

View File

@@ -13,12 +13,18 @@ import {
archiveProjectViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
import { clearTableState, getSeededRowOrder } from './utils/table';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
}
// Helper to clear localStorage before tests that check persistence
async function clearProjectTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('project-table-state');
});
}
// Create new project via modal
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
@@ -78,7 +84,7 @@ test('test that archiving and unarchiving projects works', async ({ page, ctx })
await createProjectViaApi(ctx, { name: newProjectName });
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await clearProjectTableState(page);
await page.reload();
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
@@ -474,7 +480,7 @@ test('test that sorting projects by all columns works', async ({ page, ctx }) =>
});
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await clearProjectTableState(page);
await page.reload();
await expect(page.getByTestId('project_table')).toBeVisible();
await expect(page.getByText('AAA Project')).toBeVisible();
@@ -603,7 +609,7 @@ test('test that filtering projects by status works', async ({ page, ctx }) => {
await createProjectViaApi(ctx, { name: newProjectName });
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await clearProjectTableState(page);
await page.reload();
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
@@ -634,7 +640,7 @@ test('test that filtering projects by status works', async ({ page, ctx }) => {
test('test that filter state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await clearProjectTableState(page);
await page.reload();
// Apply Active status filter
@@ -650,96 +656,9 @@ test('test that filter state persists after page reload', async ({ page }) => {
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
});
test('test that projects without a client or estimate are ordered by name at the bottom', async ({
page,
ctx,
}) => {
// Seeded a second apart: created_at only has second precision and same-second rows
// fall back to a random UUID order. The spacing makes the API order of the clientless
// rows (created_at desc: ZZZ, AAA) deterministic and different from the alphabetical
// order the name tie-break should produce.
await createProjectViaApi(ctx, { name: 'AAA Tiebreak Project' });
await page.waitForTimeout(1100);
await createProjectViaApi(ctx, { name: 'ZZZ Tiebreak Project' });
const clientAardvark = await createClientViaApi(ctx, { name: 'Aardvark Co' });
const clientZulu = await createClientViaApi(ctx, { name: 'Zulu Co' });
const projectM = await createProjectViaApi(ctx, {
name: 'MMM Tiebreak Project',
client_id: clientAardvark.id,
estimated_time: 36000, // 10h, 1h tracked below = 10%
});
await createTimeEntryViaApi(ctx, { duration: '1h', projectId: projectM.id });
const projectN = await createProjectViaApi(ctx, {
name: 'NNN Tiebreak Project',
client_id: clientZulu.id,
estimated_time: 14400, // 4h, 2h tracked below = 50%
});
await createTimeEntryViaApi(ctx, { duration: '2h', projectId: projectN.id });
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await page.reload();
const table = page.getByTestId('project_table');
await expect(table).toBeVisible();
const seeded = [
'AAA Tiebreak Project',
'MMM Tiebreak Project',
'NNN Tiebreak Project',
'ZZZ Tiebreak Project',
];
const getOrder = () => getSeededRowOrder(table, seeded);
// -- Client: empty rows last in both directions, alphabetical among themselves --
const clientHeader = table.locator('.select-none', { hasText: 'Client' }).first();
await clientHeader.click();
await expect
.poll(getOrder)
.toEqual([
'MMM Tiebreak Project',
'NNN Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
await clientHeader.click();
await expect
.poll(getOrder)
.toEqual([
'NNN Tiebreak Project',
'MMM Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
// -- Progress: same, and the first click sorts highest first --
const progressHeader = table.locator('.select-none', { hasText: 'Progress' }).first();
await progressHeader.click();
await expect
.poll(getOrder)
.toEqual([
'NNN Tiebreak Project',
'MMM Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
await progressHeader.click();
await expect
.poll(getOrder)
.toEqual([
'MMM Tiebreak Project',
'NNN Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
});
test('test that sort state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await clearProjectTableState(page);
await page.reload();
// Click on Name header twice to sort descending
@@ -1195,7 +1114,7 @@ test.describe('Projects Pagination', () => {
);
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await clearProjectTableState(page);
await page.reload();
// Default sort is name asc; first 15 projects (00–14) should be on page 1.
@@ -1249,7 +1168,7 @@ test.describe('Projects Pagination', () => {
);
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await clearProjectTableState(page);
await page.reload();
await expect(page.getByTestId('project_table')).toBeVisible();
@@ -1266,7 +1185,7 @@ test.describe('Projects Pagination', () => {
);
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await clearProjectTableState(page);
await page.reload();
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
@@ -1288,36 +1207,3 @@ test.describe('Projects Pagination', () => {
await expect(page.getByText(prefix + '00')).not.toBeVisible();
});
});
test('test that searching projects by name works', async ({ page, ctx }) => {
const suffix = Math.floor(1 + Math.random() * 10000);
const matchingProjectName = 'Searchable Project ' + suffix;
const otherProjectName = 'Unrelated Work ' + suffix;
await createProjectViaApi(ctx, { name: matchingProjectName });
await createProjectViaApi(ctx, { name: otherProjectName });
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await page.reload();
await expect(page.getByText(matchingProjectName)).toBeVisible({ timeout: 10000 });
await expect(page.getByText(otherProjectName)).toBeVisible();
const searchInput = page.getByRole('searchbox', { name: 'Search projects' });
// Searching is case insensitive and matches part of the name
await searchInput.fill('SEARCHABLE');
await expect(page.getByText(matchingProjectName)).toBeVisible();
await expect(page.getByText(otherProjectName)).not.toBeVisible();
// A term that matches nothing empties the table
await searchInput.fill('no project has this name');
await expect(page.getByText(matchingProjectName)).not.toBeVisible();
await expect(page.getByText(otherProjectName)).not.toBeVisible();
await expect(page.getByText('No matching projects')).toBeVisible();
await expect(page.getByRole('button', { name: 'Create your First Project' })).not.toBeVisible();
// Clearing the search restores both projects
await searchInput.fill('');
await expect(page.getByText(matchingProjectName)).toBeVisible();
await expect(page.getByText(otherProjectName)).toBeVisible();
});

View File

@@ -1,69 +0,0 @@
import { expect } from '@playwright/test';
import { test } from '../playwright/fixtures';
import { goToReportingDetailed, waitForDetailedReportingUpdate } from './utils/reporting';
import { createTimeEntryWithTagViaApi } from './utils/api';
// Each test registers a new user and creates test data via the API
test.describe.configure({ timeout: 30000 });
test('detailed reporting: "Does Not Contain" excludes entries with the selected tag', async ({
page,
ctx,
}) => {
const tagA = 'MatchTagA ' + Math.floor(Math.random() * 10000);
const tagB = 'MatchTagB ' + Math.floor(Math.random() * 10000);
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
await goToReportingDetailed(page);
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
// Open the Tags dropdown, select tagA, then switch the match mode to "Does Not Contain"
await page.getByRole('button', { name: 'Tags' }).click();
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('option').filter({ hasText: tagA }).click(),
]);
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
]);
await page.keyboard.press('Escape');
// The entry with tagA is excluded; the entry with tagB remains
await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0);
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
});
test('detailed reporting: toggling between "Contains" and "Does Not Contain" flips the result', async ({
page,
ctx,
}) => {
const tagA = 'ToggleTagA ' + Math.floor(Math.random() * 10000);
const tagB = 'ToggleTagB ' + Math.floor(Math.random() * 10000);
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
await goToReportingDetailed(page);
await page.getByRole('button', { name: 'Tags' }).click();
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('option').filter({ hasText: tagA }).click(),
]);
// "Contains" tagA -> only the tagA entry is listed
await page.keyboard.press('Escape');
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tagB}`)).toHaveCount(0);
// "Does Not Contain" tagA -> flips to the tagB entry
await page.getByRole('button', { name: 'Tags' }).click();
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
]);
await page.keyboard.press('Escape');
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0);
});

View File

@@ -10,7 +10,6 @@ import {
createTimeEntryWithTagViaApi,
createTimeEntryWithBillableStatusViaApi,
createBareTimeEntryViaApi,
createTimeEntryOnDateViaApi,
createPublicProjectViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
@@ -842,127 +841,6 @@ test('test that setting group by to current sub group triggers sub group fallbac
await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible();
});
test('test that group by date groups the report by day and formats the date labels with organization settings', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { date_format: 'point-separated-d-m-yyyy' });
await createTimeEntryViaApi(ctx, {
description: 'Entry for group by date',
duration: '1h',
});
// Go to reporting page
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
// Find the "Group by" selects within the reporting table
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
// Default state: group=Project
await groupBySelects.filter({ hasText: 'Project' }).first().click();
const [aggregateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=day') &&
response.status() === 200
),
page.getByRole('option', { name: 'Date', exact: true }).click(),
]);
// Verify the API request contains the correct group parameter
const requestUrl = new URL(aggregateResponse.url());
expect(requestUrl.searchParams.get('group')).toBe('day');
// The row label is rendered in the organization date format (D.M.YYYY)
await expect(
page.getByTestId('reporting_view').getByText(/^\d{1,2}\.\d{1,2}\.\d{4}$/)
).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(
0
);
});
test('test that group by week requests week grouping and does not leak the raw group key', async ({
page,
ctx,
}) => {
await createTimeEntryViaApi(ctx, {
description: 'Entry for group by week',
duration: '1h',
});
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
await groupBySelects.filter({ hasText: 'Project' }).first().click();
const [aggregateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=week') &&
response.status() === 200
),
page.getByRole('option', { name: 'Week', exact: true }).click(),
]);
const requestUrl = new URL(aggregateResponse.url());
expect(requestUrl.searchParams.get('group')).toBe('week');
// The raw group key is the first day of the week and must not leak through.
await expect(page.getByTestId('reporting_view').getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(
0
);
});
test('test that group by week labels a week spanning new year with a range crossing the year', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { date_format: 'slash-separated-dd-mm-yyyy' });
for (const day of ['2025-12-22', '2025-12-29', '2026-01-05']) {
await createTimeEntryOnDateViaApi(ctx, {
date: new Date(`${day}T09:00:00Z`),
duration: '1h',
description: `Entry for ${day}`,
});
}
// The reporting page keeps its range in session storage, so seed a range spanning new year
// rather than driving the date picker.
await page.addInitScript(() => {
window.sessionStorage.setItem('reporting-start-date', '2025-12-15');
window.sessionStorage.setItem('reporting-end-date', '2026-01-15');
});
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
await groupBySelects.filter({ hasText: 'Project' }).first().click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=week') &&
response.status() === 200
),
page.getByRole('option', { name: 'Week', exact: true }).click(),
]);
const reportingView = page.getByTestId('reporting_view');
await expect(reportingView.getByText('22/12/2025 - 28/12/2025', { exact: true })).toBeVisible();
await expect(reportingView.getByText('29/12/2025 - 04/01/2026', { exact: true })).toBeVisible();
await expect(reportingView.getByText('05/01/2026 - 11/01/2026', { exact: true })).toBeVisible();
await expect(reportingView.getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(0);
});
// ──────────────────────────────────────────────────
// Export Tests
// ──────────────────────────────────────────────────
@@ -1141,24 +1019,3 @@ test.describe('Employee Reporting Restrictions', () => {
await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible();
});
});
test('test that reporting has a type filter that can show only breaks', async ({ page, ctx }) => {
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Regular work entry' });
await createTimeEntryViaApi(ctx, { duration: '20min', type: 'break' });
await goToReporting(page);
// The type filter defaults to "Work time"; switching it to "Breaks" re-aggregates.
const typeFilter = page.getByRole('combobox').filter({ hasText: 'Work time' });
await expect(typeFilter).toBeVisible();
await typeFilter.click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('type=break') &&
response.status() === 200
),
page.getByRole('option', { name: 'Breaks' }).click(),
]);
});

View File

@@ -16,7 +16,6 @@ import {
createTimeEntryWithBillableStatusViaApi,
createTagViaApi,
createReportViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
import {
goToReporting,
@@ -69,42 +68,6 @@ test('test that saving a report creates a shared report and its shareable link s
await expect(page.getByText('Total')).toBeVisible();
});
test('test that a shared report grouped by date shows date labels formatted by the organization setting', async ({
page,
ctx,
}) => {
const reportName = 'DateGroupReport ' + Math.floor(Math.random() * 10000);
await updateOrganizationSettingViaApi(ctx, { date_format: 'point-separated-d-m-yyyy' });
await createTimeEntryViaApi(ctx, {
description: 'Entry for date grouping',
duration: '1h',
});
await goToReporting(page);
// Switch the grouping to "Date"
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
await groupBySelects.filter({ hasText: 'Project' }).first().click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=day') &&
response.status() === 200
),
page.getByRole('option', { name: 'Date', exact: true }).click(),
]);
const { shareableLink } = await saveAsSharedReport(page, reportName);
// Verify row labels are formatted correctly
await page.goto(shareableLink);
await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText(/^\d{1,2}\.\d{1,2}\.\d{4}$/)).toBeVisible();
await expect(page.getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(0);
});
test('test that shared report with invalid secret shows no data', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value');
await expect(page.getByText('No time entries found').first()).toBeVisible();

View File

@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { createTagViaApi } from './utils/api';
import { clearTableState, getTableRowNames } from './utils/table';
import { getTableRowNames } from './utils/table';
async function goToTagsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/tags');
@@ -147,12 +147,18 @@ test('test that tag context menu delete deletes the tag', async ({ page, ctx })
// Sorting Tests
// =============================================
async function clearTagTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('tag-table-state');
});
}
test('test that sorting tags by name works', async ({ page, ctx }) => {
await createTagViaApi(ctx, { name: 'AAA SortTag' });
await createTagViaApi(ctx, { name: 'ZZZ SortTag' });
await goToTagsOverview(page);
await clearTableState(page, 'tag-table-state');
await clearTagTableState(page);
await page.reload();
const table = page.getByTestId('tag_table');
@@ -170,7 +176,7 @@ test('test that sorting tags by name works', async ({ page, ctx }) => {
test('test that tag sort state persists after page reload', async ({ page }) => {
await goToTagsOverview(page);
await clearTableState(page, 'tag-table-state');
await clearTagTableState(page);
await page.reload();
const table = page.getByTestId('tag_table');

View File

@@ -7,56 +7,13 @@ import {
createPublicProjectViaApi,
createTaskViaApi,
createClientViaApi,
createTimeEntryViaApi,
updateOrganizationSettingViaApi,
type TestContext,
} from './utils/api';
import { clearTableState, getTableRowNames } from './utils/table';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
}
async function createSortableTasks(ctx: TestContext) {
const project = await createProjectViaApi(ctx, { name: 'Task Sorting Project' });
const taskA = await createTaskViaApi(ctx, {
name: 'AAA Sorting Task',
project_id: project.id,
estimated_time: 36000,
});
const taskB = await createTaskViaApi(ctx, {
name: 'BBB Sorting Task',
project_id: project.id,
estimated_time: 14400,
});
const taskC = await createTaskViaApi(ctx, {
name: 'CCC Sorting Task',
project_id: project.id,
});
expect(taskA.estimated_time).toBe(36000);
expect(taskB.estimated_time).toBe(14400);
expect(taskC.estimated_time).toBeNull();
await createTimeEntryViaApi(ctx, {
duration: '1h',
projectId: project.id,
taskId: taskA.id,
});
await createTimeEntryViaApi(ctx, {
duration: '2h',
projectId: project.id,
taskId: taskB.id,
});
await createTimeEntryViaApi(ctx, {
duration: '3h',
projectId: project.id,
taskId: taskC.id,
});
return { project, taskA, taskB, taskC };
}
test('test that creating and deleting a new task in a new project works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
@@ -344,59 +301,6 @@ test('test that creating a new project from the task create modal project dropdo
await expect(page.getByTestId('task_table')).toContainText(newTaskName);
});
// =============================================
// Sorting Tests
// =============================================
test('test that sorting tasks by name, total time and progress works', async ({ page, ctx }) => {
const { project, taskA, taskB, taskC } = await createSortableTasks(ctx);
await goToProjectsOverview(page);
await clearTableState(page, 'task-table-state');
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
const table = page.getByTestId('task_table');
await expect(table).toBeVisible();
// This project contains only the seeded tasks, so assert the complete order.
const expectOrder = async (expected: string[]) => {
await expect.poll(() => getTableRowNames(table)).toEqual(expected);
};
const clickHeader = async (headerText: string) => {
await table.getByText(headerText).first().click();
};
await expectOrder([taskA.name, taskB.name, taskC.name]);
await clickHeader('Task Name');
await expectOrder([taskC.name, taskB.name, taskA.name]);
await clickHeader('Task Name');
await expectOrder([taskA.name, taskB.name, taskC.name]);
await clickHeader('Total Time');
await expectOrder([taskC.name, taskB.name, taskA.name]);
await clickHeader('Total Time');
await expectOrder([taskA.name, taskB.name, taskC.name]);
await clickHeader('Progress');
await expectOrder([taskB.name, taskA.name, taskC.name]);
await clickHeader('Progress');
await expectOrder([taskA.name, taskB.name, taskC.name]);
});
test('test that task sort state persists after page reload', async ({ page, ctx }) => {
const { project, taskA, taskB, taskC } = await createSortableTasks(ctx);
await goToProjectsOverview(page);
await clearTableState(page, 'task-table-state');
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
const table = page.getByTestId('task_table');
await expect(table).toBeVisible();
await table.getByText('Progress').first().click();
await expect.poll(() => getTableRowNames(table)).toEqual([taskB.name, taskA.name, taskC.name]);
await page.reload();
// Verify the persisted row order, not just the sort indicator.
await expect.poll(() => getTableRowNames(table)).toEqual([taskB.name, taskA.name, taskC.name]);
});
// =============================================
// Employee Permission Tests
// =============================================

View File

@@ -50,7 +50,7 @@ async function goToTimeOverview(page: Page) {
async function goToOrganizationSettings(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await page.locator('[data-testid="organization_switcher"]:visible').click();
await page.getByRole('menuitem', { name: 'Organization Settings' }).click();
await page.getByText('Organization Settings').click();
}
async function createEmptyTimeEntry(page: Page) {
@@ -2303,21 +2303,3 @@ test('test that aggregate row context menu delete removes all grouped entries',
page.locator('[data-testid="time_entry_row"]').filter({ hasText: description })
).not.toBeVisible();
});
test('test that break entries show a break badge and split day total on the time page', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
await createTimeEntryViaApi(ctx, { duration: '2h', description: 'Some work' });
await createTimeEntryViaApi(ctx, { duration: '30min', type: 'break', description: '' });
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(page.getByTestId('break_badge').first()).toBeVisible();
await expect(page.getByTestId('break_badge').first()).toContainText('Break');
// Day heading shows worked time first, then the break portion
await expect(page.getByTestId('day_break_duration').first()).toBeVisible();
await expect(page.getByTestId('day_break_duration').first().locator('..')).toContainText(
'2h 00min work · 0h 30min break'
);
});

View File

@@ -2,15 +2,7 @@ import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import {
createProjectViaApi,
createTaskViaApi,
createTimeEntryOnDateViaApi,
createTimeEntryWithTimestampsViaApi,
getTimeEntriesViaApi,
updateOrganizationSettingViaApi,
type TestContext,
} from './utils/api';
import { createProjectViaApi, createTaskViaApi, createTimeEntryOnDateViaApi } from './utils/api';
// ──────────────────────────────────────────────────
// Helpers
@@ -66,34 +58,6 @@ function addRowButton(page: Page) {
return page.getByRole('button', { name: /Add row/i }).first();
}
async function fillBreakCell(page: Page, hours: string, dayIndex = 0) {
const input = page
.locator('[data-testid="timesheet_row"]')
.filter({ has: page.getByText('Break', { exact: true }) })
.locator('[data-testid="timesheet_cell"]')
.nth(dayIndex)
.locator('input');
await input.click();
await input.fill(hours);
return input;
}
function waitForBreakCreated(page: Page) {
return page.waitForResponse(
async (resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201 &&
(await resp.json()).data.type === 'break'
);
}
async function getDayEntriesViaApi(ctx: TestContext, day: string) {
return (await getTimeEntriesViaApi(ctx))
.filter((e) => e.start.startsWith(day))
.sort((a, b) => a.start.localeCompare(b.start));
}
async function chooseRowIdentity(page: Page, optionName: string) {
await addRowButton(page).click();
@@ -675,252 +639,3 @@ test('cell accepts various duration input formats', async ({ page, ctx }) => {
// 1.5 hours = 1h 30min
await expect(mondayInput).toHaveValue('1h 30min');
});
test('test that adding a timesheet break to a full day splits the work entry via the placement modal', async ({
page,
ctx,
}) => {
// A single work entry filling the day leaves no gap for a break, so the placement
// modal must offer to split it (the only entry) and drop the break in the middle.
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T09:00:00Z`,
end: `${day}T17:00:00Z`,
description: 'Split me',
});
await goToTimesheet(page);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
// The break row is always present — enter a 30m break on Monday
const breakCell = await fillBreakCell(page, '0.5');
await breakCell.press('Enter');
// The placement modal opens with the split preview, naming the entry that
// will be split so the user can recognize it.
await expect(page.getByTestId('break_placement_summary')).toBeVisible();
await expect(page.getByTestId('break_placement_summary')).toContainText(
'No Project · Split me'
);
await Promise.all([
waitForBreakCreated(page),
page.getByRole('button', { name: 'Add break' }).click(),
]);
// The break is inserted without reducing the eight hours of work.
const dayEntries = await getDayEntriesViaApi(ctx, day);
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
['work', `${day}T09:00:00Z`, `${day}T13:00:00Z`],
['break', `${day}T13:00:00Z`, `${day}T13:30:00Z`],
['work', `${day}T13:30:00Z`, `${day}T17:30:00Z`],
]);
});
test('test that adding a break into an oversized gap places it without moving other entries', async ({
page,
ctx,
}) => {
// 09-12 and 15-17 leave a 3h gap — wider than the placement tolerance allows,
// but easily big enough to hold the break. Such a gap is deliberate (the app
// itself never creates one), so the break goes flush after the morning entry
// and nothing else moves — no placement modal.
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T09:00:00Z`,
end: `${day}T12:00:00Z`,
description: 'Morning',
});
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T15:00:00Z`,
end: `${day}T17:00:00Z`,
description: 'Afternoon',
});
await goToTimesheet(page);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
const breakCell = await fillBreakCell(page, '0.5');
await Promise.all([waitForBreakCreated(page), breakCell.press('Enter')]);
await expect(page.getByTestId('break_placement_summary')).not.toBeVisible();
const dayEntries = await getDayEntriesViaApi(ctx, day);
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
['work', `${day}T09:00:00Z`, `${day}T12:00:00Z`],
['break', `${day}T12:00:00Z`, `${day}T12:30:00Z`],
['work', `${day}T15:00:00Z`, `${day}T17:00:00Z`],
]);
});
test('test that the placement modal warns when the chosen time would leave the break misaligned', async ({
page,
ctx,
}) => {
// Back-to-back 09-12 and 12-17 leave no gap, so the placement modal opens.
// The suggested slot (flush at 12:00) is aligned — no warning. Moving the
// break to 07:00, before any work, keeps the plan feasible but the result
// would immediately carry the misaligned hint, so the modal warns upfront.
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T09:00:00Z`,
end: `${day}T12:00:00Z`,
description: 'Morning',
});
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T12:00:00Z`,
end: `${day}T17:00:00Z`,
description: 'Afternoon',
});
await goToTimesheet(page);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
const breakCell = await fillBreakCell(page, '0.5');
await breakCell.press('Enter');
// Default suggestion sits flush between work → no warning
await expect(page.getByTestId('break_placement_summary')).toBeVisible();
await expect(page.getByTestId('break_placement_misaligned_warning')).not.toBeVisible();
// Move the break to 07:00-07:30, before all work
const modal = page.getByRole('dialog');
const startTimeInput = modal.getByTestId('time_picker_input').first();
await startTimeInput.fill('07:00');
await startTimeInput.press('Tab');
const endTimeInput = modal.getByTestId('time_picker_input').nth(1);
await endTimeInput.fill('07:30');
await endTimeInput.press('Tab');
// Feasible (nothing has to move), but flagged as misaligned beforehand
await expect(page.getByTestId('break_placement_misaligned_warning')).toBeVisible();
await expect(page.getByTestId('break_placement_summary')).toContainText(
'No entries need to move.'
);
// The warning is non-blocking: the break can still be added as chosen
await Promise.all([
waitForBreakCreated(page),
page.getByRole('button', { name: 'Add break' }).click(),
]);
const dayEntries = await getDayEntriesViaApi(ctx, day);
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
['break', `${day}T07:00:00Z`, `${day}T07:30:00Z`],
['work', `${day}T09:00:00Z`, `${day}T12:00:00Z`],
['work', `${day}T12:00:00Z`, `${day}T17:00:00Z`],
]);
// ...and the timesheet now shows the misaligned-break hint for that day
const hint = page.getByRole('button', {
name: 'does not align with your work entries',
});
await expect(hint).toBeVisible();
// The resulting warning links to the calendar on the affected date.
await hint.click();
await expect(page.getByRole('link', { name: 'Fix in calendar' })).toHaveAttribute(
'href',
`/calendar?date=${day}`
);
});
test('test that editing a timesheet break re-places it as one entry instead of fragmenting it', async ({
page,
ctx,
}) => {
// Two work entries with a 1h gap, and a 30m break created directly inside it (12:15–12:45).
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T09:00:00Z`,
end: `${day}T12:00:00Z`,
description: 'Work',
});
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T13:00:00Z`,
end: `${day}T17:00:00Z`,
description: 'Work',
});
const breakEntry = await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T12:15:00Z`,
end: `${day}T12:45:00Z`,
type: 'break',
});
await goToTimesheet(page);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
const breakCell = await fillBreakCell(page, '0.75');
await Promise.all([
// A break that still fits its gap is re-placed in place (PUT on the same entry),
// not deleted and recreated — that's what keeps it a single entry.
page.waitForResponse(
async (resp) =>
resp.url().includes(`/time-entries/${breakEntry.id}`) &&
resp.request().method() === 'PUT' &&
resp.status() === 200 &&
(await resp.json()).data.type === 'break'
),
breakCell.press('Enter'),
]);
// Still exactly one break on the day (not fragmented). It stays anchored at its current
// start (12:15) rather than re-centering, growing its end to 13:00 to reach 45 minutes.
const breaks = (await getDayEntriesViaApi(ctx, day)).filter((e) => e.type === 'break');
expect(breaks).toHaveLength(1);
expect(breaks[0].duration).toBe(2700);
expect(breaks[0].start).toBe(`${day}T12:15:00Z`);
expect(breaks[0].end).toBe(`${day}T13:00:00Z`);
});
test('test that editing an adjacent break vacates its old slot before extending work', async ({
page,
ctx,
}) => {
// The existing break must move before work can extend through its old slot.
await updateOrganizationSettingViaApi(ctx, {
breaks_enabled: true,
prevent_overlapping_time_entries: true,
});
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T09:00:00Z`,
end: `${day}T17:00:00Z`,
description: 'Work before break',
});
const breakEntry = await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T17:00:00Z`,
end: `${day}T17:30:00Z`,
type: 'break',
});
await goToTimesheet(page);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
const breakCell = await fillBreakCell(page, '1');
await breakCell.press('Enter');
await expect(page.getByTestId('break_placement_summary')).toBeVisible();
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes(`/time-entries/${breakEntry.id}`) &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
page.waitForResponse(
async (resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201 &&
(await resp.json()).data.type === 'work'
),
page.getByRole('button', { name: 'Add break' }).click(),
]);
const entries = await getDayEntriesViaApi(ctx, day);
expect(entries.map((entry) => [entry.id, entry.type, entry.start, entry.end])).toEqual([
[expect.any(String), 'work', `${day}T09:00:00Z`, `${day}T13:00:00Z`],
[breakEntry.id, 'break', `${day}T13:00:00Z`, `${day}T14:00:00Z`],
[expect.any(String), 'work', `${day}T14:00:00Z`, `${day}T18:00:00Z`],
]);
});

View File

@@ -13,7 +13,6 @@ import {
createProjectViaApi,
createTaskViaApi,
createClientViaApi,
createTimeEntryViaApi,
archiveProjectViaApi,
markTaskDoneViaApi,
updateOrganizationCurrencyViaWeb,
@@ -376,66 +375,6 @@ test('test that timer started on dashboard is visible on time page', async ({ pa
await assertThatTimerIsStopped(page);
});
test('test that picking a recently tracked entry starts a timer with its fields', async ({
page,
ctx,
}) => {
const project = await createProjectViaApi(ctx, {
name: `RecentProj ${Math.floor(Math.random() * 100000)}`,
is_billable: false,
});
await createTimeEntryViaApi(ctx, {
description: 'Recent work item',
duration: '1h',
projectId: project.id,
});
await goToDashboard(page);
const description = page.getByTestId('time_entry_description');
await expect(description).toBeEditable();
// Focusing the description opens the "Recently Tracked" dropdown listing the finished entry.
await description.click();
const recentEntry = page.getByText('Recent work item').first();
await expect(recentEntry).toBeVisible();
// Clicking it (mousedown) copies its fields — including the project — into a new running entry.
await Promise.all([
page.waitForResponse(async (response) => {
if (
!response.url().includes('/time-entries') ||
response.request().method() !== 'POST' ||
response.status() !== 201
) {
return false;
}
const body = await response.json();
return (
body.data.description === 'Recent work item' &&
body.data.project_id === project.id &&
body.data.end === null
);
}),
recentEntry.click(),
]);
await assertThatTimerHasStarted(page);
await expect(description).toHaveValue('Recent work item');
await expect(page.getByRole('button', { name: project.name })).toBeVisible();
// Cleanup: stop the running (project-bearing) entry
await Promise.all([
page.waitForResponse(async (response) => {
if (response.status() !== 200 || !response.url().includes('/time-entries/')) {
return false;
}
const body = await response.json();
return body.data.description === 'Recent work item' && body.data.end !== null;
}),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that creating a new project from the time tracker dropdown prefills the search text', async ({
page,
ctx,
@@ -742,39 +681,3 @@ test.describe('Project Task Dropdown', () => {
await expect(page.getByRole('button', { name: projectName })).toBeVisible();
});
});
test('test that simple mode hides the project, tag and billable controls', async ({ page }) => {
await goToDashboard(page);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
// Project mode shows the project and billable controls
await expect(page.getByRole('button', { name: 'No Project' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Non Billable' }).first()).toBeVisible();
// Switch to simple mode via the more options dropdown (client-side preference, no request)
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Switch to simple mode' }).click();
// Simple mode is the project tracker without the project/tag/billable selectors; the
// description input and clock-in/out stay.
await expect(page.getByTestId('time_entry_description')).toBeEditable();
await expect(page.getByRole('button', { name: 'No Project' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Non Billable' })).toHaveCount(0);
// Clock in and out
await Promise.all([
newTimeEntryResponse(page, { type: 'work' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
await Promise.all([
stoppedTimeEntryResponse(page, { type: 'work' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
// Switch back to project mode: the controls return
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Switch to project mode' }).click();
await expect(page.getByRole('button', { name: 'No Project' })).toBeVisible();
});

View File

@@ -357,7 +357,7 @@ export async function createProjectWithClientViaApi(
export async function createTaskViaApi(
ctx: TestContext,
data: { name: string; project_id: string; estimated_time?: number }
data: { name: string; project_id: string }
) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tasks`,
@@ -365,20 +365,12 @@ export async function createTaskViaApi(
data: {
name: data.name,
project_id: data.project_id,
...(data.estimated_time !== undefined
? { estimated_time: data.estimated_time }
: {}),
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as {
id: string;
name: string;
project_id: string;
estimated_time: number | null;
};
return body.data as { id: string; name: string; project_id: string };
}
export async function markTaskDoneViaApi(ctx: TestContext, task: { id: string; name: string }) {
@@ -414,7 +406,6 @@ export async function createTimeEntryViaApi(
taskId?: string | null;
tags?: string[];
billable?: boolean;
type?: 'work' | 'break';
}
) {
const { start, end } = createTimestamps(data.duration);
@@ -430,7 +421,6 @@ export async function createTimeEntryViaApi(
task_id: data.taskId ?? null,
tags: data.tags ?? [],
billable: data.billable ?? false,
type: data.type ?? 'work',
},
}
);
@@ -764,7 +754,6 @@ export async function getTimeEntriesViaApi(
project_id: string | null;
task_id: string | null;
description: string;
type: 'work' | 'break';
}>
> {
const params = new URLSearchParams();
@@ -790,7 +779,6 @@ export async function createTimeEntryWithTimestampsViaApi(
taskId?: string | null;
tags?: string[];
billable?: boolean;
type?: 'work' | 'break';
}
) {
const response = await ctx.request.post(
@@ -805,19 +793,12 @@ export async function createTimeEntryWithTimestampsViaApi(
task_id: data.taskId ?? null,
tags: data.tags ?? [],
billable: data.billable ?? false,
type: data.type ?? 'work',
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as {
id: string;
start: string;
end: string;
description: string;
type: 'work' | 'break';
};
return body.data as { id: string; start: string; end: string; description: string };
}
// ──────────────────────────────────────────────────
@@ -922,71 +903,3 @@ export async function createReportViaApi(
public_until: string | null;
};
}
// ──────────────────────────────────────────────────
// Invoices
// ──────────────────────────────────────────────────
export async function createInvoiceViaApi(
ctx: TestContext,
data: {
reference: string;
buyer_name?: string;
seller_name?: string;
currency?: string;
date?: string;
tax_rate?: number;
}
) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoices`,
{
data: {
seller_name: data.seller_name ?? 'Test Seller',
buyer_name: data.buyer_name ?? 'Test Buyer',
reference: data.reference,
currency: data.currency ?? 'EUR',
date: data.date ?? new Date().toISOString().split('T')[0],
// Mirror the UI create form, which always sends a tax rate (default 0).
// Invoices with a null tax_rate currently crash PDF rendering.
tax_rate: data.tax_rate ?? 0,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; reference: string; buyer_name: string };
}
export async function updateInvoiceSettingsViaApi(ctx: TestContext, data: Record<string, unknown>) {
const response = await ctx.request.put(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoice-settings`,
{ data }
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data as Record<string, unknown>;
}
export async function getInvoiceSettingsViaApi(ctx: TestContext) {
const response = await ctx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoice-settings`
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data as Record<string, unknown>;
}
export async function getInvoicesViaApi(ctx: TestContext) {
const response = await ctx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoices`
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data as Array<{
id: string;
reference: string;
buyer_name: string;
paid_date: string | null;
}>;
}

View File

@@ -20,17 +20,7 @@ export async function assertThatTimerHasStarted(page: Page) {
export function newTimeEntryResponse(
page: Page,
{
description = '',
status = 201,
tags = [],
type,
}: {
description?: string;
status?: number;
tags?: string[];
type?: 'work' | 'break';
} = {}
{ description = '', status = 201, tags = [] } = {}
) {
return page.waitForResponse(async (response) => {
return (
@@ -44,7 +34,6 @@ export function newTimeEntryResponse(
(await response.json()).data.description === description &&
(await response.json()).data.task_id === null &&
(await response.json()).data.user_id !== null &&
(type === undefined || (await response.json()).data.type === type) &&
JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags)
);
});
@@ -59,18 +48,7 @@ export async function assertThatTimerIsStopped(page: Page) {
).toHaveClass(/bg-accent-300\/70/);
}
export async function stoppedTimeEntryResponse(
page: Page,
{
description = '',
tags = [],
type,
}: {
description?: string;
tags?: string[];
type?: 'work' | 'break';
} = {}
) {
export async function stoppedTimeEntryResponse(page: Page, { description = '', tags = [] } = {}) {
return page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
@@ -84,7 +62,6 @@ export async function stoppedTimeEntryResponse(
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
(type === undefined || (await response.json()).data.type === type) &&
JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags)
);
});

View File

@@ -1,4 +1,4 @@
import type { Locator, Page } from '@playwright/test';
import type { Locator } from '@playwright/test';
/**
* Extract the first cell's text content from each row in a table.
@@ -14,22 +14,3 @@ export async function getTableRowNames(table: Locator): Promise<string[]> {
}
return names;
}
/**
* The visual order of the given seeded names within the table, ignoring any other rows.
*/
export async function getSeededRowOrder(table: Locator, seeded: string[]): Promise<string[]> {
const rowNames = await getTableRowNames(table);
return rowNames
.map((rowName) => seeded.find((name) => rowName.includes(name)))
.filter((name): name is string => Boolean(name));
}
/**
* Drop a table's persisted sort/filter state so a test starts from the defaults.
*/
export async function clearTableState(page: Page, key: string) {
await page.evaluate((storageKey) => {
localStorage.removeItem(storageKey);
}, key);
}

View File

@@ -1,14 +0,0 @@
{
"Billing": {
"repository": "solidtime-io/extension-billing",
"ref": "v0.0.6"
},
"Services": {
"repository": "solidtime-io/extension-services",
"ref": "v0.0.2"
},
"Invoicing": {
"repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.3"
}
}

4
package-lock.json generated
View File

@@ -8396,7 +8396,7 @@
},
"resources/js/packages/api": {
"name": "@solidtime/api",
"version": "0.0.7",
"version": "0.0.6",
"license": "AGPL-3.0",
"devDependencies": {
"vite-plugin-dts": "^4.5.4"
@@ -8411,7 +8411,7 @@
},
"resources/js/packages/ui": {
"name": "@solidtime/ui",
"version": "0.0.22",
"version": "0.0.21",
"license": "AGPL-3.0",
"devDependencies": {
"@types/chroma-js": "^3.1.2",

View File

@@ -11,13 +11,14 @@ import Pagination from '@/Components/Common/Pagination.vue';
import { canCreateClients } from '@/utils/permissions';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import {
useSortableTable,
type SortableColumnDef,
type SortDirection,
} from '@/utils/useSortableTable';
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
export type SortColumn = 'name' | 'projects_count' | 'status';
export type { SortDirection } from '@/utils/useSortableTable';
export type SortDirection = 'asc' | 'desc';
const props = defineProps<{
clients: Client[];
@@ -43,7 +44,17 @@ const projectCountMap = computed(() => {
return map;
});
const columns = computed<SortableColumnDef<Client, SortColumn>[]>(() => [
// Name is always the secondary sort so rows with equal values render
// alphabetically instead of in API (created_at) order.
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
]);
const columns = computed(() => [
{
id: 'name',
accessorFn: (row: Client) => row.name.toLowerCase(),
@@ -59,22 +70,41 @@ const columns = computed<SortableColumnDef<Client, SortColumn>[]>(() => [
},
]);
const {
sortedRows: sortedClients,
descFirstColumns,
nextDirection,
} = useSortableTable({
data: () => props.clients,
columns: () => columns.value,
sortColumn: () => props.sortColumn,
sortDirection: () => props.sortDirection,
tieBreakColumn: 'name',
});
const descFirstColumns = new Set<SortColumn>(
columns.value
.filter((c) => 'sortDescFirst' in c && c.sortDescFirst)
.map((c) => c.id as SortColumn)
);
function handleSort(column: SortColumn) {
emit('sort', column, nextDirection(column));
if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
}
const table = useVueTable({
get data() {
return props.clients;
},
get columns() {
return columns.value;
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedClients = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
// Client-side pagination: the full list is in memory, only one page is mounted at a time.
const PAGE_SIZE = 15;
const currentPage = ref(1);

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
const props = defineProps<{
@@ -9,29 +9,57 @@ const props = defineProps<{
descFirstColumns: ReadonlySet<SortColumn>;
}>();
defineEmits<{
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script>
<template>
<TableHeading>
<SortableTableHeaderCell
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"
column="name"
v-bind="props"
@sort="$emit('sort', $event)">
<div
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
Name
</SortableTableHeaderCell>
<SortableTableHeaderCell
column="projects_count"
v-bind="props"
@sort="$emit('sort', $event)">
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('projects_count')">
Projects
</SortableTableHeaderCell>
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)">
<ChevronDownIcon v-if="isChevronDown('projects_count')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('projects_count')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
Status
</SortableTableHeaderCell>
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>

View File

@@ -3,14 +3,16 @@ import MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vu
import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue';
import { useMembersQuery } from '@/utils/useMembersQuery';
import type { Member } from '@/packages/api/src';
import { computed } from 'vue';
import {
useSortableTable,
type SortableColumnDef,
type SortDirection,
} from '@/utils/useSortableTable';
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
export type SortColumn = 'name' | 'email' | 'role' | 'billable_rate' | 'status';
export type { SortDirection } from '@/utils/useSortableTable';
export type SortDirection = 'asc' | 'desc';
const props = defineProps<{
sortColumn: SortColumn;
@@ -31,7 +33,14 @@ const roleOrder: Record<string, number> = {
placeholder: 4,
};
const columns: SortableColumnDef<Member, SortColumn>[] = [
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
]);
const columns = [
{
id: 'name',
accessorFn: (row: Member) => row.name.toLowerCase(),
@@ -47,6 +56,7 @@ const columns: SortableColumnDef<Member, SortColumn>[] = [
{
id: 'billable_rate',
sortDescFirst: true,
sortUndefined: 'last' as const,
accessorFn: (row: Member) => {
if (row.billable_rate === null) return undefined;
return row.billable_rate;
@@ -58,21 +68,36 @@ const columns: SortableColumnDef<Member, SortColumn>[] = [
},
];
const {
sortedRows: sortedMembers,
descFirstColumns,
nextDirection,
} = useSortableTable({
data: () => members.value,
columns: () => columns,
sortColumn: () => props.sortColumn,
sortDirection: () => props.sortDirection,
tieBreakColumn: 'name',
});
const descFirstColumns = new Set<SortColumn>(
columns.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
);
function handleSort(column: SortColumn) {
emit('sort', column, nextDirection(column));
if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
}
const table = useVueTable({
get data() {
return members.value;
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedMembers = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
</script>
<template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
const props = defineProps<{
@@ -9,35 +9,73 @@ const props = defineProps<{
descFirstColumns: ReadonlySet<SortColumn>;
}>();
defineEmits<{
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script>
<template>
<TableHeading>
<SortableTableHeaderCell
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"
column="name"
v-bind="props"
@sort="$emit('sort', $event)">
<div
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
Name
</SortableTableHeaderCell>
<SortableTableHeaderCell column="email" v-bind="props" @sort="$emit('sort', $event)">
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('email')">
Email
</SortableTableHeaderCell>
<SortableTableHeaderCell column="role" v-bind="props" @sort="$emit('sort', $event)">
<ChevronDownIcon v-if="isChevronDown('email')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('email')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('role')">
Role
</SortableTableHeaderCell>
<SortableTableHeaderCell
column="billable_rate"
v-bind="props"
@sort="$emit('sort', $event)">
<ChevronDownIcon v-if="isChevronDown('role')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('role')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('billable_rate')">
Billable Rate
</SortableTableHeaderCell>
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)">
<ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('billable_rate')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
Status
</SortableTableHeaderCell>
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span>
</div>

View File

@@ -7,7 +7,6 @@ import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import Pagination from '@/Components/Common/Pagination.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
export type SortColumn =
| 'name'
@@ -17,7 +16,7 @@ export type SortColumn =
| 'billable_rate'
| 'status'
| 'visibility';
export type { SortDirection } from '@/utils/useSortableTable';
export type SortDirection = 'asc' | 'desc';
import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
import { useProjectsStore } from '@/utils/useProjects';
@@ -28,24 +27,20 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
import {
useSortableTable,
type SortableColumnDef,
type SortDirection,
} from '@/utils/useSortableTable';
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const props = withDefaults(
defineProps<{
projects: Project[];
showBillableRate: boolean;
sortColumn: SortColumn;
sortDirection: SortDirection;
isFiltered?: boolean;
isLoading?: boolean;
}>(),
{ isFiltered: false, isLoading: false }
);
const props = defineProps<{
projects: Project[];
showBillableRate: boolean;
sortColumn: SortColumn;
sortDirection: SortDirection;
}>();
const emit = defineEmits<{
sort: [column: SortColumn, direction: SortDirection];
@@ -62,16 +57,28 @@ const clientNameMap = computed(() => {
return map;
});
// Convert sort props to TanStack Table format.
// Name is always the secondary sort so rows with equal values render
// alphabetically instead of in API (created_at) order.
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
]);
// Define column accessors for sorting.
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
// while text columns default to ascending (A-Z) on first click (chevron down).
const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
const columns = computed(() => [
{
id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(),
},
{
id: 'client_name',
sortUndefined: 'last' as const,
accessorFn: (row: Project) => {
if (!row.client_id) return undefined;
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
@@ -80,11 +87,12 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
{
id: 'spent_time',
sortDescFirst: true,
accessorFn: (row: Project) => row.spent_time,
accessorFn: (row: Project) => row.spent_time ?? 0,
},
{
id: 'progress',
sortDescFirst: true,
sortUndefined: 'last' as const,
accessorFn: (row: Project) => {
if (!row.estimated_time) return undefined;
return (row.spent_time / row.estimated_time) * 100;
@@ -93,7 +101,7 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
{
id: 'billable_rate',
sortDescFirst: true,
accessorFn: (row: Project) => row.billable_rate,
accessorFn: (row: Project) => row.billable_rate ?? 0,
},
{
id: 'status',
@@ -105,22 +113,40 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
},
]);
const {
sortedRows: sortedProjects,
descFirstColumns,
nextDirection,
} = useSortableTable({
data: () => props.projects,
columns: () => columns.value,
sortColumn: () => props.sortColumn,
sortDirection: () => props.sortDirection,
tieBreakColumn: 'name',
});
// Columns with sortDescFirst get desc as default direction on first click.
const descFirstColumns = new Set<SortColumn>(
columns.value.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
);
function handleSort(column: SortColumn) {
emit('sort', column, nextDirection(column));
if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
}
const table = useVueTable({
get data() {
return props.projects;
},
get columns() {
return columns.value;
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedProjects = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
// Client-side pagination: the full list is in memory, only one page is mounted at a time.
const PAGE_SIZE = 15;
const currentPage = ref(1);
@@ -134,28 +160,6 @@ const paginatedProjects = computed(() => {
return sortedProjects.value.slice(start, start + PAGE_SIZE);
});
const emptyState = computed(() => {
if (props.isFiltered) {
return {
title: 'No matching projects',
description: 'Try a different search term or adjust your filters.',
showCreateButton: false,
};
}
if (!canCreateProjects()) {
return {
title: 'You are not a member of any projects',
description: 'Ask your manager to add you to a project as a team member.',
showCreateButton: false,
};
}
return {
title: 'No projects found',
description: 'Create your first project now!',
showCreateButton: true,
};
});
const showCreateProjectModal = ref(false);
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
@@ -189,21 +193,24 @@ const gridTemplate = computed(() => {
:sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns"
@sort="handleSort"></ProjectTableHeading>
<div
v-if="props.isLoading"
class="col-span-full flex justify-center items-center py-24">
<LoadingSpinner></LoadingSpinner>
</div>
<div
v-else-if="sortedProjects.length === 0"
class="col-span-full py-24 text-center">
<div v-if="sortedProjects.length === 0" class="col-span-full py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold">{{ emptyState.title }}</h3>
<h3 class="text-text-primary font-semibold">
{{
canCreateProjects()
? 'No projects found'
: 'You are not a member of any projects'
}}
</h3>
<p class="pb-5 max-w-md mx-auto text-sm pt-1">
{{ emptyState.description }}
{{
canCreateProjects()
? 'Create your first project now!'
: 'Ask your manager to add you to a project as a team member.'
}}
</p>
<SecondaryButton
v-if="emptyState.showCreateButton"
v-if="canCreateProjects()"
:icon="PlusIcon"
@click="showCreateProjectModal = true"
>Create your First Project

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