mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-18 13:12:16 +01:00
Compare commits
1 Commits
feature/bi
...
0591b51d52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0591b51d52 |
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$query = TimeEntry::query()
|
$query = TimeEntry::query()
|
||||||
->where('organization_id', $organization->getKey())
|
->where('organization_id', $organization->getKey())
|
||||||
->where('member_id', $member->getKey())
|
->where('user_id', $member->user_id)
|
||||||
->when($exclude !== null, function (Builder $q) use ($exclude): void {
|
->when($exclude !== null, function (Builder $q) use ($exclude): void {
|
||||||
$q->where('id', '!=', $exclude->getKey());
|
$q->where('id', '!=', $exclude->getKey());
|
||||||
})
|
})
|
||||||
@@ -107,8 +107,8 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get time entries in organization
|
* Get time entries in organization
|
||||||
*
|
*
|
||||||
* If you only need time entries for a specific user, you can filter by `member_id`.
|
* If you only need time entries for a specific user, you can filter by `user_id`.
|
||||||
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
|
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
|
||||||
*
|
*
|
||||||
* @return TimeEntryCollection<TimeEntryResource>
|
* @return TimeEntryCollection<TimeEntryResource>
|
||||||
*
|
*
|
||||||
@@ -118,17 +118,16 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
|
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
/** @var Member|null $member */
|
||||||
/** @var Member|null $memberFilter */
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
if ($member !== null && $member->user_id === Auth::id()) {
|
||||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
|
||||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||||
|
|
||||||
$totalCount = $timeEntriesQuery->count();
|
$totalCount = $timeEntriesQuery->count();
|
||||||
|
|
||||||
@@ -159,7 +158,7 @@ class TimeEntryController extends Controller
|
|||||||
if ($timeEntries->count() === 0) {
|
if ($timeEntries->count() === 0) {
|
||||||
Log::warning('User has has more than '.$limit.' time entries on one date', [
|
Log::warning('User has has more than '.$limit.' time entries on one date', [
|
||||||
'date' => $lastDate->toDateString(),
|
'date' => $lastDate->toDateString(),
|
||||||
'member_id' => $request->input('member_id'),
|
'user_id' => $request->input('user_id'),
|
||||||
'auth_user_id' => Auth::id(),
|
'auth_user_id' => Auth::id(),
|
||||||
'limit' => $limit,
|
'limit' => $limit,
|
||||||
]);
|
]);
|
||||||
@@ -222,10 +221,9 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
/** @var Member|null $member */
|
||||||
/** @var Member|null $memberFilter */
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
if ($member !== null && $member->user_id === Auth::id()) {
|
||||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
@@ -242,7 +240,7 @@ class TimeEntryController extends Controller
|
|||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||||
$timeEntriesQuery->with([
|
$timeEntriesQuery->with([
|
||||||
'task',
|
'task',
|
||||||
'client',
|
'client',
|
||||||
@@ -265,7 +263,7 @@ class TimeEntryController extends Controller
|
|||||||
if ($viewFile === false) {
|
if ($viewFile === false) {
|
||||||
throw new \LogicException('View file not found');
|
throw new \LogicException('View file not found');
|
||||||
}
|
}
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesAggregateQuery,
|
$timeEntriesAggregateQuery,
|
||||||
null,
|
null,
|
||||||
@@ -372,10 +370,9 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
|
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
/** @var Member|null $member */
|
||||||
/** @var Member|null $memberFilter */
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
if ($member !== null && $member->user_id === Auth::id()) {
|
||||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
@@ -386,7 +383,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$group1Type = $request->getGroup();
|
$group1Type = $request->getGroup();
|
||||||
$group2Type = $request->getSubGroup();
|
$group2Type = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
@@ -422,10 +419,9 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
/** @var Member|null $member */
|
||||||
/** @var Member|null $memberFilter */
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
if ($member !== null && $member->user_id === Auth::id()) {
|
||||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
@@ -441,7 +437,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$group = $request->getGroup();
|
$group = $request->getGroup();
|
||||||
$subGroup = $request->getSubGroup();
|
$subGroup = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
@@ -584,7 +580,7 @@ class TimeEntryController extends Controller
|
|||||||
{
|
{
|
||||||
/** @var Member $member */
|
/** @var Member $member */
|
||||||
$member = Member::query()->findOrFail($request->input('member_id'));
|
$member = Member::query()->findOrFail($request->input('member_id'));
|
||||||
if ($member->getKey() === $this->member($organization)->getKey()) {
|
if ($member->user_id === Auth::id()) {
|
||||||
$this->checkPermission($organization, 'time-entries:create:own');
|
$this->checkPermission($organization, 'time-entries:create:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:create:all');
|
$this->checkPermission($organization, 'time-entries:create:all');
|
||||||
@@ -631,10 +627,9 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
|
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
/** @var Member|null $member */
|
||||||
/** @var Member|null $newMember */
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
$newMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
|
||||||
if ($timeEntry->member_id === $member->getKey() && ($newMember === null || $newMember->getKey() === $member->getKey())) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
||||||
@@ -666,10 +661,6 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$timeEntry->fill($request->validated());
|
$timeEntry->fill($request->validated());
|
||||||
if ($newMember !== null) {
|
|
||||||
$timeEntry->member()->associate($newMember);
|
|
||||||
$timeEntry->user()->associate($newMember->user);
|
|
||||||
}
|
|
||||||
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
||||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||||
$timeEntry->save();
|
$timeEntry->save();
|
||||||
@@ -699,7 +690,6 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
|
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
|
||||||
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
|
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
|
||||||
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
|
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
|
||||||
|
|
||||||
@@ -724,9 +714,6 @@ class TimeEntryController extends Controller
|
|||||||
throw new AuthorizationException;
|
throw new AuthorizationException;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Member|null $newMember */
|
|
||||||
$newMember = isset($changes['member_id']) ? Member::query()->findOrFail($changes['member_id']) : null;
|
|
||||||
|
|
||||||
$project = null;
|
$project = null;
|
||||||
$client = null;
|
$client = null;
|
||||||
$overwriteClient = false;
|
$overwriteClient = false;
|
||||||
@@ -753,7 +740,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (! $canAccessAll && $timeEntry->member_id !== $member->getKey()) {
|
if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) {
|
||||||
$error->push($id);
|
$error->push($id);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -763,10 +750,6 @@ class TimeEntryController extends Controller
|
|||||||
$oldTask = $timeEntry->task;
|
$oldTask = $timeEntry->task;
|
||||||
|
|
||||||
$timeEntry->fill($changes);
|
$timeEntry->fill($changes);
|
||||||
if ($newMember !== null) {
|
|
||||||
$timeEntry->member()->associate($newMember);
|
|
||||||
$timeEntry->user_id = $newMember->user_id;
|
|
||||||
}
|
|
||||||
// If project is changed, but task is not, we remove the old task from the time entry
|
// If project is changed, but task is not, we remove the old task from the time entry
|
||||||
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
|
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
|
||||||
$timeEntry->task()->disassociate();
|
$timeEntry->task()->disassociate();
|
||||||
@@ -807,8 +790,7 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
|
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
if ($timeEntry->member->user_id === Auth::id()) {
|
||||||
if ($timeEntry->member_id === $member->getKey()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
|
||||||
@@ -865,7 +847,7 @@ class TimeEntryController extends Controller
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $canDeleteAll && $timeEntry->member_id !== $this->member($organization)->getKey()) {
|
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
|
||||||
$error->push($id);
|
$error->push($id);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class InvitationStoreRequest extends BaseFormRequest
|
|||||||
return [
|
return [
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email:rfc,strict',
|
'email',
|
||||||
],
|
],
|
||||||
'role' => [
|
'role' => [
|
||||||
'required',
|
'required',
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -80,10 +80,6 @@ class PermissionStore
|
|||||||
'invoices:update',
|
'invoices:update',
|
||||||
'invoices:download',
|
'invoices:download',
|
||||||
'invoices:delete',
|
'invoices:delete',
|
||||||
'invoice-recipients:view',
|
|
||||||
'invoice-recipients:create',
|
|
||||||
'invoice-recipients:update',
|
|
||||||
'invoice-recipients:delete',
|
|
||||||
'invoice-settings:view',
|
'invoice-settings:view',
|
||||||
'invoice-settings:update',
|
'invoice-settings:update',
|
||||||
],
|
],
|
||||||
@@ -151,10 +147,6 @@ class PermissionStore
|
|||||||
'invoices:update',
|
'invoices:update',
|
||||||
'invoices:download',
|
'invoices:download',
|
||||||
'invoices:delete',
|
'invoices:delete',
|
||||||
'invoice-recipients:view',
|
|
||||||
'invoice-recipients:create',
|
|
||||||
'invoice-recipients:update',
|
|
||||||
'invoice-recipients:delete',
|
|
||||||
'invoice-settings:view',
|
'invoice-settings:view',
|
||||||
'invoice-settings:update',
|
'invoice-settings:update',
|
||||||
],
|
],
|
||||||
@@ -211,10 +203,6 @@ class PermissionStore
|
|||||||
'invoices:update',
|
'invoices:update',
|
||||||
'invoices:download',
|
'invoices:download',
|
||||||
'invoices:delete',
|
'invoices:delete',
|
||||||
'invoice-recipients:view',
|
|
||||||
'invoice-recipients:create',
|
|
||||||
'invoice-recipients:update',
|
|
||||||
'invoice-recipients:delete',
|
|
||||||
'invoice-settings:view',
|
'invoice-settings:view',
|
||||||
'invoice-settings:update',
|
'invoice-settings:update',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,14 +0,0 @@
|
|||||||
{
|
|
||||||
"Billing": {
|
|
||||||
"repository": "solidtime-io/extension-billing",
|
|
||||||
"ref": "v0.0.4"
|
|
||||||
},
|
|
||||||
"Services": {
|
|
||||||
"repository": "solidtime-io/extension-services",
|
|
||||||
"ref": "v0.0.1"
|
|
||||||
},
|
|
||||||
"Invoicing": {
|
|
||||||
"repository": "solidtime-io/extension-invoicing",
|
|
||||||
"ref": "feature/recipients"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
4369
package-lock.json
generated
4369
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -26,13 +26,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^10.0.1",
|
||||||
"@inertiajs/vue3": "^2.3.23",
|
"@inertiajs/vue3": "^3.5.0",
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/chroma-js": "^3.1.2",
|
"@types/chroma-js": "^3.1.2",
|
||||||
"@types/node": "^22.19.19",
|
"@types/node": "^26.1.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
@@ -40,14 +40,14 @@
|
|||||||
"axios": "^1.16.0",
|
"axios": "^1.16.0",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
"happy-dom": "^20.8.9",
|
"happy-dom": "^20.8.9",
|
||||||
"laravel-vite-plugin": "^2.1.0",
|
"laravel-vite-plugin": "^3.1.0",
|
||||||
"openapi-zod-client": "^1.18.3",
|
"openapi-zod-client": "^1.18.3",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.14",
|
||||||
"postcss-import": "^15.1.0",
|
"postcss-import": "^16.1.1",
|
||||||
"postcss-nesting": "^12.1.5",
|
"postcss-nesting": "^14.0.0",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^4.3.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^7.3.3",
|
"vite": "^8.1.3",
|
||||||
"vite-plugin-checker": "^0.12.0",
|
"vite-plugin-checker": "^0.12.0",
|
||||||
"vitest": "^4.1.4",
|
"vitest": "^4.1.4",
|
||||||
"vue": "^3.5.34",
|
"vue": "^3.5.34",
|
||||||
@@ -55,19 +55,19 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.7.5",
|
"@floating-ui/core": "^1.7.5",
|
||||||
"@floating-ui/vue": "^1.1.11",
|
"@floating-ui/vue": "^2.0.0",
|
||||||
"@heroicons/vue": "^2.2.0",
|
"@heroicons/vue": "^2.2.0",
|
||||||
"@lucide/vue": "^1.28.0",
|
"@lucide/vue": "^1.14.0",
|
||||||
"@rushstack/eslint-patch": "^1.16.1",
|
"@rushstack/eslint-patch": "^1.16.1",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tanstack/vue-form": "^1.32.0",
|
"@tanstack/vue-form": "^1.32.0",
|
||||||
"@tanstack/vue-query": "^5.100.10",
|
"@tanstack/vue-query": "^5.100.10",
|
||||||
"@tanstack/vue-query-devtools": "^5.91.0",
|
"@tanstack/vue-query-devtools": "^6.1.36",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@tanstack/vue-virtual": "^3.13.24",
|
"@tanstack/vue-virtual": "^3.13.24",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
"@vueuse/core": "^14.4.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
"@vueuse/integrations": "^14.3.0",
|
"@vueuse/integrations": "^14.3.0",
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
"chroma-js": "^3.2.0",
|
"chroma-js": "^3.2.0",
|
||||||
@@ -79,8 +79,8 @@
|
|||||||
"parse-duration": "^2.1.6",
|
"parse-duration": "^2.1.6",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"radix-vue": "^1.9.17",
|
"radix-vue": "^1.9.17",
|
||||||
"reka-ui": "^2.10.1",
|
"reka-ui": "^2.9.7",
|
||||||
"tailwind-merge": "^2.6.1",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vue-draggable-plus": "^0.6.1",
|
"vue-draggable-plus": "^0.6.1",
|
||||||
"vue-echarts": "^8.0.1",
|
"vue-echarts": "^8.0.1",
|
||||||
|
|||||||
@@ -25,21 +25,16 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||||||
<template>
|
<template>
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
<AlertDialogOverlay
|
<AlertDialogOverlay
|
||||||
class="fixed inset-0 z-50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0">
|
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||||
<div class="absolute inset-0 bg-default-background opacity-30" />
|
<AlertDialogContent
|
||||||
</AlertDialogOverlay>
|
v-bind="forwarded"
|
||||||
<div
|
:class="
|
||||||
class="fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-14 xl:pt-24 justify-center overflow-auto">
|
cn(
|
||||||
<AlertDialogContent
|
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
v-bind="forwarded"
|
props.class
|
||||||
:class="
|
)
|
||||||
cn(
|
">
|
||||||
'pointer-events-auto bg-default-background grid w-full max-w-lg gap-4 border border-border-tertiary p-6 shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
<slot />
|
||||||
props.class
|
</AlertDialogContent>
|
||||||
)
|
|
||||||
">
|
|
||||||
<slot />
|
|
||||||
</AlertDialogContent>
|
|
||||||
</div>
|
|
||||||
</AlertDialogPortal>
|
</AlertDialogPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -253,9 +253,7 @@ const page = usePage<{
|
|||||||
v-if="isInvoicingActivated() && canViewInvoices()"
|
v-if="isInvoicingActivated() && canViewInvoices()"
|
||||||
title="Invoices"
|
title="Invoices"
|
||||||
:icon="DocumentTextIcon"
|
:icon="DocumentTextIcon"
|
||||||
:current="
|
:current="route().current('invoices')"
|
||||||
route().current('invoices') || route().current('invoices.*')
|
|
||||||
"
|
|
||||||
href="/invoices"></NavigationSidebarItem>
|
href="/invoices"></NavigationSidebarItem>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"author": "solidtime",
|
"author": "solidtime",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite-plugin-dts": "^4.5.4"
|
"vite-plugin-dts": "^5.0.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
|
|||||||
@@ -117,8 +117,6 @@ export type DetailedInvoiceResponse = ZodiosResponseByAlias<SolidTimeApi, 'getIn
|
|||||||
export type DetailedInvoice = DetailedInvoiceResponse['data'];
|
export type DetailedInvoice = DetailedInvoiceResponse['data'];
|
||||||
|
|
||||||
export type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0];
|
export type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0];
|
||||||
export type InvoiceRecipient = ZodiosResponseByAlias<SolidTimeApi, 'getInvoiceRecipients'>['data'][0];
|
|
||||||
export type InvoiceRecipientBody = ZodiosBodyByAlias<SolidTimeApi, 'createInvoiceRecipient'>;
|
|
||||||
|
|
||||||
export type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>;
|
export type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>;
|
||||||
|
|
||||||
|
|||||||
@@ -45,54 +45,14 @@ const InvitationResource = z
|
|||||||
const InvitationStoreRequest = z
|
const InvitationStoreRequest = z
|
||||||
.object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) })
|
.object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) })
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const InvoiceRecipientResource = z
|
|
||||||
.object({
|
|
||||||
id: z.string(),
|
|
||||||
organization_id: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
vatin: z.union([z.string(), z.null()]),
|
|
||||||
address_line_1: z.union([z.string(), z.null()]),
|
|
||||||
address_line_2: z.union([z.string(), z.null()]),
|
|
||||||
address_line_3: z.union([z.string(), z.null()]),
|
|
||||||
address_post_code: z.union([z.string(), z.null()]),
|
|
||||||
address_city: z.union([z.string(), z.null()]),
|
|
||||||
address_country: z.union([z.string(), z.null()]),
|
|
||||||
phone: z.union([z.string(), z.null()]),
|
|
||||||
email: z.union([z.string(), z.null()]),
|
|
||||||
is_archived: z.boolean(),
|
|
||||||
archived_at: z.union([z.string(), z.null()]),
|
|
||||||
invoices_count: z.number().int(),
|
|
||||||
has_non_draft_invoices: z.boolean(),
|
|
||||||
created_at: z.union([z.string(), z.null()]),
|
|
||||||
updated_at: z.union([z.string(), z.null()]),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
const InvoiceRecipientCollection = z.array(InvoiceRecipientResource);
|
|
||||||
const InvoiceRecipientRequest = z
|
|
||||||
.object({
|
|
||||||
name: z.string(),
|
|
||||||
vatin: z.union([z.string(), z.null()]).optional(),
|
|
||||||
address_line_1: z.union([z.string(), z.null()]).optional(),
|
|
||||||
address_line_2: z.union([z.string(), z.null()]).optional(),
|
|
||||||
address_line_3: z.union([z.string(), z.null()]).optional(),
|
|
||||||
address_post_code: z.union([z.string(), z.null()]).optional(),
|
|
||||||
address_city: z.union([z.string(), z.null()]).optional(),
|
|
||||||
address_country: z.union([z.string(), z.null()]).optional(),
|
|
||||||
phone: z.union([z.string(), z.null()]).optional(),
|
|
||||||
email: z.union([z.string(), z.null()]).optional(),
|
|
||||||
is_archived: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
const InvoiceResource = z
|
const InvoiceResource = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
organization_id: z.string(),
|
organization_id: z.string(),
|
||||||
invoice_recipient_id: z.string(),
|
|
||||||
reference: z.string(),
|
reference: z.string(),
|
||||||
seller_name: z.string(),
|
seller_name: z.string(),
|
||||||
recipient: z.string(),
|
buyer_name: z.string(),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
status_label: z.string(),
|
|
||||||
date: z.string(),
|
date: z.string(),
|
||||||
due_at: z.string(),
|
due_at: z.string(),
|
||||||
paid_date: z.string(),
|
paid_date: z.string(),
|
||||||
@@ -116,7 +76,16 @@ const InvoiceStoreRequest = z
|
|||||||
seller_address_country: z.union([z.string(), z.null()]).optional(),
|
seller_address_country: z.union([z.string(), z.null()]).optional(),
|
||||||
seller_phone: z.union([z.string(), z.null()]).optional(),
|
seller_phone: z.union([z.string(), z.null()]).optional(),
|
||||||
seller_email: z.union([z.string(), z.null()]).optional(),
|
seller_email: z.union([z.string(), z.null()]).optional(),
|
||||||
invoice_recipient_id: z.string(),
|
buyer_name: z.string(),
|
||||||
|
buyer_vatin: z.union([z.string(), z.null()]).optional(),
|
||||||
|
buyer_address_line_1: z.union([z.string(), z.null()]).optional(),
|
||||||
|
buyer_address_line_2: z.union([z.string(), z.null()]).optional(),
|
||||||
|
buyer_address_line_3: z.union([z.string(), z.null()]).optional(),
|
||||||
|
buyer_address_post_code: z.union([z.string(), z.null()]).optional(),
|
||||||
|
buyer_address_city: z.union([z.string(), z.null()]).optional(),
|
||||||
|
buyer_address_country: z.union([z.string(), z.null()]).optional(),
|
||||||
|
buyer_phone: z.union([z.string(), z.null()]).optional(),
|
||||||
|
buyer_email: z.union([z.string(), z.null()]).optional(),
|
||||||
date: z.string(),
|
date: z.string(),
|
||||||
billing_period_start: z.union([z.string(), z.null()]).optional(),
|
billing_period_start: z.union([z.string(), z.null()]).optional(),
|
||||||
billing_period_end: z.union([z.string(), z.null()]).optional(),
|
billing_period_end: z.union([z.string(), z.null()]).optional(),
|
||||||
@@ -161,7 +130,6 @@ const DetailedInvoiceResource = z
|
|||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
organization_id: z.string(),
|
organization_id: z.string(),
|
||||||
invoice_recipient_id: z.string(),
|
|
||||||
reference: z.string(),
|
reference: z.string(),
|
||||||
seller_name: z.string(),
|
seller_name: z.string(),
|
||||||
seller_vatin: z.string(),
|
seller_vatin: z.string(),
|
||||||
@@ -173,7 +141,16 @@ const DetailedInvoiceResource = z
|
|||||||
seller_address_country: z.string(),
|
seller_address_country: z.string(),
|
||||||
seller_phone: z.string(),
|
seller_phone: z.string(),
|
||||||
seller_email: z.string(),
|
seller_email: z.string(),
|
||||||
recipient: InvoiceRecipientResource,
|
buyer_name: z.string(),
|
||||||
|
buyer_vatin: z.string(),
|
||||||
|
buyer_address_line_1: z.string(),
|
||||||
|
buyer_address_line_2: z.string(),
|
||||||
|
buyer_address_line_3: z.string(),
|
||||||
|
buyer_address_post_code: z.string(),
|
||||||
|
buyer_address_city: z.string(),
|
||||||
|
buyer_address_country: z.string(),
|
||||||
|
buyer_phone: z.string(),
|
||||||
|
buyer_email: z.string(),
|
||||||
paid_date: z.string(),
|
paid_date: z.string(),
|
||||||
due_at: z.string(),
|
due_at: z.string(),
|
||||||
discount_type: z.string(),
|
discount_type: z.string(),
|
||||||
@@ -194,7 +171,7 @@ const DetailedInvoiceResource = z
|
|||||||
entries: z.array(InvoiceEntryResource),
|
entries: z.array(InvoiceEntryResource),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const InvoiceStatus = z.enum(['draft', 'sent', 'paid', 'cancelled']);
|
const InvoiceStatus = z.enum(['draft', 'sent', 'cancelled']);
|
||||||
const InvoiceUpdateRequest = z
|
const InvoiceUpdateRequest = z
|
||||||
.object({
|
.object({
|
||||||
status: InvoiceStatus,
|
status: InvoiceStatus,
|
||||||
@@ -210,7 +187,16 @@ const InvoiceUpdateRequest = z
|
|||||||
seller_address_country: z.union([z.string(), z.null()]),
|
seller_address_country: z.union([z.string(), z.null()]),
|
||||||
seller_phone: z.union([z.string(), z.null()]),
|
seller_phone: z.union([z.string(), z.null()]),
|
||||||
seller_email: z.union([z.string(), z.null()]),
|
seller_email: z.union([z.string(), z.null()]),
|
||||||
invoice_recipient_id: z.string(),
|
buyer_name: z.string(),
|
||||||
|
buyer_vatin: z.union([z.string(), z.null()]),
|
||||||
|
buyer_address_line_1: z.union([z.string(), z.null()]),
|
||||||
|
buyer_address_line_2: z.union([z.string(), z.null()]),
|
||||||
|
buyer_address_line_3: z.union([z.string(), z.null()]),
|
||||||
|
buyer_address_post_code: z.union([z.string(), z.null()]),
|
||||||
|
buyer_address_city: z.union([z.string(), z.null()]),
|
||||||
|
buyer_address_country: z.union([z.string(), z.null()]),
|
||||||
|
buyer_phone: z.union([z.string(), z.null()]),
|
||||||
|
buyer_email: z.union([z.string(), z.null()]),
|
||||||
date: z.string(),
|
date: z.string(),
|
||||||
billing_period_start: z.union([z.string(), z.null()]),
|
billing_period_start: z.union([z.string(), z.null()]),
|
||||||
billing_period_end: z.union([z.string(), z.null()]),
|
billing_period_end: z.union([z.string(), z.null()]),
|
||||||
@@ -1899,125 +1885,6 @@ const endpoints = makeApi([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/invoice-recipients',
|
|
||||||
alias: 'getInvoiceRecipients',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.object({ data: InvoiceRecipientCollection }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'post',
|
|
||||||
path: '/v1/organizations/:organization/invoice-recipients',
|
|
||||||
alias: 'createInvoiceRecipient',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'body',
|
|
||||||
type: 'Body',
|
|
||||||
schema: InvoiceRecipientRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient',
|
|
||||||
alias: 'getInvoiceRecipient',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'invoiceRecipient',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'put',
|
|
||||||
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient',
|
|
||||||
alias: 'updateInvoiceRecipient',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'body',
|
|
||||||
type: 'Body',
|
|
||||||
schema: InvoiceRecipientRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'invoiceRecipient',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'post',
|
|
||||||
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient/duplicate',
|
|
||||||
alias: 'duplicateInvoiceRecipient',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'body',
|
|
||||||
type: 'Body',
|
|
||||||
schema: InvoiceRecipientRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'invoiceRecipient',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'delete',
|
|
||||||
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient',
|
|
||||||
alias: 'deleteInvoiceRecipient',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'invoiceRecipient',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.void(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/v1/organizations/:organization/invoices',
|
path: '/v1/organizations/:organization/invoices',
|
||||||
@@ -2034,11 +1901,6 @@ const endpoints = makeApi([
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.number().int().gte(1).lte(2147483647).optional(),
|
schema: z.number().int().gte(1).lte(2147483647).optional(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'status',
|
|
||||||
type: 'Query',
|
|
||||||
schema: InvoiceStatus.optional(),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
response: z.object({ data: InvoiceCollection }).passthrough(),
|
response: z.object({ data: InvoiceCollection }).passthrough(),
|
||||||
errors: [
|
errors: [
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chroma-js": "^3.1.2",
|
"@types/chroma-js": "^3.1.2",
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
"vite-plugin-dts": "^4.5.4",
|
"vite-plugin-dts": "^5.0.3",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -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,25 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ComboboxRootEmits, ComboboxRootProps } from 'reka-ui';
|
|
||||||
import type { HTMLAttributes } from 'vue';
|
|
||||||
import { reactiveOmit } from '@vueuse/core';
|
|
||||||
import { ComboboxRoot, useForwardPropsEmits } from 'reka-ui';
|
|
||||||
import { cn } from '../utils/cn';
|
|
||||||
|
|
||||||
const props = defineProps<ComboboxRootProps & { class?: HTMLAttributes['class'] }>();
|
|
||||||
const emits = defineEmits<ComboboxRootEmits>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, 'class');
|
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- Keep the trigger inside this root. Reka treats anything within the root
|
|
||||||
element as "not outside", so it suppresses its own dismissal for trigger
|
|
||||||
clicks and ComboboxInput's blur-close ignores them. Putting the trigger
|
|
||||||
in a separate Popover instead gives two competing open states, and the
|
|
||||||
popover then closes on mousedown and reopens on the following click. -->
|
|
||||||
<ComboboxRoot v-slot="slotProps" v-bind="forwarded" :class="cn('min-w-0', props.class)">
|
|
||||||
<slot v-bind="slotProps" />
|
|
||||||
</ComboboxRoot>
|
|
||||||
</template>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ComboboxAnchorProps } from 'reka-ui';
|
|
||||||
import type { HTMLAttributes } from 'vue';
|
|
||||||
import { reactiveOmit } from '@vueuse/core';
|
|
||||||
import { ComboboxAnchor, useForwardProps } from 'reka-ui';
|
|
||||||
import { cn } from '../utils/cn';
|
|
||||||
|
|
||||||
const props = defineProps<ComboboxAnchorProps & { class?: HTMLAttributes['class'] }>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, 'class');
|
|
||||||
|
|
||||||
const forwarded = useForwardProps(delegatedProps);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ComboboxAnchor v-bind="forwarded" :class="cn('w-full', props.class)">
|
|
||||||
<slot />
|
|
||||||
</ComboboxAnchor>
|
|
||||||
</template>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ComboboxInputEmits, ComboboxInputProps } from 'reka-ui';
|
|
||||||
import type { HTMLAttributes } from 'vue';
|
|
||||||
import { reactiveOmit } from '@vueuse/core';
|
|
||||||
import { Search } from '@lucide/vue';
|
|
||||||
import { ComboboxInput, useForwardPropsEmits } from 'reka-ui';
|
|
||||||
import { cn } from '../utils/cn';
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
inheritAttrs: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = defineProps<ComboboxInputProps & { class?: HTMLAttributes['class'] }>();
|
|
||||||
const emits = defineEmits<ComboboxInputEmits>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, 'class');
|
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="relative items-center border-b border-card-background-separator">
|
|
||||||
<ComboboxInput
|
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'h-10 w-full border-0 rounded-none bg-transparent pl-9 pr-3 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:ring-0',
|
|
||||||
props.class
|
|
||||||
)
|
|
||||||
" />
|
|
||||||
<span class="absolute start-0 inset-y-0 flex items-center justify-center px-3">
|
|
||||||
<Search class="size-4 text-text-tertiary" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ComboboxItemEmits, ComboboxItemProps } from 'reka-ui';
|
|
||||||
import type { HTMLAttributes } from 'vue';
|
|
||||||
import { reactiveOmit } from '@vueuse/core';
|
|
||||||
import { ComboboxItem, useForwardPropsEmits } from 'reka-ui';
|
|
||||||
import { cn } from '../utils/cn';
|
|
||||||
|
|
||||||
const props = defineProps<ComboboxItemProps & { class?: HTMLAttributes['class'] }>();
|
|
||||||
const emits = defineEmits<ComboboxItemEmits>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, 'class');
|
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ComboboxItem
|
|
||||||
v-bind="forwarded"
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'flex w-full cursor-default items-center rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active',
|
|
||||||
props.class
|
|
||||||
)
|
|
||||||
">
|
|
||||||
<slot />
|
|
||||||
</ComboboxItem>
|
|
||||||
</template>
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ComboboxContentEmits, ComboboxContentProps } from 'reka-ui';
|
|
||||||
import type { HTMLAttributes } from 'vue';
|
|
||||||
import { reactiveOmit } from '@vueuse/core';
|
|
||||||
import { ComboboxContent, ComboboxPortal, useForwardPropsEmits } from 'reka-ui';
|
|
||||||
import { cn } from '../utils/cn';
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
inheritAttrs: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<ComboboxContentProps & { class?: HTMLAttributes['class'] }>(),
|
|
||||||
{
|
|
||||||
position: 'popper',
|
|
||||||
align: 'start',
|
|
||||||
sideOffset: 4,
|
|
||||||
class: undefined,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const emits = defineEmits<ComboboxContentEmits>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, 'class');
|
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ComboboxPortal>
|
|
||||||
<ComboboxContent
|
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'z-50 w-[--reka-popper-anchor-width] min-w-60 overflow-hidden rounded-lg border border-popover-border bg-popover text-popover-foreground shadow-dropdown outline-none origin-[var(--reka-combobox-content-transform-origin)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
|
||||||
props.class
|
|
||||||
)
|
|
||||||
">
|
|
||||||
<slot />
|
|
||||||
</ComboboxContent>
|
|
||||||
</ComboboxPortal>
|
|
||||||
</template>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ComboboxSeparatorProps } from 'reka-ui';
|
|
||||||
import type { HTMLAttributes } from 'vue';
|
|
||||||
import { reactiveOmit } from '@vueuse/core';
|
|
||||||
import { ComboboxSeparator, useForwardProps } from 'reka-ui';
|
|
||||||
import { cn } from '../utils/cn';
|
|
||||||
|
|
||||||
const props = defineProps<ComboboxSeparatorProps & { class?: HTMLAttributes['class'] }>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, 'class');
|
|
||||||
|
|
||||||
const forwarded = useForwardProps(delegatedProps);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ComboboxSeparator
|
|
||||||
v-bind="forwarded"
|
|
||||||
:class="cn('my-1 h-px bg-card-background-separator', props.class)" />
|
|
||||||
</template>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ComboboxTriggerProps } from 'reka-ui';
|
|
||||||
import type { HTMLAttributes } from 'vue';
|
|
||||||
import { reactiveOmit } from '@vueuse/core';
|
|
||||||
import { ComboboxTrigger, useForwardProps } from 'reka-ui';
|
|
||||||
import { cn } from '../utils/cn';
|
|
||||||
|
|
||||||
const props = defineProps<ComboboxTriggerProps & { class?: HTMLAttributes['class'] }>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, 'class');
|
|
||||||
|
|
||||||
const forwarded = useForwardProps(delegatedProps);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- Reka hardcodes tabindex="-1" here because it expects a ComboboxInput
|
|
||||||
next to the trigger in the anchor to be the focusable control. Our input
|
|
||||||
lives inside ComboboxList, so this trigger is the control and has to be
|
|
||||||
tabbable. tabindex is a fallthrough attr, which Vue applies after Reka's
|
|
||||||
own props and therefore wins.
|
|
||||||
|
|
||||||
Reka also hardcodes aria-label="Show popup", which would otherwise be
|
|
||||||
the accessible name. Pass :aria-label on the child element (it wins
|
|
||||||
again, because Slot merges child props over attrs) to name the trigger
|
|
||||||
after its current value. -->
|
|
||||||
<ComboboxTrigger v-bind="forwarded" :class="cn(props.class)" tabindex="0">
|
|
||||||
<slot />
|
|
||||||
</ComboboxTrigger>
|
|
||||||
</template>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ComboboxViewportProps } from 'reka-ui';
|
|
||||||
import type { HTMLAttributes } from 'vue';
|
|
||||||
import { reactiveOmit } from '@vueuse/core';
|
|
||||||
import { ComboboxViewport, useForwardProps } from 'reka-ui';
|
|
||||||
import { cn } from '../utils/cn';
|
|
||||||
|
|
||||||
const props = defineProps<ComboboxViewportProps & { class?: HTMLAttributes['class'] }>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, 'class');
|
|
||||||
|
|
||||||
const forwarded = useForwardProps(delegatedProps);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ComboboxViewport
|
|
||||||
v-bind="forwarded"
|
|
||||||
:class="cn('ui-combobox-viewport max-h-60 overflow-y-auto p-2', props.class)">
|
|
||||||
<slot />
|
|
||||||
</ComboboxViewport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Reka's ComboboxViewport injects a global style that hides scrollbars; the
|
|
||||||
attribute+class selector below is more specific and restores them. */
|
|
||||||
[data-reka-combobox-viewport].ui-combobox-viewport {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
-ms-overflow-style: auto;
|
|
||||||
}
|
|
||||||
[data-reka-combobox-viewport].ui-combobox-viewport::-webkit-scrollbar {
|
|
||||||
display: block;
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
[data-reka-combobox-viewport].ui-combobox-viewport::-webkit-scrollbar-thumb {
|
|
||||||
background-color: rgb(127 127 127 / 0.4);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export { default as Combobox } from './Combobox.vue';
|
|
||||||
export { default as ComboboxAnchor } from './ComboboxAnchor.vue';
|
|
||||||
export { default as ComboboxInput } from './ComboboxInput.vue';
|
|
||||||
export { default as ComboboxItem } from './ComboboxItem.vue';
|
|
||||||
export { default as ComboboxList } from './ComboboxList.vue';
|
|
||||||
export { default as ComboboxSeparator } from './ComboboxSeparator.vue';
|
|
||||||
export { default as ComboboxTrigger } from './ComboboxTrigger.vue';
|
|
||||||
export { default as ComboboxViewport } from './ComboboxViewport.vue';
|
|
||||||
export { ComboboxVirtualizer } from 'reka-ui';
|
|
||||||
@@ -57,16 +57,6 @@ import {
|
|||||||
CalendarNextButton,
|
CalendarNextButton,
|
||||||
CalendarPrevButton,
|
CalendarPrevButton,
|
||||||
} from './calendar/index';
|
} from './calendar/index';
|
||||||
import {
|
|
||||||
Combobox,
|
|
||||||
ComboboxAnchor,
|
|
||||||
ComboboxInput,
|
|
||||||
ComboboxItem,
|
|
||||||
ComboboxList,
|
|
||||||
ComboboxSeparator,
|
|
||||||
ComboboxTrigger,
|
|
||||||
ComboboxViewport,
|
|
||||||
} from './combobox/index';
|
|
||||||
import { CommandPalette } from './CommandPalette/index';
|
import { CommandPalette } from './CommandPalette/index';
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -186,14 +176,6 @@ export {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
color,
|
color,
|
||||||
Combobox,
|
|
||||||
ComboboxAnchor,
|
|
||||||
ComboboxInput,
|
|
||||||
ComboboxItem,
|
|
||||||
ComboboxList,
|
|
||||||
ComboboxSeparator,
|
|
||||||
ComboboxTrigger,
|
|
||||||
ComboboxViewport,
|
|
||||||
CommandPalette,
|
CommandPalette,
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuCheckboxItem,
|
ContextMenuCheckboxItem,
|
||||||
|
|||||||
@@ -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') }}
|
||||||
|
|||||||
@@ -92,30 +92,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertJsonPath('data.0.id', $timeEntry->getKey());
|
$response->assertJsonPath('data.0.id', $timeEntry->getKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_index_endpoint_filters_by_member_id_instead_of_legacy_user_id(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:view:own',
|
|
||||||
]);
|
|
||||||
$legacyUser = User::factory()->create();
|
|
||||||
$timeEntry = TimeEntry::factory()->forMember($data->member)->create([
|
|
||||||
'user_id' => $legacyUser->getKey(),
|
|
||||||
]);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.time-entries.index', [
|
|
||||||
$data->organization->getKey(),
|
|
||||||
'member_id' => $data->member->getKey(),
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertResponseCode($response, 200);
|
|
||||||
$response->assertJsonCount(1, 'data');
|
|
||||||
$response->assertJsonPath('data.0.id', $timeEntry->getKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_index_endpoint_fails_if_user_filter_is_from_different_organization(): void
|
public function test_index_endpoint_fails_if_user_filter_is_from_different_organization(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -150,10 +126,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
Passport::actingAs($data->user);
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
$response = $this->getJson(route('api.v1.time-entries.index', [
|
$response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey(), 'user_id' => $user->getKey()]));
|
||||||
$data->organization->getKey(),
|
|
||||||
'member_id' => $member->getKey(),
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$this->assertResponseCode($response, 200);
|
$this->assertResponseCode($response, 200);
|
||||||
@@ -1799,29 +1772,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_aggregate_endpoint_filters_by_member_id_instead_of_legacy_user_id(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:view:own',
|
|
||||||
]);
|
|
||||||
$legacyUser = User::factory()->create();
|
|
||||||
TimeEntry::factory()->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create([
|
|
||||||
'user_id' => $legacyUser->getKey(),
|
|
||||||
]);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
|
|
||||||
$data->organization->getKey(),
|
|
||||||
'member_id' => $data->member->getKey(),
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertSuccessful();
|
|
||||||
$response->assertJsonPath('data.seconds', 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_aggregate_endpoint_groups_by_two_groups(): void
|
public function test_aggregate_endpoint_groups_by_two_groups(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -2869,32 +2819,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_update_endpoint_updates_user_id_when_member_id_changes(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:update:all',
|
|
||||||
]);
|
|
||||||
$otherUser = User::factory()->create();
|
|
||||||
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
|
|
||||||
$timeEntry = TimeEntry::factory()->forMember($data->member)->create();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
|
||||||
'member_id' => $otherMember->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertValid();
|
|
||||||
$this->assertResponseCode($response, 200);
|
|
||||||
$this->assertDatabaseHas(TimeEntry::class, [
|
|
||||||
'id' => $timeEntry->getKey(),
|
|
||||||
'member_id' => $otherMember->getKey(),
|
|
||||||
'user_id' => $otherUser->getKey(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_update_endpoint_can_update_project_and_automatically_set_client(): void
|
public function test_update_endpoint_can_update_project_and_automatically_set_client(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -3231,40 +3155,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_destroy_multiple_uses_member_id_for_own_permission_checks(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:delete:own',
|
|
||||||
]);
|
|
||||||
$otherUser = User::factory()->create();
|
|
||||||
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
|
|
||||||
$timeEntry = TimeEntry::factory()->forMember($otherMember)->create([
|
|
||||||
'user_id' => $data->user->getKey(),
|
|
||||||
]);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->deleteJson(route('api.v1.time-entries.destroy-multiple', [$data->organization->getKey()]), [
|
|
||||||
'ids' => [
|
|
||||||
$timeEntry->getKey(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertValid();
|
|
||||||
$this->assertResponseCode($response, 200);
|
|
||||||
$response->assertExactJson([
|
|
||||||
'success' => [],
|
|
||||||
'error' => [
|
|
||||||
$timeEntry->getKey(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas(TimeEntry::class, [
|
|
||||||
'id' => $timeEntry->getKey(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_destroy_multiple_deletes_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void
|
public function test_destroy_multiple_deletes_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -3676,46 +3566,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_update_multiple_uses_member_id_for_own_permission_checks(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:update:own',
|
|
||||||
'projects:view:all',
|
|
||||||
]);
|
|
||||||
$otherUser = User::factory()->create();
|
|
||||||
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
|
|
||||||
$timeEntry = TimeEntry::factory()->forMember($otherMember)->create([
|
|
||||||
'user_id' => $data->user->getKey(),
|
|
||||||
]);
|
|
||||||
$timeEntriesFake = TimeEntry::factory()->forOrganization($data->organization)->make();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
|
|
||||||
'ids' => [
|
|
||||||
$timeEntry->getKey(),
|
|
||||||
],
|
|
||||||
'changes' => [
|
|
||||||
'description' => $timeEntriesFake->description,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertValid();
|
|
||||||
$this->assertResponseCode($response, 200);
|
|
||||||
$response->assertExactJson([
|
|
||||||
'success' => [],
|
|
||||||
'error' => [
|
|
||||||
$timeEntry->getKey(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas(TimeEntry::class, [
|
|
||||||
'id' => $timeEntry->getKey(),
|
|
||||||
'description' => $timeEntry->description,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_update_multiple_updates_sets_description_to_empty_if_the_client_sends_null(): void
|
public function test_update_multiple_updates_sets_description_to_empty_if_the_client_sends_null(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -3762,51 +3612,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_update_multiple_updates_user_id_when_member_id_changes(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:update:all',
|
|
||||||
]);
|
|
||||||
$otherUser = User::factory()->create();
|
|
||||||
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
|
|
||||||
$timeEntry1 = TimeEntry::factory()->forMember($data->member)->create();
|
|
||||||
$timeEntry2 = TimeEntry::factory()->forMember($data->member)->create();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
|
|
||||||
'ids' => [
|
|
||||||
$timeEntry1->getKey(),
|
|
||||||
$timeEntry2->getKey(),
|
|
||||||
],
|
|
||||||
'changes' => [
|
|
||||||
'member_id' => $otherMember->getKey(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertValid();
|
|
||||||
$response->assertStatus(200);
|
|
||||||
$response->assertExactJson([
|
|
||||||
'success' => [
|
|
||||||
$timeEntry1->getKey(),
|
|
||||||
$timeEntry2->getKey(),
|
|
||||||
],
|
|
||||||
'error' => [],
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas(TimeEntry::class, [
|
|
||||||
'id' => $timeEntry1->getKey(),
|
|
||||||
'member_id' => $otherMember->getKey(),
|
|
||||||
'user_id' => $otherUser->getKey(),
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas(TimeEntry::class, [
|
|
||||||
'id' => $timeEntry2->getKey(),
|
|
||||||
'member_id' => $otherMember->getKey(),
|
|
||||||
'user_id' => $otherUser->getKey(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_update_multiple_updates_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void
|
public function test_update_multiple_updates_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user