mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-09 08:42:15 +01:00
Compare commits
3 Commits
feature/si
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdf783ce24 | ||
|
|
480c44771e | ||
|
|
ed1ffaf0db |
216
.github/workflows/build-onpremise.yml
vendored
216
.github/workflows/build-onpremise.yml
vendored
@@ -1,216 +0,0 @@
|
|||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/build-onpremise.yml'
|
|
||||||
- 'docker/prod/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
contents: read
|
|
||||||
attestations: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOCKER_REPO: registry.on-premise.solidtime.io/solidtime/solidtime
|
|
||||||
|
|
||||||
name: Build - On Premise
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- runs-on: "ubuntu-24.04-arm"
|
|
||||||
platform: "linux/arm64"
|
|
||||||
- runs-on: "ubuntu-24.04"
|
|
||||||
platform: "linux/amd64"
|
|
||||||
runs-on: ${{ matrix.runs-on }}
|
|
||||||
timeout-minutes: 90
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: "Check out code"
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
|
||||||
|
|
||||||
- name: "Get build"
|
|
||||||
id: release-build
|
|
||||||
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: "Get Previous tag (normal push)"
|
|
||||||
id: previoustag
|
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
|
||||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
|
||||||
with:
|
|
||||||
prefix: "v"
|
|
||||||
|
|
||||||
- name: "Get version"
|
|
||||||
id: release-version
|
|
||||||
run: |
|
|
||||||
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
|
||||||
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
|
||||||
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
|
|
||||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "ERROR: No previous tag found";
|
|
||||||
exit 1;
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
version=$(echo "${{ github.ref }}" | cut -c 12-)
|
|
||||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: "Copy .env template for production"
|
|
||||||
run: |
|
|
||||||
cp .env.production .env
|
|
||||||
rm .env.production .env.ci .env.example
|
|
||||||
|
|
||||||
- name: "Add version to .env"
|
|
||||||
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
|
|
||||||
|
|
||||||
- name: "Add build to .env"
|
|
||||||
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
|
|
||||||
|
|
||||||
- name: "Output .env"
|
|
||||||
run: cat .env
|
|
||||||
|
|
||||||
- name: "Setup PHP with PECL extension"
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.3'
|
|
||||||
extensions: mbstring, dom, fileinfo, pgsql
|
|
||||||
|
|
||||||
- name: "Install dependencies"
|
|
||||||
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
|
||||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
|
||||||
|
|
||||||
- name: "Use Node.js"
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20.x'
|
|
||||||
|
|
||||||
- name: "Checkout invoicing extension"
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: solidtime-io/extension-invoicing
|
|
||||||
path: extensions/Invoicing
|
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
|
||||||
|
|
||||||
- name: "Install composer dependencies in invoicing extension"
|
|
||||||
run: cd extensions/Invoicing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
|
||||||
|
|
||||||
- name: "Install npm dependencies in invoicing extension"
|
|
||||||
run: cd extensions/Invoicing && npm ci
|
|
||||||
|
|
||||||
- name: "Activate invoicing extension"
|
|
||||||
run: php artisan module:enable Invoicing
|
|
||||||
|
|
||||||
- name: "Install npm dependencies"
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: "Build"
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: "Prepare"
|
|
||||||
run: |
|
|
||||||
platform=${{ matrix.platform }}
|
|
||||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: "Docker meta"
|
|
||||||
id: "meta"
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
${{ env.DOCKER_REPO }}
|
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: registry.on-premise.solidtime.io
|
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
|
||||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
|
||||||
|
|
||||||
- name: "Set up QEMU"
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: "Build and push by digest"
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/prod/Dockerfile
|
|
||||||
build-args: |
|
|
||||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
outputs: type=image,"name=${{ env.DOCKER_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: "Export digest"
|
|
||||||
run: |
|
|
||||||
mkdir -p ${{ runner.temp }}/digests
|
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
|
||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
|
||||||
|
|
||||||
- name: "Upload digest"
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}
|
|
||||||
path: ${{ runner.temp }}/digests/*
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
merge:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 90
|
|
||||||
needs:
|
|
||||||
- build
|
|
||||||
steps:
|
|
||||||
- name: "Download digests"
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: ${{ runner.temp }}/digests
|
|
||||||
pattern: digests-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: registry.on-premise.solidtime.io
|
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
|
||||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: "Docker meta"
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
${{ env.DOCKER_REPO }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
|
|
||||||
- name: "Create manifest list and push"
|
|
||||||
working-directory: ${{ runner.temp }}/digests
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
|
|
||||||
|
|
||||||
- name: "Inspect image"
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools inspect ${{ env.DOCKER_REPO }}:${{ steps.meta.outputs.version }}
|
|
||||||
@@ -41,8 +41,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
{
|
{
|
||||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||||
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
||||||
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
|
||||||
|
|
||||||
/** @var BillingContract $billing */
|
/** @var BillingContract $billing */
|
||||||
$billing = app(BillingContract::class);
|
$billing = app(BillingContract::class);
|
||||||
|
|
||||||
@@ -51,7 +50,6 @@ class HandleInertiaRequests extends Middleware
|
|||||||
return array_merge(parent::share($request), [
|
return array_merge(parent::share($request), [
|
||||||
'has_billing_extension' => $hasBilling,
|
'has_billing_extension' => $hasBilling,
|
||||||
'has_invoicing_extension' => $hasInvoicing,
|
'has_invoicing_extension' => $hasInvoicing,
|
||||||
'has_services_extension' => $hasServices,
|
|
||||||
'billing' => $currentOrganization !== null ? [
|
'billing' => $currentOrganization !== null ? [
|
||||||
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
||||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Source: https://helgesver.re/articles/laravel-sail-create-minio-bucket-automatically
|
# Source: https://helgesver.re/articles/laravel-sail-create-minio-bucket-automatically
|
||||||
|
|
||||||
/usr/bin/mc alias set local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
|
/usr/bin/mc config host add local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
|
||||||
/usr/bin/mc rm -r --force local/${S3_BUCKET};
|
/usr/bin/mc rm -r --force local/${S3_BUCKET};
|
||||||
/usr/bin/mc mb --ignore-existing local/${S3_BUCKET};
|
/usr/bin/mc mb --ignore-existing local/${S3_BUCKET};
|
||||||
/usr/bin/mc anonymous set public local/${S3_BUCKET};
|
/usr/bin/mc anonymous set public local/${S3_BUCKET};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ RUN CGO_ENABLED=1 \
|
|||||||
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
||||||
CGO_CFLAGS=$(php-config --includes) \
|
CGO_CFLAGS=$(php-config --includes) \
|
||||||
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
||||||
xcaddy build v2.10.0 \
|
xcaddy build \
|
||||||
--output /usr/local/bin/frankenphp \
|
--output /usr/local/bin/frankenphp \
|
||||||
--with github.com/dunglas/frankenphp=./ \
|
--with github.com/dunglas/frankenphp=./ \
|
||||||
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
||||||
|
|||||||
79
package-lock.json
generated
79
package-lock.json
generated
@@ -7,11 +7,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.6.0",
|
"@floating-ui/core": "^1.6.0",
|
||||||
"@floating-ui/vue": "^1.0.6",
|
"@floating-ui/vue": "^1.0.6",
|
||||||
"@fullcalendar/core": "^6.1.18",
|
|
||||||
"@fullcalendar/daygrid": "^6.1.18",
|
|
||||||
"@fullcalendar/interaction": "^6.1.18",
|
|
||||||
"@fullcalendar/timegrid": "^6.1.18",
|
|
||||||
"@fullcalendar/vue3": "^6.1.18",
|
|
||||||
"@heroicons/vue": "^2.1.1",
|
"@heroicons/vue": "^2.1.1",
|
||||||
"@rushstack/eslint-patch": "^1.10.5",
|
"@rushstack/eslint-patch": "^1.10.5",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
@@ -23,7 +18,6 @@
|
|||||||
"@vue/eslint-config-typescript": "^14.3.0",
|
"@vue/eslint-config-typescript": "^14.3.0",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.8.2",
|
||||||
"@vueuse/integrations": "^12.5.0",
|
"@vueuse/integrations": "^12.5.0",
|
||||||
"chroma-js": "3.1.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
@@ -45,7 +39,6 @@
|
|||||||
"@playwright/test": "^1.41.1",
|
"@playwright/test": "^1.41.1",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/chroma-js": "2.4.5",
|
|
||||||
"@types/node": "^22.10.10",
|
"@types/node": "^22.10.10",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"@vue/tsconfig": "^0.5.1",
|
"@vue/tsconfig": "^0.5.1",
|
||||||
@@ -1034,55 +1027,6 @@
|
|||||||
"vue-demi": ">=0.13.0"
|
"vue-demi": ">=0.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fullcalendar/core": {
|
|
||||||
"version": "6.1.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.18.tgz",
|
|
||||||
"integrity": "sha512-cD7XtZIZZ87Cg2+itnpsONCsZ89VIfLLDZ22pQX4IQVWlpYUB3bcCf878DhWkqyEen6dhi5ePtBoqYgm5K+0fQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"preact": "~10.12.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fullcalendar/daygrid": {
|
|
||||||
"version": "6.1.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.18.tgz",
|
|
||||||
"integrity": "sha512-s452Zle1SdMEzZDw+pDczm8m3JLIZzS9ANMThXTnqeqJewW1gqNFYas18aHypJSgF9Fh9rDJjTSUw04BpXB/Mg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@fullcalendar/core": "~6.1.18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fullcalendar/interaction": {
|
|
||||||
"version": "6.1.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.18.tgz",
|
|
||||||
"integrity": "sha512-f/mD5RTjzw+Q6MGTMZrLCgIrQLIUUO9NV/58aM2J6ZBQZeRlNizDqmqldqyG+j49zj2vFhUfZibPrVKWm5yA4Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@fullcalendar/core": "~6.1.18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fullcalendar/timegrid": {
|
|
||||||
"version": "6.1.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.18.tgz",
|
|
||||||
"integrity": "sha512-T/ouhs+T1tM8JcW7Cjx+KiohL/qQWKqvRITwjol8ktJ1e1N/6noC40/obR1tyolqOxMRWHjJkYoj9fUqfoez9A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@fullcalendar/daygrid": "~6.1.18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@fullcalendar/core": "~6.1.18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fullcalendar/vue3": {
|
|
||||||
"version": "6.1.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.18.tgz",
|
|
||||||
"integrity": "sha512-YMagwTumxsIx3GFYWLa9Yr73EMA+JuH6S3EeZGS+rEjvG5fDGdf+33rxGMzmw+LdO7SWi3ctbzRnJlv3fnm3RQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@fullcalendar/core": "~6.1.18",
|
|
||||||
"vue": "^3.0.11"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@heroicons/vue": {
|
"node_modules/@heroicons/vue": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
||||||
@@ -1898,13 +1842,6 @@
|
|||||||
"vue": "^2.7.0 || ^3.0.0"
|
"vue": "^2.7.0 || ^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/chroma-js": {
|
|
||||||
"version": "2.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.5.tgz",
|
|
||||||
"integrity": "sha512-6ISjhzJViaPCy2q2e6PgK+8HcHQDQ0V2LDiKmYAh+jJlLqDa6HbwDh0wOevHY0kHHUx0iZwjSRbVD47WOUx5EQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||||
@@ -2940,12 +2877,6 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chroma-js": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==",
|
|
||||||
"license": "(BSD-3-Clause AND Apache-2.0)"
|
|
||||||
},
|
|
||||||
"node_modules/class-variance-authority": {
|
"node_modules/class-variance-authority": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||||
@@ -5209,16 +5140,6 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/preact": {
|
|
||||||
"version": "10.12.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
|
||||||
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/preact"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
"@playwright/test": "^1.41.1",
|
"@playwright/test": "^1.41.1",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/chroma-js": "2.4.5",
|
|
||||||
"@types/node": "^22.10.10",
|
"@types/node": "^22.10.10",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"@vue/tsconfig": "^0.5.1",
|
"@vue/tsconfig": "^0.5.1",
|
||||||
@@ -40,11 +39,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.6.0",
|
"@floating-ui/core": "^1.6.0",
|
||||||
"@floating-ui/vue": "^1.0.6",
|
"@floating-ui/vue": "^1.0.6",
|
||||||
"@fullcalendar/core": "^6.1.18",
|
|
||||||
"@fullcalendar/daygrid": "^6.1.18",
|
|
||||||
"@fullcalendar/interaction": "^6.1.18",
|
|
||||||
"@fullcalendar/timegrid": "^6.1.18",
|
|
||||||
"@fullcalendar/vue3": "^6.1.18",
|
|
||||||
"@heroicons/vue": "^2.1.1",
|
"@heroicons/vue": "^2.1.1",
|
||||||
"@rushstack/eslint-patch": "^1.10.5",
|
"@rushstack/eslint-patch": "^1.10.5",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
@@ -56,7 +50,6 @@
|
|||||||
"@vue/eslint-config-typescript": "^14.3.0",
|
"@vue/eslint-config-typescript": "^14.3.0",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.8.2",
|
||||||
"@vueuse/integrations": "^12.5.0",
|
"@vueuse/integrations": "^12.5.0",
|
||||||
"chroma-js": "3.1.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
|
|||||||
@@ -46,16 +46,14 @@
|
|||||||
|
|
||||||
--color-accent-default: rgba(var(--color-accent-300), 0.2);
|
--color-accent-default: rgba(var(--color-accent-300), 0.2);
|
||||||
--color-accent-foreground: rgb(var(--color-accent-100));
|
--color-accent-foreground: rgb(var(--color-accent-100));
|
||||||
--theme-color-default-background: var(--color-bg-primary);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root.light {
|
:root.light {
|
||||||
--color-bg-primary: #FFFFFF;
|
--color-bg-primary: #F5F5F5;
|
||||||
--color-bg-secondary: #f7f7f8;
|
--color-bg-secondary: #f7f7f8;
|
||||||
--color-bg-tertiary: #eeeeef;
|
--color-bg-tertiary: #e1e1e3;
|
||||||
--color-bg-quaternary: #e1e1e3;
|
--color-bg-quaternary: #ffffff;
|
||||||
--color-bg-background: #F5F5F5;
|
--color-bg-background: #ffffff;
|
||||||
--color-text-primary: #18181b;
|
--color-text-primary: #18181b;
|
||||||
--color-text-secondary: #3f3f46;
|
--color-text-secondary: #3f3f46;
|
||||||
--color-text-tertiary: #57575C;
|
--color-text-tertiary: #57575C;
|
||||||
@@ -65,14 +63,14 @@
|
|||||||
--color-border-tertiary: #dfdfdf;
|
--color-border-tertiary: #dfdfdf;
|
||||||
--color-border-quaternary: #d1d1d1;
|
--color-border-quaternary: #d1d1d1;
|
||||||
--color-input-border-active: rgba(0,0,0,0.3);
|
--color-input-border-active: rgba(0,0,0,0.3);
|
||||||
--theme-color-menu-active: var(--color-bg-quaternary);
|
--theme-color-menu-active: var(--color-bg-tertiary);
|
||||||
|
|
||||||
--theme-color-card-background: var(--color-bg-primary);
|
--theme-color-card-background: var(--color-bg-quaternary);
|
||||||
--theme-color-card-background-active: var(--color-bg-tertiary);
|
--theme-color-card-background-active: var(--color-bg-primary);
|
||||||
|
|
||||||
--theme-color-chart: var(--color-accent-400);
|
--theme-color-chart: var(--color-accent-400);
|
||||||
|
|
||||||
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
|
--theme-shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
--theme-color-row-background: var(--theme-color-card-background);
|
--theme-color-row-background: var(--theme-color-card-background);
|
||||||
@@ -87,18 +85,17 @@
|
|||||||
--theme-color-button-primary-border: rgba(var(--color-accent-600), 1);
|
--theme-color-button-primary-border: rgba(var(--color-accent-600), 1);
|
||||||
--theme-color-button-primary-text: #FFFFFF;
|
--theme-color-button-primary-text: #FFFFFF;
|
||||||
|
|
||||||
--theme-color-input-background: var(--color-bg-primary);
|
--theme-color-input-background: var(--color-bg-quaternary);
|
||||||
|
|
||||||
--theme-color-input-select-active: rgb(var(--color-accent-400));
|
--theme-color-input-select-active: rgb(var(--color-accent-400));
|
||||||
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
|
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
|
||||||
|
|
||||||
--color-accent-default: rgb(var(--color-accent-100));
|
--color-accent-default: rgb(var(--color-accent-100));
|
||||||
--color-accent-foreground: rgb(var(--color-accent-800));
|
--color-accent-foreground: rgb(var(--color-accent-800));
|
||||||
--theme-color-default-background: #FCFCFC;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--theme-color-default-background: var(--color-bg-primary);
|
||||||
--theme-color-icon-active: rgb(var(--color-text-tertiary));
|
--theme-color-icon-active: rgb(var(--color-text-tertiary));
|
||||||
--theme-color-card-background-separator: var(--color-border-tertiary);
|
--theme-color-card-background-separator: var(--color-border-tertiary);
|
||||||
--theme-color-card-border: var(--color-border-secondary);
|
--theme-color-card-border: var(--color-border-secondary);
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
|
||||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
|
||||||
import { onMounted, ref } from 'vue';
|
|
||||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
|
||||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
|
||||||
import { useForm, usePage } from '@inertiajs/vue3';
|
|
||||||
import type { User } from '@/types/models';
|
|
||||||
import { useSessionStorage } from '@vueuse/core';
|
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
|
||||||
const saving = defineModel('saving', { default: false });
|
|
||||||
|
|
||||||
const timezone = ref('');
|
|
||||||
const userTimezone = ref('');
|
|
||||||
|
|
||||||
const page = usePage<{
|
|
||||||
auth: {
|
|
||||||
user: User;
|
|
||||||
};
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false);
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
||||||
userTimezone.value = getUserTimezone();
|
|
||||||
|
|
||||||
if (
|
|
||||||
getDayJsInstance()().tz(timezone.value).format() !==
|
|
||||||
getDayJsInstance()().tz(userTimezone.value).format() &&
|
|
||||||
!hideTimezoneMismatchModal.value
|
|
||||||
) {
|
|
||||||
show.value = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
saving.value = true;
|
|
||||||
const form = useForm({
|
|
||||||
_method: 'PUT',
|
|
||||||
timezone: timezone.value,
|
|
||||||
name: page.props.auth.user.name,
|
|
||||||
email: page.props.auth.user.email,
|
|
||||||
week_start: page.props.auth.user.week_start,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.post(route('user-profile-information.update'), {
|
|
||||||
errorBag: 'updateProfileInformation',
|
|
||||||
preserveScroll: true,
|
|
||||||
onSuccess: () => {
|
|
||||||
saving.value = false;
|
|
||||||
show.value = false;
|
|
||||||
location.reload();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
show.value = false;
|
|
||||||
hideTimezoneMismatchModal.value = true;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
|
||||||
<template #title>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<span> Timezone mismatch detected </span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #content>
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<div class="col-span-6 sm:col-span-4 flex-1 space-y-2">
|
|
||||||
<p>
|
|
||||||
The timezone of your device does not match the timezone in your user
|
|
||||||
settings. <br />
|
|
||||||
<strong
|
|
||||||
>We highly recommend that you update your timezone settings to your
|
|
||||||
current timezone.</strong
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Want to change your timezone setting from
|
|
||||||
<strong>{{ userTimezone }}</strong> to <strong>{{ timezone }}</strong
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<SecondaryButton @click="cancel"> Cancel</SecondaryButton>
|
|
||||||
<PrimaryButton
|
|
||||||
class="ms-3"
|
|
||||||
:class="{ 'opacity-25': saving }"
|
|
||||||
:disabled="saving"
|
|
||||||
@click="submit()">
|
|
||||||
Update timezone
|
|
||||||
</PrimaryButton>
|
|
||||||
</template>
|
|
||||||
</DialogModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -4,7 +4,9 @@ import { computed } from 'vue';
|
|||||||
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
|
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
|
||||||
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
||||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
|
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
|
||||||
|
import { router } from '@inertiajs/vue3';
|
||||||
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import { LoadingSpinner } from '@/packages/ui/src';
|
import { LoadingSpinner } from '@/packages/ui/src';
|
||||||
@@ -88,8 +90,23 @@ window.addEventListener('dashboard:refresh', () => {
|
|||||||
<div v-else class="text-center flex flex-1 justify-center items-center">
|
<div v-else class="text-center flex flex-1 justify-center items-center">
|
||||||
<div>
|
<div>
|
||||||
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
|
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
|
||||||
<h3 class="text-text-primary font-semibold text-sm">No recent time entries</h3>
|
<h3 class="text-text-primary font-semibold text-sm">No recent tasks found</h3>
|
||||||
<p class="pb-5 text-sm">Start tracking your time!</p>
|
<p class="pb-5 text-sm">Create tasks inside of a project!</p>
|
||||||
|
<SecondaryButton @click="router.visit(route('projects'))"
|
||||||
|
>Go to Projects
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="latestTasks && latestTasks.length === 1"
|
||||||
|
class="text-center flex flex-1 justify-center items-center text-sm">
|
||||||
|
<div>
|
||||||
|
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
|
||||||
|
<h3 class="text-text-primary font-semibold">Add more tasks</h3>
|
||||||
|
<p class="pb-5">Create tasks inside of a project!</p>
|
||||||
|
<SecondaryButton @click="router.visit(route('projects'))"
|
||||||
|
>Go to Projects
|
||||||
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-end sm:p-6 z-[70]">
|
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-end sm:p-6 sm:pb-24 z-[70]">
|
||||||
<div class="flex w-full flex-col items-center space-y-4 sm:items-end">
|
<div class="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||||
<Notification
|
<Notification
|
||||||
v-for="notification in notifications"
|
v-for="notification in notifications"
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||||
import {
|
import DropdownLink from '@/Components/DropdownLink.vue';
|
||||||
Cog6ToothIcon,
|
import { usePage } from '@inertiajs/vue3';
|
||||||
PlusCircleIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
ArrowRightIcon,
|
|
||||||
} from '@heroicons/vue/24/solid';
|
|
||||||
import type { Organization, User } from '@/types/models';
|
import type { Organization, User } from '@/types/models';
|
||||||
import { isBillingActivated } from '@/utils/billing';
|
import { isBillingActivated } from '@/utils/billing';
|
||||||
import { canManageBilling } from '@/utils/permissions';
|
import { canManageBilling } from '@/utils/permissions';
|
||||||
import { switchOrganization } from '@/utils/useOrganization';
|
import { switchOrganization } from '@/utils/useOrganization';
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
} from '@/Components/ui/dropdown-menu';
|
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
jetstream: {
|
jetstream: {
|
||||||
@@ -39,79 +28,84 @@ const switchToTeam = (organization: Organization) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenu v-if="page.props.jetstream.hasTeamFeatures">
|
<Dropdown v-if="page.props.jetstream.hasTeamFeatures" align="center" width="60">
|
||||||
<DropdownMenuTrigger
|
<template #trigger>
|
||||||
class="flex w-full text-left hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-ring cursor-pointer transition pl-2 py-1 rounded w-full items-center justify-between"
|
<div
|
||||||
as-child>
|
data-testid="organization_switcher"
|
||||||
<button data-testid="organization_switcher">
|
class="flex hover:bg-white/10 cursor-pointer transition px-2 py-1 rounded-lg w-full items-center justify-between font-medium">
|
||||||
<div class="flex flex-1 space-x-2 items-center w-[calc(100%-30px)]">
|
<div class="flex flex-1 space-x-2 items-center w-[calc(100%-30px)]">
|
||||||
<div
|
<div
|
||||||
class="rounded bg-blue-900 font-medium text-xs flex-shrink-0 text-white w-5 h-5 flex items-center justify-center">
|
class="rounded sm:rounded-lg bg-blue-900 font-semibold text-xs sm:text-sm flex-shrink-0 text-white w-5 sm:w-6 h-5 sm:h-6 flex items-center justify-center">
|
||||||
{{ page.props.auth.user.current_team.name.slice(0, 1).toUpperCase() }}
|
{{ page.props.auth.user.current_team.name.slice(0, 1).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs flex-1 truncate font-medium">
|
<span class="text-sm flex-1 truncate font-semibold">
|
||||||
{{ page.props.auth.user.current_team.name }}
|
{{ page.props.auth.user.current_team.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-[30px]">
|
<div class="w-[30px]">
|
||||||
<div class="p-1 rounded-full flex items-center w-6 h-6">
|
<button
|
||||||
<ChevronDownIcon class="w-4 sm:w-full mt-[1px]"></ChevronDownIcon>
|
class="p-1 transition hover:bg-white/10 rounded-full flex items-center w-8 h-8">
|
||||||
</div>
|
<ChevronDownIcon class="w-5 sm:w-full mt-[1px]"></ChevronDownIcon>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</template>
|
||||||
|
|
||||||
<DropdownMenuContent align="start">
|
<template #content>
|
||||||
<div class="w-60">
|
<div class="w-60">
|
||||||
<DropdownMenuLabel>Manage Organization</DropdownMenuLabel>
|
<!-- Organization Management -->
|
||||||
|
<div class="block px-4 py-2 text-xs text-text-secondary">Manage Organization</div>
|
||||||
|
|
||||||
<DropdownMenuItem as-child>
|
<!-- Organization Settings -->
|
||||||
<Link
|
<DropdownLink :href="route('teams.show', page.props.auth.user.current_team.id)">
|
||||||
:href="route('teams.show', page.props.auth.user.current_team.id)"
|
Organization Settings
|
||||||
class="inline-flex items-center gap-2.5 w-full">
|
</DropdownLink>
|
||||||
<Cog6ToothIcon class="w-5 h-5 text-icon-default" />
|
|
||||||
<span>Organization Settings</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem v-if="canManageBilling() && isBillingActivated()" as-child>
|
<DropdownLink v-if="canManageBilling() && isBillingActivated()" href="/billing">
|
||||||
<Link href="/billing" class="inline-flex items-center w-full"> Billing </Link>
|
Billing
|
||||||
</DropdownMenuItem>
|
</DropdownLink>
|
||||||
|
|
||||||
<DropdownMenuItem v-if="page.props.jetstream.canCreateTeams" as-child>
|
<DropdownLink
|
||||||
<Link
|
v-if="page.props.jetstream.canCreateTeams"
|
||||||
:href="route('teams.create')"
|
:href="route('teams.create')">
|
||||||
class="inline-flex items-center gap-2.5 w-full">
|
Create new organization
|
||||||
<PlusCircleIcon class="w-5 h-5 text-icon-default" />
|
</DropdownLink>
|
||||||
<span>Create new organization</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
|
<!-- Organization Switcher -->
|
||||||
<template v-if="page.props.auth.user.all_teams.length > 1">
|
<template v-if="page.props.auth.user.all_teams.length > 1">
|
||||||
<div class="border-t border-card-background-separator" />
|
<div class="border-t border-card-background-separator" />
|
||||||
|
|
||||||
<DropdownMenuLabel>Switch Organizations</DropdownMenuLabel>
|
<div class="block px-4 py-2 text-xs text-text-secondary">
|
||||||
|
Switch Organizations
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-for="team in page.props.auth.user.all_teams" :key="team.id">
|
<template v-for="team in page.props.auth.user.all_teams" :key="team.id">
|
||||||
<form @submit.prevent="switchToTeam(team)">
|
<form @submit.prevent="switchToTeam(team)">
|
||||||
<DropdownMenuItem
|
<DropdownLink as="button">
|
||||||
as-child
|
<div class="flex items-center">
|
||||||
class="inline-flex gap-2.5 items-center w-full">
|
<svg
|
||||||
<button type="submit">
|
|
||||||
<CheckCircleIcon
|
|
||||||
v-if="team.id == page.props.auth.user.current_team_id"
|
v-if="team.id == page.props.auth.user.current_team_id"
|
||||||
class="h-5 w-5 text-green-400" />
|
class="me-2 h-5 w-5 text-green-400"
|
||||||
<ArrowRightIcon v-else class="h-5 w-5 text-icon-default" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
<div class="w-full truncate text-left">
|
<div>
|
||||||
{{ team.name }}
|
{{ team.name }}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownLink>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuContent>
|
</template>
|
||||||
</DropdownMenu>
|
</Dropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
import { router, usePage } from '@inertiajs/vue3';
|
||||||
import type { Organization, User } from '@/types/models';
|
import type { Organization, User } from '@/types/models';
|
||||||
import {
|
import DropdownLink from '@/Components/DropdownLink.vue';
|
||||||
DropdownMenu,
|
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
} from '@/Components/ui/dropdown-menu';
|
|
||||||
import {
|
|
||||||
UserCircleIcon,
|
|
||||||
KeyIcon,
|
|
||||||
ArrowLeftOnRectangleIcon,
|
|
||||||
ChatBubbleLeftRightIcon,
|
|
||||||
} from '@heroicons/vue/24/solid';
|
|
||||||
import { openFeedback } from '@/utils/feedback';
|
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
has_services_extension?: boolean;
|
|
||||||
has_billing_extension?: boolean;
|
|
||||||
jetstream: {
|
jetstream: {
|
||||||
canCreateTeams: boolean;
|
canCreateTeams: boolean;
|
||||||
hasTeamFeatures: boolean;
|
hasTeamFeatures: boolean;
|
||||||
@@ -37,58 +23,60 @@ const logout = () => {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="ms-3 relative">
|
||||||
<DropdownMenu>
|
<Dropdown align="center" width="48">
|
||||||
<DropdownMenuTrigger
|
<template #trigger>
|
||||||
class="flex text-sm border-2 outline-none border-transparent rounded-full focus-visible:ring-2 focus-visible:ring-ring transition"
|
<button
|
||||||
as-child>
|
v-if="page.props.jetstream.managesProfilePhotos"
|
||||||
<button data-testid="current_user_button">
|
data-testid="current_user_button"
|
||||||
|
class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
|
||||||
<img
|
<img
|
||||||
class="h-7 w-7 rounded-full object-cover"
|
class="h-8 w-8 rounded-full object-cover"
|
||||||
:src="page.props.auth.user.profile_photo_url"
|
:src="page.props.auth.user.profile_photo_url"
|
||||||
:alt="page.props.auth.user.name" />
|
:alt="page.props.auth.user.name" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="center" class="max-w-48">
|
|
||||||
<DropdownMenuLabel>Manage Account</DropdownMenuLabel>
|
|
||||||
|
|
||||||
<DropdownMenuItem as-child>
|
<span v-else class="inline-flex rounded-md">
|
||||||
<Link
|
|
||||||
:href="route('profile.show')"
|
|
||||||
class="inline-flex items-center gap-2.5 w-full">
|
|
||||||
<UserCircleIcon class="w-5 h-5 text-icon-default" />
|
|
||||||
<span>Profile Settings</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem v-if="page.props.jetstream.hasApiFeatures" as-child>
|
|
||||||
<Link
|
|
||||||
:href="route('api-tokens.index')"
|
|
||||||
class="inline-flex items-center gap-2.5 w-full">
|
|
||||||
<KeyIcon class="w-5 h-5 text-icon-default" />
|
|
||||||
<span>API Tokens</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem v-if="page.props.has_services_extension" as-child>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2.5 w-full"
|
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:bg-gray-50 active:bg-gray-50 transition ease-in-out duration-150">
|
||||||
@click="openFeedback">
|
{{ page.props.auth.user.name }}
|
||||||
<ChatBubbleLeftRightIcon class="w-5 h-5 text-icon-default" />
|
|
||||||
<span>Feedback</span>
|
|
||||||
</button>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<form class="w-full" @submit.prevent="logout">
|
<svg
|
||||||
<DropdownMenuItem as-child class="inline-flex items-center gap-2.5 w-full">
|
class="ms-2 -me-0.5 h-4 w-4"
|
||||||
<button type="submit" data-testid="logout_button">
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<ArrowLeftOnRectangleIcon class="w-5 h-5 text-icon-default" />
|
fill="none"
|
||||||
<span>Log Out</span>
|
viewBox="0 0 24 24"
|
||||||
</button>
|
stroke-width="1.5"
|
||||||
</DropdownMenuItem>
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<!-- Account Management -->
|
||||||
|
<div class="block px-4 py-2 text-xs text-gray-400">Manage Account</div>
|
||||||
|
|
||||||
|
<DropdownLink :href="route('profile.show')"> Profile </DropdownLink>
|
||||||
|
|
||||||
|
<DropdownLink
|
||||||
|
v-if="page.props.jetstream.hasApiFeatures"
|
||||||
|
:href="route('api-tokens.index')">
|
||||||
|
API Tokens
|
||||||
|
</DropdownLink>
|
||||||
|
|
||||||
|
<div class="border-t border-card-border" />
|
||||||
|
|
||||||
|
<!-- Authentication -->
|
||||||
|
<form @submit.prevent="logout">
|
||||||
|
<DropdownLink as="button" data-testid="logout_button"> Log Out </DropdownLink>
|
||||||
</form>
|
</form>
|
||||||
</DropdownMenuContent>
|
</template>
|
||||||
</DropdownMenu>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
|||||||
<template>
|
<template>
|
||||||
<DropdownMenuLabel
|
<DropdownMenuLabel
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
:class="cn('block px-2 py-2 text-xs text-gray-400', inset && 'pl-8', props.class)">
|
:class="cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)">
|
||||||
<slot />
|
<slot />
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
|
|||||||
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
||||||
import {
|
import {
|
||||||
Bars3Icon,
|
Bars3Icon,
|
||||||
CalendarIcon,
|
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
@@ -40,19 +39,14 @@ import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
|||||||
import { fetchToken, isTokenValid } from '@/utils/session';
|
import { fetchToken, isTokenValid } from '@/utils/session';
|
||||||
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
||||||
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
||||||
import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';
|
|
||||||
import { useTheme } from '@/utils/theme';
|
import { useTheme } from '@/utils/theme';
|
||||||
import { useQuery } from '@tanstack/vue-query';
|
import { useQuery } from '@tanstack/vue-query';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
import Button from '@/Components/ui/button/Button.vue';
|
|
||||||
import { openFeedback } from '@/utils/feedback';
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
title: String,
|
title: String,
|
||||||
mainClass: String,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const showSidebarMenu = ref(false);
|
const showSidebarMenu = ref(false);
|
||||||
@@ -96,8 +90,8 @@ onMounted(async () => {
|
|||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
has_services_extension?: boolean;
|
|
||||||
auth: {
|
auth: {
|
||||||
user: User;
|
user: User;
|
||||||
};
|
};
|
||||||
@@ -108,7 +102,7 @@ const page = usePage<{
|
|||||||
<div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary">
|
<div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary">
|
||||||
<div
|
<div
|
||||||
:class="{
|
:class="{
|
||||||
'!flex bg-default-background w-full z-30': showSidebarMenu,
|
'!flex bg-default-background w-full z-[9999999999]': showSidebarMenu,
|
||||||
}"
|
}"
|
||||||
class="flex-shrink-0 h-screen hidden fixed w-[230px] 2xl:w-[250px] px-2.5 2xl:px-3 py-4 lg:flex flex-col justify-between">
|
class="flex-shrink-0 h-screen hidden fixed w-[230px] 2xl:w-[250px] px-2.5 2xl:px-3 py-4 lg:flex flex-col justify-between">
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
@@ -137,11 +131,6 @@ const page = usePage<{
|
|||||||
:icon="ClockIcon"
|
:icon="ClockIcon"
|
||||||
:current="route().current('time')"
|
:current="route().current('time')"
|
||||||
:href="route('time')"></NavigationSidebarItem>
|
:href="route('time')"></NavigationSidebarItem>
|
||||||
<NavigationSidebarItem
|
|
||||||
title="Calendar"
|
|
||||||
:icon="CalendarIcon"
|
|
||||||
:current="route().current('calendar')"
|
|
||||||
:href="route('calendar')"></NavigationSidebarItem>
|
|
||||||
<NavigationSidebarItem
|
<NavigationSidebarItem
|
||||||
title="Reporting"
|
title="Reporting"
|
||||||
:icon="ChartBarIcon"
|
:icon="ChartBarIcon"
|
||||||
@@ -244,44 +233,35 @@ const page = usePage<{
|
|||||||
<div class="justify-self-end">
|
<div class="justify-self-end">
|
||||||
<UpdateSidebarNotification></UpdateSidebarNotification>
|
<UpdateSidebarNotification></UpdateSidebarNotification>
|
||||||
<ul
|
<ul
|
||||||
class="border-t border-default-background-separator pt-3 gap-1 pr-2 flex justify-between items-center">
|
class="border-t border-default-background-separator pt-3 flex justify-between pr-4 items-center">
|
||||||
<UserSettingsIcon></UserSettingsIcon>
|
|
||||||
|
|
||||||
<NavigationSidebarItem
|
<NavigationSidebarItem
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
title="Profile Settings"
|
title="Profile Settings"
|
||||||
:icon="Cog6ToothIcon"
|
:icon="Cog6ToothIcon"
|
||||||
:href="route('profile.show')"></NavigationSidebarItem>
|
:href="route('profile.show')"></NavigationSidebarItem>
|
||||||
|
|
||||||
<Button
|
<UserSettingsIcon></UserSettingsIcon>
|
||||||
v-if="page.props.has_services_extension"
|
|
||||||
variant="outline"
|
|
||||||
size="xs"
|
|
||||||
class="rounded-full ml-2 flex h-6 w-6 items-center text-xs text-icon-default justify-center"
|
|
||||||
@click="openFeedback">
|
|
||||||
?
|
|
||||||
</Button>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 lg:ml-[230px] 2xl:ml-[250px] min-w-0">
|
<div class="flex-1 lg:ml-[230px] 2xl:ml-[250px] min-w-0">
|
||||||
<div
|
<div
|
||||||
class="h-screen overflow-y-auto flex flex-col bg-default-background border-l border-default-background-separator">
|
class="lg:hidden w-full px-3 py-1 border-b border-b-default-background-separator text-text-secondary flex justify-between items-center">
|
||||||
<div
|
<Bars3Icon
|
||||||
class="lg:hidden w-full px-3 py-1 border-b border-b-default-background-separator text-text-secondary flex justify-between items-center">
|
class="w-7 text-text-secondary"
|
||||||
<Bars3Icon
|
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
|
||||||
class="w-7 text-text-secondary"
|
<OrganizationSwitcher></OrganizationSwitcher>
|
||||||
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
|
</div>
|
||||||
<OrganizationSwitcher></OrganizationSwitcher>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Head :title="title" />
|
<Head :title="title" />
|
||||||
|
|
||||||
|
<Banner />
|
||||||
|
<BillingBanner v-if="isBillingActivated()" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="min-h-screen flex flex-col bg-default-background border-l border-default-background-separator">
|
||||||
<!-- Page Heading -->
|
<!-- Page Heading -->
|
||||||
<Banner />
|
|
||||||
<BillingBanner v-if="isBillingActivated()" />
|
|
||||||
|
|
||||||
<header
|
<header
|
||||||
v-if="$slots.header"
|
v-if="$slots.header"
|
||||||
class="bg-default-background border-b border-default-background-separator shadow">
|
class="bg-default-background border-b border-default-background-separator shadow">
|
||||||
@@ -293,7 +273,7 @@ const page = usePage<{
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<main :class="twMerge('pb-28 relative flex-1', mainClass)">
|
<main class="pb-28 flex-1">
|
||||||
<div
|
<div
|
||||||
v-if="isOrganizationLoading"
|
v-if="isOrganizationLoading"
|
||||||
class="flex items-center justify-center h-screen">
|
class="flex items-center justify-center h-screen">
|
||||||
@@ -305,5 +285,4 @@ const page = usePage<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NotificationContainer></NotificationContainer>
|
<NotificationContainer></NotificationContainer>
|
||||||
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
|
||||||
import {
|
|
||||||
api,
|
|
||||||
type Client,
|
|
||||||
type CreateClientBody,
|
|
||||||
type CreateProjectBody,
|
|
||||||
type Project,
|
|
||||||
type TimeEntryResponse,
|
|
||||||
} from '@/packages/api/src';
|
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
|
||||||
import { TimeEntryCalendar } from '@/packages/ui/src';
|
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
|
||||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
|
||||||
import { useTagsStore } from '@/utils/useTags';
|
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
|
||||||
import { storeToRefs } from 'pinia';
|
|
||||||
import { useTasksStore } from '@/utils/useTasks';
|
|
||||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
|
||||||
|
|
||||||
const calendarStart = ref<Date | undefined>(undefined);
|
|
||||||
const calendarEnd = ref<Date | undefined>(undefined);
|
|
||||||
|
|
||||||
const enableCalendarQuery = computed(() => {
|
|
||||||
return !!getCurrentOrganizationId() && !!calendarStart.value && !!calendarEnd.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate expanded date range to include previous and next periods with timezone transformations
|
|
||||||
const expandedDateRange = computed(() => {
|
|
||||||
if (!calendarStart.value || !calendarEnd.value) {
|
|
||||||
return { start: null, end: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const dayjs = getDayJsInstance();
|
|
||||||
const duration = dayjs(calendarEnd.value).diff(dayjs(calendarStart.value), 'milliseconds');
|
|
||||||
|
|
||||||
// Calculate previous period
|
|
||||||
const previousStart = dayjs(calendarStart.value).subtract(duration, 'milliseconds');
|
|
||||||
// Calculate next period
|
|
||||||
const nextEnd = dayjs(calendarEnd.value).add(duration, 'milliseconds');
|
|
||||||
|
|
||||||
// Apply timezone transformations
|
|
||||||
const formattedStart = previousStart.utc().tz(getUserTimezone(), true).utc().format();
|
|
||||||
const formattedEnd = nextEnd.utc().tz(getUserTimezone(), true).utc().format();
|
|
||||||
|
|
||||||
return {
|
|
||||||
start: formattedStart,
|
|
||||||
end: formattedEnd,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useQuery<TimeEntryResponse>({
|
|
||||||
queryKey: computed(() => [
|
|
||||||
'timeEntry',
|
|
||||||
'calendar',
|
|
||||||
{
|
|
||||||
start: expandedDateRange.value.start,
|
|
||||||
end: expandedDateRange.value.end,
|
|
||||||
organization: getCurrentOrganizationId(),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
enabled: enableCalendarQuery,
|
|
||||||
placeholderData: (previousData) => previousData,
|
|
||||||
queryFn: () =>
|
|
||||||
api.getTimeEntries({
|
|
||||||
params: {
|
|
||||||
organization: getCurrentOrganizationId() || '',
|
|
||||||
},
|
|
||||||
queries: {
|
|
||||||
start: expandedDateRange.value.start!,
|
|
||||||
end: expandedDateRange.value.end!,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentTimeEntries = computed(() => {
|
|
||||||
return timeEntryResponse?.value?.data || [];
|
|
||||||
});
|
|
||||||
|
|
||||||
const { createTimeEntry, updateTimeEntry, deleteTimeEntry } = useTimeEntriesStore();
|
|
||||||
|
|
||||||
async function createTag(name: string) {
|
|
||||||
return await useTagsStore().createTag(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
|
||||||
return await useProjectsStore().createProject(project);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createClient(body: CreateClientBody): Promise<Client | undefined> {
|
|
||||||
return await useClientsStore().createClient(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectStore = useProjectsStore();
|
|
||||||
const { projects } = storeToRefs(projectStore);
|
|
||||||
const taskStore = useTasksStore();
|
|
||||||
const { tasks } = storeToRefs(taskStore);
|
|
||||||
const clientStore = useClientsStore();
|
|
||||||
const { clients } = storeToRefs(clientStore);
|
|
||||||
const tagsStore = useTagsStore();
|
|
||||||
const { tags } = storeToRefs(tagsStore);
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
function onDatesChange({ start, end }: { start: Date; end: Date }) {
|
|
||||||
calendarStart.value = start;
|
|
||||||
calendarEnd.value = end;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRefresh() {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ['timeEntry', 'calendar'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AppLayout title="Calendar" data-testid="calendar_view" main-class="p-0">
|
|
||||||
<TimeEntryCalendar
|
|
||||||
:time-entries="currentTimeEntries"
|
|
||||||
:projects="projects"
|
|
||||||
:tasks="tasks"
|
|
||||||
:clients="clients"
|
|
||||||
:tags="tags"
|
|
||||||
:loading="timeEntriesLoading"
|
|
||||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
|
||||||
:create-time-entry="createTimeEntry"
|
|
||||||
:update-time-entry="updateTimeEntry"
|
|
||||||
:delete-time-entry="deleteTimeEntry"
|
|
||||||
:create-client="createClient"
|
|
||||||
:create-project="createProject"
|
|
||||||
:create-tag="createTag"
|
|
||||||
@dates-change="onDatesChange"
|
|
||||||
@refresh="onRefresh" />
|
|
||||||
</AppLayout>
|
|
||||||
</template>
|
|
||||||
@@ -369,7 +369,6 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
:tags="tags"
|
:tags="tags"
|
||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
:clients="clients"
|
:clients="clients"
|
||||||
class="border-b border-default-background-separator"
|
|
||||||
:update-time-entries="
|
:update-time-entries="
|
||||||
(args) =>
|
(args) =>
|
||||||
updateTimeEntries(
|
updateTimeEntries(
|
||||||
|
|||||||
@@ -144,7 +144,6 @@ function deleteSelected() {
|
|||||||
:tags="tags"
|
:tags="tags"
|
||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
:clients="clients"
|
:clients="clients"
|
||||||
class="border-t border-default-background-separator"
|
|
||||||
:update-time-entries="
|
:update-time-entries="
|
||||||
(args) =>
|
(args) =>
|
||||||
updateTimeEntries(
|
updateTimeEntries(
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ const props = withDefaults(
|
|||||||
icon?: Component;
|
icon?: Component;
|
||||||
size?: 'small' | 'base';
|
size?: 'small' | 'base';
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
// Accept any valid Vue class binding shape (string | object | array)
|
class?: string;
|
||||||
class?: Parameters<typeof twMerge>[0];
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, inject, type ComputedRef } from 'vue';
|
|
||||||
import { formatDate, formatHumanReadableDuration } from '../utils/time';
|
|
||||||
import type { Organization } from '@/packages/api/src';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
date: Date;
|
|
||||||
totalMinutes?: number;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const totalSeconds = computed(() => (props.totalMinutes ?? 0) * 60);
|
|
||||||
|
|
||||||
// Injected organization for formatting settings
|
|
||||||
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
|
||||||
const intervalFormat = computed(() => organization?.value?.interval_format);
|
|
||||||
const numberFormat = computed(() => organization?.value?.number_format);
|
|
||||||
const dateFormat = computed(() => organization?.value?.date_format);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="fc-day-header-custom">
|
|
||||||
<div class="text-xs text-muted-foreground font-medium">
|
|
||||||
{{ date.toLocaleDateString('en-US', { weekday: 'short' }) }}
|
|
||||||
</div>
|
|
||||||
<span>{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
|
||||||
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
|
||||||
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, inject, type ComputedRef } from 'vue';
|
|
||||||
import { formatHumanReadableDuration, getDayJsInstance } from '../utils/time';
|
|
||||||
import type { Organization } from '@/packages/api/src';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
title: string;
|
|
||||||
projectName?: string | null;
|
|
||||||
taskName?: string | null;
|
|
||||||
clientName?: string | null;
|
|
||||||
durationSeconds?: number;
|
|
||||||
start?: string | Date | null;
|
|
||||||
end?: string | Date | null;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const effectiveDurationSeconds = computed(() => {
|
|
||||||
if (typeof props.durationSeconds === 'number') {
|
|
||||||
return props.durationSeconds;
|
|
||||||
}
|
|
||||||
if (props.start && props.end) {
|
|
||||||
const end = getDayJsInstance()(props.end as unknown as string | Date);
|
|
||||||
const start = getDayJsInstance()(props.start as unknown as string | Date);
|
|
||||||
const minutes = end.diff(start, 'minutes');
|
|
||||||
return minutes * 60;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
|
||||||
const intervalFormat = computed(() => organization?.value?.interval_format);
|
|
||||||
const numberFormat = computed(() => organization?.value?.number_format);
|
|
||||||
|
|
||||||
const formattedDuration = computed(() =>
|
|
||||||
formatHumanReadableDuration(
|
|
||||||
effectiveDurationSeconds.value,
|
|
||||||
intervalFormat.value,
|
|
||||||
numberFormat.value
|
|
||||||
)
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="text-xs leading-tight">
|
|
||||||
<div class="font-semibold mb-0.5">{{ title }}</div>
|
|
||||||
<div v-if="projectName" class="font-medium text-[0.6875rem] opacity-90">
|
|
||||||
{{ projectName }}
|
|
||||||
</div>
|
|
||||||
<div v-if="taskName" class="font-medium text-[0.6875rem] opacity-90">
|
|
||||||
{{ taskName }}
|
|
||||||
</div>
|
|
||||||
<div v-if="clientName" class="text-[0.625rem] italic opacity-85">
|
|
||||||
{{ clientName }}
|
|
||||||
</div>
|
|
||||||
<div class="text-[0.625rem] font-semibold opacity-90 mt-0.5">
|
|
||||||
{{ formattedDuration }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,596 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import FullCalendar from '@fullcalendar/vue3';
|
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
|
||||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
|
||||||
import interactionPlugin from '@fullcalendar/interaction';
|
|
||||||
import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';
|
|
||||||
import { computed, ref, watch, inject, type ComputedRef } from 'vue';
|
|
||||||
import chroma from 'chroma-js';
|
|
||||||
import { useCssVariable } from '@/utils/useCssVariable';
|
|
||||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
|
||||||
import { getUserTimezone, getWeekStart } from '../utils/settings';
|
|
||||||
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
|
||||||
import FullCalendarEventContent from './FullCalendarEventContent.vue';
|
|
||||||
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
|
||||||
import type {
|
|
||||||
TimeEntry,
|
|
||||||
Project,
|
|
||||||
Client,
|
|
||||||
Task,
|
|
||||||
CreateProjectBody,
|
|
||||||
CreateClientBody,
|
|
||||||
Tag,
|
|
||||||
Organization,
|
|
||||||
} from '@/packages/api/src';
|
|
||||||
import type { Dayjs } from 'dayjs';
|
|
||||||
|
|
||||||
type CalendarExtendedProps = { timeEntry: TimeEntry } & Record<string, unknown>;
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'dates-change', payload: { start: Date; end: Date }): void;
|
|
||||||
(e: 'refresh'): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
timeEntries: TimeEntry[];
|
|
||||||
projects: Project[];
|
|
||||||
tasks: Task[];
|
|
||||||
clients: Client[];
|
|
||||||
tags: Tag[];
|
|
||||||
loading?: boolean;
|
|
||||||
|
|
||||||
// Permissions / feature flags
|
|
||||||
enableEstimatedTime: boolean;
|
|
||||||
|
|
||||||
createTimeEntry: (
|
|
||||||
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
|
|
||||||
) => Promise<void>;
|
|
||||||
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
|
|
||||||
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
|
|
||||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
|
||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
|
||||||
createTag: (name: string) => Promise<Tag | undefined>;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// Local component state
|
|
||||||
const newEventStart = ref<Dayjs | null>(null);
|
|
||||||
const newEventEnd = ref<Dayjs | null>(null);
|
|
||||||
const showCreateTimeEntryModal = ref<boolean>(false);
|
|
||||||
const showEditTimeEntryModal = ref<boolean>(false);
|
|
||||||
const selectedTimeEntry = ref<TimeEntry | null>(null);
|
|
||||||
|
|
||||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
|
||||||
|
|
||||||
// Inject organization data for settings
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
|
||||||
|
|
||||||
// Helper function to convert week start to FullCalendar firstDay value
|
|
||||||
const getFirstDay = () => {
|
|
||||||
const weekStart = getWeekStart();
|
|
||||||
const weekStartMap: Record<string, number> = {
|
|
||||||
'sunday': 0,
|
|
||||||
'monday': 1,
|
|
||||||
'tuesday': 2,
|
|
||||||
'wednesday': 3,
|
|
||||||
'thursday': 4,
|
|
||||||
'friday': 5,
|
|
||||||
'saturday': 6,
|
|
||||||
};
|
|
||||||
return weekStartMap[weekStart] ?? 1; // Default to Monday if not found
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to get time format for slot labels
|
|
||||||
const getSlotLabelFormat = () => {
|
|
||||||
const timeFormat = organization?.value?.time_format || '24-hours';
|
|
||||||
if (timeFormat === '12-hours') {
|
|
||||||
return {
|
|
||||||
hour: 'numeric' as const,
|
|
||||||
hour12: true,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
hour: '2-digit' as const,
|
|
||||||
minute: '2-digit' as const,
|
|
||||||
hour12: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cssBackground = useCssVariable('--color-bg-background');
|
|
||||||
|
|
||||||
const events = computed(() => {
|
|
||||||
const themeBackground = (() => {
|
|
||||||
return cssBackground.value?.trim();
|
|
||||||
})();
|
|
||||||
return props.timeEntries
|
|
||||||
?.filter((timeEntry) => timeEntry.end !== null)
|
|
||||||
?.map((timeEntry) => {
|
|
||||||
const project = props.projects.find((p) => p.id === timeEntry.project_id);
|
|
||||||
const client = props.clients.find((c) => c.id === project?.client_id);
|
|
||||||
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
|
|
||||||
const duration = getDayJsInstance()(timeEntry.end!).diff(
|
|
||||||
getDayJsInstance()(timeEntry.start),
|
|
||||||
'minutes'
|
|
||||||
);
|
|
||||||
|
|
||||||
const title = timeEntry.description || 'No description';
|
|
||||||
|
|
||||||
const baseColor = project?.color || '#6B7280';
|
|
||||||
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
|
|
||||||
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
|
|
||||||
|
|
||||||
// For 0-duration events, display them with minimum visual duration but preserve actual duration
|
|
||||||
const startTime = getLocalizedDayJs(timeEntry.start);
|
|
||||||
const endTime =
|
|
||||||
duration === 0
|
|
||||||
? startTime.add(1, 'second') // Show as 1 second for minimal visibility
|
|
||||||
: getLocalizedDayJs(timeEntry.end!);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: timeEntry.id,
|
|
||||||
start: startTime.format(),
|
|
||||||
end: endTime.format(),
|
|
||||||
title,
|
|
||||||
backgroundColor,
|
|
||||||
borderColor,
|
|
||||||
textColor: 'var(--foreground)',
|
|
||||||
extendedProps: {
|
|
||||||
timeEntry,
|
|
||||||
project,
|
|
||||||
client,
|
|
||||||
task,
|
|
||||||
duration,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Daily totals used in day header
|
|
||||||
const dailyTotals = computed(() => {
|
|
||||||
const totals: Record<string, number> = {};
|
|
||||||
props.timeEntries
|
|
||||||
.filter((entry) => entry.end !== null)
|
|
||||||
.forEach((entry) => {
|
|
||||||
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
|
|
||||||
const duration = getDayJsInstance()(entry.end!).diff(
|
|
||||||
getDayJsInstance()(entry.start),
|
|
||||||
'minutes'
|
|
||||||
);
|
|
||||||
totals[date] = (totals[date] || 0) + duration;
|
|
||||||
});
|
|
||||||
return totals;
|
|
||||||
});
|
|
||||||
|
|
||||||
function emitDatesChange(arg: DatesSetArg) {
|
|
||||||
emit('dates-change', { start: arg.start, end: arg.end });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDateSelect(arg: { start: Date; end: Date }) {
|
|
||||||
const startTime = getDayJsInstance()(arg.start.toISOString())
|
|
||||||
.utc()
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.utc();
|
|
||||||
const endTime = getDayJsInstance()(arg.end.toISOString())
|
|
||||||
.utc()
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.utc();
|
|
||||||
newEventStart.value = startTime;
|
|
||||||
newEventEnd.value = endTime;
|
|
||||||
showCreateTimeEntryModal.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEventClick(arg: EventClickArg) {
|
|
||||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
|
||||||
selectedTimeEntry.value = ext.timeEntry;
|
|
||||||
showEditTimeEntryModal.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEventDrop(arg: EventDropArg) {
|
|
||||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
|
||||||
const timeEntry = ext.timeEntry;
|
|
||||||
if (!arg.event.start || !arg.event.end) return;
|
|
||||||
const updatedTimeEntry = {
|
|
||||||
...timeEntry,
|
|
||||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
|
||||||
.utc()
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.utc()
|
|
||||||
.format(),
|
|
||||||
end: getDayJsInstance()(arg.event.end.toISOString())
|
|
||||||
.utc()
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.utc()
|
|
||||||
.format(),
|
|
||||||
} as TimeEntry;
|
|
||||||
await props.updateTimeEntry(updatedTimeEntry);
|
|
||||||
emit('refresh');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEventResize(arg: EventChangeArg) {
|
|
||||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
|
||||||
const timeEntry = ext.timeEntry;
|
|
||||||
if (!arg.event.start || !arg.event.end) return;
|
|
||||||
const updatedTimeEntry = {
|
|
||||||
...timeEntry,
|
|
||||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
|
||||||
.utc()
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.utc()
|
|
||||||
.format(),
|
|
||||||
end: getDayJsInstance()(arg.event.end.toISOString())
|
|
||||||
.utc()
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.utc()
|
|
||||||
.format(),
|
|
||||||
} as TimeEntry;
|
|
||||||
await props.updateTimeEntry(updatedTimeEntry);
|
|
||||||
emit('refresh');
|
|
||||||
}
|
|
||||||
|
|
||||||
const calendarOptions = computed(() => ({
|
|
||||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
|
||||||
initialView: 'timeGridWeek',
|
|
||||||
headerToolbar: {
|
|
||||||
left: 'prev,next today',
|
|
||||||
center: 'title',
|
|
||||||
right: 'timeGridWeek,timeGridDay',
|
|
||||||
},
|
|
||||||
height: 'parent',
|
|
||||||
slotMinTime: '00:00:00',
|
|
||||||
slotMaxTime: '24:00:00',
|
|
||||||
slotDuration: '00:15:00',
|
|
||||||
slotLabelInterval: '01:00:00',
|
|
||||||
slotLabelFormat: getSlotLabelFormat(),
|
|
||||||
snapDuration: '00:15:00',
|
|
||||||
firstDay: getFirstDay(),
|
|
||||||
allDaySlot: false,
|
|
||||||
nowIndicator: true,
|
|
||||||
selectable: true,
|
|
||||||
selectMirror: true,
|
|
||||||
editable: true,
|
|
||||||
eventResizableFromStart: true,
|
|
||||||
eventDurationEditable: true,
|
|
||||||
timeZone: 'America/Adak',
|
|
||||||
eventStartEditable: true,
|
|
||||||
select: handleDateSelect,
|
|
||||||
eventClick: handleEventClick,
|
|
||||||
eventDrop: handleEventDrop,
|
|
||||||
eventResize: handleEventResize,
|
|
||||||
datesSet: emitDatesChange,
|
|
||||||
|
|
||||||
events: events.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
watch(showCreateTimeEntryModal, (value) => {
|
|
||||||
if (!value) {
|
|
||||||
newEventStart.value = null;
|
|
||||||
newEventEnd.value = null;
|
|
||||||
// Ensure FullCalendar clears the selection mirror when modal closes
|
|
||||||
calendarRef.value?.getApi().unselect();
|
|
||||||
emit('refresh');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(showEditTimeEntryModal, (value) => {
|
|
||||||
if (!value) {
|
|
||||||
selectedTimeEntry.value = null;
|
|
||||||
emit('refresh');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="w-full relative h-full flex-1">
|
|
||||||
<div v-if="loading" class="flex items-center justify-center h-full">
|
|
||||||
<div class="flex flex-col items-center space-y-4">
|
|
||||||
<LoadingSpinner class="h-8 w-8" />
|
|
||||||
<p class="text-muted-foreground">Loading calendar data...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TimeEntryCreateModal
|
|
||||||
v-model:show="showCreateTimeEntryModal"
|
|
||||||
:enable-estimated-time="enableEstimatedTime"
|
|
||||||
:create-time-entry="createTimeEntry"
|
|
||||||
:create-client="createClient"
|
|
||||||
:create-project="createProject"
|
|
||||||
:create-tag="createTag"
|
|
||||||
:tags="tags as any"
|
|
||||||
:projects="projects"
|
|
||||||
:tasks="tasks"
|
|
||||||
:clients="clients"
|
|
||||||
:start="newEventStart ? newEventStart.toISOString() : undefined"
|
|
||||||
:end="newEventEnd ? newEventEnd.toISOString() : undefined" />
|
|
||||||
|
|
||||||
<TimeEntryEditModal
|
|
||||||
v-model:show="showEditTimeEntryModal"
|
|
||||||
:time-entry="selectedTimeEntry as any"
|
|
||||||
:enable-estimated-time="enableEstimatedTime"
|
|
||||||
:update-time-entry="updateTimeEntry"
|
|
||||||
:delete-time-entry="deleteTimeEntry"
|
|
||||||
:create-client="createClient"
|
|
||||||
:create-project="createProject"
|
|
||||||
:create-tag="createTag"
|
|
||||||
:tags="tags as any"
|
|
||||||
:projects="projects"
|
|
||||||
:tasks="tasks"
|
|
||||||
:clients="clients" />
|
|
||||||
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
|
|
||||||
<template #eventContent="arg">
|
|
||||||
<FullCalendarEventContent
|
|
||||||
:title="arg.event.title"
|
|
||||||
:project-name="(arg.event.extendedProps as any).project?.name"
|
|
||||||
:task-name="(arg.event.extendedProps as any).task?.name"
|
|
||||||
:client-name="(arg.event.extendedProps as any).client?.name"
|
|
||||||
:duration-seconds="
|
|
||||||
((arg.event.extendedProps as any).duration ?? undefined)
|
|
||||||
? (arg.event.extendedProps as any).duration * 60
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
:start="arg.event.start as any"
|
|
||||||
:end="arg.event.end as any" />
|
|
||||||
</template>
|
|
||||||
<template #dayHeaderContent="arg">
|
|
||||||
<FullCalendarDayHeader
|
|
||||||
:date="arg.date"
|
|
||||||
:total-minutes="
|
|
||||||
dailyTotals[getDayJsInstance()(arg.date).format('YYYY-MM-DD')] || 0
|
|
||||||
" />
|
|
||||||
</template>
|
|
||||||
</FullCalendar>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.fullcalendar {
|
|
||||||
height: 100%;
|
|
||||||
--fc-border-color: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* FullCalendar theme customization */
|
|
||||||
.fullcalendar :deep(.fc) {
|
|
||||||
background-color: var(--theme-color-default-background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-slot) {
|
|
||||||
height: 25px;
|
|
||||||
transition: height 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-slot-label) {
|
|
||||||
background-color: var(--theme-color-default-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-toolbar) {
|
|
||||||
background-color: var(--theme-color-default-background);
|
|
||||||
padding: 0.5rem;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-toolbar-title) {
|
|
||||||
color: var(--foreground);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-button) {
|
|
||||||
background-color: var(--secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 14px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-button:hover) {
|
|
||||||
background-color: var(--muted);
|
|
||||||
border-color: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-button:focus) {
|
|
||||||
box-shadow: 0 0 0 2px var(--ring);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-button-active) {
|
|
||||||
background-color: var(--primary);
|
|
||||||
border-color: var(--primary);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-col-header) {
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-col-header-cell) {
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 0.75rem 0.5rem;
|
|
||||||
background-color: var(--theme-color-default-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-axis) {
|
|
||||||
background-color: var(--theme-color-default-background) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-col-header-cell .fc-col-header-cell-cushion) {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-axis) {
|
|
||||||
background-color: var(--theme-color-default-background);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Quarter-hour slots - transparent borders */
|
|
||||||
.fullcalendar :deep(.fc-timegrid-slot-minor.fc-timegrid-slot-label) {
|
|
||||||
border-top: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-slot-minor.fc-timegrid-slot-lane) {
|
|
||||||
--tw-border-opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-day-today.fc-col-header-cell) {
|
|
||||||
background-color: var(--color-accent-default);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-day-today) {
|
|
||||||
background-color: var(--theme-color-default-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-now-indicator) {
|
|
||||||
border-color: var(--primary);
|
|
||||||
border-width: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-event) {
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 0.45rem 0.25rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: var(--theme-shadow-card);
|
|
||||||
opacity: 0.9;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-v-event) {
|
|
||||||
background-color: var(--muted);
|
|
||||||
border-color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-event-title) {
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced FullCalendar resize handles */
|
|
||||||
.fullcalendar :deep(.fc-event-resizer) {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 99;
|
|
||||||
background: '#FFF';
|
|
||||||
border-radius: 2px;
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
left: 0;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-event-resizer-start) {
|
|
||||||
top: -2px;
|
|
||||||
cursor: n-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-event-resizer-end) {
|
|
||||||
bottom: -2px;
|
|
||||||
cursor: s-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-event:hover .fc-event-resizer) {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-event-resizer:hover) {
|
|
||||||
background: '#FFF';
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Update the earlier hover rule to include the shadow */
|
|
||||||
.fullcalendar :deep(.fc-event:hover) {
|
|
||||||
opacity: 1;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
box-shadow: var(--theme-shadow-dropdown);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-event-harness) {
|
|
||||||
margin: 0 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-highlight) {
|
|
||||||
background-color: var(--theme-color-default-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-select-mirror) {
|
|
||||||
background-color: var(--accent);
|
|
||||||
border: 1px solid var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-scrollgrid) {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-left: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-scrollgrid-section > td) {
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-body) {
|
|
||||||
background-color: var(--theme-color-default-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-col) {
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-axis-cushion) {
|
|
||||||
color: var(--theme-text-secondary);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-slot-label-cushion) {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-col-header-cell-cushion) {
|
|
||||||
color: var(--foreground);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Daily totals styling */
|
|
||||||
.fullcalendar :deep(.fc-col-header-cell .text-muted-foreground) {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reduce visibility of time slot dividers */
|
|
||||||
.fullcalendar :deep(.fc-timegrid-divider) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make scrollbars gray */
|
|
||||||
.fullcalendar :deep(.fc-scroller) {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--muted-foreground) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar) {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-track) {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-thumb) {
|
|
||||||
background-color: var(--muted-foreground);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-thumb:hover) {
|
|
||||||
background-color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Improve time axis styling */
|
|
||||||
.fullcalendar :deep(.fc-timegrid-axis-chunk) {
|
|
||||||
background-color: var(--theme-color-default-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Simple event main styling */
|
|
||||||
.fullcalendar :deep(.fc-event-main) {
|
|
||||||
padding: 0.125rem 0.25rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -92,7 +92,7 @@ function onSelectChange(checked: boolean) {
|
|||||||
class="border-b border-default-background-separator bg-row-background min-w-0 transition"
|
class="border-b border-default-background-separator bg-row-background min-w-0 transition"
|
||||||
data-testid="time_entry_row">
|
data-testid="time_entry_row">
|
||||||
<MainContainer class="min-w-0">
|
<MainContainer class="min-w-0">
|
||||||
<div class="sm:flex py-2 items-center min-w-0 justify-between group">
|
<div class="sm:flex py-1.5 items-center min-w-0 justify-between group">
|
||||||
<div class="flex space-x-3 items-center min-w-0">
|
<div class="flex space-x-3 items-center min-w-0">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:checked="
|
:checked="
|
||||||
@@ -172,7 +172,6 @@ function onSelectChange(checked: boolean) {
|
|||||||
class="opacity-20 flex group-hover:opacity-100 focus-visible:opacity-100"
|
class="opacity-20 flex group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
|
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
|
||||||
<TimeEntryMoreOptionsDropdown
|
<TimeEntryMoreOptionsDropdown
|
||||||
:show-edit="false"
|
|
||||||
@delete="
|
@delete="
|
||||||
deleteTimeEntries(timeEntry?.timeEntries ?? [])
|
deleteTimeEntries(timeEntry?.timeEntries ?? [])
|
||||||
"></TimeEntryMoreOptionsDropdown>
|
"></TimeEntryMoreOptionsDropdown>
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ const props = defineProps<{
|
|||||||
projects: Project[];
|
projects: Project[];
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
start?: string;
|
|
||||||
end?: string;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const description = ref<HTMLInputElement | null>(null);
|
const description = ref<HTMLInputElement | null>(null);
|
||||||
@@ -65,27 +63,7 @@ const timeEntryDefaultValues = {
|
|||||||
end: getDayJsInstance().utc().format(),
|
end: getDayJsInstance().utc().format(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeEntry = ref({
|
const timeEntry = ref({ ...timeEntryDefaultValues });
|
||||||
...timeEntryDefaultValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
// update the localStart and localEnd when props.start or props.end get updates
|
|
||||||
watch(
|
|
||||||
() => props.start,
|
|
||||||
(value) => {
|
|
||||||
if (value) {
|
|
||||||
localStart.value = getLocalizedDayJs(value).format();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
watch(
|
|
||||||
() => props.end,
|
|
||||||
(value) => {
|
|
||||||
if (value) {
|
|
||||||
localEnd.value = getLocalizedDayJs(value).format();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => timeEntry.value.project_id,
|
() => timeEntry.value.project_id,
|
||||||
|
|||||||
@@ -1,311 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
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 { computed, nextTick, ref, watch } from 'vue';
|
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
|
||||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
|
||||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
|
||||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
|
||||||
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
|
||||||
import type {
|
|
||||||
CreateClientBody,
|
|
||||||
CreateProjectBody,
|
|
||||||
Project,
|
|
||||||
Client,
|
|
||||||
TimeEntry,
|
|
||||||
} from '@/packages/api/src';
|
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
|
||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
|
||||||
import { Badge } from '@/packages/ui/src';
|
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
|
||||||
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
|
|
||||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
|
||||||
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
|
||||||
|
|
||||||
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
|
|
||||||
import type { Tag, Task } from '@/packages/api/src';
|
|
||||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
|
||||||
const saving = ref(false);
|
|
||||||
const deleting = ref(false);
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
timeEntry: TimeEntry | null;
|
|
||||||
enableEstimatedTime: boolean;
|
|
||||||
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
|
|
||||||
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
|
|
||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
|
||||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
|
||||||
createTag: (name: string) => Promise<Tag | undefined>;
|
|
||||||
tags: Tag[];
|
|
||||||
projects: Project[];
|
|
||||||
tasks: Task[];
|
|
||||||
clients: Client[];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const description = ref<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
watch(show, (value) => {
|
|
||||||
if (value) {
|
|
||||||
nextTick(() => {
|
|
||||||
description.value?.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const editableTimeEntry = ref<TimeEntry | null>(null);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.timeEntry,
|
|
||||||
(newTimeEntry) => {
|
|
||||||
if (newTimeEntry) {
|
|
||||||
editableTimeEntry.value = { ...newTimeEntry };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => editableTimeEntry.value?.project_id,
|
|
||||||
(value) => {
|
|
||||||
if (value && editableTimeEntry.value) {
|
|
||||||
// check if project is billable by default and set billable accordingly
|
|
||||||
const project = props.projects.find((p) => p.id === value);
|
|
||||||
if (project) {
|
|
||||||
editableTimeEntry.value.billable = project.is_billable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const localStart = computed({
|
|
||||||
get: () =>
|
|
||||||
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '',
|
|
||||||
set: (value: string) => {
|
|
||||||
if (editableTimeEntry.value) {
|
|
||||||
editableTimeEntry.value.start = getLocalizedDayJs(value).utc().format();
|
|
||||||
if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {
|
|
||||||
localEnd.value = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const localEnd = computed({
|
|
||||||
get: () =>
|
|
||||||
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.end).format() : '',
|
|
||||||
set: (value: string) => {
|
|
||||||
if (editableTimeEntry.value) {
|
|
||||||
editableTimeEntry.value.end = getLocalizedDayJs(value).utc().format();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (editableTimeEntry.value) {
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
await props.updateTimeEntry(editableTimeEntry.value);
|
|
||||||
show.value = false;
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteEntry() {
|
|
||||||
if (editableTimeEntry.value) {
|
|
||||||
deleting.value = true;
|
|
||||||
try {
|
|
||||||
await props.deleteTimeEntry(editableTimeEntry.value.id);
|
|
||||||
show.value = false;
|
|
||||||
} finally {
|
|
||||||
deleting.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const billableProxy = computed({
|
|
||||||
get: () =>
|
|
||||||
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
|
|
||||||
set: (value: string) => {
|
|
||||||
if (editableTimeEntry.value) {
|
|
||||||
editableTimeEntry.value.billable = value === 'true';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
type BillableOption = {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
|
||||||
<template #title>
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<span> Edit time entry </span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #content>
|
|
||||||
<div v-if="editableTimeEntry" class="space-y-4">
|
|
||||||
<div class="sm:flex items-end space-y-2 sm:space-y-0 sm:space-x-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<TextInput
|
|
||||||
id="description"
|
|
||||||
ref="description"
|
|
||||||
v-model="editableTimeEntry.description"
|
|
||||||
placeholder="What did you work on?"
|
|
||||||
type="text"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
@keydown.enter="submit" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="sm:flex justify-between items-end space-y-2 sm:space-y-0 pt-4 sm:space-x-4">
|
|
||||||
<div class="flex w-full items-center space-x-2 justify-between">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<TimeTrackerProjectTaskDropdown
|
|
||||||
v-model:project="editableTimeEntry.project_id"
|
|
||||||
v-model:task="editableTimeEntry.task_id"
|
|
||||||
:clients
|
|
||||||
:create-project
|
|
||||||
:create-client
|
|
||||||
:can-create-project="canCreateProjects()"
|
|
||||||
:currency="getOrganizationCurrencyString()"
|
|
||||||
size="xlarge"
|
|
||||||
class="bg-input-background"
|
|
||||||
:projects="projects"
|
|
||||||
:tasks="tasks"
|
|
||||||
:enable-estimated-time="
|
|
||||||
enableEstimatedTime
|
|
||||||
"></TimeTrackerProjectTaskDropdown>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div class="flex-col">
|
|
||||||
<TagDropdown
|
|
||||||
v-model="editableTimeEntry.tags"
|
|
||||||
:create-tag
|
|
||||||
:tags="tags">
|
|
||||||
<template #trigger>
|
|
||||||
<Badge
|
|
||||||
class="bg-input-background"
|
|
||||||
tag="button"
|
|
||||||
size="xlarge">
|
|
||||||
<TagIcon
|
|
||||||
v-if="editableTimeEntry.tags.length === 0"
|
|
||||||
class="w-4"></TagIcon>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
|
|
||||||
{{ editableTimeEntry.tags.length }}
|
|
||||||
</div>
|
|
||||||
<span>Tags</span>
|
|
||||||
</Badge>
|
|
||||||
</template>
|
|
||||||
</TagDropdown>
|
|
||||||
</div>
|
|
||||||
<div class="flex-col">
|
|
||||||
<SelectDropdown
|
|
||||||
v-model="billableProxy"
|
|
||||||
:get-key-from-item="(item: BillableOption) => item.value"
|
|
||||||
:get-name-for-item="(item: BillableOption) => item.label"
|
|
||||||
:items="[
|
|
||||||
{
|
|
||||||
label: 'Billable',
|
|
||||||
value: 'true',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Non Billable',
|
|
||||||
value: 'false',
|
|
||||||
},
|
|
||||||
]">
|
|
||||||
<template #trigger>
|
|
||||||
<Badge
|
|
||||||
class="bg-input-background"
|
|
||||||
tag="button"
|
|
||||||
size="xlarge">
|
|
||||||
<BillableIcon class="h-4"></BillableIcon>
|
|
||||||
<span>{{
|
|
||||||
editableTimeEntry.billable
|
|
||||||
? 'Billable'
|
|
||||||
: 'Non-Billable'
|
|
||||||
}}</span>
|
|
||||||
</Badge>
|
|
||||||
</template>
|
|
||||||
</SelectDropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex pt-4 space-x-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<InputLabel>Duration</InputLabel>
|
|
||||||
<div class="space-y-2 mt-1 flex flex-col">
|
|
||||||
<DurationHumanInput
|
|
||||||
v-model:start="localStart"
|
|
||||||
v-model:end="localEnd"
|
|
||||||
name="Duration"></DurationHumanInput>
|
|
||||||
<div class="text-sm flex space-x-1">
|
|
||||||
<InformationCircleIcon
|
|
||||||
class="w-4 text-text-quaternary"></InformationCircleIcon>
|
|
||||||
<span class="text-text-secondary text-xs">
|
|
||||||
You can type natural language here f.e.
|
|
||||||
<span class="font-semibold"> 2h 30m</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="">
|
|
||||||
<InputLabel>Start</InputLabel>
|
|
||||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
|
||||||
<TimePickerSimple v-model="localStart" size="large"></TimePickerSimple>
|
|
||||||
<DatePicker
|
|
||||||
v-model="localStart"
|
|
||||||
tabindex="1"
|
|
||||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="">
|
|
||||||
<InputLabel>End</InputLabel>
|
|
||||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
|
||||||
<TimePickerSimple v-model="localEnd" size="large"></TimePickerSimple>
|
|
||||||
<DatePicker
|
|
||||||
v-model="localEnd"
|
|
||||||
tabindex="1"
|
|
||||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<div class="flex justify-between w-full">
|
|
||||||
<SecondaryButton
|
|
||||||
tabindex="2"
|
|
||||||
class="bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700"
|
|
||||||
:disabled="deleting || saving"
|
|
||||||
@click="deleteEntry">
|
|
||||||
{{ deleting ? 'Deleting...' : 'Delete' }}
|
|
||||||
</SecondaryButton>
|
|
||||||
<div class="flex space-x-3">
|
|
||||||
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
|
|
||||||
<PrimaryButton
|
|
||||||
tabindex="2"
|
|
||||||
:class="{ 'opacity-25': saving }"
|
|
||||||
:disabled="saving || deleting"
|
|
||||||
@click="submit">
|
|
||||||
{{ saving ? 'Updating...' : 'Update Time Entry' }}
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</DialogModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -63,7 +63,7 @@ const showMassUpdateModal = ref(false);
|
|||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
props.class,
|
props.class,
|
||||||
'text-sm py-1.5 font-medium bg-secondary flex items-center space-x-3'
|
'text-sm py-1.5 font-medium border-t border-b bg-secondary border-border-secondary flex items-center space-x-3'
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TrashIcon, PencilIcon } from '@heroicons/vue/20/solid';
|
import { TrashIcon } from '@heroicons/vue/20/solid';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -7,17 +7,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/Components/ui/dropdown-menu';
|
} from '@/Components/ui/dropdown-menu';
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
showEdit?: boolean;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
showEdit: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
edit: [];
|
|
||||||
delete: [];
|
delete: [];
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
@@ -43,14 +33,6 @@ const emit = defineEmits<{
|
|||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent class="min-w-[150px]" align="end">
|
<DropdownMenuContent class="min-w-[150px]" align="end">
|
||||||
<DropdownMenuItem
|
|
||||||
v-if="props.showEdit"
|
|
||||||
data-testid="time_entry_edit"
|
|
||||||
class="flex items-center space-x-3 cursor-pointer"
|
|
||||||
@click="emit('edit')">
|
|
||||||
<PencilIcon class="w-5" />
|
|
||||||
<span>Edit</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
data-testid="time_entry_delete"
|
data-testid="time_entry_delete"
|
||||||
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
|
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ import TimeEntryDescriptionInput from '@/packages/ui/src/TimeEntry/TimeEntryDesc
|
|||||||
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
|
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
|
||||||
import TimeEntryRowDurationInput from '@/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue';
|
import TimeEntryRowDurationInput from '@/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue';
|
||||||
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
||||||
import { TimeEntryEditModal } from '@/packages/ui/src';
|
|
||||||
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed } from 'vue';
|
||||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||||
import { Checkbox } from '@/packages/ui/src';
|
import { Checkbox } from '@/packages/ui/src';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
@@ -47,8 +46,6 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{ selected: []; unselected: [] }>();
|
const emit = defineEmits<{ selected: []; unselected: [] }>();
|
||||||
|
|
||||||
const showEditModal = ref(false);
|
|
||||||
|
|
||||||
function updateTimeEntryDescription(description: string) {
|
function updateTimeEntryDescription(description: string) {
|
||||||
props.updateTimeEntry({ ...props.timeEntry, description });
|
props.updateTimeEntry({ ...props.timeEntry, description });
|
||||||
}
|
}
|
||||||
@@ -90,20 +87,6 @@ function onSelectChange(checked: boolean) {
|
|||||||
emit('unselected');
|
emit('unselected');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEdit() {
|
|
||||||
showEditModal.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdateTimeEntry(updatedEntry: TimeEntry) {
|
|
||||||
props.updateTimeEntry(updatedEntry);
|
|
||||||
showEditModal.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteTimeEntry() {
|
|
||||||
props.deleteTimeEntry();
|
|
||||||
showEditModal.value = false;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -165,26 +148,11 @@ async function handleDeleteTimeEntry() {
|
|||||||
class="opacity-20 flex focus-visible:opacity-100 group-hover:opacity-100"
|
class="opacity-20 flex focus-visible:opacity-100 group-hover:opacity-100"
|
||||||
@changed="onStartStopClick"></TimeTrackerStartStop>
|
@changed="onStartStopClick"></TimeTrackerStartStop>
|
||||||
<TimeEntryMoreOptionsDropdown
|
<TimeEntryMoreOptionsDropdown
|
||||||
@edit="handleEdit"
|
|
||||||
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
|
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TimeEntryEditModal
|
|
||||||
v-model:show="showEditModal"
|
|
||||||
:time-entry="timeEntry"
|
|
||||||
:enable-estimated-time="enableEstimatedTime"
|
|
||||||
:update-time-entry="handleUpdateTimeEntry"
|
|
||||||
:delete-time-entry="handleDeleteTimeEntry"
|
|
||||||
:create-client="createClient"
|
|
||||||
:create-project="createProject"
|
|
||||||
:create-tag="createTag"
|
|
||||||
:tags="tags"
|
|
||||||
:projects="projects"
|
|
||||||
:tasks="tasks"
|
|
||||||
:clients="clients" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -27,11 +27,7 @@ import Checkbox from './Input/Checkbox.vue';
|
|||||||
import TimeEntryGroupedTable from './TimeEntry/TimeEntryGroupedTable.vue';
|
import TimeEntryGroupedTable from './TimeEntry/TimeEntryGroupedTable.vue';
|
||||||
import TimeEntryMassActionRow from './TimeEntry/TimeEntryMassActionRow.vue';
|
import TimeEntryMassActionRow from './TimeEntry/TimeEntryMassActionRow.vue';
|
||||||
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
|
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
|
||||||
import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
|
|
||||||
import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
|
import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
|
||||||
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
|
||||||
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
|
|
||||||
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
money,
|
money,
|
||||||
@@ -56,8 +52,4 @@ export {
|
|||||||
TimeEntryMassActionRow,
|
TimeEntryMassActionRow,
|
||||||
MoreOptionsDropdown,
|
MoreOptionsDropdown,
|
||||||
TimeEntryCreateModal,
|
TimeEntryCreateModal,
|
||||||
TimeEntryEditModal,
|
|
||||||
FullCalendarEventContent,
|
|
||||||
FullCalendarDayHeader,
|
|
||||||
TimeEntryCalendar,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
export function openFeedback(): void {
|
|
||||||
if (
|
|
||||||
typeof window !== 'undefined' &&
|
|
||||||
'showChatWindow' in window &&
|
|
||||||
typeof window.showChatWindow === 'function'
|
|
||||||
) {
|
|
||||||
window.showChatWindow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,10 +36,6 @@ Route::middleware([
|
|||||||
return Inertia::render('Time');
|
return Inertia::render('Time');
|
||||||
})->name('time');
|
})->name('time');
|
||||||
|
|
||||||
Route::get('/calendar', function () {
|
|
||||||
return Inertia::render('Calendar');
|
|
||||||
})->name('calendar');
|
|
||||||
|
|
||||||
Route::get('/reporting', function () {
|
Route::get('/reporting', function () {
|
||||||
return Inertia::render('Reporting');
|
return Inertia::render('Reporting');
|
||||||
})->name('reporting');
|
})->name('reporting');
|
||||||
|
|||||||
Reference in New Issue
Block a user