Compare commits

...

55 Commits

Author SHA1 Message Date
dependabot[bot]
1e93830672 Bump the minor-updates group across 1 directory with 28 updates
Bumps the minor-updates group with 26 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [datomatic/laravel-enum-helper](https://github.com/datomatic/laravel-enum-helper) | `2.1.1` | `2.1.4` |
| [dedoc/scramble](https://github.com/dedoc/scramble) | `0.12.23` | `0.13.26` |
| [flowframe/laravel-trend](https://github.com/Flowframe/laravel-trend) | `0.4.0` | `0.5.0` |
| [gotenberg/gotenberg-php](https://github.com/gotenberg/gotenberg-php) | `2.14.0` | `2.22.0` |
| [korridor/laravel-computed-attributes](https://github.com/korridor/laravel-computed-attributes) | `3.2.0` | `3.3.0` |
| [korridor/laravel-has-many-sync](https://github.com/korridor/laravel-has-many-sync) | `3.1.0` | `3.2.0` |
| [korridor/laravel-model-validation-rules](https://github.com/korridor/laravel-model-validation-rules) | `3.3.0` | `3.4.0` |
| [laravel/jetstream](https://github.com/laravel/jetstream) | `5.3.7` | `5.5.3` |
| [laravel/octane](https://github.com/laravel/octane) | `2.11.0` | `2.17.4` |
| [laravel/passport](https://github.com/laravel/passport) | `13.0.5` | `13.7.5` |
| [league/flysystem-aws-s3-v3](https://github.com/thephpleague/flysystem-aws-s3-v3) | `3.29.0` | `3.34.0` |
| [league/iso3166](https://github.com/alcohol/iso3166) | `4.3.3` | `4.4.0` |
| [maatwebsite/excel](https://github.com/SpartnerNL/Laravel-Excel) | `3.1.67` | `3.1.69` |
| [owen-it/laravel-auditing](https://github.com/owen-it/laravel-auditing) | `14.0.0` | `14.0.3` |
| [spatie/temporary-directory](https://github.com/spatie/temporary-directory) | `2.3.0` | `2.3.1` |
| [tightenco/ziggy](https://github.com/tighten/ziggy) | `2.5.3` | `2.6.2` |
| [tpetry/laravel-postgresql-enhanced](https://github.com/tpetry/laravel-postgresql-enhanced) | `3.0.0` | `3.7.0` |
| [barryvdh/laravel-ide-helper](https://github.com/barryvdh/laravel-ide-helper) | `3.5.5` | `3.7.0` |
| [fumeapp/modeltyper](https://github.com/fumeapp/modeltyper) | `3.3.0` | `3.10.0` |
| [larastan/larastan](https://github.com/larastan/larastan) | `3.5.0` | `3.10.0` |
| [laravel/pint](https://github.com/laravel/pint) | `1.24.0` | `1.29.1` |
| [laravel/sail](https://github.com/laravel/sail) | `1.43.1` | `1.62.0` |
| [laravel/telescope](https://github.com/laravel/telescope) | `5.10.0` | `5.20.0` |
| [nunomaduro/collision](https://github.com/nunomaduro/collision) | `8.8.2` | `8.9.4` |
| [spatie/laravel-ignition](https://github.com/spatie/laravel-ignition) | `2.9.1` | `2.12.0` |
| [timacdonald/log-fake](https://github.com/timacdonald/log-fake) | `2.4.0` | `2.4.2` |



Updates `datomatic/laravel-enum-helper` from 2.1.1 to 2.1.4
- [Release notes](https://github.com/datomatic/laravel-enum-helper/releases)
- [Changelog](https://github.com/datomatic/laravel-enum-helper/blob/main/CHANGELOG.md)
- [Commits](https://github.com/datomatic/laravel-enum-helper/compare/v2.1.1...v2.1.4)

Updates `dedoc/scramble` from 0.12.23 to 0.13.26
- [Release notes](https://github.com/dedoc/scramble/releases)
- [Commits](https://github.com/dedoc/scramble/compare/v0.12.23...v0.13.26)

Updates `flowframe/laravel-trend` from 0.4.0 to 0.5.0
- [Release notes](https://github.com/Flowframe/laravel-trend/releases)
- [Changelog](https://github.com/Flowframe/laravel-trend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Flowframe/laravel-trend/compare/v0.4.0...v0.5.0)

Updates `gotenberg/gotenberg-php` from 2.14.0 to 2.22.0
- [Release notes](https://github.com/gotenberg/gotenberg-php/releases)
- [Commits](https://github.com/gotenberg/gotenberg-php/compare/v2.14.0...v2.22.0)

Updates `guzzlehttp/guzzle` from 7.10.0 to 7.11.1
- [Release notes](https://github.com/guzzle/guzzle/releases)
- [Changelog](https://github.com/guzzle/guzzle/blob/7.11/CHANGELOG.md)
- [Commits](https://github.com/guzzle/guzzle/compare/7.10.0...7.11.1)

Updates `korridor/laravel-computed-attributes` from 3.2.0 to 3.3.0
- [Release notes](https://github.com/korridor/laravel-computed-attributes/releases)
- [Commits](https://github.com/korridor/laravel-computed-attributes/compare/3.2.0...3.3.0)

Updates `korridor/laravel-has-many-sync` from 3.1.0 to 3.2.0
- [Release notes](https://github.com/korridor/laravel-has-many-sync/releases)
- [Commits](https://github.com/korridor/laravel-has-many-sync/compare/3.1.0...3.2.0)

Updates `korridor/laravel-model-validation-rules` from 3.3.0 to 3.4.0
- [Release notes](https://github.com/korridor/laravel-model-validation-rules/releases)
- [Commits](https://github.com/korridor/laravel-model-validation-rules/compare/3.3.0...3.4.0)

Updates `laravel/framework` from 12.52.0 to 12.61.1
- [Release notes](https://github.com/laravel/framework/releases)
- [Changelog](https://github.com/laravel/framework/blob/13.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/framework/compare/v12.52.0...v12.61.1)

Updates `laravel/jetstream` from 5.3.7 to 5.5.3
- [Release notes](https://github.com/laravel/jetstream/releases)
- [Changelog](https://github.com/laravel/jetstream/blob/5.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/jetstream/compare/v5.3.7...v5.5.3)

Updates `laravel/octane` from 2.11.0 to 2.17.4
- [Release notes](https://github.com/laravel/octane/releases)
- [Changelog](https://github.com/laravel/octane/blob/2.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/octane/compare/v2.11.0...v2.17.4)

Updates `laravel/passport` from 13.0.5 to 13.7.5
- [Release notes](https://github.com/laravel/passport/releases)
- [Changelog](https://github.com/laravel/passport/blob/13.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/passport/compare/v13.0.5...v13.7.5)

Updates `league/flysystem-aws-s3-v3` from 3.29.0 to 3.34.0
- [Commits](https://github.com/thephpleague/flysystem-aws-s3-v3/compare/3.29.0...3.34.0)

Updates `league/iso3166` from 4.3.3 to 4.4.0
- [Release notes](https://github.com/alcohol/iso3166/releases)
- [Changelog](https://github.com/alcohol/iso3166/blob/main/CHANGELOG.md)
- [Commits](https://github.com/alcohol/iso3166/compare/4.3.3...4.4.0)

Updates `maatwebsite/excel` from 3.1.67 to 3.1.69
- [Release notes](https://github.com/SpartnerNL/Laravel-Excel/releases)
- [Changelog](https://github.com/SpartnerNL/Laravel-Excel/blob/3.1/CHANGELOG.md)
- [Commits](https://github.com/SpartnerNL/Laravel-Excel/compare/3.1.67...3.1.69)

Updates `owen-it/laravel-auditing` from 14.0.0 to 14.0.3
- [Release notes](https://github.com/owen-it/laravel-auditing/releases)
- [Changelog](https://github.com/owen-it/laravel-auditing/blob/master/CHANGELOG.md)
- [Commits](https://github.com/owen-it/laravel-auditing/compare/v14.0.0...v14.0.3)

Updates `spatie/temporary-directory` from 2.3.0 to 2.3.1
- [Release notes](https://github.com/spatie/temporary-directory/releases)
- [Changelog](https://github.com/spatie/temporary-directory/blob/main/CHANGELOG.md)
- [Commits](https://github.com/spatie/temporary-directory/compare/2.3.0...2.3.1)

Updates `tightenco/ziggy` from 2.5.3 to 2.6.2
- [Release notes](https://github.com/tighten/ziggy/releases)
- [Changelog](https://github.com/tighten/ziggy/blob/2.x/CHANGELOG.md)
- [Commits](https://github.com/tighten/ziggy/compare/v2.5.3...v2.6.2)

Updates `tpetry/laravel-postgresql-enhanced` from 3.0.0 to 3.7.0
- [Changelog](https://github.com/tpetry/laravel-postgresql-enhanced/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tpetry/laravel-postgresql-enhanced/compare/3.0.0...3.7.0)

Updates `barryvdh/laravel-ide-helper` from 3.5.5 to 3.7.0
- [Release notes](https://github.com/barryvdh/laravel-ide-helper/releases)
- [Changelog](https://github.com/barryvdh/laravel-ide-helper/blob/master/CHANGELOG.md)
- [Commits](https://github.com/barryvdh/laravel-ide-helper/compare/v3.5.5...v3.7.0)

Updates `fumeapp/modeltyper` from 3.3.0 to 3.10.0
- [Release notes](https://github.com/fumeapp/modeltyper/releases)
- [Commits](https://github.com/fumeapp/modeltyper/compare/v3.3.0...v3.10.0)

Updates `larastan/larastan` from 3.5.0 to 3.10.0
- [Release notes](https://github.com/larastan/larastan/releases)
- [Changelog](https://github.com/larastan/larastan/blob/3.x/RELEASE.md)
- [Commits](https://github.com/larastan/larastan/compare/v3.5.0...v3.10.0)

Updates `laravel/pint` from 1.24.0 to 1.29.1
- [Release notes](https://github.com/laravel/pint/releases)
- [Changelog](https://github.com/laravel/pint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/laravel/pint/compare/v1.24.0...v1.29.1)

Updates `laravel/sail` from 1.43.1 to 1.62.0
- [Release notes](https://github.com/laravel/sail/releases)
- [Changelog](https://github.com/laravel/sail/blob/1.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/sail/compare/v1.43.1...v1.62.0)

Updates `laravel/telescope` from 5.10.0 to 5.20.0
- [Release notes](https://github.com/laravel/telescope/releases)
- [Changelog](https://github.com/laravel/telescope/blob/5.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/telescope/compare/v5.10.0...v5.20.0)

Updates `nunomaduro/collision` from 8.8.2 to 8.9.4
- [Commits](https://github.com/nunomaduro/collision/compare/v8.8.2...v8.9.4)

Updates `spatie/laravel-ignition` from 2.9.1 to 2.12.0
- [Release notes](https://github.com/spatie/laravel-ignition/releases)
- [Changelog](https://github.com/spatie/laravel-ignition/blob/main/CHANGELOG.md)
- [Commits](https://github.com/spatie/laravel-ignition/compare/2.9.1...2.12.0)

Updates `timacdonald/log-fake` from 2.4.0 to 2.4.2
- [Release notes](https://github.com/timacdonald/log-fake/releases)
- [Commits](https://github.com/timacdonald/log-fake/compare/v2.4.0...v2.4.2)

---
updated-dependencies:
- dependency-name: barryvdh/laravel-ide-helper
  dependency-version: 3.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: datomatic/laravel-enum-helper
  dependency-version: 2.1.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-updates
- dependency-name: dedoc/scramble
  dependency-version: 0.13.23
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: flowframe/laravel-trend
  dependency-version: 0.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: fumeapp/modeltyper
  dependency-version: 3.10.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: gotenberg/gotenberg-php
  dependency-version: 2.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: guzzlehttp/guzzle
  dependency-version: 7.10.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-updates
- dependency-name: korridor/laravel-computed-attributes
  dependency-version: 3.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: korridor/laravel-has-many-sync
  dependency-version: 3.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: korridor/laravel-model-validation-rules
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: larastan/larastan
  dependency-version: 3.9.6
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/framework
  dependency-version: 12.60.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/jetstream
  dependency-version: 5.5.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/octane
  dependency-version: 2.17.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/passport
  dependency-version: 13.7.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/pint
  dependency-version: 1.29.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/sail
  dependency-version: 1.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/telescope
  dependency-version: 5.20.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: league/flysystem-aws-s3-v3
  dependency-version: 3.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: league/iso3166
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: maatwebsite/excel
  dependency-version: 3.1.69
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-updates
- dependency-name: nunomaduro/collision
  dependency-version: 8.9.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: owen-it/laravel-auditing
  dependency-version: 14.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-updates
- dependency-name: spatie/laravel-ignition
  dependency-version: 2.12.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: spatie/temporary-directory
  dependency-version: 2.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-updates
- dependency-name: tightenco/ziggy
  dependency-version: 2.6.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: timacdonald/log-fake
  dependency-version: 2.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-updates
- dependency-name: tpetry/laravel-postgresql-enhanced
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 08:08:31 +00:00
Gregor Vostrak
cb5c2547f4 fix profile setting sidebar alignment 2026-06-03 12:24:53 +02:00
Gregor Vostrak
13a25524f3 add saved/saving/error indicators to timesheets 2026-06-02 17:14:32 +02:00
Gregor Vostrak
112f6aa6a6 add invoice clone to openapi client, expose DetailedInvoice type 2026-05-29 19:07:55 +02:00
Gregor Vostrak
8eab0485c9 revert reka-ui update; fix DST cellMath; 2026-05-29 17:14:52 +02:00
Gregor Vostrak
0aa0f0bd77 use cn helper for alert-dialog modals 2026-05-29 17:14:52 +02:00
Gregor Vostrak
eb63c4ef03 fix light mode timesheet background and add missing aria-label 2026-05-29 17:14:52 +02:00
Gregor Vostrak
54fffd07bc add timesheet unit and e2e tests; add unit test CI setup 2026-05-29 17:14:52 +02:00
Gregor Vostrak
da235dfdc8 remove special “Add new project” state in TimeTrackerProjectTaskDropdown 2026-05-29 17:14:52 +02:00
Gregor Vostrak
0debdddef9 set min release age for npm packages to 7 days to prevent supply chain attacks 2026-05-29 17:14:52 +02:00
Gregor Vostrak
62354cfe8b remove timetrackerprojecttaskdropdown test without setup 2026-05-29 17:14:52 +02:00
Gregor Vostrak
396e7b2b6b fix DST boundary issue in timesheets 2026-05-29 17:14:52 +02:00
Gregor Vostrak
221889ff87 fix "No project" duplicating rows, unify no project senitel to null 2026-05-29 17:14:52 +02:00
Gregor Vostrak
7ce3fa2740 change TimeEntryFilter start filter to be inclusive 2026-05-29 17:14:52 +02:00
Gregor Vostrak
df34014bfe fix e2e tests 2026-05-29 17:14:52 +02:00
Gregor Vostrak
faf3ee471c fix formatting 2026-05-29 17:14:52 +02:00
Gregor Vostrak
866e5d8594 clamp running time entry duration to min 0 for FullCalendarHeaderDuration calc 2026-05-29 17:14:52 +02:00
Gregor Vostrak
72cd0b6f05 fix formatting 2026-05-29 17:14:52 +02:00
Gregor Vostrak
6d93e48b1d add missing dayjs plugins for isSameOrBefore and isSameOrAfter 2026-05-29 17:14:52 +02:00
Gregor Vostrak
09af0f775f add timesheets page 2026-05-29 17:14:52 +02:00
Gregor Vostrak
1cc000a584 fix local storage filter migration state for visibility filter 2026-05-26 11:37:24 +02:00
Gregor Vostrak
1a754f6756 improve modal and field group spacing for project modal layout 2026-05-26 11:15:15 +02:00
Gregor Vostrak
d69d25d059 add project table visibility filter 2026-05-26 11:15:15 +02:00
Gregor Vostrak
0e15d9d9c2 add project visibility ui 2026-05-26 11:15:15 +02:00
dependabot[bot]
7d9ecd9526 Bump aglipanci/laravel-pint-action from 2.5 to 2.6
Bumps [aglipanci/laravel-pint-action](https://github.com/aglipanci/laravel-pint-action) from 2.5 to 2.6.
- [Release notes](https://github.com/aglipanci/laravel-pint-action/releases)
- [Commits](https://github.com/aglipanci/laravel-pint-action/compare/2.5...2.6)

---
updated-dependencies:
- dependency-name: aglipanci/laravel-pint-action
  dependency-version: '2.6'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:28:48 +02:00
dependabot[bot]
3a17f80f99 Bump codecov/codecov-action from 5.4.3 to 5.5.1
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.3 to 5.5.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.4.3...v5.5.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:14:44 +02:00
dependabot[bot]
e29ea2ea42 Bump actions/setup-node from 4 to 6
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:13:14 +02:00
dependabot[bot]
fb6e4639ce Bump actions/download-artifact from 4 to 6
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:12:01 +02:00
dependabot[bot]
69bc41988a Bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:11:19 +02:00
Gregor Vostrak
f7663b1c8b Clarify out of scope items for vulnerability reports
Added out of scope section for vulnerability reporting.
2026-05-18 19:21:32 +02:00
Gregor Vostrak
793bd11dcf remove member, invitation, and owner email disclosure from Teams/Show inertia props
The Teams/Show Inertia page serialized members, pending invitations, and the
owner email into props using only a belongsToTeam authorization gate, while
the corresponding API endpoints correctly enforced members:view and
invitations:view. The serialized data was unused by the live UI (the
TeamMemberManager partial that referenced it was orphaned), so dropping the
fields removes the disclosure surface without functional impact. The owner
card retains name and photo.
2026-05-18 19:04:57 +02:00
Gregor Vostrak
77a62afd69 add alphabetic sorting to multiselect dropdowns 2026-04-29 18:32:05 +02:00
Gregor Vostrak
b73aa543fd Merge commit from fork 2026-04-21 21:12:30 +02:00
Gregor Vostrak
2d6f9e514f add groupSimilarTimeEntries to TimeEntryGroupedTable 2026-04-21 20:44:33 +02:00
Gregor Vostrak
f8e668790b Fix typo in project name in README.md 2026-04-18 04:27:50 +02:00
utlark
77a5e979c6 Added the ability to disable group similar time entries (#1054)
* Added the ability to disable group similar time entries

* Fix E2E test for Group similar time entries

* Simplify `TimeEntryGroupedTable` by replacing ternary with early return logic

* Refactor time entry grouping settings: rename storage key, move logic into a dedicated module

* Replace fixed `waitForTimeout` calls in E2E tests with element-based waits and assertions

* Run frontend linting and formatting for changes
2026-04-17 16:44:59 +02:00
Gregor Vostrak
353a579850 chore: bump ui package version 2026-04-17 14:46:36 +02:00
Gregor Vostrak
bd44a2b376 fix e2e tests for new duration reporting format logic 2026-04-17 14:36:56 +02:00
Gregor Vostrak
277dbaf6eb promote duration formats that omit seconds to HH:mm:ss in reporting
views and exports
2026-04-17 12:15:26 +02:00
Gregor Vostrak
1cf33ddb3f improve dark mode color palette; rework font weights throughout the
interface
2026-04-15 15:35:20 +02:00
Gregor Vostrak
84cd0d572d bump ui package version 2026-04-08 23:18:29 +02:00
Gregor Vostrak
f37b86f377 chore: remove unused formatActivityDuration function 2026-04-08 14:49:37 +02:00
Gregor Vostrak
1e7364fc4b show calendar activities more prominently when no time entry exists 2026-04-08 14:43:09 +02:00
Gregor Vostrak
8cbc9838c9 fix minimal layout shift on time entry select and migrate to ui button 2026-04-07 21:42:34 +02:00
Gregor Vostrak
71c8992e31 Fix getLocalizedDayJsFromMinutes handling negative minute values 2026-03-31 13:56:30 +02:00
Gregor Vostrak
53d91b65d6 fix: use timezoned dates in public report endpoint tests
Replace travelTo + now() with Carbon::now($timezone)->startOfDay() to eliminate flakiness when tests run near midnight UTC, where the UTC and Vienna dates can differ.
2026-03-31 13:21:54 +02:00
Gregor Vostrak
0c88a10eb5 improve calendar current day styling 2026-03-30 00:58:40 +02:00
Gregor Vostrak
dd7b23958a fix gotenberg url in CI 2026-03-30 00:07:57 +02:00
Gregor Vostrak
1eb066f5aa Add E2E test for project name prefill 2026-03-29 23:55:10 +02:00
ShrootBuck
b1287c6a0a Prefill project name in create modal
Add optional initialProjectName prop to ProjectCreateModal and use it
to initialize the project's name. Pass the TimeTracker dropdown's
searchValue as initial-project-name so the create form is prefilled.
2026-03-29 23:55:10 +02:00
Gregor Vostrak
815abb5980 improve drag handle hit area and activity tooltip placement 2026-03-29 23:14:01 +02:00
Gregor Vostrak
e2f859be27 fix calendar scroll down on load; bump ui package version 2026-03-29 23:02:22 +02:00
Gregor Vostrak
3d26fcaefe Fix DST-related timezone offset when creating/resizing/dragging calendar
events
2026-03-29 22:55:50 +02:00
Gregor Vostrak
1e73a90f9d chore: bump ui version 2026-03-29 22:09:01 +02:00
Gregor Vostrak
0f8f906e5c clarify naming on activity type 2026-03-27 00:37:29 +01:00
149 changed files with 11250 additions and 2827 deletions

View File

@@ -60,7 +60,7 @@ AUDITING_ENABLED=true
TELESCOPE_ENABLED=false
# Services
GOTENBERG_URL=http://0.0.0.0:3000
GOTENBERG_URL=http://localhost:3000
# Octane
OCTANE_SERVER=frankenphp

View File

@@ -77,6 +77,9 @@ TELESCOPE_ENABLED=false
# Services
GOTENBERG_URL=http://gotenberg:3000
# Octane
OCTANE_SERVER=frankenphp
# Local setup
NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing

View File

@@ -91,7 +91,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -177,7 +177,7 @@ jobs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
path: ${{ runner.temp }}/digests
pattern: digests-*

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -68,12 +68,12 @@ jobs:
run: cat .env
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'
- name: "Checkout billing extension"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: solidtime-io/extension-billing
path: extensions/Billing
@@ -93,7 +93,7 @@ jobs:
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: solidtime-io/extension-services
path: extensions/Services
@@ -111,7 +111,7 @@ jobs:
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing

View File

@@ -36,7 +36,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -92,7 +92,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -169,7 +169,7 @@ jobs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
path: ${{ runner.temp }}/digests
pattern: digests-*

View File

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

View File

@@ -11,7 +11,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- 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@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

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

View File

@@ -11,10 +11,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Use Node.js"
uses: actions/setup-node@v4
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@v4
uses: actions/checkout@v5
# Setup .npmrc file to publish to npm
- name: Install root project dependencies
run: npm ci
- uses: actions/setup-node@v4
- 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@v4
uses: actions/checkout@v5
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'

27
.github/workflows/npm-test-unit.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: NPM Test Unit
on: [push]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
TZ: UTC
steps:
- name: "Checkout code"
uses: actions/checkout@v4
- name: "Use Node.js"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Install npm dependencies"
run: npm ci
- name: "Run vitest"
run: npm run test:unit

View File

@@ -10,7 +10,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- 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@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -9,7 +9,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- 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@v4
uses: actions/checkout@v5
- 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@v4
- uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.4.3
uses: codecov/codecov-action@v5.5.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -9,9 +9,9 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Check code style"
uses: aglipanci/laravel-pint-action@2.5
uses: aglipanci/laravel-pint-action@2.6
with:
configPath: "pint.json"

View File

@@ -35,10 +35,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Setup node"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
min-release-age=7

View File

@@ -1,4 +1,4 @@
# solidtime - The modern Open-Source Time Tracker
# solidtime - The modern Open-Source TimeTracker
[![GitHub License](https://img.shields.io/github/license/solidtime-io/solidtime?style=flat-square)](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
[![Codecov](https://img.shields.io/codecov/c/github/solidtime-io/solidtime?style=flat-square&logo=codecov)](https://codecov.io/gh/solidtime-io/solidtime)

View File

@@ -3,3 +3,18 @@
## Reporting a Vulnerability
If you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)!
## Out of scope
Reports we typically won't issue an advisory for:
* Theoretical findings without a working PoC
* Raw scanner output without manual validation
* Missing/weak security headers in isolation (CSP, X-Frame-Options, HSTS, etc.)
* SPF/DKIM/DMARC on non-mail-sending domains; missing DNSSEC/CAA; TLS cipher preferences
* Self-XSS; CSRF on non-state-changing endpoints (logout, theme)
* CSV / spreadsheet formula injection in exports — treated as a spreadsheet-application issue
* Org owners or admins acting destructively within their own organization
* Anything requiring direct DB, shell, or filesystem access on a self-hosted instance
* Missing OAuth Scope enforcement (this is not implemented yet, but AI scanners flag it which is why it is included in this list until we actually support it)

View File

@@ -629,9 +629,9 @@ class TimeEntryController extends Controller
/** @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');
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
} else {
$this->checkPermission($organization, 'time-entries:update:all');
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
}
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {

View File

@@ -304,28 +304,8 @@ class JetstreamServiceProvider extends ServiceProvider
'owner' => [
'id' => $owner->getKey(),
'name' => $owner->name,
'email' => $owner->email,
'profile_photo_url' => $owner->profile_photo_url,
],
'users' => $teamModel->users->map(function (User $user): array {
return [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
'profile_photo_url' => $user->profile_photo_url,
'membership' => [
'id' => $user->membership->id,
'role' => $user->membership->role,
],
];
}),
'team_invitations' => $teamModel->teamInvitations->map(function (OrganizationInvitation $invitation): array {
return [
'id' => $invitation->getKey(),
'email' => $invitation->email,
'role' => $invitation->role,
];
}),
],
'currencies' => array_map(function (Currency $currency): string {
return $currency->getName();

View File

@@ -96,6 +96,30 @@ class LocalizationService
}
}
/**
* Format a duration for reporting contexts (PDF reports, places that display duration
* directly next to cost). Promotes the verbose `Hh Mm` format to the compact `HH:MM:SS`
* so totals stay narrow and reconcile with cost, which is always computed to the second.
*/
public function formatIntervalForReporting(CarbonInterval $interval): string
{
$promoted = [
IntervalFormat::HoursMinutes,
IntervalFormat::HoursMinutesColonSeparated,
];
if (! in_array($this->intervalFormat, $promoted, true)) {
return $this->formatInterval($interval);
}
$previous = $this->intervalFormat;
$this->intervalFormat = IntervalFormat::HoursMinutesSecondsColonSeparated;
try {
return $this->formatInterval($interval);
} finally {
$this->intervalFormat = $previous;
}
}
public function formatCurrency(Money $money): string
{
$currencyService = app(CurrencyService::class);

View File

@@ -62,7 +62,7 @@ class TimeEntryFilter
if ($start === null) {
return $this;
}
$this->builder->where('start', '>', $start);
$this->builder->where('start', '>=', $start);
return $this;
}

View File

@@ -9,9 +9,9 @@
"ext-zip": "*",
"brick/money": "^0.10.0",
"datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.12.2",
"dedoc/scramble": "^0.13.26",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.4.0",
"flowframe/laravel-trend": "^0.5.0",
"gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^2.0.3",

3103
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -107,7 +107,7 @@ services:
- sail
- reverse-proxy
playwright:
image: mcr.microsoft.com/playwright:v1.58.1-jammy
image: mcr.microsoft.com/playwright:v1.59.1-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src
extra_hosts:

View File

@@ -907,7 +907,7 @@ test.describe('Employee Sidebar Navigation', () => {
// Visible links
await expect(employee.page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Time' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Time', exact: true })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Calendar' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Projects' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Clients' })).toBeVisible();

View File

@@ -230,6 +230,37 @@ test('test that theme can be changed to dark and light', async ({ page }) => {
await expect(page.getByText('System default:')).toBeVisible();
});
// =============================================
// Group similar time entries
// =============================================
test('test that group similar time entries setting can be toggled', async ({ page }) => {
await goToProfilePage(page);
// Get the checkbox
const checkbox = page.getByLabel('Group similar time entries');
// Get initial value and verify it is checked (default is true)
const initialValue = await checkbox.isChecked();
await expect(checkbox).toBeChecked();
// Toggle the checkbox
await checkbox.click();
// Reload
await page.reload();
// Verify the value is toggled
const afterValue = await page.getByLabel('Group similar time entries').isChecked();
expect(afterValue).toBe(!initialValue);
// Verify localStorage persists the setting
const storedValue = await page.evaluate(() =>
localStorage.getItem('group-similar-time-entries')
);
expect(storedValue).toBe(String(!initialValue));
});
// =============================================
// Two Factor Authentication Tests
// =============================================

View File

@@ -6,6 +6,7 @@ import { formatCentsWithOrganizationDefaults } from './utils/money';
import {
createProjectViaApi,
createPublicProjectViaApi,
createProjectMemberViaApi,
createTaskViaApi,
createClientViaApi,
createTimeEntryViaApi,
@@ -217,6 +218,59 @@ test('test that creating a non-billable project works', async ({ page }) => {
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});
test('test that creating a public project via the modal works', async ({ page }) => {
const newProjectName = 'Public Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
// Visibility defaults to Private — switch it to Public
await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private');
await page.getByRole('dialog').locator('#visibility').click();
await page.getByRole('option', { name: 'Public' }).click();
await Promise.all([
page.getByRole('button', { name: 'Create Project' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.is_public === true
),
]);
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});
test('test that changing a project to public via the edit modal works', async ({ page, ctx }) => {
const newProjectName = 'Edit Visibility Project ' + Math.floor(1 + Math.random() * 10000);
await createProjectViaApi(ctx, { name: newProjectName });
await goToProjectsOverview(page);
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
const projectRow = page.getByRole('row').filter({ hasText: newProjectName }).first();
await projectRow.getByRole('button').click();
await page.locator(`[aria-label='Edit Project ${newProjectName}']`).click();
// Loaded as Private — switch it to Public
await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private');
await page.getByRole('dialog').locator('#visibility').click();
await page.getByRole('option', { name: 'Public' }).click();
await Promise.all([
page.getByRole('button', { name: 'Update Project' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.is_public === true
),
]);
});
test('test that switching from custom rate to default rate clears billable rate', async ({
page,
ctx,
@@ -640,7 +694,7 @@ test('test that creating a project with estimated time in human-readable format
await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using human-readable format
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('2h 30m');
await estimatedTimeInput.press('Tab');
@@ -668,7 +722,7 @@ test('test that creating a project with estimated time using decimal notation wo
await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using decimal notation (1.5 hours = 1h 30m)
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('1.5');
await estimatedTimeInput.press('Tab');
@@ -696,7 +750,7 @@ test('test that creating a project with estimated time using comma decimal notat
await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m)
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('2,5');
await estimatedTimeInput.press('Tab');
@@ -727,7 +781,7 @@ test('test that updating estimated time on existing project works', async ({ pag
await page.getByRole('menuitem').getByText('Edit').first().click();
// Fill in estimated time
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('4h 15m');
await estimatedTimeInput.press('Tab');
@@ -748,7 +802,7 @@ test('test that estimated time input displays formatted value after blur', async
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
const estimatedTimeInput = page.getByLabel('Time Estimated');
// Enter time in various formats and check the displayed value
await estimatedTimeInput.fill('90');
@@ -925,6 +979,39 @@ test.describe('Employee Projects Restrictions', () => {
employee.page.locator(`[aria-label='Delete Project ${projectName}']`)
).not.toBeVisible();
});
test('employee does not see private projects they are not a member of', async ({
ctx,
employee,
}) => {
const publicName = 'EmpPublicVisible ' + Math.floor(Math.random() * 10000);
const privateName = 'EmpPrivateHidden ' + Math.floor(Math.random() * 10000);
await createPublicProjectViaApi(ctx, { name: publicName });
// createProjectViaApi defaults to is_public: false (private); the employee is not a member
await createProjectViaApi(ctx, { name: privateName });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 });
// The public project is visible — confirms the list has loaded
await expect(employee.page.getByText(publicName)).toBeVisible({ timeout: 10000 });
// The private project the employee is not a member of must not appear
await expect(employee.page.getByText(privateName)).not.toBeVisible();
});
test('employee can see a private project they are a member of', async ({ ctx, employee }) => {
const projectName = 'EmpPrivateMember ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
// Add the employee as a project member so the private project becomes visible to them
await createProjectMemberViaApi(ctx, project.id, { member_id: employee.memberId });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 });
// The private project is visible because the employee is a member
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
});
});
test.describe('Employee Billable Rate Visibility', () => {

View File

@@ -32,7 +32,7 @@ test('test that detailed view shows time entries correctly', async ({ page, ctx
// Verify the time entry is shown with all details
await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible();
await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1h 00min');
await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1:00:00');
await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible();
});
@@ -62,8 +62,8 @@ test('test that updating duration in detailed view works correctly', async ({ pa
),
]);
// Verify the new duration is displayed
await expect(durationInput).toHaveValue(updatedDuration);
// Verify the new duration is displayed (reporting views promote to HH:MM:SS format)
await expect(durationInput).toHaveValue('2:30:00');
});
// ──────────────────────────────────────────────────

View File

@@ -333,7 +333,7 @@ test('test that task filtering works in reporting', async ({ page, ctx }) => {
await page.keyboard.press('Escape');
// Verify the report only shows 1h (task1's duration)
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
});
test('test that task multiselect search filters the option list', async ({ page, ctx }) => {
@@ -474,7 +474,7 @@ test('test that tag filtering works in reporting', async ({ page, ctx }) => {
await page.keyboard.press('Escape');
// Verify only time entries with tag1 are shown
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
});
test('test that tag dropdown search filters the option list', async ({ page, ctx }) => {
@@ -594,7 +594,7 @@ test('test that billable status filtering works in reporting', async ({ page, ct
waitForReportingUpdate(page),
]);
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
});
test('test that billable filter can switch between all three states', async ({ page }) => {
@@ -885,7 +885,7 @@ test.describe('Employee Reporting Restrictions', () => {
// Employee's data should be visible (1h)
await expect(
employee.page.getByTestId('reporting_view').getByText('1h 00min').first()
employee.page.getByTestId('reporting_view').getByText('1:00:00').first()
).toBeVisible();
});

View File

@@ -292,8 +292,8 @@ test('test that shared report respects task filter', async ({ page, ctx }) => {
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('3h 00min')).not.toBeVisible();
await expect(page.getByText('1:00:00').first()).toBeVisible();
await expect(page.getByText('3:00:00')).not.toBeVisible();
});
test('test that shared report respects client filter', async ({ page, ctx }) => {
@@ -369,8 +369,8 @@ test('test that shared report respects tag filter', async ({ page, ctx }) => {
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('3h 00min')).not.toBeVisible();
await expect(page.getByText('1:00:00').first()).toBeVisible();
await expect(page.getByText('3:00:00')).not.toBeVisible();
});
test('test that shared report respects member filter', async ({ page, ctx }) => {
@@ -425,7 +425,7 @@ test('test that shared report with billable filter only shows billable entries',
]);
// Verify only 1h shows before saving
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
const { shareableLink } = await saveAsSharedReport(page, reportName);
@@ -435,8 +435,8 @@ test('test that shared report with billable filter only shows billable entries',
await expect(page.getByText('Total')).toBeVisible();
// Shared report should only show the 1h billable entry, not the 2h non-billable
await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('3h 00min')).not.toBeVisible();
await expect(page.getByText('1:00:00').first()).toBeVisible();
await expect(page.getByText('3:00:00')).not.toBeVisible();
});
// ──────────────────────────────────────────────────

View File

@@ -39,6 +39,10 @@ function getMonthFromTimestamp(timestamp: string): number {
return new Date(timestamp).getUTCMonth() + 1;
}
async function goToProfilePage(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
}
async function goToTimeOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
}
@@ -67,6 +71,14 @@ async function createEmptyTimeEntry(page: Page) {
]);
}
async function setTimeEntriesGrouping(page: Page, enabled: boolean) {
await goToProfilePage(page);
const checkbox = page.getByLabel('Group similar time entries');
const isChecked = await checkbox.isChecked();
if (isChecked !== enabled) await checkbox.click();
await goToTimeOverview(page);
}
test('test that starting and stopping an empty time entry shows a new time entry in the overview', async ({
page,
}) => {
@@ -333,6 +345,30 @@ test.skip('test that load more works when the end of page is reached', async ({
await expect(page.locator('body')).toHaveText(/All time entries are loaded!/);
});
test('test that Group similar time entries option is affected', async ({ page }) => {
// Enable grouping
await setTimeEntriesGrouping(page, true);
// Create 2 similar time entries
await createEmptyTimeEntry(page);
await page.waitForSelector('[data-testid="time_entry_row"]', { timeout: 1000 });
await createEmptyTimeEntry(page);
// Verify similar time entries are grouped
await expect(page.getByTestId('grouped_items_count_button').first()).toBeVisible({
timeout: 1000,
});
// Disable grouping
await setTimeEntriesGrouping(page, false);
// Verify similar time entries are not grouped
await expect(page.locator('[data-testid="time_entry_row"]')).toHaveCount(2, { timeout: 1000 });
await expect(page.locator('[data-testid="grouped_items_count_button"]')).toHaveCount(0, {
timeout: 1000,
});
});
// TODO: Test that updating the time entry start / end times works while it is running
// TODO: Test for project update

View File

@@ -0,0 +1,437 @@
/**
* E2E coverage for the timesheet overlap-prevention logic introduced
* in `useTimesheetCellMutations` (Phase 1+2+3 of the overlap fix).
*
* Each test:
* 1. Pre-creates entries via the API to set up a deterministic
* day-of-work scenario,
* 2. Triggers ONE cell edit through the UI,
* 3. Reads the resulting entries back via the API and asserts on
* the start/end placement.
*
* Pre-creating rows (rather than driving the "Add row" + project picker
* UI) keeps the tests focused on the placement logic and out of the
* project-dropdown's flake surface.
*/
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { expect } from '@playwright/test';
import type { Page, Request } from '@playwright/test';
import {
createProjectViaApi,
createTimeEntryAtHourViaApi,
getTimeEntriesViaApi,
} from './utils/api';
// ──────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────
async function goToTimesheet(page: Page) {
await page.addInitScript(() => {
window.localStorage.setItem('showReleaseInfo-desktop', 'false');
});
await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet');
}
function getMonday(d: Date): Date {
const date = new Date(d);
const day = date.getUTCDay();
const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1);
date.setUTCDate(diff);
date.setUTCHours(0, 0, 0, 0);
return date;
}
function getCurrentWeekMonday(): Date {
return getMonday(new Date());
}
async function waitForTimesheetLoad(page: Page) {
await expect(page.getByTestId('timesheet_view')).toBeVisible();
await expect(page.getByTestId('timesheet_week_display')).toBeVisible();
const timezoneMismatchModal = page
.getByRole('dialog')
.filter({ hasText: 'Timezone mismatch detected' });
if (await timezoneMismatchModal.isVisible().catch(() => false)) {
await timezoneMismatchModal.getByRole('button', { name: 'Cancel' }).click();
await expect(timezoneMismatchModal).not.toBeVisible();
}
}
const HOUR = 3600;
function utcHourOf(iso: string): number {
return new Date(iso).getUTCHours();
}
function utcMinuteOf(iso: string): number {
return new Date(iso).getUTCMinutes();
}
function sortByStart<T extends { start: string }>(entries: T[]): T[] {
return [...entries].sort((a, b) => a.start.localeCompare(b.start));
}
/**
* Returns the locator for the row whose project name matches the given
* substring. Robust against ordering changes.
*/
function rowByProject(page: Page, projectName: string) {
return page.locator('[data-testid="timesheet_row"]').filter({ hasText: projectName });
}
/**
* Returns the locator for the input in the (row, dayIndex) cell, where
* the row is identified by project name.
*/
function cellInputByProject(page: Page, projectName: string, dayIndex: number) {
return rowByProject(page, projectName)
.locator('[data-testid="timesheet_cell"]')
.nth(dayIndex)
.locator('input');
}
/** Asserts that no entries in the list overlap each other. */
function expectNoOverlaps(entries: Array<{ start: string; end: string | null }>) {
const sorted = sortByStart(entries.filter((e) => e.end !== null));
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]!;
const curr = sorted[i]!;
expect(
curr.start >= prev.end!,
`entries overlap: ${prev.start}${prev.end} vs ${curr.start}${curr.end}`
).toBe(true);
}
}
// ──────────────────────────────────────────────────
// Phase 1: createCell — overlap avoidance when cell is empty
// ──────────────────────────────────────────────────
test('extendCell on a row that has no entries on the day yet places after another row (Scenario #4)', async ({
page,
ctx,
}) => {
// Setup: project A has Monday 09:0010:00, project B has Tuesday
// 09:0010:00. The B row is therefore visible on the timesheet but
// has an EMPTY cell on Monday. Typing into B's Monday cell exercises
// the createCell path (cell empty → place a new entry).
const monday = getCurrentWeekMonday();
const tuesday = new Date(monday);
tuesday.setUTCDate(monday.getUTCDate() + 1);
const projectA = await createProjectViaApi(ctx, { name: 'OverlapAlpha' });
const projectB = await createProjectViaApi(ctx, { name: 'OverlapBravo' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectA.id,
});
await createTimeEntryAtHourViaApi(ctx, {
date: tuesday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
// Type 1h into project B's Monday cell. The createCell path should
// place it AFTER project A's 09:0010:00 (i.e. at 10:00 or later),
// not at 09:00.
const input = cellInputByProject(page, 'OverlapBravo', 0);
await input.click();
await input.fill('1');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const bMondayEntry = entries.find(
(e) =>
e.project_id === projectB.id &&
new Date(e.start).getTime() >= monday.getTime() &&
new Date(e.start).getTime() < tuesday.getTime()
)!;
expect(bMondayEntry).toBeDefined();
// 09:00 is blocked → must be at 10:00 or later.
expect(utcHourOf(bMondayEntry.start)).toBeGreaterThanOrEqual(10);
expectNoOverlaps(entries);
});
test('createCell refuses to cross midnight when day is full (Scenario #3)', async ({
page,
ctx,
}) => {
// Setup: fill Monday 01:0023:00 (22 hours, leaving 1h before and
// 1h after — neither big enough for a 3h ask). Project B is on
// Tuesday so the B row exists with an empty Monday cell. Typing 3h
// into B's Monday cell should be refused.
//
// We start at 01:00 (not 00:00) because the API's time-entry
// filter excludes entries whose `start` equals the query's `start`
// bound exactly. Using 01:00 avoids that boundary condition.
const monday = getCurrentWeekMonday();
const tuesday = new Date(monday);
tuesday.setUTCDate(monday.getUTCDate() + 1);
const projectFull = await createProjectViaApi(ctx, { name: 'OverlapFull' });
const projectNew = await createProjectViaApi(ctx, { name: 'OverlapNoRoom' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 1,
durationSeconds: 22 * HOUR,
projectId: projectFull.id,
});
await createTimeEntryAtHourViaApi(ctx, {
date: tuesday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectNew.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
const input = cellInputByProject(page, 'OverlapNoRoom', 0);
const seenMutationRequests: string[] = [];
const onRequest = (request: Request) => {
if (request.url().includes('/time-entries') && request.method() !== 'GET') {
seenMutationRequests.push(request.method());
}
};
page.on('request', onRequest);
await input.click();
await input.fill('3');
await input.press('Enter');
await expect(page.getByText("This day can't fit any more work")).toBeVisible();
page.off('request', onRequest);
const entries = await getTimeEntriesViaApi(ctx);
// The new project should still only have its Tuesday entry.
const newEntries = entries.filter((e) => e.project_id === projectNew.id);
expect(seenMutationRequests).toEqual([]);
expect(newEntries).toHaveLength(1);
expect(utcHourOf(newEntries[0]!.start)).toBe(9);
// The Tuesday entry's date is unchanged (still Tuesday).
expect(new Date(newEntries[0]!.start).getUTCDay()).toBe(2);
});
// ──────────────────────────────────────────────────
// Phase 2: extendCell — collision detection + split
// ──────────────────────────────────────────────────
test('extendCell splits the extension when another row blocks the path (Scenario #5)', async ({
page,
ctx,
}) => {
// Setup:
// - project A on Monday 09:0010:00 (1h)
// - project B on Monday 10:3011:30 (1h, blocker)
// Bumping A's Monday cell from 1h to 3h (+2h) should:
// - extend A to 09:0010:30 (filling the 30min gap)
// - place a new A entry at 11:3013:00 (the remaining 90min)
const monday = getCurrentWeekMonday();
const projectA = await createProjectViaApi(ctx, { name: 'OverlapExtend' });
const projectB = await createProjectViaApi(ctx, { name: 'OverlapBlocker' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectA.id,
});
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 10,
startMinute: 30,
durationSeconds: HOUR,
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
const input = cellInputByProject(page, 'OverlapExtend', 0);
await input.click();
await input.fill('3');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const aEntries = entries.filter((e) => e.project_id === projectA.id);
const bEntries = entries.filter((e) => e.project_id === projectB.id);
// The blocker is unchanged.
expect(bEntries).toHaveLength(1);
expect(utcHourOf(bEntries[0]!.start)).toBe(10);
expect(utcMinuteOf(bEntries[0]!.start)).toBe(30);
// Project A should now have 2 entries.
expect(aEntries).toHaveLength(2);
const sortedA = sortByStart(aEntries);
// Extended entry: 09:00 → 10:30
expect(utcHourOf(sortedA[0]!.start)).toBe(9);
expect(utcHourOf(sortedA[0]!.end!)).toBe(10);
expect(utcMinuteOf(sortedA[0]!.end!)).toBe(30);
// Split remainder: 11:30 → 13:00
expect(utcHourOf(sortedA[1]!.start)).toBe(11);
expect(utcMinuteOf(sortedA[1]!.start)).toBe(30);
// No overlaps anywhere on the day.
expectNoOverlaps(entries);
});
test('extendCell prefers latest-end (not latest-start) when nested entries exist (Scenario #6)', async ({
page,
ctx,
}) => {
// Pre-existing nested overlap on the same project:
// - outer: 09:00 → 12:00 (3h)
// - inner: 10:00 → 11:00 (1h, contained inside outer)
// The cell total is 3h + 1h = 4h. Bumping to 5h (+1h) should grow
// the OUTER entry's end to 13:00, not the inner.
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'OverlapNested' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 9,
durationSeconds: 3 * HOUR,
projectId: project.id,
description: 'outer',
});
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 10,
durationSeconds: HOUR,
projectId: project.id,
description: 'inner',
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(1);
const input = cellInputByProject(page, 'OverlapNested', 0);
await input.click();
await input.fill('5');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const outer = entries.find((e) => e.description === 'outer')!;
const inner = entries.find((e) => e.description === 'inner')!;
expect(utcHourOf(outer.start)).toBe(9);
expect(utcHourOf(outer.end!)).toBe(13); // extended from 12:00 → 13:00
expect(utcHourOf(inner.start)).toBe(10);
expect(utcHourOf(inner.end!)).toBe(11); // unchanged
});
// ──────────────────────────────────────────────────
// Phase 1+2 spillover from previous day
// ──────────────────────────────────────────────────
test('createCell handles intra-week spillover from previous day (Scenario #2)', async ({
page,
ctx,
}) => {
// Setup: an entry that starts on Monday 22:00 and ends Tuesday 03:00
// (5h, crosses midnight INTO Tuesday). This spillover starts inside
// the loaded week, so the timesheet query loads it.
//
// Then we try to place 1h on Tuesday for a different project. The
// expected behavior: the new entry must NOT overlap the spillover.
// Tuesday 09:00 is well clear of the [00:00, 03:00) spillover, so
// 09:00 is the correct placement.
const monday = getCurrentWeekMonday();
const tuesday = new Date(monday);
tuesday.setUTCDate(monday.getUTCDate() + 1);
const wednesday = new Date(monday);
wednesday.setUTCDate(monday.getUTCDate() + 2);
const projectSpill = await createProjectViaApi(ctx, { name: 'OverlapSpill' });
const projectNew = await createProjectViaApi(ctx, { name: 'OverlapToday' });
// Monday 22:00 → Tuesday 03:00 (5h spillover into Tuesday).
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 22,
durationSeconds: 5 * HOUR,
projectId: projectSpill.id,
});
// Stub Wednesday entry on the new project so its row is visible
// even before we type anything in Tuesday's cell.
await createTimeEntryAtHourViaApi(ctx, {
date: wednesday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectNew.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
// Type 1h into the new project's Tuesday cell (day index 1).
const input = cellInputByProject(page, 'OverlapToday', 1);
await input.click();
await input.fill('1');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const newTuesdayEntry = entries.find(
(e) =>
e.project_id === projectNew.id &&
new Date(e.start).getTime() >= tuesday.getTime() &&
new Date(e.start).getTime() < wednesday.getTime()
)!;
expect(newTuesdayEntry).toBeDefined();
// 09:00 is well past the spillover end (03:00) → should land at 09:00.
expect(utcHourOf(newTuesdayEntry.start)).toBe(9);
expectNoOverlaps(entries);
});

641
e2e/timesheet.spec.ts Normal file
View File

@@ -0,0 +1,641 @@
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 } from './utils/api';
// ──────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────
async function goToTimesheet(page: Page) {
await page.addInitScript(() => {
window.localStorage.setItem('showReleaseInfo-desktop', 'false');
});
await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet');
}
function getMonday(d: Date): Date {
const date = new Date(d);
const day = date.getUTCDay();
const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1);
date.setUTCDate(diff);
date.setUTCHours(0, 0, 0, 0);
return date;
}
function getCurrentWeekMonday(): Date {
return getMonday(new Date());
}
function getLastWeekMonday(): Date {
const monday = getCurrentWeekMonday();
monday.setUTCDate(monday.getUTCDate() - 7);
return monday;
}
function getDayOfWeek(weekStart: Date, dayOffset: number): Date {
const date = new Date(weekStart);
date.setUTCDate(date.getUTCDate() + dayOffset);
return date;
}
async function waitForTimesheetLoad(page: Page) {
await page.waitForURL(/\/timesheet(?:$|\?)/);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
await expect(page.getByTestId('timesheet_week_display')).toBeVisible();
const timezoneMismatchModal = page
.getByRole('dialog')
.filter({ hasText: 'Timezone mismatch detected' });
if (await timezoneMismatchModal.isVisible().catch(() => false)) {
await timezoneMismatchModal.getByRole('button', { name: 'Cancel' }).click();
await expect(timezoneMismatchModal).not.toBeVisible();
}
}
function addRowButton(page: Page) {
return page.getByRole('button', { name: /Add row/i }).first();
}
async function chooseRowIdentity(page: Page, optionName: string) {
await addRowButton(page).click();
const dialog = page.getByRole('dialog', { name: /Add row/i });
const dialogVisible = await dialog
.waitFor({ state: 'visible', timeout: 1000 })
.then(() => true)
.catch(() => false);
if (dialogVisible) {
await dialog.getByRole('option', { name: optionName }).click();
return;
}
if (optionName === 'No Project') return;
const row = page.locator('[data-testid="timesheet_row"]').first();
await row.getByText('No Project').click();
await page.getByText(optionName).click();
}
// ──────────────────────────────────────────────────
// Navigation & Page Load
// ──────────────────────────────────────────────────
test('timesheet renders empty with add row + copy last week actions', async ({ page }) => {
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
await expect(addRowButton(page)).toBeVisible();
await expect(page.getByRole('button', { name: /Copy last week/i })).toBeVisible();
});
// ──────────────────────────────────────────────────
// Display Existing Time Entries
// ──────────────────────────────────────────────────
test('timesheet displays existing time entries grouped by project', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const tuesday = getDayOfWeek(monday, 1);
const wednesday = getDayOfWeek(monday, 2);
const projectA = await createProjectViaApi(ctx, { name: 'Project Alpha' });
const projectB = await createProjectViaApi(ctx, { name: 'Project Beta' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: projectA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: wednesday,
duration: '1h',
projectId: projectA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: tuesday,
duration: '3h',
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(2);
// Check that the grand total is shown
await expect(page.getByTestId('timesheet_grand_total')).toBeVisible();
});
test('timesheet groups entries by project and task combination', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Task Project' });
const taskA = await createTaskViaApi(ctx, { name: 'Task A', project_id: project.id });
const taskB = await createTaskViaApi(ctx, { name: 'Task B', project_id: project.id });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '1h',
projectId: project.id,
taskId: taskA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
taskId: taskB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(2);
});
// ──────────────────────────────────────────────────
// Enter Duration in Cell
// ──────────────────────────────────────────────────
test('entering duration in empty cell creates a time entry', async ({ page, ctx }) => {
await createProjectViaApi(ctx, { name: 'Duration Test' });
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await chooseRowIdentity(page, 'Duration Test');
const row = page.locator('[data-testid="timesheet_row"]').first();
// Click the first day cell and enter duration
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayCell = cells.first();
const mondayInput = mondayCell.locator('input');
await mondayInput.click();
await mondayInput.fill('2');
// Submit and wait for create response
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(201);
// Verify the cell shows the duration
await expect(mondayInput).not.toHaveValue('');
});
// ──────────────────────────────────────────────────
// Edit Duration (Increase)
// ──────────────────────────────────────────────────
test('increasing duration in cell extends the last time entry', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Increase Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '1h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
// Click and change to 3 hours
await mondayInput.click();
await mondayInput.fill('3');
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(200);
});
// ──────────────────────────────────────────────────
// Edit Duration (Decrease)
// ──────────────────────────────────────────────────
test('decreasing duration in cell shortens the last time entry', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Decrease Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '3h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
await mondayInput.click();
await mondayInput.fill('1');
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(200);
});
// ──────────────────────────────────────────────────
// Clear Cell
// ──────────────────────────────────────────────────
test('clearing a cell deletes all time entries for that project+day', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Clear Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
await mondayInput.click();
await mondayInput.fill('0');
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'DELETE' &&
resp.status() === 200
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(200);
});
test('Escape during cell edit reverts the displayed value without an API call', async ({
page,
ctx,
}) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Escape Cancel Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
// Capture the formatted display value before editing.
const originalValue = await mondayInput.inputValue();
expect(originalValue).toMatch(/2/);
let mutationFired = false;
page.on('request', (req) => {
if (req.url().includes('/time-entries') && req.method() !== 'GET') {
mutationFired = true;
}
});
await mondayInput.click();
await mondayInput.fill('5');
await mondayInput.press('Escape');
// The Escape handler reverts the displayed value synchronously, so
// once this assertion passes we know the handler ran. Any mutation
// request would have been queued by then.
await expect(mondayInput).toHaveValue(originalValue);
expect(mutationFired).toBe(false);
});
// ──────────────────────────────────────────────────
// Week Navigation
// ──────────────────────────────────────────────────
test('navigating to previous week shows entries from that week', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Last Week Project' });
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Current week should have no entries
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
// Go to previous week — the row-count assertion below auto-retries
// until the new week's data arrives.
await page.getByTestId('timesheet_prev_week').click();
// Should now see the entry
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
});
test('can navigate forward and return to current week', async ({ page }) => {
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should show "This week"
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
// Go to next week — the text assertions below auto-retry until the
// header label flips.
await page.getByTestId('timesheet_next_week').click();
// Should no longer show "This week"
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week');
// Go back to this week
await page.getByTestId('timesheet_week_display').click();
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
});
// ──────────────────────────────────────────────────
// Copy Last Week
// ──────────────────────────────────────────────────
test('copy last week adds project rows from previous week without hours', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const lastWednesday = getDayOfWeek(lastMonday, 2);
const projectA = await createProjectViaApi(ctx, { name: 'Copy Project A' });
const projectB = await createProjectViaApi(ctx, { name: 'Copy Project B' });
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: projectA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: lastWednesday,
duration: '3h',
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Current week should have no populated rows yet.
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
// Open copy last week dropdown and click "Copy rows only"
await page.getByRole('button', { name: /Copy last week/i }).click();
await page.getByText('Copy rows only').click();
// Should now show 2 rows (one per project)
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(2);
// All row totals should be 0
const rowTotals = page.locator('[data-testid="timesheet_row_total"]');
const count = await rowTotals.count();
for (let i = 0; i < count; i++) {
await expect(rowTotals.nth(i)).toContainText('-');
}
});
test('copy last week does not duplicate rows that already exist', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const thisMonday = getCurrentWeekMonday();
const thisTuesday = getDayOfWeek(thisMonday, 1);
const project = await createProjectViaApi(ctx, { name: 'No Dup Project' });
// Create entry for last week
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: project.id,
});
// Create entry for current week
await createTimeEntryOnDateViaApi(ctx, {
date: thisTuesday,
duration: '1h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should have 1 row (from current week entry)
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Open copy last week dropdown and click "Copy rows only"
await page.getByRole('button', { name: /Copy last week/i }).click();
await page.getByText('Copy rows only').click();
// Should still have only 1 row (not duplicated)
await expect(rows).toHaveCount(1);
});
test('copy last week with time entries creates rows and entries', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Copy Time Project' });
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Current week should have no populated rows yet.
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
// Open copy last week dropdown and click "Copy rows and time entries"
await page.getByRole('button', { name: /Copy last week/i }).click();
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
page.getByText('Copy rows and time entries').click(),
]);
// Should now show 1 row with time entries
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Row total should not be 0 (entries were copied)
const rowTotal = page.locator('[data-testid="timesheet_row_total"]').first();
await expect(rowTotal).not.toContainText('0 h');
});
// ──────────────────────────────────────────────────
// Row Removal
// ──────────────────────────────────────────────────
test('can remove an empty project row without confirmation', async ({ page, ctx }) => {
const project = await createProjectViaApi(ctx, { name: 'Empty Remove Project' });
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await chooseRowIdentity(page, project.name);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Hover the row to reveal the X button, then click it
await rows.first().hover();
await rows.first().getByRole('button', { name: 'Remove row' }).click();
// Row should be removed immediately (no dialog)
await expect(rows).toHaveCount(0);
});
test('removing a row with entries shows confirmation dialog and deletes entries', async ({
page,
ctx,
}) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Delete Row Project' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Hover and click X
await rows.first().hover();
await rows.first().getByRole('button', { name: 'Remove row' }).click();
// Confirmation dialog should appear
await expect(page.getByRole('alertdialog')).toBeVisible();
await expect(page.getByText('Remove timesheet row?')).toBeVisible();
// Click Delete
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'DELETE' &&
resp.status() === 200
),
page
.getByRole('alertdialog')
.getByRole('button', { name: /Delete/i })
.click(),
]);
// Row should be gone
await expect(rows).toHaveCount(0);
});
// ──────────────────────────────────────────────────
// Multiple Entries Same Cell
// ──────────────────────────────────────────────────
test('cell correctly sums multiple entries for same project+day', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Sum Test' });
// Create 2 entries for the same project on Monday
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '1h',
projectId: project.id,
description: 'Entry 1',
});
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
description: 'Entry 2',
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should be 1 row (both entries grouped)
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// The Monday cell should show 3h total
const cells = rows.first().locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
// The value should contain "3" (for 3h in some format)
await expect(mondayInput).toHaveValue(/3/);
});
// ──────────────────────────────────────────────────
// Duration Input Formats
// ──────────────────────────────────────────────────
test('cell accepts various duration input formats', async ({ page, ctx }) => {
await createProjectViaApi(ctx, { name: 'Format Test' });
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await chooseRowIdentity(page, 'Format Test');
const row = page.locator('[data-testid="timesheet_row"]').first();
// Test entering "1.5" (should be 1h 30min)
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
await mondayInput.click();
await mondayInput.fill('1.5');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
mondayInput.press('Enter'),
]);
// 1.5 hours = 1h 30min
await expect(mondayInput).toHaveValue('1h 30min');
});

View File

@@ -9,7 +9,7 @@ import {
} from './utils/currentTimeEntry';
import type { Page } from '@playwright/test';
import { newTagResponse } from './utils/tags';
import { updateOrganizationCurrencyViaWeb } from './utils/api';
import { createProjectViaApi, updateOrganizationCurrencyViaWeb } from './utils/api';
// Date picker button name patterns for different date formats
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
@@ -368,6 +368,45 @@ test('test that timer started on dashboard is visible on time page', async ({ pa
await assertThatTimerIsStopped(page);
});
test('test that creating a new project from the time tracker dropdown prefills the search text', async ({
page,
ctx,
}) => {
const existingProjectName = 'Existing Project ' + Math.floor(Math.random() * 10000);
const searchText = 'PrefillProject ' + Math.floor(Math.random() * 10000);
// Create a project so the dropdown renders (not the "Add new project" button)
await createProjectViaApi(ctx, { name: existingProjectName });
await goToDashboard(page);
// Open the project dropdown
await page.getByRole('button', { name: 'No Project' }).click();
// Type a search term that won't match any existing project
await page.getByTestId('client_dropdown_search').fill(searchText);
// Click "Create new Project"
await page.getByText('Create new Project').click();
// Verify the project name input is pre-filled with the search text
await expect(page.getByLabel('Project name')).toHaveValue(searchText);
// Complete project creation to verify full flow works
await Promise.all([
page.waitForResponse(
async (response) =>
response.url().includes('/projects') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.name === searchText
),
page.getByRole('button', { name: 'Create Project' }).click(),
]);
// The project dropdown should now show the newly created project
await expect(page.getByRole('button', { name: searchText })).toBeVisible();
});
test('test that adding a project and tag before starting timer works', async ({ page }) => {
const newTagName = 'TimerTag ' + Math.floor(Math.random() * 10000);
await goToDashboard(page);

View File

@@ -170,10 +170,24 @@ function parseDurationToSeconds(duration: string): number {
return totalSeconds;
}
/**
* Builds a start/end pair anchored to 09:00 UTC on today's UTC date.
*
* Intentionally pinned to UTC (rather than the runner's local time) so
* the produced timestamps are identical regardless of where the suite
* runs. Playwright test users default to UTC, so this matches what the
* app will see and keeps day-of-week / "this week" assertions stable
* for developers running the suite locally in non-UTC timezones.
*/
function createTimestamps(duration: string): { start: string; end: string } {
const durationSeconds = parseDurationToSeconds(duration);
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9, 0, 0);
const start = createUtcTimestampFromDateParts(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
9
);
const end = new Date(start.getTime() + durationSeconds * 1000);
return {
@@ -186,6 +200,32 @@ function formatTimestamp(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
function createUtcTimestampFromDateParts(
year: number,
month: number,
date: number,
hours: number,
minutes: number = 0,
seconds: number = 0
): Date {
return new Date(Date.UTC(year, month, date, hours, minutes, seconds));
}
function createTimestampsOnDate(date: Date, duration: string): { start: string; end: string } {
const durationSeconds = parseDurationToSeconds(duration);
const start = createUtcTimestampFromDateParts(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
9
);
const end = new Date(start.getTime() + durationSeconds * 1000);
return {
start: formatTimestamp(start),
end: formatTimestamp(end),
};
}
function randomColor(): string {
const colors = [
'#ef5350',
@@ -375,6 +415,39 @@ export async function createTimeEntryViaApi(
return body.data as { id: string; start: string; end: string; description: string };
}
export async function createTimeEntryOnDateViaApi(
ctx: TestContext,
data: {
date: Date;
duration: string;
description?: string;
projectId?: string | null;
taskId?: string | null;
tags?: string[];
billable?: boolean;
}
) {
const { start, end } = createTimestampsOnDate(data.date, data.duration);
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
{
data: {
member_id: ctx.memberId,
start,
end,
description: data.description ?? '',
project_id: data.projectId ?? null,
task_id: data.taskId ?? null,
tags: data.tags ?? [],
billable: data.billable ?? false,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; start: string; end: string; description: string };
}
export async function createProjectMemberViaApi(
ctx: TestContext,
projectId: string,
@@ -613,6 +686,72 @@ export async function getInvitationsViaApi(ctx: TestContext) {
// Timestamp-based time entry helpers
// ──────────────────────────────────────────────────
/**
* Creates a time entry on `date` at a specific UTC hour with a duration
* in seconds. Playwright test users default to the UTC timezone, so this
* keeps time-placement scenarios stable across runner locales.
*/
export async function createTimeEntryAtHourViaApi(
ctx: TestContext,
data: {
date: Date;
startHour: number;
startMinute?: number;
durationSeconds: number;
projectId?: string | null;
taskId?: string | null;
description?: string;
}
) {
const start = createUtcTimestampFromDateParts(
data.date.getUTCFullYear(),
data.date.getUTCMonth(),
data.date.getUTCDate(),
data.startHour,
data.startMinute ?? 0
);
const end = new Date(start.getTime() + data.durationSeconds * 1000);
return createTimeEntryWithTimestampsViaApi(ctx, {
start: formatTimestamp(start),
end: formatTimestamp(end),
projectId: data.projectId ?? null,
taskId: data.taskId ?? null,
description: data.description ?? '',
});
}
/**
* Reads time entries for the current member, optionally filtered to a
* date range. Returns the raw API objects (id, start, end, project_id,
* etc.) so tests can assert on the database state after a UI action.
*/
export async function getTimeEntriesViaApi(
ctx: TestContext,
filters: { start?: string; end?: string } = {}
): Promise<
Array<{
id: string;
start: string;
end: string | null;
duration: number | null;
project_id: string | null;
task_id: string | null;
description: string;
}>
> {
const params = new URLSearchParams();
params.set('member_id', ctx.memberId);
if (filters.start) params.set('start', filters.start);
if (filters.end) params.set('end', filters.end);
const response = await ctx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries?${params.toString()}`
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data;
}
export async function createTimeEntryWithTimestampsViaApi(
ctx: TestContext,
data: {

2849
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,10 +12,17 @@
"lint": "eslint resources/js",
"lint:fix": "eslint --fix resources/js",
"type-check": "vue-tsc --noEmit",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:e2e": "rm -rf test-results/.auth && npx playwright test",
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api",
"format": "prettier --write './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'",
"format:check": "prettier --check './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'"
"format:check": "prettier --check './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'",
"build:ui": "npm run build --workspace=@solidtime/ui",
"build:api": "npm run build --workspace=@solidtime/api",
"build:packages": "npm run build:api && npm run build:ui",
"watch:ui": "npm run watch --workspace=@solidtime/ui",
"watch:api": "npm run watch --workspace=@solidtime/api"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
@@ -27,10 +34,12 @@
"@types/chroma-js": "^3.1.0",
"@types/node": "^22.10.10",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.0",
"autoprefixer": "^10.4.20",
"axios": "^1.6.4",
"eslint-plugin-unused-imports": "^4.1.4",
"happy-dom": "^20.8.9",
"laravel-vite-plugin": "^2.1.0",
"openapi-zod-client": "^1.16.2",
"postcss": "^8.4.47",
@@ -40,6 +49,7 @@
"typescript": "^5.7.3",
"vite": "^7.0.0",
"vite-plugin-checker": "^0.12.0",
"vitest": "^4.1.4",
"vue": "^3.5.0",
"vue-tsc": "^3.0.0"
},
@@ -68,7 +78,7 @@
"parse-duration": "^2.0.1",
"pinia": "^3.0.0",
"radix-vue": "^1.9.6",
"reka-ui": "^2.8.2",
"reka-ui": "2.8.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vue-echarts": "^8.0.0",

View File

@@ -57,11 +57,11 @@ const showEditModal = ref(false);
</span>
</div>
<div
class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary">
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
class="whitespace-nowrap flex items-center px-3 py-4 text-sm text-text-primary">
<span> {{ projectCount }} Projects </span>
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center">
<template v-if="client.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>

View File

@@ -83,27 +83,28 @@ const userHasValidMailAddress = computed(() => {
{{ member.name }}
</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
{{ member.email }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
{{ capitalizeFirstLetter(member.role) }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{
member.billable_rate
? formatCents(
member.billable_rate,
organization?.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="member.billable_rate">
{{
formatCents(
member.billable_rate,
organization?.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
}}
</span>
<span v-else class="text-text-tertiary"> -- </span>
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center">
<template v-if="member.is_placeholder === false">
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>

View File

@@ -19,6 +19,7 @@ import { Field, FieldGroup, FieldLabel } from '@/packages/ui/src/field';
import ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableRateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
import ProjectVisibilitySelect from '@/packages/ui/src/Project/ProjectVisibilitySelect.vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
@@ -44,6 +45,7 @@ const project = ref<CreateProjectBody>({
billable_rate: props.originalProject.billable_rate,
is_billable: props.originalProject.is_billable,
estimated_time: props.originalProject.estimated_time,
is_public: props.originalProject.is_public,
});
async function submit() {
@@ -126,6 +128,7 @@ async function submitBillableRate() {
v-if="isAllowedToPerformPremiumAction()"
v-model="project.estimated_time"
@submit="submit()"></EstimatedTimeSection>
<ProjectVisibilitySelect v-model="project.is_public"></ProjectVisibilitySelect>
</FieldGroup>
</template>
<template #footer>

View File

@@ -13,7 +13,8 @@ export type SortColumn =
| 'spent_time'
| 'progress'
| 'billable_rate'
| 'status';
| 'status'
| 'visibility';
export type SortDirection = 'asc' | 'desc';
import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
@@ -102,6 +103,10 @@ const columns = computed(() => [
id: 'status',
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
},
{
id: 'visibility',
accessorFn: (row: Project) => (row.is_public ? 1 : 0),
},
]);
// Columns with sortDescFirst get desc as default direction on first click.
@@ -149,7 +154,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
}
const gridTemplate = computed(() => {
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
});
</script>
@@ -171,7 +176,7 @@ const gridTemplate = computed(() => {
:sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns"
@sort="handleSort"></ProjectTableHeading>
<div v-if="sortedProjects.length === 0" class="col-span-5 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">
{{

View File

@@ -86,6 +86,14 @@ function isChevronUp(column: SortColumn): boolean {
<ChevronUpIcon v-else-if="isChevronUp('status')" 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('visibility')">
Visibility
<ChevronDownIcon v-if="isChevronDown('visibility')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('visibility')" 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

@@ -7,6 +7,8 @@ import {
PencilSquareIcon,
ArchiveBoxIcon as ArchiveBoxIconSolid,
TrashIcon,
GlobeAltIcon,
LockClosedIcon,
} from '@heroicons/vue/20/solid';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
@@ -72,7 +74,7 @@ const billableRateInfo = computed(() => {
return 'Default Rate';
}
}
return '--';
return null;
});
const showEditProjectModal = ref(false);
@@ -98,13 +100,13 @@ const showEditProjectModal = ref(false);
</span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
{{ client?.name }}
</div>
<div v-else>No client</div>
<div v-else class="text-text-tertiary">No client</div>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<div v-if="project.spent_time">
{{
formatHumanReadableDuration(
@@ -114,23 +116,24 @@ const showEditProjectModal = ref(false);
)
}}
</div>
<div v-else>--</div>
<div v-else class="text-text-tertiary">--</div>
</div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<EstimatedTimeProgress
v-else-if="project.estimated_time"
:estimated="project.estimated_time"
:current="project.spent_time"></EstimatedTimeProgress>
<span v-else> -- </span>
<span v-else class="text-text-tertiary"> -- </span>
</div>
<div
v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ billableRateInfo }}
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="billableRateInfo">{{ billableRateInfo }}</span>
<span v-else class="text-text-tertiary">--</span>
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center font-medium">
<template v-if="project.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>
@@ -140,6 +143,17 @@ const showEditProjectModal = ref(false);
<span>Active</span>
</template>
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center font-medium">
<template v-if="project.is_public">
<GlobeAltIcon class="w-4 text-icon-default"></GlobeAltIcon>
<span>Public</span>
</template>
<template v-else>
<LockClosedIcon class="w-4 text-icon-default"></LockClosedIcon>
<span>Private</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<ProjectMoreOptionsDropdown

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue';
import { GlobeAltIcon } from '@heroicons/vue/16/solid';
import { DropdownMenuItem } from '@/packages/ui/src';
import BaseFilterBadge from './BaseFilterBadge.vue';
type VisibilityValue = 'public' | 'private' | 'all';
const props = defineProps<{
value: VisibilityValue;
}>();
const emit = defineEmits<{
remove: [];
'update:value': [value: VisibilityValue];
}>();
const visibilityOptions = [
{ id: 'public' as const, name: 'Public' },
{ id: 'private' as const, name: 'Private' },
];
const label = computed(() => {
return visibilityOptions.find((opt) => opt.id === props.value)?.name ?? 'Visibility';
});
function updateVisibility(visibility: VisibilityValue) {
emit('update:value', visibility);
}
</script>
<template>
<BaseFilterBadge
:icon="GlobeAltIcon"
:label="label"
filter-name="Visibility"
@remove="emit('remove')">
<DropdownMenuItem
v-for="option in visibilityOptions"
:key="option.id"
:class="[value === option.id && 'bg-accent text-accent-foreground']"
@click="updateVisibility(option.id)">
{{ option.name }}
</DropdownMenuItem>
</BaseFilterBadge>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { UserGroupIcon, CheckCircleIcon } from '@heroicons/vue/16/solid';
import { UserGroupIcon, CheckCircleIcon, GlobeAltIcon } from '@heroicons/vue/16/solid';
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
import {
DropdownMenu,
@@ -19,6 +19,7 @@ import { NO_CLIENT_ID } from './constants';
export interface ProjectFilters {
status: 'active' | 'archived' | 'all';
visibility: 'public' | 'private' | 'all';
clientIds: string[];
}
@@ -36,6 +37,11 @@ const statusOptions = [
{ id: 'archived' as const, name: 'Archived' },
];
const visibilityOptions = [
{ id: 'public' as const, name: 'Public' },
{ id: 'private' as const, name: 'Private' },
];
const open = ref(false);
function updateStatus(status: 'active' | 'archived' | 'all') {
@@ -46,6 +52,14 @@ function updateStatus(status: 'active' | 'archived' | 'all') {
open.value = false;
}
function updateVisibility(visibility: 'public' | 'private' | 'all') {
emit('update:filters', {
...props.filters,
visibility,
});
open.value = false;
}
function toggleClient(clientId: string) {
const clientIds = props.filters.clientIds.includes(clientId)
? props.filters.clientIds.filter((id) => id !== clientId)
@@ -69,7 +83,11 @@ function toggleNoClient() {
}
const hasActiveFilters = computed(() => {
return props.filters.status !== 'all' || props.filters.clientIds.length > 0;
return (
props.filters.status !== 'all' ||
props.filters.visibility !== 'all' ||
props.filters.clientIds.length > 0
);
});
</script>
@@ -102,6 +120,25 @@ const hasActiveFilters = computed(() => {
</DropdownMenuSubContent>
</DropdownMenuSub>
<!-- Visibility Filter -->
<DropdownMenuSub>
<DropdownMenuSubTrigger class="gap-2">
<GlobeAltIcon class="h-4 w-4 text-icon-default" />
<span>Visibility</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
v-for="option in visibilityOptions"
:key="option.id"
:class="[
filters.visibility === option.id && 'bg-accent text-accent-foreground',
]"
@click="updateVisibility(option.id)">
{{ option.name }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<!-- Client Filter -->
<DropdownMenuSub v-if="clients.length > 0">
<DropdownMenuSubTrigger class="gap-2">

View File

@@ -2,7 +2,7 @@
import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import { formatDate, formatHumanReadableDuration, formatWeek } from '@/packages/ui/src/utils/time';
import { formatDate, formatReportingDuration, formatWeek } from '@/packages/ui/src/utils/time';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
@@ -137,7 +137,7 @@ const option = computed(() => ({
type: 'bar',
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(
return formatReportingDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format

View File

@@ -8,7 +8,7 @@ import {
import { SaveIcon } from 'lucide-vue-next';
import { getOrganizationCurrencyString } from '@/utils/money';
import {
formatHumanReadableDuration,
formatReportingDuration,
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
@@ -426,7 +426,7 @@ const tableData = computed(() => {
class="justify-end flex items-center font-medium"
:class="!showBillableRate ? 'pr-6' : ''">
{{
formatHumanReadableDuration(
formatReportingDuration(
aggregatedTableTimeEntries.seconds,
organization?.interval_format,
organization?.number_format

View File

@@ -10,7 +10,7 @@ import {
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import { useCssVariable } from '@/packages/ui/src';
import type { Organization } from '@/packages/api/src';
@@ -67,7 +67,7 @@ const option = computed(() => ({
},
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(
return formatReportingDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import { ref, inject, type ComputedRef } from 'vue';
@@ -44,7 +44,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
</div>
<div class="justify-end flex items-center" :class="!showCost ? 'pr-6' : ''">
{{
formatHumanReadableDuration(
formatReportingDuration(
entry.seconds,
organization?.interval_format,
organization?.number_format

View File

@@ -7,8 +7,8 @@ defineProps<{
<template>
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
<dt class="font-semibold text-sm text-text-secondary">{{ title }}</dt>
<dd class="text-xl text-text-primary pt-1 font-semibold">
<dt class="font-medium text-sm text-text-secondary">{{ title }}</dt>
<dd class="text-xl text-text-primary pt-1 font-medium">
{{ value ?? '--' }}
</dd>
</div>

View File

@@ -23,7 +23,7 @@ defineProps<{
<div class="items-center justify-center flex-1 hidden @2xs:flex">
<DayOverviewCardChart :history="history"></DayOverviewCardChart>
</div>
<div class="flex text-sm items-center justify-center text-text-secondary min-w-[65px]">
<div class="flex text-sm items-center justify-center text-text-primary min-w-[65px]">
{{
formatHumanReadableDuration(
duration,

View File

@@ -47,9 +47,9 @@ async function startTaskTimer() {
<template>
<div class="px-3.5 py-2 grid grid-cols-5">
<div class="col-span-4">
<p class="text-text-secondary text-sm pb-1.5 truncate">
<p class="text-text-primary text-sm pb-1.5 truncate">
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
<span v-else>No description</span>
<span v-else class="text-text-secondary">No description</span>
</p>
<ProjectBadge size="base" class="min-w-0 max-w-full" :color="project?.color">
<div class="flex items-center lg:space-x-0.5 min-w-0">

View File

@@ -48,7 +48,7 @@ const { data: latestTeamActivity, isLoading } = useQuery({
class="text-center flex flex-1 justify-center items-center">
<div>
<UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
<h3 class="text-text-primary font-semibold text-sm">Invite your co-workers</h3>
<h3 class="text-text-primary font-medium text-sm">Invite your co-workers</h3>
<p class="pb-5 text-sm">You can invite your entire team.</p>
<SecondaryButton @click="router.visit(route('members'))"
>Go to Members

View File

@@ -11,7 +11,7 @@ defineProps<{
<div class="col-span-2">
<div class="flex justify-between">
<p
class="text-xs min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-secondary">
class="text-sm font-medium min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
{{ name }}
</p>
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
@@ -20,11 +20,11 @@ defineProps<{
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span class="text-green-500 font-medium text-sm block pb-0.5"> working </span>
<span class="text-green-500 text-sm block pb-0.5"> working </span>
</div>
</div>
<div
class="text-text-secondary text-sm font-medium text-ellipsis whitespace-nowrap max-w-full overflow-hidden">
class="text-text-secondary text-sm text-ellipsis whitespace-nowrap max-w-full overflow-hidden">
{{ description }}
</div>
</div>

View File

@@ -16,7 +16,7 @@ import CardTitle from '@/packages/ui/src/CardTitle.vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
import ThisWeekReportingTable from '@/Components/Dashboard/ThisWeekReportingTable.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import { getWeekStart } from '@/packages/ui/src/utils/settings';
import { useCssVariable } from '@/packages/ui/src';
@@ -223,7 +223,7 @@ const option = computed(() => {
type: 'bar',
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(
return formatReportingDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format
@@ -252,7 +252,7 @@ const option = computed(() => {
title="Spent Time"
:value="
totalWeeklyTime
? formatHumanReadableDuration(
? formatReportingDuration(
totalWeeklyTime,
organization?.interval_format,
organization?.number_format
@@ -263,7 +263,7 @@ const option = computed(() => {
title="Billable Time"
:value="
totalWeeklyBillableTime
? formatHumanReadableDuration(
? formatReportingDuration(
totalWeeklyBillableTime,
organization?.interval_format,
organization?.number_format

View File

@@ -2,7 +2,7 @@
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import {
formatHumanReadableDuration,
formatReportingDuration,
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
@@ -174,7 +174,7 @@ const showBillableRate = computed(() => {
class="justify-end flex items-center font-medium"
:class="!showBillableRate ? 'pr-6' : ''">
{{
formatHumanReadableDuration(
formatReportingDuration(
aggregatedTableTimeEntries.seconds,
organization?.interval_format,
organization?.number_format

View File

@@ -28,7 +28,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
<CollapsibleRoot v-else v-model:open="open"
><CollapsibleTrigger class="w-full group py-0.5">
<div
class="text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-medium text-sm items-center justify-between">
class="text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-regular text-sm items-center justify-between">
<div class="flex items-center gap-x-2">
<component
:is="icon"

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/Components/ui/alert-dialog';
defineProps<{
open: boolean;
entryCount: number;
projectName: string;
}>();
defineEmits<{
(e: 'update:open', value: boolean): void;
(e: 'confirm'): void;
}>();
</script>
<template>
<AlertDialog :open="open" @update:open="$emit('update:open', $event)">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove timesheet row?</AlertDialogTitle>
<AlertDialogDescription>
This will delete {{ entryCount }} time
{{ entryCount === 1 ? 'entry' : 'entries' }}
for "{{ projectName }}". This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
@click="$emit('confirm')">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import TimesheetCell from './TimesheetCell.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import type { TimesheetCell as TimesheetCellType } from '@/utils/useTimesheetGrid';
function buildCell(totalSeconds: number): TimesheetCellType {
return {
dayIndex: 0,
date: '2026-04-13',
entries: [],
totalSeconds,
};
}
function mountTimesheetCell(totalSeconds = 2 * 3600) {
return mount(TimesheetCell, {
props: {
cell: buildCell(totalSeconds),
dayIndex: 0,
date: '2026-04-13',
isToday: false,
hasRunningEntry: false,
},
});
}
describe('TimesheetCell', () => {
it('emits 0 when the cleared value is committed on blur', async () => {
const wrapper = mountTimesheetCell();
const input = wrapper.get('input');
await input.trigger('focus');
await input.setValue('');
await input.trigger('blur');
expect(wrapper.emitted('update')).toEqual([[0]]);
});
it('emits 0 when the cleared value is committed with Enter', async () => {
const wrapper = mountTimesheetCell();
const input = wrapper.get('input');
await input.trigger('focus');
await input.setValue('');
await input.trigger('keydown', { key: 'Enter' });
expect(wrapper.emitted('update')).toEqual([[0]]);
});
it('restores the previous value and emits nothing on Escape', async () => {
const wrapper = mountTimesheetCell();
const input = wrapper.get('input');
const previousValue = formatHumanReadableDuration(2 * 3600, 'hours-minutes', 'point');
await input.trigger('focus');
await input.setValue('');
await input.trigger('keydown', { key: 'Escape' });
await nextTick();
expect(wrapper.emitted('update')).toBeUndefined();
expect((input.element as HTMLInputElement).value).toBe(previousValue);
});
it('shows a pending 0 (delete in flight) over the cell total', () => {
const wrapper = mount(TimesheetCell, {
props: {
cell: buildCell(2 * 3600),
dayIndex: 0,
date: '2026-04-13',
isToday: false,
hasRunningEntry: false,
pendingSeconds: 0,
},
});
// `??` (not `||`): a pending 0 must win over the 2h cell total.
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('');
});
it('disables editing while the cell is saving', () => {
const wrapper = mount(TimesheetCell, {
props: {
cell: buildCell(2 * 3600),
dayIndex: 0,
date: '2026-04-13',
isToday: false,
hasRunningEntry: false,
saveStatus: 'saving',
},
});
expect((wrapper.get('input').element as HTMLInputElement).disabled).toBe(true);
});
});

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed } from 'vue';
import { CheckIcon } from '@heroicons/vue/16/solid';
import DurationSecondsInput from '@/packages/ui/src/Input/DurationSecondsInput.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/packages/ui/src/tooltip';
import type { TimesheetCell } from '@/utils/useTimesheetGrid';
import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations';
const props = defineProps<{
cell?: TimesheetCell;
dayIndex: number;
date: string;
isToday: boolean;
hasRunningEntry: boolean;
saveStatus?: CellSaveStatus;
pendingSeconds?: number;
}>();
const emit = defineEmits<{
update: [newSeconds: number];
}>();
// Show the optimistic value while saving; `??` (not `||`) so a pending 0 (delete) wins.
const displaySeconds = computed(() => props.pendingSeconds ?? props.cell?.totalSeconds ?? 0);
const isSaving = computed(() => props.saveStatus === 'saving');
// Swap the border color (don't layer) to avoid same-specificity fights.
const inputClass = computed(() => {
const border = props.saveStatus === 'error' ? 'border-red-500/70' : 'border-input-border';
return [
'w-[80px] mx-auto text-center font-medium',
'bg-transparent text-text-primary placeholder:text-text-quaternary',
'rounded-lg border shadow-none',
border,
'hover:bg-card-background',
'focus-visible:bg-tertiary focus-visible:border-transparent',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
'disabled:cursor-wait disabled:opacity-70',
].join(' ');
});
</script>
<template>
<div
data-testid="timesheet_cell"
class="flex items-center justify-center border-t border-default-background-separator"
:class="{ 'bg-default-background': isToday }">
<TooltipProvider v-if="hasRunningEntry" :delay-duration="100">
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-block cursor-not-allowed">
<DurationSecondsInput
:model-value="cell?.totalSeconds ?? 0"
disabled
default-unit="hours"
placeholder="-"
size="sm"
input-class="w-[80px] mx-auto text-center font-medium
bg-transparent text-text-primary placeholder:text-text-quaternary
rounded-lg border border-input-border shadow-none
pointer-events-none
disabled:opacity-50 disabled:cursor-not-allowed" />
</span>
</TooltipTrigger>
<TooltipContent> Stop the running time entry to edit the timesheet </TooltipContent>
</Tooltip>
</TooltipProvider>
<template v-else>
<span class="relative inline-flex items-center">
<DurationSecondsInput
:model-value="displaySeconds"
default-unit="hours"
placeholder="-"
size="sm"
:disabled="isSaving"
:input-class="inputClass"
@commit="(seconds) => emit('update', seconds ?? 0)" />
<span
v-if="saveStatus === 'saving' || saveStatus === 'saved'"
class="pointer-events-none absolute left-full top-1/2 ml-1.5 flex -translate-y-1/2 items-center"
:aria-label="saveStatus === 'saving' ? 'Saving' : 'Saved'">
<LoadingSpinner
v-if="saveStatus === 'saving'"
class="h-3 w-3 m-0 text-text-tertiary" />
<CheckIcon v-else class="h-3 w-3 text-text-tertiary" />
</span>
</span>
</template>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { Button } from '@/packages/ui/src/Buttons';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/packages/ui/src/dropdown-menu';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import { ChevronDownIcon, ClockIcon, ListBulletIcon } from '@heroicons/vue/20/solid';
defineProps<{
busy: boolean;
}>();
defineEmits<{
(e: 'copy-rows'): void;
(e: 'copy-with-time'): void;
}>();
</script>
<template>
<div class="mt-2 flex items-center pl-4 pr-4">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" :disabled="busy">
<LoadingSpinner v-if="busy" class="h-3.5 w-3.5 m-0" />
Copy last week
<ChevronDownIcon v-if="!busy" class="h-3.5 w-3.5 ml-1 text-icon-default" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="min-w-[220px]">
<DropdownMenuItem
class="flex items-center space-x-3 cursor-pointer"
@click="$emit('copy-rows')">
<ListBulletIcon class="w-5 text-icon-default" />
<span>Copy rows only</span>
</DropdownMenuItem>
<DropdownMenuItem
class="flex items-center space-x-3 cursor-pointer"
@click="$emit('copy-with-time')">
<ClockIcon class="w-5 text-icon-default" />
<span>Copy rows and time entries</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import { inject, type ComputedRef } from 'vue';
import { Button } from '@/packages/ui/src/Buttons';
import { PlusIcon } from '@heroicons/vue/20/solid';
import TimesheetRow from '@/Components/Timesheet/TimesheetRow.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import type {
Client,
CreateClientBody,
CreateProjectBody,
Organization,
Project,
Tag,
Task,
} from '@/packages/api/src';
import type { TimesheetRow as TimesheetRowType, TimesheetRowKey } from '@/utils/useTimesheetGrid';
import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations';
const organization = inject<ComputedRef<Organization>>('organization');
const dayjs = getDayJsInstance();
defineProps<{
rows: TimesheetRowType[];
weekDays: string[];
todayDate: string;
dayTotals: number[];
weekTotalFormatted: string;
projects: Project[];
tasks: Task[];
clients: Client[];
tags: Tag[];
currency: string;
canCreateProject: boolean;
enableEstimatedTime: boolean;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createTag: (name: string) => Promise<Tag | undefined>;
formatDuration: (seconds: number) => string;
cellStatuses: Record<string, CellSaveStatus>;
cellPendingSeconds: Record<string, number>;
}>();
const emit = defineEmits<{
(e: 'remove-row', key: TimesheetRowKey): void;
(e: 'cell-update', row: TimesheetRowType, dayIndex: number, seconds: number): void;
(
e: 'project-task-change',
row: TimesheetRowType,
projectId: string | null,
taskId: string | null
): void;
(e: 'billable-change', row: TimesheetRowType, billable: boolean): void;
(e: 'tags-change', row: TimesheetRowType, tags: string[]): void;
(e: 'add-row', projectId: string | null, taskId: string | null): void;
}>();
</script>
<template>
<div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div
class="grid min-w-full w-max border-y border-default-background-separator"
style="
grid-template-columns:
minmax(420px, 1fr) repeat(7, minmax(116px, 120px)) minmax(100px, auto)
40px;
">
<!-- Header row -->
<div
class="bg-background dark:bg-secondary pl-7 pr-3 py-1 text-xs text-text-tertiary md:sticky md:left-0 md:z-10">
Project
</div>
<div
v-for="day in weekDays"
:key="day"
class="bg-background dark:bg-secondary px-2 py-1 text-center">
<div class="text-xs font-medium text-text-secondary">
{{ dayjs(day).format('ddd D') }}
</div>
</div>
<div
class="bg-background dark:bg-secondary pl-3 pr-3 py-1 text-right text-xs text-text-tertiary">
Total
</div>
<div class="bg-background dark:bg-secondary"></div>
<!-- Data rows -->
<TimesheetRow
v-for="row in rows"
:key="row.key"
:row="row"
:week-days="weekDays"
:today-date="todayDate"
:projects="projects"
:tasks="tasks"
:clients="clients"
:tags="tags"
:currency="currency"
:can-create-project="canCreateProject"
:enable-estimated-time="enableEstimatedTime"
:create-project="createProject"
:create-client="createClient"
:create-tag="createTag"
:format-duration="formatDuration"
:cell-statuses="cellStatuses"
:cell-pending-seconds="cellPendingSeconds"
@remove-row="$emit('remove-row', $event)"
@cell-update="
(dayIndex, seconds) => $emit('cell-update', row, dayIndex, seconds)
"
@project-task-change="(pId, tId) => $emit('project-task-change', row, pId, tId)"
@billable-change="(billable) => $emit('billable-change', row, billable)"
@tags-change="(t) => $emit('tags-change', row, t)" />
<!-- Add row -->
<div
class="col-span-full flex items-center gap-2 border-t border-default-background-separator pl-4 pr-4 py-2">
<TimeTrackerProjectTaskDropdown
:project="null"
:task="null"
:projects="projects"
:tasks="tasks"
:clients="clients"
:currency="currency"
:can-create-project="canCreateProject"
:enable-estimated-time="enableEstimatedTime"
:create-project="createProject"
:create-client="createClient"
:organization-billable-rate="organization?.billable_rate ?? null"
:no-project-value="null"
align="start"
@changed="(p, t) => emit('add-row', p, t)">
<template #trigger>
<Button variant="ghost" size="sm" class="text-text-secondary">
<PlusIcon class="h-4 w-4 mr-1 text-icon-default" />
Add row
</Button>
</template>
</TimeTrackerProjectTaskDropdown>
</div>
<!-- Totals row -->
<div
class="border-t border-default-background-separator bg-background dark:bg-secondary pl-7 pr-3 py-1 text-xs text-text-tertiary md:sticky md:left-0 md:z-10">
Total
</div>
<div
v-for="(total, dayIndex) in dayTotals"
:key="dayIndex"
data-testid="timesheet_day_total"
:class="[
'flex items-center justify-center border-t border-default-background-separator bg-background dark:bg-secondary px-2 py-1 text-xs font-medium',
weekDays[dayIndex] === todayDate
? 'text-text-primary'
: 'text-text-secondary',
]">
<span class="w-[80px] text-center">
{{ total > 0 ? formatDuration(total) : '-' }}
</span>
</div>
<div
class="flex items-center justify-end border-t border-default-background-separator bg-background dark:bg-secondary pl-3 pr-3 py-1 text-xs font-semibold text-text-primary">
{{ weekTotalFormatted }}
</div>
<div
class="border-t border-default-background-separator bg-background dark:bg-secondary"></div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { Button } from '@/packages/ui/src/Buttons';
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@heroicons/vue/20/solid';
defineProps<{
isCurrentWeek: boolean;
weekNumber: number;
weekRangeDisplay: string;
weekTotalFormatted: string;
}>();
defineEmits<{
(e: 'previous'): void;
(e: 'next'): void;
(e: 'current'): void;
}>();
</script>
<template>
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
<!-- Left: Week navigation -->
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
class="h-8 w-8"
data-testid="timesheet_prev_week"
@click="$emit('previous')">
<ChevronLeftIcon class="h-4 w-4" />
</Button>
<button
data-testid="timesheet_week_display"
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-text-primary hover:bg-card-background rounded-md transition"
@click="$emit('current')">
<CalendarIcon class="h-4 w-4 text-icon-default" />
<span v-if="isCurrentWeek">This week</span>
<span v-else>{{ weekRangeDisplay }}</span>
<span class="text-text-tertiary">&middot; W{{ weekNumber }}</span>
</button>
<Button
variant="outline"
size="icon"
class="h-8 w-8"
data-testid="timesheet_next_week"
@click="$emit('next')">
<ChevronRightIcon class="h-4 w-4" />
</Button>
</div>
<!-- Right: Week total -->
<div class="flex items-center gap-2.5">
<span class="text-xs text-text-tertiary uppercase tracking-wider">Week Total</span>
<span
data-testid="timesheet_grand_total"
class="text-sm font-semibold text-text-primary">
{{ weekTotalFormatted }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { computed, inject, type ComputedRef } from 'vue';
import { XMarkIcon } from '@heroicons/vue/16/solid';
import TimesheetCell from './TimesheetCell.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import type {
CreateClientBody,
CreateProjectBody,
Project,
Task,
Client,
Tag,
Organization,
} from '@/packages/api/src';
import type { TimesheetRow, TimesheetRowKey } from '@/utils/useTimesheetGrid';
import {
makeCellStatusKey,
type CellSaveStatus,
} from '@/utils/timesheet/useTimesheetCellMutations';
import { Button } from '@/packages/ui/src/Buttons';
const organization = inject<ComputedRef<Organization>>('organization');
const props = defineProps<{
row: TimesheetRow;
weekDays: string[];
todayDate: string;
projects: Project[];
tasks: Task[];
clients: Client[];
tags: Tag[];
currency: string;
canCreateProject: boolean;
enableEstimatedTime: boolean;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createTag: (name: string) => Promise<Tag | undefined>;
formatDuration: (seconds: number) => string;
cellStatuses: Record<string, CellSaveStatus>;
cellPendingSeconds: Record<string, number>;
}>();
const emit = defineEmits<{
removeRow: [key: TimesheetRowKey];
cellUpdate: [dayIndex: number, newSeconds: number];
projectTaskChange: [projectId: string | null, taskId: string | null];
billableChange: [billable: boolean];
tagsChange: [tags: string[]];
}>();
const selectedProject = computed({
get: () => props.row.projectId,
set: (val) => emit('projectTaskChange', val, selectedTask.value),
});
const selectedTask = computed({
get: () => props.row.taskId,
set: (val) => emit('projectTaskChange', selectedProject.value, val),
});
const rowTotalFormatted = computed(() => props.formatDuration(props.row.totalSeconds));
function hasRunningEntry(dayIndex: number): boolean {
const cell = props.row.cells.get(dayIndex);
if (!cell) return false;
return cell.entries.some((e) => e.end === null);
}
</script>
<template>
<div data-testid="timesheet_row" class="contents group">
<!-- Project/Task column -->
<div
class="flex items-center gap-1 border-t border-default-background-separator bg-default-background pl-4 pr-3 py-2 md:sticky md:left-0 md:z-10">
<div class="flex-1 min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="selectedProject"
v-model:task="selectedTask"
:projects="projects"
:tasks="tasks"
:clients="clients"
:currency="currency"
:can-create-project="canCreateProject"
:enable-estimated-time="enableEstimatedTime"
:create-project="createProject"
:create-client="createClient"
:organization-billable-rate="organization?.billable_rate ?? null"
:no-project-value="null"
variant="ghost"
size="sm"
class="w-full" />
</div>
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
<TimeEntryRowTagDropdown
:create-tag="createTag"
:tags="tags"
:model-value="row.tags"
@changed="emit('tagsChange', $event)" />
<BillableToggleButton
:model-value="row.billable"
size="small"
faded
@changed="emit('billableChange', $event)" />
</div>
</div>
<!-- Day cells -->
<TimesheetCell
v-for="(day, dayIndex) in weekDays"
:key="day"
:cell="row.cells.get(dayIndex)"
:day-index="dayIndex"
:date="day"
:is-today="day === todayDate"
:has-running-entry="hasRunningEntry(dayIndex)"
:save-status="cellStatuses[makeCellStatusKey(row.key, dayIndex)]"
:pending-seconds="cellPendingSeconds[makeCellStatusKey(row.key, dayIndex)]"
@update="(seconds) => emit('cellUpdate', dayIndex, seconds)" />
<!-- Row total -->
<div
data-testid="timesheet_row_total"
class="flex items-center justify-end border-t border-default-background-separator pl-3 pr-3 py-3 text-sm font-medium text-text-primary">
{{ rowTotalFormatted }}
</div>
<!-- Remove action -->
<div
class="flex items-center justify-center border-t border-default-background-separator pr-4 py-3">
<Button
variant="ghost"
size="icon"
aria-label="Remove row"
class="h-6 w-6 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
@click="emit('removeRow', row.key)">
<XMarkIcon class="h-3.5 w-3.5 text-icon-default" />
</Button>
</div>
</div>
</template>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import { twMerge } from 'tailwind-merge';
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = computed(() => {
@@ -13,7 +13,7 @@ const delegatedProps = computed(() => {
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="twMerge(buttonVariants(), props.class)">
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import { twMerge } from 'tailwind-merge';
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>();
@@ -16,7 +16,7 @@ const delegatedProps = computed(() => {
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="twMerge(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)">
:class="cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)">
<slot />
</AlertDialogCancel>
</template>

View File

@@ -17,6 +17,7 @@ import {
UserGroupIcon,
XMarkIcon,
DocumentTextIcon,
TableCellsIcon,
} from '@heroicons/vue/20/solid';
import { PanelLeft } from 'lucide-vue-next';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
@@ -135,7 +136,7 @@ const page = usePage<{
? 'max-lg:translate-x-0 max-lg:shadow-xl'
: 'max-lg:-translate-x-full',
]"
class="flex-shrink-0 h-screen fixed w-[280px] px-2.5 py-4 hidden lg:flex flex-col justify-between bg-background border-r border-default-background-separator max-lg:z-50 max-lg:transition-transform max-lg:duration-200 max-lg:ease-in-out lg:w-[230px] 2xl:w-[250px] 2xl:px-3 lg:border-r-0"
class="flex-shrink-0 h-screen fixed w-[280px] px-2.5 py-4 hidden lg:flex flex-col justify-between bg-background border-r border-default-background-separator max-lg:z-50 max-lg:transition-transform max-lg:duration-200 max-lg:ease-in-out lg:w-[230px] lg:border-r-0"
:style="showSidebarMenu ? { display: 'flex' } : undefined">
<div class="flex flex-col h-full">
<div
@@ -185,6 +186,11 @@ const page = usePage<{
:icon="CalendarIcon"
:current="route().current('calendar')"
:href="route('calendar')"></NavigationSidebarItem>
<NavigationSidebarItem
title="Timesheet"
:icon="TableCellsIcon"
:current="route().current('timesheet')"
:href="route('timesheet')"></NavigationSidebarItem>
<NavigationSidebarItem
title="Reporting"
:icon="ChartBarIcon"
@@ -287,7 +293,7 @@ const page = usePage<{
<div class="justify-self-end">
<UpdateSidebarNotification></UpdateSidebarNotification>
<ul
class="border-t border-default-background-separator pt-3 gap-1 pr-2 flex justify-between items-center">
class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center">
<UserSettingsIcon></UserSettingsIcon>
<NavigationSidebarItem
@@ -308,7 +314,7 @@ const page = usePage<{
</div>
</div>
</div>
<div class="flex-1 lg:ml-[230px] 2xl:ml-[250px] min-w-0">
<div class="flex-1 lg:ml-[230px] min-w-0">
<div
class="h-screen overflow-y-auto flex flex-col bg-default-background border-l border-default-background-separator">
<div

View File

@@ -3,6 +3,7 @@ import AppLayout from '@/Layouts/AppLayout.vue';
import { useTimeEntriesCalendarQuery } from '@/utils/useTimeEntriesCalendarQuery';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { computed, ref, onMounted } from 'vue';
import type { Dayjs } from 'dayjs';
import { useQueryClient } from '@tanstack/vue-query';
import {
type Client,
@@ -27,8 +28,8 @@ import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const calendarStart = ref<Date | undefined>(undefined);
const calendarEnd = ref<Date | undefined>(undefined);
const calendarStart = ref<Dayjs | undefined>(undefined);
const calendarEnd = ref<Dayjs | undefined>(undefined);
// Test-injectable activity periods (for E2E testing).
// These hooks are no-ops in production — they only take effect when test code
@@ -99,7 +100,7 @@ const { tags } = useTagsQuery();
const queryClient = useQueryClient();
function onDatesChange({ start, end }: { start: Date; end: Date }) {
function onDatesChange({ start, end }: { start: Dayjs; end: Dayjs }) {
calendarStart.value = start;
calendarEnd.value = end;
}

View File

@@ -2,8 +2,10 @@
import FormSection from '@/Components/FormSection.vue';
import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import { Checkbox } from '@/packages/ui/src';
import { usePreferredColorScheme } from '@vueuse/core';
import { themeSetting } from '@/utils/theme';
import { groupSimilarTimeEntriesSetting } from '@/utils/timeEntryGrouping';
const preferredColor = usePreferredColorScheme();
</script>
@@ -15,6 +17,7 @@ const preferredColor = usePreferredColorScheme();
<template #description> Choose how you want solidtime to look on your device </template>
<template #form>
<!-- Theme -->
<Field class="col-span-6 sm:col-span-4">
<FieldLabel for="theme">Theme</FieldLabel>
<Select id="theme" v-model="themeSetting">
@@ -31,6 +34,14 @@ const preferredColor = usePreferredColorScheme();
System default: {{ preferredColor }}
</FieldDescription>
</Field>
<!-- Group similar time entries -->
<Field class="col-span-6 sm:col-span-4" orientation="horizontal">
<Checkbox
id="group_similar_time_entries"
v-model:checked="groupSimilarTimeEntriesSetting" />
<FieldLabel for="group_similar_time_entries">Group similar time entries</FieldLabel>
</Field>
</template>
</FormSection>
</template>

View File

@@ -109,7 +109,7 @@ const shownTasks = computed(() => {
</div>
</li>
</ol>
<div class="px-4">
<div class="px-4 space-x-1">
<Badge v-if="project?.billable_rate">
{{ billableRateFormatted }}
/ h
@@ -118,6 +118,7 @@ const shownTasks = computed(() => {
Default Rate
</Badge>
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>
<Badge>{{ project?.is_public ? 'Public' : 'Private' }}</Badge>
</div>
</nav>
<div>

View File

@@ -20,6 +20,7 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useStorage } from '@vueuse/core';
import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue';
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
import ProjectVisibilityFilterBadge from '@/Components/Common/Project/ProjectVisibilityFilterBadge.vue';
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
@@ -36,6 +37,7 @@ interface ProjectTableState {
filters: {
clientIds: string[];
status: 'active' | 'archived' | 'all';
visibility: 'public' | 'private' | 'all';
};
}
@@ -47,10 +49,17 @@ const tableState = useStorage<ProjectTableState>(
filters: {
clientIds: [],
status: 'all',
visibility: 'all',
},
},
undefined,
{ mergeDefaults: true }
{
mergeDefaults: (storage, defaults) => ({
...defaults,
...storage,
filters: { ...defaults.filters, ...storage.filters },
}),
}
);
function handleSort(column: SortColumn, direction: SortDirection) {
@@ -69,6 +78,14 @@ const filteredProjects = computed(() => {
return false;
}
// Visibility filter
if (tableState.value.filters.visibility === 'public' && !project.is_public) {
return false;
}
if (tableState.value.filters.visibility === 'private' && project.is_public) {
return false;
}
// Client filter
const hasClientFilter = tableState.value.filters.clientIds.length > 0;
if (hasClientFilter) {
@@ -91,6 +108,10 @@ function removeStatusFilter() {
tableState.value.filters.status = 'all';
}
function removeVisibilityFilter() {
tableState.value.filters.visibility = 'all';
}
function removeClientFilter() {
tableState.value.filters.clientIds = [];
}
@@ -152,6 +173,15 @@ const showBillableRate = computed(() => {
tableState.filters.status = $event as 'active' | 'archived' | 'all'
" />
<ProjectVisibilityFilterBadge
v-if="tableState.filters.visibility !== 'all'"
data-testid="visibility-filter-badge"
:value="tableState.filters.visibility"
@remove="removeVisibilityFilter"
@update:value="
tableState.filters.visibility = $event as 'public' | 'private' | 'all'
" />
<ProjectClientFilterBadge
v-if="tableState.filters.clientIds.length > 0"
data-testid="client-filter-badge"

View File

@@ -390,6 +390,7 @@ async function downloadExport(format: ExportFormat) {
:organization-billable-rate="organization?.billable_rate ?? null"
:duplicate-time-entry="() => createTimeEntry(entry)"
:members="members"
is-report
show-date
show-member
:time-entry="entry"

View File

@@ -3,7 +3,7 @@ import MainContainer from '@/packages/ui/src/MainContainer.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import { ChartBarIcon } from '@heroicons/vue/20/solid';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
@@ -231,7 +231,7 @@ onMounted(async () => {
</div>
<div class="justify-end flex items-center font-medium">
{{
formatHumanReadableDuration(
formatReportingDuration(
aggregatedTableTimeEntries.seconds,
reportIntervalFormat,
reportNumberFormat

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue';
import { Field, FieldLabel } from '@/packages/ui/src/field';
import { computed, onMounted, ref } from 'vue';
import { Field, FieldDescription, FieldLabel } from '@/packages/ui/src/field';
import type { UpdateOrganizationBody } from '@/packages/api/src';
import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia';
@@ -52,6 +52,12 @@ onMounted(async () => {
}
});
const showsHhMmSsInReports = computed(
() =>
form.value.interval_format === 'hours-minutes' ||
form.value.interval_format === 'hours-minutes-colon-separated'
);
async function submit() {
mutation.mutate(form.value);
}
@@ -149,6 +155,12 @@ async function submit() {
>
</SelectContent>
</Select>
<FieldDescription v-if="showsHhMmSsInReports">
Reports and totals shown next to cost use HH:MM:SS for this format, so the
duration reconciles with the billable amount down to the second. Everywhere else
(time tracker, calendar, entry rows) seconds are omitted and durations stay in
your chosen format.
</FieldDescription>
</Field>
</template>

View File

@@ -1,448 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { router, useForm, usePage } from '@inertiajs/vue3';
import ActionMessage from '@/Components/ActionMessage.vue';
import ActionSection from '@/Components/ActionSection.vue';
import ConfirmationModal from '@/Components/ConfirmationModal.vue';
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import FormSection from '@/Components/FormSection.vue';
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import SectionBorder from '@/Components/SectionBorder.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import type { Organization, OrganizationInvitation, User } from '@/types/models';
import type { Membership, Permissions, Role } from '@/types/jetstream';
import { filterRoles } from '@/utils/roles';
type UserWithMembership = User & { membership: Membership };
const props = defineProps<{
team: Organization;
availableRoles: Role[];
userPermissions: Permissions;
}>();
const users = computed(() => {
return props.team.users as Array<UserWithMembership>;
});
const page = usePage<{
auth: {
user: User;
};
}>();
const addTeamMemberForm = useForm({
email: '',
role: null as string | null,
});
const updateRoleForm = useForm({
role: null as string | null,
});
const leaveTeamForm = useForm({});
const removeTeamMemberForm = useForm({});
const currentlyManagingRole = ref(false);
const managingRoleFor = ref<User | null>(null);
const confirmingLeavingTeam = ref(false);
const teamMemberBeingRemoved = ref<User | null>(null);
const addTeamMember = () => {
addTeamMemberForm.post(route('team-members.store', props.team.id), {
errorBag: 'addTeamMember',
preserveScroll: true,
onSuccess: () => addTeamMemberForm.reset(),
});
};
const cancelTeamInvitation = (invitation: OrganizationInvitation) => {
router.delete(route('team-invitations.destroy', invitation.id), {
preserveScroll: true,
});
};
const manageRole = (teamMember: User & { membership: Membership }) => {
managingRoleFor.value = teamMember;
updateRoleForm.role = teamMember.membership.role;
currentlyManagingRole.value = true;
};
const updateRole = () => {
updateRoleForm.put(
route('team-members.update', {
team: props.team.id,
user: managingRoleFor.value?.id,
}),
{
preserveScroll: true,
onSuccess: () => (currentlyManagingRole.value = false),
}
);
};
const confirmLeavingTeam = () => {
confirmingLeavingTeam.value = true;
};
const leaveTeam = () => {
leaveTeamForm.delete(route('team-members.destroy', [props.team.id, page.props.auth.user.id]));
};
const confirmTeamMemberRemoval = (teamMember: User) => {
teamMemberBeingRemoved.value = teamMember;
};
const removeTeamMember = () => {
removeTeamMemberForm.delete(
route('team-members.destroy', {
team: props.team.id,
user: teamMemberBeingRemoved.value?.id,
}),
{
errorBag: 'removeTeamMember',
preserveScroll: true,
preserveState: true,
onSuccess: () => (teamMemberBeingRemoved.value = null),
}
);
};
const displayableRole = (role: string) => {
return props.availableRoles.find((r) => r.key === role)?.name;
};
</script>
<template>
<div>
<div v-if="userPermissions.canAddTeamMembers">
<SectionBorder />
<!-- Add Organization Member -->
<FormSection @submitted="addTeamMember">
<template #title> Add Organization Member</template>
<template #description>
Add a new member to your organization, allowing them to collaborate with you.
</template>
<template #form>
<div class="col-span-6">
<div class="max-w-xl text-sm text-muted">
Please provide the email address of the person you would like to add to
this organization.
</div>
</div>
<!-- Member Email -->
<Field class="col-span-6 sm:col-span-4">
<FieldLabel for="email">Email</FieldLabel>
<TextInput
id="email"
v-model="addTeamMemberForm.email"
type="email"
class="block w-full" />
<FieldError v-if="addTeamMemberForm.errors.email">{{
addTeamMemberForm.errors.email
}}</FieldError>
</Field>
<!-- Role -->
<div v-if="availableRoles.length > 0" class="col-span-6 lg:col-span-4">
<FieldLabel for="roles">Role</FieldLabel>
<FieldError v-if="addTeamMemberForm.errors.role">{{
addTeamMemberForm.errors.role
}}</FieldError>
<div
class="relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer">
<button
v-for="(role, i) in filterRoles(availableRoles)"
:key="role.key"
type="button"
class="relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
:class="{
'border-t border-card-border focus:border-none rounded-t-none':
i > 0,
'rounded-b-none': i != Object.keys(availableRoles).length - 1,
}"
@click="addTeamMemberForm.role = role.key">
<div
:class="{
'opacity-50':
addTeamMemberForm.role &&
addTeamMemberForm.role != role.key,
}">
<!-- Role Name -->
<div class="flex items-center">
<div
class="text-sm text-text-primary"
:class="{
'font-semibold': addTeamMemberForm.role == role.key,
}">
{{ role.name }}
</div>
<svg
v-if="addTeamMemberForm.role == role.key"
class="ms-2 h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<!-- Role Description -->
<div class="mt-2 text-xs text-muted text-start">
{{ role.description }}
</div>
</div>
</button>
</div>
</div>
</template>
<template #actions>
<ActionMessage :on="addTeamMemberForm.recentlySuccessful" class="me-3">
Added.
</ActionMessage>
<PrimaryButton
:class="{ 'opacity-25': addTeamMemberForm.processing }"
:disabled="addTeamMemberForm.processing">
Add
</PrimaryButton>
</template>
</FormSection>
</div>
<div v-if="team.team_invitations.length > 0 && userPermissions.canAddTeamMembers">
<SectionBorder />
<!-- Organization Member Invitations -->
<ActionSection class="mt-10 sm:mt-0">
<template #title> Pending Organization Invitations</template>
<template #description>
These people have been invited to your organization and have been sent an
invitation email. They may join the organization by accepting the email
invitation.
</template>
<!-- Pending Organization Member Invitation List -->
<template #content>
<div class="space-y-6">
<div
v-for="invitation in team.team_invitations"
:key="invitation.id"
class="flex items-center justify-between">
<div class="text-muted">
{{ invitation.email }}
</div>
<div class="flex items-center">
<!-- Cancel Organization Invitation -->
<button
v-if="userPermissions.canRemoveTeamMembers"
class="cursor-pointer ms-6 text-sm text-red-500 focus:outline-none"
@click="cancelTeamInvitation(invitation)">
Cancel
</button>
</div>
</div>
</div>
</template>
</ActionSection>
</div>
<div v-if="users.length > 0">
<SectionBorder />
<!-- Manage Organization Members -->
<ActionSection class="mt-10 sm:mt-0">
<template #title> Organization Members</template>
<template #description>
All of the people that are part of this organization.
</template>
<!-- Organization Member List -->
<template #content>
<div class="space-y-6">
<div
v-for="user in users"
:key="user.id"
class="flex items-center justify-between">
<div class="flex items-center">
<img
class="w-8 h-8 rounded-full object-cover"
:src="user.profile_photo_url"
:alt="user.name" />
<div class="ms-4 text-text-primary">
{{ user.name }}
</div>
</div>
<div class="flex items-center">
<!-- Manage Organization Member Role -->
<button
v-if="
userPermissions.canUpdateTeamMembers &&
availableRoles.length
"
class="ms-2 text-sm text-gray-400 underline"
@click="manageRole(user)">
{{ displayableRole(user.membership.role) }}
</button>
<div
v-else-if="availableRoles.length"
class="ms-2 text-sm text-gray-400">
{{ displayableRole(user.membership.role) }}
</div>
<!-- Leave Organization -->
<button
v-if="page.props.auth.user.id === user.id"
class="cursor-pointer ms-6 text-sm text-red-500"
@click="confirmLeavingTeam">
Leave
</button>
<!-- Remove Organization Member -->
<button
v-else-if="userPermissions.canRemoveTeamMembers"
class="cursor-pointer ms-6 text-sm text-red-500"
@click="confirmTeamMemberRemoval(user)">
Remove
</button>
</div>
</div>
</div>
</template>
</ActionSection>
</div>
<!-- Role Management Modal -->
<DialogModal :show="currentlyManagingRole" @close="currentlyManagingRole = false">
<template #title> Manage Role</template>
<template #content>
<div v-if="managingRoleFor">
<div
class="relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer">
<button
v-for="(role, i) in availableRoles"
:key="role.key"
type="button"
class="relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
:class="{
'border-t border-card-border focus:border-none rounded-t-none':
i > 0,
'rounded-b-none': i !== Object.keys(availableRoles).length - 1,
}"
@click="updateRoleForm.role = role.key">
<div
:class="{
'opacity-50':
updateRoleForm.role && updateRoleForm.role !== role.key,
}">
<!-- Role Name -->
<div class="flex items-center">
<div
class="text-sm text-muted"
:class="{
'font-semibold': updateRoleForm.role === role.key,
}">
{{ role.name }}
</div>
<svg
v-if="updateRoleForm.role == role.key"
class="ms-2 h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<!-- Role Description -->
<div class="mt-2 text-xs text-muted">
{{ role.description }}
</div>
</div>
</button>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="currentlyManagingRole = false"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': updateRoleForm.processing }"
:disabled="updateRoleForm.processing"
@click="updateRole">
Save
</PrimaryButton>
</template>
</DialogModal>
<!-- Leave Organization Confirmation Modal -->
<ConfirmationModal :show="confirmingLeavingTeam" @close="confirmingLeavingTeam = false">
<template #title> Leave Organization</template>
<template #content> Are you sure you would like to leave this organization? </template>
<template #footer>
<SecondaryButton @click="confirmingLeavingTeam = false"> Cancel </SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': leaveTeamForm.processing }"
:disabled="leaveTeamForm.processing"
@click="leaveTeam">
Leave
</DangerButton>
</template>
</ConfirmationModal>
<!-- Remove Organization Member Confirmation Modal -->
<ConfirmationModal :show="!!teamMemberBeingRemoved" @close="teamMemberBeingRemoved = null">
<template #title> Remove Organization Member</template>
<template #content>
Are you sure you would like to remove this person from the organization?
</template>
<template #footer>
<SecondaryButton @click="teamMemberBeingRemoved = null"> Cancel </SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': removeTeamMemberForm.processing }"
:disabled="removeTeamMemberForm.processing"
@click="removeTeamMember">
Remove
</DangerButton>
</template>
</ConfirmationModal>
</div>
</template>

View File

@@ -51,9 +51,6 @@ const updateTeamName = () => {
<div class="text-text-primary">
{{ team.owner.name }}
</div>
<div class="text-text-secondary text-sm">
{{ team.owner.email }}
</div>
</div>
</div>
</div>

View File

@@ -16,6 +16,7 @@ import { useElementVisibility } from '@vueuse/core';
import { ClockIcon } from '@heroicons/vue/20/solid';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { groupSimilarTimeEntriesSetting } from '@/utils/timeEntryGrouping';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
@@ -151,6 +152,7 @@ function deleteSelected() {
:tasks="tasks"
:currency="getOrganizationCurrencyString()"
:time-entries="timeEntries"
:group-similar-time-entries="groupSimilarTimeEntriesSetting"
:tags="tags"></TimeEntryGroupedTable>
<div v-if="isPending" class="flex justify-center items-center py-12">
<LoadingSpinner></LoadingSpinner>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { storeToRefs } from 'pinia';
import AppLayout from '@/Layouts/AppLayout.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import TimesheetHeader from '@/Components/Timesheet/TimesheetHeader.vue';
import TimesheetGrid from '@/Components/Timesheet/TimesheetGrid.vue';
import TimesheetFooterActions from '@/Components/Timesheet/TimesheetFooterActions.vue';
import RemoveRowDialog from '@/Components/Timesheet/RemoveRowDialog.vue';
import { useTimesheetQuery } from '@/utils/useTimesheetQuery';
import { useTimesheetGrid } from '@/utils/useTimesheetGrid';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { useTagsStore } from '@/utils/useTags';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
import { useTimesheetRowDeletion } from '@/utils/timesheet/useTimesheetRowDeletion';
import { useCopyLastWeek } from '@/utils/timesheet/useCopyLastWeek';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import type { CreateClientBody, CreateProjectBody, Project, Client, Tag } from '@/packages/api/src';
// ── Week state ────────────────────────────────────────────────────
const {
weekStart,
weekEnd,
weekDays,
weekNumber,
isCurrentWeek,
todayDate,
goToPreviousWeek,
goToNextWeek,
goToCurrentWeek,
} = useTimesheetWeek();
// ── Data fetching ─────────────────────────────────────────────────
const { data, isPending } = useTimesheetQuery(weekStart, weekEnd);
const timeEntries = computed(() => data.value?.data ?? []);
const { projects } = useProjectsQuery();
const { tasks } = useTasksQuery();
const { clients } = useClientsQuery();
const { tags } = useTagsQuery();
const { now: currentTimerNow } = storeToRefs(useCurrentTimeEntryStore());
const mutations = useTimeEntriesMutations();
// ── Grid computation ──────────────────────────────────────────────
const { rows, dayTotals, grandTotal, addSlot, removeSlot, updateSlot, clearSlots } =
useTimesheetGrid(timeEntries, weekDays, projects, tasks, currentTimerNow);
// Wipe slots on week navigation so the new week starts fresh — the
// grid's watcher will reseed from the newly fetched entries.
watch(weekStart, () => clearSlots());
// ── Formatters ────────────────────────────────────────────────────
// Pull number/interval format off the org via its query rather than
// inject('organization'), which is undefined during the page's setup
// (AppLayout provides it later in the lifecycle).
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const intervalFormat = computed(() => organization.value?.interval_format ?? 'hours-minutes');
const numberFormat = computed(() => organization.value?.number_format ?? 'point');
function formatDuration(seconds: number): string {
if (seconds === 0) return '-';
return formatHumanReadableDuration(seconds, intervalFormat.value, numberFormat.value);
}
const weekTotalFormatted = computed(() =>
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
);
const weekRangeDisplay = computed(() => {
const start = weekStart.value;
const end = start.add(6, 'day');
return start.month() === end.month()
? `${start.format('MMM D')} - ${end.format('D')}`
: `${start.format('MMM D')} - ${end.format('MMM D')}`;
});
// ── Cell / row mutation handlers ──────────────────────────────────
const { handleCellUpdate, cellStatus, cellPendingSeconds } = useTimesheetCellMutations(
weekDays,
timeEntries,
rows,
removeSlot
);
const { handleRowIdentityChange, handleAddRow } = useTimesheetRowMutations(
mutations,
projects,
rows,
addSlot,
updateSlot,
removeSlot
);
const {
showDeleteDialog,
deleteRowEntryCount,
deleteRowProjectName,
requestRemoveRow,
confirmDeleteRow,
} = useTimesheetRowDeletion(projects, mutations, removeSlot);
function handleRemoveRow(key: string) {
const row = rows.value.find((r) => r.key === key);
if (row) requestRemoveRow(row);
}
// ── Copy last week ────────────────────────────────────────────────
const { isCopyingLastWeek, copyLastWeekRows, copyLastWeekWithTime } = useCopyLastWeek(
weekStart,
weekDays,
rows,
timeEntries,
addSlot
);
// ── Inline creation helpers (passed to TimesheetRow) ──────────────
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
return await useProjectsStore().createProject(project);
}
async function createClient(body: CreateClientBody): Promise<Client | undefined> {
return await useClientsStore().createClient(body);
}
async function createTag(name: string): Promise<Tag | undefined> {
return await useTagsStore().createTag(name);
}
</script>
<template>
<AppLayout title="Timesheet" data-testid="timesheet_view">
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
<TimesheetHeader
:is-current-week="isCurrentWeek"
:week-number="weekNumber"
:week-range-display="weekRangeDisplay"
:week-total-formatted="weekTotalFormatted"
@previous="goToPreviousWeek"
@next="goToNextWeek"
@current="goToCurrentWeek" />
<TimesheetGrid
v-if="!isPending"
:rows="rows"
:week-days="weekDays"
:today-date="todayDate"
:day-totals="dayTotals"
:week-total-formatted="weekTotalFormatted"
:projects="projects"
:tasks="tasks"
:clients="clients"
:tags="tags"
:currency="getOrganizationCurrencyString()"
:can-create-project="canCreateProjects()"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:create-project="createProject"
:create-client="createClient"
:create-tag="createTag"
:format-duration="formatDuration"
:cell-statuses="cellStatus"
:cell-pending-seconds="cellPendingSeconds"
@remove-row="handleRemoveRow"
@cell-update="handleCellUpdate"
@project-task-change="
(row, projectId, taskId) => handleRowIdentityChange(row, { projectId, taskId })
"
@billable-change="(row, billable) => handleRowIdentityChange(row, { billable })"
@tags-change="(row, tags) => handleRowIdentityChange(row, { tags })"
@add-row="handleAddRow" />
<TimesheetFooterActions
v-if="!isPending"
:busy="isCopyingLastWeek"
@copy-rows="copyLastWeekRows"
@copy-with-time="copyLastWeekWithTime" />
<div v-else class="flex justify-center items-center py-12">
<LoadingSpinner />
</div>
</div>
<RemoveRowDialog
v-model:open="showDeleteDialog"
:entry-count="deleteRowEntryCount"
:project-name="deleteRowProjectName"
@confirm="confirmDeleteRow" />
</AppLayout>
</template>

View File

@@ -114,6 +114,8 @@ export type ApiToken = ApiTokenIndexResponse['data'][0];
export type DetailedInvoiceResponse = ZodiosResponseByAlias<SolidTimeApi, 'getInvoice'>;
export type DetailedInvoice = DetailedInvoiceResponse['data'];
export type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0];
export type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>;

View File

@@ -1886,6 +1886,54 @@ const endpoints = makeApi([
},
],
},
{
method: 'post',
path: '/v1/organizations/:organization/invoices/:invoice/copy',
alias: 'copyInvoice',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ reference: z.string() }).passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoice',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: DetailedInvoiceResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/invoices/:invoice',

View File

@@ -1,6 +1,6 @@
{
"name": "@solidtime/ui",
"version": "0.0.17",
"version": "0.0.21",
"description": "Package containing the solidtime ui components",
"main": "./dist/solidtime-ui-lib.umd.cjs",
"module": "./dist/solidtime-ui-lib.js",

View File

@@ -32,7 +32,7 @@ const sizeClasses = {
:disabled="loading"
:class="
twMerge(
'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-text-primary rounded-lg font-semibold inline-flex items-center space-x-1.5 focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus:border-transparent disabled:opacity-25 ease-in-out',
'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-text-primary rounded-lg font-medium inline-flex items-center space-x-1.5 focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus:border-transparent disabled:opacity-25 ease-in-out',
sizeClasses[props.size],
props.class
)

View File

@@ -4,7 +4,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '..';
import type { DayEvent, ActivityBox } from './calendarTypes';
import type { WindowActivityInPeriod } from './activityTypes';
defineProps<{
const props = defineProps<{
dayStr: string;
totalGridHeight: number;
hasActivityStatus: boolean;
@@ -34,6 +34,8 @@ defineProps<{
getActivityBoxActivities: (box: ActivityBox) => WindowActivityInPeriod[];
getActivityPercentage: (count: number, total: number) => string;
getActivityText: (activity: WindowActivityInPeriod) => string;
getTopActivity: (box: ActivityBox) => WindowActivityInPeriod | null;
isDayView: boolean;
// Selection
showSelection: boolean;
@@ -46,6 +48,16 @@ defineProps<{
selectionEndHeight: number;
}>();
function isUncoveredByEvents(abox: ActivityBox): boolean {
return !props.dayEvents.some((de) => {
const eTop = de.top;
const eBottom = de.top + de.height;
const aTop = abox.top;
const aBottom = abox.top + abox.height;
return eTop < aBottom && eBottom > aTop;
});
}
const emit = defineEmits<{
(e: 'event-pointerdown', event: PointerEvent, dayEvent: DayEvent): void;
(e: 'event-keydown-enter', dayEvent: DayEvent): void;
@@ -55,6 +67,7 @@ const emit = defineEmits<{
dayEvent: DayEvent,
edge: 'start' | 'end'
): void;
(e: 'activity-pointerdown', event: PointerEvent): void;
}>();
</script>
@@ -63,16 +76,20 @@ const emit = defineEmits<{
class="fc-timegrid-col relative border-r border-border bg-transparent pointer-events-none"
:class="{
'has-activity-status': hasActivityStatus,
'activity-expanded': hasActivityStatus && isDayView,
}"
:data-date="dayStr"
:style="{ height: totalGridHeight + 'px' }">
<div
class="absolute inset-y-0 left-0.5 right-0.5"
:class="{ 'fc-events-inset': hasActivityStatus }">
:class="{
'fc-events-inset': hasActivityStatus && !isDayView,
'fc-events-inset-expanded': hasActivityStatus && isDayView,
}">
<div
v-for="dayEvent in dayEvents"
:key="dayEvent.event.id"
class="fc-event group pointer-events-auto rounded-sm text-xs cursor-pointer shadow-card overflow-hidden border border-border touch-none select-none hover:shadow-dropdown focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
class="fc-event group pointer-events-auto rounded-sm text-xs cursor-pointer shadow-card border border-border touch-none select-none hover:shadow-dropdown focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
:class="[
getEventOpacityClass(dayEvent, dayStr),
{
@@ -91,9 +108,7 @@ const emit = defineEmits<{
:aria-label="dayEvent.event.title"
role="button"
@pointerdown="emit('event-pointerdown', $event, dayEvent)"
@keydown.enter.prevent="
!dayEvent.event.isRunning && emit('event-keydown-enter', dayEvent)
">
@keydown.enter.prevent="emit('event-keydown-enter', dayEvent)">
<div
v-if="!dayEvent.isClippedStart"
class="fc-event-resizer fc-event-resizer-start absolute z-[99] w-full h-3 left-0 top-[-2px] cursor-row-resize flex items-center justify-center opacity-0 group-hover:opacity-100"
@@ -122,15 +137,47 @@ const emit = defineEmits<{
class="fc-timegrid-now-indicator-line absolute left-0 right-0 border-t-2 border-red-500 z-50 pointer-events-none"
:style="{ top: nowIndicatorTop + 'px' }"></div>
<TooltipProvider :delay-duration="0">
<TooltipProvider :disable-hoverable-content="true" :delay-duration="0">
<Tooltip v-for="(abox, ai) in activityBoxes" :key="'activity-' + ai">
<TooltipTrigger as-child>
<div
class="activity-status-box"
:class="abox.isIdle ? 'idle' : 'active'"
:style="{ top: abox.top + 'px', height: abox.height + 'px' }"></div>
:class="[
abox.isIdle ? 'idle' : 'active',
{
'activity-status-box-expanded': isDayView,
'activity-status-box-uncovered':
!isDayView &&
!abox.isIdle &&
getTopActivity(abox) &&
isUncoveredByEvents(abox),
},
]"
:style="{ top: abox.top + 'px', height: abox.height + 'px' }"
@pointerdown="emit('activity-pointerdown', $event)">
<div
v-if="
!abox.isIdle &&
getTopActivity(abox) &&
abox.height >= 16 &&
(isDayView || isUncoveredByEvents(abox))
"
class="activity-status-content">
<img
v-if="getTopActivity(abox)?.icon"
:src="getTopActivity(abox)!.icon!"
:alt="getTopActivity(abox)!.appName"
class="activity-status-icon" />
<div v-else class="activity-status-icon-fallback">
{{ getTopActivity(abox)!.appName.charAt(0).toUpperCase() }}
</div>
<span class="activity-status-label">
{{ getTopActivity(abox)!.label || getTopActivity(abox)!.appName }}
</span>
</div>
</div>
</TooltipTrigger>
<TooltipContent side="right" :side-offset="8">
<TooltipContent :side="isDayView ? 'right' : 'left'" :side-offset="8">
<template v-if="getActivityBoxActivities(abox).length === 0">
{{ getActivityBoxLabel(abox) }}
</template>
@@ -271,13 +318,99 @@ const emit = defineEmits<{
background-color: rgba(156, 163, 175, 0.5);
}
.activity-status-box.active::before {
background-color: rgba(34, 197, 94, 0.3);
background-color: rgba(14, 165, 233, 0.3);
}
.activity-status-box.active:hover::before {
background-color: rgba(34, 197, 94, 1);
background-color: rgba(14, 165, 233, 1);
}
/* Uncovered activity boxes in week view — fill column width */
.activity-status-box-uncovered {
width: calc(100% - 4px);
border-radius: 3px;
overflow: hidden;
}
.activity-status-box-uncovered::before {
left: 0;
right: 0;
width: auto;
}
.activity-status-box-uncovered.active::before {
background-color: rgba(14, 165, 233, 0.12);
}
.activity-status-box-uncovered.active:hover::before {
background-color: rgba(14, 165, 233, 0.25);
}
/* Expanded activity boxes for day view */
.activity-status-box-expanded {
width: 200px;
border-radius: 3px;
overflow: hidden;
}
.activity-status-box-expanded::before {
left: 0;
right: 0;
width: auto;
}
.activity-status-box-expanded.idle::before {
background-color: rgba(156, 163, 175, 0.08);
}
.activity-status-box-expanded.idle:hover::before {
background-color: rgba(156, 163, 175, 0.2);
}
.activity-status-box-expanded.active::before {
background-color: rgba(14, 165, 233, 0.12);
}
.activity-status-box-expanded.active:hover::before {
background-color: rgba(14, 165, 233, 0.25);
}
.activity-status-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 4px;
padding: 2px 4px;
height: 100%;
overflow: hidden;
}
.activity-status-icon {
width: 14px;
height: 14px;
border-radius: 2px;
flex-shrink: 0;
}
.activity-status-icon-fallback {
width: 14px;
height: 14px;
border-radius: 2px;
background-color: rgba(14, 165, 233, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
flex-shrink: 0;
color: rgba(14, 165, 233, 0.8);
}
.activity-status-label {
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.8;
}
.fc-events-inset {
left: 8px;
}
.fc-events-inset-expanded {
left: 204px;
}
</style>

View File

@@ -22,7 +22,7 @@ const emit = defineEmits<{
</script>
<template>
<div class="flex items-center justify-between bg-background px-2 py-1.5">
<div class="flex items-center justify-between bg-default-background px-2 py-1.5">
<!-- Left: Navigation -->
<div class="flex items-center gap-1">
<Button

View File

@@ -57,7 +57,7 @@ import type {
import type { Dayjs } from 'dayjs';
const emit = defineEmits<{
(e: 'dates-change', payload: { start: Date; end: Date }): void;
(e: 'dates-change', payload: { start: Dayjs; end: Dayjs }): void;
(e: 'refresh'): void;
}>();
@@ -163,6 +163,7 @@ const {
getActivityBoxActivities,
getActivityPercentage,
getActivityText,
getTopActivity,
} = useActivityBoxes({
activityPeriods: () => props.activityPeriods,
viewDays,
@@ -280,6 +281,22 @@ watch(showEditTimeEntryModal, (value) => {
}
});
/**
* Guards slot pointer-down so that clicks which dismiss an open Reka UI
* layer (context menu, popover, dialog) don't simultaneously start a
* new time-entry selection on the calendar grid.
*
* Because Reka's DismissableLayer registers its document-level
* `pointerdown` listener *without* capture, it fires AFTER the
* calendar grid's own handler. That means when this guard runs,
* `contextMenuOpen` (and modal refs) still reflect the *open* state.
*/
function guardedSlotPointerDown(e: PointerEvent) {
if (contextMenuOpen.value) return;
if (showCreateTimeEntryModal.value || showEditTimeEntryModal.value) return;
onSlotPointerDown(e);
}
const scrollToCurrentTime = () => {
nextTick(() => {
if (!scrollerRef.value) return;
@@ -314,6 +331,18 @@ watch(
{ deep: true }
);
let hasScrolledOnLoad = false;
watch(
() => props.loading,
(loading) => {
if (!loading && !hasScrolledOnLoad) {
hasScrolledOnLoad = true;
scrollToCurrentTime();
}
}
);
onMounted(() => {
scrollToCurrentTime();
emitDatesChange();
@@ -465,7 +494,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<div
class="fc-header-scroll flex border-b border-border shrink-0 sticky top-0 z-10 bg-default-background">
<div
class="shrink-0 bg-background border-r border-border"
class="shrink-0 bg-default-background border-r border-border"
:style="{
width: TIME_AXIS_WIDTH + 'px',
minWidth: TIME_AXIS_WIDTH + 'px',
@@ -478,7 +507,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<div
v-for="day in viewDays"
:key="day.format('YYYY-MM-DD')"
class="fc-col-header-cell border-r border-b border-border px-2 py-3 bg-default-background text-center"
class="fc-col-header-cell border-r border-border px-2 py-3 bg-default-background text-center"
:class="{
'bg-secondary': isToday(day),
'fc-day-today': isToday(day),
@@ -497,7 +526,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<div ref="scrollerRef" class="fc-scroller">
<div class="flex min-w-0">
<div
class="shrink-0 bg-background border-r border-border"
class="shrink-0 bg-default-background border-r border-border"
:style="{
width: TIME_AXIS_WIDTH + 'px',
minWidth: TIME_AXIS_WIDTH + 'px',
@@ -514,7 +543,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
:style="{ height: SLOT_HEIGHT + 'px' }">
<span
v-if="slot.isHour"
class="fc-timegrid-slot-label-cushion text-[0.8125rem] text-muted-foreground leading-none block">
class="fc-timegrid-slot-label-cushion text-[0.8125rem] text-muted-foreground leading-none block font-light">
{{ formatSlotLabel(slot.minutes / 60) }}
</span>
</div>
@@ -522,14 +551,32 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<div
class="flex-1 min-w-0 relative"
@pointerdown="onSlotPointerDown($event)">
@pointerdown="guardedSlotPointerDown($event)">
<div
class="bg-background"
class="bg-default-background relative"
:style="{ height: totalGridHeight + 'px' }">
<div
class="absolute inset-0 grid"
:style="{
gridTemplateColumns:
'repeat(' + viewDays.length + ', 1fr)',
}">
<div
v-for="day in viewDays"
:key="'bg-' + day.format('YYYY-MM-DD')"
:style="
isToday(day)
? {
backgroundColor:
'var(--theme-color-default-background)',
}
: undefined
" />
</div>
<div
v-for="slot in slots"
:key="'lane-' + slot.time"
class="fc-timegrid-slot fc-timegrid-slot-lane border-t border-border box-border"
class="fc-timegrid-slot fc-timegrid-slot-lane border-t border-border box-border relative"
:class="{
'fc-timegrid-slot-minor border-dotted':
!slot.isHour,
@@ -581,6 +628,8 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
:get-activity-box-activities="getActivityBoxActivities"
:get-activity-percentage="getActivityPercentage"
:get-activity-text="getActivityText"
:get-top-activity="getTopActivity"
:is-day-view="activeView === 'timeGridDay'"
:show-selection="
isSelecting || showCreateTimeEntryModal
"
@@ -599,6 +648,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
:selection-height="selectionHeight"
:selection-end-top="selectionEndTop"
:selection-end-height="selectionEndHeight"
@activity-pointerdown="guardedSlotPointerDown"
@event-pointerdown="
(e, dayEvent) =>
onEventPointerDown(e, dayEvent.event, dayEvent)

View File

@@ -1,6 +1,6 @@
export interface WindowActivityInPeriod {
appName: string;
url: string | null;
label: string | null;
count: number;
icon?: string | null;
}

View File

@@ -12,19 +12,13 @@ export function useActivityBoxes(params: {
calendarSettings: Ref<CalendarSettings>;
minutesToPixels: (minutes: number) => number;
}) {
function formatActivityDuration(durationMinutes: number): string {
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
function getActivityBoxLabel(box: ActivityBox): string {
const periodStart = getLocalizedDayJs(box.period.start);
const periodEnd = getLocalizedDayJs(box.period.end);
const durationMinutes = Math.round(periodEnd.diff(periodStart, 'minute', true));
const durationText = formatActivityDuration(durationMinutes);
const startText = periodStart.format('HH:mm');
const endText = periodEnd.format('HH:mm');
const status = box.isIdle ? 'Idling' : 'Active';
return `${status} (${durationText})`;
return `${status} (${startText} - ${endText})`;
}
function getActivityBoxActivities(box: ActivityBox) {
@@ -37,7 +31,16 @@ export function useActivityBoxes(params: {
}
function getActivityText(activity: WindowActivityInPeriod): string {
return activity.url ? `${activity.appName} - ${activity.url}` : activity.appName;
return activity.label ? `${activity.appName} - ${activity.label}` : activity.appName;
}
function getTopActivity(box: ActivityBox): WindowActivityInPeriod | null {
const activities = box.period.windowActivities;
if (!activities || activities.length === 0) return null;
return activities.reduce<WindowActivityInPeriod>(
(top, a) => (a.count > top.count ? a : top),
activities[0]!
);
}
const activityBoxes = computed<ActivityBox[]>(() => {
@@ -99,5 +102,6 @@ export function useActivityBoxes(params: {
getActivityBoxActivities,
getActivityPercentage,
getActivityText,
getTopActivity,
};
}

View File

@@ -265,9 +265,9 @@ export function useCalendarEvents(params: {
'seconds'
);
} else {
durationSeconds = params.currentTime.value.diff(
getDayJsInstance()(entry.start),
'seconds'
durationSeconds = Math.max(
0,
params.currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds')
);
}

View File

@@ -1,27 +1,17 @@
import { computed, ref } from 'vue';
import type { Dayjs } from 'dayjs';
import { getLocalizedDayJs } from '../utils/time';
import { getWeekStart } from '../utils/settings';
import { getWeekStartDayNumber } from '../utils/settings';
export function useCalendarNavigation(callbacks: {
onDatesChange: (payload: { start: Date; end: Date }) => void;
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
scrollToCurrentTime: () => void;
}) {
const activeView = ref('timeGridWeek');
const currentDate = ref(getLocalizedDayJs());
function getFirstDay(): number {
const weekStart = getWeekStart();
const weekStartMap: Record<string, number> = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6,
};
return weekStartMap[weekStart] ?? 1;
return getWeekStartDayNumber();
}
const viewDays = computed<Dayjs[]>(() => {
@@ -67,8 +57,8 @@ export function useCalendarNavigation(callbacks: {
const days = viewDays.value;
if (days.length === 0) return;
const start = days[0]!.toDate();
const end = days[days.length - 1]!.add(1, 'day').toDate();
const start = days[0]!;
const end = days[days.length - 1]!.add(1, 'day');
callbacks.onDatesChange({ start, end });
}

View File

@@ -1,8 +1,8 @@
import { ref, type Ref, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs';
import type { TimeEntry } from '@/packages/api/src';
import { getDayJsInstance } from '../utils/time';
import { getUserTimezone } from '../utils/settings';
import { getDayJsInstance, getLocalizedDayJsFromMinutes } from '../utils/time';
import type { CalendarSettings } from './calendarSettings';
import type { CalendarEvent } from './calendarTypes';
@@ -34,11 +34,8 @@ export function useContextMenu(params: {
const snap = params.calendarSettings.value.snapMinutes;
const snappedMinutes = Math.floor(minutesFromGridStart / snap) * snap;
const dayjs = getDayJsInstance();
const startLocal = dayjs(`${date}T00:00:00`)
.tz(getUserTimezone(), true)
.add(snappedMinutes, 'minute');
const snappedEnd = startLocal.add(snap, 'minute');
const startLocal = getLocalizedDayJsFromMinutes(date, snappedMinutes);
const snappedEnd = getLocalizedDayJsFromMinutes(date, snappedMinutes + snap);
return { start: startLocal.utc(), end: snappedEnd.utc() };
}

View File

@@ -1,8 +1,7 @@
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs';
import type { TimeEntry } from '@/packages/api/src';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
import { getUserTimezone } from '../utils/settings';
import { getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
import type { CalendarSettings } from './calendarSettings';
import type { CalendarEvent, DayEvent } from './calendarTypes';
import { SLOT_HEIGHT, DRAG_THRESHOLD } from './calendarTypes';
@@ -67,8 +66,7 @@ export function useEventDrag(params: {
}
if (dayEvent.isClippedStart && originDay && ev.timeEntry.end) {
const dayjs = getDayJsInstance();
const dayMidnight = dayjs(`${originDay}T00:00:00`).tz(getUserTimezone(), true);
const dayMidnight = getLocalizedDayJsFromMinutes(originDay, 0);
const evStart = getLocalizedDayJs(ev.timeEntry.start);
const eventStartFromGridStart = evStart.diff(dayMidnight, 'minute') - s.startHour * 60;
const segmentTopMinutes = (dayEvent.top / SLOT_HEIGHT) * s.slotMinutes;
@@ -154,13 +152,11 @@ export function useEventDrag(params: {
const lowerBound = startMin - 4 * 60;
const clampedMinutes = Math.max(lowerBound, Math.min(snappedMinutes, s.endHour * 60));
const dayjs = getDayJsInstance();
const originalSegmentStart = dayjs(`${savedOriginalDayStr}T00:00:00`)
.tz(getUserTimezone(), true)
.add(startMin + params.pixelsToMinutesFromMidnight(dragStartEventTop), 'minute');
const newSegmentStart = dayjs(`${targetDateStr}T00:00:00`)
.tz(getUserTimezone(), true)
.add(clampedMinutes, 'minute');
const originalSegmentStart = getLocalizedDayJsFromMinutes(
savedOriginalDayStr,
startMin + params.pixelsToMinutesFromMidnight(dragStartEventTop)
);
const newSegmentStart = getLocalizedDayJsFromMinutes(targetDateStr, clampedMinutes);
const deltaMs = newSegmentStart.diff(originalSegmentStart);
const origStart = getLocalizedDayJs(timeEntry.start);
@@ -240,11 +236,14 @@ export function useEventDrag(params: {
}
// Multi-day: compute actual start/end datetimes, then clip per day
const dayjs = getDayJsInstance();
const eventStartAbsolute = dayjs(`${dragCurrentDay.value}T00:00:00`)
.tz(getUserTimezone(), true)
.add(startMin + eventStartOnGrid, 'minute');
const eventEndAbsolute = eventStartAbsolute.add(dragFullDurationMinutes, 'minute');
const eventStartAbsolute = getLocalizedDayJsFromMinutes(
dragCurrentDay.value,
startMin + eventStartOnGrid
);
const eventEndAbsolute = getLocalizedDayJsFromMinutes(
dragCurrentDay.value,
startMin + eventStartOnGrid + dragFullDurationMinutes
);
const result: Record<string, Record<string, string>> = {};

View File

@@ -1,8 +1,7 @@
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs';
import type { TimeEntry } from '@/packages/api/src';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
import { getUserTimezone } from '../utils/settings';
import { getDayJsInstance, getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
import type { CalendarSettings } from './calendarSettings';
import type { CalendarEvent, DayEvent } from './calendarTypes';
import { SLOT_HEIGHT } from './calendarTypes';
@@ -11,10 +10,6 @@ function snapTo(value: number, step: number): number {
return Math.round(value / step) * step;
}
function dayMidnightLocal(dayStr: string): Dayjs {
return getDayJsInstance()(`${dayStr}T00:00:00`).tz(getUserTimezone(), true);
}
export function useEventResize(params: {
calendarSettings: Ref<CalendarSettings>;
viewDays: ComputedRef<Dayjs[]>;
@@ -89,7 +84,7 @@ export function useEventResize(params: {
),
s.snapMinutes
);
return { start, end: dayMidnightLocal(endDay).add(endMinutes, 'minute') };
return { start, end: getLocalizedDayJsFromMinutes(endDay, endMinutes) };
} else {
const end = resizeOriginalEvent.isRunning
? getLocalizedDayJs()
@@ -105,7 +100,7 @@ export function useEventResize(params: {
params.pixelsToMinutesFromMidnight(resizeCurrentTop.value),
s.snapMinutes
);
return { start: dayMidnightLocal(startDay).add(startMinutes, 'minute'), end };
return { start: getLocalizedDayJsFromMinutes(startDay, startMinutes), end };
}
}

View File

@@ -1,7 +1,7 @@
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs';
import { getDayJsInstance } from '../utils/time';
import { getUserTimezone } from '../utils/settings';
import { getLocalizedDayJsFromMinutes } from '../utils/time';
import type { CalendarSettings } from './calendarSettings';
import { SLOT_HEIGHT } from './calendarTypes';
@@ -29,7 +29,7 @@ export function useSlotSelection(params: {
function onSlotPointerDown(e: PointerEvent) {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('.fc-event') || target.closest('.activity-status-box')) return;
if (target.closest('.fc-event')) return;
const dateStr = params.getDayFromClientX(e.clientX);
if (!dateStr) return;
@@ -102,8 +102,6 @@ export function useSlotSelection(params: {
const s = params.calendarSettings.value;
const snap = s.snapMinutes;
const dayjs = getDayJsInstance();
const startMinutes = params.pixelsToMinutesFromMidnight(selectionTop.value);
const snappedStartMin = Math.floor(startMinutes / snap) * snap;
@@ -138,12 +136,8 @@ export function useSlotSelection(params: {
if (endMin <= 0) endMin = snap;
}
startLocal = dayjs(`${startDateStr}T00:00:00`)
.tz(getUserTimezone(), true)
.add(startMin, 'minute');
endLocal = dayjs(`${endDateStr}T00:00:00`)
.tz(getUserTimezone(), true)
.add(endMin, 'minute');
startLocal = getLocalizedDayJsFromMinutes(startDateStr, startMin);
endLocal = getLocalizedDayJsFromMinutes(endDateStr, endMin);
} else {
const startDateStr = selectionStartDay;
const endMinutes = params.pixelsToMinutesFromMidnight(
@@ -153,12 +147,8 @@ export function useSlotSelection(params: {
if (snappedEndMin <= snappedStartMin) {
snappedEndMin = snappedStartMin + snap;
}
startLocal = dayjs(`${startDateStr}T00:00:00`)
.tz(getUserTimezone(), true)
.add(snappedStartMin, 'minute');
endLocal = dayjs(`${startDateStr}T00:00:00`)
.tz(getUserTimezone(), true)
.add(snappedEndMin, 'minute');
startLocal = getLocalizedDayJsFromMinutes(startDateStr, snappedStartMin);
endLocal = getLocalizedDayJsFromMinutes(startDateStr, snappedEndMin);
}
params.onSelectionComplete(startLocal.utc(), endLocal.utc());

View File

@@ -6,10 +6,15 @@ const props = withDefaults(
defineProps<{
expanded?: boolean;
size?: string;
/**
* Test ID used for Playwright/E2E tests.
*/
testId?: string;
}>(),
{
expanded: false,
size: 'w-7 h-7',
testId: 'grouped_items_count_button',
}
);
@@ -23,6 +28,7 @@ const expandedStatusClasses = computed(() => {
<template>
<button
:data-testid="props.testId"
:class="
twMerge(
'font-medium text-base rounded flex items-center transition justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent',

View File

@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed, inject, ref, type ComputedRef } from 'vue';
import { formatHumanReadableDuration, parseTimeInput } from '@/packages/ui/src/utils/time';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import type { Organization } from '@/packages/api/src';
const organization = inject<ComputedRef<Organization>>('organization');
const organizationSettings = computed(() => ({
intervalFormat: organization?.value?.interval_format ?? 'hours-minutes',
numberFormat: organization?.value?.number_format ?? 'point',
}));
const props = withDefaults(
defineProps<{
modelValue?: number | null;
placeholder?: string;
disabled?: boolean;
inputClass?: string;
size?: 'sm' | 'base';
defaultUnit?: 'auto' | 'hours' | 'minutes';
}>(),
{
modelValue: null,
placeholder: '-',
disabled: false,
inputClass: '',
size: 'base',
defaultUnit: 'auto',
}
);
const emit = defineEmits<{
'update:modelValue': [value: number | null];
commit: [value: number | null];
submit: [];
}>();
const temporaryValue = ref('');
const isEditing = ref(false);
const hasPendingEdit = ref(false);
const skipNextCommit = ref(false);
function formatModelValue(value: number | null | undefined): string {
if (!value || value === 0) {
return '';
}
return formatHumanReadableDuration(
value,
organizationSettings.value.intervalFormat,
organizationSettings.value.numberFormat
);
}
const displayValue = computed({
get() {
if (isEditing.value) {
return temporaryValue.value;
}
return formatModelValue(props.modelValue);
},
set(newValue: string) {
temporaryValue.value = newValue;
hasPendingEdit.value = true;
},
});
function selectInput(event: Event) {
isEditing.value = true;
hasPendingEdit.value = false;
skipNextCommit.value = false;
temporaryValue.value = formatModelValue(props.modelValue);
const target = event.target as HTMLInputElement;
target.select();
}
function resetEditingState() {
temporaryValue.value = '';
isEditing.value = false;
hasPendingEdit.value = false;
}
function commitValue() {
if (skipNextCommit.value) {
skipNextCommit.value = false;
return;
}
const input = temporaryValue.value.trim();
const shouldCommit = hasPendingEdit.value;
resetEditingState();
if (!shouldCommit) {
return;
}
// Blank or literal "0" → null. Consumers decide what null means
// (clear estimate, delete cell, etc.) by reading their own emit.
if (input === '' || input === '0') {
emit('update:modelValue', null);
emit('commit', null);
return;
}
const defaultUnit =
props.defaultUnit === 'auto'
? organizationSettings.value.intervalFormat === 'decimal'
? 'hours'
: 'minutes'
: props.defaultUnit;
const seconds = parseTimeInput(input, organizationSettings.value.numberFormat, defaultUnit);
if (seconds !== null && seconds >= 0) {
emit('update:modelValue', seconds);
emit('commit', seconds);
}
}
function cancelEdit(event: Event) {
skipNextCommit.value = true;
resetEditingState();
(event.target as HTMLInputElement).blur();
}
function commitAndSubmit() {
commitValue();
emit('submit');
}
</script>
<template>
<TextInput
v-model="displayValue"
data-testid="duration_seconds_input"
name="Duration"
:size="size"
:disabled="disabled"
:placeholder="isEditing ? '0' : placeholder"
:class="inputClass"
@focus="selectInput"
@blur="commitValue"
@keydown.enter.prevent="commitAndSubmit"
@keydown.escape="cancelEdit" />
</template>

View File

@@ -1,12 +1,6 @@
<script setup lang="ts">
import { onMounted, ref, watch, inject } from 'vue';
import { formatHumanReadableDuration, parseTimeInput } from '@/packages/ui/src/utils/time';
import DurationSecondsInput from '@/packages/ui/src/Input/DurationSecondsInput.vue';
import { twMerge } from 'tailwind-merge';
import { TextInput } from '@/packages/ui/src';
import type { Organization } from '@/packages/api/src';
import { type ComputedRef } from 'vue';
const temporaryInput = ref<string>('');
const model = defineModel<number | null>({
default: null,
@@ -16,64 +10,16 @@ const emit = defineEmits<{
submit: [];
}>();
const organization = inject<ComputedRef<Organization>>('organization');
function updateDuration() {
const input = temporaryInput.value.trim();
if (input === '') {
model.value = null;
return;
}
const seconds = parseTimeInput(input, organization?.value?.number_format, 'hours');
if (seconds !== null && seconds > 0) {
model.value = seconds;
}
updateInputDisplay();
}
const props = defineProps<{
class?: string;
}>();
watch(model, updateInputDisplay);
onMounted(() => updateInputDisplay());
function updateInputDisplay() {
if (model.value !== null && model.value > 0) {
temporaryInput.value = formatHumanReadableDuration(
model.value,
organization?.value?.interval_format,
organization?.value?.number_format
);
} else {
temporaryInput.value = '';
}
}
function selectInput(event: Event) {
const target = event.target as HTMLInputElement;
target.select();
}
function updateAndSubmit() {
updateDuration();
emit('submit');
}
</script>
<template>
<TextInput
ref="inputField"
v-model="temporaryInput"
:class="twMerge('text-text-secondary', props.class)"
type="text"
<DurationSecondsInput
v-model="model"
:input-class="twMerge('placeholder:text-text-tertiary', props.class)"
placeholder="e.g. 2h 30m or 1.5"
@focus="selectInput"
@blur="updateDuration"
@keydown.enter="updateAndSubmit" />
default-unit="hours"
@submit="emit('submit')" />
</template>
<style scoped></style>

View File

@@ -35,7 +35,8 @@ watch(open, (isOpen) => {
sortedItems.value = [...props.items].sort((a, b) => {
const aSelected = model.value.includes(props.getKeyFromItem(a)) ? 0 : 1;
const bSelected = model.value.includes(props.getKeyFromItem(b)) ? 0 : 1;
return aSelected - bSelected;
if (aSelected !== bSelected) return aSelected - bSelected;
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
});
}
});

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