mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-18 21:22:15 +01:00
Compare commits
175 Commits
feature/ex
...
99aa7ed450
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99aa7ed450 | ||
|
|
f582adab0d | ||
|
|
c60cff04ce | ||
|
|
cae41e4b4f | ||
|
|
8973be9dab | ||
|
|
2a0b8d31e6 | ||
|
|
d2f3fe411a | ||
|
|
f880f9f730 | ||
|
|
556bbedeca | ||
|
|
eed638d0aa | ||
|
|
864f41bda6 | ||
|
|
26524c5f40 | ||
|
|
cf98fabe0a | ||
|
|
88c0c334e9 | ||
|
|
0fc325363d | ||
|
|
1afc16573a | ||
|
|
147514a606 | ||
|
|
435522b502 | ||
|
|
f1d001e03e | ||
|
|
7f145cf1c2 | ||
|
|
b579ed1075 | ||
|
|
ed2b7476ae | ||
|
|
8107c6a208 | ||
|
|
6dc517e07d | ||
|
|
2c60d04ba4 | ||
|
|
2c222f3f67 | ||
|
|
c5c1a7af13 | ||
|
|
22cf7cf74d | ||
|
|
cfbfbd4b6a | ||
|
|
6629482a0e | ||
|
|
38457cae4d | ||
|
|
0e63ecb520 | ||
|
|
6f207a4926 | ||
|
|
052424a581 | ||
|
|
b258717211 | ||
|
|
685cc29282 | ||
|
|
c78c681ec4 | ||
|
|
2d9f33387e | ||
|
|
b68d68a2a2 | ||
|
|
a9e03f3b29 | ||
|
|
474b294a18 | ||
|
|
334a98016f | ||
|
|
8be55359ce | ||
|
|
e45662c715 | ||
|
|
f3217baed1 | ||
|
|
562ee234a8 | ||
|
|
15e61e9789 | ||
|
|
125f6f062f | ||
|
|
f75a19bccd | ||
|
|
c17d87b710 | ||
|
|
a154293348 | ||
|
|
9832c688fe | ||
|
|
6804eb098d | ||
|
|
531443f0df | ||
|
|
bd2d57dfd1 | ||
|
|
73c92fad47 | ||
|
|
537a023ab9 | ||
|
|
28fc324c6a | ||
|
|
9379c191be | ||
|
|
ff06d4d2f3 | ||
|
|
7efb7e6071 | ||
|
|
b2af9c6bf1 | ||
|
|
73b4d66386 | ||
|
|
cb7baef0ba | ||
|
|
dd75a80df7 | ||
|
|
bc562bf76f | ||
|
|
756b423295 | ||
|
|
3707f2469c | ||
|
|
c6c1434430 | ||
|
|
70b78e41c3 | ||
|
|
8c16302f17 | ||
|
|
bfc369794e | ||
|
|
3c2ea0e645 | ||
|
|
b0d28f2f6d | ||
|
|
6555bca5f1 | ||
|
|
81d9561656 | ||
|
|
0a6bde8bc6 | ||
|
|
51af3db305 | ||
|
|
f242ce48b5 | ||
|
|
19064cdc3d | ||
|
|
5a05ee35e0 | ||
|
|
00d9d1488e | ||
|
|
9bbbfdfafe | ||
|
|
d27f023e16 | ||
|
|
db57055941 | ||
|
|
743c64909a | ||
|
|
de97d15925 | ||
|
|
0691fe10ef | ||
|
|
513b2048ee | ||
|
|
3acf9b8b07 | ||
|
|
814d539fb0 | ||
|
|
7a51fca2f9 | ||
|
|
280032ee02 | ||
|
|
b1bb7245b0 | ||
|
|
6f37ad500a | ||
|
|
500ccd5719 | ||
|
|
bacd6f4222 | ||
|
|
022caf59ee | ||
|
|
f955ab3135 | ||
|
|
5b491b0da2 | ||
|
|
249ab67ac8 | ||
|
|
1bd2c28b37 | ||
|
|
33ac994cc0 | ||
|
|
8d3ee58bed | ||
|
|
8a2c260533 | ||
|
|
95ab1699c4 | ||
|
|
306a081a3d | ||
|
|
878ac4ab81 | ||
|
|
947550d639 | ||
|
|
09fb5aa48e | ||
|
|
9b9371e5a5 | ||
|
|
0648437478 | ||
|
|
8ba04eca0c | ||
|
|
8a2f35de0c | ||
|
|
b7dafb0892 | ||
|
|
6eca0c2c76 | ||
|
|
3417b60585 | ||
|
|
0f21fabd37 | ||
|
|
df00200464 | ||
|
|
3b41de7135 | ||
|
|
9fe0ea5a0f | ||
|
|
f8f708a664 | ||
|
|
c359259e45 | ||
|
|
55d12aaae1 | ||
|
|
9a1dd4861c | ||
|
|
1e985b71ec | ||
|
|
93d6a86f74 | ||
|
|
19a206d57c | ||
|
|
c0788c270b | ||
|
|
7765056074 | ||
|
|
639f5332e4 | ||
|
|
4a50145329 | ||
|
|
8aabffd1e7 | ||
|
|
b373427dc7 | ||
|
|
d2a4d60441 | ||
|
|
c3305b3df6 | ||
|
|
7584e59d0b | ||
|
|
d2f75cca6e | ||
|
|
250379d4bd | ||
|
|
7f89fd8ea1 | ||
|
|
0b45f3b473 | ||
|
|
9827a74ae2 | ||
|
|
3425847a44 | ||
|
|
47b778fab9 | ||
|
|
85d69f1f16 | ||
|
|
fca55fe0e1 | ||
|
|
f19abb9db6 | ||
|
|
e3bd50ed6b | ||
|
|
c582530899 | ||
|
|
fb5185a32f | ||
|
|
0a0854f771 | ||
|
|
4e635cde83 | ||
|
|
9fa9522237 | ||
|
|
04c44097d0 | ||
|
|
3d5a0cb974 | ||
|
|
da98e0571c | ||
|
|
f68f05d1aa | ||
|
|
8fdc4c1219 | ||
|
|
93148299a9 | ||
|
|
78d2ea1a25 | ||
|
|
14f559c4c2 | ||
|
|
61fd2b1187 | ||
|
|
9ea3c5dc29 | ||
|
|
cb30487a21 | ||
|
|
b11672732b | ||
|
|
97dcadc795 | ||
|
|
e7fa414c06 | ||
|
|
43073b5be2 | ||
|
|
9589c9106d | ||
|
|
8a0d2235a8 | ||
|
|
38f38790d5 | ||
|
|
e3cfc155b8 | ||
|
|
4b726635b2 | ||
|
|
e1185af281 | ||
|
|
f9c0d64f82 |
10
.env.ci
10
.env.ci
@@ -34,7 +34,12 @@ SESSION_DRIVER=database
|
|||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
# Mail
|
# Mail
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=smtp
|
||||||
|
MAIL_HOST=localhost
|
||||||
|
MAIL_PORT=1025
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_ENCRYPTION=null
|
||||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||||
MAIL_FROM_NAME="solidtime"
|
MAIL_FROM_NAME="solidtime"
|
||||||
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
||||||
@@ -56,3 +61,6 @@ TELESCOPE_ENABLED=false
|
|||||||
|
|
||||||
# Services
|
# Services
|
||||||
GOTENBERG_URL=http://0.0.0.0:3000
|
GOTENBERG_URL=http://0.0.0.0:3000
|
||||||
|
|
||||||
|
# Octane
|
||||||
|
OCTANE_SERVER=frankenphp
|
||||||
|
|||||||
@@ -80,8 +80,7 @@ GOTENBERG_URL=http://gotenberg:3000
|
|||||||
# Local setup
|
# Local setup
|
||||||
NGINX_HOST_NAME=solidtime.test
|
NGINX_HOST_NAME=solidtime.test
|
||||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||||
FORWARD_DB_PORT=5432
|
FORWARD_DB_PORT=54329
|
||||||
FORWARD_WEB_PORT=8083
|
|
||||||
VITE_HOST_NAME=vite.solidtime.test
|
VITE_HOST_NAME=vite.solidtime.test
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
#SAIL_XDEBUG_MODE=develop,debug,coverage
|
#SAIL_XDEBUG_MODE=develop,debug,coverage
|
||||||
|
|||||||
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,8 +1,11 @@
|
|||||||
<!--
|
## What does this PR do?
|
||||||
This project is early stage. The structure and APIs are still subject to change and not stable.
|
|
||||||
Therefore, we do not currently accept any contributions, unless you are a member of the team.
|
|
||||||
|
|
||||||
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||||
|
|
||||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
- Fixes #XXXX (GitHub issue number)
|
||||||
-->
|
|
||||||
|
## Checklist (DO NOT REMOVE)
|
||||||
|
|
||||||
|
- [ ] I read the [contributing guide](https://github.com/solidtime-io/solidtime/blob/main/CONTRIBUTING.md)
|
||||||
|
- [ ] I signed the [Contributor License Agreement](https://cla-assistant.io/solidtime-io/solidtime).
|
||||||
|
- [ ] I commented my code, particularly in hard-to-understand areas
|
||||||
|
|||||||
216
.github/workflows/build-onpremise.yml
vendored
Normal file
216
.github/workflows/build-onpremise.yml
vendored
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/build-onpremise.yml'
|
||||||
|
- 'docker/prod/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKER_REPO: registry.on-premise.solidtime.io/solidtime/solidtime
|
||||||
|
|
||||||
|
name: Build - On Premise
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- runs-on: "ubuntu-24.04-arm"
|
||||||
|
platform: "linux/arm64"
|
||||||
|
- runs-on: "ubuntu-24.04"
|
||||||
|
platform: "linux/amd64"
|
||||||
|
runs-on: ${{ matrix.runs-on }}
|
||||||
|
timeout-minutes: 90
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: "Check out code"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
|
- name: "Get build"
|
||||||
|
id: release-build
|
||||||
|
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: "Get Previous tag (normal push)"
|
||||||
|
id: previoustag
|
||||||
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
|
with:
|
||||||
|
prefix: "v"
|
||||||
|
|
||||||
|
- name: "Get version"
|
||||||
|
id: release-version
|
||||||
|
run: |
|
||||||
|
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
||||||
|
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
||||||
|
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
|
||||||
|
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "ERROR: No previous tag found";
|
||||||
|
exit 1;
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
version=$(echo "${{ github.ref }}" | cut -c 12-)
|
||||||
|
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: "Copy .env template for production"
|
||||||
|
run: |
|
||||||
|
cp .env.production .env
|
||||||
|
rm .env.production .env.ci .env.example
|
||||||
|
|
||||||
|
- name: "Add version to .env"
|
||||||
|
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
|
||||||
|
|
||||||
|
- name: "Add build to .env"
|
||||||
|
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
|
||||||
|
|
||||||
|
- name: "Output .env"
|
||||||
|
run: cat .env
|
||||||
|
|
||||||
|
- name: "Setup PHP with PECL extension"
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
extensions: mbstring, dom, fileinfo, pgsql
|
||||||
|
|
||||||
|
- name: "Install dependencies"
|
||||||
|
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||||
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
|
|
||||||
|
- name: "Use Node.js"
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.x'
|
||||||
|
|
||||||
|
- name: "Checkout invoicing extension"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: solidtime-io/extension-invoicing
|
||||||
|
path: extensions/Invoicing
|
||||||
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||||
|
|
||||||
|
- name: "Install composer dependencies in invoicing extension"
|
||||||
|
run: cd extensions/Invoicing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||||
|
|
||||||
|
- name: "Install npm dependencies in invoicing extension"
|
||||||
|
run: cd extensions/Invoicing && npm ci
|
||||||
|
|
||||||
|
- name: "Activate invoicing extension"
|
||||||
|
run: php artisan module:enable Invoicing
|
||||||
|
|
||||||
|
- name: "Install npm dependencies"
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: "Build"
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: "Prepare"
|
||||||
|
run: |
|
||||||
|
platform=${{ matrix.platform }}
|
||||||
|
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: "Docker meta"
|
||||||
|
id: "meta"
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.DOCKER_REPO }}
|
||||||
|
|
||||||
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: registry.on-premise.solidtime.io
|
||||||
|
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||||
|
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||||
|
|
||||||
|
- name: "Set up QEMU"
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: "Set up Docker Buildx"
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: "Build and push by digest"
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/prod/Dockerfile
|
||||||
|
build-args: |
|
||||||
|
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,"name=${{ env.DOCKER_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: "Export digest"
|
||||||
|
run: |
|
||||||
|
mkdir -p ${{ runner.temp }}/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: "Upload digest"
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
|
path: ${{ runner.temp }}/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
needs:
|
||||||
|
- build
|
||||||
|
steps:
|
||||||
|
- name: "Download digests"
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: registry.on-premise.solidtime.io
|
||||||
|
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||||
|
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||||
|
|
||||||
|
- name: "Set up Docker Buildx"
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: "Docker meta"
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.DOCKER_REPO }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
|
||||||
|
- name: "Create manifest list and push"
|
||||||
|
working-directory: ${{ runner.temp }}/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: "Inspect image"
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ env.DOCKER_REPO }}:${{ steps.meta.outputs.version }}
|
||||||
23
.github/workflows/npm-format-check.yml
vendored
Normal file
23
.github/workflows/npm-format-check.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: NPM Format Check
|
||||||
|
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
format-check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: "Checkout code"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: "Use Node.js"
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.x'
|
||||||
|
|
||||||
|
- name: "Install npm dependencies"
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: "Check code formatting"
|
||||||
|
run: npm run format:check
|
||||||
63
.github/workflows/playwright.yml
vendored
63
.github/workflows/playwright.yml
vendored
@@ -6,10 +6,18 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||||
|
shardTotal: [8]
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mailpit:
|
mailpit:
|
||||||
image: 'axllent/mailpit:latest'
|
image: 'axllent/mailpit:latest'
|
||||||
|
ports:
|
||||||
|
- 1025:1025
|
||||||
|
- 8025:8025
|
||||||
pgsql_test:
|
pgsql_test:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
env:
|
env:
|
||||||
@@ -57,22 +65,63 @@ jobs:
|
|||||||
- name: "Build Frontend"
|
- name: "Build Frontend"
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: "Run Laravel Server"
|
- name: "Install FrankenPHP"
|
||||||
run: php artisan serve > /dev/null 2>&1 &
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
curl -fsSL "https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-${ARCH}" -o /usr/local/bin/frankenphp
|
||||||
|
chmod +x /usr/local/bin/frankenphp
|
||||||
|
|
||||||
|
- name: "Run Laravel Octane Server"
|
||||||
|
run: php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000 --workers=4 --max-requests=500 > /dev/null 2>&1 &
|
||||||
|
env:
|
||||||
|
OCTANE_SERVER: frankenphp
|
||||||
|
|
||||||
- name: "Install Playwright Browsers"
|
- name: "Install Playwright Browsers"
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
- name: "Run Playwright tests"
|
- name: "Run Playwright tests"
|
||||||
run: npx playwright test
|
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
|
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
|
||||||
|
MAILPIT_BASE_URL: 'http://localhost:8025'
|
||||||
|
|
||||||
- name: "Upload test results"
|
- name: "Upload blob report"
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: blob-report-${{ matrix.shardIndex }}
|
||||||
path: test-results/
|
path: blob-report/
|
||||||
retention-days: 30
|
retention-days: 7
|
||||||
|
|
||||||
|
merge-reports:
|
||||||
|
if: always()
|
||||||
|
needs: [test]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: "Checkout code"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: "Setup node"
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.x'
|
||||||
|
|
||||||
|
- name: "Install dependencies"
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: "Download blob reports"
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: all-blob-reports
|
||||||
|
pattern: blob-report-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: "Merge reports"
|
||||||
|
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
||||||
|
|
||||||
|
- name: "Upload merged HTML report"
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 30
|
||||||
|
|||||||
27
.prettierignore
Normal file
27
.prettierignore
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Ignore build outputs
|
||||||
|
node_modules/
|
||||||
|
vendor/
|
||||||
|
storage/
|
||||||
|
bootstrap/cache/
|
||||||
|
public/build/
|
||||||
|
public/hot/
|
||||||
|
|
||||||
|
# Ignore lock files
|
||||||
|
package-lock.json
|
||||||
|
composer.lock
|
||||||
|
|
||||||
|
# Ignore generated files
|
||||||
|
*.min.js
|
||||||
|
*.min.css
|
||||||
|
|
||||||
|
# Ignore test results
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
|
# Ignore IDE files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Ignore OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -3,5 +3,6 @@
|
|||||||
"tabWidth": 4,
|
"tabWidth": 4,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"bracketSameLine": true,
|
"bracketSameLine": true,
|
||||||
"quoteProps": "preserve"
|
"quoteProps": "preserve",
|
||||||
|
"printWidth": 100
|
||||||
}
|
}
|
||||||
|
|||||||
81
CONTRIBUTING.md
Normal file
81
CONTRIBUTING.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Contributing to solidtime
|
||||||
|
|
||||||
|
Contributions are greatly apprecited, please make sure to read the rules and vision for solidtime before contributing.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
### Issues for Bugs, Discussions for Feature requests
|
||||||
|
|
||||||
|
In order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancement are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted.
|
||||||
|
|
||||||
|
### Only work on approved issues
|
||||||
|
|
||||||
|
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
||||||
|
|
||||||
|
### Contributor License Agreement
|
||||||
|
|
||||||
|
You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.
|
||||||
|
|
||||||
|
We want to be transparent about why we require the CLA and what it means for your contributions and the codebase. That’s why we’ve written a few paragraphs below outlining our plans and vision for solidtime in the **Vision** part of this document.
|
||||||
|
|
||||||
|
### Prevent Duplicate Work
|
||||||
|
|
||||||
|
Before you submit a new PR, make sure that none exists already. If you plan to work on an issue, make sure to let us and others know by commenting on the issue/discussion.
|
||||||
|
|
||||||
|
### Give context
|
||||||
|
|
||||||
|
Tell us what you thinking was behind the decisions you made while drafting the PR. Treat the PR itself as documentation for everyone who wants to go back and understand why certain decisions were made.
|
||||||
|
|
||||||
|
### Summarize your PR
|
||||||
|
|
||||||
|
Please make sure to include a short summary at the top of your PR to make it easy for us to quickly check what the PR is about, without looking at the code changes.
|
||||||
|
|
||||||
|
### Use Github Keywords and Auto-Link Issues
|
||||||
|
|
||||||
|
Use phrases like "Closes #123" or "Fixes #123" in the PR description to link the PR with the issue that you are adressing.
|
||||||
|
|
||||||
|
### Mention what you tested and how
|
||||||
|
|
||||||
|
Explain how you tested and validated the implementation.
|
||||||
|
|
||||||
|
### Keep Naming consistent
|
||||||
|
|
||||||
|
Look at existing code patterns and use naming conventions that already exist in the code base.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
We have an exhaustive test-suite of PHPUnit (Backend) and Playwright (Frontend) testing. Whereever applicable please make sure to write add tests to the codebase.
|
||||||
|
|
||||||
|
### Linting & Formatting
|
||||||
|
|
||||||
|
Make sure to run linting and formatting commands before you commit the changes.
|
||||||
|
|
||||||
|
For backend changes:
|
||||||
|
|
||||||
|
```
|
||||||
|
composer fix
|
||||||
|
composer analyse
|
||||||
|
```
|
||||||
|
|
||||||
|
For frontend changes:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run lint:fix
|
||||||
|
npm run format
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vision
|
||||||
|
|
||||||
|
We started solidtime to provide an open infrastructure solution for time tracking—one that empowers teams and individuals to fully own their data, instead of depending on proprietary platforms. We believe infrastructure software should be open, accessible, and built to last. However, competing with established market leaders in this space requires long-term financial sustainability.
|
||||||
|
|
||||||
|
solidtime is licensed under the AGPL, which we believe is the best available license to strike a balance between openness and financial viability. The AGPL gives us, as the copyright holders, certain exclusive rights that we plan to leverage to fund development. To ensure we retain those rights across the entire codebase, we've put a CLA in place that contributors must sign before submitting code.
|
||||||
|
|
||||||
|
One of solidtime’s key advantages is that it's built to be self-hostable. This makes it a great solution for organizations like governments, healthcare providers, and enterprises that are required to keep data on their own infrastructure due to regulations or internal policies. These organizations may need custom licenses, integrations, or modifications that aren't suitable for the open-source version. To support them, we offer relicensed versions of solidtime along with support plans.
|
||||||
|
|
||||||
|
We’ll also provide proprietary extensions for solidtime. These will be available to enterprise customers with support plans, but also to individual users or teams who don’t need support, at much more accessible price points. For companies running solidtime on their own infrastructure, this is the easiest way to support the project while gaining additional functionality. While we plan to make it easier to build custom extensions in the future, our current APIs are still highly experimental.
|
||||||
|
|
||||||
|
Finally - and perhaps most importantly - we offer a hosted SaaS version called solidtime Cloud, for users who can’t or don’t want to run the software themselves. This version includes proprietary extensions, always runs the latest commit, and includes monitoring and billing features available exclusively on this hosted instance. We expect solidtime Cloud to play a critical role in funding the project long-term.
|
||||||
|
|
||||||
|
Having full control over the source code’s licensing also gives us the ability to change the license of the main project in the future. That said, we have no plans to do so and would only consider it in extreme cases - for example, if a malicious actor were to directly compete with our hosted service in a way that threatens the sustainability of the project, the legal interpretation of AGPL changes in a way that would make it unreasonable to use for certain companies, or a new similar license gains wide-spread adoption. Regardless, solidtime will always remain free to self-host for individuals and companies who use it as part of their work, and all previous releases will remain licensed under AGPL.
|
||||||
|
|
||||||
|
If you are using the open-source version of solidtime and want to support us, the best way to do so is to spread the word.
|
||||||
@@ -35,10 +35,9 @@ If you have a **feature request**, please [**create a discussion**](https://gith
|
|||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
This project is in a very early stage. The structure and APIs are still subject to change and not stable.
|
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
||||||
Therefore, we do not currently accept any contributions, unless you are a member of the team.
|
|
||||||
|
|
||||||
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
|
||||||
|
|
||||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Exceptions\MovedToApiException;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\MemberService;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Illuminate\Validation\Rules\In;
|
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
|
||||||
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||||
|
|
||||||
class AddOrganizationMember implements AddsTeamMembers
|
class AddOrganizationMember implements AddsTeamMembers
|
||||||
@@ -25,70 +16,6 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
*/
|
*/
|
||||||
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
||||||
{
|
{
|
||||||
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring
|
throw new MovedToApiException;
|
||||||
|
|
||||||
$this->validate($organization, $email, $role);
|
|
||||||
|
|
||||||
$newOrganizationMember = User::query()
|
|
||||||
->where('email', $email)
|
|
||||||
->where('is_placeholder', '=', false)
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the add member operation.
|
|
||||||
*/
|
|
||||||
protected function validate(Organization $organization, string $email, ?string $role): void
|
|
||||||
{
|
|
||||||
Validator::make([
|
|
||||||
'email' => $email,
|
|
||||||
'role' => $role,
|
|
||||||
], $this->rules())->after(
|
|
||||||
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
|
||||||
)->validateWithBag('addTeamMember');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules for adding a team member.
|
|
||||||
*
|
|
||||||
* @return array<string, array<ValidationRule|Rule|string|In>>
|
|
||||||
*/
|
|
||||||
protected function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'email' => [
|
|
||||||
'required',
|
|
||||||
'email',
|
|
||||||
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
|
|
||||||
/** @var Builder<User> $builder */
|
|
||||||
return $builder->where('is_placeholder', '=', false);
|
|
||||||
})->withMessage(__('We were unable to find a registered user with this email address.')),
|
|
||||||
],
|
|
||||||
'role' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
Rule::in([
|
|
||||||
Role::Admin->value,
|
|
||||||
Role::Manager->value,
|
|
||||||
Role::Employee->value,
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure that the user is not already on the team.
|
|
||||||
*/
|
|
||||||
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
|
|
||||||
{
|
|
||||||
return function ($validator) use ($team, $email): void {
|
|
||||||
$validator->errors()->addIf(
|
|
||||||
$team->hasRealUserWithEmail($email),
|
|
||||||
'email',
|
|
||||||
__('This user already belongs to the team.')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class CreateOrganization implements CreatesTeams
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function create(User $user, array $input): Organization
|
public function create(User $user, array $input): Organization
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class DeleteOrganization implements DeletesTeams
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Delete the given team.
|
* Delete the given team.
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function delete(Organization $organization): void
|
public function delete(Organization $organization): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class DeleteUser implements DeletesUsers
|
|||||||
* Delete the given user.
|
* Delete the given user.
|
||||||
*
|
*
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function delete(User $user): void
|
public function delete(User $user): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ class ValidateOrganizationDeletion
|
|||||||
* @param Organization $organization Organization to be deleted
|
* @param Organization $organization Organization to be deleted
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function validate(User $user, Organization $organization): void
|
public function validate(User $user, Organization $organization): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,13 +22,27 @@ class Kernel extends ConsoleKernel
|
|||||||
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
|
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
|
||||||
->everyTenMinutes();
|
->everyTenMinutes();
|
||||||
|
|
||||||
$schedule->command('self-host:check-for-update')
|
if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) {
|
||||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
// Convert string to a stable integer for seeding
|
||||||
->twiceDaily();
|
/** @var int $seed Take the first 8 hex chars → 32-bit int */
|
||||||
|
$seed = hexdec(substr(hash('md5', config('app.key')), 0, 8));
|
||||||
|
$seed = abs($seed); // Ensure it's positive
|
||||||
|
mt_srand($seed);
|
||||||
|
$firstHour = mt_rand(0, 23);
|
||||||
|
$secondHour = ($firstHour + 12) % 24;
|
||||||
|
$minuteOffset = mt_rand(0, 59);
|
||||||
|
mt_srand(null); // Reset the random number generator
|
||||||
|
|
||||||
$schedule->command('self-host:telemetry')
|
if (config('scheduling.tasks.self_hosting_check_for_update')) {
|
||||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
|
$schedule->command('self-host:check-for-update')
|
||||||
->twiceDaily();
|
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config('scheduling.tasks.self_hosting_telemetry')) {
|
||||||
|
$schedule->command('self-host:telemetry')
|
||||||
|
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$schedule->command('self-host:database-consistency')
|
$schedule->command('self-host:database-consistency')
|
||||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
|
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ enum TimeEntryAggregationType: string
|
|||||||
case Client = 'client';
|
case Client = 'client';
|
||||||
case Billable = 'billable';
|
case Billable = 'billable';
|
||||||
case Description = 'description';
|
case Description = 'description';
|
||||||
|
case Tag = 'tag';
|
||||||
|
|
||||||
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
||||||
{
|
{
|
||||||
|
|||||||
16
app/Enums/TimeEntryRoundingType.php
Normal file
16
app/Enums/TimeEntryRoundingType.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||||
|
|
||||||
|
enum TimeEntryRoundingType: string
|
||||||
|
{
|
||||||
|
use LaravelEnumHelper;
|
||||||
|
|
||||||
|
case Up = 'up';
|
||||||
|
case Down = 'down';
|
||||||
|
case Nearest = 'nearest';
|
||||||
|
}
|
||||||
28
app/Events/MemberAdded.php
Normal file
28
app/Events/MemberAdded.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
class MemberAdded
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
public Member $member;
|
||||||
|
|
||||||
|
public Organization $organization;
|
||||||
|
|
||||||
|
public User $user;
|
||||||
|
|
||||||
|
public function __construct(Member $member, Organization $organization, User $user)
|
||||||
|
{
|
||||||
|
$this->member = $member;
|
||||||
|
$this->organization = $organization;
|
||||||
|
$this->user = $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Events/MemberAdding.php
Normal file
28
app/Events/MemberAdding.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
class MemberAdding
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
public User $user;
|
||||||
|
|
||||||
|
public Organization $organization;
|
||||||
|
|
||||||
|
public Role $role;
|
||||||
|
|
||||||
|
public function __construct(User $user, Organization $organization, Role $role)
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
$this->organization = $organization;
|
||||||
|
$this->role = $role;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/Exceptions/Api/OverlappingTimeEntryApiException.php
Normal file
10
app/Exceptions/Api/OverlappingTimeEntryApiException.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class OverlappingTimeEntryApiException extends ApiException
|
||||||
|
{
|
||||||
|
public const string KEY = 'overlapping_time_entry';
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\TimeEntryResource\Pages;
|
use App\Filament\Resources\TimeEntryResource\Pages;
|
||||||
|
use App\Models\Member;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@@ -16,6 +17,7 @@ use Filament\Tables;
|
|||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
class TimeEntryResource extends Resource
|
class TimeEntryResource extends Resource
|
||||||
{
|
{
|
||||||
@@ -51,15 +53,23 @@ class TimeEntryResource extends Resource
|
|||||||
->rules([
|
->rules([
|
||||||
'after_or_equal:start',
|
'after_or_equal:start',
|
||||||
]),
|
]),
|
||||||
Select::make('user_id')
|
Select::make('member_id')
|
||||||
->relationship(name: 'user', titleAttribute: 'email')
|
->relationship(
|
||||||
->searchable(['name', 'email'])
|
name: 'member',
|
||||||
|
titleAttribute: 'id',
|
||||||
|
modifyQueryUsing: fn (Builder $query) => $query->with(['user', 'organization'])
|
||||||
|
)
|
||||||
|
->getOptionLabelFromRecordUsing(fn (Member $record): string => $record->user->email.' ('.$record->organization->name.')')
|
||||||
|
->searchable()
|
||||||
->required(),
|
->required(),
|
||||||
Select::make('project_id')
|
Select::make('project_id')
|
||||||
->relationship(name: 'project', titleAttribute: 'name')
|
->relationship(name: 'project', titleAttribute: 'name')
|
||||||
->searchable(['name'])
|
->searchable(['name'])
|
||||||
->nullable(),
|
->nullable(),
|
||||||
// TODO
|
Select::make('task_id')
|
||||||
|
->relationship(name: 'task', titleAttribute: 'name')
|
||||||
|
->searchable(['name'])
|
||||||
|
->nullable(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,28 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TimeEntryResource;
|
use App\Filament\Resources\TimeEntryResource;
|
||||||
|
use App\Models\Member;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
class CreateTimeEntry extends CreateRecord
|
class CreateTimeEntry extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = TimeEntryResource::class;
|
protected static string $resource = TimeEntryResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
if (isset($data['member_id'])) {
|
||||||
|
/** @var Member|null $member */
|
||||||
|
$member = Member::query()->find($data['member_id']);
|
||||||
|
if ($member !== null) {
|
||||||
|
$data['user_id'] = $member->user_id;
|
||||||
|
$data['organization_id'] = $member->organization_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TimeEntryResource;
|
use App\Filament\Resources\TimeEntryResource;
|
||||||
|
use App\Models\Member;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
@@ -19,4 +20,22 @@ class EditTimeEntry extends EditRecord
|
|||||||
->icon('heroicon-m-trash'),
|
->icon('heroicon-m-trash'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
if (isset($data['member_id'])) {
|
||||||
|
/** @var Member|null $member */
|
||||||
|
$member = Member::query()->find($data['member_id']);
|
||||||
|
if ($member !== null) {
|
||||||
|
$data['user_id'] = $member->user_id;
|
||||||
|
$data['organization_id'] = $member->organization_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class ApiTokenController extends Controller
|
|||||||
/** @var Builder<Client> $query */
|
/** @var Builder<Client> $query */
|
||||||
$query->whereJsonContains('grant_types', 'personal_access');
|
$query->whereJsonContains('grant_types', 'personal_access');
|
||||||
})
|
})
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return new ApiTokenCollection($tokens);
|
return new ApiTokenCollection($tokens);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ use Illuminate\Http\JsonResponse;
|
|||||||
class ChartController extends Controller
|
class ChartController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
* Get chart data for the weekly project overview.
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId weeklyProjectOverview
|
* @operationId weeklyProjectOverview
|
||||||
@@ -31,6 +33,8 @@ class ChartController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Get chart data for the latest tasks.
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId latestTasks
|
* @operationId latestTasks
|
||||||
@@ -48,6 +52,8 @@ class ChartController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Get chart data for the last seven days.
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId lastSevenDays
|
* @operationId lastSevenDays
|
||||||
@@ -65,6 +71,8 @@ class ChartController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Get chart data for the latest team activity.
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId latestTeamActivity
|
* @operationId latestTeamActivity
|
||||||
@@ -81,6 +89,8 @@ class ChartController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Get chart data for daily tracked hours.
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId dailyTrackedHours
|
* @operationId dailyTrackedHours
|
||||||
@@ -92,12 +102,14 @@ class ChartController extends Controller
|
|||||||
$this->checkPermission($organization, 'charts:view:own');
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
|
|
||||||
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 100);
|
||||||
|
|
||||||
return response()->json($dailyTrackedHours);
|
return response()->json($dailyTrackedHours);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Get chart data for total weekly time.
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId totalWeeklyTime
|
* @operationId totalWeeklyTime
|
||||||
@@ -115,6 +127,8 @@ class ChartController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Get chart data for total weekly billable time.
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId totalWeeklyBillableTime
|
* @operationId totalWeeklyBillableTime
|
||||||
@@ -132,6 +146,8 @@ class ChartController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Get chart data for total weekly billable amount.
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId totalWeeklyBillableAmount
|
* @operationId totalWeeklyBillableAmount
|
||||||
@@ -154,6 +170,8 @@ class ChartController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Get chart data for weekly history.
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId weeklyHistory
|
* @operationId weeklyHistory
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ class ClientController extends Controller
|
|||||||
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
|
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'clients:view');
|
$this->checkPermission($organization, 'clients:view');
|
||||||
|
$canViewAllClients = $this->hasPermission($organization, 'clients:view:all');
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
$clientsQuery = Client::query()
|
$clientsQuery = Client::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->orderBy('created_at', 'desc');
|
->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
|
if (! $canViewAllClients) {
|
||||||
|
$clientsQuery->visibleByEmployee($user);
|
||||||
|
}
|
||||||
|
|
||||||
$filterArchived = $request->getFilterArchived();
|
$filterArchived = $request->getFilterArchived();
|
||||||
if ($filterArchived === 'true') {
|
if ($filterArchived === 'true') {
|
||||||
$clientsQuery->whereNotNull('archived_at');
|
$clientsQuery->whereNotNull('archived_at');
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class InvitationController extends Controller
|
|||||||
$this->checkPermission($organization, 'invitations:view');
|
$this->checkPermission($organization, 'invitations:view');
|
||||||
|
|
||||||
$invitations = $organization->teamInvitations()
|
$invitations = $organization->teamInvitations()
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return InvitationCollection::make($invitations);
|
return InvitationCollection::make($invitations);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class MemberController extends Controller
|
|||||||
$members = Member::query()
|
$members = Member::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->with(['user'])
|
->with(['user'])
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return MemberCollection::make($members);
|
return MemberCollection::make($members);
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Events\AfterCreateOrganization;
|
||||||
|
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
||||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||||
use App\Http\Resources\V1\Organization\OrganizationResource;
|
use App\Http\Resources\V1\Organization\OrganizationResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Service\BillableRateService;
|
use App\Service\BillableRateService;
|
||||||
|
use App\Service\DeletionService;
|
||||||
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
|
use App\Service\OrganizationService;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class OrganizationController extends Controller
|
class OrganizationController extends Controller
|
||||||
{
|
{
|
||||||
@@ -46,6 +52,9 @@ class OrganizationController extends Controller
|
|||||||
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
||||||
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
||||||
}
|
}
|
||||||
|
if ($request->getEmployeesCanManageTasks() !== null) {
|
||||||
|
$organization->employees_can_manage_tasks = $request->getEmployeesCanManageTasks();
|
||||||
|
}
|
||||||
if ($request->getNumberFormat() !== null) {
|
if ($request->getNumberFormat() !== null) {
|
||||||
$organization->number_format = $request->getNumberFormat();
|
$organization->number_format = $request->getNumberFormat();
|
||||||
}
|
}
|
||||||
@@ -61,6 +70,9 @@ class OrganizationController extends Controller
|
|||||||
if ($request->getTimeFormat() !== null) {
|
if ($request->getTimeFormat() !== null) {
|
||||||
$organization->time_format = $request->getTimeFormat();
|
$organization->time_format = $request->getTimeFormat();
|
||||||
}
|
}
|
||||||
|
if ($request->getPreventOverlappingTimeEntries() !== null) {
|
||||||
|
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
|
||||||
|
}
|
||||||
$hasBillableRate = $request->has('billable_rate');
|
$hasBillableRate = $request->has('billable_rate');
|
||||||
if ($hasBillableRate) {
|
if ($hasBillableRate) {
|
||||||
$oldBillableRate = $organization->billable_rate;
|
$oldBillableRate = $organization->billable_rate;
|
||||||
@@ -74,4 +86,48 @@ class OrganizationController extends Controller
|
|||||||
|
|
||||||
return new OrganizationResource($organization, true);
|
return new OrganizationResource($organization, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create organization
|
||||||
|
*
|
||||||
|
* @operationId createOrganization
|
||||||
|
*/
|
||||||
|
public function store(OrganizationStoreRequest $request, OrganizationService $organizationService): OrganizationResource
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup($request->ip());
|
||||||
|
|
||||||
|
$currency = $ipLookupResponse?->currency;
|
||||||
|
|
||||||
|
$organization = $organizationService->createOrganization(
|
||||||
|
$request->getName(),
|
||||||
|
$user,
|
||||||
|
false,
|
||||||
|
$currency
|
||||||
|
);
|
||||||
|
|
||||||
|
$user->switchTeam($organization);
|
||||||
|
|
||||||
|
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
|
||||||
|
$organization = $organization->refresh();
|
||||||
|
AfterCreateOrganization::dispatch($organization);
|
||||||
|
|
||||||
|
return new OrganizationResource($organization, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete organization
|
||||||
|
*
|
||||||
|
* @operationId deleteOrganization
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'organizations:delete');
|
||||||
|
|
||||||
|
$deletionService->deleteOrganization($organization);
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ class ProjectController extends Controller
|
|||||||
$projectsQuery->whereNull('archived_at');
|
$projectsQuery->whereNull('archived_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
|
$projects = $projectsQuery
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
|
|||||||
|
|
||||||
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
||||||
|
use App\Http\Requests\V1\ProjectMember\ProjectMemberIndexRequest;
|
||||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest;
|
use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest;
|
||||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
|
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
|
||||||
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
|
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
|
||||||
@@ -41,12 +42,13 @@ class ProjectMemberController extends Controller
|
|||||||
*
|
*
|
||||||
* @operationId getProjectMembers
|
* @operationId getProjectMembers
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization, Project $project): ProjectMemberCollection
|
public function index(Organization $organization, Project $project, ProjectMemberIndexRequest $request): ProjectMemberCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'project-members:view', $project);
|
$this->checkPermission($organization, 'project-members:view', $project);
|
||||||
|
|
||||||
$projectMembers = ProjectMember::query()
|
$projectMembers = ProjectMember::query()
|
||||||
->whereBelongsTo($project, 'project')
|
->whereBelongsTo($project, 'project')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return new ProjectMemberCollection($projectMembers);
|
return new ProjectMemberCollection($projectMembers);
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ class ReportController extends Controller
|
|||||||
false,
|
false,
|
||||||
$report->properties->start,
|
$report->properties->start,
|
||||||
$report->properties->end,
|
$report->properties->end,
|
||||||
true
|
true,
|
||||||
|
$report->properties->roundingType,
|
||||||
|
$report->properties->roundingMinutes,
|
||||||
);
|
);
|
||||||
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||||
$timeEntriesQuery->clone(),
|
$timeEntriesQuery->clone(),
|
||||||
@@ -84,7 +86,9 @@ class ReportController extends Controller
|
|||||||
true,
|
true,
|
||||||
$report->properties->start,
|
$report->properties->start,
|
||||||
$report->properties->end,
|
$report->properties->end,
|
||||||
true
|
true,
|
||||||
|
$report->properties->roundingType,
|
||||||
|
$report->properties->roundingMinutes,
|
||||||
);
|
);
|
||||||
|
|
||||||
return new DetailedWithDataReportResource($report, $data, $historyData);
|
return new DetailedWithDataReportResource($report, $data, $historyData);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
|
use App\Http\Requests\V1\Report\ReportIndexRequest;
|
||||||
use App\Http\Requests\V1\Report\ReportStoreRequest;
|
use App\Http\Requests\V1\Report\ReportStoreRequest;
|
||||||
use App\Http\Requests\V1\Report\ReportUpdateRequest;
|
use App\Http\Requests\V1\Report\ReportUpdateRequest;
|
||||||
use App\Http\Resources\V1\Report\DetailedReportResource;
|
use App\Http\Resources\V1\Report\DetailedReportResource;
|
||||||
@@ -40,7 +41,7 @@ class ReportController extends Controller
|
|||||||
*
|
*
|
||||||
* @operationId getReports
|
* @operationId getReports
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization): ReportCollection
|
public function index(Organization $organization, ReportIndexRequest $request): ReportCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'reports:view');
|
$this->checkPermission($organization, 'reports:view');
|
||||||
|
|
||||||
@@ -107,6 +108,8 @@ class ReportController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$properties->timezone = $timezone;
|
$properties->timezone = $timezone;
|
||||||
|
$properties->roundingType = $request->getPropertyRoundingType();
|
||||||
|
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
|
||||||
$report->properties = $properties;
|
$report->properties = $properties;
|
||||||
if ($isPublic) {
|
if ($isPublic) {
|
||||||
$report->share_secret = $reportService->generateSecret();
|
$report->share_secret = $reportService->generateSecret();
|
||||||
@@ -148,6 +151,9 @@ class ReportController extends Controller
|
|||||||
$report->share_secret = null;
|
$report->share_secret = null;
|
||||||
$report->public_until = null;
|
$report->public_until = null;
|
||||||
}
|
}
|
||||||
|
} elseif ($report->is_public && $request->has('public_until')) {
|
||||||
|
// Allow updating expiration date on already-public reports
|
||||||
|
$report->public_until = $request->getPublicUntil();
|
||||||
}
|
}
|
||||||
$report->save();
|
$report->save();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||||
|
use App\Http\Requests\V1\Tag\TagIndexRequest;
|
||||||
use App\Http\Requests\V1\Tag\TagStoreRequest;
|
use App\Http\Requests\V1\Tag\TagStoreRequest;
|
||||||
use App\Http\Requests\V1\Tag\TagUpdateRequest;
|
use App\Http\Requests\V1\Tag\TagUpdateRequest;
|
||||||
use App\Http\Resources\V1\Tag\TagCollection;
|
use App\Http\Resources\V1\Tag\TagCollection;
|
||||||
@@ -34,7 +35,7 @@ class TagController extends Controller
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization): TagCollection
|
public function index(Organization $organization, TagIndexRequest $request): TagCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'tags:view');
|
$this->checkPermission($organization, 'tags:view');
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Http\Requests\V1\Task\TaskUpdateRequest;
|
|||||||
use App\Http\Resources\V1\Task\TaskCollection;
|
use App\Http\Resources\V1\Task\TaskCollection;
|
||||||
use App\Http\Resources\V1\Task\TaskResource;
|
use App\Http\Resources\V1\Task\TaskResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Models\Project;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -27,6 +28,26 @@ class TaskController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check scoped permission and verify user has access to the project
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
private function checkScopedPermissionForProject(Organization $organization, Project $project, string $permission): void
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, $permission);
|
||||||
|
|
||||||
|
$user = $this->user();
|
||||||
|
$hasAccess = Project::query()
|
||||||
|
->where('id', $project->id)
|
||||||
|
->visibleByEmployee($user)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $hasAccess) {
|
||||||
|
throw new AuthorizationException('You do not have permission to '.$permission.' in this project.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tasks
|
* Get tasks
|
||||||
*
|
*
|
||||||
@@ -61,7 +82,9 @@ class TaskController extends Controller
|
|||||||
$query->whereNull('done_at');
|
$query->whereNull('done_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
$tasks = $query->paginate(config('app.pagination_per_page_default'));
|
$tasks = $query
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return new TaskCollection($tasks);
|
return new TaskCollection($tasks);
|
||||||
}
|
}
|
||||||
@@ -75,7 +98,15 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
|
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'tasks:create');
|
/** @var Project $project */
|
||||||
|
$project = Project::query()->findOrFail($request->input('project_id'));
|
||||||
|
|
||||||
|
if ($this->hasPermission($organization, 'tasks:create:all')) {
|
||||||
|
$this->checkPermission($organization, 'tasks:create:all');
|
||||||
|
} else {
|
||||||
|
$this->checkScopedPermissionForProject($organization, $project, 'tasks:create');
|
||||||
|
}
|
||||||
|
|
||||||
$task = new Task;
|
$task = new Task;
|
||||||
$task->name = $request->input('name');
|
$task->name = $request->input('name');
|
||||||
$task->project_id = $request->input('project_id');
|
$task->project_id = $request->input('project_id');
|
||||||
@@ -97,7 +128,17 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
|
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'tasks:update', $task);
|
// Check task belongs to organization
|
||||||
|
if ($task->organization_id !== $organization->id) {
|
||||||
|
throw new AuthorizationException('Task does not belong to organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasPermission($organization, 'tasks:update:all')) {
|
||||||
|
$this->checkPermission($organization, 'tasks:update:all');
|
||||||
|
} else {
|
||||||
|
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:update');
|
||||||
|
}
|
||||||
|
|
||||||
$task->name = $request->input('name');
|
$task->name = $request->input('name');
|
||||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||||
$task->estimated_time = $request->getEstimatedTime();
|
$task->estimated_time = $request->getEstimatedTime();
|
||||||
@@ -119,7 +160,16 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function destroy(Organization $organization, Task $task): JsonResponse
|
public function destroy(Organization $organization, Task $task): JsonResponse
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'tasks:delete', $task);
|
// Check task belongs to organization
|
||||||
|
if ($task->organization_id !== $organization->id) {
|
||||||
|
throw new AuthorizationException('Task does not belong to organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasPermission($organization, 'tasks:delete:all')) {
|
||||||
|
$this->checkPermission($organization, 'tasks:delete:all');
|
||||||
|
} else {
|
||||||
|
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:delete');
|
||||||
|
}
|
||||||
|
|
||||||
if ($task->timeEntries()->exists()) {
|
if ($task->timeEntries()->exists()) {
|
||||||
throw new EntityStillInUseApiException('task', 'time_entry');
|
throw new EntityStillInUseApiException('task', 'time_entry');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api\V1;
|
|||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||||
|
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
@@ -33,6 +34,7 @@ use App\Service\ReportExport\TimeEntriesDetailedExport;
|
|||||||
use App\Service\ReportExport\TimeEntriesReportExport;
|
use App\Service\ReportExport\TimeEntriesReportExport;
|
||||||
use App\Service\TimeEntryAggregationService;
|
use App\Service\TimeEntryAggregationService;
|
||||||
use App\Service\TimeEntryFilter;
|
use App\Service\TimeEntryFilter;
|
||||||
|
use App\Service\TimeEntryService;
|
||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
use Gotenberg\Exceptions\GotenbergApiErrored;
|
use Gotenberg\Exceptions\GotenbergApiErrored;
|
||||||
use Gotenberg\Exceptions\NoOutputFileInResponse;
|
use Gotenberg\Exceptions\NoOutputFileInResponse;
|
||||||
@@ -44,9 +46,11 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
use Illuminate\Http\File;
|
use Illuminate\Http\File;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Maatwebsite\Excel\Facades\Excel;
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
@@ -54,6 +58,43 @@ use Spatie\TemporaryDirectory\TemporaryDirectory;
|
|||||||
|
|
||||||
class TimeEntryController extends Controller
|
class TimeEntryController extends Controller
|
||||||
{
|
{
|
||||||
|
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
|
||||||
|
{
|
||||||
|
if (! $organization->prevent_overlapping_time_entries) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = TimeEntry::query()
|
||||||
|
->where('organization_id', $organization->getKey())
|
||||||
|
->where('user_id', $member->user_id)
|
||||||
|
->when($exclude !== null, function (Builder $q) use ($exclude): void {
|
||||||
|
$q->where('id', '!=', $exclude->getKey());
|
||||||
|
})
|
||||||
|
->where(function (Builder $q) use ($start, $end): void {
|
||||||
|
$q->where(function (Builder $q2) use ($start): void {
|
||||||
|
$q2->where('end', '>', $start)
|
||||||
|
->where('start', '<', $start);
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($end !== null) {
|
||||||
|
$q->orWhere(function (Builder $q4) use ($end): void {
|
||||||
|
$q4->where('start', '<', $end)
|
||||||
|
->where('end', '>', $end);
|
||||||
|
});
|
||||||
|
// Check if the new entry completely surrounds an existing entry
|
||||||
|
$q->orWhere(function (Builder $q6) use ($start, $end): void {
|
||||||
|
$q6->where('start', '>=', $start)
|
||||||
|
->where('end', '<=', $end);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($query->exists()) {
|
||||||
|
throw new OverlappingTimeEntryApiException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
|
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
|
||||||
{
|
{
|
||||||
parent::checkPermission($organization, $permission);
|
parent::checkPermission($organization, $permission);
|
||||||
@@ -84,7 +125,8 @@ class TimeEntryController extends Controller
|
|||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||||
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||||
|
|
||||||
$totalCount = $timeEntriesQuery->count();
|
$totalCount = $timeEntriesQuery->count();
|
||||||
|
|
||||||
@@ -138,10 +180,19 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* @return Builder<TimeEntry>
|
* @return Builder<TimeEntry>
|
||||||
*/
|
*/
|
||||||
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
|
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member, bool $canAccessPremiumFeatures): Builder
|
||||||
{
|
{
|
||||||
|
$select = TimeEntry::SELECT_COLUMNS;
|
||||||
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
if ($roundingType !== null && $roundingMinutes !== null) {
|
||||||
|
$select = array_diff($select, ['start', 'end']);
|
||||||
|
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start');
|
||||||
|
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end');
|
||||||
|
}
|
||||||
$timeEntriesQuery = TimeEntry::query()
|
$timeEntriesQuery = TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->select($select)
|
||||||
->orderBy('start', 'desc');
|
->orderBy('start', 'desc');
|
||||||
|
|
||||||
$filter = new TimeEntryFilter($timeEntriesQuery);
|
$filter = new TimeEntryFilter($timeEntriesQuery);
|
||||||
@@ -175,16 +226,19 @@ class TimeEntryController extends Controller
|
|||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||||
$debug = $request->getDebug();
|
$debug = $request->getDebug();
|
||||||
$format = $request->getFormatValue();
|
$format = $request->getFormatValue();
|
||||||
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
if ($format === ExportFormat::PDF && ! $canAccessPremiumFeatures) {
|
||||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||||
}
|
}
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$timezone = $user->timezone;
|
$timezone = $user->timezone;
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||||
$timeEntriesQuery->with([
|
$timeEntriesQuery->with([
|
||||||
'task',
|
'task',
|
||||||
'client',
|
'client',
|
||||||
@@ -207,8 +261,9 @@ 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, $member);
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesQuery->clone()->reorder()->withOnly([]),
|
$timeEntriesAggregateQuery,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
$user->timezone,
|
$user->timezone,
|
||||||
@@ -216,7 +271,9 @@ class TimeEntryController extends Controller
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
$showBillableRate
|
$showBillableRate,
|
||||||
|
$roundingType,
|
||||||
|
$roundingMinutes,
|
||||||
);
|
);
|
||||||
$html = Blade::render($viewFile, [
|
$html = Blade::render($viewFile, [
|
||||||
'timeEntries' => $timeEntriesQuery->get(),
|
'timeEntries' => $timeEntriesQuery->get(),
|
||||||
@@ -318,12 +375,15 @@ class TimeEntryController extends Controller
|
|||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|
||||||
$group1Type = $request->getGroup();
|
$group1Type = $request->getGroup();
|
||||||
$group2Type = $request->getSubGroup();
|
$group2Type = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesAggregateQuery,
|
$timeEntriesAggregateQuery,
|
||||||
@@ -334,7 +394,9 @@ class TimeEntryController extends Controller
|
|||||||
$request->getFillGapsInTimeGroups(),
|
$request->getFillGapsInTimeGroups(),
|
||||||
$request->getStart(),
|
$request->getStart(),
|
||||||
$request->getEnd(),
|
$request->getEnd(),
|
||||||
$showBillableRate
|
$showBillableRate,
|
||||||
|
$roundingType,
|
||||||
|
$roundingMinutes
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -362,6 +424,7 @@ class TimeEntryController extends Controller
|
|||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||||
$format = $request->getFormatValue();
|
$format = $request->getFormatValue();
|
||||||
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
||||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||||
@@ -373,6 +436,8 @@ class TimeEntryController extends Controller
|
|||||||
$group = $request->getGroup();
|
$group = $request->getGroup();
|
||||||
$subGroup = $request->getSubGroup();
|
$subGroup = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||||
$timeEntriesAggregateQuery->clone(),
|
$timeEntriesAggregateQuery->clone(),
|
||||||
@@ -383,7 +448,9 @@ class TimeEntryController extends Controller
|
|||||||
false,
|
false,
|
||||||
$request->getStart(),
|
$request->getStart(),
|
||||||
$request->getEnd(),
|
$request->getEnd(),
|
||||||
$showBillableRate
|
$showBillableRate,
|
||||||
|
$roundingType,
|
||||||
|
$roundingMinutes
|
||||||
);
|
);
|
||||||
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesAggregateQuery->clone(),
|
$timeEntriesAggregateQuery->clone(),
|
||||||
@@ -394,7 +461,9 @@ class TimeEntryController extends Controller
|
|||||||
true,
|
true,
|
||||||
$request->getStart(),
|
$request->getStart(),
|
||||||
$request->getEnd(),
|
$request->getEnd(),
|
||||||
$showBillableRate
|
$showBillableRate,
|
||||||
|
$roundingType,
|
||||||
|
$roundingMinutes
|
||||||
);
|
);
|
||||||
$currency = $organization->currency;
|
$currency = $organization->currency;
|
||||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
||||||
@@ -477,7 +546,7 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* @return Builder<TimeEntry>
|
* @return Builder<TimeEntry>
|
||||||
*/
|
*/
|
||||||
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder
|
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
|
||||||
{
|
{
|
||||||
$timeEntriesQuery = TimeEntry::query()
|
$timeEntriesQuery = TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization');
|
->whereBelongsTo($organization, 'organization');
|
||||||
@@ -519,17 +588,15 @@ class TimeEntryController extends Controller
|
|||||||
throw new TimeEntryStillRunningApiException;
|
throw new TimeEntryStillRunningApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overlap check for create
|
||||||
|
$start = Carbon::parse($request->input('start'));
|
||||||
|
$end = $request->input('end') !== null ? Carbon::parse($request->input('end')) : null;
|
||||||
|
$this->assertNoOverlap($organization, $member, $start, $end);
|
||||||
|
|
||||||
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
|
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
|
||||||
$client = $project?->client;
|
$client = $project?->client;
|
||||||
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
|
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
|
||||||
|
|
||||||
if ($project !== null) {
|
|
||||||
RecalculateSpentTimeForProject::dispatch($project);
|
|
||||||
}
|
|
||||||
if ($task !== null) {
|
|
||||||
RecalculateSpentTimeForTask::dispatch($task);
|
|
||||||
}
|
|
||||||
|
|
||||||
$timeEntry = new TimeEntry;
|
$timeEntry = new TimeEntry;
|
||||||
$timeEntry->fill($request->validated());
|
$timeEntry->fill($request->validated());
|
||||||
$timeEntry->client()->associate($client);
|
$timeEntry->client()->associate($client);
|
||||||
@@ -539,6 +606,13 @@ class TimeEntryController extends Controller
|
|||||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||||
$timeEntry->save();
|
$timeEntry->save();
|
||||||
|
|
||||||
|
if ($project !== null) {
|
||||||
|
RecalculateSpentTimeForProject::dispatch($project);
|
||||||
|
}
|
||||||
|
if ($task !== null) {
|
||||||
|
RecalculateSpentTimeForTask::dispatch($task);
|
||||||
|
}
|
||||||
|
|
||||||
return new TimeEntryResource($timeEntry);
|
return new TimeEntryResource($timeEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,6 +637,13 @@ class TimeEntryController extends Controller
|
|||||||
throw new TimeEntryCanNotBeRestartedApiException;
|
throw new TimeEntryCanNotBeRestartedApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overlap check for update (exclude current)
|
||||||
|
/** @var Member $effectiveMember */
|
||||||
|
$effectiveMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : $timeEntry->member;
|
||||||
|
$effectiveStart = $request->has('start') ? Carbon::parse($request->input('start')) : $timeEntry->start;
|
||||||
|
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
|
||||||
|
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
|
||||||
|
|
||||||
$oldProject = $timeEntry->project;
|
$oldProject = $timeEntry->project;
|
||||||
$oldTask = $timeEntry->task;
|
$oldTask = $timeEntry->task;
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
use App\Http\Resources\V1\User\UserResource;
|
use App\Http\Resources\V1\User\UserResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\DeletionService;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class UserController extends Controller
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
@@ -24,4 +28,29 @@ class UserController extends Controller
|
|||||||
|
|
||||||
return new UserResource($user);
|
return new UserResource($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the deletion of a user.
|
||||||
|
*
|
||||||
|
* This endpoint is independent of organization.
|
||||||
|
*
|
||||||
|
* @operationId deleteUser
|
||||||
|
*
|
||||||
|
* @param User $user The user instance to be deleted.
|
||||||
|
* @param DeletionService $deletionService The service responsible for performing the user deletion.
|
||||||
|
* @return JsonResponse A JSON response with a 204 No Content status upon successful deletion.
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
|
||||||
|
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
|
||||||
|
*/
|
||||||
|
public function destroy(User $user, DeletionService $deletionService): JsonResponse
|
||||||
|
{
|
||||||
|
if ($user->getKey() !== $this->user()->getKey()) {
|
||||||
|
throw new AuthorizationException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deletionService->deleteUser($user);
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,30 +4,13 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Service\DashboardService;
|
|
||||||
use App\Service\PermissionStore;
|
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
public function dashboard(): Response
|
||||||
* @throws AuthorizationException
|
|
||||||
*/
|
|
||||||
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
|
|
||||||
{
|
{
|
||||||
$user = $this->user();
|
|
||||||
$organization = $this->currentOrganization();
|
|
||||||
|
|
||||||
$latestTeamActivity = null;
|
|
||||||
if ($permissionStore->has($organization, 'time-entries:view:all')) {
|
|
||||||
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
|
||||||
}
|
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
|
||||||
|
|
||||||
return Inertia::render('Dashboard');
|
return Inertia::render('Dashboard');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\OrganizationInvitation;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\MemberService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class OrganizationInvitationController extends Controller
|
||||||
|
{
|
||||||
|
public function accept(OrganizationInvitation $invitation, MemberService $memberService): RedirectResponse
|
||||||
|
{
|
||||||
|
$email = strtolower($invitation->email);
|
||||||
|
$role = Role::tryFrom($invitation->role);
|
||||||
|
if ($role === null || $role === Role::Owner || $role === Role::Placeholder) {
|
||||||
|
throw new RuntimeException('Invalid role');
|
||||||
|
}
|
||||||
|
|
||||||
|
$newOrganizationMember = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->where('is_placeholder', '=', false)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($newOrganizationMember === null) {
|
||||||
|
if ($invitation->accepted_at === null) {
|
||||||
|
$invitation->accepted_at = now();
|
||||||
|
$invitation->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(route('register', [
|
||||||
|
'bannerStyle' => 'info',
|
||||||
|
'bannerText' => __('Please create an account to finish joining the :organization organization.', [
|
||||||
|
'organization' => $invitation->organization->name,
|
||||||
|
]),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
$organization = $invitation->organization;
|
||||||
|
if ($memberService->isEmailAlreadyMember($organization, $email)) {
|
||||||
|
return redirect(route('dashboard', [
|
||||||
|
'bannerStyle' => 'danger',
|
||||||
|
'bannerText' => __('You are already a member of the :organization organization.', [
|
||||||
|
'organization' => $organization->name,
|
||||||
|
]),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
$memberService->addMember($newOrganizationMember, $organization, $role);
|
||||||
|
|
||||||
|
$invitation->delete();
|
||||||
|
|
||||||
|
return redirect(route('dashboard', [
|
||||||
|
'bannerStyle' => 'success',
|
||||||
|
'bannerText' => __('Great! You have accepted the invitation to join the :organization organization.', [
|
||||||
|
'organization' => $invitation->organization->name,
|
||||||
|
]),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
{
|
{
|
||||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||||
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
||||||
|
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
||||||
|
|
||||||
/** @var BillingContract $billing */
|
/** @var BillingContract $billing */
|
||||||
$billing = app(BillingContract::class);
|
$billing = app(BillingContract::class);
|
||||||
@@ -50,6 +51,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
return array_merge(parent::share($request), [
|
return array_merge(parent::share($request), [
|
||||||
'has_billing_extension' => $hasBilling,
|
'has_billing_extension' => $hasBilling,
|
||||||
'has_invoicing_extension' => $hasInvoicing,
|
'has_invoicing_extension' => $hasInvoicing,
|
||||||
|
'has_services_extension' => $hasServices,
|
||||||
'billing' => $currentOrganization !== null ? [
|
'billing' => $currentOrganization !== null ? [
|
||||||
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
||||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ class InvitationIndexRequest extends BaseFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'page' => [
|
||||||
|
'integer',
|
||||||
|
'min:1',
|
||||||
|
'max:2147483647',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ class MemberIndexRequest extends BaseFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'page' => [
|
||||||
|
'integer',
|
||||||
|
'min:1',
|
||||||
|
'max:2147483647',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V1\Organization;
|
||||||
|
|
||||||
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
|
use App\Models\Organization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property Organization $organization Organization from model binding
|
||||||
|
*/
|
||||||
|
class OrganizationStoreRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return (string) $this->input('name');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,12 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
|||||||
'employees_can_see_billable_rates' => [
|
'employees_can_see_billable_rates' => [
|
||||||
'boolean',
|
'boolean',
|
||||||
],
|
],
|
||||||
|
'employees_can_manage_tasks' => [
|
||||||
|
'boolean',
|
||||||
|
],
|
||||||
|
'prevent_overlapping_time_entries' => [
|
||||||
|
'boolean',
|
||||||
|
],
|
||||||
'number_format' => [
|
'number_format' => [
|
||||||
Rule::enum(NumberFormat::class),
|
Rule::enum(NumberFormat::class),
|
||||||
],
|
],
|
||||||
@@ -98,4 +104,14 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
|||||||
{
|
{
|
||||||
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
|
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getEmployeesCanManageTasks(): ?bool
|
||||||
|
{
|
||||||
|
return $this->has('employees_can_manage_tasks') ? $this->boolean('employees_can_manage_tasks') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPreventOverlappingTimeEntries(): ?bool
|
||||||
|
{
|
||||||
|
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V1\ProjectMember;
|
||||||
|
|
||||||
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
|
class ProjectMemberIndexRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string|ValidationRule>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'page' => [
|
||||||
|
'integer',
|
||||||
|
'min:1',
|
||||||
|
'max:2147483647',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Requests/V1/Report/ReportIndexRequest.php
Normal file
27
app/Http/Requests/V1/Report/ReportIndexRequest.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V1\Report;
|
||||||
|
|
||||||
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
|
class ReportIndexRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string|ValidationRule>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'page' => [
|
||||||
|
'integer',
|
||||||
|
'min:1',
|
||||||
|
'max:2147483647',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,15 @@ namespace App\Http\Requests\V1\Report;
|
|||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Service\TimeEntryFilter;
|
||||||
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
|
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +25,7 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|ValidationRule|LegacyValidationRule>>
|
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -80,7 +83,14 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'properties.client_ids.*' => [
|
'properties.client_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
'uuid',
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! Str::isUuid($value)) {
|
||||||
|
$fail('The '.$attribute.' must be a valid UUID.');
|
||||||
|
}
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by project IDs, project IDs are OR combined
|
// Filter by project IDs, project IDs are OR combined
|
||||||
'properties.project_ids' => [
|
'properties.project_ids' => [
|
||||||
@@ -89,7 +99,14 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'properties.project_ids.*' => [
|
'properties.project_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
'uuid',
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! Str::isUuid($value)) {
|
||||||
|
$fail('The '.$attribute.' must be a valid UUID.');
|
||||||
|
}
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by tag IDs, tag IDs are OR combined
|
// Filter by tag IDs, tag IDs are OR combined
|
||||||
'properties.tag_ids' => [
|
'properties.tag_ids' => [
|
||||||
@@ -98,7 +115,14 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'properties.tag_ids.*' => [
|
'properties.tag_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
'uuid',
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! Str::isUuid($value)) {
|
||||||
|
$fail('The '.$attribute.' must be a valid UUID.');
|
||||||
|
}
|
||||||
|
},
|
||||||
],
|
],
|
||||||
'properties.task_ids' => [
|
'properties.task_ids' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
@@ -106,7 +130,14 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'properties.task_ids.*' => [
|
'properties.task_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
'uuid',
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! Str::isUuid($value)) {
|
||||||
|
$fail('The '.$attribute.' must be a valid UUID.');
|
||||||
|
}
|
||||||
|
},
|
||||||
],
|
],
|
||||||
'properties.group' => [
|
'properties.group' => [
|
||||||
'required',
|
'required',
|
||||||
@@ -128,6 +159,18 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
'nullable',
|
'nullable',
|
||||||
'timezone:all',
|
'timezone:all',
|
||||||
],
|
],
|
||||||
|
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||||
|
'properties.rounding_type' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
Rule::enum(TimeEntryRoundingType::class),
|
||||||
|
],
|
||||||
|
// Defines the length of the interval that the time entry rounding rounds to.
|
||||||
|
'properties.rounding_minutes' => [
|
||||||
|
'nullable',
|
||||||
|
'numeric',
|
||||||
|
'integer',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,4 +248,22 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
{
|
{
|
||||||
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
|
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getPropertyRoundingType(): ?TimeEntryRoundingType
|
||||||
|
{
|
||||||
|
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeEntryRoundingType::from($this->input('properties.rounding_type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPropertyRoundingMinutes(): ?int
|
||||||
|
{
|
||||||
|
if (! $this->has('properties.rounding_minutes') || $this->input('properties.rounding_minutes') === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $this->input('properties.rounding_minutes');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
app/Http/Requests/V1/Tag/TagIndexRequest.php
Normal file
27
app/Http/Requests/V1/Tag/TagIndexRequest.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V1\Tag;
|
||||||
|
|
||||||
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
|
class TagIndexRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string|ValidationRule>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'page' => [
|
||||||
|
'integer',
|
||||||
|
'min:1',
|
||||||
|
'max:2147483647',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ class TaskIndexRequest extends BaseFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'page' => [
|
||||||
|
'integer',
|
||||||
|
'min:1',
|
||||||
|
'max:2147483647',
|
||||||
|
],
|
||||||
'project_id' => [
|
'project_id' => [
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\TimeEntry;
|
|||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
@@ -15,6 +16,7 @@ use App\Models\Project;
|
|||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\TimeEntryFilter;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -29,7 +31,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -93,10 +95,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'project_ids.*' => [
|
'project_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Project> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Project> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by client IDs, client IDs are OR combined
|
// Filter by client IDs, client IDs are OR combined
|
||||||
'client_ids' => [
|
'client_ids' => [
|
||||||
@@ -105,10 +112,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'client_ids.*' => [
|
'client_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Client> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Client> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by tag IDs, tag IDs are OR combined
|
// Filter by tag IDs, tag IDs are OR combined
|
||||||
'tag_ids' => [
|
'tag_ids' => [
|
||||||
@@ -117,10 +129,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'tag_ids.*' => [
|
'tag_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Tag> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Tag> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
@@ -129,9 +146,14 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'task_ids.*' => [
|
'task_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
})->uuid(),
|
return;
|
||||||
|
}
|
||||||
|
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||||
'start' => [
|
'start' => [
|
||||||
@@ -164,6 +186,18 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
'string',
|
'string',
|
||||||
'in:true,false',
|
'in:true,false',
|
||||||
],
|
],
|
||||||
|
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||||
|
'rounding_type' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
Rule::enum(TimeEntryRoundingType::class),
|
||||||
|
],
|
||||||
|
// Defines the length of the interval that the time entry rounding rounds to.
|
||||||
|
'rounding_minutes' => [
|
||||||
|
'nullable',
|
||||||
|
'numeric',
|
||||||
|
'integer',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,4 +245,22 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
{
|
{
|
||||||
return ExportFormat::from($this->validated('format'));
|
return ExportFormat::from($this->validated('format'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRoundingType(): ?TimeEntryRoundingType
|
||||||
|
{
|
||||||
|
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeEntryRoundingType::from($this->validated('rounding_type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoundingMinutes(): ?int
|
||||||
|
{
|
||||||
|
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $this->validated('rounding_minutes');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
@@ -13,6 +14,7 @@ use App\Models\Project;
|
|||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\TimeEntryFilter;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -27,7 +29,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -79,10 +81,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'project_ids.*' => [
|
'project_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Project> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Project> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by client IDs, client IDs are OR combined
|
// Filter by client IDs, client IDs are OR combined
|
||||||
'client_ids' => [
|
'client_ids' => [
|
||||||
@@ -91,10 +98,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'client_ids.*' => [
|
'client_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Client> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Client> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by tag IDs, tag IDs are OR combined
|
// Filter by tag IDs, tag IDs are OR combined
|
||||||
'tag_ids' => [
|
'tag_ids' => [
|
||||||
@@ -103,10 +115,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'tag_ids.*' => [
|
'tag_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Tag> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Tag> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
@@ -115,9 +132,14 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'task_ids.*' => [
|
'task_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
})->uuid(),
|
return;
|
||||||
|
}
|
||||||
|
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||||
'start' => [
|
'start' => [
|
||||||
@@ -146,6 +168,18 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
'string',
|
'string',
|
||||||
'in:true,false',
|
'in:true,false',
|
||||||
],
|
],
|
||||||
|
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||||
|
'rounding_type' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
Rule::enum(TimeEntryRoundingType::class),
|
||||||
|
],
|
||||||
|
// Defines the length of the interval that the time entry rounding rounds to.
|
||||||
|
'rounding_minutes' => [
|
||||||
|
'nullable',
|
||||||
|
'numeric',
|
||||||
|
'integer',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,4 +207,22 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
{
|
{
|
||||||
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
|
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRoundingType(): ?TimeEntryRoundingType
|
||||||
|
{
|
||||||
|
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeEntryRoundingType::from($this->validated('rounding_type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoundingMinutes(): ?int
|
||||||
|
{
|
||||||
|
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $this->validated('rounding_minutes');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
|
use App\Models\Client;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Service\TimeEntryFilter;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -24,7 +27,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -56,6 +59,23 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
// Filter by client IDs, client IDs are OR combined
|
||||||
|
'client_ids' => [
|
||||||
|
'array',
|
||||||
|
'min:1',
|
||||||
|
],
|
||||||
|
'client_ids.*' => [
|
||||||
|
'string',
|
||||||
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Client> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
|
],
|
||||||
// Filter by project IDs, project IDs are OR combined
|
// Filter by project IDs, project IDs are OR combined
|
||||||
'project_ids' => [
|
'project_ids' => [
|
||||||
'array',
|
'array',
|
||||||
@@ -63,11 +83,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
],
|
],
|
||||||
'project_ids.*' => [
|
'project_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
'uuid',
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
/** @var Builder<Project> $builder */
|
return;
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
}
|
||||||
}),
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Project> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by tag IDs, tag IDs are OR combined
|
// Filter by tag IDs, tag IDs are OR combined
|
||||||
'tag_ids' => [
|
'tag_ids' => [
|
||||||
@@ -76,11 +100,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
],
|
],
|
||||||
'tag_ids.*' => [
|
'tag_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
'uuid',
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
/** @var Builder<Tag> $builder */
|
return;
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
}
|
||||||
}),
|
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Tag> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
@@ -89,11 +117,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
],
|
],
|
||||||
'task_ids.*' => [
|
'task_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
'uuid',
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
/** @var Builder<Task> $builder */
|
return;
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
}
|
||||||
}),
|
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Task> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||||
'start' => [
|
'start' => [
|
||||||
@@ -133,6 +165,18 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
'string',
|
'string',
|
||||||
'in:true,false',
|
'in:true,false',
|
||||||
],
|
],
|
||||||
|
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||||
|
'rounding_type' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
Rule::enum(TimeEntryRoundingType::class),
|
||||||
|
],
|
||||||
|
// Defines the length of the interval that the time entry rounding rounds to.
|
||||||
|
'rounding_minutes' => [
|
||||||
|
'nullable',
|
||||||
|
'numeric',
|
||||||
|
'integer',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,4 +214,22 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
{
|
{
|
||||||
return ExportFormat::from($this->validated('format'));
|
return ExportFormat::from($this->validated('format'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRoundingType(): ?TimeEntryRoundingType
|
||||||
|
{
|
||||||
|
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeEntryRoundingType::from($this->validated('rounding_type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoundingMinutes(): ?int
|
||||||
|
{
|
||||||
|
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $this->validated('rounding_minutes');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
@@ -11,8 +12,11 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Service\TimeEntryFilter;
|
||||||
|
use Illuminate\Contracts\Validation\Rule as RuleContract;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +27,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|ValidationRule>>
|
* @return array<string, array<string|ValidationRule|RuleContract|\Closure>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -55,10 +59,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'client_ids.*' => [
|
'client_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Client> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Client> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by project IDs, project IDs are OR combined
|
// Filter by project IDs, project IDs are OR combined
|
||||||
'project_ids' => [
|
'project_ids' => [
|
||||||
@@ -67,10 +76,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'project_ids.*' => [
|
'project_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Project> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Project> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by tag IDs, tag IDs are OR combined
|
// Filter by tag IDs, tag IDs are OR combined
|
||||||
'tag_ids' => [
|
'tag_ids' => [
|
||||||
@@ -79,10 +93,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'tag_ids.*' => [
|
'tag_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Tag> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Tag> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
@@ -91,10 +110,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'task_ids.*' => [
|
'task_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Task> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Task> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||||
'start' => [
|
'start' => [
|
||||||
@@ -136,6 +160,18 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
'string',
|
'string',
|
||||||
'in:true,false',
|
'in:true,false',
|
||||||
],
|
],
|
||||||
|
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
|
||||||
|
'rounding_type' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
Rule::enum(TimeEntryRoundingType::class),
|
||||||
|
],
|
||||||
|
// Defines the length of the interval that the time entry rounding rounds to.
|
||||||
|
'rounding_minutes' => [
|
||||||
|
'nullable',
|
||||||
|
'numeric',
|
||||||
|
'integer',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,4 +189,22 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
{
|
{
|
||||||
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
|
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRoundingType(): ?TimeEntryRoundingType
|
||||||
|
{
|
||||||
|
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeEntryRoundingType::from($this->validated('rounding_type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoundingMinutes(): ?int
|
||||||
|
{
|
||||||
|
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $this->validated('rounding_minutes');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,7 +44,16 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
|||||||
'required_with:task_id',
|
'required_with:task_id',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
|
||||||
|
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
||||||
|
$permissionStore = app(PermissionStore::class);
|
||||||
|
if (! $permissionStore->has($this->organization, 'time-entries:create:all')
|
||||||
|
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
||||||
|
$builder = $builder->visibleByEmployee(Auth::user());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder;
|
||||||
})->uuid(),
|
})->uuid(),
|
||||||
],
|
],
|
||||||
// ID of the task that the time entry should belong to
|
// ID of the task that the time entry should belong to
|
||||||
@@ -79,7 +90,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
|||||||
'description' => [
|
'description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:500',
|
'max:5000',
|
||||||
],
|
],
|
||||||
// List of tag IDs
|
// List of tag IDs
|
||||||
'tags' => [
|
'tags' => [
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +56,16 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
|
|||||||
'required_with:task_id',
|
'required_with:task_id',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
|
||||||
|
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
||||||
|
$permissionStore = app(PermissionStore::class);
|
||||||
|
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
|
||||||
|
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
||||||
|
$builder = $builder->visibleByEmployee(Auth::user());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder;
|
||||||
})->uuid(),
|
})->uuid(),
|
||||||
],
|
],
|
||||||
// ID of the task that the time entry should belong to
|
// ID of the task that the time entry should belong to
|
||||||
@@ -79,7 +90,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
|
|||||||
'changes.description' => [
|
'changes.description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:500',
|
'max:5000',
|
||||||
],
|
],
|
||||||
// List of tag IDs
|
// List of tag IDs
|
||||||
'changes.tags' => [
|
'changes.tags' => [
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,7 +44,16 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
|||||||
'required_with:task_id',
|
'required_with:task_id',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
|
||||||
|
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
||||||
|
$permissionStore = app(PermissionStore::class);
|
||||||
|
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
|
||||||
|
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
||||||
|
$builder = $builder->visibleByEmployee(Auth::user());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder;
|
||||||
})->uuid(),
|
})->uuid(),
|
||||||
],
|
],
|
||||||
// ID of the task that the time entry should belong to
|
// ID of the task that the time entry should belong to
|
||||||
@@ -77,7 +88,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
|||||||
'description' => [
|
'description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:500',
|
'max:5000',
|
||||||
],
|
],
|
||||||
// List of tag IDs
|
// List of tag IDs
|
||||||
'tags' => [
|
'tags' => [
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Client;
|
namespace App\Http\Resources\V1\Client;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class ClientCollection extends ResourceCollection
|
class ClientCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ class OrganizationResource extends BaseResource
|
|||||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||||
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
|
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
|
||||||
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
||||||
|
/** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
||||||
|
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
|
||||||
|
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
|
||||||
|
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
|
||||||
/** @var string $currency Currency code (ISO 4217) */
|
/** @var string $currency Currency code (ISO 4217) */
|
||||||
'currency' => $this->resource->currency,
|
'currency' => $this->resource->currency,
|
||||||
/** @var string $currency_symbol Currency symbol */
|
/** @var string $currency_symbol Currency symbol */
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ class DetailedReportResource extends BaseResource
|
|||||||
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
||||||
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
||||||
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
||||||
|
/** @var string|null $rounding_type Rounding type for time entries */
|
||||||
|
'rounding_type' => $this->resource->properties->roundingType?->value,
|
||||||
|
/** @var int|null $rounding_minutes Rounding minutes for time entries */
|
||||||
|
'rounding_minutes' => $this->resource->properties->roundingMinutes,
|
||||||
],
|
],
|
||||||
/** @var string $created_at Date when the report was created */
|
/** @var string $created_at Date when the report was created */
|
||||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Tag;
|
namespace App\Http\Resources\V1\Tag;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class TagCollection extends ResourceCollection
|
class TagCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Listeners;
|
|
||||||
|
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Service\MemberService;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
|
||||||
|
|
||||||
class RemovePlaceholder
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Handle the event.
|
|
||||||
*/
|
|
||||||
public function handle(TeamMemberAdded $event): void
|
|
||||||
{
|
|
||||||
$memberService = app(MemberService::class);
|
|
||||||
$member = Member::query()
|
|
||||||
->whereBelongsTo($event->team, 'organization')
|
|
||||||
->whereBelongsTo($event->user, 'user')
|
|
||||||
->firstOrFail();
|
|
||||||
$placeholders = Member::query()
|
|
||||||
->whereHas('user', function (Builder $query) use ($event): void {
|
|
||||||
/** @var Builder<User> $query */
|
|
||||||
$query->where('is_placeholder', '=', true)
|
|
||||||
->where('email', '=', $event->user->email);
|
|
||||||
})
|
|
||||||
->whereBelongsTo($event->team, 'organization')
|
|
||||||
->with(['user'])
|
|
||||||
->get();
|
|
||||||
|
|
||||||
foreach ($placeholders as $placeholder) {
|
|
||||||
/** @var Member $placeholder */
|
|
||||||
$placeholderUser = $placeholder->user;
|
|
||||||
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);
|
|
||||||
$placeholder->delete();
|
|
||||||
$placeholderUser->delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ use App\Models\OrganizationInvitation;
|
|||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
class OrganizationInvitationMail extends Mailable
|
class OrganizationInvitationMail extends Mailable
|
||||||
@@ -32,9 +33,12 @@ class OrganizationInvitationMail extends Mailable
|
|||||||
public function build(): self
|
public function build(): self
|
||||||
{
|
{
|
||||||
return $this->markdown('emails.organization-invitation', [
|
return $this->markdown('emails.organization-invitation', [
|
||||||
'acceptUrl' => URL::signedRoute('team-invitations.accept', [
|
'acceptUrl' => URL::to(URL::signedRoute(
|
||||||
'invitation' => $this->invitation,
|
'organization-invitations.accept',
|
||||||
]),
|
['invitation' => $this->invitation->getKey()],
|
||||||
|
Carbon::now()->addDays(90),
|
||||||
|
false
|
||||||
|
)),
|
||||||
])->subject(__('Organization Invitation'));
|
])->subject(__('Organization Invitation'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
|||||||
use App\Models\Concerns\CustomAuditable;
|
use App\Models\Concerns\CustomAuditable;
|
||||||
use App\Models\Concerns\HasUuids;
|
use App\Models\Concerns\HasUuids;
|
||||||
use Database\Factories\ClientFactory;
|
use Database\Factories\ClientFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -62,6 +63,18 @@ class Client extends Model implements AuditableContract
|
|||||||
return $this->hasMany(Project::class, 'client_id');
|
return $this->hasMany(Project::class, 'client_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<Client> $builder
|
||||||
|
* @return Builder<Client>
|
||||||
|
*/
|
||||||
|
public function scopeVisibleByEmployee(Builder $builder, User $user): Builder
|
||||||
|
{
|
||||||
|
return $builder->whereHas('projects', function (Builder $builder) use ($user): Builder {
|
||||||
|
/** @var Builder<Project> $builder */
|
||||||
|
return $builder->visibleByEmployee($user);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Attribute<bool, never>
|
* @return Attribute<bool, never>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property int|null $billable_rate
|
* @property int|null $billable_rate
|
||||||
* @property string $user_id
|
* @property string $user_id
|
||||||
* @property bool $employees_can_see_billable_rates
|
* @property bool $employees_can_see_billable_rates
|
||||||
|
* @property bool $employees_can_manage_tasks
|
||||||
|
* @property bool $prevent_overlapping_time_entries
|
||||||
* @property User $owner
|
* @property User $owner
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
@@ -70,6 +72,8 @@ class Organization extends JetstreamTeam implements AuditableContract
|
|||||||
'personal_team' => 'boolean',
|
'personal_team' => 'boolean',
|
||||||
'currency' => 'string',
|
'currency' => 'string',
|
||||||
'employees_can_see_billable_rates' => 'boolean',
|
'employees_can_see_billable_rates' => 'boolean',
|
||||||
|
'employees_can_manage_tasks' => 'boolean',
|
||||||
|
'prevent_overlapping_time_entries' => 'boolean',
|
||||||
'number_format' => NumberFormat::class,
|
'number_format' => NumberFormat::class,
|
||||||
'currency_format' => CurrencyFormat::class,
|
'currency_format' => CurrencyFormat::class,
|
||||||
'date_format' => DateFormat::class,
|
'date_format' => DateFormat::class,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $email
|
* @property string $email
|
||||||
* @property string $role
|
* @property string $role
|
||||||
* @property string $organization_id
|
* @property string $organization_id
|
||||||
|
* @property Carbon|null $accepted_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property-read Organization $organization
|
* @property-read Organization $organization
|
||||||
@@ -41,14 +42,16 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
|
|||||||
protected $table = 'organization_invitations';
|
protected $table = 'organization_invitations';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* Get the attributes that should be cast.
|
||||||
*
|
*
|
||||||
* @var array<int, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
protected $fillable = [
|
public function casts(): array
|
||||||
'email',
|
{
|
||||||
'role',
|
return [
|
||||||
];
|
'accepted_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the organization that the invitation belongs to.
|
* Get the organization that the invitation belongs to.
|
||||||
|
|||||||
@@ -77,6 +77,26 @@ class TimeEntry extends Model implements AuditableContract
|
|||||||
'still_active_email_sent_at' => 'datetime',
|
'still_active_email_sent_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public const array SELECT_COLUMNS = [
|
||||||
|
'id',
|
||||||
|
'description',
|
||||||
|
'start',
|
||||||
|
'end',
|
||||||
|
'billable_rate',
|
||||||
|
'billable',
|
||||||
|
'user_id',
|
||||||
|
'organization_id',
|
||||||
|
'project_id',
|
||||||
|
'task_id',
|
||||||
|
'tags',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
'member_id',
|
||||||
|
'client_id',
|
||||||
|
'is_imported',
|
||||||
|
'still_active_email_sent_at',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are computed. (f.e. for performance reasons)
|
* The attributes that are computed. (f.e. for performance reasons)
|
||||||
* These attributes can be regenerated at any time.
|
* These attributes can be regenerated at any time.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Policies;
|
|||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
@@ -58,19 +59,7 @@ class OrganizationPolicy
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user->ownsTeam($organization);
|
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether the user can add team members.
|
|
||||||
*/
|
|
||||||
public function addTeamMember(User $user, Organization $organization): bool
|
|
||||||
{
|
|
||||||
if (Filament::isServing()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Listeners\RemovePlaceholder;
|
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
|
||||||
|
|
||||||
class EventServiceProvider extends ServiceProvider
|
class EventServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -21,9 +19,6 @@ class EventServiceProvider extends ServiceProvider
|
|||||||
Registered::class => [
|
Registered::class => [
|
||||||
SendEmailVerificationNotification::class,
|
SendEmailVerificationNotification::class,
|
||||||
],
|
],
|
||||||
TeamMemberAdded::class => [
|
|
||||||
RemovePlaceholder::class,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -94,8 +94,11 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tasks:view',
|
'tasks:view',
|
||||||
'tasks:view:all',
|
'tasks:view:all',
|
||||||
'tasks:create',
|
'tasks:create',
|
||||||
|
'tasks:create:all',
|
||||||
'tasks:update',
|
'tasks:update',
|
||||||
|
'tasks:update:all',
|
||||||
'tasks:delete',
|
'tasks:delete',
|
||||||
|
'tasks:delete:all',
|
||||||
'time-entries:view:all',
|
'time-entries:view:all',
|
||||||
'time-entries:create:all',
|
'time-entries:create:all',
|
||||||
'time-entries:update:all',
|
'time-entries:update:all',
|
||||||
@@ -109,6 +112,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tags:update',
|
'tags:update',
|
||||||
'tags:delete',
|
'tags:delete',
|
||||||
'clients:view',
|
'clients:view',
|
||||||
|
'clients:view:all',
|
||||||
'clients:create',
|
'clients:create',
|
||||||
'clients:update',
|
'clients:update',
|
||||||
'clients:delete',
|
'clients:delete',
|
||||||
@@ -157,8 +161,11 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tasks:view',
|
'tasks:view',
|
||||||
'tasks:view:all',
|
'tasks:view:all',
|
||||||
'tasks:create',
|
'tasks:create',
|
||||||
|
'tasks:create:all',
|
||||||
'tasks:update',
|
'tasks:update',
|
||||||
|
'tasks:update:all',
|
||||||
'tasks:delete',
|
'tasks:delete',
|
||||||
|
'tasks:delete:all',
|
||||||
'time-entries:view:all',
|
'time-entries:view:all',
|
||||||
'time-entries:create:all',
|
'time-entries:create:all',
|
||||||
'time-entries:update:all',
|
'time-entries:update:all',
|
||||||
@@ -172,6 +179,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tags:update',
|
'tags:update',
|
||||||
'tags:delete',
|
'tags:delete',
|
||||||
'clients:view',
|
'clients:view',
|
||||||
|
'clients:view:all',
|
||||||
'clients:create',
|
'clients:create',
|
||||||
'clients:update',
|
'clients:update',
|
||||||
'clients:delete',
|
'clients:delete',
|
||||||
@@ -217,8 +225,11 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tasks:view',
|
'tasks:view',
|
||||||
'tasks:view:all',
|
'tasks:view:all',
|
||||||
'tasks:create',
|
'tasks:create',
|
||||||
|
'tasks:create:all',
|
||||||
'tasks:update',
|
'tasks:update',
|
||||||
|
'tasks:update:all',
|
||||||
'tasks:delete',
|
'tasks:delete',
|
||||||
|
'tasks:delete:all',
|
||||||
'time-entries:view:all',
|
'time-entries:view:all',
|
||||||
'time-entries:create:all',
|
'time-entries:create:all',
|
||||||
'time-entries:update:all',
|
'time-entries:update:all',
|
||||||
@@ -232,6 +243,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tags:update',
|
'tags:update',
|
||||||
'tags:delete',
|
'tags:delete',
|
||||||
'clients:view',
|
'clients:view',
|
||||||
|
'clients:view:all',
|
||||||
'clients:create',
|
'clients:create',
|
||||||
'clients:update',
|
'clients:update',
|
||||||
'clients:delete',
|
'clients:delete',
|
||||||
@@ -256,12 +268,13 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'projects:view',
|
'projects:view',
|
||||||
'tags:view',
|
'tags:view',
|
||||||
'tasks:view',
|
'tasks:view',
|
||||||
|
'clients:view',
|
||||||
'time-entries:view:own',
|
'time-entries:view:own',
|
||||||
'time-entries:create:own',
|
'time-entries:create:own',
|
||||||
'time-entries:update:own',
|
'time-entries:update:own',
|
||||||
'time-entries:delete:own',
|
'time-entries:delete:own',
|
||||||
'organizations:view',
|
'organizations:view',
|
||||||
])->description('Employees have the ability to read, create, and update their own time entries and they can see the projects that they are members of.');
|
])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
|
||||||
|
|
||||||
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
|
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
|
||||||
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
||||||
|
|||||||
@@ -266,7 +266,8 @@ class DashboardService
|
|||||||
) as aggregate'))
|
) as aggregate'))
|
||||||
->where('billable', '=', true)
|
->where('billable', '=', true)
|
||||||
->whereNotNull('billable_rate')
|
->whereNotNull('billable_rate')
|
||||||
->where('user_id', '=', $user->id);
|
->where('user_id', '=', $user->getKey())
|
||||||
|
->where('organization_id', '=', $organization->getKey());
|
||||||
|
|
||||||
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
||||||
/** @var Collection<int, object{aggregate: int}> $resultDb */
|
/** @var Collection<int, object{aggregate: int}> $resultDb */
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ namespace App\Service\Dto;
|
|||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
|
use App\Service\TimeEntryFilter;
|
||||||
use Illuminate\Contracts\Database\Eloquent\Castable;
|
use Illuminate\Contracts\Database\Eloquent\Castable;
|
||||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -59,6 +61,10 @@ class ReportPropertiesDto implements Castable
|
|||||||
*/
|
*/
|
||||||
public ?Collection $taskIds = null;
|
public ?Collection $taskIds = null;
|
||||||
|
|
||||||
|
public ?TimeEntryRoundingType $roundingType = null;
|
||||||
|
|
||||||
|
public ?int $roundingMinutes = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the caster class to use when casting from / to this cast target.
|
* Get the caster class to use when casting from / to this cast target.
|
||||||
*
|
*
|
||||||
@@ -115,6 +121,10 @@ class ReportPropertiesDto implements Castable
|
|||||||
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
|
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
|
||||||
$dto->weekStart = Weekday::from($data->weekStart);
|
$dto->weekStart = Weekday::from($data->weekStart);
|
||||||
$dto->timezone = $data->timezone;
|
$dto->timezone = $data->timezone;
|
||||||
|
// Note: roundingType was added later so it is possible that the value is missing in persisted reports in the DB
|
||||||
|
$dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null;
|
||||||
|
// Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB
|
||||||
|
$dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null;
|
||||||
|
|
||||||
return $dto;
|
return $dto;
|
||||||
}
|
}
|
||||||
@@ -140,6 +150,8 @@ class ReportPropertiesDto implements Castable
|
|||||||
'historyGroup' => $value->historyGroup->value,
|
'historyGroup' => $value->historyGroup->value,
|
||||||
'weekStart' => $value->weekStart->value,
|
'weekStart' => $value->weekStart->value,
|
||||||
'timezone' => $value->timezone,
|
'timezone' => $value->timezone,
|
||||||
|
'roundingType' => $value->roundingType?->value,
|
||||||
|
'roundingMinutes' => $value->roundingMinutes,
|
||||||
];
|
];
|
||||||
|
|
||||||
$jsonString = json_encode($data);
|
$jsonString = json_encode($data);
|
||||||
@@ -163,7 +175,7 @@ class ReportPropertiesDto implements Castable
|
|||||||
if (! is_string($id)) {
|
if (! is_string($id)) {
|
||||||
throw new \InvalidArgumentException('The given ID is not a string');
|
throw new \InvalidArgumentException('The given ID is not a string');
|
||||||
}
|
}
|
||||||
if (! Str::isUuid($id)) {
|
if ($id !== TimeEntryFilter::NONE_VALUE && ! Str::isUuid($id)) {
|
||||||
throw new \InvalidArgumentException('The given ID is not a valid UUID');
|
throw new \InvalidArgumentException('The given ID is not a valid UUID');
|
||||||
}
|
}
|
||||||
$collection->push($id);
|
$collection->push($id);
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ class ExportService
|
|||||||
$client->id,
|
$client->id,
|
||||||
$client->name,
|
$client->name,
|
||||||
$client->organization_id,
|
$client->organization_id,
|
||||||
$client->archived_at ?? '',
|
$client->archived_at?->toIso8601ZuluString() ?? '',
|
||||||
$client->created_at?->toIso8601ZuluString() ?? '',
|
$client->created_at?->toIso8601ZuluString() ?? '',
|
||||||
$client->updated_at?->toIso8601ZuluString() ?? '',
|
$client->updated_at?->toIso8601ZuluString() ?? '',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
$timeEntry->project_id = $projectId;
|
$timeEntry->project_id = $projectId;
|
||||||
$timeEntry->client_id = $clientId;
|
$timeEntry->client_id = $clientId;
|
||||||
$timeEntry->organization_id = $this->organization->id;
|
$timeEntry->organization_id = $this->organization->id;
|
||||||
if (strlen($record['Description']) > 500) {
|
if (strlen($record['Description']) > 5000) {
|
||||||
throw new ImportException('Time entry description is too long');
|
throw new ImportException('Time entry description is too long');
|
||||||
}
|
}
|
||||||
$timeEntry->description = $record['Description'];
|
$timeEntry->description = $record['Description'];
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
|||||||
$timeEntry->project_id = $projectId;
|
$timeEntry->project_id = $projectId;
|
||||||
$timeEntry->client_id = $clientId;
|
$timeEntry->client_id = $clientId;
|
||||||
$timeEntry->organization_id = $this->organization->id;
|
$timeEntry->organization_id = $this->organization->id;
|
||||||
if (strlen($record['Notes']) > 500) {
|
if (strlen($record['Notes']) > 5000) {
|
||||||
throw new ImportException('Time entry note is too long');
|
throw new ImportException('Time entry note is too long');
|
||||||
}
|
}
|
||||||
$timeEntry->description = $record['Notes'];
|
$timeEntry->description = $record['Notes'];
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$timeEntry->project_id = $projectId;
|
$timeEntry->project_id = $projectId;
|
||||||
$timeEntry->client_id = $clientId;
|
$timeEntry->client_id = $clientId;
|
||||||
$timeEntry->organization_id = $this->organization->id;
|
$timeEntry->organization_id = $this->organization->id;
|
||||||
if (strlen($timeEntryRow['description']) > 500) {
|
if (strlen($timeEntryRow['description']) > 5000) {
|
||||||
throw new ImportException('Time entry description is too long');
|
throw new ImportException('Time entry description is too long');
|
||||||
}
|
}
|
||||||
$timeEntry->description = $timeEntryRow['description'];
|
$timeEntry->description = $timeEntryRow['description'];
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ use App\Enums\Role;
|
|||||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
use App\Mail\OrganizationInvitationMail;
|
use App\Mail\OrganizationInvitationMail;
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\OrganizationInvitation;
|
use App\Models\OrganizationInvitation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||||
|
|
||||||
@@ -21,11 +23,7 @@ class InvitationService
|
|||||||
*/
|
*/
|
||||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
||||||
{
|
{
|
||||||
if (Member::query()
|
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
||||||
->whereBelongsTo($organization, 'organization')
|
|
||||||
->whereRelation('user', 'email', '=', $email)
|
|
||||||
->where('role', '!=', Role::Placeholder->value)
|
|
||||||
->exists()) {
|
|
||||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,4 +46,37 @@ class InvitationService
|
|||||||
|
|
||||||
return $invitation;
|
return $invitation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Organization>
|
||||||
|
*/
|
||||||
|
public function processAcceptedInvitations(User $user): Collection
|
||||||
|
{
|
||||||
|
$organizations = new Collection;
|
||||||
|
|
||||||
|
$invitations = OrganizationInvitation::query()
|
||||||
|
->where('email', $user->email)
|
||||||
|
->whereNotNull('accepted_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($invitations as $invitation) {
|
||||||
|
$organization = $invitation->organization;
|
||||||
|
$role = Role::tryFrom($invitation->role);
|
||||||
|
if ($role === null) {
|
||||||
|
Log::error('Invalid role in invitation', [
|
||||||
|
'invitation' => $invitation->getKey(),
|
||||||
|
'role' => $invitation->role,
|
||||||
|
]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
app(MemberService::class)->addMember($user, $organization, $role);
|
||||||
|
|
||||||
|
$invitation->delete();
|
||||||
|
|
||||||
|
$organizations->push($organization);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $organizations;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Events\MemberAdded;
|
||||||
|
use App\Events\MemberAdding;
|
||||||
use App\Events\MemberRemoved;
|
use App\Events\MemberRemoved;
|
||||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||||
@@ -36,7 +38,8 @@ class MemberService
|
|||||||
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
||||||
{
|
{
|
||||||
if (! $asSuperAdmin) {
|
if (! $asSuperAdmin) {
|
||||||
AddingTeamMember::dispatch($organization, $user);
|
MemberAdding::dispatch($user, $organization, $role);
|
||||||
|
AddingTeamMember::dispatch($organization, $user); // Legacy event
|
||||||
}
|
}
|
||||||
|
|
||||||
$member = new Member;
|
$member = new Member;
|
||||||
@@ -49,14 +52,37 @@ class MemberService
|
|||||||
$user->currentOrganization()->associate($organization);
|
$user->currentOrganization()->associate($organization);
|
||||||
$user->save();
|
$user->save();
|
||||||
});
|
});
|
||||||
|
$this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user);
|
||||||
|
|
||||||
if (! $asSuperAdmin) {
|
if (! $asSuperAdmin) {
|
||||||
TeamMemberAdded::dispatch($organization, $user);
|
MemberAdded::dispatch($member, $organization, $user);
|
||||||
|
TeamMemberAdded::dispatch($organization, $user); // Legacy event
|
||||||
}
|
}
|
||||||
|
|
||||||
return $member;
|
return $member;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function mergePlaceholderMembersIntoExistingMember(Member $member, Organization $organization, User $user): void
|
||||||
|
{
|
||||||
|
$placeholders = Member::query()
|
||||||
|
->whereHas('user', function (Builder $query) use ($user): void {
|
||||||
|
/** @var Builder<User> $query */
|
||||||
|
$query->where('is_placeholder', '=', true)
|
||||||
|
->where('email', '=', $user->email);
|
||||||
|
})
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->with(['user'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($placeholders as $placeholder) {
|
||||||
|
/** @var Member $placeholder */
|
||||||
|
$placeholderUser = $placeholder->user;
|
||||||
|
$this->assignOrganizationEntitiesToDifferentMember($organization, $placeholder, $member);
|
||||||
|
$placeholder->delete();
|
||||||
|
$placeholderUser->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws CanNotRemoveOwnerFromOrganization
|
* @throws CanNotRemoveOwnerFromOrganization
|
||||||
* @throws EntityStillInUseApiException
|
* @throws EntityStillInUseApiException
|
||||||
@@ -196,6 +222,7 @@ class MemberService
|
|||||||
|
|
||||||
$placeholderUser = $user->replicate();
|
$placeholderUser = $user->replicate();
|
||||||
$placeholderUser->is_placeholder = true;
|
$placeholderUser->is_placeholder = true;
|
||||||
|
$placeholderUser->current_team_id = $member->organization_id;
|
||||||
$placeholderUser->save();
|
$placeholderUser->save();
|
||||||
|
|
||||||
$member->user()->associate($placeholderUser);
|
$member->user()->associate($placeholderUser);
|
||||||
@@ -208,4 +235,13 @@ class MemberService
|
|||||||
$this->userService->makeSureUserHasCurrentOrganization($user);
|
$this->userService->makeSureUserHasCurrentOrganization($user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isEmailAlreadyMember(Organization $organization, string $email): bool
|
||||||
|
{
|
||||||
|
return Member::query()
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->whereRelation('user', 'email', '=', $email)
|
||||||
|
->where('role', '!=', Role::Placeholder->value)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,19 @@ class PermissionStore
|
|||||||
/** @var Role|null $roleObj */
|
/** @var Role|null $roleObj */
|
||||||
$roleObj = Jetstream::findRole($role);
|
$roleObj = Jetstream::findRole($role);
|
||||||
|
|
||||||
return $roleObj->permissions ?? [];
|
$permissions = $roleObj->permissions ?? [];
|
||||||
|
|
||||||
|
// If the organization allows employees to manage tasks and the user is an employee,
|
||||||
|
// add the task management permissions for accessible projects
|
||||||
|
if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) {
|
||||||
|
$permissions = array_merge($permissions, [
|
||||||
|
'tasks:create',
|
||||||
|
'tasks:update',
|
||||||
|
'tasks:delete',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $permissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ namespace App\Service;
|
|||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -16,6 +18,7 @@ use Carbon\CarbonTimeZone;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class TimeEntryAggregationService
|
class TimeEntryAggregationService
|
||||||
@@ -41,12 +44,24 @@ class TimeEntryAggregationService
|
|||||||
* cost: int|null
|
* cost: int|null
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
|
||||||
{
|
{
|
||||||
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
||||||
|
/** @var Builder<TimeEntry> $baseTotalsQuery */
|
||||||
|
$baseTotalsQuery = $timeEntriesQuery->clone();
|
||||||
$group1Select = null;
|
$group1Select = null;
|
||||||
$group2Select = null;
|
$group2Select = null;
|
||||||
$groupBy = null;
|
$groupBy = null;
|
||||||
|
// If any grouping is by tag, expand rows per tag and ensure a NULL row for entries without tags
|
||||||
|
if (($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag)) {
|
||||||
|
$timeEntriesQuery->crossJoin(DB::raw(
|
||||||
|
"LATERAL (\n".
|
||||||
|
" SELECT jsonb_array_elements_text(coalesce(tags, '[]'::jsonb)) AS tag\n".
|
||||||
|
" UNION ALL\n".
|
||||||
|
" SELECT ''::text AS tag WHERE coalesce(jsonb_array_length(tags), 0) = 0\n".
|
||||||
|
') AS tag(tag)'
|
||||||
|
));
|
||||||
|
}
|
||||||
if ($group1Type !== null) {
|
if ($group1Type !== null) {
|
||||||
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
|
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
|
||||||
$groupBy = ['group_1'];
|
$groupBy = ['group_1'];
|
||||||
@@ -56,15 +71,14 @@ class TimeEntryAggregationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$startRawSelect = app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes);
|
||||||
|
$endRawSelect = app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes);
|
||||||
|
|
||||||
$timeEntriesQuery->selectRaw(
|
$timeEntriesQuery->selectRaw(
|
||||||
($group1Select !== null ? $group1Select.' as group_1,' : '').
|
($group1Select !== null ? $group1Select.' as group_1,' : '').
|
||||||
($group2Select !== null ? $group2Select.' as group_2,' : '').
|
($group2Select !== null ? $group2Select.' as group_2,' : '').
|
||||||
' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'.
|
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
||||||
' round(
|
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
||||||
sum(
|
|
||||||
extract(epoch from (coalesce("end", now()) - start)) * (coalesce(billable_rate, 0)::float/60/60)
|
|
||||||
)
|
|
||||||
) as cost'
|
|
||||||
);
|
);
|
||||||
if ($groupBy !== null) {
|
if ($groupBy !== null) {
|
||||||
$timeEntriesQuery->groupBy($groupBy);
|
$timeEntriesQuery->groupBy($groupBy);
|
||||||
@@ -84,6 +98,26 @@ class TimeEntryAggregationService
|
|||||||
$group1Response = [];
|
$group1Response = [];
|
||||||
$group1ResponseSum = 0;
|
$group1ResponseSum = 0;
|
||||||
$group1ResponseCost = 0;
|
$group1ResponseCost = 0;
|
||||||
|
// If Tag is subgroup, prepare base totals per primary group without tag expansion
|
||||||
|
$baseTotalsPerGroup1Map = [];
|
||||||
|
if ($group2Type === TimeEntryAggregationType::Tag) {
|
||||||
|
$baseTotalsPerGroup1Query = $baseTotalsQuery->clone();
|
||||||
|
$baseTotalsPerGroup1 = $baseTotalsPerGroup1Query
|
||||||
|
->selectRaw(
|
||||||
|
$group1Select.' as group_1,'.
|
||||||
|
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
||||||
|
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
||||||
|
)
|
||||||
|
->groupBy('group_1')
|
||||||
|
->get();
|
||||||
|
foreach ($baseTotalsPerGroup1 as $row) {
|
||||||
|
/** @var object{group_1: mixed, aggregate: int|null, cost: int|null} $row */
|
||||||
|
$baseTotalsPerGroup1Map[(string) ($row->group_1 ?? '')] = [
|
||||||
|
'aggregate' => (int) ($row->aggregate ?? 0),
|
||||||
|
'cost' => (int) ($row->cost ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
foreach ($groupedAggregates as $group1 => $group1Aggregates) {
|
foreach ($groupedAggregates as $group1 => $group1Aggregates) {
|
||||||
/** @var string|int $group1 */
|
/** @var string|int $group1 */
|
||||||
$group2Response = [];
|
$group2Response = [];
|
||||||
@@ -103,6 +137,14 @@ class TimeEntryAggregationService
|
|||||||
$group2ResponseSum += (int) $aggregate->get(0)->aggregate;
|
$group2ResponseSum += (int) $aggregate->get(0)->aggregate;
|
||||||
$group2ResponseCost += (int) $aggregate->get(0)->cost;
|
$group2ResponseCost += (int) $aggregate->get(0)->cost;
|
||||||
}
|
}
|
||||||
|
// Override primary group totals when Tag is subgroup to avoid double counting
|
||||||
|
if ($group2Type === TimeEntryAggregationType::Tag) {
|
||||||
|
$keyForMap = (string) $group1;
|
||||||
|
if (array_key_exists($keyForMap, $baseTotalsPerGroup1Map)) {
|
||||||
|
$group2ResponseSum = $baseTotalsPerGroup1Map[$keyForMap]['aggregate'];
|
||||||
|
$group2ResponseCost = $baseTotalsPerGroup1Map[$keyForMap]['cost'];
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
/** @var Collection<int, object{aggregate: int, cost: int}> $group1Aggregates */
|
/** @var Collection<int, object{aggregate: int, cost: int}> $group1Aggregates */
|
||||||
$group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate;
|
$group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate;
|
||||||
@@ -121,6 +163,23 @@ class TimeEntryAggregationService
|
|||||||
$group1ResponseCost += $group2ResponseCost;
|
$group1ResponseCost += $group2ResponseCost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If Tag is selected in any grouping, compute overall totals from base (non-tag-expanded) query to avoid double counting
|
||||||
|
$hasTagGrouping = ($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag);
|
||||||
|
if ($hasTagGrouping) {
|
||||||
|
// Reset selects and ordering on the cloned base query
|
||||||
|
$baseTotals = $baseTotalsQuery
|
||||||
|
->selectRaw(
|
||||||
|
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
||||||
|
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
||||||
|
)
|
||||||
|
->first();
|
||||||
|
if ($baseTotals !== null) {
|
||||||
|
/** @var object{aggregate: int|null, cost: int|null} $baseTotals */
|
||||||
|
$group1ResponseSum = (int) ($baseTotals->aggregate ?? 0);
|
||||||
|
$group1ResponseCost = (int) ($baseTotals->cost ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($fillGapsInTimeGroupsIsPossible) {
|
if ($fillGapsInTimeGroupsIsPossible) {
|
||||||
$group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end);
|
$group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end);
|
||||||
}
|
}
|
||||||
@@ -164,9 +223,9 @@ class TimeEntryAggregationService
|
|||||||
* cost: int|null
|
* cost: int|null
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
|
||||||
{
|
{
|
||||||
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate);
|
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate, $roundingType, $roundingMinutes);
|
||||||
|
|
||||||
$keysGroup1 = [];
|
$keysGroup1 = [];
|
||||||
$keysGroup2 = [];
|
$keysGroup2 = [];
|
||||||
@@ -294,6 +353,17 @@ class TimeEntryAggregationService
|
|||||||
'color' => null,
|
'color' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
} elseif ($type === TimeEntryAggregationType::Tag) {
|
||||||
|
$tags = Tag::query()
|
||||||
|
->whereIn('id', $keys)
|
||||||
|
->select('id', 'name')
|
||||||
|
->get();
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
$descriptorMap[$tag->id] = [
|
||||||
|
'description' => $tag->name,
|
||||||
|
'color' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $descriptorMap;
|
return $descriptorMap;
|
||||||
@@ -436,6 +506,8 @@ class TimeEntryAggregationService
|
|||||||
return 'billable';
|
return 'billable';
|
||||||
} elseif ($group === TimeEntryAggregationType::Description) {
|
} elseif ($group === TimeEntryAggregationType::Description) {
|
||||||
return 'description';
|
return 'description';
|
||||||
|
} elseif ($group === TimeEntryAggregationType::Tag) {
|
||||||
|
return 'tag';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ use Illuminate\Support\Facades\Log;
|
|||||||
|
|
||||||
class TimeEntryFilter
|
class TimeEntryFilter
|
||||||
{
|
{
|
||||||
|
public const string NONE_VALUE = 'none';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Builder<TimeEntry>
|
* @var Builder<TimeEntry>
|
||||||
*/
|
*/
|
||||||
@@ -149,7 +151,17 @@ class TimeEntryFilter
|
|||||||
if ($clientIds === null) {
|
if ($clientIds === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$this->builder->whereIn('client_id', $clientIds);
|
$includeNone = in_array(self::NONE_VALUE, $clientIds, true);
|
||||||
|
$clientIds = array_values(array_filter($clientIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||||
|
|
||||||
|
$this->builder->where(function (Builder $builder) use ($clientIds, $includeNone): void {
|
||||||
|
if (count($clientIds) > 0) {
|
||||||
|
$builder->whereIn('client_id', $clientIds);
|
||||||
|
}
|
||||||
|
if ($includeNone) {
|
||||||
|
$builder->orWhereNull('client_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -162,7 +174,17 @@ class TimeEntryFilter
|
|||||||
if ($projectIds === null) {
|
if ($projectIds === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$this->builder->whereIn('project_id', $projectIds);
|
$includeNone = in_array(self::NONE_VALUE, $projectIds, true);
|
||||||
|
$projectIds = array_values(array_filter($projectIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||||
|
|
||||||
|
$this->builder->where(function (Builder $builder) use ($projectIds, $includeNone): void {
|
||||||
|
if (count($projectIds) > 0) {
|
||||||
|
$builder->whereIn('project_id', $projectIds);
|
||||||
|
}
|
||||||
|
if ($includeNone) {
|
||||||
|
$builder->orWhereNull('project_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -175,10 +197,18 @@ class TimeEntryFilter
|
|||||||
if ($tagIds === null) {
|
if ($tagIds === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$this->builder->where(function (Builder $builder) use ($tagIds): void {
|
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
|
||||||
|
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||||
|
|
||||||
|
$this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {
|
||||||
foreach ($tagIds as $tagId) {
|
foreach ($tagIds as $tagId) {
|
||||||
$builder->orWhereJsonContains('tags', $tagId);
|
$builder->orWhereJsonContains('tags', $tagId);
|
||||||
}
|
}
|
||||||
|
if ($includeNone) {
|
||||||
|
$builder->orWhere(function (Builder $query): void {
|
||||||
|
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -192,7 +222,17 @@ class TimeEntryFilter
|
|||||||
if ($taskIds === null) {
|
if ($taskIds === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$this->builder->whereIn('task_id', $taskIds);
|
$includeNone = in_array(self::NONE_VALUE, $taskIds, true);
|
||||||
|
$taskIds = array_values(array_filter($taskIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||||
|
|
||||||
|
$this->builder->where(function (Builder $builder) use ($taskIds, $includeNone): void {
|
||||||
|
if (count($taskIds) > 0) {
|
||||||
|
$builder->whereIn('task_id', $taskIds);
|
||||||
|
}
|
||||||
|
if ($includeNone) {
|
||||||
|
$builder->orWhereNull('task_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
47
app/Service/TimeEntryService.php
Normal file
47
app/Service/TimeEntryService.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
class TimeEntryService
|
||||||
|
{
|
||||||
|
public function getStartSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
|
||||||
|
{
|
||||||
|
if ($roundingType === null || $roundingMinutes === null) {
|
||||||
|
return 'start';
|
||||||
|
}
|
||||||
|
if ($roundingMinutes < 1) {
|
||||||
|
throw new LogicException('Rounding minutes must be greater than 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'date_bin(\'1 minutes\', start, TIMESTAMP \'1970-01-01\')';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
|
||||||
|
{
|
||||||
|
if ($roundingType === null || $roundingMinutes === null) {
|
||||||
|
return 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
||||||
|
}
|
||||||
|
if ($roundingMinutes < 1) {
|
||||||
|
throw new LogicException('Rounding minutes must be greater than 0');
|
||||||
|
}
|
||||||
|
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
||||||
|
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
|
||||||
|
if ($roundingType === TimeEntryRoundingType::Down) {
|
||||||
|
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')';
|
||||||
|
} elseif ($roundingType === TimeEntryRoundingType::Up) {
|
||||||
|
// If end is already on a boundary, keep it; otherwise round up to next boundary
|
||||||
|
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
|
||||||
|
'THEN '.$end.' '.
|
||||||
|
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
|
||||||
|
'END';
|
||||||
|
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
|
||||||
|
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ class UserService
|
|||||||
): User {
|
): User {
|
||||||
$user = new User;
|
$user = new User;
|
||||||
$user->name = $name;
|
$user->name = $name;
|
||||||
$user->email = $email;
|
$user->email = strtolower($email);
|
||||||
$user->password = Hash::make($password);
|
$user->password = Hash::make($password);
|
||||||
$user->timezone = $timezone;
|
$user->timezone = $timezone;
|
||||||
$user->week_start = $weekStart;
|
$user->week_start = $weekStart;
|
||||||
@@ -47,19 +47,22 @@ class UserService
|
|||||||
}
|
}
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
$organization = app(OrganizationService::class)->createOrganization(
|
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
||||||
$this->getOrganizationNameForUserName($user->name),
|
|
||||||
$user,
|
|
||||||
true,
|
|
||||||
$currency,
|
|
||||||
$numberFormat,
|
|
||||||
$currencyFormat,
|
|
||||||
$dateFormat,
|
|
||||||
$intervalFormat,
|
|
||||||
$timeFormat,
|
|
||||||
);
|
|
||||||
|
|
||||||
$user->ownedTeams()->save($organization);
|
if ($organizations->isEmpty()) {
|
||||||
|
$organization = app(OrganizationService::class)->createOrganization(
|
||||||
|
$this->getOrganizationNameForUserName($user->name),
|
||||||
|
$user,
|
||||||
|
true,
|
||||||
|
$currency,
|
||||||
|
$numberFormat,
|
||||||
|
$currencyFormat,
|
||||||
|
$dateFormat,
|
||||||
|
$intervalFormat,
|
||||||
|
$timeFormat,
|
||||||
|
);
|
||||||
|
$user->ownedTeams()->save($organization);
|
||||||
|
}
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,8 @@
|
|||||||
"extra": {
|
"extra": {
|
||||||
"laravel": {
|
"laravel": {
|
||||||
"dont-discover": [
|
"dont-discover": [
|
||||||
"laravel/telescope"
|
"laravel/telescope",
|
||||||
|
"nwidart/laravel-modules"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
1562
composer.lock
generated
1562
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ use App\Enums\NumberFormat;
|
|||||||
use App\Enums\TimeFormat;
|
use App\Enums\TimeFormat;
|
||||||
use Illuminate\Support\Facades\Facade;
|
use Illuminate\Support\Facades\Facade;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Nwidart\Modules\LaravelModulesServiceProvider;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
@@ -197,6 +198,7 @@ return [
|
|||||||
App\Providers\FortifyServiceProvider::class,
|
App\Providers\FortifyServiceProvider::class,
|
||||||
App\Providers\JetstreamServiceProvider::class,
|
App\Providers\JetstreamServiceProvider::class,
|
||||||
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
|
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
|
||||||
|
LaravelModulesServiceProvider::class,
|
||||||
])->toArray(),
|
])->toArray(),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -25,9 +25,24 @@ class OrganizationInvitationFactory extends Factory
|
|||||||
'email' => $this->faker->unique()->safeEmail(),
|
'email' => $this->faker->unique()->safeEmail(),
|
||||||
'role' => Role::Employee->value,
|
'role' => Role::Employee->value,
|
||||||
'organization_id' => Organization::factory(),
|
'organization_id' => Organization::factory(),
|
||||||
|
'accepted_at' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function role(Role $role): self
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'role' => $role->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function accepted(): self
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'accepted_at' => $this->faker->dateTime(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function forOrganization(Organization $organization): self
|
public function forOrganization(Organization $organization): self
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
|
|||||||
@@ -153,6 +153,16 @@ class TimeEntryFactory extends Factory
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function endWithDuration(Carbon $end, int $durationInSeconds): self
|
||||||
|
{
|
||||||
|
return $this->state(function (array $attributes) use ($end, $durationInSeconds): array {
|
||||||
|
return [
|
||||||
|
'start' => $end->copy()->utc()->subSeconds($durationInSeconds),
|
||||||
|
'end' => $end->copy()->utc(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function start(Carbon $start): self
|
public function start(Carbon $start): self
|
||||||
{
|
{
|
||||||
return $this->state(function (array $attributes) use ($start): array {
|
return $this->state(function (array $attributes) use ($start): array {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('organizations', function (Blueprint $table): void {
|
||||||
|
$table->boolean('prevent_overlapping_time_entries')->default(false)->after('employees_can_see_billable_rates');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('organizations', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('prevent_overlapping_time_entries');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('time_entries', function (Blueprint $table): void {
|
||||||
|
$table->string('description', 5000)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('time_entries', function (Blueprint $table): void {
|
||||||
|
$table->string('description', 500)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('organizations', function (Blueprint $table): void {
|
||||||
|
$table->boolean('employees_can_manage_tasks')->default(false)->after('employees_can_see_billable_rates');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('organizations', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('employees_can_manage_tasks');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('organization_invitations', function (Blueprint $table): void {
|
||||||
|
$table->timestamp('accepted_at')->nullable()->after('email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('organization_invitations', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('accepted_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -435,7 +435,7 @@ CREATE TABLE public.tasks (
|
|||||||
|
|
||||||
CREATE TABLE public.time_entries (
|
CREATE TABLE public.time_entries (
|
||||||
id uuid NOT NULL,
|
id uuid NOT NULL,
|
||||||
description character varying(500) NOT NULL,
|
description character varying(5000) NOT NULL,
|
||||||
start timestamp(0) without time zone NOT NULL,
|
start timestamp(0) without time zone NOT NULL,
|
||||||
"end" timestamp(0) without time zone,
|
"end" timestamp(0) without time zone,
|
||||||
billable_rate integer,
|
billable_rate integer,
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
WWWGROUP: '${WWWGROUP}'
|
WWWGROUP: '${WWWGROUP}'
|
||||||
ports:
|
|
||||||
- '${FORWARD_WEB_PORT:-8083}:80'
|
|
||||||
image: sail-8.3/app
|
image: sail-8.3/app
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
@@ -109,7 +107,7 @@ services:
|
|||||||
- sail
|
- sail
|
||||||
- reverse-proxy
|
- reverse-proxy
|
||||||
playwright:
|
playwright:
|
||||||
image: mcr.microsoft.com/playwright:v1.51.1-jammy
|
image: mcr.microsoft.com/playwright:v1.58.1-jammy
|
||||||
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
||||||
working_dir: /src
|
working_dir: /src
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Source: https://helgesver.re/articles/laravel-sail-create-minio-bucket-automatically
|
# Source: https://helgesver.re/articles/laravel-sail-create-minio-bucket-automatically
|
||||||
|
|
||||||
/usr/bin/mc config host add local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
|
/usr/bin/mc alias set local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
|
||||||
/usr/bin/mc rm -r --force local/${S3_BUCKET};
|
/usr/bin/mc rm -r --force local/${S3_BUCKET};
|
||||||
/usr/bin/mc mb --ignore-existing local/${S3_BUCKET};
|
/usr/bin/mc mb --ignore-existing local/${S3_BUCKET};
|
||||||
/usr/bin/mc anonymous set public local/${S3_BUCKET};
|
/usr/bin/mc anonymous set public local/${S3_BUCKET};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ RUN CGO_ENABLED=1 \
|
|||||||
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
||||||
CGO_CFLAGS=$(php-config --includes) \
|
CGO_CFLAGS=$(php-config --includes) \
|
||||||
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
||||||
xcaddy build \
|
xcaddy build v2.10.0 \
|
||||||
--output /usr/local/bin/frankenphp \
|
--output /usr/local/bin/frankenphp \
|
||||||
--with github.com/dunglas/frankenphp=./ \
|
--with github.com/dunglas/frankenphp=./ \
|
||||||
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
||||||
|
|||||||
189
e2e/auth.spec.ts
189
e2e/auth.spec.ts
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
|
import { getPasswordResetUrl } from './utils/mailpit';
|
||||||
|
|
||||||
async function registerNewUser(page, email, password) {
|
async function registerNewUser(page, email, password) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||||
@@ -35,14 +36,198 @@ test('can register and delete account', async ({ page }) => {
|
|||||||
await registerNewUser(page, email, password);
|
await registerNewUser(page, email, password);
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
await page.getByPlaceholder('Password').fill(password);
|
await page.getByPlaceholder('Password').fill(password);
|
||||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Delete Account' }).click();
|
||||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
await page.getByLabel('Email').fill(email);
|
await page.getByLabel('Email').fill(email);
|
||||||
await page.getByLabel('Password').fill(password);
|
await page.getByLabel('Password').fill(password);
|
||||||
await page.getByRole('button', { name: 'Log in' }).click();
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
await expect(page.getByRole('paragraph')).toContainText(
|
await expect(page.getByRole('alert')).toContainText(
|
||||||
'These credentials do not match our records.'
|
'These credentials do not match our records.'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shows error for invalid email on forgot password', async ({ page }) => {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||||
|
|
||||||
|
// Request password reset with non-existent email
|
||||||
|
await page.getByLabel('Email').fill('nonexistent@example.com');
|
||||||
|
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||||
|
|
||||||
|
// Should show error message
|
||||||
|
await expect(page.getByText("We can't find a user with that email address.")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows browser validation for invalid email format on forgot password', async ({ page }) => {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||||
|
|
||||||
|
// Request password reset with invalid email format
|
||||||
|
const emailInput = page.getByLabel('Email');
|
||||||
|
await emailInput.fill('notanemail');
|
||||||
|
|
||||||
|
// Check for browser validation - the input should be invalid
|
||||||
|
const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => !el.validity.valid);
|
||||||
|
expect(isInvalid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows browser validation for empty email on forgot password', async ({ page }) => {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||||
|
|
||||||
|
// The email input is required, so it should be invalid when empty
|
||||||
|
const emailInput = page.getByLabel('Email');
|
||||||
|
|
||||||
|
// Check for browser validation - the input should be invalid because it's required and empty
|
||||||
|
const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => el.validity.valueMissing);
|
||||||
|
expect(isInvalid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can reset password via email link', async ({ page, request }) => {
|
||||||
|
// First register a new user
|
||||||
|
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||||
|
const originalPassword = 'suchagreatpassword123';
|
||||||
|
const newPassword = 'mynewsecurepassword456';
|
||||||
|
await registerNewUser(page, email, originalPassword);
|
||||||
|
|
||||||
|
// Log out
|
||||||
|
await page.getByTestId('current_user_button').click();
|
||||||
|
await page.getByText('Log Out').click();
|
||||||
|
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
|
|
||||||
|
// Request password reset
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||||
|
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
|
||||||
|
|
||||||
|
// Get password reset URL from email
|
||||||
|
const resetUrl = await getPasswordResetUrl(request, email);
|
||||||
|
|
||||||
|
// Navigate to reset page
|
||||||
|
await page.goto(resetUrl);
|
||||||
|
|
||||||
|
// Fill in new password
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(newPassword);
|
||||||
|
await page.getByLabel('Confirm Password').fill(newPassword);
|
||||||
|
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||||
|
|
||||||
|
// Should redirect to login page after successful reset
|
||||||
|
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
|
|
||||||
|
// Try logging in with new password
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password').fill(newPassword);
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
await expect(page.getByTestId('dashboard_view')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows validation error for password mismatch on reset', async ({ page, request }) => {
|
||||||
|
// First register a new user
|
||||||
|
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||||
|
const originalPassword = 'suchagreatpassword123';
|
||||||
|
await registerNewUser(page, email, originalPassword);
|
||||||
|
|
||||||
|
// Log out
|
||||||
|
await page.getByTestId('current_user_button').click();
|
||||||
|
await page.getByText('Log Out').click();
|
||||||
|
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
|
|
||||||
|
// Request password reset
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||||
|
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
|
||||||
|
|
||||||
|
// Get password reset URL from email
|
||||||
|
const resetUrl = await getPasswordResetUrl(request, email);
|
||||||
|
|
||||||
|
// Navigate to reset page
|
||||||
|
await page.goto(resetUrl);
|
||||||
|
|
||||||
|
// Fill in mismatched passwords
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill('newpassword123');
|
||||||
|
await page.getByLabel('Confirm Password').fill('differentpassword456');
|
||||||
|
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||||
|
|
||||||
|
// Should show validation error
|
||||||
|
await expect(page.getByText('The password field confirmation does not match.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows validation error for short password on reset', async ({ page, request }) => {
|
||||||
|
// First register a new user
|
||||||
|
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||||
|
const originalPassword = 'suchagreatpassword123';
|
||||||
|
await registerNewUser(page, email, originalPassword);
|
||||||
|
|
||||||
|
// Log out
|
||||||
|
await page.getByTestId('current_user_button').click();
|
||||||
|
await page.getByText('Log Out').click();
|
||||||
|
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
|
|
||||||
|
// Request password reset
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||||
|
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
|
||||||
|
|
||||||
|
// Get password reset URL from email
|
||||||
|
const resetUrl = await getPasswordResetUrl(request, email);
|
||||||
|
|
||||||
|
// Navigate to reset page
|
||||||
|
await page.goto(resetUrl);
|
||||||
|
|
||||||
|
// Fill in short password
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill('short');
|
||||||
|
await page.getByLabel('Confirm Password').fill('short');
|
||||||
|
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||||
|
|
||||||
|
// Should show validation error about minimum length
|
||||||
|
await expect(page.getByText('must be at least')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows error for invalid login credentials', async ({ page }) => {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
|
await page.getByLabel('Email').fill('nonexistent@example.com');
|
||||||
|
await page.getByLabel('Password').fill('wrongpassword123');
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('These credentials do not match our records.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows error when registering with existing email', async ({ page }) => {
|
||||||
|
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||||
|
const password = 'suchagreatpassword123';
|
||||||
|
|
||||||
|
// Register first user
|
||||||
|
await registerNewUser(page, email, password);
|
||||||
|
|
||||||
|
// Log out
|
||||||
|
await page.getByTestId('current_user_button').click();
|
||||||
|
await page.getByText('Log Out').click();
|
||||||
|
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
|
|
||||||
|
// Try to register with the same email
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||||
|
await page.getByLabel('Name').fill('Another User');
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||||
|
await page.getByLabel('Confirm Password').fill(password);
|
||||||
|
await page.getByLabel('I agree to the Terms of').click();
|
||||||
|
await page.getByRole('button', { name: 'Register' }).click();
|
||||||
|
|
||||||
|
// Should show error about email already taken
|
||||||
|
await expect(page.getByText('The resource already exists.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows validation error for weak password on registration', async ({ page }) => {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||||
|
await page.getByLabel('Name').fill('Weak Password User');
|
||||||
|
await page.getByLabel('Email').fill(`weak+${Math.round(Math.random() * 10000)}@test.com`);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill('short');
|
||||||
|
await page.getByLabel('Confirm Password').fill('short');
|
||||||
|
await page.getByLabel('I agree to the Terms of').click();
|
||||||
|
await page.getByRole('button', { name: 'Register' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('must be at least')).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
172
e2e/calendar-settings.spec.ts
Normal file
172
e2e/calendar-settings.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
|
import { test } from '../playwright/fixtures';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
async function goToCalendar(page: Page) {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
||||||
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSettingsPopover(page: Page) {
|
||||||
|
await page.getByRole('button', { name: 'Calendar settings' }).click();
|
||||||
|
await expect(page.getByText('Calendar Settings')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCalendarSettings(page: Page) {
|
||||||
|
await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Calendar Settings', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await clearCalendarSettings(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings popover shows all fields with correct defaults', async ({ page }) => {
|
||||||
|
await goToCalendar(page);
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
|
||||||
|
await expect(page.getByLabel('Snap Interval')).toContainText('15 min');
|
||||||
|
await expect(page.getByLabel('Start Time')).toContainText('12:00 AM');
|
||||||
|
await expect(page.getByLabel('End Time')).toContainText('12:00 AM (next)');
|
||||||
|
await expect(page.getByLabel('Grid Scale')).toContainText('15 min');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snap interval can be changed and persists across reload', async ({ page }) => {
|
||||||
|
await goToCalendar(page);
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
|
||||||
|
// Change snap interval to 30 min
|
||||||
|
await page.getByLabel('Snap Interval').click();
|
||||||
|
await page.getByRole('option', { name: '30 min' }).click();
|
||||||
|
await page.locator('.fc-toolbar-title').click();
|
||||||
|
|
||||||
|
// Verify localStorage was updated
|
||||||
|
const stored = await page.evaluate(() =>
|
||||||
|
JSON.parse(localStorage.getItem('solidtime:calendar-settings') || '{}')
|
||||||
|
);
|
||||||
|
expect(stored.snapMinutes).toBe(30);
|
||||||
|
|
||||||
|
// Reload and verify persistence
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
await expect(page.getByLabel('Snap Interval')).toContainText('30 min');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('start time change is applied to calendar and rejects values >= end time', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Verify 7 AM slot exists with default start (00:00)
|
||||||
|
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).not.toHaveCount(0);
|
||||||
|
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
|
||||||
|
// Set end time to 6 PM first
|
||||||
|
await page.getByLabel('End Time').click();
|
||||||
|
await page.getByRole('option', { name: '6:00 PM' }).click();
|
||||||
|
|
||||||
|
// Change start time to 8 AM (valid)
|
||||||
|
await page.getByLabel('Start Time').click();
|
||||||
|
await page.getByRole('option', { name: '8:00 AM' }).click();
|
||||||
|
await page.locator('.fc-toolbar-title').click();
|
||||||
|
|
||||||
|
// Calendar should no longer show hours before 8 AM
|
||||||
|
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
|
||||||
|
|
||||||
|
// Try to set start time to 6 PM (invalid: equals end time)
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
await page.getByLabel('Start Time').click();
|
||||||
|
await page.getByRole('option', { name: '6:00 PM' }).click();
|
||||||
|
|
||||||
|
// Should be rejected — start time stays at 8 AM
|
||||||
|
await expect(page.getByLabel('Start Time')).toContainText('8:00 AM');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('end time change is applied to calendar and rejects values <= start time', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Verify 19:00 slot exists with default end (24:00)
|
||||||
|
await expect(page.locator('.fc-timegrid-slot[data-time="19:00:00"]')).not.toHaveCount(0);
|
||||||
|
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
|
||||||
|
// Set start time to 8 AM first
|
||||||
|
await page.getByLabel('Start Time').click();
|
||||||
|
await page.getByRole('option', { name: '8:00 AM' }).click();
|
||||||
|
|
||||||
|
// Change end time to 6 PM (valid)
|
||||||
|
await page.getByLabel('End Time').click();
|
||||||
|
await page.getByRole('option', { name: '6:00 PM' }).click();
|
||||||
|
await page.locator('.fc-toolbar-title').click();
|
||||||
|
|
||||||
|
// Calendar should no longer show hours at or after 6 PM
|
||||||
|
await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0);
|
||||||
|
|
||||||
|
// Try to set end time to 8 AM (invalid: equals start time)
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
await page.getByLabel('End Time').click();
|
||||||
|
await page.getByRole('option', { name: '8:00 AM' }).click();
|
||||||
|
|
||||||
|
// Should be rejected — end time stays at 6 PM
|
||||||
|
await expect(page.getByLabel('End Time')).toContainText('6:00 PM');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grid scale affects number of calendar slots', async ({ page }) => {
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Count slots with default 15-min scale
|
||||||
|
const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();
|
||||||
|
|
||||||
|
// Change to 30 min scale (should halve the slots)
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
await page.getByLabel('Grid Scale').click();
|
||||||
|
await page.getByRole('option', { name: '30 min' }).click();
|
||||||
|
await page.locator('.fc-toolbar-title').click();
|
||||||
|
|
||||||
|
const largerSlotCount = await page.locator('.fc-timegrid-slot').count();
|
||||||
|
expect(largerSlotCount).toBeLessThan(defaultSlotCount);
|
||||||
|
|
||||||
|
// Change to 5 min scale (should have many more slots)
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
await page.getByLabel('Grid Scale').click();
|
||||||
|
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
||||||
|
await page.locator('.fc-toolbar-title').click();
|
||||||
|
|
||||||
|
const smallerSlotCount = await page.locator('.fc-timegrid-slot').count();
|
||||||
|
expect(smallerSlotCount).toBeGreaterThan(defaultSlotCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all settings persist across navigation', async ({ page }) => {
|
||||||
|
await goToCalendar(page);
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
|
||||||
|
// Change every setting
|
||||||
|
await page.getByLabel('Snap Interval').click();
|
||||||
|
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
||||||
|
await page.getByLabel('Start Time').click();
|
||||||
|
await page.getByRole('option', { name: '6:00 AM' }).click();
|
||||||
|
await page.getByLabel('End Time').click();
|
||||||
|
await page.getByRole('option', { name: '10:00 PM' }).click();
|
||||||
|
await page.getByLabel('Grid Scale').click();
|
||||||
|
await page.getByRole('option', { name: '30 min' }).click();
|
||||||
|
await page.locator('.fc-toolbar-title').click();
|
||||||
|
|
||||||
|
// Navigate away and back
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Verify all settings persisted
|
||||||
|
await openSettingsPopover(page);
|
||||||
|
await expect(page.getByLabel('Snap Interval')).toContainText('5 min');
|
||||||
|
await expect(page.getByLabel('Start Time')).toContainText('6:00 AM');
|
||||||
|
await expect(page.getByLabel('End Time')).toContainText('10:00 PM');
|
||||||
|
await expect(page.getByLabel('Grid Scale')).toContainText('30 min');
|
||||||
|
});
|
||||||
|
});
|
||||||
326
e2e/calendar.spec.ts
Normal file
326
e2e/calendar.spec.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
|
import { test } from '../playwright/fixtures';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
createBillableProjectViaApi,
|
||||||
|
createProjectViaApi,
|
||||||
|
createBareTimeEntryViaApi,
|
||||||
|
createTimeEntryViaApi,
|
||||||
|
} from './utils/api';
|
||||||
|
|
||||||
|
async function goToCalendar(page: Page) {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These tests verify that changing the project on a time entry via the calendar
|
||||||
|
* updates the billable status to match the new project's is_billable setting.
|
||||||
|
*
|
||||||
|
* Issue: https://github.com/solidtime-io/solidtime/issues/981
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('test that changing project in calendar edit modal from non-billable to billable updates billable status', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const billableProjectName = 'Billable Cal Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
|
||||||
|
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Test billable calendar', '1h');
|
||||||
|
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Click on the time entry event in the calendar
|
||||||
|
await page.locator('.fc-event').filter({ hasText: 'Test billable calendar' }).first().click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Verify initially non-billable
|
||||||
|
await expect(
|
||||||
|
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Select the billable project
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();
|
||||||
|
await page.getByRole('option', { name: billableProjectName }).click();
|
||||||
|
|
||||||
|
// Verify the billable dropdown updated to Billable
|
||||||
|
await expect(
|
||||||
|
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Save and verify
|
||||||
|
const [updateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
const responseBody = await updateResponse.json();
|
||||||
|
expect(responseBody.data.billable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that changing project in calendar edit modal from billable to non-billable updates billable status', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const billableProjectName = 'Billable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
const nonBillableProjectName =
|
||||||
|
'NonBillable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
|
||||||
|
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
||||||
|
await createProjectViaApi(ctx, { name: nonBillableProjectName });
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Test billable cal reverse', '1h');
|
||||||
|
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Click on the time entry event in the calendar
|
||||||
|
await page
|
||||||
|
.locator('.fc-event')
|
||||||
|
.filter({ hasText: 'Test billable cal reverse' })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// First assign the billable project
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();
|
||||||
|
await page.getByRole('option', { name: billableProjectName }).click();
|
||||||
|
|
||||||
|
// Verify billable status flipped to Billable
|
||||||
|
await expect(
|
||||||
|
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Now switch to the non-billable project
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: billableProjectName }).click();
|
||||||
|
await page.getByRole('option', { name: nonBillableProjectName }).click();
|
||||||
|
|
||||||
|
// Verify billable status reverted to Non-Billable
|
||||||
|
await expect(
|
||||||
|
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Save and verify
|
||||||
|
const [updateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
const responseBody = await updateResponse.json();
|
||||||
|
expect(responseBody.data.billable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that opening calendar edit modal for a time entry with manually overridden billable status preserves that status', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const billableProjectName =
|
||||||
|
'Billable Cal Persist Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
|
||||||
|
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Test cal persist override', '1h');
|
||||||
|
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Click on the time entry event in the calendar
|
||||||
|
await page
|
||||||
|
.locator('.fc-event')
|
||||||
|
.filter({ hasText: 'Test cal persist override' })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Assign the billable project
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();
|
||||||
|
await page.getByRole('option', { name: billableProjectName }).click();
|
||||||
|
|
||||||
|
// Verify it auto-set to Billable
|
||||||
|
await expect(
|
||||||
|
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Now manually override billable to Non-Billable via the dropdown
|
||||||
|
await page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }).click();
|
||||||
|
await page.getByRole('option', { name: 'Non Billable' }).click();
|
||||||
|
|
||||||
|
// Verify it shows Non-Billable now
|
||||||
|
await expect(
|
||||||
|
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const [firstSaveResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
const firstBody = await firstSaveResponse.json();
|
||||||
|
expect(firstBody.data.billable).toBe(false);
|
||||||
|
|
||||||
|
// Re-open the edit modal from the calendar — the project_id watcher should NOT override billable
|
||||||
|
await page
|
||||||
|
.locator('.fc-event')
|
||||||
|
.filter({ hasText: 'Test cal persist override' })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// The billable dropdown should still show Non-Billable
|
||||||
|
await expect(
|
||||||
|
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Save without changes and verify the response still has billable=false
|
||||||
|
const [updateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
const responseBody = await updateResponse.json();
|
||||||
|
expect(responseBody.data.billable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that calendar page loads and displays time entries', async ({ page, ctx }) => {
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Calendar display test', '1h');
|
||||||
|
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Calendar container should be visible
|
||||||
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
|
|
||||||
|
// The time entry should appear as a calendar event
|
||||||
|
await expect(
|
||||||
|
page.locator('.fc-event').filter({ hasText: 'Calendar display test' }).first()
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that calendar navigation buttons work', async ({ page }) => {
|
||||||
|
await goToCalendar(page);
|
||||||
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
|
|
||||||
|
// Click the "next" button to navigate forward
|
||||||
|
await page.locator('button.fc-next-button').click();
|
||||||
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
|
|
||||||
|
// Click the "prev" button to navigate back
|
||||||
|
await page.locator('button.fc-prev-button').click();
|
||||||
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
|
|
||||||
|
// Navigate forward first so "today" button becomes enabled, then click it
|
||||||
|
await page.locator('button.fc-next-button').click();
|
||||||
|
await page.locator('button.fc-today-button').click();
|
||||||
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {
|
||||||
|
const originalDescription = 'Edit me in calendar ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
const updatedDescription = 'Updated in calendar ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createBareTimeEntryViaApi(ctx, originalDescription, '1h');
|
||||||
|
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Click on the time entry event
|
||||||
|
await page.locator('.fc-event').filter({ hasText: originalDescription }).first().click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Update the description (edit modal uses placeholder, not data-testid)
|
||||||
|
const descriptionInput = page.getByRole('dialog').getByPlaceholder('What did you work on?');
|
||||||
|
await descriptionInput.fill(updatedDescription);
|
||||||
|
|
||||||
|
// Save and verify
|
||||||
|
const [editResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
const editBody = await editResponse.json();
|
||||||
|
expect(editBody.data.description).toBe(updatedDescription);
|
||||||
|
|
||||||
|
// Verify the updated description is shown in the calendar UI
|
||||||
|
await expect(
|
||||||
|
page.locator('.fc-event').filter({ hasText: updatedDescription }).first()
|
||||||
|
).toBeVisible();
|
||||||
|
// Verify the old description is no longer shown
|
||||||
|
await expect(
|
||||||
|
page.locator('.fc-event').filter({ hasText: originalDescription })
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that deleting time entry from calendar modal works', async ({ page, ctx }) => {
|
||||||
|
const description = 'Delete me from calendar ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||||||
|
|
||||||
|
await goToCalendar(page);
|
||||||
|
|
||||||
|
// Click on the time entry event
|
||||||
|
await page.locator('.fc-event').filter({ hasText: description }).first().click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Click the delete button
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries/') &&
|
||||||
|
response.request().method() === 'DELETE' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify the event is removed from the calendar
|
||||||
|
await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Employee Permission Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test.describe('Employee Calendar Isolation', () => {
|
||||||
|
test('employee can only see their own time entries on the calendar', async ({
|
||||||
|
ctx,
|
||||||
|
employee,
|
||||||
|
}) => {
|
||||||
|
// Owner creates a time entry for today
|
||||||
|
const ownerDescription = 'OwnerCalEntry ' + Math.floor(Math.random() * 10000);
|
||||||
|
await createBareTimeEntryViaApi(ctx, ownerDescription, '1h');
|
||||||
|
|
||||||
|
// Create a time entry for the employee for today
|
||||||
|
const employeeDescription = 'EmpCalEntry ' + Math.floor(Math.random() * 10000);
|
||||||
|
await createTimeEntryViaApi(
|
||||||
|
{ ...ctx, memberId: employee.memberId },
|
||||||
|
{ description: employeeDescription, duration: '30min' }
|
||||||
|
);
|
||||||
|
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
||||||
|
await expect(employee.page.locator('.fc')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Employee's event IS visible
|
||||||
|
await expect(
|
||||||
|
employee.page.locator('.fc-event').filter({ hasText: employeeDescription }).first()
|
||||||
|
).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Owner's event is NOT visible
|
||||||
|
await expect(
|
||||||
|
employee.page.locator('.fc-event').filter({ hasText: ownerDescription })
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
import { expect, Page } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
import { test } from '../playwright/fixtures';
|
import { test } from '../playwright/fixtures';
|
||||||
|
import {
|
||||||
|
createClientViaApi,
|
||||||
|
createProjectMemberViaApi,
|
||||||
|
createProjectViaApi,
|
||||||
|
createPublicProjectViaApi,
|
||||||
|
} from './utils/api';
|
||||||
|
import { getTableRowNames } from './utils/table';
|
||||||
|
|
||||||
async function goToProjectsOverview(page: Page) {
|
async function goToClientsOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new project via modal
|
// Create new client via modal
|
||||||
test('test that creating and deleting a new client via the modal works', async ({
|
test('test that creating and deleting a new client via the modal works', async ({ page }) => {
|
||||||
page,
|
const newClientName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
}) => {
|
await goToClientsOverview(page);
|
||||||
const newClientName =
|
|
||||||
'New Project ' + Math.floor(1 + Math.random() * 10000);
|
|
||||||
await goToProjectsOverview(page);
|
|
||||||
await page.getByRole('button', { name: 'Create Client' }).click();
|
await page.getByRole('button', { name: 'Create Client' }).click();
|
||||||
await page.getByPlaceholder('Client Name').fill(newClientName);
|
await page.getByPlaceholder('Client Name').fill(newClientName);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -28,13 +33,9 @@ test('test that creating and deleting a new client via the modal works', async (
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(page.getByTestId('client_table')).toContainText(newClientName);
|
await expect(page.getByTestId('client_table')).toContainText(newClientName);
|
||||||
const moreButton = page.locator(
|
const moreButton = page.locator("[aria-label='Actions for Client " + newClientName + "']");
|
||||||
"[aria-label='Actions for Client " + newClientName + "']"
|
await moreButton.click();
|
||||||
);
|
const deleteButton = page.locator("[aria-label='Delete Client " + newClientName + "']");
|
||||||
moreButton.click();
|
|
||||||
const deleteButton = page.locator(
|
|
||||||
"[aria-label='Delete Client " + newClientName + "']"
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
deleteButton.click(),
|
deleteButton.click(),
|
||||||
@@ -45,18 +46,14 @@ test('test that creating and deleting a new client via the modal works', async (
|
|||||||
response.status() === 204
|
response.status() === 204
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
await expect(page.getByTestId('client_table')).not.toContainText(
|
await expect(page.getByTestId('client_table')).not.toContainText(newClientName);
|
||||||
newClientName
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that archiving and unarchiving clients works', async ({ page }) => {
|
test('test that archiving and unarchiving clients works', async ({ page, ctx }) => {
|
||||||
const newClientName = 'New Client ' + Math.floor(1 + Math.random() * 10000);
|
const newClientName = 'New Client ' + Math.floor(1 + Math.random() * 10000);
|
||||||
await goToProjectsOverview(page);
|
await createClientViaApi(ctx, { name: newClientName });
|
||||||
await page.getByRole('button', { name: 'Create Client' }).click();
|
|
||||||
await page.getByLabel('Client Name').fill(newClientName);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Client' }).click();
|
await goToClientsOverview(page);
|
||||||
await expect(page.getByText(newClientName)).toBeVisible();
|
await expect(page.getByText(newClientName)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
@@ -80,4 +77,226 @@ test('test that archiving and unarchiving clients works', async ({ page }) => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Add Name Update Test
|
test('test that editing a client name works', async ({ page, ctx }) => {
|
||||||
|
const originalName = 'Original Client ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
const updatedName = 'Updated Client ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createClientViaApi(ctx, { name: originalName });
|
||||||
|
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await expect(page.getByText(originalName)).toBeVisible();
|
||||||
|
|
||||||
|
// Open edit modal via actions menu
|
||||||
|
const moreButton = page.locator("[aria-label='Actions for Client " + originalName + "']");
|
||||||
|
await moreButton.click();
|
||||||
|
await page.getByTestId('client_edit').click();
|
||||||
|
|
||||||
|
// Update the client name
|
||||||
|
await page.getByPlaceholder('Client Name').fill(updatedName);
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Update Client' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/clients') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify updated name is shown and old name is gone
|
||||||
|
await expect(page.getByTestId('client_table')).toContainText(updatedName);
|
||||||
|
await expect(page.getByTestId('client_table')).not.toContainText(originalName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that deleting a client via actions menu works', async ({ page, ctx }) => {
|
||||||
|
const clientName = 'DeleteMe Client ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
|
||||||
|
await createClientViaApi(ctx, { name: clientName });
|
||||||
|
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await expect(page.getByTestId('client_table')).toContainText(clientName);
|
||||||
|
|
||||||
|
const moreButton = page.locator("[aria-label='Actions for Client " + clientName + "']");
|
||||||
|
await moreButton.click();
|
||||||
|
const deleteButton = page.locator("[aria-label='Delete Client " + clientName + "']");
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
deleteButton.click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/clients') &&
|
||||||
|
response.request().method() === 'DELETE' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Sorting Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
async function clearClientTableState(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.removeItem('client-table-state');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that sorting clients by name and status works', async ({ page, ctx }) => {
|
||||||
|
await createClientViaApi(ctx, { name: 'AAA SortClient' });
|
||||||
|
await createClientViaApi(ctx, { name: 'ZZZ SortClient' });
|
||||||
|
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await clearClientTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('client_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// -- Name sorting (default is name asc) --
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('AAA SortClient')).toBeLessThan(names.indexOf('ZZZ SortClient'));
|
||||||
|
|
||||||
|
const nameHeader = table.getByText('Name').first();
|
||||||
|
await nameHeader.click(); // toggle to desc
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('ZZZ SortClient')).toBeLessThan(names.indexOf('AAA SortClient'));
|
||||||
|
|
||||||
|
// -- Status sorting --
|
||||||
|
const statusHeader = table.getByText('Status').first();
|
||||||
|
await statusHeader.click(); // asc
|
||||||
|
await expect(statusHeader.locator('svg')).toBeVisible();
|
||||||
|
await statusHeader.click(); // desc
|
||||||
|
await expect(statusHeader.locator('svg')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that sorting clients by project count works', async ({ page, ctx }) => {
|
||||||
|
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
|
||||||
|
const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' });
|
||||||
|
|
||||||
|
// Create projects for the first client
|
||||||
|
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
|
||||||
|
await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id });
|
||||||
|
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await clearClientTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('client_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// Click Projects header - first click should sort desc (most projects first)
|
||||||
|
const projectsHeader = table.getByText('Projects').first();
|
||||||
|
await projectsHeader.click();
|
||||||
|
await expect(projectsHeader.locator('svg')).toBeVisible();
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('ManyProjects Client')).toBeLessThan(names.indexOf('NoProjects Client'));
|
||||||
|
|
||||||
|
// Second click toggles to asc (least projects first)
|
||||||
|
await projectsHeader.click();
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('NoProjects Client')).toBeLessThan(names.indexOf('ManyProjects Client'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that client sort state persists after page reload', async ({ page }) => {
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await clearClientTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('client_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
const nameHeader = table.getByText('Name').first();
|
||||||
|
await nameHeader.click(); // toggle to desc
|
||||||
|
await expect(nameHeader.locator('svg')).toBeVisible();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('client_table')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('client_table').getByText('Name').first().locator('svg')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Employee Permission Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test.describe('Employee Clients Restrictions', () => {
|
||||||
|
test('employee can view clients but cannot create', async ({ ctx, employee }) => {
|
||||||
|
// Create a client with a public project so the employee can see the client
|
||||||
|
const clientName = 'EmpViewClient ' + Math.floor(Math.random() * 10000);
|
||||||
|
const client = await createClientViaApi(ctx, { name: clientName });
|
||||||
|
await createPublicProjectViaApi(ctx, { name: 'EmpClientProj', client_id: client.id });
|
||||||
|
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||||
|
await expect(employee.page.getByTestId('clients_view')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Employee can see the client
|
||||||
|
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Employee cannot see Create Client button
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('button', { name: 'Create Client' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('employee cannot see edit/delete/archive actions on clients', async ({
|
||||||
|
ctx,
|
||||||
|
employee,
|
||||||
|
}) => {
|
||||||
|
const clientName = 'EmpActionsClient ' + Math.floor(Math.random() * 10000);
|
||||||
|
const client = await createClientViaApi(ctx, { name: clientName });
|
||||||
|
await createPublicProjectViaApi(ctx, { name: 'EmpClientActProj', client_id: client.id });
|
||||||
|
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||||
|
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Click the actions dropdown trigger to open the menu
|
||||||
|
const actionsButton = employee.page.locator(
|
||||||
|
`[aria-label='Actions for Client ${clientName}']`
|
||||||
|
);
|
||||||
|
await actionsButton.click();
|
||||||
|
|
||||||
|
// The dropdown menu items (Edit, Archive, Delete) should NOT be visible
|
||||||
|
await expect(
|
||||||
|
employee.page.locator(`[aria-label='Edit Client ${clientName}']`)
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(
|
||||||
|
employee.page.locator(`[aria-label='Archive Client ${clientName}']`)
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(
|
||||||
|
employee.page.locator(`[aria-label='Delete Client ${clientName}']`)
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('employee can see client when they are a member of its private project', async ({
|
||||||
|
ctx,
|
||||||
|
employee,
|
||||||
|
}) => {
|
||||||
|
const clientName = 'EmpPrivateClient ' + Math.floor(Math.random() * 10000);
|
||||||
|
const client = await createClientViaApi(ctx, { name: clientName });
|
||||||
|
|
||||||
|
// Create a private project under this client
|
||||||
|
const project = await createProjectViaApi(ctx, {
|
||||||
|
name: 'PrivateProj',
|
||||||
|
client_id: client.id,
|
||||||
|
is_public: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the employee as a project member
|
||||||
|
await createProjectMemberViaApi(ctx, project.id, {
|
||||||
|
member_id: employee.memberId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||||
|
await expect(employee.page.getByTestId('clients_view')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Employee can see the client because they are a member of its private project
|
||||||
|
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user