mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-09 08:42:15 +01:00
Compare commits
11 Commits
feature/fi
...
feature/fr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c3f7e2b67 | ||
|
|
65fbb43aa6 | ||
|
|
4a5ba9ff28 | ||
|
|
56c45adc1a | ||
|
|
fa8d350c4a | ||
|
|
27f5d4a200 | ||
|
|
c0f5baace1 | ||
|
|
fddc9abf05 | ||
|
|
2da0146651 | ||
|
|
1f7679145f | ||
|
|
7d9db18063 |
@@ -1,54 +0,0 @@
|
|||||||
.git
|
|
||||||
**/.git
|
|
||||||
.gitmodules
|
|
||||||
**/.gitmodules
|
|
||||||
.github
|
|
||||||
.DS_Store
|
|
||||||
.fleet
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
*.log
|
|
||||||
npm-debug.log
|
|
||||||
yarn-error.log
|
|
||||||
k8s
|
|
||||||
docs
|
|
||||||
e2e
|
|
||||||
tests
|
|
||||||
|
|
||||||
docker-compose.yml
|
|
||||||
docker/local
|
|
||||||
|
|
||||||
.phpunit.cache
|
|
||||||
.phpunit.result.cache
|
|
||||||
coverage
|
|
||||||
test-results
|
|
||||||
playwright-report
|
|
||||||
blob-report
|
|
||||||
playwright/.cache
|
|
||||||
openapi.json
|
|
||||||
playwright
|
|
||||||
playwright.config.ts
|
|
||||||
vitest.config.ts
|
|
||||||
phpunit.xml
|
|
||||||
phpstan.neon
|
|
||||||
pint.json
|
|
||||||
eslint.config.mjs
|
|
||||||
tsconfig.json
|
|
||||||
jsconfig.json
|
|
||||||
postcss.config.js
|
|
||||||
tailwind.config.js
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
extensions/*/node_modules
|
|
||||||
|
|
||||||
Homestead.json
|
|
||||||
Homestead.yaml
|
|
||||||
auth.json
|
|
||||||
.env.backup
|
|
||||||
.rnd
|
|
||||||
|
|
||||||
_ide_helper.php
|
|
||||||
.phpstorm.meta.php
|
|
||||||
|
|
||||||
storage/logs/*
|
|
||||||
storage/*.key
|
|
||||||
19
.github/workflows/build-onpremise.yml
vendored
19
.github/workflows/build-onpremise.yml
vendored
@@ -8,8 +8,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-onpremise.yml'
|
- '.github/workflows/build-onpremise.yml'
|
||||||
- '.dockerignore'
|
|
||||||
- 'extensions/manifest.json'
|
|
||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -37,7 +35,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
@@ -93,23 +91,14 @@ jobs:
|
|||||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
- name: "Read extension manifest"
|
|
||||||
id: extension-manifest
|
|
||||||
run: |
|
|
||||||
{
|
|
||||||
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
|
||||||
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: "Checkout invoicing extension"
|
- name: "Checkout invoicing extension"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
|
repository: solidtime-io/extension-invoicing
|
||||||
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
|
|
||||||
path: extensions/Invoicing
|
path: extensions/Invoicing
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||||
|
|
||||||
|
|||||||
33
.github/workflows/build-private.yml
vendored
33
.github/workflows/build-private.yml
vendored
@@ -8,8 +8,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-private.yml'
|
- '.github/workflows/build-private.yml'
|
||||||
- '.dockerignore'
|
|
||||||
- 'extensions/manifest.json'
|
|
||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
permissions:
|
permissions:
|
||||||
@@ -24,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
@@ -70,27 +68,14 @@ jobs:
|
|||||||
run: cat .env
|
run: cat .env
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
- name: "Read extension manifest"
|
|
||||||
id: extension-manifest
|
|
||||||
run: |
|
|
||||||
{
|
|
||||||
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
|
|
||||||
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
|
|
||||||
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
|
|
||||||
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
|
|
||||||
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
|
||||||
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: "Checkout billing extension"
|
- name: "Checkout billing extension"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
|
repository: solidtime-io/extension-billing
|
||||||
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
|
|
||||||
path: extensions/Billing
|
path: extensions/Billing
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
|
||||||
|
|
||||||
@@ -108,10 +93,9 @@ jobs:
|
|||||||
run: cd extensions/Billing && npm ci
|
run: cd extensions/Billing && npm ci
|
||||||
|
|
||||||
- name: "Checkout services extension"
|
- name: "Checkout services extension"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
repository: ${{ steps.extension-manifest.outputs.services_repository }}
|
repository: solidtime-io/extension-services
|
||||||
ref: ${{ steps.extension-manifest.outputs.services_ref }}
|
|
||||||
path: extensions/Services
|
path: extensions/Services
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
|
||||||
|
|
||||||
@@ -127,10 +111,9 @@ jobs:
|
|||||||
run: cd extensions/Services && npm ci
|
run: cd extensions/Services && npm ci
|
||||||
|
|
||||||
- name: "Checkout invoicing extension"
|
- name: "Checkout invoicing extension"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
|
repository: solidtime-io/extension-invoicing
|
||||||
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
|
|
||||||
path: extensions/Invoicing
|
path: extensions/Invoicing
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||||
|
|
||||||
|
|||||||
5
.github/workflows/build-public.yml
vendored
5
.github/workflows/build-public.yml
vendored
@@ -8,7 +8,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-public.yml'
|
- '.github/workflows/build-public.yml'
|
||||||
- '.dockerignore'
|
|
||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
@@ -93,7 +92,7 @@ jobs:
|
|||||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/generate-api-docs.yml
vendored
2
.github/workflows/generate-api-docs.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
|
|||||||
4
.github/workflows/npm-build.yml
vendored
4
.github/workflows/npm-build.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: "Setup PHP (for Ziggy)"
|
- name: "Setup PHP (for Ziggy)"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-format-check.yml
vendored
4
.github/workflows/npm-format-check.yml
vendored
@@ -9,10 +9,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-lint.yml
vendored
4
.github/workflows/npm-lint.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-publish-api.yml
vendored
4
.github/workflows/npm-publish-api.yml
vendored
@@ -11,11 +11,11 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- name: Install root project dependencies
|
- name: Install root project dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
4
.github/workflows/npm-publish-ui.yml
vendored
4
.github/workflows/npm-publish-ui.yml
vendored
@@ -11,9 +11,9 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
4
.github/workflows/npm-test-unit.yml
vendored
4
.github/workflows/npm-test-unit.yml
vendored
@@ -13,10 +13,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-typecheck.yml
vendored
4
.github/workflows/npm-typecheck.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: "Setup PHP (for Ziggy)"
|
- name: "Setup PHP (for Ziggy)"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/phpstan.yml
vendored
2
.github/workflows/phpstan.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
|
|||||||
4
.github/workflows/phpunit.yml
vendored
4
.github/workflows/phpunit.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
|||||||
--health-retries 5
|
--health-retries 5
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
- name: "Run composer install"
|
- name: "Run composer install"
|
||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/pint.yml
vendored
2
.github/workflows/pint.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: "Check code style"
|
- name: "Check code style"
|
||||||
uses: aglipanci/laravel-pint-action@2.6
|
uses: aglipanci/laravel-pint-action@2.6
|
||||||
|
|||||||
8
.github/workflows/playwright.yml
vendored
8
.github/workflows/playwright.yml
vendored
@@ -35,10 +35,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: "Setup node"
|
- name: "Setup node"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -99,10 +99,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: "Setup node"
|
- name: "Setup node"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,10 +26,9 @@ yarn-error.log
|
|||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/coverage
|
/coverage
|
||||||
/extensions/*
|
/extensions
|
||||||
!/extensions/.gitkeep
|
!/extensions/.gitkeep
|
||||||
!/extensions/extensions_autoload.php
|
!/extensions/extensions_autoload.php
|
||||||
!/extensions/manifest.json
|
|
||||||
/auth.json
|
/auth.json
|
||||||
/modules_statuses.json
|
/modules_statuses.json
|
||||||
/k8s
|
/k8s
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
|
||||||
|
|
||||||
enum TagMatchType: string
|
|
||||||
{
|
|
||||||
use LaravelEnumHelper;
|
|
||||||
|
|
||||||
case Contains = 'contains';
|
|
||||||
|
|
||||||
case NotContains = 'not_contains';
|
|
||||||
}
|
|
||||||
@@ -66,7 +66,7 @@ class UserResource extends Resource
|
|||||||
->ignore($record?->getKey()),
|
->ignore($record?->getKey()),
|
||||||
])
|
])
|
||||||
->rule([
|
->rule([
|
||||||
'email:rfc,strict',
|
'email',
|
||||||
])
|
])
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
Forms\Components\Toggle::make('is_placeholder')
|
Forms\Components\Toggle::make('is_placeholder')
|
||||||
|
|||||||
@@ -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(), $properties->tagMatchType);
|
$filter->addTagIdsFilter($properties->tagIds?->toArray());
|
||||||
$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();
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ 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;
|
||||||
|
|||||||
@@ -204,7 +204,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'), $request->getTagMatchType());
|
$filter->addTagIdsFilter($request->input('tag_ids'));
|
||||||
$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'));
|
||||||
@@ -560,7 +560,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'), $request->getTagMatchType());
|
$filter->addTagIdsFilter($request->input('tag_ids'));
|
||||||
$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'));
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class InvitationStoreRequest extends BaseFormRequest
|
|||||||
return [
|
return [
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email:rfc,strict',
|
'email',
|
||||||
],
|
],
|
||||||
'role' => [
|
'role' => [
|
||||||
'required',
|
'required',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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;
|
||||||
@@ -125,11 +124,6 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'properties.tag_match_type' => [
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
Rule::enum(TagMatchType::class),
|
|
||||||
],
|
|
||||||
'properties.task_ids' => [
|
'properties.task_ids' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'array',
|
'array',
|
||||||
@@ -255,15 +249,6 @@ 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) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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;
|
||||||
@@ -140,10 +139,6 @@ 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',
|
||||||
@@ -251,15 +246,6 @@ 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) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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;
|
||||||
@@ -126,10 +125,6 @@ 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',
|
||||||
@@ -213,15 +208,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
|
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) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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;
|
||||||
@@ -111,10 +110,6 @@ 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',
|
||||||
@@ -220,15 +215,6 @@ 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) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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;
|
||||||
@@ -104,10 +103,6 @@ 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',
|
||||||
@@ -195,15 +190,6 @@ 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) {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class UserUpdateRequest extends BaseFormRequest
|
|||||||
'max:255',
|
'max:255',
|
||||||
],
|
],
|
||||||
'email' => [
|
'email' => [
|
||||||
'email:rfc,strict',
|
'email',
|
||||||
'max:255',
|
'max:255',
|
||||||
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
|
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
|
||||||
/** @var Builder<User> $query */
|
/** @var Builder<User> $query */
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ 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 */
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ class RecalculateSpentTimeForProject implements ShouldDispatchAfterCommit, Shoul
|
|||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the job if its models no longer exist.
|
|
||||||
*/
|
|
||||||
public bool $deleteWhenMissingModels = true;
|
|
||||||
|
|
||||||
public Project $project;
|
public Project $project;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ class RecalculateSpentTimeForTask implements ShouldDispatchAfterCommit, ShouldQu
|
|||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the job if its models no longer exist.
|
|
||||||
*/
|
|
||||||
public bool $deleteWhenMissingModels = true;
|
|
||||||
|
|
||||||
public Task $task;
|
public Task $task;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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;
|
||||||
@@ -57,8 +56,6 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -118,7 +115,6 @@ 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);
|
||||||
@@ -148,7 +144,6 @@ 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,
|
||||||
@@ -221,11 +216,6 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
$records = $reader->getRecords();
|
$records = $reader->getRecords();
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
$clientId = null;
|
$clientId = null;
|
||||||
// Newer Clockify exports no longer contain a "Client" column.
|
if ($record['Client'] !== '') {
|
||||||
if (($record['Client'] ?? '') !== '') {
|
|
||||||
$clientId = $this->clientImportHelper->getKey([
|
$clientId = $this->clientImportHelper->getKey([
|
||||||
'name' => $record['Client'],
|
'name' => $record['Client'],
|
||||||
'organization_id' => $this->organization->id,
|
'organization_id' => $this->organization->id,
|
||||||
@@ -46,7 +45,7 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
'color' => $this->colorService->getRandomColor(),
|
'color' => $this->colorService->getRandomColor(),
|
||||||
'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' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
|
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
|
||||||
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
|
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -81,6 +80,7 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
{
|
{
|
||||||
$requiredFields = [
|
$requiredFields = [
|
||||||
'Project',
|
'Project',
|
||||||
|
'Client',
|
||||||
'Status',
|
'Status',
|
||||||
'Visibility',
|
'Visibility',
|
||||||
'Billability',
|
'Billability',
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
]);
|
]);
|
||||||
$member = $this->memberImportHelper->getModelById($memberId);
|
$member = $this->memberImportHelper->getModelById($memberId);
|
||||||
$clientId = null;
|
$clientId = null;
|
||||||
if (($record['Client'] ?? '') !== '') {
|
if ($record['Client'] !== '') {
|
||||||
$clientId = $this->clientImportHelper->getKey([
|
$clientId = $this->clientImportHelper->getKey([
|
||||||
'name' => $record['Client'],
|
'name' => $record['Client'],
|
||||||
'organization_id' => $this->organization->id,
|
'organization_id' => $this->organization->id,
|
||||||
@@ -215,6 +215,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
{
|
{
|
||||||
$requiredFields = [
|
$requiredFields = [
|
||||||
'Project',
|
'Project',
|
||||||
|
'Client',
|
||||||
'Description',
|
'Description',
|
||||||
'User',
|
'User',
|
||||||
'Group',
|
'Group',
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
}, validate: [
|
}, validate: [
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email:rfc,strict',
|
'email',
|
||||||
'max:255',
|
'max:255',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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;
|
||||||
@@ -193,21 +192,15 @@ class TimeEntryFilter
|
|||||||
/**
|
/**
|
||||||
* @param array<string>|null $tagIds
|
* @param array<string>|null $tagIds
|
||||||
*/
|
*/
|
||||||
public function addTagIdsFilter(?array $tagIds, ?TagMatchType $tagMatchType = TagMatchType::Contains): self
|
public function addTagIdsFilter(?array $tagIds): 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tagCondition = function (Builder $builder) use ($tagIds, $includeNone): void {
|
$this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {
|
||||||
foreach ($tagIds as $tagId) {
|
foreach ($tagIds as $tagId) {
|
||||||
$builder->orWhereJsonContains('tags', $tagId);
|
$builder->orWhereJsonContains('tags', $tagId);
|
||||||
}
|
}
|
||||||
@@ -216,18 +209,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,9 +189,7 @@ ENV WITH_HORIZON=false \
|
|||||||
WITH_SCHEDULER=false \
|
WITH_SCHEDULER=false \
|
||||||
WITH_REVERB=false
|
WITH_REVERB=false
|
||||||
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} . ./
|
COPY --link --chown=${WWWUSER}:${WWWUSER} . .
|
||||||
|
|
||||||
RUN test -z "$(find . -name .git -print -quit)"
|
|
||||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
||||||
|
|
||||||
RUN mkdir -p \
|
RUN mkdir -p \
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { expect } from '@playwright/test';
|
|
||||||
import { test } from '../playwright/fixtures';
|
|
||||||
import { goToReportingDetailed, waitForDetailedReportingUpdate } from './utils/reporting';
|
|
||||||
import { createTimeEntryWithTagViaApi } from './utils/api';
|
|
||||||
|
|
||||||
// Each test registers a new user and creates test data via the API
|
|
||||||
test.describe.configure({ timeout: 30000 });
|
|
||||||
|
|
||||||
test('detailed reporting: "Does Not Contain" excludes entries with the selected tag', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
const tagA = 'MatchTagA ' + Math.floor(Math.random() * 10000);
|
|
||||||
const tagB = 'MatchTagB ' + Math.floor(Math.random() * 10000);
|
|
||||||
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
|
|
||||||
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
|
|
||||||
|
|
||||||
await goToReportingDetailed(page);
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
|
|
||||||
|
|
||||||
// Open the Tags dropdown, select tagA, then switch the match mode to "Does Not Contain"
|
|
||||||
await page.getByRole('button', { name: 'Tags' }).click();
|
|
||||||
await Promise.all([
|
|
||||||
waitForDetailedReportingUpdate(page),
|
|
||||||
page.getByRole('option').filter({ hasText: tagA }).click(),
|
|
||||||
]);
|
|
||||||
await Promise.all([
|
|
||||||
waitForDetailedReportingUpdate(page),
|
|
||||||
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
|
|
||||||
]);
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// The entry with tagA is excluded; the entry with tagB remains
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0);
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('detailed reporting: toggling between "Contains" and "Does Not Contain" flips the result', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
const tagA = 'ToggleTagA ' + Math.floor(Math.random() * 10000);
|
|
||||||
const tagB = 'ToggleTagB ' + Math.floor(Math.random() * 10000);
|
|
||||||
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
|
|
||||||
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
|
|
||||||
|
|
||||||
await goToReportingDetailed(page);
|
|
||||||
await page.getByRole('button', { name: 'Tags' }).click();
|
|
||||||
await Promise.all([
|
|
||||||
waitForDetailedReportingUpdate(page),
|
|
||||||
page.getByRole('option').filter({ hasText: tagA }).click(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// "Contains" tagA -> only the tagA entry is listed
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagB}`)).toHaveCount(0);
|
|
||||||
|
|
||||||
// "Does Not Contain" tagA -> flips to the tagB entry
|
|
||||||
await page.getByRole('button', { name: 'Tags' }).click();
|
|
||||||
await Promise.all([
|
|
||||||
waitForDetailedReportingUpdate(page),
|
|
||||||
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
|
|
||||||
]);
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0);
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"Billing": {
|
|
||||||
"repository": "solidtime-io/extension-billing",
|
|
||||||
"ref": "v0.0.1"
|
|
||||||
},
|
|
||||||
"Services": {
|
|
||||||
"repository": "solidtime-io/extension-services",
|
|
||||||
"ref": "v0.0.1"
|
|
||||||
},
|
|
||||||
"Invoicing": {
|
|
||||||
"repository": "solidtime-io/extension-invoicing",
|
|
||||||
"ref": "v0.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
<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';
|
||||||
@@ -16,7 +14,6 @@ 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';
|
||||||
|
|
||||||
@@ -25,7 +22,6 @@ 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 });
|
||||||
@@ -39,16 +35,6 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -107,34 +93,6 @@ 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')">
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ 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';
|
||||||
|
|
||||||
@@ -68,7 +67,6 @@ 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);
|
||||||
@@ -124,7 +122,6 @@ 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,
|
||||||
@@ -369,7 +366,6 @@ 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"
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ 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';
|
||||||
@@ -72,7 +71,6 @@ 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');
|
||||||
@@ -104,7 +102,6 @@ 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,
|
||||||
@@ -327,7 +324,6 @@ 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"
|
||||||
|
|||||||
@@ -448,7 +448,6 @@ 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,
|
||||||
@@ -482,7 +481,6 @@ 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()]),
|
||||||
@@ -3786,11 +3784,6 @@ Users with the permission `time-entries:view:own` 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',
|
||||||
@@ -4172,11 +4165,6 @@ If the group parameters are all set to `null` 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',
|
||||||
@@ -4371,11 +4359,6 @@ If the group parameters are all set to `null` 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',
|
||||||
@@ -4504,11 +4487,6 @@ If the group parameters are all set to `null` 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',
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ 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"
|
||||||
|
|||||||
@@ -527,11 +527,6 @@ const selectedProjectColor = computed(() => {
|
|||||||
return currentProject.value?.color || 'var(--theme-color-icon-default)';
|
return currentProject.value?.color || 'var(--theme-color-icon-default)';
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedClientName = computed(() => {
|
|
||||||
const clientId = currentProject.value?.client_id;
|
|
||||||
return clientId ? clientsById.value.get(clientId)?.name : undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
// This state prevents the selection to jump to random items when the mouse cursor is
|
// This state prevents the selection to jump to random items when the mouse cursor is
|
||||||
// over an item and some Item in the Dropdown is selected by keyboard navigation to scroll into view
|
// over an item and some Item in the Dropdown is selected by keyboard navigation to scroll into view
|
||||||
const mouseEnterHighlightActivated = ref(true);
|
const mouseEnterHighlightActivated = ref(true);
|
||||||
@@ -577,24 +572,15 @@ const showCreateProject = ref(false);
|
|||||||
:size="props.size"
|
:size="props.size"
|
||||||
:class="twMerge('w-full justify-start overflow-hidden', props.class)">
|
:class="twMerge('w-full justify-start overflow-hidden', props.class)">
|
||||||
<div
|
<div
|
||||||
class="w-2.5 h-2.5 rounded-full shrink-0"
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
:style="{ backgroundColor: selectedProjectColor }"></div>
|
:style="{ backgroundColor: selectedProjectColor }"></div>
|
||||||
<span class="truncate shrink-[1] text-text-primary">{{
|
<span class="truncate shrink-[1] text-text-primary pr-1">{{
|
||||||
selectedProjectName
|
selectedProjectName
|
||||||
}}</span>
|
}}</span>
|
||||||
<template v-if="currentTask">
|
<template v-if="currentTask">
|
||||||
<ChevronRightIcon class="!size-3 text-text-primary shrink-0 -mx-1" />
|
<ChevronRightIcon class="w-4 h-4 text-text-tertiary shrink-0" />
|
||||||
<span class="truncate shrink-[100]">{{ currentTask.name }}</span>
|
<span class="truncate shrink-[100]">{{ currentTask.name }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="selectedClientName">
|
|
||||||
<span class="dark:text-text-tertiary text-text-quaternary shrink-0"
|
|
||||||
>•</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="truncate shrink-[200] dark:text-text-tertiary text-text-quaternary"
|
|
||||||
>{{ selectedClientName }}</span
|
|
||||||
>
|
|
||||||
</template>
|
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
v-if="allowReset && project !== null"
|
v-if="allowReset && project !== null"
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export type ExportFormat = 'xlsx' | 'csv' | 'ods' | 'pdf';
|
export type ExportFormat = 'xlsx' | 'csv' | 'ods' | 'pdf';
|
||||||
export type TagMatchType = 'contains' | 'not_contains';
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
"Project","Status","Visibility","Billability","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Progress(%)","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Project members","Project manager","Note"
|
|
||||||
"Project Without Client Column","Active","Public","Yes","Task 1, Task 2","0.00","100.00","","","","0.00","0.00","100.01","0.00","Constantin Graf","",""
|
|
||||||
|
@@ -1,3 +0,0 @@
|
|||||||
"Project","Description","Task","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
|
|
||||||
"Project A","","","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 B","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,2 +0,0 @@
|
|||||||
"Project","Description","Task","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Client"
|
|
||||||
"Project A","Working hard","Task 1","Peter Tester","","peter.test@email.test","","Yes","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM"
|
|
||||||
|
@@ -5,7 +5,7 @@
|
|||||||
{{ __('Your currently running time entry ":description" is now running for more than 8 hours!', ['description' => $timeEntry->description]) }}
|
{{ __('Your currently running time entry ":description" is now running for more than 8 hours!', ['description' => $timeEntry->description]) }}
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{ __('If you forgot to stop the Time Tracker, you can do so in solidtime:') }}
|
{{ __('If you forgot to stop the Time Tracker you do that in solidtime:') }}
|
||||||
|
|
||||||
@component('mail::button', ['url' => $dashboardUrl])
|
@component('mail::button', ['url' => $dashboardUrl])
|
||||||
{{ __('Go to solidtime') }}
|
{{ __('Go to solidtime') }}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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;
|
||||||
@@ -668,58 +667,4 @@ 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,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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;
|
||||||
@@ -686,64 +685,4 @@ 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']);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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;
|
||||||
@@ -4406,153 +4405,4 @@ 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,29 +96,6 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_import_of_test_file_without_client_column_succeeds(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$importer = new ClockifyProjectsImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
// Newer Clockify exports no longer contain a "Client" column.
|
|
||||||
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_4.csv');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$project = Project::query()->where('organization_id', $organization->id)->where('name', 'Project Without Client Column')->firstOrFail();
|
|
||||||
$this->assertNull($project->client_id);
|
|
||||||
$this->assertSame(100 * 3600, $project->estimated_time);
|
|
||||||
$this->assertEqualsCanonicalizing(
|
|
||||||
['Task 1', 'Task 2'],
|
|
||||||
Task::query()->where('project_id', $project->id)->pluck('name')->all(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_import_supports_activities_column_alias_for_tasks(): void
|
public function test_import_supports_activities_column_alias_for_tasks(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -136,46 +136,6 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
|||||||
$this->assertSame(1, $report->tasksCreated);
|
$this->assertSame(1, $report->tasksCreated);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_import_of_test_file_without_client_column_succeeds(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$importer = new ClockifyTimeEntriesImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
// Newer Clockify exports no longer contain a "Client" column.
|
|
||||||
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_6.csv');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
$report = $importer->getReport();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertSame(2, $report->timeEntriesCreated);
|
|
||||||
$this->assertSame(2, $report->projectsCreated);
|
|
||||||
$this->assertSame(0, $report->clientsCreated);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_import_of_test_file_with_client_column_but_missing_values_succeeds(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$importer = new ClockifyTimeEntriesImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
// Rows shorter than the header are padded with null by the CSV reader.
|
|
||||||
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_7.csv');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
$report = $importer->getReport();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertSame(1, $report->timeEntriesCreated);
|
|
||||||
$this->assertSame(1, $report->projectsCreated);
|
|
||||||
$this->assertSame(0, $report->clientsCreated);
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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;
|
||||||
@@ -251,188 +250,4 @@ 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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user