mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-09 16:52:17 +01:00
Compare commits
26 Commits
feature/ad
...
feature/si
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4623697f79 | ||
|
|
1c787a0ad0 | ||
|
|
5e678edb9d | ||
|
|
65e145b20f | ||
|
|
d2b636842a | ||
|
|
1510884f3b | ||
|
|
7f89fd8ea1 | ||
|
|
0b45f3b473 | ||
|
|
9827a74ae2 | ||
|
|
3425847a44 | ||
|
|
47b778fab9 | ||
|
|
85d69f1f16 | ||
|
|
fca55fe0e1 | ||
|
|
f19abb9db6 | ||
|
|
e3bd50ed6b | ||
|
|
c582530899 | ||
|
|
fb5185a32f | ||
|
|
0a0854f771 | ||
|
|
4e635cde83 | ||
|
|
9fa9522237 | ||
|
|
04c44097d0 | ||
|
|
3d5a0cb974 | ||
|
|
da98e0571c | ||
|
|
f68f05d1aa | ||
|
|
8fdc4c1219 | ||
|
|
93148299a9 |
216
.github/workflows/build-onpremise.yml
vendored
Normal file
216
.github/workflows/build-onpremise.yml
vendored
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/build-onpremise.yml'
|
||||||
|
- 'docker/prod/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKER_REPO: registry.on-premise.solidtime.io/solidtime/solidtime
|
||||||
|
|
||||||
|
name: Build - On Premise
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- runs-on: "ubuntu-24.04-arm"
|
||||||
|
platform: "linux/arm64"
|
||||||
|
- runs-on: "ubuntu-24.04"
|
||||||
|
platform: "linux/amd64"
|
||||||
|
runs-on: ${{ matrix.runs-on }}
|
||||||
|
timeout-minutes: 90
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: "Check out code"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
|
- name: "Get build"
|
||||||
|
id: release-build
|
||||||
|
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: "Get Previous tag (normal push)"
|
||||||
|
id: previoustag
|
||||||
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
|
with:
|
||||||
|
prefix: "v"
|
||||||
|
|
||||||
|
- name: "Get version"
|
||||||
|
id: release-version
|
||||||
|
run: |
|
||||||
|
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
||||||
|
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
||||||
|
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
|
||||||
|
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "ERROR: No previous tag found";
|
||||||
|
exit 1;
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
version=$(echo "${{ github.ref }}" | cut -c 12-)
|
||||||
|
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: "Copy .env template for production"
|
||||||
|
run: |
|
||||||
|
cp .env.production .env
|
||||||
|
rm .env.production .env.ci .env.example
|
||||||
|
|
||||||
|
- name: "Add version to .env"
|
||||||
|
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
|
||||||
|
|
||||||
|
- name: "Add build to .env"
|
||||||
|
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
|
||||||
|
|
||||||
|
- name: "Output .env"
|
||||||
|
run: cat .env
|
||||||
|
|
||||||
|
- name: "Setup PHP with PECL extension"
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
extensions: mbstring, dom, fileinfo, pgsql
|
||||||
|
|
||||||
|
- name: "Install dependencies"
|
||||||
|
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||||
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
|
|
||||||
|
- name: "Use Node.js"
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.x'
|
||||||
|
|
||||||
|
- name: "Checkout invoicing extension"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: solidtime-io/extension-invoicing
|
||||||
|
path: extensions/Invoicing
|
||||||
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||||
|
|
||||||
|
- name: "Install composer dependencies in invoicing extension"
|
||||||
|
run: cd extensions/Invoicing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||||
|
|
||||||
|
- name: "Install npm dependencies in invoicing extension"
|
||||||
|
run: cd extensions/Invoicing && npm ci
|
||||||
|
|
||||||
|
- name: "Activate invoicing extension"
|
||||||
|
run: php artisan module:enable Invoicing
|
||||||
|
|
||||||
|
- name: "Install npm dependencies"
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: "Build"
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: "Prepare"
|
||||||
|
run: |
|
||||||
|
platform=${{ matrix.platform }}
|
||||||
|
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: "Docker meta"
|
||||||
|
id: "meta"
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.DOCKER_REPO }}
|
||||||
|
|
||||||
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: registry.on-premise.solidtime.io
|
||||||
|
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||||
|
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||||
|
|
||||||
|
- name: "Set up QEMU"
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: "Set up Docker Buildx"
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: "Build and push by digest"
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/prod/Dockerfile
|
||||||
|
build-args: |
|
||||||
|
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,"name=${{ env.DOCKER_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: "Export digest"
|
||||||
|
run: |
|
||||||
|
mkdir -p ${{ runner.temp }}/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: "Upload digest"
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
|
path: ${{ runner.temp }}/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
needs:
|
||||||
|
- build
|
||||||
|
steps:
|
||||||
|
- name: "Download digests"
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: registry.on-premise.solidtime.io
|
||||||
|
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||||
|
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||||
|
|
||||||
|
- name: "Set up Docker Buildx"
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: "Docker meta"
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.DOCKER_REPO }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
|
||||||
|
- name: "Create manifest list and push"
|
||||||
|
working-directory: ${{ runner.temp }}/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: "Inspect image"
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ env.DOCKER_REPO }}:${{ steps.meta.outputs.version }}
|
||||||
@@ -41,7 +41,8 @@ class HandleInertiaRequests extends Middleware
|
|||||||
{
|
{
|
||||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||||
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
||||||
|
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
||||||
|
|
||||||
/** @var BillingContract $billing */
|
/** @var BillingContract $billing */
|
||||||
$billing = app(BillingContract::class);
|
$billing = app(BillingContract::class);
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
return array_merge(parent::share($request), [
|
return array_merge(parent::share($request), [
|
||||||
'has_billing_extension' => $hasBilling,
|
'has_billing_extension' => $hasBilling,
|
||||||
'has_invoicing_extension' => $hasInvoicing,
|
'has_invoicing_extension' => $hasInvoicing,
|
||||||
|
'has_services_extension' => $hasServices,
|
||||||
'billing' => $currentOrganization !== null ? [
|
'billing' => $currentOrganization !== null ? [
|
||||||
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
||||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Source: https://helgesver.re/articles/laravel-sail-create-minio-bucket-automatically
|
# Source: https://helgesver.re/articles/laravel-sail-create-minio-bucket-automatically
|
||||||
|
|
||||||
/usr/bin/mc config host add local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
|
/usr/bin/mc alias set local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
|
||||||
/usr/bin/mc rm -r --force local/${S3_BUCKET};
|
/usr/bin/mc rm -r --force local/${S3_BUCKET};
|
||||||
/usr/bin/mc mb --ignore-existing local/${S3_BUCKET};
|
/usr/bin/mc mb --ignore-existing local/${S3_BUCKET};
|
||||||
/usr/bin/mc anonymous set public local/${S3_BUCKET};
|
/usr/bin/mc anonymous set public local/${S3_BUCKET};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ RUN CGO_ENABLED=1 \
|
|||||||
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
||||||
CGO_CFLAGS=$(php-config --includes) \
|
CGO_CFLAGS=$(php-config --includes) \
|
||||||
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
||||||
xcaddy build \
|
xcaddy build v2.10.0 \
|
||||||
--output /usr/local/bin/frankenphp \
|
--output /usr/local/bin/frankenphp \
|
||||||
--with github.com/dunglas/frankenphp=./ \
|
--with github.com/dunglas/frankenphp=./ \
|
||||||
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
||||||
|
|||||||
79
package-lock.json
generated
79
package-lock.json
generated
@@ -7,6 +7,11 @@
|
|||||||
"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",
|
||||||
@@ -18,6 +23,7 @@
|
|||||||
"@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",
|
||||||
@@ -39,6 +45,7 @@
|
|||||||
"@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",
|
||||||
@@ -1027,6 +1034,55 @@
|
|||||||
"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",
|
||||||
@@ -1842,6 +1898,13 @@
|
|||||||
"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",
|
||||||
@@ -2877,6 +2940,12 @@
|
|||||||
"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",
|
||||||
@@ -5140,6 +5209,16 @@
|
|||||||
"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,6 +19,7 @@
|
|||||||
"@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",
|
||||||
@@ -39,6 +40,11 @@
|
|||||||
"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",
|
||||||
@@ -50,6 +56,7 @@
|
|||||||
"@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,14 +46,16 @@
|
|||||||
|
|
||||||
--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: #F5F5F5;
|
--color-bg-primary: #FFFFFF;
|
||||||
--color-bg-secondary: #f7f7f8;
|
--color-bg-secondary: #f7f7f8;
|
||||||
--color-bg-tertiary: #e1e1e3;
|
--color-bg-tertiary: #eeeeef;
|
||||||
--color-bg-quaternary: #ffffff;
|
--color-bg-quaternary: #e1e1e3;
|
||||||
--color-bg-background: #ffffff;
|
--color-bg-background: #F5F5F5;
|
||||||
--color-text-primary: #18181b;
|
--color-text-primary: #18181b;
|
||||||
--color-text-secondary: #3f3f46;
|
--color-text-secondary: #3f3f46;
|
||||||
--color-text-tertiary: #57575C;
|
--color-text-tertiary: #57575C;
|
||||||
@@ -63,14 +65,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-tertiary);
|
--theme-color-menu-active: var(--color-bg-quaternary);
|
||||||
|
|
||||||
--theme-color-card-background: var(--color-bg-quaternary);
|
--theme-color-card-background: var(--color-bg-primary);
|
||||||
--theme-color-card-background-active: var(--color-bg-primary);
|
--theme-color-card-background-active: var(--color-bg-tertiary);
|
||||||
|
|
||||||
--theme-color-chart: var(--color-accent-400);
|
--theme-color-chart: var(--color-accent-400);
|
||||||
|
|
||||||
--theme-shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
|
||||||
--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);
|
||||||
@@ -85,17 +87,18 @@
|
|||||||
--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-quaternary);
|
--theme-color-input-background: var(--color-bg-primary);
|
||||||
|
|
||||||
--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);
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<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,9 +4,7 @@ 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';
|
||||||
@@ -90,23 +88,8 @@ 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 tasks found</h3>
|
<h3 class="text-text-primary font-semibold text-sm">No recent time entries</h3>
|
||||||
<p class="pb-5 text-sm">Create tasks inside of a project!</p>
|
<p class="pb-5 text-sm">Start tracking your time!</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 sm:pb-24 z-[70]">
|
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-end sm:p-6 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,12 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
import DropdownLink from '@/Components/DropdownLink.vue';
|
import {
|
||||||
import { usePage } from '@inertiajs/vue3';
|
Cog6ToothIcon,
|
||||||
|
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: {
|
||||||
@@ -28,84 +39,79 @@ const switchToTeam = (organization: Organization) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dropdown v-if="page.props.jetstream.hasTeamFeatures" align="center" width="60">
|
<DropdownMenu v-if="page.props.jetstream.hasTeamFeatures">
|
||||||
<template #trigger>
|
<DropdownMenuTrigger
|
||||||
<div
|
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"
|
||||||
data-testid="organization_switcher"
|
as-child>
|
||||||
class="flex hover:bg-white/10 cursor-pointer transition px-2 py-1 rounded-lg w-full items-center justify-between font-medium">
|
<button data-testid="organization_switcher">
|
||||||
<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 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">
|
class="rounded bg-blue-900 font-medium text-xs flex-shrink-0 text-white w-5 h-5 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-sm flex-1 truncate font-semibold">
|
<span class="text-xs flex-1 truncate font-medium">
|
||||||
{{ 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]">
|
||||||
<button
|
<div class="p-1 rounded-full flex items-center w-6 h-6">
|
||||||
class="p-1 transition hover:bg-white/10 rounded-full flex items-center w-8 h-8">
|
<ChevronDownIcon class="w-4 sm:w-full mt-[1px]"></ChevronDownIcon>
|
||||||
<ChevronDownIcon class="w-5 sm:w-full mt-[1px]"></ChevronDownIcon>
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</template>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<template #content>
|
<DropdownMenuContent align="start">
|
||||||
<div class="w-60">
|
<div class="w-60">
|
||||||
<!-- Organization Management -->
|
<DropdownMenuLabel>Manage Organization</DropdownMenuLabel>
|
||||||
<div class="block px-4 py-2 text-xs text-text-secondary">Manage Organization</div>
|
|
||||||
|
|
||||||
<!-- Organization Settings -->
|
<DropdownMenuItem as-child>
|
||||||
<DropdownLink :href="route('teams.show', page.props.auth.user.current_team.id)">
|
<Link
|
||||||
Organization Settings
|
:href="route('teams.show', page.props.auth.user.current_team.id)"
|
||||||
</DropdownLink>
|
class="inline-flex items-center gap-2.5 w-full">
|
||||||
|
<Cog6ToothIcon class="w-5 h-5 text-icon-default" />
|
||||||
|
<span>Organization Settings</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownLink v-if="canManageBilling() && isBillingActivated()" href="/billing">
|
<DropdownMenuItem v-if="canManageBilling() && isBillingActivated()" as-child>
|
||||||
Billing
|
<Link href="/billing" class="inline-flex items-center w-full"> Billing </Link>
|
||||||
</DropdownLink>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownLink
|
<DropdownMenuItem v-if="page.props.jetstream.canCreateTeams" as-child>
|
||||||
v-if="page.props.jetstream.canCreateTeams"
|
<Link
|
||||||
:href="route('teams.create')">
|
:href="route('teams.create')"
|
||||||
Create new organization
|
class="inline-flex items-center gap-2.5 w-full">
|
||||||
</DropdownLink>
|
<PlusCircleIcon class="w-5 h-5 text-icon-default" />
|
||||||
|
<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" />
|
||||||
|
|
||||||
<div class="block px-4 py-2 text-xs text-text-secondary">
|
<DropdownMenuLabel>Switch Organizations</DropdownMenuLabel>
|
||||||
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)">
|
||||||
<DropdownLink as="button">
|
<DropdownMenuItem
|
||||||
<div class="flex items-center">
|
as-child
|
||||||
<svg
|
class="inline-flex gap-2.5 items-center w-full">
|
||||||
|
<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="me-2 h-5 w-5 text-green-400"
|
class="h-5 w-5 text-green-400" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<ArrowRightIcon v-else class="h-5 w-5 text-icon-default" />
|
||||||
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>
|
<div class="w-full truncate text-left">
|
||||||
{{ team.name }}
|
{{ team.name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</DropdownLink>
|
</DropdownMenuItem>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</DropdownMenuContent>
|
||||||
</Dropdown>
|
</DropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { router, usePage } from '@inertiajs/vue3';
|
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||||
import type { Organization, User } from '@/types/models';
|
import type { Organization, User } from '@/types/models';
|
||||||
import DropdownLink from '@/Components/DropdownLink.vue';
|
import {
|
||||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
DropdownMenu,
|
||||||
|
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;
|
||||||
@@ -23,60 +37,58 @@ const logout = () => {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="ms-3 relative">
|
<div class="relative">
|
||||||
<Dropdown align="center" width="48">
|
<DropdownMenu>
|
||||||
<template #trigger>
|
<DropdownMenuTrigger
|
||||||
<button
|
class="flex text-sm border-2 outline-none border-transparent rounded-full focus-visible:ring-2 focus-visible:ring-ring transition"
|
||||||
v-if="page.props.jetstream.managesProfilePhotos"
|
as-child>
|
||||||
data-testid="current_user_button"
|
<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-8 w-8 rounded-full object-cover"
|
class="h-7 w-7 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>
|
||||||
|
|
||||||
<span v-else class="inline-flex rounded-md">
|
<DropdownMenuItem as-child>
|
||||||
|
<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 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">
|
class="inline-flex items-center gap-2.5 w-full"
|
||||||
{{ page.props.auth.user.name }}
|
@click="openFeedback">
|
||||||
|
<ChatBubbleLeftRightIcon class="w-5 h-5 text-icon-default" />
|
||||||
<svg
|
<span>Feedback</span>
|
||||||
class="ms-2 -me-0.5 h-4 w-4"
|
|
||||||
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="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</DropdownMenuItem>
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #content>
|
<form class="w-full" @submit.prevent="logout">
|
||||||
<!-- Account Management -->
|
<DropdownMenuItem as-child class="inline-flex items-center gap-2.5 w-full">
|
||||||
<div class="block px-4 py-2 text-xs text-gray-400">Manage Account</div>
|
<button type="submit" data-testid="logout_button">
|
||||||
|
<ArrowLeftOnRectangleIcon class="w-5 h-5 text-icon-default" />
|
||||||
<DropdownLink :href="route('profile.show')"> Profile </DropdownLink>
|
<span>Log Out</span>
|
||||||
|
</button>
|
||||||
<DropdownLink
|
</DropdownMenuItem>
|
||||||
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>
|
||||||
</template>
|
</DropdownMenuContent>
|
||||||
</Dropdown>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
|||||||
<template>
|
<template>
|
||||||
<DropdownMenuLabel
|
<DropdownMenuLabel
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
:class="cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)">
|
:class="cn('block px-2 py-2 text-xs text-gray-400', inset && 'pl-8', props.class)">
|
||||||
<slot />
|
<slot />
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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,
|
||||||
@@ -39,14 +40,19 @@ 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);
|
||||||
@@ -90,8 +96,8 @@ onMounted(async () => {
|
|||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
|
has_services_extension?: boolean;
|
||||||
auth: {
|
auth: {
|
||||||
user: User;
|
user: User;
|
||||||
};
|
};
|
||||||
@@ -102,7 +108,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-[9999999999]': showSidebarMenu,
|
'!flex bg-default-background w-full z-30': 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">
|
||||||
@@ -131,6 +137,11 @@ 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"
|
||||||
@@ -233,35 +244,44 @@ 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 flex justify-between pr-4 items-center">
|
class="border-t border-default-background-separator pt-3 gap-1 pr-2 flex justify-between 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>
|
||||||
|
|
||||||
<UserSettingsIcon></UserSettingsIcon>
|
<Button
|
||||||
|
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="lg:hidden w-full px-3 py-1 border-b border-b-default-background-separator text-text-secondary flex justify-between items-center">
|
class="h-screen overflow-y-auto flex flex-col bg-default-background border-l border-default-background-separator">
|
||||||
<Bars3Icon
|
<div
|
||||||
class="w-7 text-text-secondary"
|
class="lg:hidden w-full px-3 py-1 border-b border-b-default-background-separator text-text-secondary flex justify-between items-center">
|
||||||
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
|
<Bars3Icon
|
||||||
<OrganizationSwitcher></OrganizationSwitcher>
|
class="w-7 text-text-secondary"
|
||||||
</div>
|
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
|
||||||
|
<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">
|
||||||
@@ -273,7 +293,7 @@ const page = usePage<{
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<main class="pb-28 flex-1">
|
<main :class="twMerge('pb-28 relative flex-1', mainClass)">
|
||||||
<div
|
<div
|
||||||
v-if="isOrganizationLoading"
|
v-if="isOrganizationLoading"
|
||||||
class="flex items-center justify-center h-screen">
|
class="flex items-center justify-center h-screen">
|
||||||
@@ -285,4 +305,5 @@ const page = usePage<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NotificationContainer></NotificationContainer>
|
<NotificationContainer></NotificationContainer>
|
||||||
|
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
140
resources/js/Pages/Calendar.vue
Normal file
140
resources/js/Pages/Calendar.vue
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<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,6 +369,7 @@ 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,6 +144,7 @@ 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,7 +10,8 @@ const props = withDefaults(
|
|||||||
icon?: Component;
|
icon?: Component;
|
||||||
size?: 'small' | 'base';
|
size?: 'small' | 'base';
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
class?: string;
|
// Accept any valid Vue class binding shape (string | object | array)
|
||||||
|
class?: Parameters<typeof twMerge>[0];
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<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>
|
||||||
596
resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue
Normal file
596
resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
<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-1.5 items-center min-w-0 justify-between group">
|
<div class="sm:flex py-2 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,6 +172,7 @@ 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,6 +41,8 @@ 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);
|
||||||
@@ -63,7 +65,27 @@ const timeEntryDefaultValues = {
|
|||||||
end: getDayJsInstance().utc().format(),
|
end: getDayJsInstance().utc().format(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeEntry = ref({ ...timeEntryDefaultValues });
|
const timeEntry = ref({
|
||||||
|
...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,
|
||||||
|
|||||||
311
resources/js/packages/ui/src/TimeEntry/TimeEntryEditModal.vue
Normal file
311
resources/js/packages/ui/src/TimeEntry/TimeEntryEditModal.vue
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
<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 border-t border-b bg-secondary border-border-secondary flex items-center space-x-3'
|
'text-sm py-1.5 font-medium bg-secondary flex items-center space-x-3'
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TrashIcon } from '@heroicons/vue/20/solid';
|
import { TrashIcon, PencilIcon } from '@heroicons/vue/20/solid';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -7,7 +7,17 @@ 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>
|
||||||
@@ -33,6 +43,14 @@ 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,8 +16,9 @@ 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 } from 'vue';
|
import { computed, ref } 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';
|
||||||
@@ -46,6 +47,8 @@ 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 });
|
||||||
}
|
}
|
||||||
@@ -87,6 +90,20 @@ 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>
|
||||||
@@ -148,11 +165,26 @@ function onSelectChange(checked: boolean) {
|
|||||||
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,7 +27,11 @@ 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,
|
||||||
@@ -52,4 +56,8 @@ export {
|
|||||||
TimeEntryMassActionRow,
|
TimeEntryMassActionRow,
|
||||||
MoreOptionsDropdown,
|
MoreOptionsDropdown,
|
||||||
TimeEntryCreateModal,
|
TimeEntryCreateModal,
|
||||||
|
TimeEntryEditModal,
|
||||||
|
FullCalendarEventContent,
|
||||||
|
FullCalendarDayHeader,
|
||||||
|
TimeEntryCalendar,
|
||||||
};
|
};
|
||||||
|
|||||||
9
resources/js/utils/feedback.ts
Normal file
9
resources/js/utils/feedback.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function openFeedback(): void {
|
||||||
|
if (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
'showChatWindow' in window &&
|
||||||
|
typeof window.showChatWindow === 'function'
|
||||||
|
) {
|
||||||
|
window.showChatWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,10 @@ 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