mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 16:22:16 +01:00
Compare commits
53 Commits
feature/di
...
feature/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9070f6cd7e | ||
|
|
919399e828 | ||
|
|
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 | ||
|
|
0a956fd9e7 | ||
|
|
09b168cddb | ||
|
|
31b9659f7e | ||
|
|
db7111da44 | ||
|
|
18ab1f714b | ||
|
|
00e2518196 | ||
|
|
6f6e5fb4c3 | ||
|
|
68228bccb2 | ||
|
|
2dd80ba6cc | ||
|
|
b783ea9ecd | ||
|
|
dce608e403 | ||
|
|
84c9cfe2f2 | ||
|
|
f14bd6413a | ||
|
|
eb19199bc6 | ||
|
|
0252d984cb | ||
|
|
18162b0ff5 | ||
|
|
3dab7440dd | ||
|
|
713e12e54e | ||
|
|
fc0a840ded | ||
|
|
28904b650e | ||
|
|
1d34a77eb2 | ||
|
|
49e045809b | ||
|
|
e90fa8307f | ||
|
|
895540d0a9 |
42
.env.ci
42
.env.ci
@@ -1,3 +1,4 @@
|
|||||||
|
# Application
|
||||||
APP_NAME=solidtime
|
APP_NAME=solidtime
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
@@ -5,7 +6,6 @@ APP_DEBUG=true
|
|||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
APP_FORCE_HTTPS=false
|
APP_FORCE_HTTPS=false
|
||||||
APP_ENABLE_REGISTRATION=true
|
APP_ENABLE_REGISTRATION=true
|
||||||
SESSION_SECURE_COOKIE=false
|
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stack
|
||||||
@@ -20,35 +20,39 @@ DB_TEST_DATABASE=laravel
|
|||||||
DB_TEST_USERNAME=root
|
DB_TEST_USERNAME=root
|
||||||
DB_TEST_PASSWORD=root
|
DB_TEST_PASSWORD=root
|
||||||
|
|
||||||
BROADCAST_DRIVER=log
|
# Broadcasting
|
||||||
|
BROADCAST_DRIVER=null
|
||||||
|
|
||||||
|
# Cache
|
||||||
CACHE_DRIVER=file
|
CACHE_DRIVER=file
|
||||||
|
|
||||||
|
# Queue
|
||||||
QUEUE_CONNECTION=sync
|
QUEUE_CONNECTION=sync
|
||||||
|
|
||||||
|
# Session
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
# Mail
|
# Mail
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=log
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
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
|
# Filesystems
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
PUBLIC_FILESYSTEM_DISK=public
|
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
|
# Services
|
||||||
GOTENBERG_URL=http://0.0.0.0:3000
|
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_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=https://solidtime.test
|
APP_URL=https://solidtime.test
|
||||||
AUDITING_ENABLED=true
|
APP_FORCE_HTTPS=false
|
||||||
APP_ENABLE_REGISTRATION=true
|
APP_ENABLE_REGISTRATION=true
|
||||||
SUPER_ADMINS=admin@example.com
|
SUPER_ADMINS=admin@example.com
|
||||||
PAGINATION_PER_PAGE_DEFAULT=500
|
PAGINATION_PER_PAGE_DEFAULT=500
|
||||||
@@ -49,7 +49,9 @@ MAIL_USERNAME=null
|
|||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_ENCRYPTION=null
|
MAIL_ENCRYPTION=null
|
||||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
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
|
# Filesystems
|
||||||
FILESYSTEM_DISK=s3
|
FILESYSTEM_DISK=s3
|
||||||
@@ -62,14 +64,24 @@ S3_URL=http://storage.solidtime.test/local
|
|||||||
S3_ENDPOINT=http://storage.solidtime.test
|
S3_ENDPOINT=http://storage.solidtime.test
|
||||||
S3_USE_PATH_STYLE_ENDPOINT=true
|
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
|
# Services
|
||||||
GOTENBERG_URL=http://gotenberg:3000
|
GOTENBERG_URL=http://gotenberg:3000
|
||||||
|
|
||||||
VITE_HOST_NAME=vite.solidtime.test
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
|
||||||
|
|
||||||
# Local setup
|
# Local setup
|
||||||
NGINX_HOST_NAME=solidtime.test
|
NGINX_HOST_NAME=solidtime.test
|
||||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||||
FORWARD_DB_PORT=5432
|
FORWARD_DB_PORT=5432
|
||||||
FORWARD_WEB_PORT=8083
|
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_ENV=production
|
||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
APP_FORCE_HTTPS=true
|
APP_FORCE_HTTPS=true
|
||||||
SESSION_SECURE_COOKIE=true
|
|
||||||
OCTANE_SERVER=frankenphp
|
OCTANE_SERVER=frankenphp
|
||||||
PAGINATION_PER_PAGE_DEFAULT=500
|
PAGINATION_PER_PAGE_DEFAULT=500
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
/* eslint-env node */
|
|
||||||
require("@rushstack/eslint-patch/modern-module-resolution")
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
extends: ['plugin:vue/vue3-essential', '@vue/eslint-config-typescript/recommended', '@vue/eslint-config-prettier'],
|
|
||||||
rules: {
|
|
||||||
'vue/multi-word-component-names': 'off',
|
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
|
||||||
"unused-imports/no-unused-imports": "error",
|
|
||||||
"unused-imports/no-unused-vars": "error",
|
|
||||||
},
|
|
||||||
plugins: ['unused-imports'],
|
|
||||||
}
|
|
||||||
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.
|
||||||
|
-->
|
||||||
145
.github/workflows/build-public.yml
vendored
145
.github/workflows/build-public.yml
vendored
@@ -11,10 +11,21 @@ on:
|
|||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKERHUB_REPO: solidtime/solidtime
|
||||||
|
GHCR_REPO: ghcr.io/solidtime-io/solidtime
|
||||||
|
|
||||||
name: Build - Public
|
name: Build - Public
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
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:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
@@ -29,7 +40,7 @@ jobs:
|
|||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
- name: "Get build"
|
- name: "Get build"
|
||||||
id: build
|
id: release-build
|
||||||
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: "Get Previous tag (normal push)"
|
- name: "Get Previous tag (normal push)"
|
||||||
@@ -40,7 +51,7 @@ jobs:
|
|||||||
prefix: "v"
|
prefix: "v"
|
||||||
|
|
||||||
- name: "Get version"
|
- name: "Get version"
|
||||||
id: version
|
id: release-version
|
||||||
run: |
|
run: |
|
||||||
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
||||||
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
||||||
@@ -61,21 +72,23 @@ jobs:
|
|||||||
rm .env.production .env.ci .env.example
|
rm .env.production .env.ci .env.example
|
||||||
|
|
||||||
- name: "Add version to .env"
|
- 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"
|
- 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"
|
- name: "Output .env"
|
||||||
run: cat .env
|
run: cat .env
|
||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Setup PHP with PECL extension"
|
||||||
uses: php-actions/composer@v6
|
uses: shivammathur/setup-php@v2
|
||||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
|
||||||
with:
|
with:
|
||||||
command: install
|
php-version: '8.3'
|
||||||
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
extensions: mbstring, dom, fileinfo, pgsql
|
||||||
php_version: 8.3
|
|
||||||
|
- 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"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -88,29 +101,31 @@ jobs:
|
|||||||
- name: "Build"
|
- name: "Build"
|
||||||
run: npm run 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
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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"
|
- name: "Set up QEMU"
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -118,16 +133,90 @@ jobs:
|
|||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Build and push"
|
- name: "Build and push by digest"
|
||||||
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/prod/Dockerfile
|
file: docker/prod/Dockerfile
|
||||||
build-args: |
|
build-args: |
|
||||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
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-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
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
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: "Checkout code"
|
||||||
|
uses: actions/checkout@v4
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- name: Install root project dependencies
|
- name: Install root project dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
3
.github/workflows/npm-publish-ui.yml
vendored
3
.github/workflows/npm-publish-ui.yml
vendored
@@ -8,7 +8,8 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: "Checkout code"
|
||||||
|
uses: actions/checkout@v4
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
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
|
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||||
|
|
||||||
- name: "Upload coverage reports to Codecov"
|
- name: "Upload coverage reports to Codecov"
|
||||||
uses: codecov/codecov-action@v4.5.0
|
uses: codecov/codecov-action@v5.4.0
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
slug: solidtime-io/solidtime
|
slug: solidtime-io/solidtime
|
||||||
|
|||||||
2
.github/workflows/pint.yml
vendored
2
.github/workflows/pint.yml
vendored
@@ -10,6 +10,6 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Check code style"
|
- name: "Check code style"
|
||||||
uses: aglipanci/laravel-pint-action@2.4
|
uses: aglipanci/laravel-pint-action@2.5
|
||||||
with:
|
with:
|
||||||
configPath: "pint.json"
|
configPath: "pint.json"
|
||||||
|
|||||||
24
.github/workflows/playwright.yml
vendored
24
.github/workflows/playwright.yml
vendored
@@ -27,45 +27,47 @@ jobs:
|
|||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- name: "Setup node"
|
||||||
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: '8.3'
|
php-version: '8.3'
|
||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- name: Run composer install
|
- name: "Run composer install"
|
||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- name: Prepare Laravel Application
|
- name: "Prepare Laravel Application"
|
||||||
run: |
|
run: |
|
||||||
cp .env.ci .env
|
cp .env.ci .env
|
||||||
php artisan key:generate
|
php artisan key:generate
|
||||||
php artisan migrate --seed
|
|
||||||
php artisan passport:keys
|
php artisan passport:keys
|
||||||
|
php artisan migrate --seed
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: "Install dependencies"
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build Frontend
|
- name: "Build Frontend"
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Run Laravel Server
|
- name: "Run Laravel Server"
|
||||||
run: php artisan serve > /dev/null 2>&1 &
|
run: php artisan serve > /dev/null 2>&1 &
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: "Install Playwright Browsers"
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: "Run Playwright tests"
|
||||||
run: npx playwright test
|
run: npx playwright test
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
|
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()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class UserCreateCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user = null;
|
$user = null;
|
||||||
DB::transaction(function () use (&$user, $name, $email, $password): void {
|
DB::transaction(function () use (&$user, $name, $email, $password, $verifyEmail): void {
|
||||||
$user = app(UserService::class)->createUser(
|
$user = app(UserService::class)->createUser(
|
||||||
$name,
|
$name,
|
||||||
$email,
|
$email,
|
||||||
@@ -65,6 +65,7 @@ class UserCreateCommand extends Command
|
|||||||
'UTC',
|
'UTC',
|
||||||
Weekday::Monday,
|
Weekday::Monday,
|
||||||
'EUR',
|
'EUR',
|
||||||
|
$verifyEmail
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
/** @var Organization|null $organization */
|
/** @var Organization|null $organization */
|
||||||
@@ -73,10 +74,6 @@ class UserCreateCommand extends Command
|
|||||||
throw new LogicException('User does not have an organization');
|
throw new LogicException('User does not have an organization');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($verifyEmail) {
|
|
||||||
$user->markEmailAsVerified();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info('Created user "'.$name.'" ("'.$email.'")');
|
$this->info('Created user "'.$name.'" ("'.$email.'")');
|
||||||
$this->line('ID: '.$user->getKey());
|
$this->line('ID: '.$user->getKey());
|
||||||
$this->line('Name: '.$name);
|
$this->line('Name: '.$name);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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';
|
||||||
|
}
|
||||||
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 [
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ use Filament\Tables\Table;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||||
use STS\FilamentImpersonate\Tables\Actions\Impersonate;
|
use STS\FilamentImpersonate\Tables\Actions\Impersonate;
|
||||||
|
|
||||||
class UserResource extends Resource
|
class UserResource extends Resource
|
||||||
@@ -39,6 +40,9 @@ class UserResource extends Resource
|
|||||||
|
|
||||||
public static function form(Form $form): Form
|
public static function form(Form $form): Form
|
||||||
{
|
{
|
||||||
|
/** @var User|null $record */
|
||||||
|
$record = $form->getRecord();
|
||||||
|
|
||||||
return $form
|
return $form
|
||||||
->columns(1)
|
->columns(1)
|
||||||
->schema([
|
->schema([
|
||||||
@@ -55,6 +59,13 @@ class UserResource extends Resource
|
|||||||
Forms\Components\TextInput::make('email')
|
Forms\Components\TextInput::make('email')
|
||||||
->label('Email')
|
->label('Email')
|
||||||
->required()
|
->required()
|
||||||
|
->rules($record?->is_placeholder ? [] : [
|
||||||
|
UniqueEloquent::make(User::class, 'email')
|
||||||
|
->ignore($record?->getKey()),
|
||||||
|
])
|
||||||
|
->rule([
|
||||||
|
'email',
|
||||||
|
])
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
Forms\Components\Toggle::make('is_placeholder')
|
Forms\Components\Toggle::make('is_placeholder')
|
||||||
->label('Is Placeholder?')
|
->label('Is Placeholder?')
|
||||||
@@ -62,7 +73,11 @@ class UserResource extends Resource
|
|||||||
->disabledOn(['edit']),
|
->disabledOn(['edit']),
|
||||||
Forms\Components\DateTimePicker::make('email_verified_at')
|
Forms\Components\DateTimePicker::make('email_verified_at')
|
||||||
->label('Email Verified At')
|
->label('Email Verified At')
|
||||||
|
->hiddenOn(['create'])
|
||||||
->nullable(),
|
->nullable(),
|
||||||
|
Forms\Components\Toggle::make('is_email_verified')
|
||||||
|
->label('Email Verified?')
|
||||||
|
->visibleOn(['create']),
|
||||||
Forms\Components\Select::make('timezone')
|
Forms\Components\Select::make('timezone')
|
||||||
->label('Timezone')
|
->label('Timezone')
|
||||||
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
|
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
|
||||||
@@ -74,8 +89,16 @@ class UserResource extends Resource
|
|||||||
->required(),
|
->required(),
|
||||||
TextInput::make('password')
|
TextInput::make('password')
|
||||||
->password()
|
->password()
|
||||||
|
->label('Password')
|
||||||
->dehydrateStateUsing(fn ($state) => Hash::make($state))
|
->dehydrateStateUsing(fn ($state) => Hash::make($state))
|
||||||
->dehydrated(fn ($state) => filled($state))
|
->dehydrated(fn ($state) => filled($state))
|
||||||
|
->hiddenOn(['create'])
|
||||||
|
->required(fn (string $context): bool => $context === 'create')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('password_create')
|
||||||
|
->password()
|
||||||
|
->label('Password')
|
||||||
|
->visibleOn(['create'])
|
||||||
->required(fn (string $context): bool => $context === 'create')
|
->required(fn (string $context): bool => $context === 'create')
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
Forms\Components\Select::make('currency')
|
Forms\Components\Select::make('currency')
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ class CreateUser extends CreateRecord
|
|||||||
$user = $userService->createUser(
|
$user = $userService->createUser(
|
||||||
$data['name'],
|
$data['name'],
|
||||||
$data['email'],
|
$data['email'],
|
||||||
$data['password'],
|
$data['password_create'],
|
||||||
$data['timezone'],
|
$data['timezone'],
|
||||||
Weekday::from($data['week_start']),
|
Weekday::from($data['week_start']),
|
||||||
$data['currency'],
|
$data['currency'],
|
||||||
|
(bool) $data['is_email_verified']
|
||||||
);
|
);
|
||||||
|
|
||||||
return $user;
|
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\Enums\Role;
|
||||||
use App\Events\MemberMadeToPlaceholder;
|
use App\Events\MemberMadeToPlaceholder;
|
||||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||||
|
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||||
|
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
||||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||||
|
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||||
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||||
use App\Http\Requests\V1\Member\MemberIndexRequest;
|
use App\Http\Requests\V1\Member\MemberIndexRequest;
|
||||||
|
use App\Http\Requests\V1\Member\MemberMergeIntoRequest;
|
||||||
use App\Http\Requests\V1\Member\MemberUpdateRequest;
|
use App\Http\Requests\V1\Member\MemberUpdateRequest;
|
||||||
use App\Http\Resources\V1\Member\MemberCollection;
|
use App\Http\Resources\V1\Member\MemberCollection;
|
||||||
use App\Http\Resources\V1\Member\MemberResource;
|
use App\Http\Resources\V1\Member\MemberResource;
|
||||||
@@ -24,6 +29,8 @@ use App\Service\MemberService;
|
|||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class MemberController extends Controller
|
class MemberController extends Controller
|
||||||
{
|
{
|
||||||
@@ -63,6 +70,7 @@ class MemberController extends Controller
|
|||||||
* @throws OrganizationNeedsAtLeastOneOwner
|
* @throws OrganizationNeedsAtLeastOneOwner
|
||||||
* @throws OnlyOwnerCanChangeOwnership
|
* @throws OnlyOwnerCanChangeOwnership
|
||||||
* @throws ChangingRoleToPlaceholderIsNotAllowed
|
* @throws ChangingRoleToPlaceholderIsNotAllowed
|
||||||
|
* @throws ChangingRoleOfPlaceholderIsNotAllowed
|
||||||
*
|
*
|
||||||
* @operationId updateMember
|
* @operationId updateMember
|
||||||
*/
|
*/
|
||||||
@@ -105,7 +113,9 @@ class MemberController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Make a member a placeholder member
|
* 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
|
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -114,6 +124,9 @@ class MemberController extends Controller
|
|||||||
if ($member->role === Role::Owner->value) {
|
if ($member->role === Role::Owner->value) {
|
||||||
throw new CanNotRemoveOwnerFromOrganization;
|
throw new CanNotRemoveOwnerFromOrganization;
|
||||||
}
|
}
|
||||||
|
if ($member->role === Role::Placeholder->value) {
|
||||||
|
throw new ChangingRoleOfPlaceholderIsNotAllowed;
|
||||||
|
}
|
||||||
|
|
||||||
$memberService->makeMemberToPlaceholder($member);
|
$memberService->makeMemberToPlaceholder($member);
|
||||||
|
|
||||||
@@ -122,10 +135,39 @@ class MemberController extends Controller
|
|||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
* 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
|
* @operationId invitePlaceholder
|
||||||
*/
|
*/
|
||||||
@@ -138,6 +180,10 @@ class MemberController extends Controller
|
|||||||
throw new UserNotPlaceholderApiException;
|
throw new UserNotPlaceholderApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Str::endsWith($user->email, '@solidtime-import.test')) {
|
||||||
|
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||||
|
}
|
||||||
|
|
||||||
$invitationService->inviteUser($organization, $user->email, Role::Employee);
|
$invitationService->inviteUser($organization, $user->email, Role::Employee);
|
||||||
|
|
||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class ReportController extends Controller
|
|||||||
false,
|
false,
|
||||||
$report->properties->start,
|
$report->properties->start,
|
||||||
$report->properties->end,
|
$report->properties->end,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||||
$timeEntriesQuery->clone(),
|
$timeEntriesQuery->clone(),
|
||||||
@@ -83,6 +84,7 @@ class ReportController extends Controller
|
|||||||
true,
|
true,
|
||||||
$report->properties->start,
|
$report->properties->start,
|
||||||
$report->properties->end,
|
$report->properties->end,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
return new DetailedWithDataReportResource($report, $data, $historyData);
|
return new DetailedWithDataReportResource($report, $data, $historyData);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
|
use App\Enums\Role;
|
||||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
@@ -180,6 +181,7 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$timezone = $user->timezone;
|
$timezone = $user->timezone;
|
||||||
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
||||||
$timeEntriesQuery->with([
|
$timeEntriesQuery->with([
|
||||||
@@ -211,7 +213,8 @@ class TimeEntryController extends Controller
|
|||||||
$user->week_start,
|
$user->week_start,
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null
|
null,
|
||||||
|
$showBillableRate
|
||||||
);
|
);
|
||||||
$html = Blade::render($viewFile, [
|
$html = Blade::render($viewFile, [
|
||||||
'timeEntries' => $timeEntriesQuery->get(),
|
'timeEntries' => $timeEntriesQuery->get(),
|
||||||
@@ -285,18 +288,18 @@ class TimeEntryController extends Controller
|
|||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int
|
* cost: int|null
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
@@ -312,6 +315,7 @@ class TimeEntryController extends Controller
|
|||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|
||||||
$group1Type = $request->getGroup();
|
$group1Type = $request->getGroup();
|
||||||
$group2Type = $request->getSubGroup();
|
$group2Type = $request->getSubGroup();
|
||||||
@@ -325,7 +329,8 @@ class TimeEntryController extends Controller
|
|||||||
$user->week_start,
|
$user->week_start,
|
||||||
$request->getFillGapsInTimeGroups(),
|
$request->getFillGapsInTimeGroups(),
|
||||||
$request->getStart(),
|
$request->getStart(),
|
||||||
$request->getEnd()
|
$request->getEnd(),
|
||||||
|
$showBillableRate
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -359,6 +364,7 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
$debug = $request->getDebug();
|
$debug = $request->getDebug();
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|
||||||
$group = $request->getGroup();
|
$group = $request->getGroup();
|
||||||
$subGroup = $request->getSubGroup();
|
$subGroup = $request->getSubGroup();
|
||||||
@@ -372,7 +378,8 @@ class TimeEntryController extends Controller
|
|||||||
$user->week_start,
|
$user->week_start,
|
||||||
false,
|
false,
|
||||||
$request->getStart(),
|
$request->getStart(),
|
||||||
$request->getEnd()
|
$request->getEnd(),
|
||||||
|
$showBillableRate
|
||||||
);
|
);
|
||||||
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesAggregateQuery->clone(),
|
$timeEntriesAggregateQuery->clone(),
|
||||||
@@ -382,7 +389,8 @@ class TimeEntryController extends Controller
|
|||||||
$user->week_start,
|
$user->week_start,
|
||||||
true,
|
true,
|
||||||
$request->getStart(),
|
$request->getStart(),
|
||||||
$request->getEnd()
|
$request->getEnd(),
|
||||||
|
$showBillableRate
|
||||||
);
|
);
|
||||||
$currency = $organization->currency;
|
$currency = $organization->currency;
|
||||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
use App\Service\DashboardService;
|
use App\Service\DashboardService;
|
||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
@@ -19,30 +20,14 @@ class DashboardController extends Controller
|
|||||||
{
|
{
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$organization = $this->currentOrganization();
|
$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;
|
$latestTeamActivity = null;
|
||||||
if ($permissionStore->has($organization, 'time-entries:view:all')) {
|
if ($permissionStore->has($organization, 'time-entries:view:all')) {
|
||||||
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Inertia::render('Dashboard', [
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
'weeklyProjectOverview' => $weeklyProjectOverview,
|
|
||||||
'latestTasks' => $latestTasks,
|
return Inertia::render('Dashboard');
|
||||||
'lastSevenDays' => $lastSevenDays,
|
|
||||||
'latestTeamActivity' => $latestTeamActivity,
|
|
||||||
'dailyTrackedHours' => $dailyTrackedHours,
|
|
||||||
'totalWeeklyTime' => $totalWeeklyTime,
|
|
||||||
'totalWeeklyBillableTime' => $totalWeeklyBillableTime,
|
|
||||||
'totalWeeklyBillableAmount' => $totalWeeklyBillableAmount,
|
|
||||||
'weeklyHistory' => $weeklyHistory,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class HealthCheckController extends Controller
|
|||||||
$response['app_env'] = app()->environment();
|
$response['app_env'] = app()->environment();
|
||||||
$response['app_timezone'] = config('app.timezone');
|
$response['app_timezone'] = config('app.timezone');
|
||||||
$response['app_force_https'] = config('app.force_https');
|
$response['app_force_https'] = config('app.force_https');
|
||||||
|
$response['session_secure'] = config('session.secure');
|
||||||
$response['trusted_proxies'] = config('trustedproxy.proxies');
|
$response['trusted_proxies'] = config('trustedproxy.proxies');
|
||||||
$headers = $request->headers->all();
|
$headers = $request->headers->all();
|
||||||
if (isset($headers['cookie'])) {
|
if (isset($headers['cookie'])) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class Kernel extends HttpKernel
|
|||||||
* @var array<int, class-string|string>
|
* @var array<int, class-string|string>
|
||||||
*/
|
*/
|
||||||
protected $middleware = [
|
protected $middleware = [
|
||||||
// \App\Http\Middleware\TrustHosts::class,
|
\App\Http\Middleware\ForceHttps::class,
|
||||||
\App\Http\Middleware\TrustProxies::class,
|
\App\Http\Middleware\TrustProxies::class,
|
||||||
\Illuminate\Http\Middleware\HandleCors::class,
|
\Illuminate\Http\Middleware\HandleCors::class,
|
||||||
\App\Http\Middleware\PreventRequestsDuringMaintenance::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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,20 +18,20 @@ use Illuminate\Http\Request;
|
|||||||
* description: string|null,
|
* description: string|null,
|
||||||
* color: string|null,
|
* color: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* description: string|null,
|
* description: string|null,
|
||||||
* color: string|null,
|
* color: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int
|
* cost: int|null
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
class DetailedWithDataReportResource extends BaseResource
|
class DetailedWithDataReportResource extends BaseResource
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace App\Listeners;
|
|||||||
|
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\UserService;
|
use App\Service\MemberService;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||||
|
|
||||||
@@ -17,8 +17,11 @@ class RemovePlaceholder
|
|||||||
*/
|
*/
|
||||||
public function handle(TeamMemberAdded $event): void
|
public function handle(TeamMemberAdded $event): void
|
||||||
{
|
{
|
||||||
/** @var UserService $userService */
|
$memberService = app(MemberService::class);
|
||||||
$userService = app(UserService::class);
|
$member = Member::query()
|
||||||
|
->whereBelongsTo($event->team, 'organization')
|
||||||
|
->whereBelongsTo($event->user, 'user')
|
||||||
|
->firstOrFail();
|
||||||
$placeholders = Member::query()
|
$placeholders = Member::query()
|
||||||
->whereHas('user', function (Builder $query) use ($event): void {
|
->whereHas('user', function (Builder $query) use ($event): void {
|
||||||
/** @var Builder<User> $query */
|
/** @var Builder<User> $query */
|
||||||
@@ -32,7 +35,7 @@ class RemovePlaceholder
|
|||||||
foreach ($placeholders as $placeholder) {
|
foreach ($placeholders as $placeholder) {
|
||||||
/** @var Member $placeholder */
|
/** @var Member $placeholder */
|
||||||
$placeholderUser = $placeholder->user;
|
$placeholderUser = $placeholder->user;
|
||||||
$userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholderUser, $event->user);
|
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);
|
||||||
$placeholder->delete();
|
$placeholder->delete();
|
||||||
$placeholderUser->delete();
|
$placeholderUser->delete();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
|||||||
use App\Models\Concerns\CustomAuditable;
|
use App\Models\Concerns\CustomAuditable;
|
||||||
use App\Models\Concerns\HasUuids;
|
use App\Models\Concerns\HasUuids;
|
||||||
use Database\Factories\MemberFactory;
|
use Database\Factories\MemberFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
@@ -24,6 +25,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property-read Organization $organization
|
* @property-read Organization $organization
|
||||||
* @property-read User $user
|
* @property-read User $user
|
||||||
|
* @property-read Collection<ProjectMember> $projectMembers
|
||||||
|
* @property-read Collection<TimeEntry> $timeEntries
|
||||||
*
|
*
|
||||||
* @method static MemberFactory factory()
|
* @method static MemberFactory factory()
|
||||||
*/
|
*/
|
||||||
@@ -59,6 +62,14 @@ class Member extends JetstreamMembership implements AuditableContract
|
|||||||
return $this->belongsTo(Organization::class, 'organization_id');
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<TimeEntry>
|
||||||
|
*/
|
||||||
|
public function timeEntries(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TimeEntry::class, 'member_id');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return HasMany<ProjectMember>
|
* @return HasMany<ProjectMember>
|
||||||
*/
|
*/
|
||||||
|
|||||||
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\Enums\Weekday;
|
||||||
use App\Models\Concerns\CustomAuditable;
|
use App\Models\Concerns\CustomAuditable;
|
||||||
use App\Models\Concerns\HasUuids;
|
use App\Models\Concerns\HasUuids;
|
||||||
|
use App\Models\Passport\Token;
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Filament\Models\Contracts\FilamentUser;
|
use Filament\Models\Contracts\FilamentUser;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
@@ -27,14 +28,13 @@ use Laravel\Jetstream\HasProfilePhoto;
|
|||||||
use Laravel\Jetstream\HasTeams;
|
use Laravel\Jetstream\HasTeams;
|
||||||
use Laravel\Passport\AuthCode;
|
use Laravel\Passport\AuthCode;
|
||||||
use Laravel\Passport\HasApiTokens;
|
use Laravel\Passport\HasApiTokens;
|
||||||
use Laravel\Passport\Token;
|
|
||||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property string $id
|
* @property string $id
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property string $email
|
* @property string $email
|
||||||
* @property string|null $email_verified_at
|
* @property Carbon|null $email_verified_at
|
||||||
* @property string|null $password
|
* @property string|null $password
|
||||||
* @property string|null $two_factor_secret
|
* @property string|null $two_factor_secret
|
||||||
* @property string $timezone
|
* @property string $timezone
|
||||||
@@ -44,6 +44,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property-read Organization|null $currentOrganization
|
* @property-read Organization|null $currentOrganization
|
||||||
* @property-read Organization|null $currentTeam
|
* @property-read Organization|null $currentTeam
|
||||||
* @property-read string $profile_photo_url
|
* @property-read string $profile_photo_url
|
||||||
|
* @property-read Collection<int, Token> $tokens
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property string|null $current_team_id
|
* @property string|null $current_team_id
|
||||||
@@ -196,6 +197,17 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
return $this->hasMany(AuthCode::class);
|
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
|
* @param Builder<User> $builder
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\FailedJob;
|
|||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\OrganizationInvitation;
|
use App\Models\OrganizationInvitation;
|
||||||
|
use App\Models\Passport\Token;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
@@ -29,7 +30,6 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Support\Facades\URL;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends 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 {
|
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
|
||||||
return new PermissionStore;
|
return new PermissionStore;
|
||||||
});
|
});
|
||||||
@@ -107,5 +101,6 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
// Routing
|
// Routing
|
||||||
Route::model('member', Member::class);
|
Route::model('member', Member::class);
|
||||||
Route::model('invitation', OrganizationInvitation::class);
|
Route::model('invitation', OrganizationInvitation::class);
|
||||||
|
Route::model('apiToken', Token::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Models\Organization;
|
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 App\Policies\OrganizationPolicy;
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
use Laravel\Jetstream\Jetstream;
|
use Laravel\Jetstream\Jetstream;
|
||||||
@@ -42,6 +47,16 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
// 'delete',
|
// '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
|
// same as passport default above
|
||||||
Jetstream::defaultApiTokenPermissions(['read']);
|
Jetstream::defaultApiTokenPermissions(['read']);
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
NavigationGroup::make()
|
NavigationGroup::make()
|
||||||
->label('System')
|
->label('System')
|
||||||
->collapsed(),
|
->collapsed(),
|
||||||
|
NavigationGroup::make()
|
||||||
|
->label('Auth')
|
||||||
|
->collapsed(),
|
||||||
])
|
])
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
Jetstream::defaultApiTokenPermissions([]);
|
Jetstream::defaultApiTokenPermissions([]);
|
||||||
|
|
||||||
Jetstream::role(Role::Owner->value, 'Owner', [
|
Jetstream::role(Role::Owner->value, 'Owner', [
|
||||||
|
'charts:view:own',
|
||||||
|
'charts:view:all',
|
||||||
'projects:view',
|
'projects:view',
|
||||||
'projects:view:all',
|
'projects:view:all',
|
||||||
'projects:create',
|
'projects:create',
|
||||||
@@ -123,6 +125,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'members:invite-placeholder',
|
'members:invite-placeholder',
|
||||||
'members:change-ownership',
|
'members:change-ownership',
|
||||||
'members:make-placeholder',
|
'members:make-placeholder',
|
||||||
|
'members:merge-into',
|
||||||
'members:update',
|
'members:update',
|
||||||
'members:delete',
|
'members:delete',
|
||||||
'billing',
|
'billing',
|
||||||
@@ -133,6 +136,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
])->description('Owner users can perform any action. There is only one owner per organization.');
|
])->description('Owner users can perform any action. There is only one owner per organization.');
|
||||||
|
|
||||||
Jetstream::role(Role::Admin->value, 'Administrator', [
|
Jetstream::role(Role::Admin->value, 'Administrator', [
|
||||||
|
'charts:view:own',
|
||||||
|
'charts:view:all',
|
||||||
'projects:view',
|
'projects:view',
|
||||||
'projects:view:all',
|
'projects:view:all',
|
||||||
'projects:create',
|
'projects:create',
|
||||||
@@ -172,8 +177,10 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'invitations:resend',
|
'invitations:resend',
|
||||||
'invitations:remove',
|
'invitations:remove',
|
||||||
'members:view',
|
'members:view',
|
||||||
'members:update',
|
|
||||||
'members:invite-placeholder',
|
'members:invite-placeholder',
|
||||||
|
'members:make-placeholder',
|
||||||
|
'members:merge-into',
|
||||||
|
'members:update',
|
||||||
'reports:view',
|
'reports:view',
|
||||||
'reports:create',
|
'reports:create',
|
||||||
'reports:update',
|
'reports:update',
|
||||||
@@ -181,6 +188,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
|
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
|
||||||
|
|
||||||
Jetstream::role(Role::Manager->value, 'Manager', [
|
Jetstream::role(Role::Manager->value, 'Manager', [
|
||||||
|
'charts:view:own',
|
||||||
|
'charts:view:all',
|
||||||
'projects:view',
|
'projects:view',
|
||||||
'projects:view:all',
|
'projects:view:all',
|
||||||
'projects:create',
|
'projects:create',
|
||||||
@@ -221,6 +230,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
|
])->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', [
|
Jetstream::role(Role::Employee->value, 'Employee', [
|
||||||
|
'charts:view:own',
|
||||||
'projects:view',
|
'projects:view',
|
||||||
'tags:view',
|
'tags:view',
|
||||||
'tasks:view',
|
'tasks:view',
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ class ColorService
|
|||||||
|
|
||||||
private const string VALID_REGEX = '/^#[0-9a-f]{6}$/';
|
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
|
public function getRandomColor(?string $seed = null): string
|
||||||
{
|
{
|
||||||
if ($seed !== null) {
|
if ($seed !== null) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Models\TimeEntry;
|
|||||||
use Carbon\Exceptions\InvalidFormatException;
|
use Carbon\Exceptions\InvalidFormatException;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use League\Csv\Exception as CsvException;
|
use League\Csv\Exception as CsvException;
|
||||||
use League\Csv\Reader;
|
use League\Csv\Reader;
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
*/
|
*/
|
||||||
private function getTags(string $tags): array
|
private function getTags(string $tags): array
|
||||||
{
|
{
|
||||||
if (trim($tags) === '') {
|
if (Str::trim($tags) === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$tagsParsed = explode(', ', $tags);
|
$tagsParsed = explode(', ', $tags);
|
||||||
|
|||||||
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'],
|
||||||
|
'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',
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'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'],
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
], [
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'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'],
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
], [
|
||||||
|
'color' => $this->colorService->getRandomColor(),
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'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'],
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
], [
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'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_time_entries' => ClockifyTimeEntriesImporter::class,
|
||||||
'clockify_projects' => ClockifyProjectsImporter::class,
|
'clockify_projects' => ClockifyProjectsImporter::class,
|
||||||
'solidtime' => SolidtimeImporter::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,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
*/
|
*/
|
||||||
private function getTags(string $tags): array
|
private function getTags(string $tags): array
|
||||||
{
|
{
|
||||||
if (trim($tags) === '') {
|
if (Str::trim($tags) === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$tagsParsed = json_decode($tags);
|
$tagsParsed = json_decode($tags);
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Service\Import\Importers;
|
namespace App\Service\Import\Importers;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Service\TimezoneService;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Override;
|
use Override;
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
use ValueError;
|
use ValueError;
|
||||||
@@ -93,11 +96,22 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($workspaceUsers as $workspaceUser) {
|
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([
|
$userId = $this->userImportHelper->getKey([
|
||||||
'email' => $workspaceUser->email,
|
'email' => $workspaceUser->email,
|
||||||
], [
|
], [
|
||||||
'name' => $workspaceUser->name,
|
'name' => $workspaceUser->name,
|
||||||
'timezone' => $workspaceUser->timezone ?? 'UTC',
|
'timezone' => $timezone,
|
||||||
'is_placeholder' => true,
|
'is_placeholder' => true,
|
||||||
], (string) $workspaceUser->uid);
|
], (string) $workspaceUser->uid);
|
||||||
$this->memberImportHelper->getKey([
|
$this->memberImportHelper->getKey([
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Models\TimeEntry;
|
|||||||
use Carbon\Exceptions\InvalidFormatException;
|
use Carbon\Exceptions\InvalidFormatException;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use League\Csv\Exception as CsvException;
|
use League\Csv\Exception as CsvException;
|
||||||
use League\Csv\Reader;
|
use League\Csv\Reader;
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
|||||||
*/
|
*/
|
||||||
private function getTags(string $tags): array
|
private function getTags(string $tags): array
|
||||||
{
|
{
|
||||||
if (trim($tags) === '') {
|
if (Str::trim($tags) === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$tagsParsed = explode(', ', $tags);
|
$tagsParsed = explode(', ', $tags);
|
||||||
|
|||||||
@@ -7,15 +7,18 @@ namespace App\Service;
|
|||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Events\MemberRemoved;
|
use App\Events\MemberRemoved;
|
||||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||||
|
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Models\Project;
|
||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Laravel\Jetstream\Events\AddingTeamMember;
|
use Laravel\Jetstream\Events\AddingTeamMember;
|
||||||
@@ -75,6 +78,7 @@ class MemberService
|
|||||||
* @throws ChangingRoleToPlaceholderIsNotAllowed
|
* @throws ChangingRoleToPlaceholderIsNotAllowed
|
||||||
* @throws OnlyOwnerCanChangeOwnership
|
* @throws OnlyOwnerCanChangeOwnership
|
||||||
* @throws OrganizationNeedsAtLeastOneOwner
|
* @throws OrganizationNeedsAtLeastOneOwner
|
||||||
|
* @throws ChangingRoleOfPlaceholderIsNotAllowed
|
||||||
*/
|
*/
|
||||||
public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void
|
public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void
|
||||||
{
|
{
|
||||||
@@ -82,6 +86,9 @@ class MemberService
|
|||||||
if ($oldRole === Role::Owner) {
|
if ($oldRole === Role::Owner) {
|
||||||
throw new OrganizationNeedsAtLeastOneOwner;
|
throw new OrganizationNeedsAtLeastOneOwner;
|
||||||
}
|
}
|
||||||
|
if ($oldRole === Role::Placeholder) {
|
||||||
|
throw new ChangingRoleOfPlaceholderIsNotAllowed;
|
||||||
|
}
|
||||||
if ($newRole === Role::Placeholder) {
|
if ($newRole === Role::Placeholder) {
|
||||||
throw new ChangingRoleToPlaceholderIsNotAllowed;
|
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.
|
* Change the ownership of an organization to a new user.
|
||||||
* The previous owner will be demoted to an admin.
|
* The previous owner will be demoted to an admin.
|
||||||
@@ -132,7 +172,7 @@ class MemberService
|
|||||||
$member->role = Role::Placeholder->value;
|
$member->role = Role::Placeholder->value;
|
||||||
$member->save();
|
$member->save();
|
||||||
|
|
||||||
$this->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
|
$this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser);
|
||||||
if ($makeSureUserHasAtLeastOneOrganization) {
|
if ($makeSureUserHasAtLeastOneOrganization) {
|
||||||
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
|
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,18 +22,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
|||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int
|
* cost: int|null
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
private array $data;
|
private array $data;
|
||||||
@@ -52,18 +52,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
|||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int
|
* cost: int|null
|
||||||
* } $data
|
* } $data
|
||||||
*/
|
*/
|
||||||
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)
|
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)
|
||||||
|
|||||||
@@ -27,21 +27,21 @@ class TimeEntryAggregationService
|
|||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* 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;
|
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
||||||
$group1Select = null;
|
$group1Select = null;
|
||||||
@@ -96,7 +96,7 @@ class TimeEntryAggregationService
|
|||||||
$group2Response[] = [
|
$group2Response[] = [
|
||||||
'key' => $group2 === '' ? null : (string) $group2,
|
'key' => $group2 === '' ? null : (string) $group2,
|
||||||
'seconds' => (int) $aggregate->get(0)->aggregate,
|
'seconds' => (int) $aggregate->get(0)->aggregate,
|
||||||
'cost' => (int) $aggregate->get(0)->cost,
|
'cost' => $showBillableRate ? (int) $aggregate->get(0)->cost : null,
|
||||||
'grouped_type' => null,
|
'grouped_type' => null,
|
||||||
'grouped_data' => null,
|
'grouped_data' => null,
|
||||||
];
|
];
|
||||||
@@ -113,7 +113,7 @@ class TimeEntryAggregationService
|
|||||||
$group1Response[] = [
|
$group1Response[] = [
|
||||||
'key' => $group1 === '' ? null : (string) $group1,
|
'key' => $group1 === '' ? null : (string) $group1,
|
||||||
'seconds' => $group2ResponseSum,
|
'seconds' => $group2ResponseSum,
|
||||||
'cost' => $group2ResponseCost,
|
'cost' => $showBillableRate ? $group2ResponseCost : null,
|
||||||
'grouped_type' => $group2Type?->value,
|
'grouped_type' => $group2Type?->value,
|
||||||
'grouped_data' => $group2Response,
|
'grouped_data' => $group2Response,
|
||||||
];
|
];
|
||||||
@@ -133,7 +133,7 @@ class TimeEntryAggregationService
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'seconds' => $group1ResponseSum,
|
'seconds' => $group1ResponseSum,
|
||||||
'cost' => $group1ResponseCost,
|
'cost' => $showBillableRate ? $group1ResponseCost : null,
|
||||||
'grouped_type' => $group1Type?->value,
|
'grouped_type' => $group1Type?->value,
|
||||||
'grouped_data' => $group1Response,
|
'grouped_data' => $group1Response,
|
||||||
];
|
];
|
||||||
@@ -148,25 +148,25 @@ class TimeEntryAggregationService
|
|||||||
* description: string|null,
|
* description: string|null,
|
||||||
* color: string|null,
|
* color: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* description: string|null,
|
* description: string|null,
|
||||||
* color: string|null,
|
* color: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* 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 = [];
|
$keysGroup1 = [];
|
||||||
$keysGroup2 = [];
|
$keysGroup2 = [];
|
||||||
@@ -289,12 +289,12 @@ class TimeEntryAggregationService
|
|||||||
* @param array<array{
|
* @param array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: null|mixed,
|
* grouped_type: null|mixed,
|
||||||
* grouped_data: null|mixed
|
* grouped_data: null|mixed
|
||||||
* }>
|
* }>
|
||||||
@@ -302,12 +302,12 @@ class TimeEntryAggregationService
|
|||||||
* @return array<array{
|
* @return array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int,
|
* cost: int|null,
|
||||||
* grouped_type: null|mixed,
|
* grouped_type: null|mixed,
|
||||||
* grouped_data: null|mixed
|
* grouped_data: null|mixed
|
||||||
* }>
|
* }>
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ use App\Models\Organization;
|
|||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
class UserService
|
class UserService
|
||||||
{
|
{
|
||||||
public function createUser(string $name, string $email, string $password, string $timezone, Weekday $weekStart, string $currency): User
|
public function createUser(string $name, string $email, string $password, string $timezone, Weekday $weekStart, string $currency, bool $verifyEmail = false): User
|
||||||
{
|
{
|
||||||
$user = new User;
|
$user = new User;
|
||||||
$user->name = $name;
|
$user->name = $name;
|
||||||
@@ -24,6 +25,9 @@ class UserService
|
|||||||
$user->password = Hash::make($password);
|
$user->password = Hash::make($password);
|
||||||
$user->timezone = $timezone;
|
$user->timezone = $timezone;
|
||||||
$user->week_start = $weekStart;
|
$user->week_start = $weekStart;
|
||||||
|
if ($verifyEmail) {
|
||||||
|
$user->email_verified_at = Carbon::now();
|
||||||
|
}
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
$organization = new Organization;
|
$organization = new Organization;
|
||||||
@@ -45,24 +49,10 @@ class UserService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assign all organization entities (time entries, project members) from one user to another.
|
* This does NOT change the member id.
|
||||||
* This is useful when a placeholder user is replaced with a real user.
|
* 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
|
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
|
// Time entries
|
||||||
TimeEntry::query()
|
TimeEntry::query()
|
||||||
@@ -70,7 +60,6 @@ class UserService
|
|||||||
->whereBelongsTo($fromUser, 'user')
|
->whereBelongsTo($fromUser, 'user')
|
||||||
->update([
|
->update([
|
||||||
'user_id' => $toUser->getKey(),
|
'user_id' => $toUser->getKey(),
|
||||||
'member_id' => $toMember->getKey(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Project members
|
// Project members
|
||||||
@@ -79,7 +68,6 @@ class UserService
|
|||||||
->whereBelongsTo($fromUser, 'user')
|
->whereBelongsTo($fromUser, 'user')
|
||||||
->update([
|
->update([
|
||||||
'user_id' => $toUser->getKey(),
|
'user_id' => $toUser->getKey(),
|
||||||
'member_id' => $toMember->getKey(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
116
composer.lock
generated
116
composer.lock
generated
@@ -128,16 +128,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "aws/aws-sdk-php",
|
"name": "aws/aws-sdk-php",
|
||||||
"version": "3.339.6",
|
"version": "3.339.7",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/aws/aws-sdk-php.git",
|
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||||
"reference": "cc0b21de3b1eaabb7d0a1ed4f3f067bad4be5f31"
|
"reference": "7b7e48ce7970c0416c5fda045df7b93948fbf643"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/cc0b21de3b1eaabb7d0a1ed4f3f067bad4be5f31",
|
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7b7e48ce7970c0416c5fda045df7b93948fbf643",
|
||||||
"reference": "cc0b21de3b1eaabb7d0a1ed4f3f067bad4be5f31",
|
"reference": "7b7e48ce7970c0416c5fda045df7b93948fbf643",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -220,9 +220,9 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||||
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.339.6"
|
"source": "https://github.com/aws/aws-sdk-php/tree/3.339.7"
|
||||||
},
|
},
|
||||||
"time": "2025-02-04T19:03:40+00:00"
|
"time": "2025-02-05T19:06:15+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
@@ -692,16 +692,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "composer/class-map-generator",
|
"name": "composer/class-map-generator",
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/composer/class-map-generator.git",
|
"url": "https://github.com/composer/class-map-generator.git",
|
||||||
"reference": "4b0a223cf5be7c9ee7e0ef1bc7db42b4a97c9915"
|
"reference": "ffe442c5974c44a9343e37a0abcb1cc37319f5b9"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/composer/class-map-generator/zipball/4b0a223cf5be7c9ee7e0ef1bc7db42b4a97c9915",
|
"url": "https://api.github.com/repos/composer/class-map-generator/zipball/ffe442c5974c44a9343e37a0abcb1cc37319f5b9",
|
||||||
"reference": "4b0a223cf5be7c9ee7e0ef1bc7db42b4a97c9915",
|
"reference": "ffe442c5974c44a9343e37a0abcb1cc37319f5b9",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -745,7 +745,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/composer/class-map-generator/issues",
|
"issues": "https://github.com/composer/class-map-generator/issues",
|
||||||
"source": "https://github.com/composer/class-map-generator/tree/1.5.0"
|
"source": "https://github.com/composer/class-map-generator/tree/1.6.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -761,7 +761,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2024-11-25T16:11:06+00:00"
|
"time": "2025-02-05T10:05:34+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "composer/composer",
|
"name": "composer/composer",
|
||||||
@@ -1519,16 +1519,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dedoc/scramble",
|
"name": "dedoc/scramble",
|
||||||
"version": "v0.12.2",
|
"version": "v0.12.5",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/dedoc/scramble.git",
|
"url": "https://github.com/dedoc/scramble.git",
|
||||||
"reference": "9482d3c6285ebb16a067bd28b01e94899e1fddd8"
|
"reference": "de055584b61338dd6de6cc1cc20d79da6a442230"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/dedoc/scramble/zipball/9482d3c6285ebb16a067bd28b01e94899e1fddd8",
|
"url": "https://api.github.com/repos/dedoc/scramble/zipball/de055584b61338dd6de6cc1cc20d79da6a442230",
|
||||||
"reference": "9482d3c6285ebb16a067bd28b01e94899e1fddd8",
|
"reference": "de055584b61338dd6de6cc1cc20d79da6a442230",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1583,7 +1583,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/dedoc/scramble/issues",
|
"issues": "https://github.com/dedoc/scramble/issues",
|
||||||
"source": "https://github.com/dedoc/scramble/tree/v0.12.2"
|
"source": "https://github.com/dedoc/scramble/tree/v0.12.5"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1591,7 +1591,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-02-03T12:23:33+00:00"
|
"time": "2025-02-05T11:22:52+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "defuse/php-encryption",
|
"name": "defuse/php-encryption",
|
||||||
@@ -2249,16 +2249,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/actions",
|
"name": "filament/actions",
|
||||||
"version": "v3.2.136",
|
"version": "v3.2.137",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/actions.git",
|
"url": "https://github.com/filamentphp/actions.git",
|
||||||
"reference": "cdefacc18993050cdd37e8e980ec66ca4109ae9a"
|
"reference": "0eee8f8eeea4422c279c2622442b2a9a101d558c"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/filamentphp/actions/zipball/cdefacc18993050cdd37e8e980ec66ca4109ae9a",
|
"url": "https://api.github.com/repos/filamentphp/actions/zipball/0eee8f8eeea4422c279c2622442b2a9a101d558c",
|
||||||
"reference": "cdefacc18993050cdd37e8e980ec66ca4109ae9a",
|
"reference": "0eee8f8eeea4422c279c2622442b2a9a101d558c",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2298,20 +2298,20 @@
|
|||||||
"issues": "https://github.com/filamentphp/filament/issues",
|
"issues": "https://github.com/filamentphp/filament/issues",
|
||||||
"source": "https://github.com/filamentphp/filament"
|
"source": "https://github.com/filamentphp/filament"
|
||||||
},
|
},
|
||||||
"time": "2025-01-31T11:08:24+00:00"
|
"time": "2025-02-06T11:47:20+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/filament",
|
"name": "filament/filament",
|
||||||
"version": "v3.2.136",
|
"version": "v3.2.137",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/panels.git",
|
"url": "https://github.com/filamentphp/panels.git",
|
||||||
"reference": "c92daf4b6e4b478be5d32d5e1b404ba92bb45414"
|
"reference": "407b52ee57feed6f67706af2a18bc81d32aea3f1"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/filamentphp/panels/zipball/c92daf4b6e4b478be5d32d5e1b404ba92bb45414",
|
"url": "https://api.github.com/repos/filamentphp/panels/zipball/407b52ee57feed6f67706af2a18bc81d32aea3f1",
|
||||||
"reference": "c92daf4b6e4b478be5d32d5e1b404ba92bb45414",
|
"reference": "407b52ee57feed6f67706af2a18bc81d32aea3f1",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2363,20 +2363,20 @@
|
|||||||
"issues": "https://github.com/filamentphp/filament/issues",
|
"issues": "https://github.com/filamentphp/filament/issues",
|
||||||
"source": "https://github.com/filamentphp/filament"
|
"source": "https://github.com/filamentphp/filament"
|
||||||
},
|
},
|
||||||
"time": "2025-01-31T11:08:29+00:00"
|
"time": "2025-02-06T11:47:25+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/forms",
|
"name": "filament/forms",
|
||||||
"version": "v3.2.136",
|
"version": "v3.2.137",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/forms.git",
|
"url": "https://github.com/filamentphp/forms.git",
|
||||||
"reference": "8856b3b3714a0efae65d9f817fcc1934b6f34fda"
|
"reference": "6ab0419fd3599aacdd7b2a8f69641cab6f158674"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/filamentphp/forms/zipball/8856b3b3714a0efae65d9f817fcc1934b6f34fda",
|
"url": "https://api.github.com/repos/filamentphp/forms/zipball/6ab0419fd3599aacdd7b2a8f69641cab6f158674",
|
||||||
"reference": "8856b3b3714a0efae65d9f817fcc1934b6f34fda",
|
"reference": "6ab0419fd3599aacdd7b2a8f69641cab6f158674",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2419,11 +2419,11 @@
|
|||||||
"issues": "https://github.com/filamentphp/filament/issues",
|
"issues": "https://github.com/filamentphp/filament/issues",
|
||||||
"source": "https://github.com/filamentphp/filament"
|
"source": "https://github.com/filamentphp/filament"
|
||||||
},
|
},
|
||||||
"time": "2025-01-31T11:08:23+00:00"
|
"time": "2025-02-06T11:47:21+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/infolists",
|
"name": "filament/infolists",
|
||||||
"version": "v3.2.136",
|
"version": "v3.2.137",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/infolists.git",
|
"url": "https://github.com/filamentphp/infolists.git",
|
||||||
@@ -2474,7 +2474,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/notifications",
|
"name": "filament/notifications",
|
||||||
"version": "v3.2.136",
|
"version": "v3.2.137",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/notifications.git",
|
"url": "https://github.com/filamentphp/notifications.git",
|
||||||
@@ -2526,7 +2526,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/support",
|
"name": "filament/support",
|
||||||
"version": "v3.2.136",
|
"version": "v3.2.137",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/support.git",
|
"url": "https://github.com/filamentphp/support.git",
|
||||||
@@ -2585,16 +2585,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/tables",
|
"name": "filament/tables",
|
||||||
"version": "v3.2.136",
|
"version": "v3.2.137",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/tables.git",
|
"url": "https://github.com/filamentphp/tables.git",
|
||||||
"reference": "1b1d9c3b837c11408ad28240dc9e3e33340d870b"
|
"reference": "d420b0b0cbe605c712ab054a43d51baf5095dc87"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/filamentphp/tables/zipball/1b1d9c3b837c11408ad28240dc9e3e33340d870b",
|
"url": "https://api.github.com/repos/filamentphp/tables/zipball/d420b0b0cbe605c712ab054a43d51baf5095dc87",
|
||||||
"reference": "1b1d9c3b837c11408ad28240dc9e3e33340d870b",
|
"reference": "d420b0b0cbe605c712ab054a43d51baf5095dc87",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2633,11 +2633,11 @@
|
|||||||
"issues": "https://github.com/filamentphp/filament/issues",
|
"issues": "https://github.com/filamentphp/filament/issues",
|
||||||
"source": "https://github.com/filamentphp/filament"
|
"source": "https://github.com/filamentphp/filament"
|
||||||
},
|
},
|
||||||
"time": "2025-01-31T11:08:58+00:00"
|
"time": "2025-02-06T11:47:34+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/widgets",
|
"name": "filament/widgets",
|
||||||
"version": "v3.2.136",
|
"version": "v3.2.137",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/widgets.git",
|
"url": "https://github.com/filamentphp/widgets.git",
|
||||||
@@ -9112,27 +9112,27 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "spatie/laravel-package-tools",
|
"name": "spatie/laravel-package-tools",
|
||||||
"version": "1.18.3",
|
"version": "1.19.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/spatie/laravel-package-tools.git",
|
"url": "https://github.com/spatie/laravel-package-tools.git",
|
||||||
"reference": "ba67eee37d86ed775dab7dad58a7cbaf9a6cfe78"
|
"reference": "1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/ba67eee37d86ed775dab7dad58a7cbaf9a6cfe78",
|
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa",
|
||||||
"reference": "ba67eee37d86ed775dab7dad58a7cbaf9a6cfe78",
|
"reference": "1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"illuminate/contracts": "^9.28|^10.0|^11.0",
|
"illuminate/contracts": "^9.28|^10.0|^11.0|^12.0",
|
||||||
"php": "^8.0"
|
"php": "^8.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"mockery/mockery": "^1.5",
|
"mockery/mockery": "^1.5",
|
||||||
"orchestra/testbench": "^7.7|^8.0|^9.0",
|
"orchestra/testbench": "^7.7|^8.0|^9.0|^10.0",
|
||||||
"pestphp/pest": "^1.22|^2",
|
"pestphp/pest": "^1.23|^2.1|^3.1",
|
||||||
"phpunit/phpunit": "^9.5.24|^10.5",
|
"phpunit/phpunit": "^9.5.24|^10.5|^11.5",
|
||||||
"spatie/pest-plugin-test-time": "^1.1|^2.2"
|
"spatie/pest-plugin-test-time": "^1.1|^2.2"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
@@ -9160,7 +9160,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/spatie/laravel-package-tools/issues",
|
"issues": "https://github.com/spatie/laravel-package-tools/issues",
|
||||||
"source": "https://github.com/spatie/laravel-package-tools/tree/1.18.3"
|
"source": "https://github.com/spatie/laravel-package-tools/tree/1.19.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -9168,7 +9168,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-01-22T08:51:18+00:00"
|
"time": "2025-02-06T14:58:20+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "spatie/regex",
|
"name": "spatie/regex",
|
||||||
@@ -14188,16 +14188,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/phpunit",
|
"name": "phpunit/phpunit",
|
||||||
"version": "11.5.6",
|
"version": "11.5.7",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||||
"reference": "3c3ae14c90f244cdda95028c3e469028e8d1c02c"
|
"reference": "e1cb706f019e2547039ca2c839898cd5f557ee5d"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3c3ae14c90f244cdda95028c3e469028e8d1c02c",
|
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e1cb706f019e2547039ca2c839898cd5f557ee5d",
|
||||||
"reference": "3c3ae14c90f244cdda95028c3e469028e8d1c02c",
|
"reference": "e1cb706f019e2547039ca2c839898cd5f557ee5d",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -14269,7 +14269,7 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.6"
|
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.7"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -14285,7 +14285,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-01-31T07:03:30+00:00"
|
"time": "2025-02-06T16:10:05+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/cli-parser",
|
"name": "sebastian/cli-parser",
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
'secure' => env('SESSION_SECURE_COOKIE', env('APP_FORCE_HTTPS')),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
55
database/factories/Passport/ClientFactory.php
Normal file
55
database/factories/Passport/ClientFactory.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories\Passport;
|
||||||
|
|
||||||
|
use App\Models\Passport\Client;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Client>
|
||||||
|
*/
|
||||||
|
class ClientFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->faker->uuid,
|
||||||
|
'user_id' => null,
|
||||||
|
'name' => $this->faker->company(),
|
||||||
|
'secret' => $this->faker->regexify('[A-Za-z]{40}'),
|
||||||
|
'provider' => 'users',
|
||||||
|
'redirect' => $this->faker->url(),
|
||||||
|
'personal_access_client' => false,
|
||||||
|
'password_client' => false,
|
||||||
|
'revoked' => false,
|
||||||
|
'created_at' => $this->faker->dateTime(),
|
||||||
|
'updated_at' => $this->faker->dateTime(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function personalAccessClient(): self
|
||||||
|
{
|
||||||
|
return $this->state(function (array $attributes) {
|
||||||
|
return [
|
||||||
|
'personal_access_client' => true,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forUser(User $user): self
|
||||||
|
{
|
||||||
|
return $this->state(function (array $attributes) use ($user): array {
|
||||||
|
return [
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
54
database/factories/Passport/TokenFactory.php
Normal file
54
database/factories/Passport/TokenFactory.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories\Passport;
|
||||||
|
|
||||||
|
use App\Models\Passport\Client;
|
||||||
|
use App\Models\Passport\Token;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Token>
|
||||||
|
*/
|
||||||
|
class TokenFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->faker->uuid,
|
||||||
|
'user_id' => null,
|
||||||
|
'client_id' => $this->faker->uuid,
|
||||||
|
'name' => null,
|
||||||
|
'scopes' => [],
|
||||||
|
'revoked' => false,
|
||||||
|
'created_at' => $this->faker->dateTime,
|
||||||
|
'updated_at' => $this->faker->dateTime,
|
||||||
|
'expires_at' => $this->faker->dateTime,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forUser(User $user): self
|
||||||
|
{
|
||||||
|
return $this->state(function (array $attributes) use ($user): array {
|
||||||
|
return [
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forClient(Client $client): self
|
||||||
|
{
|
||||||
|
return $this->state(function (array $attributes) use ($client): array {
|
||||||
|
return [
|
||||||
|
'client_id' => $client->getKey(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,29 @@ class DatabaseSeeder extends Seeder
|
|||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$this->deleteAll();
|
$this->deleteAll();
|
||||||
|
|
||||||
|
app(ClientRepository::class)->create(
|
||||||
|
null,
|
||||||
|
'desktop',
|
||||||
|
'solidtime://oauth/callback',
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
$personalAccessClient = new PassportClient;
|
||||||
|
$personalAccessClient->id = config('passport.personal_access_client.id');
|
||||||
|
$personalAccessClient->secret = config('passport.personal_access_client.secret');
|
||||||
|
$personalAccessClient->name = 'API';
|
||||||
|
$personalAccessClient->redirect = 'http://localhost';
|
||||||
|
$personalAccessClient->user_id = null;
|
||||||
|
$personalAccessClient->revoked = false;
|
||||||
|
$personalAccessClient->provider = null;
|
||||||
|
$personalAccessClient->personal_access_client = true;
|
||||||
|
$personalAccessClient->password_client = false;
|
||||||
|
$personalAccessClient->save();
|
||||||
|
|
||||||
$userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([
|
$userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([
|
||||||
'name' => 'Mister Overemployed',
|
'name' => 'Mister Overemployed',
|
||||||
'email' => 'overemployed@acme.test',
|
'email' => 'overemployed@acme.test',
|
||||||
@@ -55,6 +78,8 @@ class DatabaseSeeder extends Seeder
|
|||||||
'name' => 'Acme Manager',
|
'name' => 'Acme Manager',
|
||||||
'email' => 'test@example.com',
|
'email' => 'test@example.com',
|
||||||
]);
|
]);
|
||||||
|
$userAcmeManager->createToken('Testing Token 1')->accessToken;
|
||||||
|
$userAcmeManager->createToken('Testing Token 2')->accessToken;
|
||||||
$userAcmeAdmin = User::factory()->withPersonalOrganization()->create([
|
$userAcmeAdmin = User::factory()->withPersonalOrganization()->create([
|
||||||
'name' => 'Acme Admin',
|
'name' => 'Acme Admin',
|
||||||
'email' => 'admin@acme.test',
|
'email' => 'admin@acme.test',
|
||||||
@@ -159,15 +184,6 @@ class DatabaseSeeder extends Seeder
|
|||||||
'email' => 'admin@example.com',
|
'email' => 'admin@example.com',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
app(ClientRepository::class)->create(
|
|
||||||
null,
|
|
||||||
'desktop',
|
|
||||||
'solidtime://oauth/callback',
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function deleteAll(): void
|
private function deleteAll(): void
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ services:
|
|||||||
- sail
|
- sail
|
||||||
- reverse-proxy
|
- reverse-proxy
|
||||||
playwright:
|
playwright:
|
||||||
image: mcr.microsoft.com/playwright:v1.46.1-jammy
|
image: mcr.microsoft.com/playwright:v1.50.0-jammy
|
||||||
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
||||||
working_dir: /src
|
working_dir: /src
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/ph
|
|||||||
# && composer clear-cache
|
# && composer clear-cache
|
||||||
|
|
||||||
RUN cat .env
|
RUN cat .env
|
||||||
RUN php artisan env
|
#RUN php artisan env
|
||||||
RUN php artisan storage:link
|
RUN php artisan storage:link
|
||||||
|
|
||||||
RUN chmod +x /usr/local/bin/start-container
|
RUN chmod +x /usr/local/bin/start-container
|
||||||
|
|||||||
@@ -1,25 +1,69 @@
|
|||||||
import { test, expect } from '../playwright/fixtures';
|
import {test, expect} from '../playwright/fixtures';
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import {PLAYWRIGHT_BASE_URL} from '../playwright/config';
|
||||||
|
|
||||||
test('test that user name can be updated', async ({ page }) => {
|
test('test that user name can be updated', async ({page}) => {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
await page.getByLabel('Name').fill('NEW NAME');
|
await page.getByLabel('Name', {exact: true} ).fill('NEW NAME');
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.getByRole('button', { name: 'Save' }).first().click(),
|
page.getByRole('button', {name: 'Save'}).first().click(),
|
||||||
page.waitForResponse('**/user/profile-information'),
|
page.waitForResponse('**/user/profile-information'),
|
||||||
]);
|
]);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByLabel('Name')).toHaveValue('NEW NAME');
|
await expect(page.getByLabel('Name', {exact: true})).toHaveValue('NEW NAME');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip('test that user email can be updated', async ({ page }) => {
|
test.skip('test that user email can be updated', async ({page}) => {
|
||||||
// this does not work because of email verification currently
|
// this does not work because of email verification currently
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
const emailId = Math.round(Math.random() * 10000);
|
const emailId = Math.round(Math.random() * 10000);
|
||||||
await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);
|
await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);
|
||||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
await page.getByRole('button', {name: 'Save'}).first().click();
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByLabel('Email')).toHaveValue(
|
await expect(page.getByLabel('Email')).toHaveValue(
|
||||||
`newemail+${emailId}@test.com`
|
`newemail+${emailId}@test.com`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function createNewApiToken(page) {
|
||||||
|
await page.getByLabel('API Key Name').fill('NEW API KEY');
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', {name: 'Create API Key'}).click(),
|
||||||
|
page.waitForResponse('**/users/me/api-tokens')
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.locator('body')).toContainText('API Token created successfully');
|
||||||
|
await page.getByRole('dialog').getByText('Close').click();
|
||||||
|
await expect(page.locator('body')).toContainText('NEW API KEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that user can create an API key', async ({page}) => {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
|
await createNewApiToken(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that user can delete an API key', async ({page}) => {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
|
await createNewApiToken(page);
|
||||||
|
page.getByLabel('Delete API Token NEW API KEY').click();
|
||||||
|
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to delete this API token?');
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('dialog').getByRole('button', {name: 'Delete'}).click(),
|
||||||
|
page.waitForResponse('**/users/me/api-tokens')
|
||||||
|
]);
|
||||||
|
await expect(page.locator('body')).not.toContainText('NEW API KEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('test that user can revoke an API key', async ({page}) => {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
|
await createNewApiToken(page);
|
||||||
|
page.getByLabel('Revoke API Token NEW API KEY').click();
|
||||||
|
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to revoke this API token?');
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('dialog').getByRole('button', {name: 'Revoke'}).click(),
|
||||||
|
page.waitForResponse('**/users/me/api-tokens')
|
||||||
|
]);
|
||||||
|
await expect(page.getByRole('button', {name: 'Revoke'})).toBeHidden();
|
||||||
|
await expect(page.locator('body')).toContainText('NEW API KEY');
|
||||||
|
await expect(page.locator('body')).toContainText('Revoked');
|
||||||
|
});
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ test('test that updating a the start of an existing time entry in the overview w
|
|||||||
'time_entry_range_selector'
|
'time_entry_range_selector'
|
||||||
);
|
);
|
||||||
await timeEntryRangeElement.click();
|
await timeEntryRangeElement.click();
|
||||||
await page.getByTestId('time_picker_input').first().fill('1');
|
await page.getByTestId('time_entry_range_start').first().fill('1');
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForResponse(async (response) => {
|
page.waitForResponse(async (response) => {
|
||||||
return (
|
return (
|
||||||
@@ -204,10 +204,7 @@ test('test that updating a the start of an existing time entry in the overview w
|
|||||||
(await response.json()).data.end !== null
|
(await response.json()).data.end !== null
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
page
|
page.getByTestId('time_entry_range_end').press('Enter'),
|
||||||
.getByTestId('time_entry_range_end')
|
|
||||||
.getByTestId('time_picker_input')
|
|
||||||
.press('Enter'),
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ test('test that starting and updating the time while running works', async ({
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForResponse(async (response) => {
|
page.waitForResponse(async (response) => {
|
||||||
return (
|
return (
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
response.status() === 200 &&
|
response.status() === 200 &&
|
||||||
(await response.headerValue('Content-Type')) ===
|
(await response.headerValue('Content-Type')) ===
|
||||||
'application/json' &&
|
'application/json' &&
|
||||||
@@ -152,7 +153,7 @@ test('test that starting and updating the time while running works', async ({
|
|||||||
JSON.stringify([])
|
JSON.stringify([])
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
page.getByTestId('time_entry_time').press('Tab'),
|
page.getByTestId('time_entry_time').press('Enter'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);
|
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function newTimeEntryResponse(
|
|||||||
) {
|
) {
|
||||||
return page.waitForResponse(async (response) => {
|
return page.waitForResponse(async (response) => {
|
||||||
return (
|
return (
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
response.status() === status &&
|
response.status() === status &&
|
||||||
(await response.headerValue('Content-Type')) ===
|
(await response.headerValue('Content-Type')) ===
|
||||||
'application/json' &&
|
'application/json' &&
|
||||||
|
|||||||
36
eslint.config.mjs
Normal file
36
eslint.config.mjs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||||
|
import eslintPluginVue from 'eslint-plugin-vue';
|
||||||
|
import globals from 'globals';
|
||||||
|
import typescriptEslint from 'typescript-eslint';
|
||||||
|
import unusedImports from "eslint-plugin-unused-imports";
|
||||||
|
|
||||||
|
export default typescriptEslint.config(
|
||||||
|
{ ignores: ['*.d.ts', '**/coverage', '**/dist'] },
|
||||||
|
{
|
||||||
|
extends: [
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...typescriptEslint.configs.recommended,
|
||||||
|
...eslintPluginVue.configs['flat/recommended'],
|
||||||
|
],
|
||||||
|
files: ['**/*.{ts,vue,js}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
parser: typescriptEslint.parser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"unused-imports": unusedImports,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
"unused-imports/no-unused-imports": "error",
|
||||||
|
"unused-imports/no-unused-vars": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eslintConfigPrettier
|
||||||
|
);
|
||||||
@@ -4,14 +4,18 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||||
|
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||||
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
||||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||||
|
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
||||||
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
|
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||||
|
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
@@ -37,6 +41,10 @@ return [
|
|||||||
OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members',
|
OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members',
|
||||||
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
|
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
|
||||||
FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan',
|
FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan',
|
||||||
|
PersonalAccessClientIsNotConfiguredException::KEY => 'Personal access client is not configured',
|
||||||
|
ChangingRoleOfPlaceholderIsNotAllowed::KEY => 'Changing role of placeholder is not allowed',
|
||||||
|
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
|
||||||
|
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
|
||||||
],
|
],
|
||||||
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,18 +5,28 @@ declare(strict_types=1);
|
|||||||
return [
|
return [
|
||||||
'clockify_time_entries' => [
|
'clockify_time_entries' => [
|
||||||
'name' => 'Clockify Time Entries',
|
'name' => 'Clockify Time Entries',
|
||||||
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br> '.
|
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
|
||||||
'2. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
|
'2. In the same preferences page change the language of Clockfiy to English.<br>'.
|
||||||
'3. Now select the date range that you want to export in the right top. '.
|
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
|
||||||
|
'4. Now select the date range that you want to export in the right top. '.
|
||||||
'It is currently not possible to select more than one year. You can export each year separately and import them one after another .'.
|
'It is currently not possible to select more than one year. You can export each year separately and import them one after another .'.
|
||||||
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
|
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
|
||||||
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
|
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
|
||||||
],
|
],
|
||||||
|
'generic_projects' => [
|
||||||
|
'name' => 'Generic Projects',
|
||||||
|
'description' => 'If you want to import many projects yourself this importer the right choice. Please see our docs for <a href="https://docs.solidtime.io/user-guide/import">more information about the CSV structure</a>',
|
||||||
|
],
|
||||||
|
'generic_time_entries' => [
|
||||||
|
'name' => 'Generic Time Entries',
|
||||||
|
'description' => 'If you want to import many time entries yourself this importer the right choice. Please see our docs for <a href="https://docs.solidtime.io/user-guide/import">more information about the CSV structure</a>',
|
||||||
|
],
|
||||||
'clockify_projects' => [
|
'clockify_projects' => [
|
||||||
'name' => 'Clockify Projects',
|
'name' => 'Clockify Projects',
|
||||||
'description' => '1. Go to PROJECTS in the navigation on the left.<br> '.
|
'description' => '1. Make sure to set the language of Clockify to English in "Preferences -> General".<br>'.
|
||||||
'2. Now click on the three dots on the right of the project that you want to export and select Export.<br> '.
|
'2. Go to PROJECTS in the navigation on the left.<br> '.
|
||||||
'3. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table in the top right corner.',
|
'3. Now click on the three dots on the right of the project that you want to export and select Export.<br> '.
|
||||||
|
'4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table in the top right corner.',
|
||||||
],
|
],
|
||||||
'toggl_data_importer' => [
|
'toggl_data_importer' => [
|
||||||
'name' => 'Toggl Data Importer',
|
'name' => 'Toggl Data Importer',
|
||||||
@@ -36,4 +46,22 @@ return [
|
|||||||
'name' => 'Solidtime',
|
'name' => 'Solidtime',
|
||||||
'description' => '1. Choose the organization you want to export in dropdown in the left top corner<br>2. Click on "Export" in the left navigation under "Admin" (You need to be Admin or Owner of the organization to see this)<br>3. Click on "Export". <br>4. Save the file and upload it here.',
|
'description' => '1. Choose the organization you want to export in dropdown in the left top corner<br>2. Click on "Export" in the left navigation under "Admin" (You need to be Admin or Owner of the organization to see this)<br>3. Click on "Export". <br>4. Save the file and upload it here.',
|
||||||
],
|
],
|
||||||
|
'harvest_clients' => [
|
||||||
|
'name' => 'Harvest Clients',
|
||||||
|
'description' => '1. Go to "Manage" (top navigation)<br>2. Click on the "Clients"'.
|
||||||
|
'<br>3. Click on "Import/Export" and in the dropdown "Export clients to CSV" '.
|
||||||
|
'<br>',
|
||||||
|
],
|
||||||
|
'harvest_projects' => [
|
||||||
|
'name' => 'Harvest Projects',
|
||||||
|
'description' => '1. Go to "Projects" (top navigation)<br>2. Click on the "Export" button'.
|
||||||
|
'<br>3. Select which projects you would like to export and select CSV format '.
|
||||||
|
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
|
||||||
|
],
|
||||||
|
'harvest_time_entries' => [
|
||||||
|
'name' => 'Harvest Time Entries',
|
||||||
|
'description' => '1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
|
||||||
|
'<br>3. Now click on "Export all time" '.
|
||||||
|
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
3424
package-lock.json
generated
3424
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -4,53 +4,60 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint --ext .js,.vue,.ts --ignore-path .gitignore resources/js",
|
"lint": "eslint resources/js",
|
||||||
"lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore resources/js",
|
"lint:fix": "eslint --fix resources/js",
|
||||||
"type-check": "vue-tsc --noEmit",
|
"type-check": "vue-tsc --noEmit",
|
||||||
"test:e2e": "rm -rf test-results/.auth && npx playwright test",
|
"test:e2e": "rm -rf test-results/.auth && npx playwright test",
|
||||||
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api"
|
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.19.0",
|
||||||
"@inertiajs/vue3": "^1.0.0",
|
"@inertiajs/vue3": "^1.0.0",
|
||||||
"@playwright/test": "^1.41.1",
|
"@playwright/test": "^1.41.1",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/node": "^20.11.5",
|
"@types/node": "^22.10.10",
|
||||||
"@vitejs/plugin-vue": "^4.5.0",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"@vue/tsconfig": "^0.5.1",
|
"@vue/tsconfig": "^0.5.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"axios": "^1.6.4",
|
"axios": "^1.6.4",
|
||||||
"eslint-plugin-unused-imports": "^3.1.0",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"laravel-vite-plugin": "^1.0.0",
|
"laravel-vite-plugin": "^1.0.0",
|
||||||
"openapi-zod-client": "^1.16.2",
|
"openapi-zod-client": "^1.16.2",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"postcss-nesting": "^12.1.5",
|
"postcss-nesting": "^12.1.5",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^5.0.0",
|
"vite": "^6.0.11",
|
||||||
"vite-plugin-checker": "^0.7.2",
|
"vite-plugin-checker": "^0.8.0",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.5.0",
|
||||||
"vue-tsc": "^2.0.28"
|
"vue-tsc": "^2.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.6.0",
|
"@floating-ui/core": "^1.6.0",
|
||||||
"@floating-ui/vue": "^1.0.6",
|
"@floating-ui/vue": "^1.0.6",
|
||||||
"@heroicons/vue": "^2.1.1",
|
"@heroicons/vue": "^2.1.1",
|
||||||
"@rushstack/eslint-patch": "^1.7.0",
|
"@rushstack/eslint-patch": "^1.10.5",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tanstack/vue-query": "^5.56.2",
|
"@tanstack/vue-query": "^5.56.2",
|
||||||
"@tanstack/vue-query-devtools": "^5.58.0",
|
"@tanstack/vue-query-devtools": "^5.58.0",
|
||||||
"@vue/eslint-config-prettier": "^9.0.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^13.0.0",
|
"@vue/eslint-config-typescript": "^14.3.0",
|
||||||
"@vueuse/core": "^10.11.0",
|
"@vueuse/core": "^12.5.0",
|
||||||
"@vueuse/integrations": "^11.1.0",
|
"@vueuse/integrations": "^12.5.0",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"echarts": "^5.5.0",
|
"echarts": "^5.5.0",
|
||||||
"focus-trap": "^7.6.0",
|
"focus-trap": "^7.6.0",
|
||||||
"parse-duration": "^1.1.0",
|
"parse-duration": "^2.0.1",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"radix-vue": "^1.9.6",
|
"radix-vue": "^1.9.6",
|
||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.2.1",
|
||||||
"vue-echarts": "^6.7.2"
|
"vue-echarts": "^7.0.3"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"vite-plugin-checker": {
|
||||||
|
"vue-tsc": "$vue-tsc"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
|
<env name="APP_FORCE_HTTPS" value="false"/>
|
||||||
|
<env name="TRUSTED_PROXIES" value="0.0.0.0/0,2000:0:0:0:0:0:0:0/3"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="CACHE_DRIVER" value="array"/>
|
<env name="CACHE_DRIVER" value="array"/>
|
||||||
<env name="DB_CONNECTION" value="pgsql_test"/>
|
<env name="DB_CONNECTION" value="pgsql_test"/>
|
||||||
@@ -39,5 +41,7 @@
|
|||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
<env name="AUDITING_ENABLED" value="true"/>
|
<env name="AUDITING_ENABLED" value="true"/>
|
||||||
<env name="NEWSLETTER_URL" value="null"/>
|
<env name="NEWSLETTER_URL" value="null"/>
|
||||||
|
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
|
||||||
|
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ const showBlackFridayBanner = computed(() => {
|
|||||||
<span>Upgrade now</span>
|
<span>Upgrade now</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<button @click="hideBlackFridayBanner = true" class="p-1">
|
<button class="p-1" @click="hideBlackFridayBanner = true">
|
||||||
<XMarkIcon
|
<XMarkIcon
|
||||||
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||||
</button>
|
</button>
|
||||||
@@ -142,7 +142,7 @@ const showBlackFridayBanner = computed(() => {
|
|||||||
<span>Upgrade now</span>
|
<span>Upgrade now</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<button @click="hideTrialBanner = true" class="p-1">
|
<button class="p-1" @click="hideTrialBanner = true">
|
||||||
<XMarkIcon
|
<XMarkIcon
|
||||||
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||||
</button>
|
</button>
|
||||||
@@ -174,7 +174,7 @@ const showBlackFridayBanner = computed(() => {
|
|||||||
<span>Upgrade now</span>
|
<span>Upgrade now</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<button @click="hideBlockedBanner = true" class="p-1">
|
<button class="p-1" @click="hideBlockedBanner = true">
|
||||||
<XMarkIcon
|
<XMarkIcon
|
||||||
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||||
</button>
|
</button>
|
||||||
@@ -206,7 +206,7 @@ const showBlackFridayBanner = computed(() => {
|
|||||||
<span>Upgrade now</span>
|
<span>Upgrade now</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<button @click="hideFreeUpgradeBanner = true" class="p-1">
|
<button class="p-1" @click="hideFreeUpgradeBanner = true">
|
||||||
<XMarkIcon
|
<XMarkIcon
|
||||||
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ useFocus(clientNameInput, { initialValue: true });
|
|||||||
v-model="client.name"
|
v-model="client.name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Client Name"
|
placeholder="Client Name"
|
||||||
@keydown.enter="submit"
|
|
||||||
class="mt-1 block w-full"
|
class="mt-1 block w-full"
|
||||||
required
|
required
|
||||||
autocomplete="clientName" />
|
autocomplete="clientName"
|
||||||
|
@keydown.enter="submit" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ useFocus(clientNameInput, { initialValue: true });
|
|||||||
v-model="clientBody.name"
|
v-model="clientBody.name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Client Name"
|
placeholder="Client Name"
|
||||||
@keydown.enter="submit"
|
|
||||||
class="mt-1 block w-full"
|
class="mt-1 block w-full"
|
||||||
required
|
required
|
||||||
autocomplete="clientName" />
|
autocomplete="clientName"
|
||||||
|
@keydown.enter="submit" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -23,28 +23,28 @@ const props = defineProps<{
|
|||||||
<div class="min-w-[150px]">
|
<div class="min-w-[150px]">
|
||||||
<button
|
<button
|
||||||
v-if="canUpdateClients()"
|
v-if="canUpdateClients()"
|
||||||
@click="emit('edit')"
|
|
||||||
:aria-label="'Edit Client ' + props.client.name"
|
:aria-label="'Edit Client ' + props.client.name"
|
||||||
data-testid="client_edit"
|
data-testid="client_edit"
|
||||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
|
||||||
|
@click="emit('edit')">
|
||||||
<PencilSquareIcon
|
<PencilSquareIcon
|
||||||
class="w-5 text-icon-active"></PencilSquareIcon>
|
class="w-5 text-icon-active"></PencilSquareIcon>
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click.prevent="emit('archive')"
|
|
||||||
v-if="canUpdateClients()"
|
v-if="canUpdateClients()"
|
||||||
:aria-label="'Archive Client ' + props.client.name"
|
:aria-label="'Archive Client ' + props.client.name"
|
||||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
|
||||||
|
@click.prevent="emit('archive')">
|
||||||
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon>
|
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon>
|
||||||
<span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span>
|
<span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="canDeleteClients()"
|
v-if="canDeleteClients()"
|
||||||
@click="emit('delete')"
|
|
||||||
:aria-label="'Delete Client ' + props.client.name"
|
:aria-label="'Delete Client ' + props.client.name"
|
||||||
data-testid="client_delete"
|
data-testid="client_delete"
|
||||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
|
||||||
|
@click="emit('delete')">
|
||||||
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
||||||
<span>Delete</span>
|
<span>Delete</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function getNameForItem(item: Client) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<MultiselectDropdown
|
<MultiselectDropdown
|
||||||
searchPlaceholder="Search for a Client..."
|
search-placeholder="Search for a Client..."
|
||||||
:items="clients"
|
:items="clients"
|
||||||
:get-key-from-item="getKeyFromItem"
|
:get-key-from-item="getKeyFromItem"
|
||||||
:get-name-for-item="getNameForItem">
|
:get-name-for-item="getNameForItem">
|
||||||
|
|||||||
@@ -25,18 +25,18 @@ const createClient = ref(false);
|
|||||||
style="grid-template-columns: 1fr 150px 200px 80px">
|
style="grid-template-columns: 1fr 150px 200px 80px">
|
||||||
<ClientTableHeading></ClientTableHeading>
|
<ClientTableHeading></ClientTableHeading>
|
||||||
<div
|
<div
|
||||||
class="col-span-2 py-24 text-center"
|
v-if="clients.length === 0"
|
||||||
v-if="clients.length === 0">
|
class="col-span-2 py-24 text-center">
|
||||||
<UserCircleIcon
|
<UserCircleIcon
|
||||||
class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
|
class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
|
||||||
<h3 class="text-white font-semibold">No clients found</h3>
|
<h3 class="text-white font-semibold">No clients found</h3>
|
||||||
<p class="pb-5" v-if="canCreateClients()">
|
<p v-if="canCreateClients()" class="pb-5">
|
||||||
Create your first client now!
|
Create your first client now!
|
||||||
</p>
|
</p>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
v-if="canCreateClients()"
|
v-if="canCreateClients()"
|
||||||
@click="createClient = true"
|
|
||||||
:icon="PlusIcon as Component"
|
:icon="PlusIcon as Component"
|
||||||
|
@click="createClient = true"
|
||||||
>Create your First Client
|
>Create your First Client
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ const showEditModal = ref(false);
|
|||||||
<template>
|
<template>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<ClientEditModal
|
<ClientEditModal
|
||||||
:client="client"
|
v-model:show="showEditModal"
|
||||||
v-model:show="showEditModal"></ClientEditModal>
|
:client="client"></ClientEditModal>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ const emit = defineEmits<{
|
|||||||
<template>
|
<template>
|
||||||
<MoreOptionsDropdown label="Actions for the invitation">
|
<MoreOptionsDropdown label="Actions for the invitation">
|
||||||
<button
|
<button
|
||||||
@click="emit('resend')"
|
|
||||||
data-testid="invitation_delete"
|
data-testid="invitation_delete"
|
||||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
|
||||||
|
@click="emit('resend')">
|
||||||
<ArrowPathIcon class="w-5 text-icon-active"></ArrowPathIcon>
|
<ArrowPathIcon class="w-5 text-icon-active"></ArrowPathIcon>
|
||||||
<span>Resend Invitation</span>
|
<span>Resend Invitation</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="emit('delete')"
|
|
||||||
data-testid="invitation_delete"
|
data-testid="invitation_delete"
|
||||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
|
||||||
|
@click="emit('delete')">
|
||||||
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
||||||
<span>Delete</span>
|
<span>Delete</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async function resendInvitation() {
|
|||||||
await handleApiRequestNotifications(
|
await handleApiRequestNotifications(
|
||||||
() =>
|
() =>
|
||||||
api.resendInvitationEmail(
|
api.resendInvitationEmail(
|
||||||
{},
|
undefined,
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
invitation: props.invitation.id,
|
invitation: props.invitation.id,
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ defineEmits<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BillableRateModal
|
<BillableRateModal
|
||||||
@submit="$emit('submit')"
|
|
||||||
v-model:show="show"
|
v-model:show="show"
|
||||||
v-model:saving="saving"
|
v-model:saving="saving"
|
||||||
title="Update Member Billable Rate">
|
title="Update Member Billable Rate"
|
||||||
|
@submit="$emit('submit')">
|
||||||
<p class="py-1 text-center">
|
<p class="py-1 text-center">
|
||||||
The billable rate of {{ memberName }} will be updated to
|
The billable rate of {{ memberName }} will be updated to
|
||||||
<strong>{{
|
<strong>{{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useMembersStore } from '@/utils/useMembers';
|
import { useMembersStore } from '@/utils/useMembers';
|
||||||
import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid';
|
import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid';
|
||||||
@@ -17,8 +17,8 @@ const model = defineModel<string>({
|
|||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
hiddenMembers: ProjectMember[];
|
hiddenMembers?: ProjectMember[];
|
||||||
disabled: boolean;
|
disabled?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
hiddenMembers: () => [] as ProjectMember[],
|
hiddenMembers: () => [] as ProjectMember[],
|
||||||
@@ -46,22 +46,6 @@ const filteredMembers = computed<Member[]>(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(filteredMembers, () => {
|
|
||||||
resetHighlightedItem();
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
resetHighlightedItem();
|
|
||||||
});
|
|
||||||
|
|
||||||
function resetHighlightedItem() {
|
|
||||||
if (filteredMembers.value.length > 0) {
|
|
||||||
highlightedItemId.value = filteredMembers.value[0].id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const highlightedItemId = ref<string | null>(null);
|
|
||||||
|
|
||||||
const currentValue = computed(() => {
|
const currentValue = computed(() => {
|
||||||
if (model.value) {
|
if (model.value) {
|
||||||
return members.value.find((member) => member.id === model.value)?.name;
|
return members.value.find((member) => member.id === model.value)?.name;
|
||||||
@@ -76,7 +60,7 @@ const currentValue = computed(() => {
|
|||||||
:items="filteredMembers"
|
:items="filteredMembers"
|
||||||
:get-key-from-item="(member) => member.id"
|
:get-key-from-item="(member) => member.id"
|
||||||
:get-name-for-item="(member) => member.name">
|
:get-name-for-item="(member) => member.name">
|
||||||
<template v-slot:trigger>
|
<template #trigger>
|
||||||
<Badge
|
<Badge
|
||||||
tag="button"
|
tag="button"
|
||||||
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
|
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
|
||||||
@@ -84,7 +68,7 @@ const currentValue = computed(() => {
|
|||||||
<div v-if="currentValue" class="flex-1 truncate">
|
<div v-if="currentValue" class="flex-1 truncate">
|
||||||
{{ currentValue }}
|
{{ currentValue }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1" v-else>Select a member...</div>
|
<div v-else class="flex-1">Select a member...</div>
|
||||||
<ChevronDownIcon class="w-4 text-muted"></ChevronDownIcon>
|
<ChevronDownIcon class="w-4 text-muted"></ChevronDownIcon>
|
||||||
</Badge>
|
</Badge>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -108,11 +108,11 @@ const roleDescription = computed(() => {
|
|||||||
v-model:saving="saving"
|
v-model:saving="saving"
|
||||||
v-model:show="showBillableRateModal"
|
v-model:show="showBillableRateModal"
|
||||||
:member-name="member.name"
|
:member-name="member.name"
|
||||||
:newBillableRate="memberBody.billable_rate"
|
:new-billable-rate="memberBody.billable_rate"
|
||||||
@submit="submitBillableRate"></MemberBillableRateModal>
|
@submit="submitBillableRate"></MemberBillableRateModal>
|
||||||
<MemberOwnershipTransferConfirmModal
|
<MemberOwnershipTransferConfirmModal
|
||||||
:member-name="member.name"
|
|
||||||
v-model:show="showOwnershipTransferConfirmModal"
|
v-model:show="showOwnershipTransferConfirmModal"
|
||||||
|
:member-name="member.name"
|
||||||
@submit="submit"></MemberOwnershipTransferConfirmModal>
|
@submit="submit"></MemberOwnershipTransferConfirmModal>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false">
|
||||||
<template #title>
|
<template #title>
|
||||||
@@ -127,9 +127,9 @@ const roleDescription = computed(() => {
|
|||||||
<div>
|
<div>
|
||||||
<InputLabel for="role" value="Role" />
|
<InputLabel for="role" value="Role" />
|
||||||
<MemberRoleSelect
|
<MemberRoleSelect
|
||||||
|
v-model="memberBody.role"
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
name="role"
|
name="role"></MemberRoleSelect>
|
||||||
v-model="memberBody.role"></MemberRoleSelect>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 text-xs flex items-center pt-6">
|
<div class="flex-1 text-xs flex items-center pt-6">
|
||||||
<p>{{ roleDescription }}</p>
|
<p>{{ roleDescription }}</p>
|
||||||
@@ -140,28 +140,28 @@ const roleDescription = computed(() => {
|
|||||||
<div>
|
<div>
|
||||||
<InputLabel for="billableType" value="Billable" />
|
<InputLabel for="billableType" value="Billable" />
|
||||||
<MemberBillableSelect
|
<MemberBillableSelect
|
||||||
class="mt-2"
|
|
||||||
name="billableType"
|
|
||||||
v-model="
|
v-model="
|
||||||
billableRateSelect
|
billableRateSelect
|
||||||
"></MemberBillableSelect>
|
"
|
||||||
|
class="mt-2"
|
||||||
|
name="billableType"></MemberBillableSelect>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex-1"
|
v-if="billableRateSelect === 'custom-rate'"
|
||||||
v-if="billableRateSelect === 'custom-rate'">
|
class="flex-1">
|
||||||
<InputLabel
|
<InputLabel
|
||||||
for="memberBillableRate"
|
for="memberBillableRate"
|
||||||
class="mb-2"
|
class="mb-2"
|
||||||
value="Billable Rate" />
|
value="Billable Rate" />
|
||||||
<BillableRateInput
|
<BillableRateInput
|
||||||
|
v-model="
|
||||||
|
memberBody.billable_rate
|
||||||
|
"
|
||||||
focus
|
focus
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
@keydown.enter="saveWithChecks()"
|
|
||||||
name="memberBillableRate"
|
name="memberBillableRate"
|
||||||
v-model="
|
@keydown.enter="saveWithChecks()"></BillableRateInput>
|
||||||
memberBody.billable_rate
|
|
||||||
"></BillableRateInput>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -112,11 +112,11 @@ useFocus(clientNameInput, { initialValue: true });
|
|||||||
v-if="isBillingActivated() && canManageBilling()"
|
v-if="isBillingActivated() && canManageBilling()"
|
||||||
href="/billing">
|
href="/billing">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
type="button"
|
|
||||||
class="mt-6"
|
|
||||||
v-if="
|
v-if="
|
||||||
isBillingActivated() && canUpdateOrganization()
|
isBillingActivated() && canUpdateOrganization()
|
||||||
">
|
"
|
||||||
|
type="button"
|
||||||
|
class="mt-6">
|
||||||
<CreditCardIcon class="w-5 h-5 me-2" />
|
<CreditCardIcon class="w-5 h-5 me-2" />
|
||||||
Go to Billing
|
Go to Billing
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
@@ -128,15 +128,15 @@ useFocus(clientNameInput, { initialValue: true });
|
|||||||
<InputLabel for="email" value="Email" />
|
<InputLabel for="email" value="Email" />
|
||||||
<TextInput
|
<TextInput
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
|
||||||
ref="memberEmailInput"
|
ref="memberEmailInput"
|
||||||
v-model="addTeamMemberForm.email"
|
v-model="addTeamMemberForm.email"
|
||||||
|
name="email"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Member Email"
|
placeholder="Member Email"
|
||||||
@keydown.enter="submit"
|
|
||||||
class="mt-1 block w-full"
|
class="mt-1 block w-full"
|
||||||
required
|
required
|
||||||
autocomplete="memberName" />
|
autocomplete="memberName"
|
||||||
|
@keydown.enter="submit" />
|
||||||
<InputError :message="errors.email" class="mt-2" />
|
<InputError :message="errors.email" class="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
|
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||||
|
import {ref} from 'vue';
|
||||||
|
import {api, type Member} from '@/packages/api/src';
|
||||||
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
|
import {useMutation} from '@tanstack/vue-query';
|
||||||
|
import {getCurrentOrganizationId} from "@/utils/useUser";
|
||||||
|
import {useNotificationsStore} from "@/utils/notification";
|
||||||
|
import {useMembersStore} from "@/utils/useMembers";
|
||||||
|
|
||||||
|
const {handleApiRequestNotifications} = useNotificationsStore();
|
||||||
|
|
||||||
|
const show = defineModel('show', {default: false});
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
member: Member;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const turnToPlaceholderMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const organizationId = getCurrentOrganizationId();
|
||||||
|
if (organizationId === null) {
|
||||||
|
throw new Error('No current organization id - create report');
|
||||||
|
}
|
||||||
|
return await api.makePlaceholder(undefined, {
|
||||||
|
params: {
|
||||||
|
organization: organizationId,
|
||||||
|
member: props.member.id
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
saving.value = true;
|
||||||
|
await handleApiRequestNotifications(
|
||||||
|
() =>
|
||||||
|
turnToPlaceholderMutation.mutateAsync(),
|
||||||
|
'Deactivating the member was successful!',
|
||||||
|
'There was an error deactivating the user.',
|
||||||
|
() => {
|
||||||
|
show.value = false;
|
||||||
|
useMembersStore().fetchMembers()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogModal closeable :show="show" @close="show = false">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<span> Deactivate User </span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<p>
|
||||||
|
Deactivating the user <strong>{{ member.name }} </strong> will remove the user's access to
|
||||||
|
the organization. You will not be billed for inactive users and all time entries will be preserved.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
||||||
|
|
||||||
|
<PrimaryButton
|
||||||
|
class="ms-3"
|
||||||
|
:class="{ 'opacity-25': saving }"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="submit()">
|
||||||
|
Deactivate
|
||||||
|
</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
109
resources/js/Components/Common/Member/MemberMergeModal.vue
Normal file
109
resources/js/Components/Common/Member/MemberMergeModal.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
|
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import {api, type Member} from '@/packages/api/src';
|
||||||
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
|
import MemberCombobox from "@/Components/Common/Member/MemberCombobox.vue";
|
||||||
|
import {UserIcon, ArrowRightIcon} from "@heroicons/vue/24/solid";
|
||||||
|
import {Badge} from "@/packages/ui/src";
|
||||||
|
import { useMutation } from '@tanstack/vue-query';
|
||||||
|
import {getCurrentOrganizationId} from "@/utils/useUser";
|
||||||
|
import {useNotificationsStore} from "@/utils/notification";
|
||||||
|
const { handleApiRequestNotifications, addNotification } = useNotificationsStore();
|
||||||
|
|
||||||
|
const show = defineModel('show', { default: false });
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
member: Member;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const newMember = ref<string>('');
|
||||||
|
|
||||||
|
const mergeMember = useMutation({
|
||||||
|
mutationFn: async (newMemberId: string) => {
|
||||||
|
const organizationId = getCurrentOrganizationId();
|
||||||
|
if (organizationId === null) {
|
||||||
|
throw new Error('No current organization id - create report');
|
||||||
|
}
|
||||||
|
return await api.mergeMember({
|
||||||
|
member_id: newMemberId,
|
||||||
|
}, {
|
||||||
|
params: {
|
||||||
|
organization: organizationId,
|
||||||
|
member: props.member.id
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const newMemberId = newMember.value;
|
||||||
|
if(newMemberId !== ''){
|
||||||
|
saving.value = true;
|
||||||
|
await handleApiRequestNotifications(
|
||||||
|
() =>
|
||||||
|
mergeMember.mutateAsync(newMemberId),
|
||||||
|
'Members successfully merged!',
|
||||||
|
'There was an error merging the members.',
|
||||||
|
() => {
|
||||||
|
show.value = false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
addNotification(
|
||||||
|
'error',
|
||||||
|
'Please select a member to merge into.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogModal closeable :show="show" @close="show = false">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<span> Merge Member </span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<p>Merging the user <strong>{{ member.name }} </strong> into another one will transfer all time entries to the new user. <strong>This cannot be reverted!</strong></p>
|
||||||
|
<div class="py-5 flex flex-col md:flex-row gap-6 items-center">
|
||||||
|
<div class="flex-1">
|
||||||
|
<Badge class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
|
||||||
|
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon>
|
||||||
|
<div class="flex-1 font-medium truncate">
|
||||||
|
{{ member.name }}
|
||||||
|
</div>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ArrowRightIcon class="relative z-10 w-4 text-muted"></ArrowRightIcon>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<MemberCombobox
|
||||||
|
v-model="newMember"
|
||||||
|
></MemberCombobox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
||||||
|
|
||||||
|
<PrimaryButton
|
||||||
|
class="ms-3"
|
||||||
|
:class="{ 'opacity-25': saving }"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="submit()">
|
||||||
|
Merge Member
|
||||||
|
</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
|
import { TrashIcon, UserCircleIcon, PencilSquareIcon, ArrowDownOnSquareStackIcon } from '@heroicons/vue/20/solid';
|
||||||
import type { Member } from '@/packages/api/src';
|
import type { Member } from '@/packages/api/src';
|
||||||
import { canDeleteMembers, canUpdateMembers } from '@/utils/permissions';
|
import {canDeleteMembers, canMakeMembersPlaceholders, canMergeMembers, canUpdateMembers} from '@/utils/permissions';
|
||||||
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
|
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
delete: [];
|
delete: [];
|
||||||
edit: [];
|
edit: [];
|
||||||
|
merge: [];
|
||||||
|
makePlaceholder: [];
|
||||||
}>();
|
}>();
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
member: Member;
|
member: Member;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -20,22 +23,39 @@ const props = defineProps<{
|
|||||||
<div class="min-w-[150px]">
|
<div class="min-w-[150px]">
|
||||||
<button
|
<button
|
||||||
v-if="canUpdateMembers()"
|
v-if="canUpdateMembers()"
|
||||||
@click="emit('edit')"
|
|
||||||
:aria-label="'Edit Member ' + props.member.name"
|
:aria-label="'Edit Member ' + props.member.name"
|
||||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
|
||||||
|
@click="emit('edit')">
|
||||||
<PencilSquareIcon
|
<PencilSquareIcon
|
||||||
class="w-5 text-icon-active"></PencilSquareIcon>
|
class="w-5 text-icon-active"></PencilSquareIcon>
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="canDeleteMembers()"
|
v-if="canDeleteMembers()"
|
||||||
@click="emit('delete')"
|
|
||||||
:aria-label="'Delete Member ' + props.member.name"
|
:aria-label="'Delete Member ' + props.member.name"
|
||||||
data-testid="member_delete"
|
data-testid="member_delete"
|
||||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
|
||||||
|
@click="emit('delete')">
|
||||||
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
||||||
<span>Delete</span>
|
<span>Delete</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="props.member.role === 'placeholder' && canMergeMembers()"
|
||||||
|
:aria-label="'Merge Member ' + props.member.name"
|
||||||
|
data-testid="member_merge"
|
||||||
|
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
|
||||||
|
@click="emit('merge')">
|
||||||
|
<ArrowDownOnSquareStackIcon class="w-5 text-icon-active"></ArrowDownOnSquareStackIcon>
|
||||||
|
<span>Merge</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="props.member.role !== 'placeholder' && canMakeMembersPlaceholders()"
|
||||||
|
:aria-label="'Make Member ' + props.member.name + ' a placeholder'"
|
||||||
|
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
|
||||||
|
@click="emit('makePlaceholder')">
|
||||||
|
<UserCircleIcon class="w-5 text-icon-active"></UserCircleIcon>
|
||||||
|
<span>Deactivate</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</MoreOptionsDropdown>
|
</MoreOptionsDropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user