Compare commits

..

67 Commits

Author SHA1 Message Date
dependabot[bot]
b1d674bf71 Bump the major-updates group across 1 directory with 13 updates
Bumps the major-updates group with 13 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@floating-ui/vue](https://github.com/floating-ui/floating-ui/tree/HEAD/packages/vue) | `1.1.11` | `2.0.0` |
| [@tanstack/vue-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/vue-query-devtools) | `5.91.0` | `6.1.34` |
| [tailwind-merge](https://github.com/dcastil/tailwind-merge) | `2.6.1` | `3.6.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` |
| [@inertiajs/vue3](https://github.com/inertiajs/inertia/tree/HEAD/packages/vue3) | `2.3.23` | `3.4.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.19` | `26.0.0` |
| [laravel-vite-plugin](https://github.com/laravel/vite-plugin) | `2.1.0` | `3.1.0` |
| [postcss-import](https://github.com/postcss/postcss-import) | `15.1.0` | `16.1.1` |
| [postcss-nesting](https://github.com/csstools/postcss-plugins/tree/HEAD/plugins/postcss-nesting) | `12.1.5` | `14.0.0` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `3.4.19` | `4.3.1` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` | `6.0.3` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.3.3` | `8.0.16` |
| [vite-plugin-dts](https://github.com/qmhc/unplugin-dts/tree/HEAD/packages/vite-plugin-dts) | `4.5.4` | `5.0.2` |



Updates `@floating-ui/vue` from 1.1.11 to 2.0.0
- [Release notes](https://github.com/floating-ui/floating-ui/releases)
- [Changelog](https://github.com/floating-ui/floating-ui/blob/master/packages/vue/CHANGELOG.md)
- [Commits](https://github.com/floating-ui/floating-ui/commits/@floating-ui/vue@2.0.0/packages/vue)

Updates `@tanstack/vue-query-devtools` from 5.91.0 to 6.1.34
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/vue-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/vue-query-devtools@6.1.34/packages/vue-query-devtools)

Updates `tailwind-merge` from 2.6.1 to 3.6.0
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/compare/v2.6.1...v3.6.0)

Updates `@eslint/js` from 9.39.4 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@inertiajs/vue3` from 2.3.23 to 3.4.0
- [Release notes](https://github.com/inertiajs/inertia/releases)
- [Changelog](https://github.com/inertiajs/inertia/blob/3.x/CHANGELOG.md)
- [Commits](https://github.com/inertiajs/inertia/commits/v3.4.0/packages/vue3)

Updates `@types/node` from 22.19.19 to 26.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `laravel-vite-plugin` from 2.1.0 to 3.1.0
- [Release notes](https://github.com/laravel/vite-plugin/releases)
- [Changelog](https://github.com/laravel/vite-plugin/blob/3.x/CHANGELOG.md)
- [Upgrade guide](https://github.com/laravel/vite-plugin/blob/3.x/UPGRADE.md)
- [Commits](https://github.com/laravel/vite-plugin/compare/v2.1.0...v3.1.0)

Updates `postcss-import` from 15.1.0 to 16.1.1
- [Release notes](https://github.com/postcss/postcss-import/releases)
- [Changelog](https://github.com/postcss/postcss-import/blob/master/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-import/compare/15.1.0...16.1.1)

Updates `postcss-nesting` from 12.1.5 to 14.0.0
- [Changelog](https://github.com/csstools/postcss-plugins/blob/main/plugins/postcss-nesting/CHANGELOG.md)
- [Commits](https://github.com/csstools/postcss-plugins/commits/HEAD/plugins/postcss-nesting)

Updates `tailwindcss` from 3.4.19 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss)

Updates `typescript` from 5.9.3 to 6.0.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.3)

Updates `vite` from 7.3.3 to 8.0.16
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

Updates `vite-plugin-dts` from 4.5.4 to 5.0.2
- [Release notes](https://github.com/qmhc/unplugin-dts/releases)
- [Changelog](https://github.com/qmhc/unplugin-dts/blob/main/packages/vite-plugin-dts/CHANGELOG.md)
- [Commits](https://github.com/qmhc/unplugin-dts/commits/vite-plugin-dts@5.0.2/packages/vite-plugin-dts)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@floating-ui/vue"
  dependency-version: 2.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@inertiajs/vue3"
  dependency-version: 3.4.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@tanstack/vue-query-devtools"
  dependency-version: 6.1.34
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 25.9.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: laravel-vite-plugin
  dependency-version: 3.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: postcss-import
  dependency-version: 16.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: postcss-nesting
  dependency-version: 14.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: tailwind-merge
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: vite-plugin-dts
  dependency-version: 5.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-29 08:24:27 +00:00
Gregor Vostrak
981bcbe091 add tests for tag match type (backend + e2e) 2026-06-26 14:53:37 +02:00
Gregor Vostrak
ccf3cec8c0 migrate tag match type buttons to reka-ui radio group for accessibility 2026-06-26 14:53:37 +02:00
Gregor Vostrak
23ea1500b0 Use an enum for tag match type 2026-06-26 14:53:37 +02:00
Gregor Vostrak
4bbde04e28 Rename tag_filter parameter to tag_match_type 2026-06-26 14:53:37 +02:00
Gregor Vostrak
caa36d2875 Fix not-contains tag filter dropping entries where tags is null 2026-06-26 14:53:37 +02:00
Beda Schmid
58d7b33366 Add filter to include/exclude tags
[Added]
- Introduced a tag filter feature allowing users to specify whether tags should be included or excluded in time entry filters. This supports 'contains' and 'not_contains' modes.
2026-06-26 14:53:37 +02:00
Gregor Vostrak
50f57f6997 add support for activity columns in clockify exports 2026-06-25 12:10:44 +02:00
Gregor Vostrak
ab9f6e6afb adapt clockify importer to support task -> tasks column rename 2026-06-25 00:20:29 +02:00
Gregor Vostrak
2a40bb7edb add support for archived projects in the clockify importer 2026-06-25 00:08:42 +02:00
Gregor Vostrak
365f672cfc clarify import order for clockify and harvest 2026-06-25 00:02:41 +02:00
Gregor Vostrak
25f30fa820 fix destructive color contrast 2026-06-24 17:44:56 +02:00
Constantin Graf
37a8f43b23 Allow Clockify import without Billable column 2026-06-24 13:44:48 +02:00
Constantin Graf
c94aa8038d Add base path to the vite config to be able to tunnel all vite assets through a CDN 2026-06-20 21:55:29 +02:00
Constantin Graf
c9e84260b1 Updated GitHub actions 2026-06-19 16:53:40 +02:00
dependabot[bot]
9a53f903d8 Bump WyriHaximus/github-action-get-previous-tag from 1 to 2
Bumps [WyriHaximus/github-action-get-previous-tag](https://github.com/wyrihaximus/github-action-get-previous-tag) from 1 to 2.
- [Release notes](https://github.com/wyrihaximus/github-action-get-previous-tag/releases)
- [Commits](https://github.com/wyrihaximus/github-action-get-previous-tag/compare/v1...v2)

---
updated-dependencies:
- dependency-name: WyriHaximus/github-action-get-previous-tag
  dependency-version: '2'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 13:13:24 +02:00
dependabot[bot]
d50ce52b5b Bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 13:10:17 +02:00
dependabot[bot]
aea310e639 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-06-19 13:10:01 +02:00
dependabot[bot]
a94627321b Bump docker/setup-buildx-action from 3 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 13:09:51 +02:00
dependabot[bot]
806d138fc9 Bump docker/build-push-action from 6 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 13:09:37 +02:00
Constantin Graf
240dd809f4 Fixed organiztion page for employees 2026-06-18 17:30:54 +02:00
dependabot[bot]
dedb50ea73 Bump docker/setup-qemu-action from 3 to 4
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 13:32:01 +02:00
dependabot[bot]
6fe856fd31 Bump docker/metadata-action from 5 to 6
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 13:31:39 +02:00
dependabot[bot]
20735c2fed Bump codecov/codecov-action from 5.5.1 to 7.0.0
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.5.1 to 7.0.0.
- [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.5.1...v7.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 13:31:20 +02:00
dependabot[bot]
b14e40c64b Bump actions/download-artifact from 4 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 13:30:41 +02:00
dependabot[bot]
f2277ff708 Bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 13:30:11 +02:00
Constantin Graf
6a197f7f34 Add password check to users.destroy and organizations.destroy 2026-06-18 13:27:43 +02:00
Constantin Graf
24c94af952 Add option to enable email verification locally for testing 2026-06-18 13:27:43 +02:00
Constantin Graf
111ce94150 Add comments to events about legacy events 2026-06-18 13:27:43 +02:00
Constantin Graf
49e69b85a6 Fixed middlwares after jetstream removal 2026-06-18 13:27:43 +02:00
Gregor Vostrak
90a5c2b87c add comprehensive 2 factor authentication e2e tests 2026-06-18 13:27:43 +02:00
Gregor Vostrak
6b01034bdd add back destroy other browser sessions endpoint (jetstream migration) 2026-06-18 13:27:43 +02:00
Gregor Vostrak
a4c400ef4e update e2e test setup to use user settings api endpoint 2026-06-18 13:27:43 +02:00
Gregor Vostrak
5d01b20dbf change redirects and references to new organization routes 2026-06-18 13:27:43 +02:00
Gregor Vostrak
24875f9424 add currency to organization update endpoint 2026-06-18 13:27:43 +02:00
Gregor Vostrak
4975cf1dab replace hardcoded inertia props with organization delete/update perms 2026-06-18 13:27:43 +02:00
Gregor Vostrak
b3fb04c4be remove unused inertia organization page props 2026-06-18 13:27:43 +02:00
Gregor Vostrak
0cc8d762a5 fix inertia backend role data structure after jetstream migration 2026-06-18 13:27:43 +02:00
Gregor Vostrak
e3e45161a1 call api for organization create/update/delete and switch 2026-06-18 13:27:43 +02:00
Constantin Graf
0c825a691e Fixed current organization after normal registration 2026-06-18 13:27:43 +02:00
Constantin Graf
24775d2c47 Added tests 2026-06-18 13:27:43 +02:00
Gregor Vostrak
c5c129b239 add switch current organization endpoint 2026-06-18 13:27:43 +02:00
Constantin Graf
89a9341d91 Removed Laravel Jetstream 2026-06-18 13:27:43 +02:00
Gregor Vostrak
bffd0773be remove jetstream inertia properties; remove unused ApiTokenManager; 2026-06-18 13:27:43 +02:00
Gregor Vostrak
56ea0ed90e move banners on login and register cards into the cards 2026-06-18 13:27:43 +02:00
Gregor Vostrak
24c19e7e7c add pending email cancel button 2026-06-18 13:27:43 +02:00
Constantin Graf
734aab6353 Replaces all Jetstream model trait functions and relations 2026-06-18 13:27:43 +02:00
Constantin Graf
5eb1a6a006 Add reset pending email endpoint to user controller 2026-06-18 13:27:43 +02:00
Gregor Vostrak
d0334bd730 move user delete to api endpoint 2026-06-18 13:27:43 +02:00
Gregor Vostrak
fe4e903203 use api routes for profile information updates 2026-06-18 13:27:43 +02:00
Gregor Vostrak
43e42ace54 show null billable rate as empty not as 0 to avoid confusion 2026-06-18 13:27:43 +02:00
Gregor Vostrak
84728f0914 fix e2e selectors to adapt to reka-ui change; 2026-06-18 13:27:43 +02:00
Gregor Vostrak
8f02f98afc add pending email to UserResource and update openapi client 2026-06-18 13:27:43 +02:00
Gregor Vostrak
c1ecd0eff5 update ui package dependencies; update lucide imports 2026-06-18 13:27:43 +02:00
Gregor Vostrak
486b5d2152 add user endpoint tests for idempotence email update, unauthenticated
update and invalid email
2026-06-18 13:27:43 +02:00
Gregor Vostrak
5476b4e5a9 update npm dependencies 2026-06-18 13:27:43 +02:00
Gregor Vostrak
ceec4cbf62 add profile page e2e tests 2026-06-18 13:27:43 +02:00
Gregor Vostrak
372c7b02a0 update email address change info to use session based banners 2026-06-18 13:27:43 +02:00
Gregor Vostrak
cb8047028c add 1MB photo upload limit 2026-06-18 13:27:43 +02:00
Gregor Vostrak
e29be581fa add photo delete logic to user update endpoint 2026-06-18 13:27:43 +02:00
Constantin Graf
02da133fb3 Updated composer dependencies 2026-06-18 13:27:43 +02:00
Constantin Graf
d00fe2baff Add more tests 2026-06-18 13:27:43 +02:00
Constantin Graf
a73fe59892 Add migration to lower case the user emails 2026-06-18 13:27:43 +02:00
Constantin Graf
5d744d70c4 Migrate permission away from Jetstream; Moved update user to REST API 2026-06-18 13:27:43 +02:00
Gregor Vostrak
12d58ee42c rephrase logged out user invite accept message to clarify that the
invite was accepted
2026-06-18 13:27:43 +02:00
Gregor Vostrak
f9be92894e add banners for invitation accept 2026-06-18 13:27:43 +02:00
Constantin Graf
a30d192ea2 Updated invitation flow, Moved jetstream function to REST endpoints; Lower case email 2026-06-18 13:27:43 +02:00
47 changed files with 2105 additions and 2239 deletions

View File

@@ -46,9 +46,9 @@ jobs:
- name: "Get Previous tag (normal push)" - name: "Get Previous tag (normal push)"
id: previoustag id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }} if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1" uses: "WyriHaximus/github-action-get-previous-tag@v2"
with: with:
prefix: "v" pattern: "v*[0-9].*[0-9].*[0-9]"
- name: "Get version" - name: "Get version"
id: release-version id: release-version
@@ -124,27 +124,27 @@ jobs:
- name: "Docker meta" - name: "Docker meta"
id: "meta" id: "meta"
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: | images: |
${{ env.DOCKER_REPO }} ${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }} password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up QEMU" - name: "Set up QEMU"
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: "Build and push by digest" - name: "Build and push by digest"
id: build id: build
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: docker/prod/Dockerfile file: docker/prod/Dockerfile
@@ -163,7 +163,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}" touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest" - name: "Upload digest"
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: digests-${{ env.PLATFORM_PAIR }} name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
@@ -177,25 +177,25 @@ jobs:
- build - build
steps: steps:
- name: "Download digests" - name: "Download digests"
uses: actions/download-artifact@v6 uses: actions/download-artifact@v8
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: digests-* pattern: digests-*
merge-multiple: true merge-multiple: true
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }} password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: "Docker meta" - name: "Docker meta"
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: | images: |
${{ env.DOCKER_REPO }} ${{ env.DOCKER_REPO }}

View File

@@ -33,9 +33,9 @@ jobs:
- name: "Get Previous tag (normal push)" - name: "Get Previous tag (normal push)"
id: previoustag id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }} if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1" uses: "WyriHaximus/github-action-get-previous-tag@v2"
with: with:
prefix: "v" pattern: "v*[0-9].*[0-9].*[0-9]"
- name: "Get version" - name: "Get version"
id: version id: version
@@ -160,7 +160,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: rg.fr-par.scw.cloud/solidtime registry: rg.fr-par.scw.cloud/solidtime
username: nologin username: nologin
@@ -168,7 +168,7 @@ jobs:
- name: "Docker meta" - name: "Docker meta"
id: "meta" id: "meta"
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: rg.fr-par.scw.cloud/solidtime/solidtime images: rg.fr-par.scw.cloud/solidtime/solidtime
tags: | tags: |
@@ -179,13 +179,13 @@ jobs:
type=sha,format=long type=sha,format=long
- name: "Set up QEMU" - name: "Set up QEMU"
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: "Build and push" - name: "Build and push"
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
build-args: | build-args: |

View File

@@ -47,9 +47,9 @@ jobs:
- name: "Get Previous tag (normal push)" - name: "Get Previous tag (normal push)"
id: previoustag id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }} if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1" uses: "WyriHaximus/github-action-get-previous-tag@v2"
with: with:
prefix: "v" pattern: "v*[0-9].*[0-9].*[0-9]"
- name: "Get version" - name: "Get version"
id: release-version id: release-version
@@ -109,34 +109,34 @@ jobs:
- name: "Docker meta" - name: "Docker meta"
id: "meta" id: "meta"
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: | images: |
${{ env.DOCKERHUB_REPO }} ${{ env.DOCKERHUB_REPO }}
${{ env.GHCR_REPO }} ${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry" - name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: "Set up QEMU" - name: "Set up QEMU"
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: "Build and push by digest" - name: "Build and push by digest"
id: build id: build
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: docker/prod/Dockerfile file: docker/prod/Dockerfile
@@ -155,7 +155,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}" touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest" - name: "Upload digest"
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: digests-${{ env.PLATFORM_PAIR }} name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
@@ -169,31 +169,31 @@ jobs:
- build - build
steps: steps:
- name: "Download digests" - name: "Download digests"
uses: actions/download-artifact@v6 uses: actions/download-artifact@v8
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: digests-* pattern: digests-*
merge-multiple: true merge-multiple: true
- name: "Login to Docker Hub" - name: "Login to Docker Hub"
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR" - name: "Login to GHCR"
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: "Docker meta" - name: "Docker meta"
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: | images: |
${{ env.DOCKERHUB_REPO }} ${{ env.DOCKERHUB_REPO }}

View File

@@ -52,7 +52,7 @@ jobs:
run: php artisan scramble:export --path=build/api-docs.json run: php artisan scramble:export --path=build/api-docs.json
- name: "Upload API docs to GitHub" - name: "Upload API docs to GitHub"
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: api-docs.json name: api-docs.json
path: build/api-docs.json path: build/api-docs.json

View File

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

View File

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

View File

@@ -86,7 +86,7 @@ jobs:
MAILPIT_BASE_URL: 'http://localhost:8025' MAILPIT_BASE_URL: 'http://localhost:8025'
- name: "Upload blob report" - name: "Upload blob report"
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
if: always() if: always()
with: with:
name: blob-report-${{ matrix.shardIndex }} name: blob-report-${{ matrix.shardIndex }}
@@ -102,7 +102,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: "Setup node" - name: "Setup node"
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: '20.x' node-version: '20.x'
@@ -110,7 +110,7 @@ jobs:
run: npm ci run: npm ci
- name: "Download blob reports" - name: "Download blob reports"
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
path: all-blob-reports path: all-blob-reports
pattern: blob-report-* pattern: blob-report-*
@@ -120,7 +120,7 @@ jobs:
run: npx playwright merge-reports --reporter html ./all-blob-reports run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: "Upload merged HTML report" - name: "Upload merged HTML report"
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: playwright-report name: playwright-report
path: playwright-report/ path: playwright-report/

View File

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

View File

@@ -59,7 +59,7 @@ class ReportController extends Controller
$filter->addBillable($properties->billable); $filter->addBillable($properties->billable);
$filter->addMemberIdsFilter($properties->memberIds?->toArray()); $filter->addMemberIdsFilter($properties->memberIds?->toArray());
$filter->addProjectIdsFilter($properties->projectIds?->toArray()); $filter->addProjectIdsFilter($properties->projectIds?->toArray());
$filter->addTagIdsFilter($properties->tagIds?->toArray()); $filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagMatchType);
$filter->addTaskIdsFilter($properties->taskIds?->toArray()); $filter->addTaskIdsFilter($properties->taskIds?->toArray());
$filter->addClientIdsFilter($properties->clientIds?->toArray()); $filter->addClientIdsFilter($properties->clientIds?->toArray());
$timeEntriesQuery = $filter->get(); $timeEntriesQuery = $filter->get();

View File

@@ -96,6 +96,7 @@ class ReportController extends Controller
$properties->setClientIds($request->input('properties.client_ids', null)); $properties->setClientIds($request->input('properties.client_ids', null));
$properties->setProjectIds($request->input('properties.project_ids', null)); $properties->setProjectIds($request->input('properties.project_ids', null));
$properties->setTagIds($request->input('properties.tag_ids', null)); $properties->setTagIds($request->input('properties.tag_ids', null));
$properties->setTagMatchType($request->getPropertyTagMatchType());
$properties->setTaskIds($request->input('properties.task_ids', null)); $properties->setTaskIds($request->input('properties.task_ids', null));
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start; $properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
$timezone = $user->timezone; $timezone = $user->timezone;

View File

@@ -203,7 +203,7 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member); $filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids')); $filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids')); $filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids')); $filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
$filter->addTaskIdsFilter($request->input('task_ids')); $filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids')); $filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable')); $filter->addBillableFilter($request->input('billable'));
@@ -559,7 +559,7 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member); $filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids')); $filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids')); $filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids')); $filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
$filter->addTaskIdsFilter($request->input('task_ids')); $filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids')); $filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable')); $filter->addBillableFilter($request->input('billable'));

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Report; namespace App\Http\Requests\V1\Report;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
@@ -124,6 +125,11 @@ class ReportStoreRequest extends BaseFormRequest
} }
}, },
], ],
'properties.tag_match_type' => [
'nullable',
'string',
Rule::enum(TagMatchType::class),
],
'properties.task_ids' => [ 'properties.task_ids' => [
'nullable', 'nullable',
'array', 'array',
@@ -249,6 +255,15 @@ class ReportStoreRequest extends BaseFormRequest
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group')); return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
} }
public function getPropertyTagMatchType(): ?TagMatchType
{
if (! $this->has('properties.tag_match_type') || $this->input('properties.tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->input('properties.tag_match_type'));
}
public function getPropertyRoundingType(): ?TimeEntryRoundingType public function getPropertyRoundingType(): ?TimeEntryRoundingType
{ {
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) { if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
@@ -139,6 +140,10 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail); })->uuid()->validate($attribute, $value, $fail);
}, },
], ],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
'array', 'array',
@@ -246,6 +251,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
return ExportFormat::from($this->validated('format')); return ExportFormat::from($this->validated('format'));
} }
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType public function getRoundingType(): ?TimeEntryRoundingType
{ {
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -110,6 +111,10 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
})->uuid()->validate($attribute, $value, $fail); })->uuid()->validate($attribute, $value, $fail);
}, },
], ],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
'array', 'array',
@@ -215,6 +220,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
return ExportFormat::from($this->validated('format')); return ExportFormat::from($this->validated('format'));
} }
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType public function getRoundingType(): ?TimeEntryRoundingType
{ {
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
@@ -103,6 +104,10 @@ class TimeEntryIndexRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail); })->uuid()->validate($attribute, $value, $fail);
}, },
], ],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
'array', 'array',
@@ -190,6 +195,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0; return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
} }
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType public function getRoundingType(): ?TimeEntryRoundingType
{ {
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -56,6 +56,8 @@ class DetailedReportResource extends BaseResource
'project_ids' => $this->resource->properties->projectIds?->toArray(), 'project_ids' => $this->resource->properties->projectIds?->toArray(),
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */ /** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->properties->tagIds?->toArray(), 'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var string|null $tag_match_type Tag match type */
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */ /** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(), 'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** @var string|null $rounding_type Rounding type for time entries */ /** @var string|null $rounding_type Rounding type for time entries */

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service\Dto; namespace App\Service\Dto;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
@@ -56,6 +57,8 @@ class ReportPropertiesDto implements Castable
*/ */
public ?Collection $tagIds = null; public ?Collection $tagIds = null;
public ?TagMatchType $tagMatchType = null;
/** /**
* @var Collection<int, string>|null * @var Collection<int, string>|null
*/ */
@@ -115,6 +118,7 @@ class ReportPropertiesDto implements Castable
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null; $dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null; $dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null; $dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null; $dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
$dto->group = TimeEntryAggregationType::from($data->group); $dto->group = TimeEntryAggregationType::from($data->group);
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup); $dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
@@ -144,6 +148,7 @@ class ReportPropertiesDto implements Castable
'clientIds' => $value->clientIds?->toArray(), 'clientIds' => $value->clientIds?->toArray(),
'projectIds' => $value->projectIds?->toArray(), 'projectIds' => $value->projectIds?->toArray(),
'tagIds' => $value->tagIds?->toArray(), 'tagIds' => $value->tagIds?->toArray(),
'tagMatchType' => $value->tagMatchType?->value,
'taskIds' => $value->taskIds?->toArray(), 'taskIds' => $value->taskIds?->toArray(),
'group' => $value->group->value, 'group' => $value->group->value,
'subGroup' => $value->subGroup->value, 'subGroup' => $value->subGroup->value,
@@ -216,6 +221,11 @@ class ReportPropertiesDto implements Castable
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null; $this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
} }
public function setTagMatchType(?TagMatchType $tagMatchType): void
{
$this->tagMatchType = $tagMatchType;
}
/** /**
* @param array<mixed>|null $taskIds * @param array<mixed>|null $taskIds
*/ */

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service\Import\Importers; namespace App\Service\Import\Importers;
use Exception; use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use League\Csv\Exception as CsvException; use League\Csv\Exception as CsvException;
use League\Csv\Reader; use League\Csv\Reader;
@@ -24,6 +25,7 @@ class ClockifyProjectsImporter extends DefaultImporter
$header = $reader->getHeader(); $header = $reader->getHeader();
$this->validateHeader($header); $this->validateHeader($header);
$billableRateKey = $this->getBillableRateKey($header); $billableRateKey = $this->getBillableRateKey($header);
$tasksKey = $this->getTasksKey($header);
$records = $reader->getRecords(); $records = $reader->getRecords();
foreach ($records as $record) { foreach ($records as $record) {
$clientId = null; $clientId = null;
@@ -44,11 +46,12 @@ class ClockifyProjectsImporter extends DefaultImporter
'is_billable' => $record['Billability'] === 'Yes', 'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null, 'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null, 'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
]); ]);
} }
if ($record['Task'] !== '') { if ($tasksKey !== null && $record[$tasksKey] !== '') {
$tasks = explode(', ', $record['Task']); $tasks = explode(', ', $record[$tasksKey]);
foreach ($tasks as $task) { foreach ($tasks as $task) {
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task, 'name' => $task,
@@ -81,13 +84,33 @@ class ClockifyProjectsImporter extends DefaultImporter
'Status', 'Status',
'Visibility', 'Visibility',
'Billability', 'Billability',
'Task',
]; ];
foreach ($requiredFields as $requiredField) { foreach ($requiredFields as $requiredField) {
if (! in_array($requiredField, $header, true)) { if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField); throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
} }
} }
// Clockify names the tasks column "Task", "Tasks" or "Activities" depending on the export; accept any.
if ($this->getTasksKey($header) === null) {
throw new ImportException('Invalid CSV header, missing field: Tasks');
}
}
/**
* Clockify names the tasks column differently depending on the export
* version: "Task" (older), "Tasks" (newer) or "Activities".
*
* @param array<string> $header
*/
private function getTasksKey(array $header): ?string
{
foreach (['Tasks', 'Task', 'Activities'] as $field) {
if (in_array($field, $header, true)) {
return $field;
}
}
return null;
} }
/** /**

View File

@@ -54,6 +54,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$reader->setEscape(''); $reader->setEscape('');
$header = $reader->getHeader(); $header = $reader->getHeader();
$this->validateHeader($header); $this->validateHeader($header);
$taskKey = $this->getTaskKey($header);
$records = $reader->getRecords(); $records = $reader->getRecords();
foreach ($records as $record) { foreach ($records as $record) {
$userId = $this->userImportHelper->getKey([ $userId = $this->userImportHelper->getKey([
@@ -96,9 +97,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]); ]);
} }
$taskId = null; $taskId = null;
if ($record['Task'] !== '') { if ($taskKey !== null && $record[$taskKey] !== '') {
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'], 'name' => $record[$taskKey],
'project_id' => $projectId, 'project_id' => $projectId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
]); ]);
@@ -116,10 +117,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
throw new ImportException('Time entry description is too long'); throw new ImportException('Time entry description is too long');
} }
$timeEntry->description = $record['Description']; $timeEntry->description = $record['Description'];
if (! in_array($record['Billable'], ['Yes', 'No'], true)) { if (isset($record['Billable'])) {
throw new ImportException('Invalid billable value'); if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $record['Billable'] === 'Yes';
} }
$timeEntry->billable = $record['Billable'] === 'Yes';
$timeEntry->tags = $this->getTags($record['Tags']); $timeEntry->tags = $this->getTags($record['Tags']);
$timeEntry->is_imported = true; $timeEntry->is_imported = true;
@@ -214,12 +217,10 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'Project', 'Project',
'Client', 'Client',
'Description', 'Description',
'Task',
'User', 'User',
'Group', 'Group',
'Email', 'Email',
'Tags', 'Tags',
'Billable',
'Start Date', 'Start Date',
'Start Time', 'Start Time',
'End Date', 'End Date',
@@ -230,6 +231,26 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
throw new ImportException('Invalid CSV header, missing field: '.$requiredField); throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
} }
} }
// Clockify names the task column "Task" or "Activity" depending on the export; accept either.
if ($this->getTaskKey($header) === null) {
throw new ImportException('Invalid CSV header, missing field: Task');
}
}
/**
* Clockify names the task column "Task" or "Activity" depending on the export version.
*
* @param array<string> $header
*/
private function getTaskKey(array $header): ?string
{
foreach (['Task', 'Activity'] as $field) {
if (in_array($field, $header, true)) {
return $field;
}
}
return null;
} }
#[\Override] #[\Override]

View File

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

View File

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

View File

@@ -5,7 +5,8 @@ declare(strict_types=1);
return [ return [
'clockify_time_entries' => [ 'clockify_time_entries' => [
'name' => 'Clockify Time Entries', 'name' => 'Clockify Time Entries',
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'. 'description' => '<strong>Important:</strong> If you also want to import your projects use the "Clockify Projects" importer before this one, since that export contains more details such as billable status, billable rates and estimated time.<br><br>'.
'1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
'2. In the same preferences page change the language of Clockfiy to English.<br>'. '2. In the same preferences page change the language of Clockfiy to English.<br>'.
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'. '3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'4. Now select the date range that you want to export in the right top. '. '4. Now select the date range that you want to export in the right top. '.
@@ -61,7 +62,8 @@ return [
], ],
'harvest_time_entries' => [ 'harvest_time_entries' => [
'name' => 'Harvest Time Entries', 'name' => 'Harvest Time Entries',
'description' => '1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'. 'description' => '<strong>Important:</strong> If you also want to import your projects use the "Harvest Projects" importer before this one, since that export contains more details such as billable status and estimated time.<br><br>'.
'1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
'<br>3. Now click on "Export all time" '. '<br>3. Now click on "Export all time" '.
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.', '<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
], ],

3299
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,13 +26,13 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.39.4", "@eslint/js": "^10.0.1",
"@inertiajs/vue3": "^2.3.23", "@inertiajs/vue3": "^3.4.0",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/chroma-js": "^3.1.2", "@types/chroma-js": "^3.1.2",
"@types/node": "^22.19.19", "@types/node": "^26.0.0",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
@@ -40,14 +40,14 @@
"axios": "^1.16.0", "axios": "^1.16.0",
"eslint-plugin-unused-imports": "^4.4.1", "eslint-plugin-unused-imports": "^4.4.1",
"happy-dom": "^20.8.9", "happy-dom": "^20.8.9",
"laravel-vite-plugin": "^2.1.0", "laravel-vite-plugin": "^3.1.0",
"openapi-zod-client": "^1.18.3", "openapi-zod-client": "^1.18.3",
"postcss": "^8.5.14", "postcss": "^8.5.14",
"postcss-import": "^15.1.0", "postcss-import": "^16.1.1",
"postcss-nesting": "^12.1.5", "postcss-nesting": "^14.0.0",
"tailwindcss": "^3.4.19", "tailwindcss": "^4.3.1",
"typescript": "^5.9.3", "typescript": "^6.0.3",
"vite": "^7.3.3", "vite": "^8.0.16",
"vite-plugin-checker": "^0.12.0", "vite-plugin-checker": "^0.12.0",
"vitest": "^4.1.4", "vitest": "^4.1.4",
"vue": "^3.5.34", "vue": "^3.5.34",
@@ -55,14 +55,14 @@
}, },
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.7.5", "@floating-ui/core": "^1.7.5",
"@floating-ui/vue": "^1.1.11", "@floating-ui/vue": "^2.0.0",
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@lucide/vue": "^1.14.0", "@lucide/vue": "^1.14.0",
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-form": "^1.32.0", "@tanstack/vue-form": "^1.32.0",
"@tanstack/vue-query": "^5.100.10", "@tanstack/vue-query": "^5.100.10",
"@tanstack/vue-query-devtools": "^5.91.0", "@tanstack/vue-query-devtools": "^6.1.34",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0", "@vue/eslint-config-typescript": "^14.7.0",
@@ -79,7 +79,7 @@
"pinia": "^3.0.4", "pinia": "^3.0.4",
"radix-vue": "^1.9.17", "radix-vue": "^1.9.17",
"reka-ui": "^2.9.7", "reka-ui": "^2.9.7",
"tailwind-merge": "^2.6.1", "tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vue-draggable-plus": "^0.6.1", "vue-draggable-plus": "^0.6.1",
"vue-echarts": "^8.0.1", "vue-echarts": "^8.0.1",

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid'; import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { FolderIcon } from '@heroicons/vue/16/solid'; import { FolderIcon } from '@heroicons/vue/16/solid';
import { Check } from '@lucide/vue';
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue'; import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue'; import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
@@ -14,6 +16,7 @@ import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { useTagsQuery } from '@/utils/useTagsQuery'; import { useTagsQuery } from '@/utils/useTagsQuery';
import { useTagsStore } from '@/utils/useTags'; import { useTagsStore } from '@/utils/useTags';
import type { TagMatchType } from '@/types/reporting';
type TimeEntryRoundingType = 'up' | 'down' | 'nearest'; type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -22,6 +25,7 @@ const selectedProjects = defineModel<string[]>('selectedProjects', { required: t
const selectedTasks = defineModel<string[]>('selectedTasks', { required: true }); const selectedTasks = defineModel<string[]>('selectedTasks', { required: true });
const selectedClients = defineModel<string[]>('selectedClients', { required: true }); const selectedClients = defineModel<string[]>('selectedClients', { required: true });
const selectedTags = defineModel<string[]>('selectedTags', { required: true }); const selectedTags = defineModel<string[]>('selectedTags', { required: true });
const tagMatchType = defineModel<TagMatchType>('tagMatchType', { required: true });
const billable = defineModel<'true' | 'false' | null>('billable', { required: true }); const billable = defineModel<'true' | 'false' | null>('billable', { required: true });
const roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true }); const roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true });
const roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true }); const roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true });
@@ -35,6 +39,16 @@ const emit = defineEmits<{
const { tags } = useTagsQuery(); const { tags } = useTagsQuery();
const tagMatchOptions: { value: TagMatchType; label: string }[] = [
{ value: 'contains', label: 'Contains' },
{ value: 'not_contains', label: 'Does Not Contain' },
];
function selectTagMatchType(value: AcceptableValue) {
tagMatchType.value = value as TagMatchType;
emit('submit');
}
async function createTag(name: string) { async function createTag(name: string) {
return await useTagsStore().createTag(name); return await useTagsStore().createTag(name);
} }
@@ -93,6 +107,34 @@ async function createTag(name: string) {
title="Tags" title="Tags"
:icon="TagIcon" /> :icon="TagIcon" />
</template> </template>
<template #content-before-list>
<div class="mt-2 border-b border-card-background-separator pb-2">
<div
id="tag-match-type-label"
class="mb-1.5 px-2 text-xs font-medium text-text-tertiary uppercase">
Match
</div>
<RadioGroupRoot
:model-value="tagMatchType"
aria-labelledby="tag-match-type-label"
class="space-y-1"
@update:model-value="selectTagMatchType">
<RadioGroupItem
v-for="option in tagMatchOptions"
:key="option.value"
:value="option.value"
class="relative flex w-full items-center rounded-md py-1.5 pl-2 pr-8 text-left text-sm font-medium text-text-secondary hover:bg-card-background-active data-[state=checked]:text-text-primary">
{{ option.label }}
<span
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<RadioGroupIndicator>
<Check class="h-4 w-4" />
</RadioGroupIndicator>
</span>
</RadioGroupItem>
</RadioGroupRoot>
</div>
</template>
</TagDropdown> </TagDropdown>
<Select v-model="billable" @update:model-value="emit('submit')"> <Select v-model="billable" @update:model-value="emit('submit')">

View File

@@ -49,6 +49,7 @@ import type { ExportFormat } from '@/types/reporting';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color'; import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useAggregatedTimeEntriesQuery } from '@/utils/useAggregatedTimeEntriesQuery'; import { useAggregatedTimeEntriesQuery } from '@/utils/useAggregatedTimeEntriesQuery';
import type { TagMatchType } from '@/types/reporting';
type TimeEntryRoundingType = 'up' | 'down' | 'nearest'; type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -67,6 +68,7 @@ const selectedProjects = ref<string[]>([]);
const selectedMembers = ref<string[]>([]); const selectedMembers = ref<string[]>([]);
const selectedTasks = ref<string[]>([]); const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]); const selectedClients = ref<string[]>([]);
const tagMatchType = ref<TagMatchType>('contains');
const billable = ref<'true' | 'false' | null>(null); const billable = ref<'true' | 'false' | null>(null);
const roundingEnabled = ref<boolean>(false); const roundingEnabled = ref<boolean>(false);
@@ -122,6 +124,7 @@ const filterParams = computed<AggregatedTimeEntriesQueryParams>(() => {
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined, task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined, client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined, tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
tag_match_type: selectedTags.value.length > 0 ? tagMatchType.value : undefined,
billable: billable.value !== null ? billable.value : undefined, billable: billable.value !== null ? billable.value : undefined,
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined, member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined, rounding_type: roundingEnabled.value ? roundingType.value : undefined,
@@ -366,6 +369,7 @@ const tableData = computed(() => {
v-model:selected-tasks="selectedTasks" v-model:selected-tasks="selectedTasks"
v-model:selected-clients="selectedClients" v-model:selected-clients="selectedClients"
v-model:selected-tags="selectedTags" v-model:selected-tags="selectedTags"
v-model:tag-match-type="tagMatchType"
v-model:billable="billable" v-model:billable="billable"
v-model:rounding-enabled="roundingEnabled" v-model:rounding-enabled="roundingEnabled"
v-model:rounding-type="roundingType" v-model:rounding-type="roundingType"

View File

@@ -67,6 +67,7 @@ import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar
import { useTimeEntriesReportQuery } from '@/utils/useTimeEntriesReportQuery'; import { useTimeEntriesReportQuery } from '@/utils/useTimeEntriesReportQuery';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations'; import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import type { TagMatchType } from '@/types/reporting';
// TimeEntryRoundingType is now defined in ReportingRoundingControls component // TimeEntryRoundingType is now defined in ReportingRoundingControls component
type TimeEntryRoundingType = 'up' | 'down' | 'nearest'; type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -84,6 +85,7 @@ const selectedProjects = ref<string[]>([]);
const selectedMembers = ref<string[]>([]); const selectedMembers = ref<string[]>([]);
const selectedTasks = ref<string[]>([]); const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]); const selectedClients = ref<string[]>([]);
const tagMatchType = ref<TagMatchType>('contains');
const billable = ref<'true' | 'false' | null>(null); const billable = ref<'true' | 'false' | null>(null);
const roundingEnabled = ref<boolean>(false); const roundingEnabled = ref<boolean>(false);
const roundingType = ref<TimeEntryRoundingType>('nearest'); const roundingType = ref<TimeEntryRoundingType>('nearest');
@@ -115,6 +117,7 @@ function getFilterAttributes() {
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined, task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined, client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined, tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
tag_match_type: selectedTags.value.length > 0 ? tagMatchType.value : undefined,
billable: billable.value !== null ? billable.value : undefined, billable: billable.value !== null ? billable.value : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined, rounding_type: roundingEnabled.value ? roundingType.value : undefined,
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined, rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
@@ -337,6 +340,7 @@ async function downloadExport(format: ExportFormat) {
v-model:selected-tasks="selectedTasks" v-model:selected-tasks="selectedTasks"
v-model:selected-clients="selectedClients" v-model:selected-clients="selectedClients"
v-model:selected-tags="selectedTags" v-model:selected-tags="selectedTags"
v-model:tag-match-type="tagMatchType"
v-model:billable="billable" v-model:billable="billable"
v-model:rounding-enabled="roundingEnabled" v-model:rounding-enabled="roundingEnabled"
v-model:rounding-type="roundingType" v-model:rounding-type="roundingType"

View File

@@ -43,17 +43,17 @@ onMounted(async () => {
<template v-else> <template v-else>
<UpdateTeamNameForm :team="team" :permissions="permissions" /> <UpdateTeamNameForm :team="team" :permissions="permissions" />
<SectionBorder /> <SectionBorder v-if="permissions.canUpdateTeam" />
<OrganizationBillableRate v-if="permissions.canUpdateTeam" :team="team" /> <OrganizationBillableRate v-if="permissions.canUpdateTeam" :team="team" />
<SectionBorder />
<SectionBorder v-if="permissions.canUpdateTeam" />
<OrganizationFormatSettings v-if="permissions.canUpdateTeam" :team="team" /> <OrganizationFormatSettings v-if="permissions.canUpdateTeam" :team="team" />
<SectionBorder />
<SectionBorder v-if="permissions.canUpdateTeam" />
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" /> <OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
<SectionBorder />
<template v-if="permissions.canDeleteTeam"> <template v-if="permissions.canDeleteTeam">
<SectionBorder />
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" /> <DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
</template> </template>
</template> </template>

View File

@@ -29,7 +29,7 @@
"author": "solidtime", "author": "solidtime",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"devDependencies": { "devDependencies": {
"vite-plugin-dts": "^4.5.4" "vite-plugin-dts": "^5.0.2"
}, },
"peerDependencies": { "peerDependencies": {
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",

View File

@@ -448,6 +448,7 @@ const ReportStoreRequest = z
client_ids: z.union([z.array(z.string()), z.null()]).optional(), client_ids: z.union([z.array(z.string()), z.null()]).optional(),
project_ids: z.union([z.array(z.string()), z.null()]).optional(), project_ids: z.union([z.array(z.string()), z.null()]).optional(),
tag_ids: z.union([z.array(z.string()), z.null()]).optional(), tag_ids: z.union([z.array(z.string()), z.null()]).optional(),
tag_match_type: z.enum(['contains', 'not_contains']).optional(),
task_ids: z.union([z.array(z.string()), z.null()]).optional(), task_ids: z.union([z.array(z.string()), z.null()]).optional(),
group: TimeEntryAggregationType, group: TimeEntryAggregationType,
sub_group: TimeEntryAggregationType, sub_group: TimeEntryAggregationType,
@@ -481,6 +482,7 @@ const DetailedReportResource = z
client_ids: z.union([z.array(z.string()), z.null()]), client_ids: z.union([z.array(z.string()), z.null()]),
project_ids: z.union([z.array(z.string()), z.null()]), project_ids: z.union([z.array(z.string()), z.null()]),
tag_ids: z.union([z.array(z.string()), z.null()]), tag_ids: z.union([z.array(z.string()), z.null()]),
tag_match_type: z.union([z.enum(['contains', 'not_contains']), z.null()]),
task_ids: z.union([z.array(z.string()), z.null()]), task_ids: z.union([z.array(z.string()), z.null()]),
rounding_type: z.union([z.string(), z.null()]), rounding_type: z.union([z.string(), z.null()]),
rounding_minutes: z.union([z.number(), z.null()]), rounding_minutes: z.union([z.number(), z.null()]),
@@ -3784,6 +3786,11 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
type: 'Query', type: 'Query',
schema: z.array(z.string()).min(1).optional(), schema: z.array(z.string()).min(1).optional(),
}, },
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{ {
name: 'task_ids', name: 'task_ids',
type: 'Query', type: 'Query',
@@ -4165,6 +4172,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query', type: 'Query',
schema: z.array(z.string()).min(1).optional(), schema: z.array(z.string()).min(1).optional(),
}, },
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{ {
name: 'task_ids', name: 'task_ids',
type: 'Query', type: 'Query',
@@ -4359,6 +4371,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query', type: 'Query',
schema: z.array(z.string()).min(1).optional(), schema: z.array(z.string()).min(1).optional(),
}, },
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{ {
name: 'task_ids', name: 'task_ids',
type: 'Query', type: 'Query',
@@ -4487,6 +4504,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query', type: 'Query',
schema: z.array(z.string()).min(1).optional(), schema: z.array(z.string()).min(1).optional(),
}, },
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{ {
name: 'task_ids', name: 'task_ids',
type: 'Query', type: 'Query',

View File

@@ -50,7 +50,7 @@
"devDependencies": { "devDependencies": {
"@types/chroma-js": "^3.1.2", "@types/chroma-js": "^3.1.2",
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",
"vite-plugin-dts": "^4.5.4", "vite-plugin-dts": "^5.0.2",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"peerDependencies": { "peerDependencies": {

View File

@@ -114,6 +114,7 @@ const showCreateTagModal = ref(false);
class="w-full rounded-md border border-input-border bg-input-background px-3 py-1.5 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none" class="w-full rounded-md border border-input-border bg-input-background px-3 py-1.5 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
placeholder="Search for a Tag..." /> placeholder="Search for a Tag..." />
</ComboboxAnchor> </ComboboxAnchor>
<slot name="content-before-list"></slot>
<ComboboxContent <ComboboxContent
:dismiss-able="false" :dismiss-able="false"
position="inline" position="inline"

View File

@@ -199,7 +199,7 @@ body {
--muted-foreground: var(--color-text-tertiary); --muted-foreground: var(--color-text-tertiary);
--accent: var(--color-bg-tertiary); --accent: var(--color-bg-tertiary);
--accent-foreground: var(--color-text-primary); --accent-foreground: var(--color-text-primary);
--destructive: 0 62.8% 30.6%; --destructive: 0 72% 60%;
--destructive-foreground: var(--color-text-primary); --destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary); --border: var(--color-border-primary);
--input: var(--color-border-tertiary); --input: var(--color-border-tertiary);

View File

@@ -1 +1,2 @@
export type ExportFormat = 'xlsx' | 'csv' | 'ods' | 'pdf'; export type ExportFormat = 'xlsx' | 'csv' | 'ods' | 'pdf';
export type TagMatchType = 'contains' | 'not_contains';

View File

@@ -0,0 +1,3 @@
"Project","Client","Status","Visibility","Billability","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Tracked (USD)","Estimated (USD)","Remaining (USD)","Overage (USD)","Progress(%)","Recurring estimate","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Cost Rate (USD)","Expenses (USD)","Billable expenses (USD)","Non-billable expenses (USD)","Additional fields","Project members","Project manager","Note"
"Active Project","Big Company","Active","Public","Yes","Task 1, Task 2","0.00","100.00","","","","","","","","","0.00","0.00","100.01","0.00","","0.00","0.00","0.00","","Constantin Graf","",""
"Archived Project","","Archived","Public","Yes","","0.00","","","","","","","","","","0.00","0.00","","0.00","","0.00","0.00","0.00","","Constantin Graf","",""
1 Project Client Status Visibility Billability Tasks Tracked (h) Estimated (h) Remaining (h) Overage (h) Tracked (USD) Estimated (USD) Remaining (USD) Overage (USD) Progress(%) Recurring estimate Billable (h) Non-billable (h) Billable Rate (USD) Amount (USD) Cost Rate (USD) Expenses (USD) Billable expenses (USD) Non-billable expenses (USD) Additional fields Project members Project manager Note
2 Active Project Big Company Active Public Yes Task 1, Task 2 0.00 100.00 0.00 0.00 100.01 0.00 0.00 0.00 0.00 Constantin Graf
3 Archived Project Archived Public Yes 0.00 0.00 0.00 0.00 0.00 0.00 0.00 Constantin Graf

View File

@@ -0,0 +1,2 @@
"Project","Client","Status","Visibility","Billability","Activities","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Tracked (USD)","Estimated (USD)","Remaining (USD)","Overage (USD)","Progress(%)","Recurring estimate","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Cost Rate (USD)","Expenses (USD)","Billable expenses (USD)","Non-billable expenses (USD)","Additional fields","Project members","Project manager","Note"
"Project With Activities","","Active","Public","Yes","Activity A, Activity B","0.00","","","","","","","","","","0.00","0.00","","0.00","","0.00","0.00","0.00","","","",""
1 Project Client Status Visibility Billability Activities Tracked (h) Estimated (h) Remaining (h) Overage (h) Tracked (USD) Estimated (USD) Remaining (USD) Overage (USD) Progress(%) Recurring estimate Billable (h) Non-billable (h) Billable Rate (USD) Amount (USD) Cost Rate (USD) Expenses (USD) Billable expenses (USD) Non-billable expenses (USD) Additional fields Project members Project manager Note
2 Project With Activities Active Public Yes Activity A, Activity B 0.00 0.00 0.00 0.00 0.00 0.00 0.00

View File

@@ -0,0 +1,3 @@
"Project","Client","Description","Task","User","Group","Email","Tags","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
"Project without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00"
"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
1 Project Client Description Task User Group Email Tags Start Date Start Time End Date End Time Duration (h) Duration (decimal) Billable Rate (USD) Billable Amount (USD)
2 Project without Client Peter Tester peter.test@email.test Development, Backend 03/04/2024 10:23:52 AM 03/04/2024 10:23:52 AM 00:00:00 0.00 0.00 0.00
3 Project for Big Company Big Company Working hard Task 1 Peter Tester peter.test@email.test 03/04/2024 10:23 AM 03/04/2024 11:23:01 AM 01:00:01 0.00 0.00 0.00

View File

@@ -0,0 +1,3 @@
"Project","Client","Description","Activity","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
"Project without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","No","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00"
"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","Yes","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
1 Project Client Description Activity User Group Email Tags Billable Start Date Start Time End Date End Time Duration (h) Duration (decimal) Billable Rate (USD) Billable Amount (USD)
2 Project without Client Peter Tester peter.test@email.test Development, Backend No 03/04/2024 10:23:52 AM 03/04/2024 10:23:52 AM 00:00:00 0.00 0.00 0.00
3 Project for Big Company Big Company Working hard Task 1 Peter Tester peter.test@email.test Yes 03/04/2024 10:23 AM 03/04/2024 11:23:01 AM 01:00:01 0.00 0.00 0.00

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1\Public; namespace Tests\Unit\Endpoint\Api\V1\Public;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday; use App\Enums\Weekday;
@@ -667,4 +668,58 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
], ],
]); ]);
} }
public function test_show_applies_not_contains_tag_match_type(): void
{
// Arrange
$organization = Organization::factory()->create();
$tagA = Tag::factory()->forOrganization($organization)->create();
$tagB = Tag::factory()->forOrganization($organization)->create();
// Entry with tagA (should be excluded by "does not contain tagA")
TimeEntry::factory()->forOrganization($organization)
->startWithDuration(now()->subDay(), 100)
->create([
'tags' => [$tagA->getKey()],
]);
// Entry with a different tag (should be included)
TimeEntry::factory()->forOrganization($organization)
->startWithDuration(now()->subDay(), 200)
->create([
'tags' => [$tagB->getKey()],
]);
// Entry without tags (should be included)
TimeEntry::factory()->forOrganization($organization)
->startWithDuration(now()->subDay(), 50)
->create();
$reportDto = new ReportPropertiesDto;
$reportDto->start = now()->subDays(2);
$reportDto->end = now();
$reportDto->group = TimeEntryAggregationType::Project;
$reportDto->subGroup = TimeEntryAggregationType::Task;
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
$reportDto->weekStart = Weekday::Monday;
$reportDto->timezone = 'Europe/Vienna';
$reportDto->setTagIds([$tagA->getKey()]);
$reportDto->setTagMatchType(TagMatchType::NotContains);
$report = Report::factory()->forOrganization($organization)->public()->create([
'public_until' => null,
'properties' => $reportDto,
]);
// Act
$response = $this->getJson(route('api.v1.public.reports.show'), [
'X-Api-Key' => $report->share_secret,
]);
// Assert: tagA entry (100s) excluded; tagB (200s) + untagged (50s) included
$response->assertOk();
$response->assertJson([
'data' => [
'seconds' => 250,
'grouped_type' => TimeEntryAggregationType::Project->value,
],
]);
}
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1; namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday; use App\Enums\Weekday;
@@ -685,4 +686,64 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
'id' => $report->getKey(), 'id' => $report->getKey(),
]); ]);
} }
public function test_store_endpoint_persists_tag_match_type(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:create',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
'name' => 'Report with tag match type',
'is_public' => false,
'properties' => [
'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'group' => TimeEntryAggregationType::Project->value,
'sub_group' => TimeEntryAggregationType::Task->value,
'history_group' => TimeEntryAggregationType::Day->value,
'tag_ids' => [$tag->getKey()],
'tag_match_type' => TagMatchType::NotContains->value,
],
]);
// Assert
$response->assertStatus(201);
/** @var Report $report */
$report = Report::query()->findOrFail($response->json('data.id'));
$this->assertSame(TagMatchType::NotContains, $report->properties->tagMatchType);
// DetailedReportResource exposes the match type in the response
$response->assertJsonPath('data.properties.tag_match_type', TagMatchType::NotContains->value);
}
public function test_store_endpoint_rejects_invalid_tag_match_type(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:create',
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
'name' => 'Report with invalid tag match type',
'is_public' => false,
'properties' => [
'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'group' => TimeEntryAggregationType::Project->value,
'sub_group' => TimeEntryAggregationType::Task->value,
'history_group' => TimeEntryAggregationType::Day->value,
'tag_match_type' => 'invalid_value',
],
]);
// Assert
$response->assertStatus(422);
$response->assertInvalid(['properties.tag_match_type']);
}
} }

View File

@@ -6,6 +6,7 @@ namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\Role; use App\Enums\Role;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
@@ -4351,4 +4352,153 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(1, 'data'); $response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $timeEntryWithoutTag->getKey()); $response->assertJsonPath('data.0.id', $timeEntryWithoutTag->getKey());
} }
public function test_index_endpoint_with_not_contains_tag_match_type_excludes_entries_with_tag(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
$timeEntryWithTag = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
'tags' => [$tag->getKey()],
]);
$timeEntryWithEmptyTags = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
'tags' => [],
]);
$timeEntryWithNullTags = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
'tags' => null,
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'tag_ids' => [$tag->getKey()],
'tag_match_type' => TagMatchType::NotContains->value,
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert: the tagged entry is excluded; the untagged (empty + null) entries remain
$response->assertValid();
$this->assertResponseCode($response, 200);
$response->assertJsonCount(2, 'data');
$returnedIds = collect($response->json('data'))->pluck('id');
$this->assertTrue($returnedIds->contains($timeEntryWithEmptyTags->getKey()));
$this->assertTrue($returnedIds->contains($timeEntryWithNullTags->getKey()));
$this->assertFalse($returnedIds->contains($timeEntryWithTag->getKey()));
}
public function test_index_endpoint_with_contains_tag_match_type_returns_only_entries_with_tag(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
$timeEntryWithTag = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
'tags' => [$tag->getKey()],
]);
TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
'tags' => [],
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'tag_ids' => [$tag->getKey()],
'tag_match_type' => TagMatchType::Contains->value,
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert: only the entry that has the tag
$response->assertValid();
$this->assertResponseCode($response, 200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $timeEntryWithTag->getKey());
}
public function test_index_endpoint_rejects_invalid_tag_match_type(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'tag_ids' => [$tag->getKey()],
'tag_match_type' => 'invalid_value',
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 422);
$response->assertInvalid(['tag_match_type']);
}
public function test_aggregate_endpoint_with_not_contains_tag_match_type_excludes_entries_with_tag(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->startWithDuration(Carbon::now()->subHour(), 100)
->create([
'tags' => [$tag->getKey()],
]);
TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->startWithDuration(Carbon::now()->subHour(), 200)
->create([
'tags' => [],
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
$data->organization->getKey(),
'tag_ids' => [$tag->getKey()],
'tag_match_type' => TagMatchType::NotContains->value,
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert: only the untagged entry (200s) is aggregated
$response->assertValid();
$this->assertResponseCode($response, 200);
$response->assertJsonPath('data.seconds', 200);
}
} }

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers; namespace Tests\Unit\Service\Import\Importers;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Service\Import\Importers\ClockifyProjectsImporter; use App\Service\Import\Importers\ClockifyProjectsImporter;
use App\Service\Import\Importers\DefaultImporter; use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\ImportException;
@@ -50,4 +52,68 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
// Assert // Assert
$this->checkTestScenarioProjectsOnlyAfterImport(); $this->checkTestScenarioProjectsOnlyAfterImport();
} }
public function test_import_sets_archived_at_based_on_status_column(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_2.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$activeProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Active Project')->firstOrFail();
$this->assertNull($activeProject->archived_at);
$this->assertFalse($activeProject->is_archived);
$archivedProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Archived Project')->firstOrFail();
$this->assertNotNull($archivedProject->archived_at);
$this->assertTrue($archivedProject->is_archived);
}
public function test_import_supports_renamed_tasks_column(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
// Newer Clockify exports rename the "Task" column to "Tasks".
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_2.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$activeProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Active Project')->firstOrFail();
$this->assertEqualsCanonicalizing(
['Task 1', 'Task 2'],
Task::query()->where('project_id', $activeProject->id)->pluck('name')->all(),
);
}
public function test_import_supports_activities_column_alias_for_tasks(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
// Some Clockify exports name the tasks column "Activities".
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_3.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$project = Project::query()->where('organization_id', $organization->id)->where('name', 'Project With Activities')->firstOrFail();
$this->assertEqualsCanonicalizing(
['Activity A', 'Activity B'],
Task::query()->where('project_id', $project->id)->pluck('name')->all(),
);
}
} }

View File

@@ -41,6 +41,30 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(1, $report->clientsCreated); $this->assertSame(1, $report->clientsCreated);
} }
public function test_import_of_test_file_without_billable_works_and_defaults_to_non_billable(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyTimeEntriesImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_4.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(false, true);
$this->checkTimeEntries($testScenario, false, true);
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(2, $report->tagsCreated);
$this->assertSame(1, $report->tasksCreated);
$this->assertSame(1, $report->usersCreated);
$this->assertSame(2, $report->projectsCreated);
$this->assertSame(1, $report->clientsCreated);
}
public function test_import_of_test_with_special_characters_description_succeeds(): void public function test_import_of_test_with_special_characters_description_succeeds(): void
{ {
// Arrange // Arrange
@@ -93,6 +117,25 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->clientsCreated); $this->assertSame(0, $report->clientsCreated);
} }
public function test_import_supports_activity_column_alias_for_task(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyTimeEntriesImporter;
$importer->init($organization);
// Some Clockify exports name the task column "Activity".
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_5.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(1, $report->tasksCreated);
}
public function test_import_fails_if_month_in_date_is_bigger_than_12(): void public function test_import_fails_if_month_in_date_is_bigger_than_12(): void
{ {
// Arrange // Arrange

View File

@@ -26,7 +26,7 @@ class ImporterTestAbstract extends TestCase
/** /**
* @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} * @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag}
*/ */
protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false): object protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false, bool $billableDefault = false): object
{ {
$users = User::all(); $users = User::all();
$this->assertCount(2, $users); $this->assertCount(2, $users);
@@ -80,12 +80,12 @@ class ImporterTestAbstract extends TestCase
$this->assertSame('#ef5350', $project1->color); $this->assertSame('#ef5350', $project1->color);
$this->assertSame(null, $project1->billable_rate); $this->assertSame(null, $project1->billable_rate);
// Project for Big Company // Project for Big Company
$this->assertSame(true, $project2->is_billable); $this->assertSame(! $billableDefault, $project2->is_billable);
$this->assertSame(false, $project2->is_public); $this->assertSame(false, $project2->is_public);
$this->assertSame('#ec407a', $project2->color); $this->assertSame('#ec407a', $project2->color);
$this->assertSame(10001, $project2->billable_rate); $this->assertSame(10001, $project2->billable_rate);
// Project (Archived) // Project (Archived)
$this->assertSame(true, $project3->is_billable); $this->assertSame(! $billableDefault, $project3->is_billable);
$this->assertSame(true, $project3->is_public); $this->assertSame(true, $project3->is_public);
$this->assertSame('#6a407f', $project3->color); $this->assertSame('#6a407f', $project3->color);
$this->assertSame(null, $project3->billable_rate); $this->assertSame(null, $project3->billable_rate);
@@ -176,7 +176,7 @@ class ImporterTestAbstract extends TestCase
/** /**
* @param object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} $testScenario * @param object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} $testScenario
*/ */
protected function checkTimeEntries(object $testScenario, bool $secondRun = false): void protected function checkTimeEntries(object $testScenario, bool $secondRun = false, bool $billableDefault = false): void
{ {
$timeEntries = TimeEntry::all(); $timeEntries = TimeEntry::all();
if ($secondRun) { if ($secondRun) {
@@ -197,7 +197,7 @@ class ImporterTestAbstract extends TestCase
$this->assertSame('Working hard', $timeEntry2->description); $this->assertSame('Working hard', $timeEntry2->description);
$this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString()); $this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString());
$this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString()); $this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString());
$this->assertTrue($timeEntry2->billable); $this->assertSame(! $billableDefault, $timeEntry2->billable);
$this->assertTrue($timeEntry2->is_imported); $this->assertTrue($timeEntry2->is_imported);
$this->assertSame([], $timeEntry2->tags); $this->assertSame([], $timeEntry2->tags);
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Service; namespace Tests\Unit\Service;
use App\Enums\TagMatchType;
use App\Models\Client; use App\Models\Client;
use App\Models\Project; use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
@@ -250,4 +251,188 @@ class TimeEntryFilterTest extends TestCaseWithDatabase
$this->assertTrue($timeEntries->contains($timeEntryWithNoTags)); $this->assertTrue($timeEntries->contains($timeEntryWithNoTags));
$this->assertFalse($timeEntries->contains($timeEntryWithTag2)); $this->assertFalse($timeEntries->contains($timeEntryWithTag2));
} }
public function test_add_tag_ids_filter_not_contains_includes_entries_without_matching_tag(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$timeEntryWithTag1 = TimeEntry::factory()->create([
'tags' => [$tag1->getKey()],
]);
$timeEntryWithTag2 = TimeEntry::factory()->create([
'tags' => [$tag2->getKey()],
]);
$timeEntryWithAllTags = TimeEntry::factory()->create([
'tags' => [$tag1->getKey(), $tag2->getKey()],
]);
$timeEntryWithEmptyTags = TimeEntry::factory()->create([
'tags' => [],
]);
$timeEntryWithNullTags = TimeEntry::factory()->create([
'tags' => null,
]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act
$filter->addTagIdsFilter([$tag1->getKey()], TagMatchType::NotContains);
// Assert
$timeEntries = $builder->get();
$this->assertCount(3, $timeEntries);
$this->assertFalse($timeEntries->contains($timeEntryWithTag1));
$this->assertTrue($timeEntries->contains($timeEntryWithTag2));
$this->assertFalse($timeEntries->contains($timeEntryWithAllTags));
$this->assertTrue($timeEntries->contains($timeEntryWithEmptyTags));
$this->assertTrue($timeEntries->contains($timeEntryWithNullTags));
}
public function test_add_tag_ids_filter_not_contains_with_none_excludes_entries_without_tags(): void
{
// Arrange
$tag = Tag::factory()->create();
$timeEntryWithTag = TimeEntry::factory()->create([
'tags' => [$tag->getKey()],
]);
$timeEntryWithEmptyTags = TimeEntry::factory()->create([
'tags' => [],
]);
$timeEntryWithNullTags = TimeEntry::factory()->create([
'tags' => null,
]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act
$filter->addTagIdsFilter([TimeEntryFilter::NONE_VALUE], TagMatchType::NotContains);
// Assert
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertTrue($timeEntries->contains($timeEntryWithTag));
$this->assertFalse($timeEntries->contains($timeEntryWithEmptyTags));
$this->assertFalse($timeEntries->contains($timeEntryWithNullTags));
}
public function test_add_tag_ids_filter_not_contains_with_multiple_tags_excludes_entries_with_any_of_them(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$tag3 = Tag::factory()->create();
$timeEntryWithTag1 = TimeEntry::factory()->create(['tags' => [$tag1->getKey()]]);
$timeEntryWithTag2 = TimeEntry::factory()->create(['tags' => [$tag2->getKey()]]);
$timeEntryWithTag3 = TimeEntry::factory()->create(['tags' => [$tag3->getKey()]]);
// a filtered tag (tag1) mixed with an unrelated one (tag3): still excluded
$timeEntryWithTag1AndTag3 = TimeEntry::factory()->create(['tags' => [$tag1->getKey(), $tag3->getKey()]]);
$timeEntryWithoutTags = TimeEntry::factory()->create(['tags' => null]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act: "does not contain tag1 or tag2" (NOT (has tag1 OR has tag2))
$filter->addTagIdsFilter([$tag1->getKey(), $tag2->getKey()], TagMatchType::NotContains);
// Assert: only entries that have neither tag1 nor tag2 remain
$timeEntries = $builder->get();
$this->assertCount(2, $timeEntries);
$this->assertFalse($timeEntries->contains($timeEntryWithTag1));
$this->assertFalse($timeEntries->contains($timeEntryWithTag2));
$this->assertTrue($timeEntries->contains($timeEntryWithTag3));
$this->assertFalse($timeEntries->contains($timeEntryWithTag1AndTag3));
$this->assertTrue($timeEntries->contains($timeEntryWithoutTags));
}
public function test_add_tag_ids_filter_contains_mode_returns_only_entries_with_tag(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$timeEntryWithTag1 = TimeEntry::factory()->create(['tags' => [$tag1->getKey()]]);
$timeEntryWithTag2 = TimeEntry::factory()->create(['tags' => [$tag2->getKey()]]);
$timeEntryWithEmptyTags = TimeEntry::factory()->create(['tags' => []]);
$timeEntryWithNullTags = TimeEntry::factory()->create(['tags' => null]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act: explicit contains mode
$filter->addTagIdsFilter([$tag1->getKey()], TagMatchType::Contains);
// Assert: only the entry that has tag1
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertTrue($timeEntries->contains($timeEntryWithTag1));
$this->assertFalse($timeEntries->contains($timeEntryWithTag2));
$this->assertFalse($timeEntries->contains($timeEntryWithEmptyTags));
$this->assertFalse($timeEntries->contains($timeEntryWithNullTags));
}
public function test_add_tag_ids_filter_not_contains_with_none_and_tag_excludes_tagged_and_untagged(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$timeEntryWithTag1 = TimeEntry::factory()->create(['tags' => [$tag1->getKey()]]);
$timeEntryWithTag2 = TimeEntry::factory()->create(['tags' => [$tag2->getKey()]]);
$timeEntryWithBothTags = TimeEntry::factory()->create(['tags' => [$tag1->getKey(), $tag2->getKey()]]);
$timeEntryWithEmptyTags = TimeEntry::factory()->create(['tags' => []]);
$timeEntryWithNullTags = TimeEntry::factory()->create(['tags' => null]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act: NOT (has tag1 OR has no tags) => has at least one tag and not tag1
$filter->addTagIdsFilter([$tag1->getKey(), TimeEntryFilter::NONE_VALUE], TagMatchType::NotContains);
// Assert
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertFalse($timeEntries->contains($timeEntryWithTag1));
$this->assertTrue($timeEntries->contains($timeEntryWithTag2));
$this->assertFalse($timeEntries->contains($timeEntryWithBothTags));
$this->assertFalse($timeEntries->contains($timeEntryWithEmptyTags));
$this->assertFalse($timeEntries->contains($timeEntryWithNullTags));
}
public function test_add_tag_ids_filter_with_empty_array_applies_no_filter(): void
{
// Arrange
$tag = Tag::factory()->create();
TimeEntry::factory()->create(['tags' => [$tag->getKey()]]);
TimeEntry::factory()->create(['tags' => []]);
TimeEntry::factory()->create(['tags' => null]);
// Act + Assert: an empty selection is no constraint in either mode
$builderNotContains = TimeEntry::query();
(new TimeEntryFilter($builderNotContains))->addTagIdsFilter([], TagMatchType::NotContains);
$this->assertCount(3, $builderNotContains->get());
$builderContains = TimeEntry::query();
(new TimeEntryFilter($builderContains))->addTagIdsFilter([], TagMatchType::Contains);
$this->assertCount(3, $builderContains->get());
}
public function test_add_tag_ids_filter_with_null_match_type_defaults_to_contains(): void
{
// Arrange
$tag = Tag::factory()->create();
$timeEntryWithTag = TimeEntry::factory()->create(['tags' => [$tag->getKey()]]);
$timeEntryWithoutTag = TimeEntry::factory()->create(['tags' => null]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act: a null match type falls back to "contains"
$filter->addTagIdsFilter([$tag->getKey()], null);
// Assert
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertTrue($timeEntries->contains($timeEntryWithTag));
$this->assertFalse($timeEntries->contains($timeEntryWithoutTag));
}
} }

View File

@@ -14,6 +14,7 @@ async function getConfig() {
const additionalPlugins = await collectModulePlugins('extensions'); const additionalPlugins = await collectModulePlugins('extensions');
return defineConfig({ return defineConfig({
base: './',
build: { build: {
sourcemap: true, // Source map generation must be turned on sourcemap: true, // Source map generation must be turned on
}, },