mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
65 Commits
feature/fi
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
692f7a725d | ||
|
|
26c50867b3 | ||
|
|
b8110e222a | ||
|
|
7673b365ca | ||
|
|
da5fc3f113 | ||
|
|
8c66068663 | ||
|
|
dd0cc0d60b | ||
|
|
3a482c1e6a | ||
|
|
ef9f353047 | ||
|
|
f1a1d2a266 | ||
|
|
f5efbad703 | ||
|
|
17242188c2 | ||
|
|
0a376b1caa | ||
|
|
10a8310e37 | ||
|
|
89131b9e77 | ||
|
|
c17c5dc6c0 | ||
|
|
3444281703 | ||
|
|
84e2365a6d | ||
|
|
92ac9948a0 | ||
|
|
8da358dbe6 | ||
|
|
b7b9092e64 | ||
|
|
15ac3e9a43 | ||
|
|
d03dd60864 | ||
|
|
827e0fe377 | ||
|
|
e78a551098 | ||
|
|
ae00fdb0e9 | ||
|
|
3c9160a08a | ||
|
|
4fb744db1d | ||
|
|
bc9b104c3f | ||
|
|
880c363ae4 | ||
|
|
8e6d1abbf3 | ||
|
|
d202bd9c47 | ||
|
|
992d8945df | ||
|
|
df2fe1da1e | ||
|
|
7339b79e35 | ||
|
|
6deb281565 | ||
|
|
6ba0b19d40 | ||
|
|
01f6f0f5ea | ||
|
|
aa3c64e496 | ||
|
|
eee13897c9 | ||
|
|
ac6e2b8079 | ||
|
|
50cc7053e4 | ||
|
|
73ce5f793d | ||
|
|
02a716897d | ||
|
|
e5ec11af44 | ||
|
|
ab263e725f | ||
|
|
f93c5370bf | ||
|
|
9faa8fe6e1 | ||
|
|
9948cb1fc1 | ||
|
|
3026edd27b | ||
|
|
b6bbcd7097 | ||
|
|
0d4ffa1061 | ||
|
|
b7abe3738e | ||
|
|
128a21ba63 | ||
|
|
e25461a439 | ||
|
|
ba8751c7c4 | ||
|
|
21b33a0028 | ||
|
|
97585b5771 | ||
|
|
ae76135373 | ||
|
|
69a8c8bb2b | ||
|
|
4ea55e5867 | ||
|
|
bbed618fdc | ||
|
|
d924fa74ec | ||
|
|
adf0d35c11 | ||
|
|
4ed8f16ae3 |
42
.env.ci
42
.env.ci
@@ -1,3 +1,4 @@
|
||||
# Application
|
||||
APP_NAME=solidtime
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
@@ -5,7 +6,6 @@ APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_FORCE_HTTPS=false
|
||||
APP_ENABLE_REGISTRATION=true
|
||||
SESSION_SECURE_COOKIE=false
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
@@ -20,35 +20,39 @@ DB_TEST_DATABASE=laravel
|
||||
DB_TEST_USERNAME=root
|
||||
DB_TEST_PASSWORD=root
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
# Broadcasting
|
||||
BROADCAST_DRIVER=null
|
||||
|
||||
# Cache
|
||||
CACHE_DRIVER=file
|
||||
|
||||
# Queue
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
# Session
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
|
||||
# Mail
|
||||
MAIL_MAILER=log
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||
MAIL_FROM_NAME="solidtime"
|
||||
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
||||
MAIL_REPLY_TO_NAME="solidtime"
|
||||
|
||||
# Filesystems
|
||||
FILESYSTEM_DISK=local
|
||||
PUBLIC_FILESYSTEM_DISK=public
|
||||
|
||||
# Passport
|
||||
PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92"
|
||||
PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf"
|
||||
|
||||
# Auditing
|
||||
AUDITING_ENABLED=true
|
||||
|
||||
# Telescope
|
||||
TELESCOPE_ENABLED=false
|
||||
|
||||
# Services
|
||||
GOTENBERG_URL=http://0.0.0.0:3000
|
||||
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_APP_KEY=
|
||||
PUSHER_APP_SECRET=
|
||||
PUSHER_HOST=
|
||||
PUSHER_PORT=443
|
||||
PUSHER_SCHEME=https
|
||||
PUSHER_APP_CLUSTER=mt1
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||
VITE_PUSHER_HOST="${PUSHER_HOST}"
|
||||
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
||||
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
||||
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||
|
||||
22
.env.example
22
.env.example
@@ -4,7 +4,7 @@ APP_ENV=local
|
||||
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://solidtime.test
|
||||
AUDITING_ENABLED=true
|
||||
APP_FORCE_HTTPS=false
|
||||
APP_ENABLE_REGISTRATION=true
|
||||
SUPER_ADMINS=admin@example.com
|
||||
PAGINATION_PER_PAGE_DEFAULT=500
|
||||
@@ -49,7 +49,9 @@ MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
MAIL_FROM_NAME="solidtime"
|
||||
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
||||
MAIL_REPLY_TO_NAME="solidtime"
|
||||
|
||||
# Filesystems
|
||||
FILESYSTEM_DISK=s3
|
||||
@@ -62,14 +64,24 @@ S3_URL=http://storage.solidtime.test/local
|
||||
S3_ENDPOINT=http://storage.solidtime.test
|
||||
S3_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
# Passport
|
||||
PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92"
|
||||
PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf"
|
||||
|
||||
# Auditing
|
||||
AUDITING_ENABLED=true
|
||||
|
||||
# Telescope
|
||||
TELESCOPE_ENABLED=false
|
||||
|
||||
# Services
|
||||
GOTENBERG_URL=http://gotenberg:3000
|
||||
|
||||
VITE_HOST_NAME=vite.solidtime.test
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Local setup
|
||||
NGINX_HOST_NAME=solidtime.test
|
||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||
FORWARD_DB_PORT=5432
|
||||
FORWARD_WEB_PORT=8083
|
||||
VITE_HOST_NAME=vite.solidtime.test
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
#SAIL_XDEBUG_MODE=develop,debug,coverage
|
||||
|
||||
@@ -5,7 +5,6 @@ VITE_APP_NAME=solidtime
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_FORCE_HTTPS=true
|
||||
SESSION_SECURE_COOKIE=true
|
||||
OCTANE_SERVER=frankenphp
|
||||
PAGINATION_PER_PAGE_DEFAULT=500
|
||||
|
||||
|
||||
47
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
Normal file
47
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Bug Report
|
||||
description: "Report a bug"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before creating a new bug report, please check that there isn't already a similar issue.
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Steps To Reproduce"
|
||||
description: How do you trigger this bug? Please walk us through it step by step.
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: "Self-hosted or Cloud?"
|
||||
options:
|
||||
- Self-Hosted
|
||||
- solidtime Cloud
|
||||
- Both
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: "Version of solidtime: (for self-hosted)"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: "solidtime self-hosting guide: (for self-hosted)"
|
||||
description: "Did you use the official guide to self-host solidtime? If yes, which one?"
|
||||
validations:
|
||||
required: false
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 🚀 Feature Request
|
||||
url: https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests
|
||||
about: Share ideas for new features
|
||||
- name: ❓ Ask a Question
|
||||
url: https://github.com/solidtime-io/solidtime/discussions/new?category=general
|
||||
about: Ask the community for help
|
||||
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||
-->
|
||||
21
.github/workflows/build-private.yml
vendored
21
.github/workflows/build-private.yml
vendored
@@ -107,6 +107,24 @@ jobs:
|
||||
- name: "Install npm dependencies in services extension"
|
||||
run: cd extensions/Services && npm ci
|
||||
|
||||
- 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"
|
||||
uses: php-actions/composer@v6
|
||||
with:
|
||||
working_dir: "extensions/Invoicing"
|
||||
command: install
|
||||
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||
php_version: 8.3
|
||||
|
||||
- name: "Install npm dependencies in invoicing extension"
|
||||
run: cd extensions/Invoicing && npm ci
|
||||
|
||||
- name: "Setup PHP with PECL extension"
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
@@ -127,6 +145,9 @@ jobs:
|
||||
- name: "Activate services extension"
|
||||
run: php artisan module:enable Services
|
||||
|
||||
- name: "Activate invoicing extension"
|
||||
run: php artisan module:enable Invoicing
|
||||
|
||||
- name: "Install npm dependencies"
|
||||
run: npm ci
|
||||
|
||||
|
||||
145
.github/workflows/build-public.yml
vendored
145
.github/workflows/build-public.yml
vendored
@@ -11,10 +11,21 @@ on:
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DOCKERHUB_REPO: solidtime/solidtime
|
||||
GHCR_REPO: ghcr.io/solidtime-io/solidtime
|
||||
|
||||
name: Build - Public
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
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 }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
@@ -29,7 +40,7 @@ jobs:
|
||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||
|
||||
- name: "Get build"
|
||||
id: build
|
||||
id: release-build
|
||||
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Get Previous tag (normal push)"
|
||||
@@ -40,7 +51,7 @@ jobs:
|
||||
prefix: "v"
|
||||
|
||||
- name: "Get version"
|
||||
id: version
|
||||
id: release-version
|
||||
run: |
|
||||
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
||||
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
||||
@@ -61,21 +72,23 @@ jobs:
|
||||
rm .env.production .env.ci .env.example
|
||||
|
||||
- name: "Add version to .env"
|
||||
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .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.build.outputs.build }}/g' .env
|
||||
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
|
||||
|
||||
- name: "Output .env"
|
||||
run: cat .env
|
||||
|
||||
- name: "Install dependencies"
|
||||
uses: php-actions/composer@v6
|
||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||
- name: "Setup PHP with PECL extension"
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
command: install
|
||||
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||
php_version: 8.3
|
||||
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
|
||||
@@ -88,29 +101,31 @@ jobs:
|
||||
- name: "Build"
|
||||
run: npm run build
|
||||
|
||||
- name: "Login to GitHub Container Registry"
|
||||
- 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.DOCKERHUB_REPO }}
|
||||
${{ env.GHCR_REPO }}
|
||||
|
||||
- name: "Login to Docker Hub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: "Login to GitHub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: "Docker meta"
|
||||
id: "meta"
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
solidtime/solidtime
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: "Set up QEMU"
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -118,16 +133,90 @@ jobs:
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: "Build and push"
|
||||
- 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: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_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
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
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 Docker Hub"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: "Login to GHCR"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_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.DOCKERHUB_REPO }}
|
||||
${{ env.GHCR_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.DOCKERHUB_REPO }}@sha256:%s ' *)
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *)
|
||||
|
||||
- name: "Inspect image"
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.DOCKERHUB_REPO }}:${{ steps.meta.outputs.version }}
|
||||
docker buildx imagetools inspect ${{ env.GHCR_REPO }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
3
.github/workflows/npm-publish-api.yml
vendored
3
.github/workflows/npm-publish-api.yml
vendored
@@ -8,7 +8,8 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
# Setup .npmrc file to publish to npm
|
||||
- name: Install root project dependencies
|
||||
run: npm ci
|
||||
|
||||
3
.github/workflows/npm-publish-ui.yml
vendored
3
.github/workflows/npm-publish-ui.yml
vendored
@@ -8,7 +8,8 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
2
.github/workflows/phpunit.yml
vendored
2
.github/workflows/phpunit.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||
|
||||
- name: "Upload coverage reports to Codecov"
|
||||
uses: codecov/codecov-action@v5.3.1
|
||||
uses: codecov/codecov-action@v5.4.2
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: solidtime-io/solidtime
|
||||
|
||||
24
.github/workflows/playwright.yml
vendored
24
.github/workflows/playwright.yml
vendored
@@ -27,45 +27,47 @@ jobs:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- name: "Setup node"
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: Setup PHP
|
||||
- name: "Setup PHP"
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||
coverage: none
|
||||
|
||||
- name: Run composer install
|
||||
- name: "Run composer install"
|
||||
run: composer install -n --prefer-dist
|
||||
|
||||
- name: Prepare Laravel Application
|
||||
- name: "Prepare Laravel Application"
|
||||
run: |
|
||||
cp .env.ci .env
|
||||
php artisan key:generate
|
||||
php artisan migrate --seed
|
||||
php artisan passport:keys
|
||||
php artisan migrate --seed
|
||||
|
||||
- name: Install dependencies
|
||||
- name: "Install dependencies"
|
||||
run: npm ci
|
||||
|
||||
- name: Build Frontend
|
||||
- name: "Build Frontend"
|
||||
run: npm run build
|
||||
|
||||
- name: Run Laravel Server
|
||||
- name: "Run Laravel Server"
|
||||
run: php artisan serve > /dev/null 2>&1 &
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
- name: "Install Playwright Browsers"
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
- name: "Run Playwright tests"
|
||||
run: npx playwright test
|
||||
env:
|
||||
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- name: "Upload test results"
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: test-results
|
||||
|
||||
@@ -76,6 +76,11 @@ class CreateNewUser implements CreatesNewUsers
|
||||
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
|
||||
|
||||
$startOfWeek = Weekday::Monday;
|
||||
$numberFormat = null;
|
||||
$currencyFormat = null;
|
||||
$dateFormat = null;
|
||||
$intervalFormat = null;
|
||||
$timeFormat = null;
|
||||
$currency = null;
|
||||
if ($ipLookupResponse !== null) {
|
||||
$startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday;
|
||||
@@ -85,7 +90,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
$currency = $ipLookupResponse->currency;
|
||||
}
|
||||
$user = null;
|
||||
DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency): void {
|
||||
DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency, $numberFormat, $currencyFormat, $dateFormat, $intervalFormat, $timeFormat): void {
|
||||
$userService = app(UserService::class);
|
||||
$user = $userService->createUser(
|
||||
$input['name'],
|
||||
@@ -93,7 +98,12 @@ class CreateNewUser implements CreatesNewUsers
|
||||
$input['password'],
|
||||
$timezone ?? 'UTC',
|
||||
$startOfWeek,
|
||||
$currency ?? 'EUR',
|
||||
$currency,
|
||||
$numberFormat,
|
||||
$currencyFormat,
|
||||
$dateFormat,
|
||||
$intervalFormat,
|
||||
$timeFormat
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\OrganizationService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
@@ -33,16 +34,18 @@ class CreateOrganization implements CreatesTeams
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
])->validateWithBag('createTeam');
|
||||
|
||||
$organization = new Organization;
|
||||
$organization->name = $input['name'];
|
||||
$organization->personal_team = false;
|
||||
$organization->owner()->associate($user);
|
||||
$organization->save();
|
||||
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
|
||||
|
||||
$organization->users()->attach(
|
||||
$user, [
|
||||
'role' => Role::Owner->value,
|
||||
]
|
||||
$currency = null;
|
||||
if ($ipLookupResponse !== null) {
|
||||
$currency = $ipLookupResponse->currency;
|
||||
}
|
||||
|
||||
$organization = app(OrganizationService::class)->createOrganization(
|
||||
$input['name'],
|
||||
$user,
|
||||
false,
|
||||
$currency
|
||||
);
|
||||
|
||||
$user->switchTeam($organization);
|
||||
|
||||
@@ -64,8 +64,8 @@ class UserCreateCommand extends Command
|
||||
$password,
|
||||
'UTC',
|
||||
Weekday::Monday,
|
||||
'EUR',
|
||||
$verifyEmail
|
||||
null,
|
||||
verifyEmail: $verifyEmail
|
||||
);
|
||||
});
|
||||
/** @var Organization|null $organization */
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Correction;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Member;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class CorrectionPlaceholderMembersCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'correction:placeholder-members '.
|
||||
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Sets all members who belong to a placeholder user to role placeholder';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->comment('Sets all members who belong to a placeholder user to role placeholder...');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
if ($dryRun) {
|
||||
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
|
||||
}
|
||||
|
||||
$members = Member::query()
|
||||
->where('role', '!=', Role::Placeholder->value)
|
||||
->whereHas('user', function (Builder $builder): void {
|
||||
/** @var Builder<User> $builder */
|
||||
$builder->where('is_placeholder', '=', true);
|
||||
})
|
||||
->get();
|
||||
foreach ($members as $member) {
|
||||
/** @var Member $member */
|
||||
$member->role = Role::Placeholder->value;
|
||||
if (! $dryRun) {
|
||||
$member->save();
|
||||
}
|
||||
$this->line('Set role of member (id='.$member->getKey().') to placeholder');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
36
app/Enums/CurrencyFormat.php
Normal file
36
app/Enums/CurrencyFormat.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum CurrencyFormat: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case ISOCodeBeforeWithSpace = 'iso-code-before-with-space';
|
||||
case ISOCodeAfterWithSpace = 'iso-code-after-with-space';
|
||||
|
||||
case SymbolBefore = 'symbol-before';
|
||||
|
||||
case SymbolAfter = 'symbol-after';
|
||||
|
||||
case SymbolBeforeWithSpace = 'symbol-before-with-space';
|
||||
|
||||
case SymbolAfterWithSpace = 'symbol-after-with-space';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function toSelectArray(): array
|
||||
{
|
||||
$selectArray = [];
|
||||
foreach (self::values() as $value) {
|
||||
$selectArray[(string) $value] = (string) __('enum.currency_format.'.$value);
|
||||
}
|
||||
|
||||
return $selectArray;
|
||||
}
|
||||
}
|
||||
48
app/Enums/DateFormat.php
Normal file
48
app/Enums/DateFormat.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum DateFormat: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case PointSeperatedDMYYYY = 'point-seperated-d-m-yyyy';
|
||||
case SlashSeperatedMMDDYYYY = 'slash-seperated-mm-dd-yyyy';
|
||||
|
||||
case SlashSeperatedDDMMYYYY = 'slash-seperated-dd-mm-yyyy';
|
||||
|
||||
case HyphenSeperatedDDMMYYY = 'hyphen-seperated-dd-mm-yyyy';
|
||||
|
||||
case HyphenSeperatedMMDDDYYYY = 'hyphen-seperated-mm-dd-yyyy';
|
||||
|
||||
case HyphenSeperatedYYYYMMDD = 'hyphen-seperated-yyyy-mm-dd';
|
||||
|
||||
public function toCarbonFormat(): string
|
||||
{
|
||||
return match ($this->value) {
|
||||
self::PointSeperatedDMYYYY->value => 'j.n.Y',
|
||||
self::SlashSeperatedMMDDYYYY->value => 'm/d/Y',
|
||||
self::SlashSeperatedDDMMYYYY->value => 'd/m/Y',
|
||||
self::HyphenSeperatedDDMMYYY->value => 'd-m-Y',
|
||||
self::HyphenSeperatedMMDDDYYYY->value => 'm-d-Y',
|
||||
self::HyphenSeperatedYYYYMMDD->value => 'Y-m-d',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function toSelectArray(): array
|
||||
{
|
||||
$selectArray = [];
|
||||
foreach (self::values() as $value) {
|
||||
$selectArray[(string) $value] = (string) __('enum.date_format.'.$value);
|
||||
}
|
||||
|
||||
return $selectArray;
|
||||
}
|
||||
}
|
||||
32
app/Enums/IntervalFormat.php
Normal file
32
app/Enums/IntervalFormat.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum IntervalFormat: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case Decimal = 'decimal';
|
||||
case HoursMinutes = 'hours-minutes';
|
||||
|
||||
case HoursMinutesColonSeperated = 'hours-minutes-colon-seperated';
|
||||
|
||||
case HoursMinutesSecondsColonSeperated = 'hours-minutes-seconds-colon-seperated';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function toSelectArray(): array
|
||||
{
|
||||
$selectArray = [];
|
||||
foreach (self::values() as $value) {
|
||||
$selectArray[(string) $value] = (string) __('enum.interval_format.'.$value);
|
||||
}
|
||||
|
||||
return $selectArray;
|
||||
}
|
||||
}
|
||||
37
app/Enums/NumberFormat.php
Normal file
37
app/Enums/NumberFormat.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
/**
|
||||
* @info https://en.wikipedia.org/wiki/Decimal_separator
|
||||
*/
|
||||
enum NumberFormat: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case ThousandsPointDecimalComma = 'point-comma';
|
||||
|
||||
case ThousandsCommaDecimalPoint = 'comma-point';
|
||||
case ThousandsSpaceDecimalComma = 'space-comma';
|
||||
|
||||
case ThousandsSpaceDecimalPoint = 'space-point';
|
||||
|
||||
case ThousandsApostropheDecimalPoint = 'apostrophe-point';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function toSelectArray(): array
|
||||
{
|
||||
$selectArray = [];
|
||||
foreach (self::values() as $value) {
|
||||
$selectArray[(string) $value] = (string) __('enum.number_format.'.$value);
|
||||
}
|
||||
|
||||
return $selectArray;
|
||||
}
|
||||
}
|
||||
28
app/Enums/TimeFormat.php
Normal file
28
app/Enums/TimeFormat.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum TimeFormat: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case TwelveHours = '12-hours';
|
||||
case TwentyFourHours = '24-hours';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function toSelectArray(): array
|
||||
{
|
||||
$selectArray = [];
|
||||
foreach (self::values() as $value) {
|
||||
$selectArray[(string) $value] = (string) __('enum.time_format.'.$value);
|
||||
}
|
||||
|
||||
return $selectArray;
|
||||
}
|
||||
}
|
||||
10
app/Exceptions/Api/ChangingRoleOfPlaceholderIsNotAllowed.php
Normal file
10
app/Exceptions/Api/ChangingRoleOfPlaceholderIsNotAllowed.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
|
||||
{
|
||||
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
|
||||
{
|
||||
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class PersonalAccessClientIsNotConfiguredException extends ApiException
|
||||
{
|
||||
public const string KEY = 'personal_access_client_is_not_configured';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
|
||||
{
|
||||
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use Filament\Tables;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
use Novadaemon\FilamentPrettyJson\PrettyJson;
|
||||
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
|
||||
|
||||
class AuditResource extends Resource
|
||||
{
|
||||
@@ -38,8 +38,8 @@ class AuditResource extends Resource
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('auditable_id')
|
||||
->required(),
|
||||
PrettyJson::make('old_values'),
|
||||
PrettyJson::make('new_values'),
|
||||
PrettyJsonField::make('old_values'),
|
||||
PrettyJsonField::make('new_values'),
|
||||
Forms\Components\Textarea::make('url'),
|
||||
Forms\Components\TextInput::make('ip_address'),
|
||||
Forms\Components\TextInput::make('user_agent')
|
||||
|
||||
@@ -20,7 +20,7 @@ use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Novadaemon\FilamentPrettyJson\PrettyJson;
|
||||
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
|
||||
|
||||
/**
|
||||
* @source https://gitlab.com/amvisor/filament-failed-jobs
|
||||
@@ -50,7 +50,7 @@ class FailedJobResource extends Resource
|
||||
|
||||
// make text a little bit smaller because often a complete Stack Trace is shown:
|
||||
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
|
||||
PrettyJson::make('payload')->disabled()->columnSpan(4),
|
||||
PrettyJsonField::make('payload')->disabled()->columnSpan(4),
|
||||
])->columns(4);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Enums\CurrencyFormat;
|
||||
use App\Enums\DateFormat;
|
||||
use App\Enums\IntervalFormat;
|
||||
use App\Enums\NumberFormat;
|
||||
use App\Enums\TimeFormat;
|
||||
use App\Filament\Resources\OrganizationResource\Pages;
|
||||
use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager;
|
||||
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
|
||||
@@ -56,6 +61,21 @@ class OrganizationResource extends Resource
|
||||
->searchable(['name', 'email'])
|
||||
->disabledOn(['edit'])
|
||||
->required(),
|
||||
Select::make('date_format')
|
||||
->options(DateFormat::toSelectArray())
|
||||
->required(),
|
||||
Select::make('currency_format')
|
||||
->options(CurrencyFormat::toSelectArray())
|
||||
->required(),
|
||||
Select::make('interval_format')
|
||||
->options(IntervalFormat::toSelectArray())
|
||||
->required(),
|
||||
Select::make('number_format')
|
||||
->options(NumberFormat::toSelectArray())
|
||||
->required(),
|
||||
Select::make('time_format')
|
||||
->options(TimeFormat::toSelectArray())
|
||||
->required(),
|
||||
Forms\Components\Select::make('currency')
|
||||
->label('Currency')
|
||||
->options(function (): array {
|
||||
|
||||
@@ -18,7 +18,7 @@ use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Novadaemon\FilamentPrettyJson\PrettyJson;
|
||||
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
|
||||
|
||||
class ReportResource extends Resource
|
||||
{
|
||||
@@ -58,7 +58,7 @@ class ReportResource extends Resource
|
||||
Forms\Components\TextInput::make('share_secret')
|
||||
->label('Share Secret')
|
||||
->nullable(),
|
||||
PrettyJson::make('properties')
|
||||
PrettyJsonField::make('properties')
|
||||
->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string {
|
||||
return $record->getRawOriginal('properties');
|
||||
})
|
||||
|
||||
148
app/Filament/Resources/TokenResource.php
Normal file
148
app/Filament/Resources/TokenResource.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\TokenResource\Pages;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\Token;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class TokenResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Token::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-key';
|
||||
|
||||
protected static ?string $navigationGroup = 'Auth';
|
||||
|
||||
protected static ?int $navigationSort = 6;
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->columns(1)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('id')
|
||||
->label('ID')
|
||||
->disabled()
|
||||
->visibleOn(['update', 'show'])
|
||||
->readOnly()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\Select::make('user_id')
|
||||
->label('User')
|
||||
->relationship(name: 'user', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
->disabled()
|
||||
->required(),
|
||||
Forms\Components\Select::make('client_id')
|
||||
->label('Client')
|
||||
->relationship(name: 'client', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
->required(),
|
||||
Forms\Components\Toggle::make('revoked')
|
||||
->label('Revoked')
|
||||
->required(),
|
||||
Forms\Components\DateTimePicker::make('expires_at')
|
||||
->label('Expires At')
|
||||
->disabled(),
|
||||
Forms\Components\DateTimePicker::make('created_at')
|
||||
->label('Created At')
|
||||
->disabled(),
|
||||
Forms\Components\DateTimePicker::make('updated_at')
|
||||
->label('Updated At')
|
||||
->disabled(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('client.name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\IconColumn::make('client.personal_access_client')
|
||||
->boolean()
|
||||
->label('API token?')
|
||||
->sortable(),
|
||||
Tables\Columns\IconColumn::make('revoked')
|
||||
->boolean()
|
||||
->label('Revoked?')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('expires_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
TernaryFilter::make('is_personal_access_client')
|
||||
->queries(
|
||||
true: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
return $query->whereHas('client', function (Builder $query) {
|
||||
/** @var Builder<Client> $query */
|
||||
return $query->where('personal_access_client', true);
|
||||
});
|
||||
},
|
||||
false: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
return $query->whereHas('client', function (Builder $query) {
|
||||
/** @var Builder<Client> $query */
|
||||
return $query->where('personal_access_client', false);
|
||||
});
|
||||
},
|
||||
blank: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
return $query;
|
||||
},
|
||||
)
|
||||
->label('API token?'),
|
||||
TernaryFilter::make('revoked')
|
||||
->label('Revoked?'),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListTokens::route('/'),
|
||||
'view' => Pages\ViewToken::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/TokenResource/Pages/ListTokens.php
Normal file
19
app/Filament/Resources/TokenResource/Pages/ListTokens.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\TokenResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TokenResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTokens extends ListRecords
|
||||
{
|
||||
protected static string $resource = TokenResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/TokenResource/Pages/ViewToken.php
Normal file
19
app/Filament/Resources/TokenResource/Pages/ViewToken.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\TokenResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TokenResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewToken extends ViewRecord
|
||||
{
|
||||
protected static string $resource = TokenResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ class UserResource extends Resource
|
||||
{
|
||||
/** @var User|null $record */
|
||||
$record = $form->getRecord();
|
||||
|
||||
return $form
|
||||
->columns(1)
|
||||
->schema([
|
||||
|
||||
@@ -24,7 +24,7 @@ class CreateUser extends CreateRecord
|
||||
$data['timezone'],
|
||||
Weekday::from($data['week_start']),
|
||||
$data['currency'],
|
||||
(bool) $data['is_email_verified']
|
||||
verifyEmail: (bool) $data['is_email_verified']
|
||||
);
|
||||
|
||||
return $user;
|
||||
|
||||
114
app/Http/Controllers/Api/V1/ApiTokenController.php
Normal file
114
app/Http/Controllers/Api/V1/ApiTokenController.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||
use App\Http\Requests\V1\ApiToken\ApiTokenStoreRequest;
|
||||
use App\Http\Resources\V1\ApiToken\ApiTokenCollection;
|
||||
use App\Http\Resources\V1\ApiToken\ApiTokenWithAccessTokenResource;
|
||||
use App\Models\Passport\Token;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ApiTokenController extends Controller
|
||||
{
|
||||
/**
|
||||
* List all api token of the currently authenticated user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
*
|
||||
* @operationId getApiTokens
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function index(): ApiTokenCollection
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
$tokens = $user->tokens()
|
||||
->where('client_id', '=', config('passport.personal_access_client.id'))
|
||||
->get();
|
||||
|
||||
return new ApiTokenCollection($tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new api token for the currently authenticated user
|
||||
*
|
||||
* The response will contain the access token that can be used to send authenticated API requests.
|
||||
* Please note that the access token is only shown in this response and cannot be retrieved later.
|
||||
*
|
||||
* @operationId createApiToken
|
||||
*
|
||||
* @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException
|
||||
*/
|
||||
public function store(ApiTokenStoreRequest $request): ApiTokenWithAccessTokenResource
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
|
||||
throw new PersonalAccessClientIsNotConfiguredException;
|
||||
}
|
||||
|
||||
$token = $user->createToken($request->getName(), ['*']);
|
||||
/** @var Token $tokenModel */
|
||||
$tokenModel = $token->token;
|
||||
|
||||
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an api token
|
||||
*
|
||||
* @operationId revokeApiToken
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws PersonalAccessClientIsNotConfiguredException
|
||||
*/
|
||||
public function revoke(Token $apiToken): JsonResponse
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
|
||||
throw new PersonalAccessClientIsNotConfiguredException;
|
||||
}
|
||||
if ($apiToken->user_id !== $user->getKey()) {
|
||||
throw new AuthorizationException('API token does not belong to user');
|
||||
}
|
||||
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
|
||||
throw new AuthorizationException('API token is not a personal access token');
|
||||
}
|
||||
|
||||
$apiToken->revoke();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an api token
|
||||
*
|
||||
* @operationId deleteApiToken
|
||||
*
|
||||
* @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException
|
||||
*/
|
||||
public function destroy(Token $apiToken): JsonResponse
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
|
||||
throw new PersonalAccessClientIsNotConfiguredException;
|
||||
}
|
||||
if ($apiToken->user_id !== $user->getKey()) {
|
||||
throw new AuthorizationException('API token does not belong to user');
|
||||
}
|
||||
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
|
||||
throw new AuthorizationException('API token is not a personal access token');
|
||||
}
|
||||
|
||||
$apiToken->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
172
app/Http/Controllers/Api/V1/ChartController.php
Normal file
172
app/Http/Controllers/Api/V1/ChartController.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Service\DashboardService;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ChartController extends Controller
|
||||
{
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId weeklyProjectOverview
|
||||
*
|
||||
* @response array<int, array{value: int, name: string, color: string}>
|
||||
*/
|
||||
public function weeklyProjectOverview(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'charts:view:own');
|
||||
$user = $this->user();
|
||||
|
||||
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
|
||||
|
||||
return response()->json($weeklyProjectOverview);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId latestTasks
|
||||
*
|
||||
* @response array<int, array{task_id: string, name: string, description: string|null, status: bool, time_entry_id: string|null}>
|
||||
*/
|
||||
public function latestTasks(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'charts:view:own');
|
||||
$user = $this->user();
|
||||
|
||||
$latestTasks = $dashboardService->latestTasks($user, $organization);
|
||||
|
||||
return response()->json($latestTasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId lastSevenDays
|
||||
*
|
||||
* @response array<int, array{ date: string, duration: int, history: array<int> }>
|
||||
*/
|
||||
public function lastSevenDays(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'charts:view:own');
|
||||
$user = $this->user();
|
||||
|
||||
$lastSevenDays = $dashboardService->lastSevenDays($user, $organization);
|
||||
|
||||
return response()->json($lastSevenDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId latestTeamActivity
|
||||
*
|
||||
* @response array<int, array{member_id: string, name: string, description: string|null, time_entry_id: string, task_id: string|null, status: bool }>
|
||||
*/
|
||||
public function latestTeamActivity(Organization $organization, DashboardService $dashboardService, PermissionStore $permissionStore): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'charts:view:all');
|
||||
|
||||
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
||||
|
||||
return response()->json($latestTeamActivity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId dailyTrackedHours
|
||||
*
|
||||
* @response array<int, array{date: string, duration: int}>
|
||||
*/
|
||||
public function dailyTrackedHours(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'charts:view:own');
|
||||
$user = $this->user();
|
||||
|
||||
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
||||
|
||||
return response()->json($dailyTrackedHours);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId totalWeeklyTime
|
||||
*
|
||||
* @response int
|
||||
*/
|
||||
public function totalWeeklyTime(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'charts:view:own');
|
||||
$user = $this->user();
|
||||
|
||||
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
|
||||
|
||||
return response()->json($totalWeeklyTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId totalWeeklyBillableTime
|
||||
*
|
||||
* @response int
|
||||
*/
|
||||
public function totalWeeklyBillableTime(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'charts:view:own');
|
||||
$user = $this->user();
|
||||
|
||||
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
|
||||
|
||||
return response()->json($totalWeeklyBillableTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId totalWeeklyBillableAmount
|
||||
*
|
||||
* @response array{value: int, currency: string}
|
||||
*/
|
||||
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'charts:view:own');
|
||||
$user = $this->user();
|
||||
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
if (! $showBillableRate) {
|
||||
throw new AuthorizationException('You do not have permission to view billable rates.');
|
||||
}
|
||||
|
||||
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
|
||||
|
||||
return response()->json($totalWeeklyBillableAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId weeklyHistory
|
||||
*
|
||||
* @response array<int, array{date: string, duration: int}>
|
||||
*/
|
||||
public function weeklyHistory(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'charts:view:own');
|
||||
$user = $this->user();
|
||||
|
||||
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
|
||||
|
||||
return response()->json($weeklyHistory);
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,17 @@ namespace App\Http\Controllers\Api\V1;
|
||||
use App\Enums\Role;
|
||||
use App\Events\MemberMadeToPlaceholder;
|
||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||
use App\Http\Requests\V1\Member\MemberIndexRequest;
|
||||
use App\Http\Requests\V1\Member\MemberMergeIntoRequest;
|
||||
use App\Http\Requests\V1\Member\MemberUpdateRequest;
|
||||
use App\Http\Resources\V1\Member\MemberCollection;
|
||||
use App\Http\Resources\V1\Member\MemberResource;
|
||||
@@ -24,6 +29,8 @@ use App\Service\MemberService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MemberController extends Controller
|
||||
{
|
||||
@@ -63,6 +70,7 @@ class MemberController extends Controller
|
||||
* @throws OrganizationNeedsAtLeastOneOwner
|
||||
* @throws OnlyOwnerCanChangeOwnership
|
||||
* @throws ChangingRoleToPlaceholderIsNotAllowed
|
||||
* @throws ChangingRoleOfPlaceholderIsNotAllowed
|
||||
*
|
||||
* @operationId updateMember
|
||||
*/
|
||||
@@ -105,7 +113,9 @@ class MemberController extends Controller
|
||||
/**
|
||||
* Make a member a placeholder member
|
||||
*
|
||||
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
|
||||
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization|ChangingRoleOfPlaceholderIsNotAllowed
|
||||
*
|
||||
* @operationId makePlaceholder
|
||||
*/
|
||||
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
|
||||
{
|
||||
@@ -114,6 +124,9 @@ class MemberController extends Controller
|
||||
if ($member->role === Role::Owner->value) {
|
||||
throw new CanNotRemoveOwnerFromOrganization;
|
||||
}
|
||||
if ($member->role === Role::Placeholder->value) {
|
||||
throw new ChangingRoleOfPlaceholderIsNotAllowed;
|
||||
}
|
||||
|
||||
$memberService->makeMemberToPlaceholder($member);
|
||||
|
||||
@@ -122,10 +135,41 @@ class MemberController extends Controller
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge one member into another
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
|
||||
* @throws \Throwable
|
||||
*
|
||||
* @operationId mergeMember
|
||||
*/
|
||||
public function mergeInto(Organization $organization, Member $member, MemberMergeIntoRequest $request, MemberService $memberService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'members:merge-into', $member);
|
||||
|
||||
$user = $member->user;
|
||||
if ($member->role !== Role::Placeholder->value || ! $user->is_placeholder) {
|
||||
throw new OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
||||
}
|
||||
$memberTo = Member::findOrFail($request->getMemberId());
|
||||
|
||||
DB::transaction(function () use ($organization, $member, $user, $memberTo, $memberService): void {
|
||||
$memberService->assignOrganizationEntitiesToDifferentMember($organization, $member, $memberTo);
|
||||
$member->delete();
|
||||
$user->delete();
|
||||
});
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a placeholder member to become a real member of the organization
|
||||
*
|
||||
* @throws AuthorizationException|UserNotPlaceholderApiException
|
||||
* @throws AuthorizationException
|
||||
* @throws UserNotPlaceholderApiException
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException
|
||||
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
|
||||
*
|
||||
* @operationId invitePlaceholder
|
||||
*/
|
||||
@@ -138,6 +182,10 @@ class MemberController extends Controller
|
||||
throw new UserNotPlaceholderApiException;
|
||||
}
|
||||
|
||||
if (Str::endsWith($user->email, '@solidtime-import.test')) {
|
||||
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||
}
|
||||
|
||||
$invitationService->inviteUser($organization, $user->email, Role::Employee);
|
||||
|
||||
return response()->json(null, 204);
|
||||
|
||||
@@ -40,15 +40,35 @@ class OrganizationController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'organizations:update');
|
||||
|
||||
$organization->name = $request->input('name');
|
||||
$oldBillableRate = $organization->billable_rate;
|
||||
if ($request->has('employees_can_see_billable_rates')) {
|
||||
$organization->employees_can_see_billable_rates = $request->validated('employees_can_see_billable_rates');
|
||||
if ($request->getName() !== null) {
|
||||
$organization->name = $request->getName();
|
||||
}
|
||||
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
||||
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
||||
}
|
||||
if ($request->getNumberFormat() !== null) {
|
||||
$organization->number_format = $request->getNumberFormat();
|
||||
}
|
||||
if ($request->getCurrencyFormat() !== null) {
|
||||
$organization->currency_format = $request->getCurrencyFormat();
|
||||
}
|
||||
if ($request->getDateFormat() !== null) {
|
||||
$organization->date_format = $request->getDateFormat();
|
||||
}
|
||||
if ($request->getIntervalFormat() !== null) {
|
||||
$organization->interval_format = $request->getIntervalFormat();
|
||||
}
|
||||
if ($request->getTimeFormat() !== null) {
|
||||
$organization->time_format = $request->getTimeFormat();
|
||||
}
|
||||
$hasBillableRate = $request->has('billable_rate');
|
||||
if ($hasBillableRate) {
|
||||
$oldBillableRate = $organization->billable_rate;
|
||||
$organization->billable_rate = $request->getBillableRate();
|
||||
}
|
||||
$organization->billable_rate = $request->getBillableRate();
|
||||
$organization->save();
|
||||
|
||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
||||
if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ class ReportController extends Controller
|
||||
false,
|
||||
$report->properties->start,
|
||||
$report->properties->end,
|
||||
true
|
||||
);
|
||||
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||
$timeEntriesQuery->clone(),
|
||||
@@ -83,6 +84,7 @@ class ReportController extends Controller
|
||||
true,
|
||||
$report->properties->start,
|
||||
$report->properties->end,
|
||||
true
|
||||
);
|
||||
|
||||
return new DetailedWithDataReportResource($report, $data, $historyData);
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||
@@ -26,6 +27,7 @@ use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\LocalizationService;
|
||||
use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
|
||||
use App\Service\ReportExport\TimeEntriesDetailedExport;
|
||||
use App\Service\ReportExport\TimeEntriesReportExport;
|
||||
@@ -180,6 +182,7 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
$user = $this->user();
|
||||
$timezone = $user->timezone;
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
||||
$timeEntriesQuery->with([
|
||||
@@ -192,6 +195,7 @@ class TimeEntryController extends Controller
|
||||
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
|
||||
$folderPath = 'exports';
|
||||
$path = $folderPath.'/'.$filename;
|
||||
$localizationService = LocalizationService::forOrganization($organization);
|
||||
if ($format === ExportFormat::CSV) {
|
||||
$export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000, $timezone);
|
||||
$export->export();
|
||||
@@ -211,7 +215,8 @@ class TimeEntryController extends Controller
|
||||
$user->week_start,
|
||||
false,
|
||||
null,
|
||||
null
|
||||
null,
|
||||
$showBillableRate
|
||||
);
|
||||
$html = Blade::render($viewFile, [
|
||||
'timeEntries' => $timeEntriesQuery->get(),
|
||||
@@ -220,6 +225,7 @@ class TimeEntryController extends Controller
|
||||
'currency' => $organization->currency,
|
||||
'start' => $request->getStart()->timezone($timezone),
|
||||
'end' => $request->getEnd()->timezone($timezone),
|
||||
'localization' => $localizationService,
|
||||
]);
|
||||
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));
|
||||
if ($footerViewFile === false) {
|
||||
@@ -254,7 +260,7 @@ class TimeEntryController extends Controller
|
||||
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
|
||||
} else {
|
||||
Excel::store(
|
||||
new TimeEntriesDetailedExport($timeEntriesQuery, $format, $timezone),
|
||||
new TimeEntriesDetailedExport($timeEntriesQuery, $format, $timezone, $localizationService),
|
||||
$path,
|
||||
config('filesystems.private'),
|
||||
$format->getExportPackageType(),
|
||||
@@ -285,18 +291,18 @@ class TimeEntryController extends Controller
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* seconds: int,
|
||||
* cost: int
|
||||
* cost: int|null
|
||||
* }
|
||||
* }
|
||||
*
|
||||
@@ -312,6 +318,7 @@ class TimeEntryController extends Controller
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
}
|
||||
$user = $this->user();
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
$group1Type = $request->getGroup();
|
||||
$group2Type = $request->getSubGroup();
|
||||
@@ -325,7 +332,8 @@ class TimeEntryController extends Controller
|
||||
$user->week_start,
|
||||
$request->getFillGapsInTimeGroups(),
|
||||
$request->getStart(),
|
||||
$request->getEnd()
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -359,6 +367,7 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
$debug = $request->getDebug();
|
||||
$user = $this->user();
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
$group = $request->getGroup();
|
||||
$subGroup = $request->getSubGroup();
|
||||
@@ -372,7 +381,8 @@ class TimeEntryController extends Controller
|
||||
$user->week_start,
|
||||
false,
|
||||
$request->getStart(),
|
||||
$request->getEnd()
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
);
|
||||
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesAggregateQuery->clone(),
|
||||
@@ -382,10 +392,12 @@ class TimeEntryController extends Controller
|
||||
$user->week_start,
|
||||
true,
|
||||
$request->getStart(),
|
||||
$request->getEnd()
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
);
|
||||
$currency = $organization->currency;
|
||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
||||
$localizationService = LocalizationService::forOrganization($organization);
|
||||
|
||||
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
|
||||
$folderPath = 'exports';
|
||||
@@ -411,9 +423,11 @@ class TimeEntryController extends Controller
|
||||
'currency' => $currency,
|
||||
'group' => $group,
|
||||
'subGroup' => $subGroup,
|
||||
'timezone' => $timezone,
|
||||
'start' => $request->getStart()->timezone($timezone),
|
||||
'end' => $request->getEnd()->timezone($timezone),
|
||||
'debug' => $debug,
|
||||
'localization' => $localizationService,
|
||||
]);
|
||||
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));
|
||||
if ($footerViewFile === false) {
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Resources\V1\User\UserResource;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
@@ -19,7 +18,7 @@ class UserController extends Controller
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function me(): JsonResource
|
||||
public function me(): UserResource
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Service\DashboardService;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
@@ -19,30 +20,14 @@ class DashboardController extends Controller
|
||||
{
|
||||
$user = $this->user();
|
||||
$organization = $this->currentOrganization();
|
||||
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
||||
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
|
||||
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
|
||||
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
|
||||
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
|
||||
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
|
||||
$latestTasks = $dashboardService->latestTasks($user, $organization);
|
||||
$lastSevenDays = $dashboardService->lastSevenDays($user, $organization);
|
||||
|
||||
$latestTeamActivity = null;
|
||||
if ($permissionStore->has($organization, 'time-entries:view:all')) {
|
||||
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
||||
}
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'weeklyProjectOverview' => $weeklyProjectOverview,
|
||||
'latestTasks' => $latestTasks,
|
||||
'lastSevenDays' => $lastSevenDays,
|
||||
'latestTeamActivity' => $latestTeamActivity,
|
||||
'dailyTrackedHours' => $dailyTrackedHours,
|
||||
'totalWeeklyTime' => $totalWeeklyTime,
|
||||
'totalWeeklyBillableTime' => $totalWeeklyBillableTime,
|
||||
'totalWeeklyBillableAmount' => $totalWeeklyBillableAmount,
|
||||
'weeklyHistory' => $weeklyHistory,
|
||||
]);
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
return Inertia::render('Dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ class HealthCheckController extends Controller
|
||||
$response['app_env'] = app()->environment();
|
||||
$response['app_timezone'] = config('app.timezone');
|
||||
$response['app_force_https'] = config('app.force_https');
|
||||
$response['session_secure'] = config('session.secure');
|
||||
$response['trusted_proxies'] = config('trustedproxy.proxies');
|
||||
$headers = $request->headers->all();
|
||||
if (isset($headers['cookie'])) {
|
||||
|
||||
@@ -18,7 +18,7 @@ class Kernel extends HttpKernel
|
||||
* @var array<int, class-string|string>
|
||||
*/
|
||||
protected $middleware = [
|
||||
// \App\Http\Middleware\TrustHosts::class,
|
||||
\App\Http\Middleware\ForceHttps::class,
|
||||
\App\Http\Middleware\TrustProxies::class,
|
||||
\Illuminate\Http\Middleware\HandleCors::class,
|
||||
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
|
||||
|
||||
29
app/Http/Middleware/ForceHttps.php
Normal file
29
app/Http/Middleware/ForceHttps.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ForceHttps
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string ...$guards): Response
|
||||
{
|
||||
if (config('app.force_https', false)) {
|
||||
URL::forceScheme('https');
|
||||
$request->server->set('HTTPS', 'on');
|
||||
$request->headers->set('X-Forwarded-Proto', 'https');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ class HandleInertiaRequests extends Middleware
|
||||
public function share(Request $request): array
|
||||
{
|
||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
||||
|
||||
/** @var BillingContract $billing */
|
||||
$billing = app(BillingContract::class);
|
||||
@@ -48,6 +49,7 @@ class HandleInertiaRequests extends Middleware
|
||||
|
||||
return array_merge(parent::share($request), [
|
||||
'has_billing_extension' => $hasBilling,
|
||||
'has_invoicing_extension' => $hasInvoicing,
|
||||
'billing' => $billing !== null && $currentOrganization !== null ? [
|
||||
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Http\Middleware\TrustHosts as Middleware;
|
||||
|
||||
class TrustHosts extends Middleware
|
||||
{
|
||||
/**
|
||||
* Get the host patterns that should be trusted.
|
||||
*
|
||||
* @return array<int, string|null>
|
||||
*/
|
||||
public function hosts(): array
|
||||
{
|
||||
return [
|
||||
$this->allSubdomainsOfApplicationUrl(),
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/V1/ApiToken/ApiTokenStoreRequest.php
Normal file
32
app/Http/Requests/V1/ApiToken/ApiTokenStoreRequest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\ApiToken;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ApiTokenStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
'max:255',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->input('name');
|
||||
}
|
||||
}
|
||||
42
app/Http/Requests/V1/Member/MemberMergeIntoRequest.php
Normal file
42
app/Http/Requests/V1/Member/MemberMergeIntoRequest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Member;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization
|
||||
*/
|
||||
class MemberMergeIntoRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// ID of the member to which the data should be transferred (destination)
|
||||
'member_id' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Member> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getMemberId(): string
|
||||
{
|
||||
return (string) $this->input('member_id');
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Organization;
|
||||
|
||||
use App\Enums\CurrencyFormat;
|
||||
use App\Enums\DateFormat;
|
||||
use App\Enums\IntervalFormat;
|
||||
use App\Enums\NumberFormat;
|
||||
use App\Enums\TimeFormat;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
@@ -16,13 +21,12 @@ class OrganizationUpdateRequest extends FormRequest
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
@@ -35,13 +39,63 @@ class OrganizationUpdateRequest extends FormRequest
|
||||
'employees_can_see_billable_rates' => [
|
||||
'boolean',
|
||||
],
|
||||
'number_format' => [
|
||||
Rule::enum(NumberFormat::class),
|
||||
],
|
||||
'currency_format' => [
|
||||
Rule::enum(CurrencyFormat::class),
|
||||
],
|
||||
'date_format' => [
|
||||
Rule::enum(DateFormat::class),
|
||||
],
|
||||
'interval_format' => [
|
||||
Rule::enum(IntervalFormat::class),
|
||||
],
|
||||
'time_format' => [
|
||||
Rule::enum(TimeFormat::class),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->has('name') ? (string) $this->input('name') : null;
|
||||
}
|
||||
|
||||
public function getNumberFormat(): ?NumberFormat
|
||||
{
|
||||
return $this->has('number_format') ? NumberFormat::from($this->input('number_format')) : null;
|
||||
}
|
||||
|
||||
public function getCurrencyFormat(): ?CurrencyFormat
|
||||
{
|
||||
return $this->has('currency_format') ? CurrencyFormat::from($this->input('currency_format')) : null;
|
||||
}
|
||||
|
||||
public function getDateFormat(): ?DateFormat
|
||||
{
|
||||
return $this->has('date_format') ? DateFormat::from($this->input('date_format')) : null;
|
||||
}
|
||||
|
||||
public function getIntervalFormat(): ?IntervalFormat
|
||||
{
|
||||
return $this->has('interval_format') ? IntervalFormat::from($this->input('interval_format')) : null;
|
||||
}
|
||||
|
||||
public function getTimeFormat(): ?TimeFormat
|
||||
{
|
||||
return $this->has('time_format') ? TimeFormat::from($this->input('time_format')) : null;
|
||||
}
|
||||
|
||||
public function getBillableRate(): ?int
|
||||
{
|
||||
$input = $this->input('billable_rate');
|
||||
|
||||
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
|
||||
}
|
||||
|
||||
public function getEmployeesCanSeeBillableRates(): ?bool
|
||||
{
|
||||
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Rules\ColorRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Str;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
@@ -27,6 +28,7 @@ class ProjectStoreRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// Name of the project, the name needs to be unique per client and organization
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
@@ -34,7 +36,13 @@ class ProjectStoreRequest extends FormRequest
|
||||
'max:255',
|
||||
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
$clientId = $this->input('client_id');
|
||||
if (! is_string($clientId) || ! Str::isUuid($clientId)) {
|
||||
$clientId = null;
|
||||
}
|
||||
|
||||
return $builder->whereBelongsTo($this->organization, 'organization')
|
||||
->where('client_id', $clientId);
|
||||
})->withCustomTranslation('validation.project_name_already_exists'),
|
||||
],
|
||||
'color' => [
|
||||
@@ -55,6 +63,7 @@ class ProjectStoreRequest extends FormRequest
|
||||
],
|
||||
// ID of the client
|
||||
'client_id' => [
|
||||
'present',
|
||||
'nullable',
|
||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Rules\ColorRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Str;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
@@ -34,7 +35,13 @@ class ProjectUpdateRequest extends FormRequest
|
||||
'max:255',
|
||||
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
$clientId = $this->input('client_id');
|
||||
if (! is_string($clientId) || ! Str::isUuid($clientId)) {
|
||||
$clientId = null;
|
||||
}
|
||||
|
||||
return $builder->whereBelongsTo($this->organization, 'organization')
|
||||
->where('client_id', $clientId);
|
||||
})->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'),
|
||||
],
|
||||
'color' => [
|
||||
@@ -54,6 +61,7 @@ class ProjectUpdateRequest extends FormRequest
|
||||
'boolean',
|
||||
],
|
||||
'client_id' => [
|
||||
'present',
|
||||
'nullable',
|
||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
|
||||
@@ -40,7 +40,7 @@ class ReportStoreRequest extends FormRequest
|
||||
'required',
|
||||
'boolean',
|
||||
],
|
||||
// After this date the report will be automatically set to private (is_public=false) (ISO 8601 format, UTC timezone)
|
||||
// After this date the report will be automatically set to private (is_public=false) (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
|
||||
'public_until' => [
|
||||
'nullable',
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
|
||||
@@ -59,12 +59,12 @@ class TimeEntryStoreRequest extends FormRequest
|
||||
->where('project_id', $this->input('project_id'));
|
||||
})->uuid()->withMessage(__('validation.task_belongs_to_project')),
|
||||
],
|
||||
// Start of time entry (ISO 8601 format, UTC timezone)
|
||||
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
|
||||
'start' => [
|
||||
'required',
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
],
|
||||
// End of time entry (ISO 8601 format, UTC timezone)
|
||||
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
|
||||
'end' => [
|
||||
'nullable',
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
|
||||
@@ -59,11 +59,11 @@ class TimeEntryUpdateRequest extends FormRequest
|
||||
->where('project_id', $this->input('project_id'));
|
||||
})->uuid()->withMessage(__('validation.task_belongs_to_project')),
|
||||
],
|
||||
// Start of time entry (ISO 8601 format, UTC timezone)
|
||||
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
|
||||
'start' => [
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
],
|
||||
// End of time entry (ISO 8601 format, UTC timezone)
|
||||
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
|
||||
'end' => [
|
||||
'nullable',
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
|
||||
17
app/Http/Resources/V1/ApiToken/ApiTokenCollection.php
Normal file
17
app/Http/Resources/V1/ApiToken/ApiTokenCollection.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\ApiToken;
|
||||
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ApiTokenCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $collects = ApiTokenResource::class;
|
||||
}
|
||||
38
app/Http/Resources/V1/ApiToken/ApiTokenResource.php
Normal file
38
app/Http/Resources/V1/ApiToken/ApiTokenResource.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\ApiToken;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Passport\Token;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property-read Token $resource
|
||||
*/
|
||||
class ApiTokenResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null|array<string>>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of the API token, this ID is NOT a UUID */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name of the API token */
|
||||
'name' => $this->resource->name,
|
||||
/** @var bool $revoked Whether the API token is revoked */
|
||||
'revoked' => $this->resource->revoked,
|
||||
/** @var array<string> $scopes List of scopes that the API token has */
|
||||
'scopes' => $this->resource->scopes,
|
||||
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
'expires_at' => $this->formatDateTime($this->resource->expires_at),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\ApiToken;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Passport\Token;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property-read Token $resource
|
||||
*/
|
||||
class ApiTokenWithAccessTokenResource extends BaseResource
|
||||
{
|
||||
private string $accessToken;
|
||||
|
||||
public function __construct(Token $resource, string $accessToken)
|
||||
{
|
||||
$this->accessToken = $accessToken;
|
||||
parent::__construct($resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null|array<string>>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of the API token, this ID is NOT a UUID */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name of the API token */
|
||||
'name' => $this->resource->name,
|
||||
/** @var bool $revoked Whether the API token is revoked */
|
||||
'revoked' => $this->resource->revoked,
|
||||
/** @var array<string> $scopes List of scopes that the API token has */
|
||||
'scopes' => $this->resource->scopes,
|
||||
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
'expires_at' => $this->formatDateTime($this->resource->expires_at),
|
||||
// Additional fields
|
||||
/** @var string $access_token Access token that can be used to authenticate requests */
|
||||
'access_token' => $this->accessToken,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ abstract class BaseResource extends JsonResource
|
||||
protected function formatDateTime(?Carbon $carbon): ?string
|
||||
{
|
||||
return $carbon?->toIso8601ZuluString();
|
||||
}
|
||||
|
||||
protected function formatDate(?Carbon $carbon): ?string
|
||||
{
|
||||
return $carbon?->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,16 @@ class OrganizationResource extends BaseResource
|
||||
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
||||
/** @var string $currency Currency code (ISO 4217) */
|
||||
'currency' => $this->resource->currency,
|
||||
/** @var string $number_format Number format */
|
||||
'number_format' => $this->resource->number_format->value,
|
||||
/** @var string $currency_format Currency format */
|
||||
'currency_format' => $this->resource->currency_format->value,
|
||||
/** @var string $date_format Date format */
|
||||
'date_format' => $this->resource->date_format->value,
|
||||
/** @var string $interval_format Interval format */
|
||||
'interval_format' => $this->resource->interval_format->value,
|
||||
/** @var string $time_format Time format */
|
||||
'time_format' => $this->resource->time_format->value,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class DetailedReportResource extends BaseResource
|
||||
/** @var bool $is_public Whether the report can be accessed via an external link */
|
||||
'is_public' => $this->resource->is_public,
|
||||
/** @var string|null $public_until Date until the report is public */
|
||||
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
|
||||
'public_until' => $this->formatDateTime($this->resource->public_until),
|
||||
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
|
||||
'shareable_link' => $this->resource->getShareableLink(),
|
||||
'properties' => [
|
||||
@@ -41,9 +41,9 @@ class DetailedReportResource extends BaseResource
|
||||
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
|
||||
'history_group' => $this->resource->properties->historyGroup->value,
|
||||
/** @var string $start Start date of the report */
|
||||
'start' => $this->resource->properties->start->toIso8601ZuluString(),
|
||||
'start' => $this->formatDateTime($this->resource->properties->start),
|
||||
/** @var string $end End date of the report */
|
||||
'end' => $this->resource->properties->end->toIso8601ZuluString(),
|
||||
'end' => $this->formatDateTime($this->resource->properties->end),
|
||||
/** @var bool|null $active Whether the report is active */
|
||||
'active' => $this->resource->properties->active,
|
||||
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
|
||||
@@ -60,9 +60,9 @@ class DetailedReportResource extends BaseResource
|
||||
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
||||
],
|
||||
/** @var string $created_at Date when the report was created */
|
||||
'created_at' => $this->resource->created_at?->toIso8601ZuluString(),
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string $updated_at Date when the report was last updated */
|
||||
'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(),
|
||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,20 +18,20 @@ use Illuminate\Http\Request;
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* seconds: int,
|
||||
* cost: int
|
||||
* cost: int|null
|
||||
* }
|
||||
*/
|
||||
class DetailedWithDataReportResource extends BaseResource
|
||||
@@ -70,7 +70,7 @@ class DetailedWithDataReportResource extends BaseResource
|
||||
/** @var string|null $email Description */
|
||||
'description' => $this->resource->description,
|
||||
/** @var string|null $public_until Date until the report is public */
|
||||
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
|
||||
'public_until' => $this->formatDateTime($this->resource->public_until),
|
||||
/** @var string $currency Currency code (ISO 4217) */
|
||||
'currency' => $this->resource->organization->currency,
|
||||
'properties' => [
|
||||
@@ -81,9 +81,9 @@ class DetailedWithDataReportResource extends BaseResource
|
||||
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
|
||||
'history_group' => $this->resource->properties->historyGroup->value,
|
||||
/** @var string $start Start date of the report */
|
||||
'start' => $this->resource->properties->start->toIso8601ZuluString(),
|
||||
'start' => $this->formatDateTime($this->resource->properties->start),
|
||||
/** @var string $end End date of the report */
|
||||
'end' => $this->resource->properties->end->toIso8601ZuluString(),
|
||||
'end' => $this->formatDateTime($this->resource->properties->end),
|
||||
],
|
||||
/** @var array{
|
||||
* grouped_type: string|null,
|
||||
|
||||
@@ -30,13 +30,13 @@ class ReportResource extends BaseResource
|
||||
/** @var bool $is_public Whether the report can be accessed via an external link */
|
||||
'is_public' => $this->resource->is_public,
|
||||
/** @var string|null $public_until Date until the report is public */
|
||||
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
|
||||
'public_until' => $this->formatDateTime($this->resource->public_until),
|
||||
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
|
||||
'shareable_link' => $this->resource->getShareableLink(),
|
||||
/** @var string $created_at Date when the report was created */
|
||||
'created_at' => $this->resource->created_at?->toIso8601ZuluString(),
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string $updated_at Date when the report was last updated */
|
||||
'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(),
|
||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace App\Listeners;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\User;
|
||||
use App\Service\UserService;
|
||||
use App\Service\MemberService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
|
||||
@@ -17,8 +17,11 @@ class RemovePlaceholder
|
||||
*/
|
||||
public function handle(TeamMemberAdded $event): void
|
||||
{
|
||||
/** @var UserService $userService */
|
||||
$userService = app(UserService::class);
|
||||
$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 */
|
||||
@@ -32,7 +35,7 @@ class RemovePlaceholder
|
||||
foreach ($placeholders as $placeholder) {
|
||||
/** @var Member $placeholder */
|
||||
$placeholderUser = $placeholder->user;
|
||||
$userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholderUser, $event->user);
|
||||
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);
|
||||
$placeholder->delete();
|
||||
$placeholderUser->delete();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\MemberFactory;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
@@ -24,6 +25,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Organization $organization
|
||||
* @property-read User $user
|
||||
* @property-read Collection<ProjectMember> $projectMembers
|
||||
* @property-read Collection<TimeEntry> $timeEntries
|
||||
*
|
||||
* @method static MemberFactory factory()
|
||||
*/
|
||||
@@ -59,6 +62,14 @@ class Member extends JetstreamMembership implements AuditableContract
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TimeEntry>
|
||||
*/
|
||||
public function timeEntries(): HasMany
|
||||
{
|
||||
return $this->hasMany(TimeEntry::class, 'member_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProjectMember>
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\CurrencyFormat;
|
||||
use App\Enums\DateFormat;
|
||||
use App\Enums\IntervalFormat;
|
||||
use App\Enums\NumberFormat;
|
||||
use App\Enums\TimeFormat;
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\OrganizationFactory;
|
||||
@@ -18,7 +23,6 @@ use Illuminate\Support\Str;
|
||||
use Laravel\Jetstream\Events\TeamCreated;
|
||||
use Laravel\Jetstream\Events\TeamDeleted;
|
||||
use Laravel\Jetstream\Events\TeamUpdated;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Laravel\Jetstream\Team as JetstreamTeam;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
@@ -37,6 +41,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property Collection<int, User> $realUsers
|
||||
* @property-read Collection<int, OrganizationInvitation> $teamInvitations
|
||||
* @property Member $membership
|
||||
* @property NumberFormat $number_format
|
||||
* @property CurrencyFormat $currency_format
|
||||
* @property DateFormat $date_format
|
||||
* @property IntervalFormat $interval_format
|
||||
* @property TimeFormat $time_format
|
||||
*
|
||||
* @method HasMany<OrganizationInvitation> teamInvitations()
|
||||
* @method static OrganizationFactory factory()
|
||||
@@ -60,6 +69,11 @@ class Organization extends JetstreamTeam implements AuditableContract
|
||||
'personal_team' => 'boolean',
|
||||
'currency' => 'string',
|
||||
'employees_can_see_billable_rates' => 'boolean',
|
||||
'number_format' => NumberFormat::class,
|
||||
'currency_format' => CurrencyFormat::class,
|
||||
'date_format' => DateFormat::class,
|
||||
'interval_format' => IntervalFormat::class,
|
||||
'time_format' => TimeFormat::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -89,7 +103,6 @@ class Organization extends JetstreamTeam implements AuditableContract
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected $attributes = [
|
||||
'currency' => 'EUR',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
9
app/Models/Passport/AuthCode.php
Normal file
9
app/Models/Passport/AuthCode.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use Laravel\Passport\AuthCode as PassportAuthCode;
|
||||
|
||||
class AuthCode extends PassportAuthCode {}
|
||||
26
app/Models/Passport/Client.php
Normal file
26
app/Models/Passport/Client.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use Database\Factories\Passport\ClientFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Laravel\Passport\Client as PassportClient;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string|null $user_id
|
||||
* @property string $name
|
||||
* @property string|null $secret
|
||||
* @property string|null $provider
|
||||
* @property string $redirect
|
||||
* @property bool $personal_access_client
|
||||
* @property bool $password_client
|
||||
* @property bool $revoked
|
||||
*/
|
||||
class Client extends PassportClient
|
||||
{
|
||||
/** @use HasFactory<ClientFactory> */
|
||||
use HasFactory;
|
||||
}
|
||||
9
app/Models/Passport/PersonalAccessClient.php
Normal file
9
app/Models/Passport/PersonalAccessClient.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use Laravel\Passport\PersonalAccessClient as PassportPersonalAccessClient;
|
||||
|
||||
class PersonalAccessClient extends PassportPersonalAccessClient {}
|
||||
9
app/Models/Passport/RefreshToken.php
Normal file
9
app/Models/Passport/RefreshToken.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use Laravel\Passport\RefreshToken as PassportRefreshToken;
|
||||
|
||||
class RefreshToken extends PassportRefreshToken {}
|
||||
38
app/Models/Passport/Token.php
Normal file
38
app/Models/Passport/Token.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Passport;
|
||||
|
||||
use Database\Factories\Passport\TokenFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Passport\Token as PassportToken;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property null|string $user_id
|
||||
* @property string $client_id
|
||||
* @property null|string $name
|
||||
* @property array<string> $scopes
|
||||
* @property bool $revoked
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property Carbon|null $expires_at
|
||||
*/
|
||||
class Token extends PassportToken
|
||||
{
|
||||
/** @use HasFactory<TokenFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Get the client that the token belongs to.
|
||||
*
|
||||
* @return BelongsTo<Client, Token>
|
||||
*/
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Client::class, 'client_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use App\Models\Passport\Token;
|
||||
use Database\Factories\UserFactory;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Panel;
|
||||
@@ -27,7 +28,6 @@ use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Laravel\Jetstream\HasTeams;
|
||||
use Laravel\Passport\AuthCode;
|
||||
use Laravel\Passport\HasApiTokens;
|
||||
use Laravel\Passport\Token;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
@@ -44,6 +44,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property-read Organization|null $currentOrganization
|
||||
* @property-read Organization|null $currentTeam
|
||||
* @property-read string $profile_photo_url
|
||||
* @property-read Collection<int, Token> $tokens
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property string|null $current_team_id
|
||||
@@ -196,6 +197,17 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
return $this->hasMany(AuthCode::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access tokens for the user.
|
||||
*
|
||||
* @return HasMany<Token>
|
||||
*/
|
||||
public function tokens(): HasMany
|
||||
{
|
||||
return $this->hasMany(Token::class, 'user_id')
|
||||
->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<User> $builder
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\FailedJob;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\Tag;
|
||||
@@ -29,7 +30,6 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -90,12 +90,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
);
|
||||
});
|
||||
|
||||
if (config('app.force_https', false)) {
|
||||
URL::forceScheme('https');
|
||||
request()->server->set('HTTPS', 'on');
|
||||
request()->headers->set('X-Forwarded-Proto', 'https');
|
||||
}
|
||||
|
||||
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
|
||||
return new PermissionStore;
|
||||
});
|
||||
@@ -107,5 +101,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
// Routing
|
||||
Route::model('member', Member::class);
|
||||
Route::model('invitation', OrganizationInvitation::class);
|
||||
Route::model('apiToken', Token::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ declare(strict_types=1);
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Passport\AuthCode;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\PersonalAccessClient;
|
||||
use App\Models\Passport\RefreshToken;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Policies\OrganizationPolicy;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
@@ -42,6 +47,16 @@ class AuthServiceProvider extends ServiceProvider
|
||||
// 'delete',
|
||||
]);
|
||||
|
||||
Passport::useTokenModel(Token::class);
|
||||
Passport::useRefreshTokenModel(RefreshToken::class);
|
||||
Passport::useAuthCodeModel(AuthCode::class);
|
||||
Passport::useClientModel(Client::class);
|
||||
Passport::usePersonalAccessClientModel(PersonalAccessClient::class);
|
||||
|
||||
// Passport::tokensExpireIn(now()->addDays(15));
|
||||
// Passport::refreshTokensExpireIn(now()->addDays(30));
|
||||
Passport::personalAccessTokensExpireIn(now()->addMonths(12));
|
||||
|
||||
// same as passport default above
|
||||
Jetstream::defaultApiTokenPermissions(['read']);
|
||||
|
||||
|
||||
@@ -69,6 +69,9 @@ class AdminPanelProvider extends PanelProvider
|
||||
NavigationGroup::make()
|
||||
->label('System')
|
||||
->collapsed(),
|
||||
NavigationGroup::make()
|
||||
->label('Auth')
|
||||
->collapsed(),
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
|
||||
@@ -80,6 +80,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
Jetstream::defaultApiTokenPermissions([]);
|
||||
|
||||
Jetstream::role(Role::Owner->value, 'Owner', [
|
||||
'charts:view:own',
|
||||
'charts:view:all',
|
||||
'projects:view',
|
||||
'projects:view:all',
|
||||
'projects:create',
|
||||
@@ -123,6 +125,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'members:invite-placeholder',
|
||||
'members:change-ownership',
|
||||
'members:make-placeholder',
|
||||
'members:merge-into',
|
||||
'members:update',
|
||||
'members:delete',
|
||||
'billing',
|
||||
@@ -130,9 +133,18 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
'invoices:view',
|
||||
'invoices:create',
|
||||
'invoices:update',
|
||||
'invoices:download',
|
||||
'invoices:delete',
|
||||
'invoice-settings:view',
|
||||
'invoice-settings:update',
|
||||
])->description('Owner users can perform any action. There is only one owner per organization.');
|
||||
|
||||
Jetstream::role(Role::Admin->value, 'Administrator', [
|
||||
'charts:view:own',
|
||||
'charts:view:all',
|
||||
'projects:view',
|
||||
'projects:view:all',
|
||||
'projects:create',
|
||||
@@ -172,15 +184,26 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'invitations:resend',
|
||||
'invitations:remove',
|
||||
'members:view',
|
||||
'members:update',
|
||||
'members:invite-placeholder',
|
||||
'members:make-placeholder',
|
||||
'members:merge-into',
|
||||
'members:update',
|
||||
'reports:view',
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
'invoices:view',
|
||||
'invoices:create',
|
||||
'invoices:update',
|
||||
'invoices:download',
|
||||
'invoices:delete',
|
||||
'invoice-settings:view',
|
||||
'invoice-settings:update',
|
||||
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
|
||||
|
||||
Jetstream::role(Role::Manager->value, 'Manager', [
|
||||
'charts:view:own',
|
||||
'charts:view:all',
|
||||
'projects:view',
|
||||
'projects:view:all',
|
||||
'projects:create',
|
||||
@@ -218,9 +241,17 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
'invoices:view',
|
||||
'invoices:create',
|
||||
'invoices:update',
|
||||
'invoices:download',
|
||||
'invoices:delete',
|
||||
'invoice-settings:view',
|
||||
'invoice-settings:update',
|
||||
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
|
||||
|
||||
Jetstream::role(Role::Employee->value, 'Employee', [
|
||||
'charts:view:own',
|
||||
'projects:view',
|
||||
'tags:view',
|
||||
'tasks:view',
|
||||
|
||||
@@ -33,6 +33,11 @@ class ColorService
|
||||
|
||||
private const string VALID_REGEX = '/^#[0-9a-f]{6}$/';
|
||||
|
||||
public function isBuiltInColor(string $color): bool
|
||||
{
|
||||
return in_array($color, self::COLORS, true);
|
||||
}
|
||||
|
||||
public function getRandomColor(?string $seed = null): string
|
||||
{
|
||||
if ($seed !== null) {
|
||||
|
||||
377
app/Service/CurrencyService.php
Normal file
377
app/Service/CurrencyService.php
Normal file
@@ -0,0 +1,377 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Brick\Money\Money;
|
||||
|
||||
class CurrencyService
|
||||
{
|
||||
/**
|
||||
* @source https://gist.github.com/stephenfrank/a8245c2486f3e546107c5363706ac93e
|
||||
*
|
||||
* @const array<string, array<{ symbol: string }>>
|
||||
*/
|
||||
private const array CURRENCIES = [
|
||||
'ALL' => [
|
||||
'symbol' => 'L',
|
||||
],
|
||||
'AFN' => [
|
||||
'symbol' => '؋',
|
||||
],
|
||||
'ARS' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'AWG' => [
|
||||
'symbol' => 'ƒ',
|
||||
],
|
||||
'AUD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'AZN' => [
|
||||
'symbol' => '₼',
|
||||
],
|
||||
'BSD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'BBD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'BDT' => [
|
||||
'symbol' => '৳',
|
||||
],
|
||||
'BYR' => [
|
||||
'symbol' => 'Br',
|
||||
],
|
||||
'BZD' => [
|
||||
'symbol' => 'BZ$',
|
||||
],
|
||||
'BMD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'BOB' => [
|
||||
'symbol' => '$b',
|
||||
],
|
||||
'BAM' => [
|
||||
'symbol' => 'KM',
|
||||
],
|
||||
'BWP' => [
|
||||
'symbol' => 'P',
|
||||
],
|
||||
'BGN' => [
|
||||
'symbol' => 'лв',
|
||||
],
|
||||
'BRL' => [
|
||||
'symbol' => 'R$',
|
||||
],
|
||||
'BND' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'KHR' => [
|
||||
'symbol' => '៛',
|
||||
],
|
||||
'CAD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'KYD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'CLP' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'CNY' => [
|
||||
'symbol' => '¥',
|
||||
],
|
||||
'COP' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'CRC' => [
|
||||
'symbol' => '₡',
|
||||
],
|
||||
'HRK' => [
|
||||
'symbol' => 'kn',
|
||||
],
|
||||
'CUP' => [
|
||||
'symbol' => '₱',
|
||||
],
|
||||
'CZK' => [
|
||||
'symbol' => 'Kč',
|
||||
],
|
||||
'DKK' => [
|
||||
'symbol' => 'kr',
|
||||
],
|
||||
'DOP' => [
|
||||
'symbol' => 'RD$',
|
||||
],
|
||||
'XCD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'EGP' => [
|
||||
'symbol' => '£',
|
||||
],
|
||||
'SVC' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'EEK' => [
|
||||
'symbol' => 'kr',
|
||||
],
|
||||
'EUR' => [
|
||||
'symbol' => '€',
|
||||
],
|
||||
'FKP' => [
|
||||
'symbol' => '£',
|
||||
],
|
||||
'FJD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'GHC' => [
|
||||
'symbol' => '₵',
|
||||
],
|
||||
'GIP' => [
|
||||
'symbol' => '£',
|
||||
],
|
||||
'GTQ' => [
|
||||
'symbol' => 'Q',
|
||||
],
|
||||
'GGP' => [
|
||||
'symbol' => '£',
|
||||
],
|
||||
'GYD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'HNL' => [
|
||||
'symbol' => 'L',
|
||||
],
|
||||
'HKD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'HUF' => [
|
||||
'symbol' => 'Ft',
|
||||
],
|
||||
'ISK' => [
|
||||
'symbol' => 'kr',
|
||||
],
|
||||
'INR' => [
|
||||
'symbol' => '₹',
|
||||
],
|
||||
'IDR' => [
|
||||
'symbol' => 'Rp',
|
||||
],
|
||||
'IRR' => [
|
||||
'symbol' => '﷼',
|
||||
],
|
||||
'IMP' => [
|
||||
'symbol' => '£',
|
||||
],
|
||||
'ILS' => [
|
||||
'symbol' => '₪',
|
||||
],
|
||||
'JMD' => [
|
||||
'symbol' => 'J$',
|
||||
],
|
||||
'JPY' => [
|
||||
'symbol' => '¥',
|
||||
],
|
||||
'JEP' => [
|
||||
'symbol' => '£',
|
||||
],
|
||||
'KZT' => [
|
||||
'symbol' => 'лв',
|
||||
],
|
||||
'KPW' => [
|
||||
'symbol' => '₩',
|
||||
],
|
||||
'KRW' => [
|
||||
'symbol' => '₩',
|
||||
],
|
||||
'KGS' => [
|
||||
'symbol' => 'лв',
|
||||
],
|
||||
'LAK' => [
|
||||
'symbol' => '₭',
|
||||
],
|
||||
'LVL' => [
|
||||
'symbol' => 'Ls',
|
||||
],
|
||||
'LBP' => [
|
||||
'symbol' => '£',
|
||||
],
|
||||
'LRD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'LTL' => [
|
||||
'symbol' => 'Lt',
|
||||
],
|
||||
'MKD' => [
|
||||
'symbol' => 'ден',
|
||||
],
|
||||
'MYR' => [
|
||||
'symbol' => 'RM',
|
||||
],
|
||||
'MUR' => [
|
||||
'symbol' => '₨',
|
||||
],
|
||||
'MXN' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'MNT' => [
|
||||
'symbol' => '₮',
|
||||
],
|
||||
'MZN' => [
|
||||
'symbol' => 'MT',
|
||||
],
|
||||
'NAD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'NPR' => [
|
||||
'symbol' => '₨',
|
||||
],
|
||||
'ANG' => [
|
||||
'symbol' => 'ƒ',
|
||||
],
|
||||
'NZD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'NIO' => [
|
||||
'symbol' => 'C$',
|
||||
],
|
||||
'NGN' => [
|
||||
'symbol' => '₦',
|
||||
],
|
||||
'NOK' => [
|
||||
'symbol' => 'kr',
|
||||
],
|
||||
'OMR' => [
|
||||
'symbol' => '﷼',
|
||||
],
|
||||
'PKR' => [
|
||||
'symbol' => '₨',
|
||||
],
|
||||
'PAB' => [
|
||||
'symbol' => 'B/.',
|
||||
],
|
||||
'PYG' => [
|
||||
'symbol' => 'Gs',
|
||||
],
|
||||
'PEN' => [
|
||||
'symbol' => 'S/.',
|
||||
],
|
||||
'PHP' => [
|
||||
'symbol' => '₱',
|
||||
],
|
||||
'PLN' => [
|
||||
'symbol' => 'zł',
|
||||
],
|
||||
'QAR' => [
|
||||
'symbol' => '﷼',
|
||||
],
|
||||
'RON' => [
|
||||
'symbol' => 'lei',
|
||||
],
|
||||
'RUB' => [
|
||||
'symbol' => '₽',
|
||||
],
|
||||
'SHP' => [
|
||||
'symbol' => '£',
|
||||
],
|
||||
'SAR' => [
|
||||
'symbol' => '﷼',
|
||||
],
|
||||
'RSD' => [
|
||||
'symbol' => 'Дин.',
|
||||
],
|
||||
'SCR' => [
|
||||
'symbol' => '₨',
|
||||
],
|
||||
'SGD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'SBD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'SOS' => [
|
||||
'symbol' => 'S',
|
||||
],
|
||||
'ZAR' => [
|
||||
'symbol' => 'R',
|
||||
],
|
||||
'LKR' => [
|
||||
'symbol' => '₨',
|
||||
],
|
||||
'SEK' => [
|
||||
'symbol' => 'kr',
|
||||
],
|
||||
'CHF' => [
|
||||
'symbol' => 'CHF',
|
||||
],
|
||||
'SRD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'SYP' => [
|
||||
'symbol' => '£',
|
||||
],
|
||||
'TWD' => [
|
||||
'symbol' => 'NT$',
|
||||
],
|
||||
'THB' => [
|
||||
'symbol' => '฿',
|
||||
],
|
||||
'TTD' => [
|
||||
'symbol' => 'TT$',
|
||||
],
|
||||
'TRY' => [
|
||||
'symbol' => '₺',
|
||||
],
|
||||
'TRL' => [
|
||||
'symbol' => '₤',
|
||||
],
|
||||
'TVD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'UAH' => [
|
||||
'symbol' => '₴',
|
||||
],
|
||||
'GBP' => [
|
||||
'symbol' => '£',
|
||||
],
|
||||
'UGX' => [
|
||||
'symbol' => 'USh',
|
||||
],
|
||||
'USD' => [
|
||||
'symbol' => '$',
|
||||
],
|
||||
'UYU' => [
|
||||
'symbol' => '$U',
|
||||
],
|
||||
'UZS' => [
|
||||
'symbol' => 'лв',
|
||||
],
|
||||
'VEF' => [
|
||||
'symbol' => 'Bs',
|
||||
],
|
||||
'VND' => [
|
||||
'symbol' => '₫',
|
||||
],
|
||||
'YER' => [
|
||||
'symbol' => '﷼',
|
||||
],
|
||||
'ZWD' => [
|
||||
'symbol' => 'Z$',
|
||||
],
|
||||
];
|
||||
|
||||
public function getCurrencySymbolForMoney(Money $money): string
|
||||
{
|
||||
return $this->getCurrencySymbol($money->getCurrency()->getCurrencyCode());
|
||||
}
|
||||
|
||||
public function getCurrencySymbol(string $currencyCode): string
|
||||
{
|
||||
if (isset(self::CURRENCIES[$currencyCode]['symbol'])) {
|
||||
return self::CURRENCIES[$currencyCode]['symbol'];
|
||||
}
|
||||
|
||||
return $currencyCode;
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,9 @@ class ClockifyProjectsImporter extends DefaultImporter
|
||||
if ($record['Project'] !== '') {
|
||||
$projectId = $this->projectImportHelper->getKey([
|
||||
'name' => $record['Project'],
|
||||
'client_id' => $clientId,
|
||||
'organization_id' => $this->organization->id,
|
||||
], [
|
||||
'client_id' => $clientId,
|
||||
'color' => $this->colorService->getRandomColor(),
|
||||
'is_billable' => $record['Billability'] === 'Yes',
|
||||
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\TimeEntry;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Exception;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Csv\Exception as CsvException;
|
||||
use League\Csv\Reader;
|
||||
|
||||
@@ -23,7 +24,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
*/
|
||||
private function getTags(string $tags): array
|
||||
{
|
||||
if (trim($tags) === '') {
|
||||
if (Str::trim($tags) === '') {
|
||||
return [];
|
||||
}
|
||||
$tagsParsed = explode(', ', $tags);
|
||||
@@ -82,9 +83,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
if ($record['Project'] !== '') {
|
||||
$projectId = $this->projectImportHelper->getKey([
|
||||
'name' => $record['Project'],
|
||||
'client_id' => $clientId,
|
||||
'organization_id' => $this->organization->id,
|
||||
], [
|
||||
'client_id' => $clientId,
|
||||
'color' => $this->colorService->getRandomColor(),
|
||||
'is_billable' => false,
|
||||
]);
|
||||
@@ -123,34 +124,59 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
$timeEntry->is_imported = true;
|
||||
|
||||
// Start
|
||||
$start = null;
|
||||
try {
|
||||
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['Start Time']) === 1) {
|
||||
$start = Carbon::createFromFormat('m/d/Y h:i A', $record['Start Date'].' '.$record['Start Time'], $timezone);
|
||||
} else {
|
||||
$start = Carbon::createFromFormat('m/d/Y H:i:s A', $record['Start Date'].' '.$record['Start Time'], $timezone);
|
||||
$startDateStr = $record['Start Date'];
|
||||
$startTimeStr = $record['Start Time'];
|
||||
$startStr = $startDateStr.' '.$startTimeStr;
|
||||
$matches = [];
|
||||
$checkResult = preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4}) ([0-9]{1,2}):([0-9]{1,2})(:[0-9]{1,2})? (AM|PM)$/', $startStr, $matches);
|
||||
|
||||
if ($checkResult === 1) {
|
||||
if ((int) $matches[1] > 12) {
|
||||
throw new ImportException('Start date ("'.$startDateStr.'") is invalid, please select the correct date format before exporting from Clockify');
|
||||
}
|
||||
if ($matches[6] === '') {
|
||||
$start = Carbon::createFromFormat('m/d/Y h:i A', $startStr, $timezone);
|
||||
} else {
|
||||
$start = Carbon::createFromFormat('m/d/Y H:i:s A', $startStr, $timezone);
|
||||
}
|
||||
}
|
||||
} catch (InvalidFormatException) {
|
||||
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
|
||||
throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid');
|
||||
}
|
||||
if ($start === null) {
|
||||
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
|
||||
throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid');
|
||||
}
|
||||
$timeEntry->start = $start->utc();
|
||||
|
||||
// End
|
||||
$end = null;
|
||||
try {
|
||||
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['End Time']) === 1) {
|
||||
$end = Carbon::createFromFormat('m/d/Y h:i A', $record['End Date'].' '.$record['End Time'], $timezone);
|
||||
} else {
|
||||
$end = Carbon::createFromFormat('m/d/Y H:i:s A', $record['End Date'].' '.$record['End Time'], $timezone);
|
||||
$endDateStr = $record['End Date'];
|
||||
$endTimeStr = $record['End Time'];
|
||||
$endStr = $endDateStr.' '.$endTimeStr;
|
||||
$matches = [];
|
||||
$checkResult = preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4}) ([0-9]{1,2}):([0-9]{1,2})(:[0-9]{1,2})? (AM|PM)$/', $endStr, $matches);
|
||||
|
||||
if ($checkResult === 1) {
|
||||
if ((int) $matches[1] > 12) {
|
||||
throw new ImportException('Start date ("'.$endDateStr.'") is invalid, please select the correct date format before exporting from Clockify');
|
||||
}
|
||||
if ($matches[6] === '') {
|
||||
$end = Carbon::createFromFormat('m/d/Y h:i A', $endStr, $timezone);
|
||||
} else {
|
||||
$end = Carbon::createFromFormat('m/d/Y H:i:s A', $endStr, $timezone);
|
||||
}
|
||||
}
|
||||
} catch (InvalidFormatException) {
|
||||
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
|
||||
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
|
||||
}
|
||||
if ($end === null) {
|
||||
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
|
||||
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
|
||||
}
|
||||
$timeEntry->end = $end->utc();
|
||||
|
||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
||||
$timeEntry,
|
||||
$projectMember,
|
||||
|
||||
@@ -97,7 +97,7 @@ abstract class DefaultImporter implements ImporterContract
|
||||
'in:placeholder',
|
||||
],
|
||||
]);
|
||||
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
}, validate: [
|
||||
@@ -114,6 +114,11 @@ abstract class DefaultImporter implements ImporterContract
|
||||
'integer',
|
||||
'max:2147483647',
|
||||
],
|
||||
'client_id' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'uuid',
|
||||
],
|
||||
], beforeSave: function (Project $project): void {
|
||||
if ($project->billable_rate === 0) {
|
||||
$project->billable_rate = null;
|
||||
|
||||
105
app/Service/Import/Importers/GenericProjectsImporter.php
Normal file
105
app/Service/Import/Importers/GenericProjectsImporter.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Service\ColorService;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Exception;
|
||||
use Illuminate\Support\Carbon;
|
||||
use League\Csv\Exception as CsvException;
|
||||
use League\Csv\Reader;
|
||||
use Override;
|
||||
|
||||
class GenericProjectsImporter extends DefaultImporter
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
private const array REQUIRED_FIELDS = [
|
||||
'name',
|
||||
];
|
||||
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
#[Override]
|
||||
public function importData(string $data, string $timezone): void
|
||||
{
|
||||
try {
|
||||
$reader = Reader::createFromString($data);
|
||||
$reader->setHeaderOffset(0);
|
||||
$reader->setDelimiter(',');
|
||||
$reader->setEnclosure('"');
|
||||
$reader->setEscape('');
|
||||
$header = $reader->getHeader();
|
||||
$this->validateHeader($header);
|
||||
$records = $reader->getRecords();
|
||||
foreach ($records as $record) {
|
||||
$clientId = null;
|
||||
if (isset($record['client']) && $record['client'] !== '') {
|
||||
$clientId = $this->clientImportHelper->getKey([
|
||||
'name' => $record['client'],
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
}
|
||||
if ($record['name'] !== '') {
|
||||
$archivedAt = null;
|
||||
if (isset($record['archived_at']) && $record['archived_at'] !== '') {
|
||||
try {
|
||||
$archivedAt = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['archived_at'], 'UTC');
|
||||
} catch (InvalidFormatException) {
|
||||
throw new ImportException('Value of archived_at ("'.$record['archived_at'].'") is invalid');
|
||||
}
|
||||
}
|
||||
$this->projectImportHelper->getKey([
|
||||
'name' => $record['name'],
|
||||
'client_id' => $clientId,
|
||||
'organization_id' => $this->organization->id,
|
||||
], [
|
||||
'color' => isset($record['color']) && $record['color'] !== '' ? $record['color'] : app(ColorService::class)->getRandomColor(),
|
||||
'billable_rate' => isset($record['billable_rate']) && $record['billable_rate'] !== '' ? (int) $record['billable_rate'] : null,
|
||||
'is_public' => isset($record['is_public']) && $record['is_public'] === 'true',
|
||||
'is_billable' => isset($record['billable_default']) && $record['billable_default'] === 'true',
|
||||
'estimated_time' => isset($record['estimated_time']) && $record['estimated_time'] !== '' && is_numeric($record['estimated_time']) && ((int) $record['estimated_time'] !== 0) ? (int) $record['estimated_time'] : null,
|
||||
'archived_at' => $archivedAt,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (ImportException $exception) {
|
||||
throw $exception;
|
||||
} catch (CsvException $exception) {
|
||||
throw new ImportException('Invalid CSV data');
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
throw new ImportException('Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $header
|
||||
*
|
||||
* @throws ImportException
|
||||
*/
|
||||
private function validateHeader(array $header): void
|
||||
{
|
||||
foreach (self::REQUIRED_FIELDS as $requiredField) {
|
||||
if (! in_array($requiredField, $header, true)) {
|
||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getName(): string
|
||||
{
|
||||
return __('importer.generic_projects.name');
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getDescription(): string
|
||||
{
|
||||
return __('importer.generic_projects.description');
|
||||
}
|
||||
}
|
||||
208
app/Service/Import/Importers/GenericTimeEntriesImporter.php
Normal file
208
app/Service/Import/Importers/GenericTimeEntriesImporter.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Jobs\RecalculateSpentTimeForProject;
|
||||
use App\Jobs\RecalculateSpentTimeForTask;
|
||||
use App\Models\TimeEntry;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Exception;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Csv\Exception as CsvException;
|
||||
use League\Csv\Reader;
|
||||
|
||||
class GenericTimeEntriesImporter extends DefaultImporter
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
private const array REQUIRED_FIELDS = [
|
||||
'description',
|
||||
'billable',
|
||||
'client',
|
||||
'project',
|
||||
'tags',
|
||||
'start',
|
||||
'end',
|
||||
'task',
|
||||
'user_name',
|
||||
'user_email',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*
|
||||
* @throws ImportException
|
||||
*/
|
||||
private function getTags(string $tags): array
|
||||
{
|
||||
if (Str::trim($tags) === '') {
|
||||
return [];
|
||||
}
|
||||
$tagsParsed = explode(',', $tags);
|
||||
$tagIds = [];
|
||||
foreach ($tagsParsed as $tagParsed) {
|
||||
$tagId = $this->tagImportHelper->getKey([
|
||||
'name' => Str::trim($tagParsed),
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
$tagIds[] = $tagId;
|
||||
}
|
||||
|
||||
return $tagIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
#[\Override]
|
||||
public function importData(string $data, string $timezone): void
|
||||
{
|
||||
try {
|
||||
$reader = Reader::createFromString($data);
|
||||
$reader->setHeaderOffset(0);
|
||||
$reader->setDelimiter(',');
|
||||
$reader->setEnclosure('"');
|
||||
$reader->setEscape('');
|
||||
$header = $reader->getHeader();
|
||||
$this->validateHeader($header);
|
||||
$records = $reader->getRecords();
|
||||
foreach ($records as $record) {
|
||||
$userId = $this->userImportHelper->getKey([
|
||||
'email' => $record['user_email'],
|
||||
], [
|
||||
'name' => $record['user_name'],
|
||||
'timezone' => 'UTC',
|
||||
'is_placeholder' => true,
|
||||
]);
|
||||
$memberId = $this->memberImportHelper->getKey([
|
||||
'user_id' => $userId,
|
||||
'organization_id' => $this->organization->getKey(),
|
||||
], [
|
||||
'role' => Role::Placeholder->value,
|
||||
]);
|
||||
$member = $this->memberImportHelper->getModelById($memberId);
|
||||
$clientId = null;
|
||||
if ($record['client'] !== '') {
|
||||
$clientId = $this->clientImportHelper->getKey([
|
||||
'name' => $record['client'],
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
}
|
||||
$projectId = null;
|
||||
$project = null;
|
||||
$projectMember = null;
|
||||
if ($record['project'] !== '') {
|
||||
$projectId = $this->projectImportHelper->getKey([
|
||||
'name' => $record['project'],
|
||||
'client_id' => $clientId,
|
||||
'organization_id' => $this->organization->id,
|
||||
], [
|
||||
'is_billable' => false,
|
||||
'color' => $this->colorService->getRandomColor(),
|
||||
]);
|
||||
$project = $this->projectImportHelper->getModelById($projectId);
|
||||
$projectMember = $this->projectMemberImportHelper->getModel([
|
||||
'project_id' => $projectId,
|
||||
'member_id' => $memberId,
|
||||
]);
|
||||
}
|
||||
$taskId = null;
|
||||
if ($record['task'] !== '') {
|
||||
$taskId = $this->taskImportHelper->getKey([
|
||||
'name' => $record['task'],
|
||||
'project_id' => $projectId,
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
$this->taskImportHelper->getModelById($taskId);
|
||||
}
|
||||
$timeEntry = new TimeEntry;
|
||||
$timeEntry->disableAuditing();
|
||||
$timeEntry->user_id = $userId;
|
||||
$timeEntry->member_id = $memberId;
|
||||
$timeEntry->task_id = $taskId;
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
$timeEntry->description = $record['description'];
|
||||
if (! in_array($record['billable'], ['true', 'false'], true)) {
|
||||
throw new ImportException('Invalid billable value');
|
||||
}
|
||||
$timeEntry->billable = $record['billable'] === 'true';
|
||||
$timeEntry->tags = $this->getTags($record['tags']);
|
||||
$timeEntry->is_imported = true;
|
||||
try {
|
||||
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['start'], 'UTC');
|
||||
} catch (InvalidFormatException) {
|
||||
throw new ImportException('Value of start ("'.$record['start'].'") is invalid');
|
||||
}
|
||||
if ($start === null) {
|
||||
throw new ImportException('Value of start ("'.$record['start'].'") is invalid');
|
||||
}
|
||||
$timeEntry->start = $start->utc();
|
||||
|
||||
try {
|
||||
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['end'], 'UTC');
|
||||
} catch (InvalidFormatException) {
|
||||
throw new ImportException('Value of end ("'.$record['end'].'") is invalid');
|
||||
}
|
||||
if ($end === null) {
|
||||
throw new ImportException('Value of end ("'.$record['end'].'") is invalid');
|
||||
}
|
||||
$timeEntry->end = $end->utc();
|
||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
||||
$timeEntry,
|
||||
$projectMember,
|
||||
$project,
|
||||
$member,
|
||||
$this->organization
|
||||
);
|
||||
$timeEntry->save();
|
||||
$this->timeEntriesCreated++;
|
||||
}
|
||||
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
|
||||
RecalculateSpentTimeForProject::dispatch($usedProject);
|
||||
}
|
||||
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
|
||||
RecalculateSpentTimeForTask::dispatch($usedTask);
|
||||
}
|
||||
} catch (ImportException $exception) {
|
||||
throw $exception;
|
||||
} catch (CsvException $exception) {
|
||||
throw new ImportException('Invalid CSV data');
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
throw new ImportException('Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $header
|
||||
*
|
||||
* @throws ImportException
|
||||
*/
|
||||
private function validateHeader(array $header): void
|
||||
{
|
||||
foreach (self::REQUIRED_FIELDS as $requiredField) {
|
||||
if (! in_array($requiredField, $header, true)) {
|
||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getName(): string
|
||||
{
|
||||
return __('importer.generic_time_entries.name');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getDescription(): string
|
||||
{
|
||||
return __('importer.generic_time_entries.description');
|
||||
}
|
||||
}
|
||||
76
app/Service/Import/Importers/HarvestClientsImporter.php
Normal file
76
app/Service/Import/Importers/HarvestClientsImporter.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use Exception;
|
||||
use League\Csv\Exception as CsvException;
|
||||
use League\Csv\Reader;
|
||||
|
||||
class HarvestClientsImporter extends DefaultImporter
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
private const array REQUIRED_FIELDS = [
|
||||
'Client Name',
|
||||
];
|
||||
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
#[\Override]
|
||||
public function importData(string $data, string $timezone): void
|
||||
{
|
||||
try {
|
||||
$reader = Reader::createFromString($data);
|
||||
$reader->setHeaderOffset(0);
|
||||
$reader->setDelimiter(',');
|
||||
$reader->setEnclosure('"');
|
||||
$reader->setEscape('');
|
||||
$header = $reader->getHeader();
|
||||
$this->validateHeader($header);
|
||||
$records = $reader->getRecords();
|
||||
foreach ($records as $record) {
|
||||
$this->clientImportHelper->getKey([
|
||||
'name' => $record['Client Name'],
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
}
|
||||
} catch (ImportException $exception) {
|
||||
throw $exception;
|
||||
} catch (CsvException $exception) {
|
||||
throw new ImportException('Invalid CSV data');
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
throw new ImportException('Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $header
|
||||
*
|
||||
* @throws ImportException
|
||||
*/
|
||||
private function validateHeader(array $header): void
|
||||
{
|
||||
foreach (self::REQUIRED_FIELDS as $requiredField) {
|
||||
if (! in_array($requiredField, $header, true)) {
|
||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getName(): string
|
||||
{
|
||||
return __('importer.harvest_clients.name');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getDescription(): string
|
||||
{
|
||||
return __('importer.harvest_clients.description');
|
||||
}
|
||||
}
|
||||
107
app/Service/Import/Importers/HarvestProjectsImporter.php
Normal file
107
app/Service/Import/Importers/HarvestProjectsImporter.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Csv\Exception as CsvException;
|
||||
use League\Csv\Reader;
|
||||
|
||||
class HarvestProjectsImporter extends DefaultImporter
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
private const array REQUIRED_FIELDS = [
|
||||
'Client',
|
||||
'Project',
|
||||
'Budget',
|
||||
'Billable Hours',
|
||||
];
|
||||
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
#[\Override]
|
||||
public function importData(string $data, string $timezone): void
|
||||
{
|
||||
try {
|
||||
$reader = Reader::createFromString($data);
|
||||
$reader->setHeaderOffset(0);
|
||||
$reader->setDelimiter(',');
|
||||
$reader->setEnclosure('"');
|
||||
$reader->setEscape('');
|
||||
$header = $reader->getHeader();
|
||||
$this->validateHeader($header);
|
||||
$records = $reader->getRecords();
|
||||
foreach ($records as $record) {
|
||||
$clientId = null;
|
||||
if ($record['Client'] !== '') {
|
||||
$clientId = $this->clientImportHelper->getKey([
|
||||
'name' => $record['Client'],
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
}
|
||||
if ($record['Project'] !== '') {
|
||||
if (! isset($record['Budget']) || ! is_string($record['Budget'])) {
|
||||
throw new ImportException('The value for "Budget" is invalid');
|
||||
}
|
||||
$estimatedTimeField = Str::replace(',', '.', $record['Budget']);
|
||||
$estimatedTime = $estimatedTimeField !== '' && is_numeric($estimatedTimeField) ? (int) (((float) $estimatedTimeField) * 60 * 60) : null;
|
||||
if ($estimatedTime === 0) {
|
||||
$estimatedTime = null;
|
||||
}
|
||||
if (! isset($record['Billable Hours']) || ! is_string($record['Billable Hours'])) {
|
||||
throw new ImportException('The value for "Billable Hours" is invalid');
|
||||
}
|
||||
$billableHoursField = Str::replace(',', '.', $record['Billable Hours']);
|
||||
$billableHours = $billableHoursField !== '' && is_numeric($billableHoursField) ? (int) ((float) $billableHoursField) : null;
|
||||
$this->projectImportHelper->getKey([
|
||||
'name' => $record['Project'],
|
||||
'client_id' => $clientId,
|
||||
'organization_id' => $this->organization->id,
|
||||
], [
|
||||
'color' => $this->colorService->getRandomColor(),
|
||||
'estimated_time' => $estimatedTime,
|
||||
'is_billable' => $billableHours > 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (ImportException $exception) {
|
||||
throw $exception;
|
||||
} catch (CsvException $exception) {
|
||||
throw new ImportException('Invalid CSV data');
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
throw new ImportException('Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $header
|
||||
*
|
||||
* @throws ImportException
|
||||
*/
|
||||
private function validateHeader(array $header): void
|
||||
{
|
||||
foreach (self::REQUIRED_FIELDS as $requiredField) {
|
||||
if (! in_array($requiredField, $header, true)) {
|
||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getName(): string
|
||||
{
|
||||
return __('importer.harvest_projects.name');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getDescription(): string
|
||||
{
|
||||
return __('importer.harvest_projects.description');
|
||||
}
|
||||
}
|
||||
191
app/Service/Import/Importers/HarvestTimeEntriesImporter.php
Normal file
191
app/Service/Import/Importers/HarvestTimeEntriesImporter.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Jobs\RecalculateSpentTimeForProject;
|
||||
use App\Jobs\RecalculateSpentTimeForTask;
|
||||
use App\Models\TimeEntry;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Exception;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Csv\Exception as CsvException;
|
||||
use League\Csv\Reader;
|
||||
use Override;
|
||||
|
||||
class HarvestTimeEntriesImporter extends DefaultImporter
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
private const array REQUIRED_FIELDS = [
|
||||
'Date',
|
||||
'Hours',
|
||||
'Client',
|
||||
'Project',
|
||||
'Task',
|
||||
'Billable?',
|
||||
'First Name',
|
||||
'Last Name',
|
||||
'Notes',
|
||||
];
|
||||
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
#[Override]
|
||||
public function importData(string $data, string $timezone): void
|
||||
{
|
||||
try {
|
||||
$reader = Reader::createFromString($data);
|
||||
$reader->setHeaderOffset(0);
|
||||
$reader->setDelimiter(',');
|
||||
$reader->setEnclosure('"');
|
||||
$reader->setEscape('');
|
||||
$header = $reader->getHeader();
|
||||
$this->validateHeader($header);
|
||||
$records = $reader->getRecords();
|
||||
foreach ($records as $record) {
|
||||
$firstname = $record['First Name'];
|
||||
$lastname = $record['Last Name'];
|
||||
$userId = $this->userImportHelper->getKey([
|
||||
'email' => Str::slug($firstname).'.'.Str::slug($lastname).'@solidtime-import.test',
|
||||
], [
|
||||
'name' => $firstname.' '.$lastname,
|
||||
'timezone' => 'UTC',
|
||||
'is_placeholder' => true,
|
||||
]);
|
||||
$memberId = $this->memberImportHelper->getKey([
|
||||
'user_id' => $userId,
|
||||
'organization_id' => $this->organization->getKey(),
|
||||
], [
|
||||
'role' => Role::Placeholder->value,
|
||||
]);
|
||||
$member = $this->memberImportHelper->getModelById($memberId);
|
||||
$clientId = null;
|
||||
if ($record['Client'] !== '') {
|
||||
$clientId = $this->clientImportHelper->getKey([
|
||||
'name' => $record['Client'],
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
}
|
||||
$projectId = null;
|
||||
$project = null;
|
||||
$projectMember = null;
|
||||
if ($record['Project'] !== '') {
|
||||
$projectId = $this->projectImportHelper->getKey([
|
||||
'name' => $record['Project'],
|
||||
'client_id' => $clientId,
|
||||
'organization_id' => $this->organization->id,
|
||||
], [
|
||||
'color' => $this->colorService->getRandomColor(),
|
||||
'is_billable' => true,
|
||||
]);
|
||||
$project = $this->projectImportHelper->getModelById($projectId);
|
||||
$projectMember = $this->projectMemberImportHelper->getModel([
|
||||
'project_id' => $projectId,
|
||||
'member_id' => $memberId,
|
||||
]);
|
||||
}
|
||||
$taskId = null;
|
||||
if ($record['Task'] !== '') {
|
||||
$taskId = $this->taskImportHelper->getKey([
|
||||
'name' => $record['Task'],
|
||||
'project_id' => $projectId,
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
$this->taskImportHelper->getModelById($taskId);
|
||||
}
|
||||
$timeEntry = new TimeEntry;
|
||||
$timeEntry->disableAuditing();
|
||||
$timeEntry->user_id = $userId;
|
||||
$timeEntry->member_id = $memberId;
|
||||
$timeEntry->task_id = $taskId;
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
if (strlen($record['Notes']) > 500) {
|
||||
throw new ImportException('Time entry note is too long');
|
||||
}
|
||||
$timeEntry->description = $record['Notes'];
|
||||
if (! in_array($record['Billable?'], ['Yes', 'No'], true)) {
|
||||
throw new ImportException('Invalid billable value');
|
||||
}
|
||||
$timeEntry->billable = $record['Billable?'] === 'Yes';
|
||||
$timeEntry->tags = [];
|
||||
$timeEntry->is_imported = true;
|
||||
|
||||
// Start & End
|
||||
try {
|
||||
$date = Carbon::createFromFormat('Y-m-d', $record['Date'], $timezone);
|
||||
} catch (InvalidFormatException) {
|
||||
throw new ImportException('Date ("'.$record['Date'].'") is invalid');
|
||||
}
|
||||
if ($date === null) {
|
||||
throw new ImportException('Date ("'.$record['Date'].'") is invalid');
|
||||
}
|
||||
if (! isset($record['Hours']) || ! is_string($record['Hours'])) {
|
||||
throw new ImportException('Hours ("'.($record['Hours'] ?? '<null>').'") is invalid');
|
||||
}
|
||||
$hoursField = Str::replace(',', '.', $record['Hours']);
|
||||
if (! is_numeric($hoursField)) {
|
||||
throw new ImportException('Hours ("'.$record['Hours'].'") is invalid');
|
||||
}
|
||||
$hours = (float) $hoursField;
|
||||
$timeEntry->start = $date->copy()->startOfDay()->utc();
|
||||
$timeEntry->end = $date->copy()->startOfDay()->addHours($hours)->utc();
|
||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
||||
$timeEntry,
|
||||
$projectMember,
|
||||
$project,
|
||||
$member,
|
||||
$this->organization
|
||||
);
|
||||
$timeEntry->save();
|
||||
$this->timeEntriesCreated++;
|
||||
}
|
||||
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
|
||||
RecalculateSpentTimeForProject::dispatch($usedProject);
|
||||
}
|
||||
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
|
||||
RecalculateSpentTimeForTask::dispatch($usedTask);
|
||||
}
|
||||
} catch (ImportException $exception) {
|
||||
throw $exception;
|
||||
} catch (CsvException $exception) {
|
||||
throw new ImportException('Invalid CSV data');
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
throw new ImportException('Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $header
|
||||
*
|
||||
* @throws ImportException
|
||||
*/
|
||||
private function validateHeader(array $header): void
|
||||
{
|
||||
foreach (self::REQUIRED_FIELDS as $requiredField) {
|
||||
if (! in_array($requiredField, $header, true)) {
|
||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getName(): string
|
||||
{
|
||||
return __('importer.harvest_time_entries.name');
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getDescription(): string
|
||||
{
|
||||
return __('importer.harvest_time_entries.description');
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,11 @@ class ImporterProvider
|
||||
'clockify_time_entries' => ClockifyTimeEntriesImporter::class,
|
||||
'clockify_projects' => ClockifyProjectsImporter::class,
|
||||
'solidtime' => SolidtimeImporter::class,
|
||||
'harvest_projects' => HarvestProjectsImporter::class,
|
||||
'harvest_time_entries' => HarvestTimeEntriesImporter::class,
|
||||
'harvest_clients' => HarvestClientsImporter::class,
|
||||
'generic_projects' => GenericProjectsImporter::class,
|
||||
'generic_time_entries' => GenericTimeEntriesImporter::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -176,12 +176,12 @@ class SolidtimeImporter extends DefaultImporter
|
||||
|
||||
$this->projectImportHelper->getKey([
|
||||
'name' => $project['name'],
|
||||
'client_id' => $clientId,
|
||||
'organization_id' => $this->organization->getKey(),
|
||||
], [
|
||||
'color' => $project['color'],
|
||||
'billable_rate' => $project['billable_rate'] === '' ? null : (int) $project['billable_rate'],
|
||||
'is_public' => $project['is_public'] === 'true',
|
||||
'client_id' => $clientId,
|
||||
'is_billable' => $project['is_billable'] === 'true',
|
||||
'archived_at' => $project['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $project['archived_at'], 'UTC') : null,
|
||||
], $project['id']);
|
||||
@@ -328,7 +328,7 @@ class SolidtimeImporter extends DefaultImporter
|
||||
*/
|
||||
private function getTags(string $tags): array
|
||||
{
|
||||
if (trim($tags) === '') {
|
||||
if (Str::trim($tags) === '') {
|
||||
return [];
|
||||
}
|
||||
$tagsParsed = json_decode($tags);
|
||||
|
||||
@@ -5,8 +5,11 @@ declare(strict_types=1);
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Service\TimezoneService;
|
||||
use Exception;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Override;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use ValueError;
|
||||
@@ -93,11 +96,22 @@ class TogglDataImporter extends DefaultImporter
|
||||
}
|
||||
|
||||
foreach ($workspaceUsers as $workspaceUser) {
|
||||
$timezone = Str::trim($workspaceUser->timezone);
|
||||
if ($timezone === '') {
|
||||
$timezone = 'UTC';
|
||||
}
|
||||
if (! app(TimezoneService::class)->isValid($timezone)) {
|
||||
Log::warning('TogglDateImporter: Invalid timezone', [
|
||||
'timezone' => $timezone,
|
||||
]);
|
||||
$timezone = 'UTC';
|
||||
}
|
||||
|
||||
$userId = $this->userImportHelper->getKey([
|
||||
'email' => $workspaceUser->email,
|
||||
], [
|
||||
'name' => $workspaceUser->name,
|
||||
'timezone' => $workspaceUser->timezone ?? 'UTC',
|
||||
'timezone' => $timezone,
|
||||
'is_placeholder' => true,
|
||||
], (string) $workspaceUser->uid);
|
||||
$this->memberImportHelper->getKey([
|
||||
@@ -123,9 +137,9 @@ class TogglDataImporter extends DefaultImporter
|
||||
|
||||
$projectId = $this->projectImportHelper->getKey([
|
||||
'name' => $project->name,
|
||||
'client_id' => $clientId,
|
||||
'organization_id' => $this->organization->getKey(),
|
||||
], [
|
||||
'client_id' => $clientId,
|
||||
'color' => $project->color,
|
||||
'is_billable' => $project->billable,
|
||||
'is_public' => ! $project->is_private,
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\TimeEntry;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Exception;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Csv\Exception as CsvException;
|
||||
use League\Csv\Reader;
|
||||
|
||||
@@ -23,7 +24,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
||||
*/
|
||||
private function getTags(string $tags): array
|
||||
{
|
||||
if (trim($tags) === '') {
|
||||
if (Str::trim($tags) === '') {
|
||||
return [];
|
||||
}
|
||||
$tagsParsed = explode(', ', $tags);
|
||||
@@ -82,9 +83,9 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
||||
if ($record['Project'] !== '') {
|
||||
$projectId = $this->projectImportHelper->getKey([
|
||||
'name' => $record['Project'],
|
||||
'client_id' => $clientId,
|
||||
'organization_id' => $this->organization->id,
|
||||
], [
|
||||
'client_id' => $clientId,
|
||||
'is_billable' => false,
|
||||
'color' => $this->colorService->getRandomColor(),
|
||||
]);
|
||||
|
||||
155
app/Service/LocalizationService.php
Normal file
155
app/Service/LocalizationService.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\CurrencyFormat;
|
||||
use App\Enums\DateFormat;
|
||||
use App\Enums\IntervalFormat;
|
||||
use App\Enums\NumberFormat;
|
||||
use App\Enums\TimeFormat;
|
||||
use App\Models\Organization;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Money\Money;
|
||||
use Carbon\CarbonInterface;
|
||||
use Carbon\CarbonInterval;
|
||||
|
||||
class LocalizationService
|
||||
{
|
||||
private CurrencyFormat $currencyFormat;
|
||||
|
||||
private IntervalFormat $intervalFormat;
|
||||
|
||||
private DateFormat $dateFormat;
|
||||
|
||||
private TimeFormat $timeFormat;
|
||||
|
||||
private NumberFormat $numberFormat;
|
||||
|
||||
public function __construct(CurrencyFormat $currencyFormat, DateFormat $dateFormat, TimeFormat $timeFormat, NumberFormat $numberFormat, IntervalFormat $intervalFormat)
|
||||
{
|
||||
$this->currencyFormat = $currencyFormat;
|
||||
$this->dateFormat = $dateFormat;
|
||||
$this->timeFormat = $timeFormat;
|
||||
$this->numberFormat = $numberFormat;
|
||||
$this->intervalFormat = $intervalFormat;
|
||||
}
|
||||
|
||||
public static function forOrganization(Organization $organization): self
|
||||
{
|
||||
return new LocalizationService(
|
||||
$organization->currency_format,
|
||||
$organization->date_format,
|
||||
$organization->time_format,
|
||||
$organization->number_format,
|
||||
$organization->interval_format
|
||||
);
|
||||
}
|
||||
|
||||
public function formatNumber(BigDecimal|float $number): string
|
||||
{
|
||||
$numberFloat = $number instanceof BigDecimal ? $number->toFloat() : $number;
|
||||
|
||||
if ($this->numberFormat === NumberFormat::ThousandsPointDecimalComma) {
|
||||
return number_format($numberFloat, 2, ',', '.');
|
||||
} elseif ($this->numberFormat === NumberFormat::ThousandsSpaceDecimalPoint) {
|
||||
return number_format($numberFloat, 2, '.', ' ');
|
||||
} elseif ($this->numberFormat === NumberFormat::ThousandsCommaDecimalPoint) {
|
||||
return number_format($numberFloat, 2, '.', ',');
|
||||
} elseif ($this->numberFormat === NumberFormat::ThousandsSpaceDecimalComma) {
|
||||
return number_format($numberFloat, 2, ',', ' ');
|
||||
} elseif ($this->numberFormat === NumberFormat::ThousandsApostropheDecimalPoint) {
|
||||
return number_format($numberFloat, 2, '.', '\'');
|
||||
}
|
||||
}
|
||||
|
||||
public function formatNumberWithoutTrailingZeros(BigDecimal|float $number): string
|
||||
{
|
||||
$number = $this->formatNumber($number);
|
||||
|
||||
$number = rtrim($number, '0');
|
||||
$number = rtrim($number, '.');
|
||||
$number = rtrim($number, ',');
|
||||
|
||||
return $number;
|
||||
}
|
||||
|
||||
public function formatInterval(CarbonInterval $interval): string
|
||||
{
|
||||
if ($this->intervalFormat === IntervalFormat::Decimal) {
|
||||
$interval->cascade();
|
||||
|
||||
return $this->formatNumber($interval->totalHours);
|
||||
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutes) {
|
||||
$interval->cascade();
|
||||
|
||||
return ((int) floor($interval->totalHours)).'h '.$interval->format('%I').'m';
|
||||
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutesColonSeperated) {
|
||||
$interval->cascade();
|
||||
|
||||
return ((int) floor($interval->totalHours)).':'.$interval->format('%I');
|
||||
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutesSecondsColonSeperated) {
|
||||
$interval->cascade();
|
||||
|
||||
return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S');
|
||||
}
|
||||
}
|
||||
|
||||
public function formatCurrency(Money $money): string
|
||||
{
|
||||
$currencyService = app(CurrencyService::class);
|
||||
if ($this->currencyFormat === CurrencyFormat::ISOCodeAfterWithSpace) {
|
||||
return $this->formatNumber($money->getAmount()).' '.$money->getCurrency()->getCurrencyCode();
|
||||
} elseif ($this->currencyFormat === CurrencyFormat::ISOCodeBeforeWithSpace) {
|
||||
return $money->getCurrency()->getCurrencyCode().' '.$this->formatNumber($money->getAmount());
|
||||
} elseif ($this->currencyFormat === CurrencyFormat::SymbolAfter) {
|
||||
return $this->formatNumber($money->getAmount()).$currencyService->getCurrencySymbolForMoney($money);
|
||||
} elseif ($this->currencyFormat === CurrencyFormat::SymbolBefore) {
|
||||
return $currencyService->getCurrencySymbolForMoney($money).$this->formatNumber($money->getAmount());
|
||||
} elseif ($this->currencyFormat === CurrencyFormat::SymbolBeforeWithSpace) {
|
||||
return $currencyService->getCurrencySymbolForMoney($money).' '.$this->formatNumber($money->getAmount());
|
||||
} elseif ($this->currencyFormat === CurrencyFormat::SymbolAfterWithSpace) {
|
||||
return $this->formatNumber($money->getAmount()).' '.$currencyService->getCurrencySymbolForMoney($money);
|
||||
}
|
||||
}
|
||||
|
||||
public function formatTime(CarbonInterface $time): string
|
||||
{
|
||||
if ($this->timeFormat === TimeFormat::TwelveHours) {
|
||||
return $time->format('h:i a'); // Examples: "11:01 am", "1:02 am"
|
||||
} elseif ($this->timeFormat === TimeFormat::TwentyFourHours) {
|
||||
return $time->format('H:i'); // Examples: "23:01", "01:02"
|
||||
}
|
||||
}
|
||||
|
||||
public function formatDate(CarbonInterface $date): string
|
||||
{
|
||||
return $date->format($this->dateFormat->toCarbonFormat());
|
||||
}
|
||||
|
||||
public function setDateFormat(DateFormat $dateFormat): void
|
||||
{
|
||||
$this->dateFormat = $dateFormat;
|
||||
}
|
||||
|
||||
public function setCurrencyFormat(CurrencyFormat $currencyFormat): void
|
||||
{
|
||||
$this->currencyFormat = $currencyFormat;
|
||||
}
|
||||
|
||||
public function setIntervalFormat(IntervalFormat $intervalFormat): void
|
||||
{
|
||||
$this->intervalFormat = $intervalFormat;
|
||||
}
|
||||
|
||||
public function setTimeFormat(TimeFormat $timeFormat): void
|
||||
{
|
||||
$this->timeFormat = $timeFormat;
|
||||
}
|
||||
|
||||
public function setNumberFormat(NumberFormat $numberFormat): void
|
||||
{
|
||||
$this->numberFormat = $numberFormat;
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,18 @@ namespace App\Service;
|
||||
use App\Enums\Role;
|
||||
use App\Events\MemberRemoved;
|
||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
use Laravel\Jetstream\Events\AddingTeamMember;
|
||||
@@ -75,6 +78,7 @@ class MemberService
|
||||
* @throws ChangingRoleToPlaceholderIsNotAllowed
|
||||
* @throws OnlyOwnerCanChangeOwnership
|
||||
* @throws OrganizationNeedsAtLeastOneOwner
|
||||
* @throws ChangingRoleOfPlaceholderIsNotAllowed
|
||||
*/
|
||||
public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void
|
||||
{
|
||||
@@ -82,6 +86,9 @@ class MemberService
|
||||
if ($oldRole === Role::Owner) {
|
||||
throw new OrganizationNeedsAtLeastOneOwner;
|
||||
}
|
||||
if ($oldRole === Role::Placeholder) {
|
||||
throw new ChangingRoleOfPlaceholderIsNotAllowed;
|
||||
}
|
||||
if ($newRole === Role::Placeholder) {
|
||||
throw new ChangingRoleToPlaceholderIsNotAllowed;
|
||||
}
|
||||
@@ -96,6 +103,39 @@ class MemberService
|
||||
}
|
||||
}
|
||||
|
||||
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void
|
||||
{
|
||||
// Time entries
|
||||
TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereBelongsTo($fromMember, 'member')
|
||||
->update([
|
||||
'user_id' => $toMember->user_id,
|
||||
'member_id' => $toMember->getKey(),
|
||||
]);
|
||||
|
||||
// Project members
|
||||
ProjectMember::query()
|
||||
->whereBelongsToOrganization($organization)
|
||||
->whereBelongsTo($fromMember, 'member')
|
||||
->whereDoesntHave('project', function (Builder $builder) use ($toMember): void {
|
||||
/** @var Builder<Project> $builder */
|
||||
$builder->whereHas('members', function (Builder $builder) use ($toMember): void {
|
||||
/** @var Builder<ProjectMember> $builder */
|
||||
$builder->where('member_id', $toMember->getKey());
|
||||
});
|
||||
})
|
||||
->update([
|
||||
'user_id' => $toMember->user_id,
|
||||
'member_id' => $toMember->getKey(),
|
||||
]);
|
||||
|
||||
ProjectMember::query()
|
||||
->whereBelongsToOrganization($organization)
|
||||
->whereBelongsTo($fromMember, 'member')
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the ownership of an organization to a new user.
|
||||
* The previous owner will be demoted to an admin.
|
||||
@@ -132,7 +172,7 @@ class MemberService
|
||||
$member->role = Role::Placeholder->value;
|
||||
$member->save();
|
||||
|
||||
$this->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
|
||||
$this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser);
|
||||
if ($makeSureUserHasAtLeastOneOrganization) {
|
||||
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
|
||||
}
|
||||
|
||||
68
app/Service/OrganizationService.php
Normal file
68
app/Service/OrganizationService.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\CurrencyFormat;
|
||||
use App\Enums\DateFormat;
|
||||
use App\Enums\IntervalFormat;
|
||||
use App\Enums\NumberFormat;
|
||||
use App\Enums\Role;
|
||||
use App\Enums\TimeFormat;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
|
||||
class OrganizationService
|
||||
{
|
||||
public function createOrganization(
|
||||
string $name,
|
||||
User $owner,
|
||||
bool $personalOrganization,
|
||||
?string $currency = null,
|
||||
?NumberFormat $numberFormat = null,
|
||||
?CurrencyFormat $currencyFormat = null,
|
||||
?DateFormat $dateFormat = null,
|
||||
?IntervalFormat $intervalFormat = null,
|
||||
?TimeFormat $timeFormat = null,
|
||||
): Organization {
|
||||
|
||||
$organization = new Organization;
|
||||
$organization->name = $name;
|
||||
$organization->personal_team = $personalOrganization;
|
||||
if ($currency === null) {
|
||||
$currency = config('app.localization.default_currency');
|
||||
}
|
||||
$organization->currency = $currency;
|
||||
if ($numberFormat === null) {
|
||||
$numberFormat = NumberFormat::from(config('app.localization.default_number_format'));
|
||||
}
|
||||
$organization->number_format = $numberFormat;
|
||||
if ($currencyFormat === null) {
|
||||
$currencyFormat = CurrencyFormat::from(config('app.localization.default_currency_format'));
|
||||
}
|
||||
$organization->currency_format = $currencyFormat;
|
||||
if ($dateFormat === null) {
|
||||
$dateFormat = DateFormat::from(config('app.localization.default_date_format'));
|
||||
}
|
||||
$organization->date_format = $dateFormat;
|
||||
if ($intervalFormat === null) {
|
||||
$intervalFormat = IntervalFormat::from(config('app.localization.default_interval_format'));
|
||||
}
|
||||
$organization->interval_format = $intervalFormat;
|
||||
if ($timeFormat === null) {
|
||||
$timeFormat = TimeFormat::from(config('app.localization.default_time_format'));
|
||||
}
|
||||
$organization->time_format = $timeFormat;
|
||||
$organization->owner()->associate($owner);
|
||||
$organization->save();
|
||||
|
||||
$organization->users()->attach(
|
||||
$owner, [
|
||||
'role' => Role::Owner->value,
|
||||
]
|
||||
);
|
||||
|
||||
return $organization;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace App\Service\ReportExport;
|
||||
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\IntervalService;
|
||||
use App\Service\LocalizationService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use LogicException;
|
||||
use Maatwebsite\Excel\Concerns\Exportable;
|
||||
@@ -37,14 +37,17 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
|
||||
|
||||
private string $timezone;
|
||||
|
||||
private LocalizationService $localizationService;
|
||||
|
||||
/**
|
||||
* @param Builder<TimeEntry> $builder
|
||||
*/
|
||||
public function __construct(Builder $builder, ExportFormat $exportFormat, string $timezone)
|
||||
public function __construct(Builder $builder, ExportFormat $exportFormat, string $timezone, LocalizationService $localizationService)
|
||||
{
|
||||
$this->builder = $builder;
|
||||
$this->exportFormat = $exportFormat;
|
||||
$this->timezone = $timezone;
|
||||
$this->localizationService = $localizationService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,7 +116,6 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
|
||||
*/
|
||||
public function map($model): array
|
||||
{
|
||||
$interval = app(IntervalService::class);
|
||||
$duration = $model->getDuration();
|
||||
|
||||
if ($this->exportFormat === ExportFormat::XLSX) {
|
||||
@@ -125,7 +127,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
|
||||
$model->user->name,
|
||||
Date::dateTimeToExcel($model->start->timezone($this->timezone)),
|
||||
$model->end !== null ? Date::dateTimeToExcel($model->end->timezone($this->timezone)) : null,
|
||||
$duration !== null ? $interval->format($duration) : null,
|
||||
$duration !== null ? $this->localizationService->formatInterval($duration) : null,
|
||||
$duration?->totalHours,
|
||||
$model->billable ? 'Yes' : 'No',
|
||||
$model->tagsRelation->pluck('name')->implode(', '),
|
||||
@@ -139,7 +141,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
|
||||
$model->user->name,
|
||||
$model->start->timezone($this->timezone)->format('Y-m-d H:i:s'),
|
||||
$model->end?->timezone($this->timezone)?->format('Y-m-d H:i:s'),
|
||||
$duration !== null ? (int) floor($duration->totalHours).':'.$duration->format('%I:%S') : null,
|
||||
$duration !== null ? $this->localizationService->formatInterval($duration) : null,
|
||||
$duration?->totalHours,
|
||||
$model->billable ? 'Yes' : 'No',
|
||||
$model->tagsRelation->pluck('name')->implode(', '),
|
||||
|
||||
@@ -22,18 +22,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* seconds: int,
|
||||
* cost: int
|
||||
* cost: int|null
|
||||
* }
|
||||
*/
|
||||
private array $data;
|
||||
@@ -52,18 +52,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* seconds: int,
|
||||
* cost: int
|
||||
* cost: int|null
|
||||
* } $data
|
||||
*/
|
||||
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)
|
||||
|
||||
@@ -27,21 +27,21 @@ class TimeEntryAggregationService
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* seconds: int,
|
||||
* cost: int
|
||||
* cost: int|null
|
||||
* }
|
||||
*/
|
||||
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array
|
||||
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
||||
{
|
||||
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
||||
$group1Select = null;
|
||||
@@ -96,7 +96,7 @@ class TimeEntryAggregationService
|
||||
$group2Response[] = [
|
||||
'key' => $group2 === '' ? null : (string) $group2,
|
||||
'seconds' => (int) $aggregate->get(0)->aggregate,
|
||||
'cost' => (int) $aggregate->get(0)->cost,
|
||||
'cost' => $showBillableRate ? (int) $aggregate->get(0)->cost : null,
|
||||
'grouped_type' => null,
|
||||
'grouped_data' => null,
|
||||
];
|
||||
@@ -113,7 +113,7 @@ class TimeEntryAggregationService
|
||||
$group1Response[] = [
|
||||
'key' => $group1 === '' ? null : (string) $group1,
|
||||
'seconds' => $group2ResponseSum,
|
||||
'cost' => $group2ResponseCost,
|
||||
'cost' => $showBillableRate ? $group2ResponseCost : null,
|
||||
'grouped_type' => $group2Type?->value,
|
||||
'grouped_data' => $group2Response,
|
||||
];
|
||||
@@ -133,7 +133,7 @@ class TimeEntryAggregationService
|
||||
|
||||
return [
|
||||
'seconds' => $group1ResponseSum,
|
||||
'cost' => $group1ResponseCost,
|
||||
'cost' => $showBillableRate ? $group1ResponseCost : null,
|
||||
'grouped_type' => $group1Type?->value,
|
||||
'grouped_data' => $group1Response,
|
||||
];
|
||||
@@ -148,25 +148,25 @@ class TimeEntryAggregationService
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* seconds: int,
|
||||
* cost: int
|
||||
* cost: int|null
|
||||
* }
|
||||
*/
|
||||
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array
|
||||
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
||||
{
|
||||
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end);
|
||||
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate);
|
||||
|
||||
$keysGroup1 = [];
|
||||
$keysGroup2 = [];
|
||||
@@ -280,6 +280,20 @@ class TimeEntryAggregationService
|
||||
'color' => null,
|
||||
];
|
||||
}
|
||||
} elseif ($type === TimeEntryAggregationType::Description) {
|
||||
foreach ($keys as $key) {
|
||||
$descriptorMap[$key] = [
|
||||
'description' => $key,
|
||||
'color' => null,
|
||||
];
|
||||
}
|
||||
} elseif ($type === TimeEntryAggregationType::Billable) {
|
||||
foreach ($keys as $key) {
|
||||
$descriptorMap[$key] = [
|
||||
'description' => $key === '0' ? 'Non-billable' : 'Billable',
|
||||
'color' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $descriptorMap;
|
||||
@@ -289,12 +303,12 @@ class TimeEntryAggregationService
|
||||
* @param array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null|mixed,
|
||||
* grouped_data: null|mixed
|
||||
* }>
|
||||
@@ -302,12 +316,12 @@ class TimeEntryAggregationService
|
||||
* @return array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* cost: int|null,
|
||||
* grouped_type: null|mixed,
|
||||
* grouped_data: null|mixed
|
||||
* }>
|
||||
|
||||
@@ -4,7 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\CurrencyFormat;
|
||||
use App\Enums\DateFormat;
|
||||
use App\Enums\IntervalFormat;
|
||||
use App\Enums\NumberFormat;
|
||||
use App\Enums\Role;
|
||||
use App\Enums\TimeFormat;
|
||||
use App\Enums\Weekday;
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Models\Member;
|
||||
@@ -17,8 +22,20 @@ use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class UserService
|
||||
{
|
||||
public function createUser(string $name, string $email, string $password, string $timezone, Weekday $weekStart, string $currency, bool $verifyEmail = false): User
|
||||
{
|
||||
public function createUser(
|
||||
string $name,
|
||||
string $email,
|
||||
string $password,
|
||||
string $timezone,
|
||||
Weekday $weekStart,
|
||||
?string $currency,
|
||||
?NumberFormat $numberFormat = null,
|
||||
?CurrencyFormat $currencyFormat = null,
|
||||
?DateFormat $dateFormat = null,
|
||||
?IntervalFormat $intervalFormat = null,
|
||||
?TimeFormat $timeFormat = null,
|
||||
bool $verifyEmail = false
|
||||
): User {
|
||||
$user = new User;
|
||||
$user->name = $name;
|
||||
$user->email = $email;
|
||||
@@ -30,17 +47,16 @@ class UserService
|
||||
}
|
||||
$user->save();
|
||||
|
||||
$organization = new Organization;
|
||||
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
|
||||
$organization->personal_team = true;
|
||||
$organization->currency = $currency;
|
||||
$organization->owner()->associate($user);
|
||||
$organization->save();
|
||||
|
||||
$organization->users()->attach(
|
||||
$user, [
|
||||
'role' => Role::Owner->value,
|
||||
]
|
||||
$organization = app(OrganizationService::class)->createOrganization(
|
||||
$this->getOrganizationNameForUserName($user->name),
|
||||
$user,
|
||||
true,
|
||||
$currency,
|
||||
$numberFormat,
|
||||
$currencyFormat,
|
||||
$dateFormat,
|
||||
$intervalFormat,
|
||||
$timeFormat,
|
||||
);
|
||||
|
||||
$user->ownedTeams()->save($organization);
|
||||
@@ -49,24 +65,10 @@ class UserService
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign all organization entities (time entries, project members) from one user to another.
|
||||
* This is useful when a placeholder user is replaced with a real user.
|
||||
* This does NOT change the member id.
|
||||
* This should only be used in if you want to change a member to a placeholder!
|
||||
*/
|
||||
public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void
|
||||
{
|
||||
/** @var Member|null $toMember */
|
||||
$toMember = Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereBelongsTo($toUser, 'user')
|
||||
->first();
|
||||
if ($toMember === null) {
|
||||
throw new \InvalidArgumentException('User is not a member of the organization');
|
||||
}
|
||||
|
||||
$this->assignOrganizationEntitiesToDifferentMember($organization, $fromUser, $toUser, $toMember);
|
||||
}
|
||||
|
||||
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, User $fromUser, User $toUser, Member $toMember): void
|
||||
{
|
||||
// Time entries
|
||||
TimeEntry::query()
|
||||
@@ -74,7 +76,6 @@ class UserService
|
||||
->whereBelongsTo($fromUser, 'user')
|
||||
->update([
|
||||
'user_id' => $toUser->getKey(),
|
||||
'member_id' => $toMember->getKey(),
|
||||
]);
|
||||
|
||||
// Project members
|
||||
@@ -83,7 +84,6 @@ class UserService
|
||||
->whereBelongsTo($fromUser, 'user')
|
||||
->update([
|
||||
'user_id' => $toUser->getKey(),
|
||||
'member_id' => $toMember->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -94,14 +94,11 @@ class UserService
|
||||
}
|
||||
|
||||
// Create a new organization
|
||||
$organization = new Organization;
|
||||
$organization->name = $user->name."'s Organization";
|
||||
$organization->personal_team = true;
|
||||
$organization->user_id = $user->id;
|
||||
$organization->save();
|
||||
|
||||
// Attach the user to the organization
|
||||
$organization->users()->attach($user, ['role' => Role::Owner->value]);
|
||||
$organization = app(OrganizationService::class)->createOrganization(
|
||||
$this->getOrganizationNameForUserName($user->name),
|
||||
$user,
|
||||
true
|
||||
);
|
||||
|
||||
// Set the organization as the user's current organization
|
||||
$user->currentOrganization()->associate($organization);
|
||||
@@ -110,6 +107,11 @@ class UserService
|
||||
AfterCreateOrganization::dispatch($organization);
|
||||
}
|
||||
|
||||
public function getOrganizationNameForUserName(string $username): string
|
||||
{
|
||||
return explode(' ', $username, 2)[0]."'s Organization";
|
||||
}
|
||||
|
||||
public function makeSureUserHasCurrentOrganization(User $user): void
|
||||
{
|
||||
if ($user->currentOrganization !== null) {
|
||||
|
||||
20
components.json
Normal file
20
components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "resources/css/app.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"composables": "@/composables",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"inertiajs/inertia-laravel": "^1.0",
|
||||
"korridor/laravel-computed-attributes": "^3.1",
|
||||
"korridor/laravel-has-many-sync": "^3.1",
|
||||
"korridor/laravel-model-validation-rules": "^3.0",
|
||||
"laravel/framework": "^11.16.0",
|
||||
"laravel/jetstream": "^5.0",
|
||||
@@ -24,6 +25,7 @@
|
||||
"laravel/tinker": "^2.8",
|
||||
"league/csv": "^9.16.0",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"league/iso3166": "^4.3",
|
||||
"maatwebsite/excel": "^3.1",
|
||||
"novadaemon/filament-pretty-json": "^2.2",
|
||||
"nwidart/laravel-modules": "^11.0.11",
|
||||
|
||||
1811
composer.lock
generated
1811
composer.lock
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user