mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 16:22:16 +01:00
Compare commits
1 Commits
feature/ch
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45a60a926f |
42
.env.ci
42
.env.ci
@@ -1,4 +1,3 @@
|
|||||||
# Application
|
|
||||||
APP_NAME=solidtime
|
APP_NAME=solidtime
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
@@ -6,6 +5,7 @@ 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,39 +20,35 @@ DB_TEST_DATABASE=laravel
|
|||||||
DB_TEST_USERNAME=root
|
DB_TEST_USERNAME=root
|
||||||
DB_TEST_PASSWORD=root
|
DB_TEST_PASSWORD=root
|
||||||
|
|
||||||
# Broadcasting
|
BROADCAST_DRIVER=log
|
||||||
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="no-reply@solidtime.test"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="solidtime"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
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
|
||||||
APP_FORCE_HTTPS=false
|
AUDITING_ENABLED=true
|
||||||
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,9 +49,7 @@ 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="solidtime"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
|
||||||
MAIL_REPLY_TO_NAME="solidtime"
|
|
||||||
|
|
||||||
# Filesystems
|
# Filesystems
|
||||||
FILESYSTEM_DISK=s3
|
FILESYSTEM_DISK=s3
|
||||||
@@ -64,24 +62,14 @@ 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,6 +5,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
47
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
47
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
@@ -1,47 +0,0 @@
|
|||||||
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
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +0,0 @@
|
|||||||
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
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,8 +0,0 @@
|
|||||||
<!--
|
|
||||||
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.
|
|
||||||
-->
|
|
||||||
143
.github/workflows/build-public.yml
vendored
143
.github/workflows/build-public.yml
vendored
@@ -11,21 +11,10 @@ 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:
|
||||||
strategy:
|
runs-on: ubuntu-22.04
|
||||||
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
|
||||||
@@ -40,7 +29,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: release-build
|
id: 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)"
|
||||||
@@ -51,7 +40,7 @@ jobs:
|
|||||||
prefix: "v"
|
prefix: "v"
|
||||||
|
|
||||||
- name: "Get version"
|
- name: "Get version"
|
||||||
id: release-version
|
id: 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
|
||||||
@@ -72,23 +61,21 @@ 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.release-version.outputs.app_version }}/g' .env
|
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.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.release-build.outputs.build }}/g' .env
|
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
|
||||||
|
|
||||||
- name: "Output .env"
|
- name: "Output .env"
|
||||||
run: cat .env
|
run: cat .env
|
||||||
|
|
||||||
- name: "Setup PHP with PECL extension"
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.3'
|
|
||||||
extensions: mbstring, dom, fileinfo, pgsql
|
|
||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
uses: php-actions/composer@v6
|
||||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
|
with:
|
||||||
|
command: install
|
||||||
|
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||||
|
php_version: 8.3
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -101,31 +88,29 @@ jobs:
|
|||||||
- name: "Build"
|
- name: "Build"
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: "Prepare"
|
- name: "Login to GitHub Container Registry"
|
||||||
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
|
||||||
@@ -133,90 +118,16 @@ 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 by digest"
|
- name: "Build and push"
|
||||||
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: ${{ matrix.platform }}
|
platforms: linux/amd64,linux/arm64
|
||||||
|
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,8 +8,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- uses: actions/checkout@v4
|
||||||
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,8 +8,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- uses: actions/checkout@v4
|
||||||
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@v5.4.0
|
uses: codecov/codecov-action@v5.3.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
slug: solidtime-io/solidtime
|
slug: solidtime-io/solidtime
|
||||||
|
|||||||
24
.github/workflows/playwright.yml
vendored
24
.github/workflows/playwright.yml
vendored
@@ -27,47 +27,45 @@ jobs:
|
|||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Setup node"
|
- uses: actions/setup-node@v4
|
||||||
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 passport:keys
|
|
||||||
php artisan migrate --seed
|
php artisan migrate --seed
|
||||||
|
php artisan passport:keys
|
||||||
|
|
||||||
- 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'
|
||||||
|
|
||||||
- name: "Upload test results"
|
- uses: actions/upload-artifact@v4
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions\Api;
|
|
||||||
|
|
||||||
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
|
|
||||||
{
|
|
||||||
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?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';
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions\Api;
|
|
||||||
|
|
||||||
class PersonalAccessClientIsNotConfiguredException extends ApiException
|
|
||||||
{
|
|
||||||
public const string KEY = 'personal_access_client_is_not_configured';
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?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';
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
<?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}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?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 [
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TokenResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TokenResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewToken extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TokenResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,7 +42,6 @@ class UserResource extends Resource
|
|||||||
{
|
{
|
||||||
/** @var User|null $record */
|
/** @var User|null $record */
|
||||||
$record = $form->getRecord();
|
$record = $form->getRecord();
|
||||||
|
|
||||||
return $form
|
return $form
|
||||||
->columns(1)
|
->columns(1)
|
||||||
->schema([
|
->schema([
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
<?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,17 +7,12 @@ 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;
|
||||||
@@ -29,8 +24,6 @@ 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
|
||||||
{
|
{
|
||||||
@@ -70,7 +63,6 @@ class MemberController extends Controller
|
|||||||
* @throws OrganizationNeedsAtLeastOneOwner
|
* @throws OrganizationNeedsAtLeastOneOwner
|
||||||
* @throws OnlyOwnerCanChangeOwnership
|
* @throws OnlyOwnerCanChangeOwnership
|
||||||
* @throws ChangingRoleToPlaceholderIsNotAllowed
|
* @throws ChangingRoleToPlaceholderIsNotAllowed
|
||||||
* @throws ChangingRoleOfPlaceholderIsNotAllowed
|
|
||||||
*
|
*
|
||||||
* @operationId updateMember
|
* @operationId updateMember
|
||||||
*/
|
*/
|
||||||
@@ -113,9 +105,7 @@ class MemberController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Make a member a placeholder member
|
* Make a member a placeholder member
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization|ChangingRoleOfPlaceholderIsNotAllowed
|
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
|
||||||
*
|
|
||||||
* @operationId makePlaceholder
|
|
||||||
*/
|
*/
|
||||||
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
|
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -124,9 +114,6 @@ 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);
|
||||||
|
|
||||||
@@ -135,39 +122,10 @@ 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
|
* @throws AuthorizationException|UserNotPlaceholderApiException
|
||||||
* @throws UserNotPlaceholderApiException
|
|
||||||
* @throws UserIsAlreadyMemberOfOrganizationApiException
|
|
||||||
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
|
|
||||||
*
|
*
|
||||||
* @operationId invitePlaceholder
|
* @operationId invitePlaceholder
|
||||||
*/
|
*/
|
||||||
@@ -180,10 +138,6 @@ 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,7 +73,6 @@ 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(),
|
||||||
@@ -84,7 +83,6 @@ 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,7 +5,6 @@ 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;
|
||||||
@@ -181,7 +180,6 @@ 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([
|
||||||
@@ -213,8 +211,7 @@ 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(),
|
||||||
@@ -288,18 +285,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|null,
|
* cost: int,
|
||||||
* 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|null,
|
* cost: int,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null
|
* cost: int
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
@@ -315,7 +312,6 @@ 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();
|
||||||
@@ -329,8 +325,7 @@ 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 [
|
||||||
@@ -364,7 +359,6 @@ 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();
|
||||||
@@ -378,8 +372,7 @@ 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(),
|
||||||
@@ -389,8 +382,7 @@ 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,7 +4,6 @@ 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;
|
||||||
@@ -20,14 +19,30 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
return Inertia::render('Dashboard', [
|
||||||
|
'weeklyProjectOverview' => $weeklyProjectOverview,
|
||||||
return Inertia::render('Dashboard');
|
'latestTasks' => $latestTasks,
|
||||||
|
'lastSevenDays' => $lastSevenDays,
|
||||||
|
'latestTeamActivity' => $latestTeamActivity,
|
||||||
|
'dailyTrackedHours' => $dailyTrackedHours,
|
||||||
|
'totalWeeklyTime' => $totalWeeklyTime,
|
||||||
|
'totalWeeklyBillableTime' => $totalWeeklyBillableTime,
|
||||||
|
'totalWeeklyBillableAmount' => $totalWeeklyBillableAmount,
|
||||||
|
'weeklyHistory' => $weeklyHistory,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ 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\ForceHttps::class,
|
// \App\Http\Middleware\TrustHosts::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,
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
22
app/Http/Middleware/TrustHosts.php
Normal file
22
app/Http/Middleware/TrustHosts.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?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),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<?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|null,
|
* cost: int,
|
||||||
* 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|null,
|
* cost: int,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null
|
* cost: int
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
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\MemberService;
|
use App\Service\UserService;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||||
|
|
||||||
@@ -17,11 +17,8 @@ class RemovePlaceholder
|
|||||||
*/
|
*/
|
||||||
public function handle(TeamMemberAdded $event): void
|
public function handle(TeamMemberAdded $event): void
|
||||||
{
|
{
|
||||||
$memberService = app(MemberService::class);
|
/** @var UserService $userService */
|
||||||
$member = Member::query()
|
$userService = app(UserService::class);
|
||||||
->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 */
|
||||||
@@ -35,7 +32,7 @@ class RemovePlaceholder
|
|||||||
foreach ($placeholders as $placeholder) {
|
foreach ($placeholders as $placeholder) {
|
||||||
/** @var Member $placeholder */
|
/** @var Member $placeholder */
|
||||||
$placeholderUser = $placeholder->user;
|
$placeholderUser = $placeholder->user;
|
||||||
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);
|
$userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholderUser, $event->user);
|
||||||
$placeholder->delete();
|
$placeholder->delete();
|
||||||
$placeholderUser->delete();
|
$placeholderUser->delete();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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;
|
||||||
@@ -25,8 +24,6 @@ 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()
|
||||||
*/
|
*/
|
||||||
@@ -62,14 +59,6 @@ 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>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models\Passport;
|
|
||||||
|
|
||||||
use Laravel\Passport\AuthCode as PassportAuthCode;
|
|
||||||
|
|
||||||
class AuthCode extends PassportAuthCode {}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models\Passport;
|
|
||||||
|
|
||||||
use Laravel\Passport\PersonalAccessClient as PassportPersonalAccessClient;
|
|
||||||
|
|
||||||
class PersonalAccessClient extends PassportPersonalAccessClient {}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models\Passport;
|
|
||||||
|
|
||||||
use Laravel\Passport\RefreshToken as PassportRefreshToken;
|
|
||||||
|
|
||||||
class RefreshToken extends PassportRefreshToken {}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?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,7 +7,6 @@ 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;
|
||||||
@@ -28,6 +27,7 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,7 +44,6 @@ 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
|
||||||
@@ -197,17 +196,6 @@ 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,7 +9,6 @@ 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;
|
||||||
@@ -30,6 +29,7 @@ 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,6 +90,12 @@ 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;
|
||||||
});
|
});
|
||||||
@@ -101,6 +107,5 @@ 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,11 +5,6 @@ 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;
|
||||||
@@ -47,16 +42,6 @@ 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,9 +69,6 @@ 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,8 +80,6 @@ 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',
|
||||||
@@ -125,7 +123,6 @@ 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',
|
||||||
@@ -136,8 +133,6 @@ 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',
|
||||||
@@ -177,10 +172,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'invitations:resend',
|
'invitations:resend',
|
||||||
'invitations:remove',
|
'invitations:remove',
|
||||||
'members:view',
|
'members:view',
|
||||||
'members:invite-placeholder',
|
|
||||||
'members:make-placeholder',
|
|
||||||
'members:merge-into',
|
|
||||||
'members:update',
|
'members:update',
|
||||||
|
'members:invite-placeholder',
|
||||||
'reports:view',
|
'reports:view',
|
||||||
'reports:create',
|
'reports:create',
|
||||||
'reports:update',
|
'reports:update',
|
||||||
@@ -188,8 +181,6 @@ 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',
|
||||||
@@ -230,7 +221,6 @@ 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,11 +33,6 @@ 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,7 +11,6 @@ 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;
|
||||||
|
|
||||||
@@ -24,7 +23,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
*/
|
*/
|
||||||
private function getTags(string $tags): array
|
private function getTags(string $tags): array
|
||||||
{
|
{
|
||||||
if (Str::trim($tags) === '') {
|
if (trim($tags) === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$tagsParsed = explode(', ', $tags);
|
$tagsParsed = explode(', ', $tags);
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
<?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,11 +15,6 @@ 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 (Str::trim($tags) === '') {
|
if (trim($tags) === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$tagsParsed = json_decode($tags);
|
$tagsParsed = json_decode($tags);
|
||||||
|
|||||||
@@ -5,11 +5,8 @@ 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;
|
||||||
@@ -96,22 +93,11 @@ 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' => $timezone,
|
'timezone' => $workspaceUser->timezone ?? 'UTC',
|
||||||
'is_placeholder' => true,
|
'is_placeholder' => true,
|
||||||
], (string) $workspaceUser->uid);
|
], (string) $workspaceUser->uid);
|
||||||
$this->memberImportHelper->getKey([
|
$this->memberImportHelper->getKey([
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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;
|
||||||
|
|
||||||
@@ -24,7 +23,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
|||||||
*/
|
*/
|
||||||
private function getTags(string $tags): array
|
private function getTags(string $tags): array
|
||||||
{
|
{
|
||||||
if (Str::trim($tags) === '') {
|
if (trim($tags) === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$tagsParsed = explode(', ', $tags);
|
$tagsParsed = explode(', ', $tags);
|
||||||
|
|||||||
@@ -7,18 +7,15 @@ 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;
|
||||||
@@ -78,7 +75,6 @@ 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
|
||||||
{
|
{
|
||||||
@@ -86,9 +82,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -103,39 +96,6 @@ 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.
|
||||||
@@ -172,7 +132,7 @@ class MemberService
|
|||||||
$member->role = Role::Placeholder->value;
|
$member->role = Role::Placeholder->value;
|
||||||
$member->save();
|
$member->save();
|
||||||
|
|
||||||
$this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser);
|
$this->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
|
||||||
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|null,
|
* cost: int,
|
||||||
* 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|null,
|
* cost: int,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null
|
* cost: int
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
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|null,
|
* cost: int,
|
||||||
* 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|null,
|
* cost: int,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null
|
* cost: int
|
||||||
* } $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|null,
|
* cost: int,
|
||||||
* 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|null,
|
* cost: int,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null
|
* cost: int
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): 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' => $showBillableRate ? (int) $aggregate->get(0)->cost : null,
|
'cost' => (int) $aggregate->get(0)->cost,
|
||||||
'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' => $showBillableRate ? $group2ResponseCost : null,
|
'cost' => $group2ResponseCost,
|
||||||
'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' => $showBillableRate ? $group1ResponseCost : null,
|
'cost' => $group1ResponseCost,
|
||||||
'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|null,
|
* cost: int,
|
||||||
* 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|null,
|
* cost: int,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null
|
* cost: int
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array
|
||||||
{
|
{
|
||||||
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate);
|
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end);
|
||||||
|
|
||||||
$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|null,
|
* cost: int,
|
||||||
* 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|null,
|
* cost: int,
|
||||||
* 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|null,
|
* cost: int,
|
||||||
* 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|null,
|
* cost: int,
|
||||||
* grouped_type: null|mixed,
|
* grouped_type: null|mixed,
|
||||||
* grouped_data: null|mixed
|
* grouped_data: null|mixed
|
||||||
* }>
|
* }>
|
||||||
|
|||||||
@@ -49,10 +49,24 @@ class UserService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This does NOT change the member id.
|
* Assign all organization entities (time entries, project members) from one user to another.
|
||||||
* This should only be used in if you want to change a member to a placeholder!
|
* This is useful when a placeholder user is replaced with a real user.
|
||||||
*/
|
*/
|
||||||
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()
|
||||||
@@ -60,6 +74,7 @@ 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
|
||||||
@@ -68,6 +83,7 @@ class UserService
|
|||||||
->whereBelongsTo($fromUser, 'user')
|
->whereBelongsTo($fromUser, 'user')
|
||||||
->update([
|
->update([
|
||||||
'user_id' => $toUser->getKey(),
|
'user_id' => $toUser->getKey(),
|
||||||
|
'member_id' => $toMember->getKey(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'secure' => env('SESSION_SECURE_COOKIE', env('APP_FORCE_HTTPS')),
|
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?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,29 +34,6 @@ 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',
|
||||||
@@ -78,8 +55,6 @@ 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',
|
||||||
@@ -184,6 +159,15 @@ 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
|
||||||
|
|||||||
@@ -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,69 +1,25 @@
|
|||||||
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', {exact: true} ).fill('NEW NAME');
|
await page.getByLabel('Name').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', {exact: true})).toHaveValue('NEW NAME');
|
await expect(page.getByLabel('Name')).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');
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ 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' &&
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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' &&
|
||||||
|
|||||||
@@ -4,18 +4,14 @@ 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;
|
||||||
@@ -41,10 +37,6 @@ 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.',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,14 +13,6 @@ return [
|
|||||||
'<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. Make sure to set the language of Clockify to English in "Preferences -> General".<br>'.
|
'description' => '1. Make sure to set the language of Clockify to English in "Preferences -> General".<br>'.
|
||||||
@@ -46,22 +38,4 @@ 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.',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -29,8 +29,6 @@
|
|||||||
</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"/>
|
||||||
@@ -41,7 +39,5 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, onMounted, ref, watch } 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';
|
||||||
@@ -46,6 +46,22 @@ 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;
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
<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,19 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TrashIcon, UserCircleIcon, PencilSquareIcon, ArrowDownOnSquareStackIcon } from '@heroicons/vue/20/solid';
|
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
|
||||||
import type { Member } from '@/packages/api/src';
|
import type { Member } from '@/packages/api/src';
|
||||||
import {canDeleteMembers, canMakeMembersPlaceholders, canMergeMembers, canUpdateMembers} from '@/utils/permissions';
|
import { canDeleteMembers, 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>
|
||||||
@@ -39,23 +36,6 @@ const props = defineProps<{
|
|||||||
<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>
|
||||||
|
|||||||
@@ -10,20 +10,16 @@ import { getCurrentOrganizationId } from '@/utils/useUser';
|
|||||||
import { useNotificationsStore } from '@/utils/notification';
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
import { canInvitePlaceholderMembers } from '@/utils/permissions';
|
import { canInvitePlaceholderMembers } from '@/utils/permissions';
|
||||||
import { useMembersStore } from '@/utils/useMembers';
|
import { useMembersStore } from '@/utils/useMembers';
|
||||||
import {computed, ref} from 'vue';
|
import { ref } from 'vue';
|
||||||
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
|
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import MemberMergeModal from "@/Components/Common/Member/MemberMergeModal.vue";
|
|
||||||
import MemberMakePlaceholderModal from "@/Components/Common/Member/MemberMakePlaceholderModal.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
member: Member;
|
member: Member;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showEditMemberModal = ref(false);
|
const showEditMemberModal = ref(false);
|
||||||
const showMergeMemberModal = ref(false);
|
|
||||||
const showMakeMemberPlaceholderModal = ref(false);
|
|
||||||
|
|
||||||
function removeMember() {
|
function removeMember() {
|
||||||
useMembersStore().removeMember(props.member.id);
|
useMembersStore().removeMember(props.member.id);
|
||||||
@@ -36,7 +32,7 @@ async function invitePlaceholder(id: string) {
|
|||||||
await handleApiRequestNotifications(
|
await handleApiRequestNotifications(
|
||||||
() =>
|
() =>
|
||||||
api.invitePlaceholder(
|
api.invitePlaceholder(
|
||||||
undefined,
|
{},
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
organization: organizationId,
|
organization: organizationId,
|
||||||
@@ -49,11 +45,6 @@ async function invitePlaceholder(id: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userHasValidMailAddress = computed(() => {
|
|
||||||
return !props.member.email.endsWith('@solidtime-import.test');
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -96,8 +87,7 @@ const userHasValidMailAddress = computed(() => {
|
|||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
v-if="
|
v-if="
|
||||||
member.is_placeholder === true &&
|
member.is_placeholder === true &&
|
||||||
canInvitePlaceholderMembers() &&
|
canInvitePlaceholderMembers()
|
||||||
userHasValidMailAddress
|
|
||||||
"
|
"
|
||||||
size="small"
|
size="small"
|
||||||
@click="invitePlaceholder(member.id)"
|
@click="invitePlaceholder(member.id)"
|
||||||
@@ -106,16 +96,11 @@ const userHasValidMailAddress = computed(() => {
|
|||||||
<MemberMoreOptionsDropdown
|
<MemberMoreOptionsDropdown
|
||||||
:member="member"
|
:member="member"
|
||||||
@edit="showEditMemberModal = true"
|
@edit="showEditMemberModal = true"
|
||||||
@delete="removeMember"
|
@delete="removeMember"></MemberMoreOptionsDropdown>
|
||||||
@merge="showMergeMemberModal = true"
|
|
||||||
@make-placeholder="showMakeMemberPlaceholderModal = true"
|
|
||||||
></MemberMoreOptionsDropdown>
|
|
||||||
</div>
|
</div>
|
||||||
<MemberEditModal
|
<MemberEditModal
|
||||||
v-model:show="showEditMemberModal"
|
v-model:show="showEditMemberModal"
|
||||||
:member="member"></MemberEditModal>
|
:member="member"></MemberEditModal>
|
||||||
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal>
|
|
||||||
<MemberMakePlaceholderModal v-model:show="showMakeMemberPlaceholderModal" :member="member"></MemberMakePlaceholderModal>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type AggregatedGroupedData = GroupedData & {
|
|||||||
|
|
||||||
type GroupedData = {
|
type GroupedData = {
|
||||||
seconds: number;
|
seconds: number;
|
||||||
cost: number | null;
|
cost: number;
|
||||||
description: string | null | undefined;
|
description: string | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ const expanded = ref(false);
|
|||||||
{{ formatHumanReadableDuration(entry.seconds) }}
|
{{ formatHumanReadableDuration(entry.seconds) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-end pr-6 flex items-center">
|
<div class="justify-end pr-6 flex items-center">
|
||||||
{{entry.cost ? formatCents(entry.cost, getOrganizationCurrencyString()) : '--' }}
|
{{ formatCents(entry.cost, getOrganizationCurrencyString()) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,13 +2,9 @@
|
|||||||
import { router } from '@inertiajs/vue3';
|
import { router } from '@inertiajs/vue3';
|
||||||
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
||||||
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
||||||
import {canViewReport} from "@/utils/permissions";
|
|
||||||
import {computed} from "vue";
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
active: 'reporting' | 'detailed' | 'shared';
|
active: 'reporting' | 'detailed' | 'shared';
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showSharedReports = computed(() => canViewReport());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -24,7 +20,6 @@ const showSharedReports = computed(() => canViewReport());
|
|||||||
>Detailed</TabBarItem
|
>Detailed</TabBarItem
|
||||||
>
|
>
|
||||||
<TabBarItem
|
<TabBarItem
|
||||||
v-if="showSharedReports"
|
|
||||||
:active="active === 'shared'"
|
:active="active === 'shared'"
|
||||||
@click="router.visit(route('reporting.shared'))"
|
@click="router.visit(route('reporting.shared'))"
|
||||||
>Shared</TabBarItem
|
>Shared</TabBarItem
|
||||||
|
|||||||
@@ -1,45 +1,29 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import VChart, { THEME_KEY } from "vue-echarts";
|
import VChart, { THEME_KEY } from 'vue-echarts';
|
||||||
import { provide, computed } from "vue";
|
import { provide, ref } from 'vue';
|
||||||
import { use } from "echarts/core";
|
import { use } from 'echarts/core';
|
||||||
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
|
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
||||||
import { BoltIcon } from "@heroicons/vue/20/solid";
|
import { BoltIcon } from '@heroicons/vue/20/solid';
|
||||||
import { HeatmapChart } from "echarts/charts";
|
import { HeatmapChart } from 'echarts/charts';
|
||||||
import {
|
import {
|
||||||
CalendarComponent,
|
CalendarComponent,
|
||||||
TitleComponent,
|
TitleComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
VisualMapComponent
|
VisualMapComponent,
|
||||||
} from "echarts/components";
|
} from 'echarts/components';
|
||||||
import { CanvasRenderer } from "echarts/renderers";
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
import dayjs from "dayjs";
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
firstDayIndex,
|
firstDayIndex,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatHumanReadableDuration,
|
formatHumanReadableDuration,
|
||||||
getDayJsInstance
|
getDayJsInstance,
|
||||||
} from "@/packages/ui/src/utils/time";
|
} from '@/packages/ui/src/utils/time';
|
||||||
import { useCssVar } from "@vueuse/core";
|
import { useCssVar } from '@vueuse/core';
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
|
||||||
import { getCurrentOrganizationId } from "@/utils/useUser";
|
|
||||||
import { api } from "@/packages/api/src";
|
|
||||||
import { LoadingSpinner } from "@/packages/ui/src";
|
|
||||||
|
|
||||||
// Get the organization ID using the utility function
|
const props = defineProps<{
|
||||||
const organizationId = computed(() => getCurrentOrganizationId());
|
dailyHoursTracked: { duration: number; date: string }[];
|
||||||
|
}>();
|
||||||
|
|
||||||
const { data: dailyHoursTracked, isLoading } = useQuery({
|
|
||||||
queryKey: ["dailyTrackedHours", organizationId],
|
|
||||||
queryFn: () => {
|
|
||||||
return api.dailyTrackedHours({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: computed(() => !!organizationId.value)
|
|
||||||
});
|
|
||||||
|
|
||||||
use([
|
use([
|
||||||
TitleComponent,
|
TitleComponent,
|
||||||
@@ -47,113 +31,89 @@ use([
|
|||||||
VisualMapComponent,
|
VisualMapComponent,
|
||||||
CalendarComponent,
|
CalendarComponent,
|
||||||
HeatmapChart,
|
HeatmapChart,
|
||||||
CanvasRenderer
|
CanvasRenderer,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
provide(THEME_KEY, "dark");
|
provide(THEME_KEY, 'dark');
|
||||||
|
|
||||||
const max = computed(() => {
|
const max = Math.max(
|
||||||
if (!isLoading.value && dailyHoursTracked.value) {
|
Math.max(...props.dailyHoursTracked.map((el) => el.duration)),
|
||||||
return Math.max(
|
1
|
||||||
Math.max(...dailyHoursTracked.value.map((el) => el.duration)),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const backgroundColor = useCssVar("--color-bg-secondary");
|
const backgroundColor = useCssVar('--color-bg-secondary');
|
||||||
const itemBackgroundColor = useCssVar("--color-bg-tertiary");
|
const itemBackgroundColor = useCssVar('--color-bg-tertiary');
|
||||||
const option = computed(() => {
|
const option = ref({
|
||||||
return {
|
tooltip: {},
|
||||||
tooltip: {},
|
visualMap: {
|
||||||
visualMap: {
|
min: 0,
|
||||||
min: 0,
|
max: max,
|
||||||
max: max.value,
|
type: 'piecewise',
|
||||||
type: "piecewise",
|
orient: 'horizontal',
|
||||||
orient: "horizontal",
|
left: 'center',
|
||||||
left: "center",
|
top: 'center',
|
||||||
top: "center",
|
inRange: {
|
||||||
inRange: {
|
color: [itemBackgroundColor.value, '#2DBE45'],
|
||||||
color: [itemBackgroundColor.value, "#2DBE45"]
|
},
|
||||||
},
|
show: false,
|
||||||
show: false
|
},
|
||||||
|
calendar: {
|
||||||
|
top: 40,
|
||||||
|
bottom: 20,
|
||||||
|
left: 40,
|
||||||
|
right: 10,
|
||||||
|
cellSize: [40, 40],
|
||||||
|
dayLabel: {
|
||||||
|
firstDay: firstDayIndex.value,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
range: [
|
||||||
|
dayjs().format('YYYY-MM-DD'),
|
||||||
|
getDayJsInstance()()
|
||||||
|
.subtract(50, 'day')
|
||||||
|
.startOf('week')
|
||||||
|
.format('YYYY-MM-DD'),
|
||||||
|
],
|
||||||
|
itemStyle: {
|
||||||
|
color: 'transparent',
|
||||||
|
borderWidth: 8,
|
||||||
|
borderColor: backgroundColor.value,
|
||||||
|
},
|
||||||
|
yearLabel: { show: false },
|
||||||
|
},
|
||||||
|
series: {
|
||||||
|
type: 'heatmap',
|
||||||
|
coordinateSystem: 'calendar',
|
||||||
|
data: props.dailyHoursTracked.map((el) => [el.date, el.duration]),
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: 5,
|
||||||
|
borderColor: 'rgba(255,255,255,0.05)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
valueFormatter: (value: number, dataIndex: number) => {
|
||||||
|
return (
|
||||||
|
formatDate(props.dailyHoursTracked[dataIndex].date) +
|
||||||
|
': ' +
|
||||||
|
formatHumanReadableDuration(value)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
calendar: {
|
},
|
||||||
top: 40,
|
},
|
||||||
bottom: 20,
|
backgroundColor: 'transparent',
|
||||||
left: 40,
|
});
|
||||||
right: 10,
|
|
||||||
cellSize: [40, 40],
|
|
||||||
dayLabel: {
|
|
||||||
firstDay: firstDayIndex.value
|
|
||||||
},
|
|
||||||
splitLine: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
range: [
|
|
||||||
dayjs().format("YYYY-MM-DD"),
|
|
||||||
getDayJsInstance()()
|
|
||||||
.subtract(50, "day")
|
|
||||||
.startOf("week")
|
|
||||||
.format("YYYY-MM-DD")
|
|
||||||
],
|
|
||||||
itemStyle: {
|
|
||||||
color: "transparent",
|
|
||||||
borderWidth: 8,
|
|
||||||
borderColor: backgroundColor.value
|
|
||||||
},
|
|
||||||
yearLabel: { show: false }
|
|
||||||
},
|
|
||||||
series: {
|
|
||||||
type: "heatmap",
|
|
||||||
coordinateSystem: "calendar",
|
|
||||||
data: dailyHoursTracked?.value?.map((el) => [el.date, el.duration]) ?? [],
|
|
||||||
itemStyle: {
|
|
||||||
borderRadius: 5,
|
|
||||||
borderColor: "rgba(255,255,255,0.05)",
|
|
||||||
borderWidth: 1
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
valueFormatter: (value: number, dataIndex: number) => {
|
|
||||||
if(dailyHoursTracked?.value){
|
|
||||||
return (
|
|
||||||
formatDate(dailyHoursTracked?.value[dataIndex].date) +
|
|
||||||
": " +
|
|
||||||
formatHumanReadableDuration(value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
backgroundColor: "transparent"
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardCard title="Activity Graph" :icon="BoltIcon">
|
<DashboardCard title="Activity Graph" :icon="BoltIcon">
|
||||||
<div class="px-2">
|
<div class="px-2">
|
||||||
<div v-if="isLoading" class="flex justify-center items-center h-40">
|
<v-chart
|
||||||
<LoadingSpinner />
|
class="chart"
|
||||||
</div>
|
:autoresize="true"
|
||||||
<div v-else-if="dailyHoursTracked">
|
:option="option"
|
||||||
<v-chart
|
style="height: 260px; background-color: transparent" />
|
||||||
class="chart"
|
|
||||||
:autoresize="true"
|
|
||||||
:option="option"
|
|
||||||
style="height: 260px; background-color: transparent" />
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-center text-gray-500 py-8">
|
|
||||||
No activity data available
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,53 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
||||||
import { computed } from "vue";
|
import DayOverviewCardEntry from '@/Components/Dashboard/DayOverviewCardEntry.vue';
|
||||||
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
|
import { CalendarIcon } from '@heroicons/vue/20/solid';
|
||||||
import DayOverviewCardEntry from "@/Components/Dashboard/DayOverviewCardEntry.vue";
|
defineProps<{
|
||||||
import { CalendarIcon } from "@heroicons/vue/20/solid";
|
last7Days: {
|
||||||
import { getCurrentOrganizationId } from "@/utils/useUser";
|
date: string;
|
||||||
import { api } from "@/packages/api/src";
|
duration: number; // Total duration in seconds
|
||||||
import { LoadingSpinner } from "@/packages/ui/src";
|
history: number[]; // Array representing the duration in seconds of the 3h windows for the day
|
||||||
|
}[];
|
||||||
// Get the organization ID using the utility function
|
}>();
|
||||||
const organizationId = computed(() => getCurrentOrganizationId());
|
|
||||||
|
|
||||||
|
|
||||||
// Set up the query
|
|
||||||
const { data: last7Days, isLoading } = useQuery({
|
|
||||||
queryKey: ["lastSevenDays", organizationId],
|
|
||||||
queryFn: () => {
|
|
||||||
return api.lastSevenDays({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: computed(() => !!organizationId.value),
|
|
||||||
placeholderData: Array.from({ length: 7 }, (_, i) => ({
|
|
||||||
date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
|
||||||
duration: 0,
|
|
||||||
history: Array(8).fill(0)
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardCard title="Last 7 Days" :icon="CalendarIcon">
|
<DashboardCard title="Last 7 Days" :icon="CalendarIcon">
|
||||||
<div v-if="isLoading" class="flex justify-center items-center h-40">
|
<DayOverviewCardEntry
|
||||||
<LoadingSpinner />
|
v-for="day in last7Days"
|
||||||
</div>
|
:key="day.date"
|
||||||
<div v-else-if="last7Days">
|
:class="last7Days.length === 7 ? 'last:border-0 first:pt-3' : ''"
|
||||||
<DayOverviewCardEntry
|
:date="day.date"
|
||||||
v-for="day in last7Days"
|
:history="day.history"
|
||||||
:key="day.date"
|
:duration="day.duration"></DayOverviewCardEntry>
|
||||||
:class="last7Days.length === 7 ? 'last:border-0 first:pt-3' : ''"
|
|
||||||
:date="day.date"
|
|
||||||
:history="day.history"
|
|
||||||
:duration="day.duration"></DayOverviewCardEntry>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-center text-gray-500 py-8">
|
|
||||||
No data available
|
|
||||||
</div>
|
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,83 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
|
||||||
import { computed } from "vue";
|
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
||||||
import RecentlyTrackedTasksCardEntry from "@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue";
|
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
||||||
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import { CheckCircleIcon } from "@heroicons/vue/20/solid";
|
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
|
||||||
import SecondaryButton from "@/packages/ui/src/Buttons/SecondaryButton.vue";
|
import { router } from '@inertiajs/vue3';
|
||||||
import { PlusCircleIcon } from "@heroicons/vue/24/solid";
|
|
||||||
import { router } from "@inertiajs/vue3";
|
|
||||||
import { getCurrentMembershipId, getCurrentOrganizationId } from "@/utils/useUser";
|
|
||||||
import { api } from "@/packages/api/src";
|
|
||||||
import { LoadingSpinner } from "@/packages/ui/src";
|
|
||||||
|
|
||||||
// Get the organization ID using the utility function
|
const props = defineProps<{
|
||||||
const organizationId = computed(() => getCurrentOrganizationId());
|
latestTasks: {
|
||||||
|
id: string;
|
||||||
// Function to fetch latest tasks using the API client
|
name: string;
|
||||||
|
project_name: string;
|
||||||
// Set up the query
|
project_id: string;
|
||||||
const { data: timeEntriesResponse, isLoading, refetch } = useQuery({
|
}[];
|
||||||
queryKey: ["timeEntries", organizationId],
|
}>();
|
||||||
queryFn: () => {
|
|
||||||
return api.getTimeEntries({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
},
|
|
||||||
queries: {
|
|
||||||
member_id: getCurrentMembershipId()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: computed(() => !!organizationId.value)
|
|
||||||
});
|
|
||||||
|
|
||||||
const latestTasks = computed(() => {
|
|
||||||
if (!timeEntriesResponse.value) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return timeEntriesResponse.value.data;
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredLatestTasks = computed(() => {
|
|
||||||
// do not include running time entries
|
|
||||||
const finishedTimeEntries = latestTasks.value.filter((item) => item.end !== null);
|
|
||||||
|
|
||||||
// filter out duplicates based on description, task, project, tags and billable
|
|
||||||
return finishedTimeEntries.filter((item, index, self) => {
|
|
||||||
return index === self.findIndex((t) => (
|
|
||||||
t.description === item.description &&
|
|
||||||
t.task_id === item.task_id &&
|
|
||||||
t.project_id === item.project_id &&
|
|
||||||
t.tags.length === item.tags.length &&
|
|
||||||
t.tags.every((tag) => item.tags.includes(tag)) &&
|
|
||||||
t.billable === item.billable
|
|
||||||
));
|
|
||||||
}).slice(0, 4);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Listen for dashboard refresh events
|
|
||||||
window.addEventListener("dashboard:refresh", () => {
|
|
||||||
refetch();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardCard title="Recent Time Entries" :icon="CheckCircleIcon">
|
<DashboardCard title="Recently Tracked Tasks" :icon="CheckCircleIcon">
|
||||||
<div v-if="isLoading" class="flex justify-center items-center h-40">
|
<RecentlyTrackedTasksCardEntry
|
||||||
<LoadingSpinner />
|
v-for="lastTask in props.latestTasks"
|
||||||
</div>
|
:key="lastTask.id"
|
||||||
<div v-else-if="filteredLatestTasks && filteredLatestTasks.length > 0">
|
:class="props.latestTasks.length === 4 ? 'last:border-0' : ''"
|
||||||
<RecentlyTrackedTasksCardEntry
|
:project_id="lastTask.project_id"
|
||||||
v-for="lastTask in filteredLatestTasks"
|
:task_id="lastTask.id"
|
||||||
:key="lastTask.id"
|
:title="lastTask.name"></RecentlyTrackedTasksCardEntry>
|
||||||
:time-entry="lastTask"
|
|
||||||
:class="filteredLatestTasks.length === 4 ? 'last:border-0' : ''"></RecentlyTrackedTasksCardEntry>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-if="props.latestTasks.length === 0"
|
||||||
class="text-center flex flex-1 justify-center items-center">
|
class="text-center flex flex-1 justify-center items-center">
|
||||||
<div>
|
<div>
|
||||||
<PlusCircleIcon
|
<PlusCircleIcon
|
||||||
@@ -87,12 +36,12 @@ window.addEventListener("dashboard:refresh", () => {
|
|||||||
</h3>
|
</h3>
|
||||||
<p class="pb-5 text-sm">Create tasks inside of a project!</p>
|
<p class="pb-5 text-sm">Create tasks inside of a project!</p>
|
||||||
<SecondaryButton @click="router.visit(route('projects'))"
|
<SecondaryButton @click="router.visit(route('projects'))"
|
||||||
>Go to Projects
|
>Go to Projects
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="latestTasks && latestTasks.length === 1"
|
v-if="props.latestTasks.length === 1"
|
||||||
class="text-center flex flex-1 justify-center items-center text-sm">
|
class="text-center flex flex-1 justify-center items-center text-sm">
|
||||||
<div>
|
<div>
|
||||||
<PlusCircleIcon
|
<PlusCircleIcon
|
||||||
@@ -100,7 +49,7 @@ window.addEventListener("dashboard:refresh", () => {
|
|||||||
<h3 class="text-white font-semibold">Add more tasks</h3>
|
<h3 class="text-white font-semibold">Add more tasks</h3>
|
||||||
<p class="pb-5">Create tasks inside of a project!</p>
|
<p class="pb-5">Create tasks inside of a project!</p>
|
||||||
<SecondaryButton @click="router.visit(route('projects'))"
|
<SecondaryButton @click="router.visit(route('projects'))"
|
||||||
>Go to Projects
|
>Go to Projects
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,16 +6,17 @@ import { storeToRefs } from 'pinia';
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||||
import type { TimeEntry } from "@/packages/api/src";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
timeEntry: TimeEntry
|
title: string;
|
||||||
|
project_id: string;
|
||||||
|
task_id: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { projects } = storeToRefs(useProjectsStore());
|
const { projects } = storeToRefs(useProjectsStore());
|
||||||
|
|
||||||
const project = computed(() => {
|
const project = computed(() => {
|
||||||
return projects.value.find((project) => project.id === props.timeEntry.project_id);
|
return projects.value.find((project) => project.id === props.project_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const { currentTimeEntry } = storeToRefs(useCurrentTimeEntryStore());
|
const { currentTimeEntry } = storeToRefs(useCurrentTimeEntryStore());
|
||||||
@@ -25,28 +26,23 @@ async function startTaskTimer() {
|
|||||||
if (currentTimeEntry.value.id) {
|
if (currentTimeEntry.value.id) {
|
||||||
await setActiveState(false);
|
await setActiveState(false);
|
||||||
}
|
}
|
||||||
currentTimeEntry.value.description = props.timeEntry.description;
|
currentTimeEntry.value.project_id = props.project_id;
|
||||||
currentTimeEntry.value.project_id = props.timeEntry.project_id;
|
currentTimeEntry.value.task_id = props.task_id;
|
||||||
currentTimeEntry.value.task_id = props.timeEntry.task_id;
|
|
||||||
currentTimeEntry.value.tags = props.timeEntry.tags;
|
|
||||||
currentTimeEntry.value.billable = props.timeEntry.billable;
|
|
||||||
currentTimeEntry.value.start = getDayJsInstance().utc().format();
|
currentTimeEntry.value.start = getDayJsInstance().utc().format();
|
||||||
await setActiveState(true);
|
await setActiveState(true);
|
||||||
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="px-3.5 py-2 grid grid-cols-5 border-b border-b-card-background-separator">
|
class="px-3.5 py-2 grid grid-cols-5 border-b border-b-card-background-separator">
|
||||||
<div class="col-span-4">
|
<div class="col-span-4">
|
||||||
<p class="font-medium text-white text-sm pb-1 truncate">
|
<p class="font-semibold text-white text-sm pb-1 overflow-ellipsis">
|
||||||
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
|
{{ title }}
|
||||||
<span v-else class="text-text-tertiary">No description</span>
|
|
||||||
</p>
|
</p>
|
||||||
<ProjectBadge
|
<ProjectBadge
|
||||||
:name="project?.name ?? 'No Project'"
|
:name="project?.name"
|
||||||
:color="project?.color"></ProjectBadge>
|
:color="project?.color"></ProjectBadge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
|
|||||||
@@ -1,52 +1,33 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useQuery } from '@tanstack/vue-query';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
||||||
import TeamActivityCardEntry from '@/Components/Dashboard/TeamActivityCardEntry.vue';
|
import TeamActivityCardEntry from '@/Components/Dashboard/TeamActivityCardEntry.vue';
|
||||||
import { UserGroupIcon } from '@heroicons/vue/20/solid';
|
import { UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
|
||||||
import { api } from '@/packages/api/src';
|
|
||||||
import { LoadingSpinner } from "@/packages/ui/src";
|
|
||||||
import { router } from '@inertiajs/vue3';
|
import { router } from '@inertiajs/vue3';
|
||||||
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
|
|
||||||
// Get the organization ID using the utility function
|
defineProps<{
|
||||||
const organizationId = computed(() => getCurrentOrganizationId());
|
latestTeamActivity: {
|
||||||
|
user_id: string;
|
||||||
// Set up the query
|
name: string;
|
||||||
const { data: latestTeamActivity, isLoading } = useQuery({
|
description: string;
|
||||||
queryKey: ['latestTeamActivity', organizationId],
|
time_entry_id: string;
|
||||||
queryFn: () => {
|
task_id: string;
|
||||||
return api.latestTeamActivity({
|
status: boolean;
|
||||||
params: {
|
}[];
|
||||||
organization: organizationId.value!
|
}>();
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
enabled: computed(() => !!organizationId.value),
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardCard title="Team Activity" :icon="UserGroupIcon">
|
<DashboardCard title="Team Activity" :icon="UserGroupIcon">
|
||||||
<div v-if="isLoading" class="flex justify-center items-center h-40">
|
<TeamActivityCardEntry
|
||||||
<LoadingSpinner />
|
v-for="activity in latestTeamActivity"
|
||||||
</div>
|
:key="activity.user_id"
|
||||||
<div v-else-if="latestTeamActivity">
|
:class="latestTeamActivity.length === 4 ? 'last:border-0' : ''"
|
||||||
<TeamActivityCardEntry
|
:name="activity.name"
|
||||||
v-for="activity in latestTeamActivity"
|
:description="activity.description"
|
||||||
:key="activity.time_entry_id"
|
:working="activity.status"></TeamActivityCardEntry>
|
||||||
:class="latestTeamActivity.length === 4 ? 'last:border-0' : ''"
|
|
||||||
:name="activity.name"
|
|
||||||
:description="activity.description"
|
|
||||||
:working="activity.status"></TeamActivityCardEntry>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-center text-gray-500 py-8">
|
|
||||||
No team activity found
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="latestTeamActivity && latestTeamActivity.length <= 1"
|
v-if="latestTeamActivity.length <= 1"
|
||||||
class="text-center flex flex-1 justify-center items-center">
|
class="text-center flex flex-1 justify-center items-center">
|
||||||
<div>
|
<div>
|
||||||
<UserGroupIcon
|
<UserGroupIcon
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
defineProps<{
|
defineProps<{
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string;
|
||||||
working?: boolean;
|
working?: boolean;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { use } from "echarts/core";
|
import { use } from 'echarts/core';
|
||||||
import { CanvasRenderer } from "echarts/renderers";
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
import { BarChart } from "echarts/charts";
|
import { BarChart } from 'echarts/charts';
|
||||||
import {
|
import {
|
||||||
GridComponent,
|
GridComponent,
|
||||||
LegendComponent,
|
LegendComponent,
|
||||||
TitleComponent,
|
TitleComponent,
|
||||||
TooltipComponent
|
TooltipComponent,
|
||||||
} from "echarts/components";
|
} from 'echarts/components';
|
||||||
import VChart, { THEME_KEY } from "vue-echarts";
|
import VChart, { THEME_KEY } from 'vue-echarts';
|
||||||
import { computed, provide } from "vue";
|
import { computed, provide, ref } from 'vue';
|
||||||
import StatCard from "@/Components/Common/StatCard.vue";
|
import StatCard from '@/Components/Common/StatCard.vue';
|
||||||
import { ClockIcon } from "@heroicons/vue/20/solid";
|
import { ClockIcon } from '@heroicons/vue/20/solid';
|
||||||
import CardTitle from "@/packages/ui/src/CardTitle.vue";
|
import CardTitle from '@/packages/ui/src/CardTitle.vue';
|
||||||
import LinearGradient from "zrender/lib/graphic/LinearGradient";
|
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
||||||
import ProjectsChartCard from "@/Components/Dashboard/ProjectsChartCard.vue";
|
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
|
||||||
import { formatHumanReadableDuration } from "@/packages/ui/src/utils/time";
|
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
||||||
import { formatCents } from "@/packages/ui/src/utils/money";
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import { getWeekStart } from "@/packages/ui/src/utils/settings";
|
import { getWeekStart } from '@/packages/ui/src/utils/settings';
|
||||||
import { useCssVar } from "@vueuse/core";
|
import { useCssVar } from '@vueuse/core';
|
||||||
import { getOrganizationCurrencyString } from "@/utils/money";
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
|
||||||
import { getCurrentOrganizationId } from "@/utils/useUser";
|
|
||||||
import { api } from "@/packages/api/src";
|
|
||||||
|
|
||||||
use([
|
use([
|
||||||
CanvasRenderer,
|
CanvasRenderer,
|
||||||
@@ -30,22 +27,85 @@ use([
|
|||||||
TitleComponent,
|
TitleComponent,
|
||||||
GridComponent,
|
GridComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
LegendComponent
|
LegendComponent,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
provide(THEME_KEY, "dark");
|
provide(THEME_KEY, 'dark');
|
||||||
const accentColor = useCssVar("--color-accent-quaternary");
|
|
||||||
|
const props = defineProps<{
|
||||||
|
weeklyProjectOverview: {
|
||||||
|
value: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}[];
|
||||||
|
totalWeeklyTime: number;
|
||||||
|
totalWeeklyBillableTime: number;
|
||||||
|
totalWeeklyBillableAmount: {
|
||||||
|
value: number;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
weeklyHistory: {
|
||||||
|
date: string;
|
||||||
|
duration: number;
|
||||||
|
}[];
|
||||||
|
}>();
|
||||||
|
const accentColor = useCssVar('--color-accent-quaternary');
|
||||||
|
|
||||||
|
const seriesData = computed(() => {
|
||||||
|
return props.weeklyHistory.map((el) => {
|
||||||
|
return {
|
||||||
|
value: el.duration,
|
||||||
|
...{
|
||||||
|
itemStyle: {
|
||||||
|
borderColor: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: 'rgba(' + accentColor.value + ',0.7)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: 'rgba(' + accentColor.value + ',0.5)',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
emphasis: {
|
||||||
|
color: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: 'rgba(' + accentColor.value + ',0.9)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: 'rgba(' + accentColor.value + ',0.7)',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
borderRadius: [12, 12, 0, 0],
|
||||||
|
color: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: 'rgba(' + accentColor.value + ',0.7)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: 'rgba(' + accentColor.value + ',0.5)',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const weekdays = computed(() => {
|
const weekdays = computed(() => {
|
||||||
const daysOrder = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
const daysOrder = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
const dayMapping: Record<string, string> = {
|
const dayMapping: Record<string, string> = {
|
||||||
monday: "Mon",
|
monday: 'Mon',
|
||||||
tuesday: "Tue",
|
tuesday: 'Tue',
|
||||||
wednesday: "Wed",
|
wednesday: 'Wed',
|
||||||
thursday: "Thu",
|
thursday: 'Thu',
|
||||||
friday: "Fri",
|
friday: 'Fri',
|
||||||
saturday: "Sat",
|
saturday: 'Sat',
|
||||||
sunday: "Sun"
|
sunday: 'Sun',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (dayMapping[getWeekStart()]) {
|
if (dayMapping[getWeekStart()]) {
|
||||||
@@ -62,179 +122,59 @@ const weekdays = computed(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const markLineColor = useCssVar("--color-border-secondary");
|
const markLineColor = useCssVar('--color-border-secondary');
|
||||||
|
|
||||||
|
const option = ref({
|
||||||
// Get the organization ID using the utility function
|
tooltip: {
|
||||||
const organizationId = computed(() => getCurrentOrganizationId());
|
trigger: 'item',
|
||||||
|
|
||||||
|
|
||||||
// Set up the queries
|
|
||||||
const { data: weeklyProjectOverview } = useQuery({
|
|
||||||
queryKey: ["weeklyProjectOverview", organizationId],
|
|
||||||
queryFn: () => {
|
|
||||||
return api.weeklyProjectOverview({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
enabled: computed(() => !!organizationId.value)
|
grid: {
|
||||||
});
|
top: 0,
|
||||||
|
right: 0,
|
||||||
const { data: totalWeeklyTime } = useQuery({
|
bottom: 50,
|
||||||
queryKey: ["totalWeeklyTime", organizationId],
|
left: 0,
|
||||||
queryFn: () => {
|
|
||||||
return api.totalWeeklyTime({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
enabled: computed(() => !!organizationId.value)
|
backgroundColor: 'transparent',
|
||||||
});
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
const { data: totalWeeklyBillableTime } = useQuery({
|
data: weekdays.value,
|
||||||
queryKey: ["totalWeeklyBillableTime", organizationId],
|
axisLine: {
|
||||||
queryFn: () => {
|
|
||||||
return api.totalWeeklyBillableTime({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: computed(() => !!organizationId.value)
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: totalWeeklyBillableAmount } = useQuery({
|
|
||||||
queryKey: ["totalWeeklyBillableAmount", organizationId],
|
|
||||||
queryFn: () => {
|
|
||||||
return api.totalWeeklyBillableAmount({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: computed(() => !!organizationId.value)
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: weeklyHistory } = useQuery({
|
|
||||||
queryKey: ["weeklyHistory", organizationId],
|
|
||||||
queryFn: () => {
|
|
||||||
return api.weeklyHistory({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: computed(() => !!organizationId.value)
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const seriesData = computed(() => {
|
|
||||||
if (!weeklyHistory.value) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return weeklyHistory.value?.map((el) => {
|
|
||||||
return {
|
|
||||||
value: el.duration,
|
|
||||||
...{
|
|
||||||
itemStyle: {
|
|
||||||
borderColor: new LinearGradient(0, 0, 0, 1, [
|
|
||||||
{
|
|
||||||
offset: 0,
|
|
||||||
color: "rgba(" + accentColor.value + ",0.7)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
color: "rgba(" + accentColor.value + ",0.5)"
|
|
||||||
}
|
|
||||||
]),
|
|
||||||
emphasis: {
|
|
||||||
color: new LinearGradient(0, 0, 0, 1, [
|
|
||||||
{
|
|
||||||
offset: 0,
|
|
||||||
color: "rgba(" + accentColor.value + ",0.9)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
color: "rgba(" + accentColor.value + ",0.7)"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
},
|
|
||||||
borderRadius: [12, 12, 0, 0],
|
|
||||||
color: new LinearGradient(0, 0, 0, 1, [
|
|
||||||
{
|
|
||||||
offset: 0,
|
|
||||||
color: "rgba(" + accentColor.value + ",0.7)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
color: "rgba(" + accentColor.value + ",0.5)"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const option = computed(() => {
|
|
||||||
return {
|
|
||||||
tooltip: {
|
|
||||||
trigger: "item"
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 50,
|
|
||||||
left: 0
|
|
||||||
},
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
xAxis: {
|
|
||||||
type: "category",
|
|
||||||
data: weekdays.value,
|
|
||||||
axisLine: {
|
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: "transparent" // Set desired color here
|
color: 'transparent', // Set desired color here
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
margin: 24,
|
margin: 24,
|
||||||
fontFamily: "Outfit, sans-serif"
|
fontFamily: 'Outfit, sans-serif',
|
||||||
},
|
},
|
||||||
axisTick: {
|
axisTick: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: "transparent" // Set desired color here
|
color: 'transparent', // Set desired color here
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: "value",
|
|
||||||
splitLine: {
|
|
||||||
lineStyle: {
|
|
||||||
color: markLineColor.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
series: [
|
},
|
||||||
{
|
yAxis: {
|
||||||
data: seriesData.value,
|
type: 'value',
|
||||||
type: "bar",
|
splitLine: {
|
||||||
tooltip: {
|
lineStyle: {
|
||||||
valueFormatter: (value: number) => {
|
color: markLineColor.value,
|
||||||
return formatHumanReadableDuration(value);
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
series: [
|
||||||
]
|
{
|
||||||
}
|
data: seriesData,
|
||||||
|
type: 'bar',
|
||||||
|
tooltip: {
|
||||||
|
valueFormatter: (value: number) => {
|
||||||
|
return formatHumanReadableDuration(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -245,35 +185,28 @@ const option = computed(() => {
|
|||||||
title="This Week"
|
title="This Week"
|
||||||
class="pb-8"
|
class="pb-8"
|
||||||
:icon="ClockIcon"></CardTitle>
|
:icon="ClockIcon"></CardTitle>
|
||||||
<v-chart
|
<v-chart :autoresize="true" class="chart" :option="option" />
|
||||||
v-if="weeklyHistory"
|
|
||||||
:autoresize="true" class="chart" :option="option" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Spent Time"
|
title="Spent Time"
|
||||||
:value="
|
:value="formatHumanReadableDuration(props.totalWeeklyTime)" />
|
||||||
totalWeeklyTime ?
|
|
||||||
formatHumanReadableDuration(totalWeeklyTime) : '--'" />
|
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Billable Time"
|
title="Billable Time"
|
||||||
:value="
|
:value="
|
||||||
totalWeeklyBillableTime ?
|
formatHumanReadableDuration(props.totalWeeklyBillableTime)
|
||||||
formatHumanReadableDuration(totalWeeklyBillableTime) : '--'
|
|
||||||
" />
|
" />
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Billable Amount"
|
title="Billable Amount"
|
||||||
:value="
|
:value="
|
||||||
totalWeeklyBillableAmount ?
|
|
||||||
formatCents(
|
formatCents(
|
||||||
totalWeeklyBillableAmount.value,
|
props.totalWeeklyBillableAmount.value,
|
||||||
getOrganizationCurrencyString()
|
getOrganizationCurrencyString()
|
||||||
) : '--'
|
)
|
||||||
" />
|
" />
|
||||||
<ProjectsChartCard
|
<ProjectsChartCard
|
||||||
v-if="weeklyProjectOverview"
|
|
||||||
:weekly-project-overview="
|
:weekly-project-overview="
|
||||||
weeklyProjectOverview
|
props.weeklyProjectOverview
|
||||||
"></ProjectsChartCard>
|
"></ProjectsChartCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const props = defineProps<{
|
|||||||
icon?: Component;
|
icon?: Component;
|
||||||
current?: boolean;
|
current?: boolean;
|
||||||
href: string;
|
href: string;
|
||||||
subItems?: { title: string; route: string, show: boolean }[];
|
subItems?: { title: string; route: string }[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const open = useSessionStorage('nav-collapse-state-' + props.title, true);
|
const open = useSessionStorage('nav-collapse-state-' + props.title, true);
|
||||||
@@ -66,7 +66,6 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
|
|||||||
:key="subItem.title"
|
:key="subItem.title"
|
||||||
class="w-full relative">
|
class="w-full relative">
|
||||||
<NavigationSidebarLink
|
<NavigationSidebarLink
|
||||||
v-if="subItem.show"
|
|
||||||
:title="subItem.title"
|
:title="subItem.title"
|
||||||
:current="route().current(subItem.route)"
|
:current="route().current(subItem.route)"
|
||||||
:href="
|
:href="
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
canUpdateOrganization,
|
canUpdateOrganization,
|
||||||
canViewClients,
|
canViewClients,
|
||||||
canViewMembers,
|
canViewMembers,
|
||||||
canViewProjects, canViewReport,
|
canViewProjects,
|
||||||
canViewTags,
|
canViewTags,
|
||||||
} from '@/utils/permissions';
|
} from '@/utils/permissions';
|
||||||
import { isBillingActivated } from '@/utils/billing';
|
import { isBillingActivated } from '@/utils/billing';
|
||||||
@@ -118,17 +118,14 @@ const page = usePage<{
|
|||||||
{
|
{
|
||||||
title: 'Overview',
|
title: 'Overview',
|
||||||
route: 'reporting',
|
route: 'reporting',
|
||||||
show: true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Detailed',
|
title: 'Detailed',
|
||||||
route: 'reporting.detailed',
|
route: 'reporting.detailed',
|
||||||
show: true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Shared',
|
title: 'Shared',
|
||||||
route: 'reporting.shared',
|
route: 'reporting.shared',
|
||||||
show: canViewReport()
|
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
:current="
|
:current="
|
||||||
|
|||||||
@@ -1,51 +1,100 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||||
import TimeTracker from "@/Components/TimeTracker.vue";
|
import TimeTracker from '@/Components/TimeTracker.vue';
|
||||||
import RecentlyTrackedTasksCard from "@/Components/Dashboard/RecentlyTrackedTasksCard.vue";
|
import RecentlyTrackedTasksCard from '@/Components/Dashboard/RecentlyTrackedTasksCard.vue';
|
||||||
import LastSevenDaysCard from "@/Components/Dashboard/LastSevenDaysCard.vue";
|
import LastSevenDaysCard from '@/Components/Dashboard/LastSevenDaysCard.vue';
|
||||||
import TeamActivityCard from "@/Components/Dashboard/TeamActivityCard.vue";
|
import TeamActivityCard from '@/Components/Dashboard/TeamActivityCard.vue';
|
||||||
import ThisWeekOverview from "@/Components/Dashboard/ThisWeekOverview.vue";
|
import ThisWeekOverview from '@/Components/Dashboard/ThisWeekOverview.vue';
|
||||||
import ActivityGraphCard from "@/Components/Dashboard/ActivityGraphCard.vue";
|
import ActivityGraphCard from '@/Components/Dashboard/ActivityGraphCard.vue';
|
||||||
import MainContainer from "@/packages/ui/src/MainContainer.vue";
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import { canViewMembers } from "@/utils/permissions";
|
import { canViewMembers } from '@/utils/permissions';
|
||||||
import { useQueryClient } from "@tanstack/vue-query";
|
import { router } from '@inertiajs/vue3';
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const props = defineProps<{
|
||||||
|
latestTasks: {
|
||||||
const refreshDashboardData = () => {
|
id: string;
|
||||||
// Invalidate all dashboard queries to trigger refetching
|
name: string;
|
||||||
queryClient.invalidateQueries({ queryKey: ["latestTasks"] });
|
project_name: string;
|
||||||
queryClient.invalidateQueries({ queryKey: ["lastSevenDays"] });
|
project_id: string;
|
||||||
queryClient.invalidateQueries({ queryKey: ["dailyTrackedHours"] });
|
}[];
|
||||||
queryClient.invalidateQueries({ queryKey: ["latestTeamActivity"] });
|
latestTeamActivity: {
|
||||||
queryClient.invalidateQueries({ queryKey: ["weeklyProjectOverview"] });
|
user_id: string;
|
||||||
queryClient.invalidateQueries({ queryKey: ["totalWeeklyTime"] });
|
name: string;
|
||||||
queryClient.invalidateQueries({ queryKey: ["totalWeeklyBillableTime"] });
|
description: string;
|
||||||
queryClient.invalidateQueries({ queryKey: ["totalWeeklyBillableAmount"] });
|
time_entry_id: string;
|
||||||
queryClient.invalidateQueries({ queryKey: ["weeklyHistory"] });
|
task_id: string;
|
||||||
};
|
status: boolean;
|
||||||
|
}[];
|
||||||
|
lastSevenDays: {
|
||||||
|
date: string;
|
||||||
|
duration: number; // Total duration in seconds
|
||||||
|
history: number[]; // Array representing the duration in seconds of the 3h windows for the day
|
||||||
|
}[];
|
||||||
|
dailyTrackedHours: { duration: number; date: string }[];
|
||||||
|
weeklyProjectOverview: {
|
||||||
|
value: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}[];
|
||||||
|
totalWeeklyTime: number;
|
||||||
|
totalWeeklyBillableTime: number;
|
||||||
|
totalWeeklyBillableAmount: {
|
||||||
|
value: number;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
weeklyHistory: {
|
||||||
|
date: string;
|
||||||
|
duration: number;
|
||||||
|
}[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function refreshDashboardData() {
|
||||||
|
router.reload({
|
||||||
|
only: [
|
||||||
|
'latestTasks',
|
||||||
|
'latestTeamActivity',
|
||||||
|
'lastSevenDays',
|
||||||
|
'dailyTrackedHours',
|
||||||
|
'weeklyProjectOverview',
|
||||||
|
'totalWeeklyTime',
|
||||||
|
'totalWeeklyBillableTime',
|
||||||
|
'totalWeeklyBillableAmount',
|
||||||
|
'weeklyHistory',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout title="Dashboard" data-testid="dashboard_view">
|
<AppLayout title="Dashboard" data-testid="dashboard_view">
|
||||||
<MainContainer
|
<MainContainer
|
||||||
class="pt-5 sm:pt-8 pb-4 sm:pb-6 border-b border-default-background-separator">
|
class="pt-5 sm:pt-8 pb-4 sm:pb-6 border-b border-default-background-separator">
|
||||||
<TimeTracker @change="refreshDashboardData"></TimeTracker>
|
<TimeTracker @change="refreshDashboardData"></TimeTracker>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
|
<MainContainer
|
||||||
<MainContainer
|
class="grid gap-5 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pt-3 sm:pt-5 pb-4 sm:pb-6 border-b border-default-background-separator items-stretch">
|
||||||
class="grid gap-5 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pt-3 sm:pt-5 pb-4 sm:pb-6 border-b border-default-background-separator items-stretch">
|
<RecentlyTrackedTasksCard
|
||||||
<RecentlyTrackedTasksCard></RecentlyTrackedTasksCard>
|
:latest-tasks="props.latestTasks"></RecentlyTrackedTasksCard>
|
||||||
<LastSevenDaysCard></LastSevenDaysCard>
|
<LastSevenDaysCard
|
||||||
<ActivityGraphCard></ActivityGraphCard>
|
:last7-days="props.lastSevenDays"></LastSevenDaysCard>
|
||||||
<TeamActivityCard
|
<ActivityGraphCard
|
||||||
v-if="canViewMembers()"
|
:daily-hours-tracked="
|
||||||
class="flex lg:hidden xl:flex">
|
props.dailyTrackedHours
|
||||||
</TeamActivityCard>
|
"></ActivityGraphCard>
|
||||||
</MainContainer>
|
<TeamActivityCard
|
||||||
<MainContainer class="py-5">
|
v-if="canViewMembers()"
|
||||||
<ThisWeekOverview></ThisWeekOverview>
|
class="flex lg:hidden xl:flex"
|
||||||
</MainContainer>
|
:latest-team-activity="
|
||||||
|
props.latestTeamActivity
|
||||||
|
"></TeamActivityCard>
|
||||||
|
</MainContainer>
|
||||||
|
<MainContainer class="py-5">
|
||||||
|
<ThisWeekOverview
|
||||||
|
:weekly-project-overview="props.weeklyProjectOverview"
|
||||||
|
:total-weekly-billable-amount="props.totalWeeklyBillableAmount"
|
||||||
|
:total-weekly-billable-time="props.totalWeeklyBillableTime"
|
||||||
|
:total-weekly-time="props.totalWeeklyTime"
|
||||||
|
:weekly-history="props.weeklyHistory"></ThisWeekOverview>
|
||||||
|
</MainContainer>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,331 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import FormSection from '@/Components/FormSection.vue';
|
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
|
||||||
import {computed, ref} from 'vue';
|
|
||||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
|
||||||
import {
|
|
||||||
api,
|
|
||||||
type ApiToken,
|
|
||||||
type CreateApiTokenBody
|
|
||||||
} from '@/packages/api/src';
|
|
||||||
import SectionBorder from "@/Components/SectionBorder.vue";
|
|
||||||
import DangerButton from "@/packages/ui/src/Buttons/DangerButton.vue";
|
|
||||||
import TextInput from "../../../packages/ui/src/Input/TextInput.vue";
|
|
||||||
import SecondaryButton from "../../../packages/ui/src/Buttons/SecondaryButton.vue";
|
|
||||||
import DialogModal from "@/packages/ui/src/DialogModal.vue";
|
|
||||||
import InputError from "@/packages/ui/src/Input/InputError.vue";
|
|
||||||
import ActionMessage from "@/Components/ActionMessage.vue";
|
|
||||||
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
|
|
||||||
import ActionSection from "@/Components/ActionSection.vue";
|
|
||||||
import {useForm} from "@inertiajs/vue3";
|
|
||||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/vue-query";
|
|
||||||
import {useNotificationsStore} from "@/utils/notification";
|
|
||||||
import {useClipboard} from "@vueuse/core";
|
|
||||||
import { formatDateTimeLocalized} from "../../../packages/ui/src/utils/time";
|
|
||||||
import {ClockIcon} from "@heroicons/vue/20/solid";
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const apiTokenBeingDeleted = ref<ApiToken | null>(null);
|
|
||||||
const apiTokenBeingRevoked = ref<ApiToken | null>(null);
|
|
||||||
|
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
|
||||||
const newToken = ref('');
|
|
||||||
|
|
||||||
const { copy, copied, isSupported } = useClipboard();
|
|
||||||
|
|
||||||
async function createApiToken(){
|
|
||||||
await handleApiRequestNotifications(
|
|
||||||
() =>
|
|
||||||
createApiTokenMutation.mutateAsync({
|
|
||||||
name: createApiTokenForm.name,
|
|
||||||
}),
|
|
||||||
'API Token successfully created',
|
|
||||||
'There was an error while creating the API Token',
|
|
||||||
(response) => {
|
|
||||||
createApiTokenForm.name = '';
|
|
||||||
displayingToken.value = true;
|
|
||||||
newToken.value = response.data.access_token;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const createApiTokenForm = useForm({
|
|
||||||
name: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
function confirmApiTokenDeletion (token: ApiToken) {
|
|
||||||
apiTokenBeingDeleted.value = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmApiTokenRevocation(token: ApiToken){
|
|
||||||
apiTokenBeingRevoked.value = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayingToken = ref(false);
|
|
||||||
|
|
||||||
async function deleteApiToken () {
|
|
||||||
if(apiTokenBeingDeleted.value){
|
|
||||||
await handleApiRequestNotifications(
|
|
||||||
() =>
|
|
||||||
deleteApiTokenMutation.mutateAsync(apiTokenBeingDeleted.value!.id),
|
|
||||||
'API Token successfully deleted',
|
|
||||||
'There was an error while deleting the API Token',
|
|
||||||
() => {
|
|
||||||
apiTokenBeingDeleted.value = null;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function revokeApiToken () {
|
|
||||||
if(apiTokenBeingRevoked.value){
|
|
||||||
await handleApiRequestNotifications(
|
|
||||||
() =>
|
|
||||||
revokeApiTokenMutation.mutateAsync(apiTokenBeingRevoked.value!.id),
|
|
||||||
'API Token successfully revoked',
|
|
||||||
'There was an error while revoking the API Token',
|
|
||||||
() => {
|
|
||||||
apiTokenBeingRevoked.value = null;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const { data: sharedReportResponseData } = useQuery({
|
|
||||||
queryKey: ['api-tokens'],
|
|
||||||
queryFn: () =>
|
|
||||||
api.getApiTokens(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokens = computed(() => {
|
|
||||||
return sharedReportResponseData.value?.data ?? [];
|
|
||||||
})
|
|
||||||
|
|
||||||
const createApiTokenMutation = useMutation({
|
|
||||||
mutationFn: async (apiToken: CreateApiTokenBody) => {
|
|
||||||
return await api.createApiToken(apiToken);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['api-tokens'] })
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteApiTokenMutation = useMutation({
|
|
||||||
mutationFn: async (apiTokenId: string) => {
|
|
||||||
return await api.deleteApiToken(undefined, {
|
|
||||||
params: {
|
|
||||||
apiToken: apiTokenId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['api-tokens'] })
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const revokeApiTokenMutation = useMutation({
|
|
||||||
mutationFn: async (apiTokenId: string) => {
|
|
||||||
return await api.revokeApiToken(undefined, {
|
|
||||||
params: {
|
|
||||||
apiToken: apiTokenId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['api-tokens'] })
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<!-- Generate API Token -->
|
|
||||||
<FormSection @submitted="createApiToken">
|
|
||||||
<template #title> Create API Token </template>
|
|
||||||
|
|
||||||
<template #description>
|
|
||||||
API tokens allow third-party services to authenticate with our
|
|
||||||
application on your behalf.
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #form>
|
|
||||||
<!-- Token Name -->
|
|
||||||
<div class="col-span-6 sm:col-span-4">
|
|
||||||
<InputLabel for="api_key_name" value="API Key Name" />
|
|
||||||
<TextInput
|
|
||||||
id="api_key_name"
|
|
||||||
v-model="createApiTokenForm.name"
|
|
||||||
type="text"
|
|
||||||
class="mt-1 block w-full" />
|
|
||||||
<InputError
|
|
||||||
:message="createApiTokenForm.errors.name"
|
|
||||||
class="mt-2" />
|
|
||||||
<div class="text-text-tertiary text-sm pt-3 flex space-x-1.5 font-medium items-center">
|
|
||||||
<ClockIcon class="w-4"></ClockIcon>
|
|
||||||
<span>
|
|
||||||
API Tokens are valid for 1 year
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<ActionMessage
|
|
||||||
:on="createApiTokenForm.recentlySuccessful"
|
|
||||||
class="me-3">
|
|
||||||
Created.
|
|
||||||
</ActionMessage>
|
|
||||||
|
|
||||||
<PrimaryButton
|
|
||||||
:class="{ 'opacity-25': createApiTokenForm.processing }"
|
|
||||||
:disabled="createApiTokenForm.processing">
|
|
||||||
Create API Key
|
|
||||||
</PrimaryButton>
|
|
||||||
</template>
|
|
||||||
</FormSection>
|
|
||||||
|
|
||||||
<div v-if="tokens.length > 0">
|
|
||||||
<SectionBorder />
|
|
||||||
|
|
||||||
<!-- Manage API Tokens -->
|
|
||||||
<div class="mt-10 sm:mt-0">
|
|
||||||
<ActionSection>
|
|
||||||
<template #title> Manage API Tokens </template>
|
|
||||||
|
|
||||||
<template #description>
|
|
||||||
You may delete or revoke any of your existing tokens if they are
|
|
||||||
no longer needed.
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- API Token List -->
|
|
||||||
<template #content>
|
|
||||||
<div class="divide-border-secondary divide-y">
|
|
||||||
<div
|
|
||||||
v-for="token in tokens"
|
|
||||||
:key="token.id"
|
|
||||||
class="flex items-center py-2.5 justify-between">
|
|
||||||
<div class="break-all text-white">
|
|
||||||
<div>{{ token.name }}</div>
|
|
||||||
<div class="text-sm text-text-tertiary space-x-3">
|
|
||||||
<span v-if="token.created_at">
|
|
||||||
Created at {{ formatDateTimeLocalized(token.created_at) }}
|
|
||||||
</span>
|
|
||||||
<span v-if="token.expires_at">
|
|
||||||
Expires at {{ formatDateTimeLocalized(token.expires_at) }}
|
|
||||||
</span>
|
|
||||||
<span v-if="token.revoked">
|
|
||||||
Revoked
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center ms-2">
|
|
||||||
<div
|
|
||||||
v-if="token.last_used_ago"
|
|
||||||
class="text-sm text-gray-400">
|
|
||||||
Last used {{ token.last_used_ago }}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-if="!token.revoked"
|
|
||||||
class="cursor-pointer ms-6 text-sm text-text-secondary"
|
|
||||||
:aria-label="'Revoke API Token ' + token.name"
|
|
||||||
@click="confirmApiTokenRevocation(token)">
|
|
||||||
Revoke
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="cursor-pointer ms-6 text-sm text-red-500"
|
|
||||||
:aria-label="'Delete API Token ' + token.name"
|
|
||||||
@click="confirmApiTokenDeletion(token)">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ActionSection>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Token Value Modal -->
|
|
||||||
<DialogModal :show="displayingToken" @close="displayingToken = false">
|
|
||||||
<template #title> API Token created successfully </template>
|
|
||||||
|
|
||||||
<template #content>
|
|
||||||
<div>
|
|
||||||
Please copy your new API token. For your security, it won't
|
|
||||||
be shown again.
|
|
||||||
<strong>This token is valid for one year</strong> unless you revoke it.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2 pt-6 w-full">
|
|
||||||
<TextInput v-if="newToken" disabled :model-value="newToken" class="flex-1 text-gray-500"></TextInput>
|
|
||||||
<PrimaryButton v-if="isSupported" @click="copy(newToken)">{{ copied ? 'Copied!' : 'Copy Token' }}</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<SecondaryButton @click="displayingToken = false">
|
|
||||||
Close
|
|
||||||
</SecondaryButton>
|
|
||||||
</template>
|
|
||||||
</DialogModal>
|
|
||||||
|
|
||||||
<!-- Delete Token Confirmation Modal -->
|
|
||||||
<ConfirmationModal
|
|
||||||
:show="apiTokenBeingDeleted != null"
|
|
||||||
@close="apiTokenBeingDeleted = null">
|
|
||||||
<template #title> Delete API Token </template>
|
|
||||||
|
|
||||||
<template #content>
|
|
||||||
Are you sure you would like to delete this API token?
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<SecondaryButton @click="apiTokenBeingDeleted = null">
|
|
||||||
Cancel
|
|
||||||
</SecondaryButton>
|
|
||||||
|
|
||||||
<DangerButton
|
|
||||||
class="ms-3"
|
|
||||||
:class="{ 'opacity-25': createApiTokenMutation.isPending.value }"
|
|
||||||
:disabled="createApiTokenMutation.isPending.value"
|
|
||||||
@click="deleteApiToken">
|
|
||||||
Delete
|
|
||||||
</DangerButton>
|
|
||||||
</template>
|
|
||||||
</ConfirmationModal>
|
|
||||||
|
|
||||||
<ConfirmationModal
|
|
||||||
:show="apiTokenBeingRevoked != null"
|
|
||||||
@close="apiTokenBeingRevoked = null">
|
|
||||||
<template #title> Revoke API Token </template>
|
|
||||||
|
|
||||||
<template #content>
|
|
||||||
Are you sure you would like to revoke this API token?
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<SecondaryButton @click="apiTokenBeingRevoked = null">
|
|
||||||
Cancel
|
|
||||||
</SecondaryButton>
|
|
||||||
|
|
||||||
<DangerButton
|
|
||||||
class="ms-3"
|
|
||||||
:class="{ 'opacity-25': revokeApiTokenMutation.isPending.value }"
|
|
||||||
:disabled="revokeApiTokenMutation.isPending.value"
|
|
||||||
@click="revokeApiToken">
|
|
||||||
Revoke
|
|
||||||
</DangerButton>
|
|
||||||
</template>
|
|
||||||
</ConfirmationModal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -9,7 +9,6 @@ import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfile
|
|||||||
import { usePage } from '@inertiajs/vue3';
|
import { usePage } from '@inertiajs/vue3';
|
||||||
import type { User } from '@/types/models';
|
import type { User } from '@/types/models';
|
||||||
import type { Session } from '@/types/jetstream';
|
import type { Session } from '@/types/jetstream';
|
||||||
import ApiTokensForm from "@/Pages/Profile/Partials/ApiTokensForm.vue";
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
confirmsTwoFactorAuthentication: boolean;
|
confirmsTwoFactorAuthentication: boolean;
|
||||||
@@ -66,9 +65,6 @@ const page = usePage<{
|
|||||||
<LogoutOtherBrowserSessionsForm
|
<LogoutOtherBrowserSessionsForm
|
||||||
:sessions="sessions"
|
:sessions="sessions"
|
||||||
class="mt-10 sm:mt-0" />
|
class="mt-10 sm:mt-0" />
|
||||||
<SectionBorder />
|
|
||||||
|
|
||||||
<ApiTokensForm></ApiTokensForm>
|
|
||||||
|
|
||||||
<template
|
<template
|
||||||
v-if="page.props.jetstream.hasAccountDeletionFeatures">
|
v-if="page.props.jetstream.hasAccountDeletionFeatures">
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ onMounted(() => {
|
|||||||
if (canViewProjectMembers()) {
|
if (canViewProjectMembers()) {
|
||||||
useProjectMembersStore().fetchProjectMembers(projectId);
|
useProjectMembersStore().fetchProjectMembers(projectId);
|
||||||
}
|
}
|
||||||
useTasksStore().fetchTasks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const showEditProjectModal = ref(false);
|
const showEditProjectModal = ref(false);
|
||||||
|
|||||||
@@ -464,11 +464,10 @@ const tableData = computed(() => {
|
|||||||
<div
|
<div
|
||||||
class="justify-end pr-6 flex items-center font-medium">
|
class="justify-end pr-6 flex items-center font-medium">
|
||||||
{{
|
{{
|
||||||
aggregatedTableTimeEntries.cost ?
|
|
||||||
formatCents(
|
formatCents(
|
||||||
aggregatedTableTimeEntries.cost,
|
aggregatedTableTimeEntries.cost,
|
||||||
getOrganizationCurrencyString()
|
getOrganizationCurrencyString()
|
||||||
) : '--'
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ import {
|
|||||||
PaginationRoot,
|
PaginationRoot,
|
||||||
} from 'radix-vue';
|
} from 'radix-vue';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
||||||
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
|
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
|
||||||
@@ -66,7 +66,7 @@ import type { ExportFormat } from '@/types/reporting';
|
|||||||
import { useNotificationsStore } from '@/utils/notification';
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import {canCreateProjects, canViewAllTimeEntries} from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
|
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
|
||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
@@ -98,7 +98,6 @@ function getFilterAttributes() {
|
|||||||
};
|
};
|
||||||
const params = {
|
const params = {
|
||||||
...defaultParams,
|
...defaultParams,
|
||||||
member_id: !canViewAllTimeEntries() ? getCurrentMembershipId() : undefined,
|
|
||||||
member_ids:
|
member_ids:
|
||||||
selectedMembers.value.length > 0
|
selectedMembers.value.length > 0
|
||||||
? selectedMembers.value
|
? selectedMembers.value
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ async function exportData() {
|
|||||||
const response = await handleApiRequestNotifications(
|
const response = await handleApiRequestNotifications(
|
||||||
() =>
|
() =>
|
||||||
api.exportOrganization(
|
api.exportOrganization(
|
||||||
undefined,
|
{},
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
organization: organizationId,
|
organization: organizationId,
|
||||||
|
|||||||
@@ -162,15 +162,6 @@ export type UpdateReportBody = ZodiosBodyByAlias<SolidTimeApi, 'updateReport'>;
|
|||||||
export type CreateReportBodyProperties = CreateReportBody['properties'];
|
export type CreateReportBodyProperties = CreateReportBody['properties'];
|
||||||
export type Report = ReportIndexResponse['data'][0];
|
export type Report = ReportIndexResponse['data'][0];
|
||||||
|
|
||||||
export type ApiTokenIndexResponse = ZodiosResponseByAlias<
|
|
||||||
SolidTimeApi,
|
|
||||||
'getApiTokens'
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type CreateApiTokenBody = ZodiosBodyByAlias<SolidTimeApi, 'createApiToken'>;
|
|
||||||
export type ApiToken = ApiTokenIndexResponse['data'][0];
|
|
||||||
|
|
||||||
|
|
||||||
const api = createApiClient('/api', { validate: 'none' });
|
const api = createApiClient('/api', { validate: 'none' });
|
||||||
|
|
||||||
export { createApiClient, api };
|
export { createApiClient, api };
|
||||||
|
|||||||
@@ -1,31 +1,6 @@
|
|||||||
import { makeApi, Zodios, type ZodiosOptions } from '@zodios/core';
|
import { makeApi, Zodios, type ZodiosOptions } from '@zodios/core';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const ApiTokenResource = z
|
|
||||||
.object({
|
|
||||||
id: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
revoked: z.boolean(),
|
|
||||||
scopes: z.array(z.string()),
|
|
||||||
created_at: z.string(),
|
|
||||||
expires_at: z.union([z.string(), z.null()]),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
const ApiTokenCollection = z.array(ApiTokenResource);
|
|
||||||
const ApiTokenStoreRequest = z
|
|
||||||
.object({ name: z.string().min(1).max(255) })
|
|
||||||
.passthrough();
|
|
||||||
const ApiTokenWithAccessTokenResource = z
|
|
||||||
.object({
|
|
||||||
id: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
revoked: z.boolean(),
|
|
||||||
scopes: z.array(z.string()),
|
|
||||||
created_at: z.string(),
|
|
||||||
expires_at: z.union([z.string(), z.null()]),
|
|
||||||
access_token: z.string(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
const ClientResource = z
|
const ClientResource = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -51,11 +26,9 @@ const ImportRequest = z
|
|||||||
const InvitationResource = z
|
const InvitationResource = z
|
||||||
.object({ id: z.string(), email: z.string(), role: z.string() })
|
.object({ id: z.string(), email: z.string(), role: z.string() })
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
const Role = z.enum(['owner', 'admin', 'manager', 'employee', 'placeholder']);
|
||||||
const InvitationStoreRequest = z
|
const InvitationStoreRequest = z
|
||||||
.object({
|
.object({ email: z.string().email(), role: Role })
|
||||||
email: z.string().email(),
|
|
||||||
role: z.enum(['admin', 'manager', 'employee']),
|
|
||||||
})
|
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const MemberResource = z
|
const MemberResource = z
|
||||||
.object({
|
.object({
|
||||||
@@ -68,15 +41,10 @@ const MemberResource = z
|
|||||||
billable_rate: z.union([z.number(), z.null()]),
|
billable_rate: z.union([z.number(), z.null()]),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const Role = z.enum(['owner', 'admin', 'manager', 'employee', 'placeholder']);
|
|
||||||
const MemberUpdateRequest = z
|
const MemberUpdateRequest = z
|
||||||
.object({ role: Role, billable_rate: z.union([z.number(), z.null()]) })
|
.object({ role: Role, billable_rate: z.union([z.number(), z.null()]) })
|
||||||
.partial()
|
.partial()
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const MemberMergeIntoRequest = z
|
|
||||||
.object({ member_id: z.string() })
|
|
||||||
.partial()
|
|
||||||
.passthrough();
|
|
||||||
const OrganizationResource = z
|
const OrganizationResource = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -222,6 +190,13 @@ const ReportStoreRequest = z
|
|||||||
timezone: z.union([z.string(), z.null()]).optional(),
|
timezone: z.union([z.string(), z.null()]).optional(),
|
||||||
})
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
|
'properties.member_ids': z.string().optional(),
|
||||||
|
'properties.client_ids': z.string().optional(),
|
||||||
|
'properties.project_ids': z.string().optional(),
|
||||||
|
'properties.tag_ids': z.string().optional(),
|
||||||
|
'properties.task_ids': z.string().optional(),
|
||||||
|
'properties.week_start': z.string().optional(),
|
||||||
|
'properties.timezone': z.string().optional(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const DetailedReportResource = z
|
const DetailedReportResource = z
|
||||||
@@ -484,23 +459,19 @@ const PersonalMembershipResource = z
|
|||||||
role: z.string(),
|
role: z.string(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
const PersonalMembershipCollection = z.array(PersonalMembershipResource);
|
||||||
|
|
||||||
export const schemas = {
|
export const schemas = {
|
||||||
ApiTokenResource,
|
|
||||||
ApiTokenCollection,
|
|
||||||
ApiTokenStoreRequest,
|
|
||||||
ApiTokenWithAccessTokenResource,
|
|
||||||
ClientResource,
|
ClientResource,
|
||||||
ClientCollection,
|
ClientCollection,
|
||||||
ClientStoreRequest,
|
ClientStoreRequest,
|
||||||
ClientUpdateRequest,
|
ClientUpdateRequest,
|
||||||
ImportRequest,
|
ImportRequest,
|
||||||
InvitationResource,
|
InvitationResource,
|
||||||
|
Role,
|
||||||
InvitationStoreRequest,
|
InvitationStoreRequest,
|
||||||
MemberResource,
|
MemberResource,
|
||||||
Role,
|
|
||||||
MemberUpdateRequest,
|
MemberUpdateRequest,
|
||||||
MemberMergeIntoRequest,
|
|
||||||
OrganizationResource,
|
OrganizationResource,
|
||||||
OrganizationUpdateRequest,
|
OrganizationUpdateRequest,
|
||||||
ProjectResource,
|
ProjectResource,
|
||||||
@@ -531,6 +502,7 @@ export const schemas = {
|
|||||||
TimeEntryUpdateRequest,
|
TimeEntryUpdateRequest,
|
||||||
UserResource,
|
UserResource,
|
||||||
PersonalMembershipResource,
|
PersonalMembershipResource,
|
||||||
|
PersonalMembershipCollection,
|
||||||
};
|
};
|
||||||
|
|
||||||
const endpoints = makeApi([
|
const endpoints = makeApi([
|
||||||
@@ -611,332 +583,6 @@ const endpoints = makeApi([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/daily-tracked-hours',
|
|
||||||
alias: 'dailyTrackedHours',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.array(
|
|
||||||
z
|
|
||||||
.object({ date: z.string(), duration: z.number().int() })
|
|
||||||
.passthrough()
|
|
||||||
),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/last-seven-days',
|
|
||||||
alias: 'lastSevenDays',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.array(
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
date: z.string(),
|
|
||||||
duration: z.number().int(),
|
|
||||||
history: z.array(z.number().int()),
|
|
||||||
})
|
|
||||||
.passthrough()
|
|
||||||
),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/latest-tasks',
|
|
||||||
alias: 'latestTasks',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.array(
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
task_id: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
description: z.union([z.string(), z.null()]),
|
|
||||||
status: z.boolean(),
|
|
||||||
time_entry_id: z.union([z.string(), z.null()]),
|
|
||||||
})
|
|
||||||
.passthrough()
|
|
||||||
),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/latest-team-activity',
|
|
||||||
alias: 'latestTeamActivity',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.array(
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
member_id: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
description: z.union([z.string(), z.null()]),
|
|
||||||
time_entry_id: z.string(),
|
|
||||||
task_id: z.union([z.string(), z.null()]),
|
|
||||||
status: z.boolean(),
|
|
||||||
})
|
|
||||||
.passthrough()
|
|
||||||
),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/total-weekly-billable-amount',
|
|
||||||
alias: 'totalWeeklyBillableAmount',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z
|
|
||||||
.object({ value: z.number().int(), currency: z.string() })
|
|
||||||
.passthrough(),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/total-weekly-billable-time',
|
|
||||||
alias: 'totalWeeklyBillableTime',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.number().int(),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/total-weekly-time',
|
|
||||||
alias: 'totalWeeklyTime',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.number().int(),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/weekly-history',
|
|
||||||
alias: 'weeklyHistory',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.array(
|
|
||||||
z
|
|
||||||
.object({ date: z.string(), duration: z.number().int() })
|
|
||||||
.passthrough()
|
|
||||||
),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/weekly-project-overview',
|
|
||||||
alias: 'weeklyProjectOverview',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.array(
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
value: z.number().int(),
|
|
||||||
name: z.string(),
|
|
||||||
color: z.string(),
|
|
||||||
})
|
|
||||||
.passthrough()
|
|
||||||
),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/v1/organizations/:organization/clients',
|
path: '/v1/organizations/:organization/clients',
|
||||||
@@ -1140,6 +786,11 @@ const endpoints = makeApi([
|
|||||||
alias: 'exportOrganization',
|
alias: 'exportOrganization',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'body',
|
||||||
|
type: 'Body',
|
||||||
|
schema: z.object({}).partial().passthrough(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
type: 'Path',
|
type: 'Path',
|
||||||
@@ -1471,6 +1122,11 @@ const endpoints = makeApi([
|
|||||||
alias: 'resendInvitationEmail',
|
alias: 'resendInvitationEmail',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'body',
|
||||||
|
type: 'Body',
|
||||||
|
schema: z.object({}).partial().passthrough(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
type: 'Path',
|
type: 'Path',
|
||||||
@@ -1501,71 +1157,6 @@ const endpoints = makeApi([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: 'post',
|
|
||||||
path: '/v1/organizations/:organization/member/:member/merge-into',
|
|
||||||
alias: 'mergeMember',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'body',
|
|
||||||
type: 'Body',
|
|
||||||
schema: z
|
|
||||||
.object({ member_id: z.string() })
|
|
||||||
.partial()
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'member',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.null(),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
description: `API exception`,
|
|
||||||
schema: z
|
|
||||||
.object({
|
|
||||||
error: z.boolean(),
|
|
||||||
key: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 422,
|
|
||||||
description: `Validation error`,
|
|
||||||
schema: z
|
|
||||||
.object({
|
|
||||||
message: z.string(),
|
|
||||||
errors: z.record(z.array(z.string())),
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/v1/organizations/:organization/members',
|
path: '/v1/organizations/:organization/members',
|
||||||
@@ -1754,6 +1345,11 @@ const endpoints = makeApi([
|
|||||||
alias: 'invitePlaceholder',
|
alias: 'invitePlaceholder',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'body',
|
||||||
|
type: 'Body',
|
||||||
|
schema: z.object({}).partial().passthrough(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
type: 'Path',
|
type: 'Path',
|
||||||
@@ -1798,9 +1394,14 @@ const endpoints = makeApi([
|
|||||||
{
|
{
|
||||||
method: 'post',
|
method: 'post',
|
||||||
path: '/v1/organizations/:organization/members/:member/make-placeholder',
|
path: '/v1/organizations/:organization/members/:member/make-placeholder',
|
||||||
alias: 'makePlaceholder',
|
alias: 'v1.members.make-placeholder',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'body',
|
||||||
|
type: 'Body',
|
||||||
|
schema: z.object({}).partial().passthrough(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
type: 'Path',
|
type: 'Path',
|
||||||
@@ -3013,11 +2614,6 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.enum(['true', 'false']).optional(),
|
schema: z.enum(['true', 'false']).optional(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'user_id',
|
|
||||||
type: 'Query',
|
|
||||||
schema: z.string().optional(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'member_ids',
|
name: 'member_ids',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -3043,6 +2639,11 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.array(z.string()).min(1).optional(),
|
schema: z.array(z.string()).min(1).optional(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'user_id',
|
||||||
|
type: 'Query',
|
||||||
|
schema: z.string().optional(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
response: z
|
response: z
|
||||||
.object({
|
.object({
|
||||||
@@ -3451,7 +3052,7 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
.object({
|
.object({
|
||||||
key: z.union([z.string(), z.null()]),
|
key: z.union([z.string(), z.null()]),
|
||||||
seconds: z.number().int(),
|
seconds: z.number().int(),
|
||||||
cost: z.union([z.number(), z.null()]),
|
cost: z.number().int(),
|
||||||
grouped_type: z.union([
|
grouped_type: z.union([
|
||||||
z.string(),
|
z.string(),
|
||||||
z.null(),
|
z.null(),
|
||||||
@@ -3467,10 +3068,7 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
seconds: z
|
seconds: z
|
||||||
.number()
|
.number()
|
||||||
.int(),
|
.int(),
|
||||||
cost: z.union([
|
cost: z.number().int(),
|
||||||
z.number(),
|
|
||||||
z.null(),
|
|
||||||
]),
|
|
||||||
grouped_type: z.null(),
|
grouped_type: z.null(),
|
||||||
grouped_data: z.null(),
|
grouped_data: z.null(),
|
||||||
})
|
})
|
||||||
@@ -3484,7 +3082,7 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
z.null(),
|
z.null(),
|
||||||
]),
|
]),
|
||||||
seconds: z.number().int(),
|
seconds: z.number().int(),
|
||||||
cost: z.union([z.number(), z.null()]),
|
cost: z.number().int(),
|
||||||
})
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
})
|
})
|
||||||
@@ -3840,163 +3438,6 @@ The report is considered public if the `is_public` field is set to &#x
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/users/me/api-tokens',
|
|
||||||
alias: 'getApiTokens',
|
|
||||||
description: `This endpoint is independent of organization.`,
|
|
||||||
requestFormat: 'json',
|
|
||||||
response: z.object({ data: ApiTokenCollection }).passthrough(),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'post',
|
|
||||||
path: '/v1/users/me/api-tokens',
|
|
||||||
alias: 'createApiToken',
|
|
||||||
description: `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.`,
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'body',
|
|
||||||
type: 'Body',
|
|
||||||
schema: z
|
|
||||||
.object({ name: z.string().min(1).max(255) })
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z
|
|
||||||
.object({ data: ApiTokenWithAccessTokenResource })
|
|
||||||
.passthrough(),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
description: `API exception`,
|
|
||||||
schema: z
|
|
||||||
.object({
|
|
||||||
error: z.boolean(),
|
|
||||||
key: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 422,
|
|
||||||
description: `Validation error`,
|
|
||||||
schema: z
|
|
||||||
.object({
|
|
||||||
message: z.string(),
|
|
||||||
errors: z.record(z.array(z.string())),
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'delete',
|
|
||||||
path: '/v1/users/me/api-tokens/:apiToken',
|
|
||||||
alias: 'deleteApiToken',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'apiToken',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.null(),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
description: `API exception`,
|
|
||||||
schema: z
|
|
||||||
.object({
|
|
||||||
error: z.boolean(),
|
|
||||||
key: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'post',
|
|
||||||
path: '/v1/users/me/api-tokens/:apiToken/revoke',
|
|
||||||
alias: 'revokeApiToken',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'apiToken',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.null(),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
description: `API exception`,
|
|
||||||
schema: z
|
|
||||||
.object({
|
|
||||||
error: z.boolean(),
|
|
||||||
key: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/v1/users/me/memberships',
|
path: '/v1/users/me/memberships',
|
||||||
@@ -4004,7 +3445,7 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
description: `This endpoint is independent of organization.`,
|
description: `This endpoint is independent of organization.`,
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
response: z
|
response: z
|
||||||
.object({ data: z.array(PersonalMembershipResource) })
|
.object({ data: PersonalMembershipCollection })
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user