mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Compare commits
1 Commits
feature/ca
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3d7cdaf4d |
@@ -80,7 +80,8 @@ GOTENBERG_URL=http://gotenberg:3000
|
||||
# Local setup
|
||||
NGINX_HOST_NAME=solidtime.test
|
||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||
FORWARD_DB_PORT=54329
|
||||
FORWARD_DB_PORT=5432
|
||||
FORWARD_WEB_PORT=8083
|
||||
VITE_HOST_NAME=vite.solidtime.test
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
#SAIL_XDEBUG_MODE=develop,debug,coverage
|
||||
|
||||
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,11 +1,8 @@
|
||||
## What does this PR do?
|
||||
<!--
|
||||
This project is early stage. The structure and APIs are still subject to change and not stable.
|
||||
Therefore, we do not currently accept any contributions, unless you are a member of the team.
|
||||
|
||||
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
||||
|
||||
- Fixes #XXXX (GitHub issue number)
|
||||
|
||||
## Checklist (DO NOT REMOVE)
|
||||
|
||||
- [ ] I read the [contributing guide](https://github.com/solidtime-io/solidtime/blob/main/CONTRIBUTING.md)
|
||||
- [ ] I signed the [Contributor License Agreement](https://cla-assistant.io/solidtime-io/solidtime).
|
||||
- [ ] I commented my code, particularly in hard-to-understand areas
|
||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||
-->
|
||||
|
||||
216
.github/workflows/build-onpremise.yml
vendored
216
.github/workflows/build-onpremise.yml
vendored
@@ -1,216 +0,0 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-onpremise.yml'
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
DOCKER_REPO: registry.on-premise.solidtime.io/solidtime/solidtime
|
||||
|
||||
name: Build - On Premise
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- runs-on: "ubuntu-24.04-arm"
|
||||
platform: "linux/arm64"
|
||||
- runs-on: "ubuntu-24.04"
|
||||
platform: "linux/amd64"
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||
|
||||
- name: "Get build"
|
||||
id: release-build
|
||||
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Get Previous tag (normal push)"
|
||||
id: previoustag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
prefix: "v"
|
||||
|
||||
- name: "Get version"
|
||||
id: release-version
|
||||
run: |
|
||||
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
||||
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
||||
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
|
||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ERROR: No previous tag found";
|
||||
exit 1;
|
||||
fi
|
||||
else
|
||||
version=$(echo "${{ github.ref }}" | cut -c 12-)
|
||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: "Copy .env template for production"
|
||||
run: |
|
||||
cp .env.production .env
|
||||
rm .env.production .env.ci .env.example
|
||||
|
||||
- name: "Add version to .env"
|
||||
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
|
||||
|
||||
- name: "Add build to .env"
|
||||
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
|
||||
|
||||
- name: "Output .env"
|
||||
run: cat .env
|
||||
|
||||
- name: "Setup PHP with PECL extension"
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: mbstring, dom, fileinfo, pgsql
|
||||
|
||||
- name: "Install dependencies"
|
||||
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: "Checkout invoicing extension"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: solidtime-io/extension-invoicing
|
||||
path: extensions/Invoicing
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||
|
||||
- name: "Install composer dependencies in invoicing extension"
|
||||
run: cd extensions/Invoicing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||
|
||||
- name: "Install npm dependencies in invoicing extension"
|
||||
run: cd extensions/Invoicing && npm ci
|
||||
|
||||
- name: "Activate invoicing extension"
|
||||
run: php artisan module:enable Invoicing
|
||||
|
||||
- name: "Install npm dependencies"
|
||||
run: npm ci
|
||||
|
||||
- name: "Build"
|
||||
run: npm run build
|
||||
|
||||
- name: "Prepare"
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: "Docker meta"
|
||||
id: "meta"
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_REPO }}
|
||||
|
||||
- name: "Login to solidtime OnPremise Registry"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.on-premise.solidtime.io
|
||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||
|
||||
- name: "Set up QEMU"
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: "Build and push by digest"
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/prod/Dockerfile
|
||||
build-args: |
|
||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=${{ env.DOCKER_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: "Export digest"
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: "Upload digest"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: "Download digests"
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: "Login to solidtime OnPremise Registry"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.on-premise.solidtime.io
|
||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: "Docker meta"
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_REPO }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: "Create manifest list and push"
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
|
||||
|
||||
- name: "Inspect image"
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.DOCKER_REPO }}:${{ steps.meta.outputs.version }}
|
||||
@@ -1,81 +0,0 @@
|
||||
# Contributing to solidtime
|
||||
|
||||
Contributions are greatly apprecited, please make sure to read the rules and vision for solidtime before contributing.
|
||||
|
||||
## Rules
|
||||
|
||||
### Issues for Bugs, Discussions for Feature requests
|
||||
|
||||
In order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancement are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted.
|
||||
|
||||
### Only work on approved issues
|
||||
|
||||
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
||||
|
||||
### Contributor License Agreement
|
||||
|
||||
You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.
|
||||
|
||||
We want to be transparent about why we require the CLA and what it means for your contributions and the codebase. That’s why we’ve written a few paragraphs below outlining our plans and vision for solidtime in the **Vision** part of this document.
|
||||
|
||||
### Prevent Duplicate Work
|
||||
|
||||
Before you submit a new PR, make sure that none exists already. If you plan to work on an issue, make sure to let us and others know by commenting on the issue/discussion.
|
||||
|
||||
### Give context
|
||||
|
||||
Tell us what you thinking was behind the decisions you made while drafting the PR. Treat the PR itself as documentation for everyone who wants to go back and understand why certain decisions were made.
|
||||
|
||||
### Summarize your PR
|
||||
|
||||
Please make sure to include a short summary at the top of your PR to make it easy for us to quickly check what the PR is about, without looking at the code changes.
|
||||
|
||||
### Use Github Keywords and Auto-Link Issues
|
||||
|
||||
Use phrases like "Closes #123" or "Fixes #123" in the PR description to link the PR with the issue that you are adressing.
|
||||
|
||||
### Mention what you tested and how
|
||||
|
||||
Explain how you tested and validated the implementation.
|
||||
|
||||
### Keep Naming consistent
|
||||
|
||||
Look at existing code patterns and use naming conventions that already exist in the code base.
|
||||
|
||||
### Testing
|
||||
|
||||
We have an exhaustive test-suite of PHPUnit (Backend) and Playwright (Frontend) testing. Whereever applicable please make sure to write add tests to the codebase.
|
||||
|
||||
### Linting & Formatting
|
||||
|
||||
Make sure to run linting and formatting commands before you commit the changes.
|
||||
|
||||
For backend changes:
|
||||
|
||||
```
|
||||
composer fix
|
||||
composer analyse
|
||||
```
|
||||
|
||||
For frontend changes:
|
||||
|
||||
```
|
||||
npm run lint:fix
|
||||
npm run format
|
||||
```
|
||||
|
||||
## Vision
|
||||
|
||||
We started solidtime to provide an open infrastructure solution for time tracking—one that empowers teams and individuals to fully own their data, instead of depending on proprietary platforms. We believe infrastructure software should be open, accessible, and built to last. However, competing with established market leaders in this space requires long-term financial sustainability.
|
||||
|
||||
solidtime is licensed under the AGPL, which we believe is the best available license to strike a balance between openness and financial viability. The AGPL gives us, as the copyright holders, certain exclusive rights that we plan to leverage to fund development. To ensure we retain those rights across the entire codebase, we've put a CLA in place that contributors must sign before submitting code.
|
||||
|
||||
One of solidtime’s key advantages is that it's built to be self-hostable. This makes it a great solution for organizations like governments, healthcare providers, and enterprises that are required to keep data on their own infrastructure due to regulations or internal policies. These organizations may need custom licenses, integrations, or modifications that aren't suitable for the open-source version. To support them, we offer relicensed versions of solidtime along with support plans.
|
||||
|
||||
We’ll also provide proprietary extensions for solidtime. These will be available to enterprise customers with support plans, but also to individual users or teams who don’t need support, at much more accessible price points. For companies running solidtime on their own infrastructure, this is the easiest way to support the project while gaining additional functionality. While we plan to make it easier to build custom extensions in the future, our current APIs are still highly experimental.
|
||||
|
||||
Finally - and perhaps most importantly - we offer a hosted SaaS version called solidtime Cloud, for users who can’t or don’t want to run the software themselves. This version includes proprietary extensions, always runs the latest commit, and includes monitoring and billing features available exclusively on this hosted instance. We expect solidtime Cloud to play a critical role in funding the project long-term.
|
||||
|
||||
Having full control over the source code’s licensing also gives us the ability to change the license of the main project in the future. That said, we have no plans to do so and would only consider it in extreme cases - for example, if a malicious actor were to directly compete with our hosted service in a way that threatens the sustainability of the project, the legal interpretation of AGPL changes in a way that would make it unreasonable to use for certain companies, or a new similar license gains wide-spread adoption. Regardless, solidtime will always remain free to self-host for individuals and companies who use it as part of their work, and all previous releases will remain licensed under AGPL.
|
||||
|
||||
If you are using the open-source version of solidtime and want to support us, the best way to do so is to spread the word.
|
||||
@@ -35,9 +35,10 @@ If you have a **feature request**, please [**create a discussion**](https://gith
|
||||
|
||||
## Contributing
|
||||
|
||||
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
||||
This project is in a very early stage. The structure and APIs are still subject to change and not stable.
|
||||
Therefore, we do not currently accept any contributions, unless you are a member of the team.
|
||||
|
||||
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
|
||||
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
||||
|
||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ use Illuminate\Http\JsonResponse;
|
||||
class ChartController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get chart data for the weekly project overview.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId weeklyProjectOverview
|
||||
@@ -33,8 +31,6 @@ class ChartController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chart data for the latest tasks.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId latestTasks
|
||||
@@ -52,8 +48,6 @@ class ChartController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chart data for the last seven days.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId lastSevenDays
|
||||
@@ -71,8 +65,6 @@ class ChartController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chart data for the latest team activity.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId latestTeamActivity
|
||||
@@ -89,8 +81,6 @@ class ChartController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chart data for daily tracked hours.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId dailyTrackedHours
|
||||
@@ -108,8 +98,6 @@ class ChartController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chart data for total weekly time.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId totalWeeklyTime
|
||||
@@ -127,8 +115,6 @@ class ChartController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chart data for total weekly billable time.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId totalWeeklyBillableTime
|
||||
@@ -146,8 +132,6 @@ class ChartController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chart data for total weekly billable amount.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId totalWeeklyBillableAmount
|
||||
@@ -170,8 +154,6 @@ class ChartController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chart data for weekly history.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId weeklyHistory
|
||||
|
||||
@@ -5,6 +5,8 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
WWWGROUP: '${WWWGROUP}'
|
||||
ports:
|
||||
- '${FORWARD_WEB_PORT:-8083}:80'
|
||||
image: sail-8.3/app
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
|
||||
79
package-lock.json
generated
79
package-lock.json
generated
@@ -7,11 +7,6 @@
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@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",
|
||||
"@rushstack/eslint-patch": "^1.10.5",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
@@ -23,7 +18,6 @@
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@vueuse/integrations": "^12.5.0",
|
||||
"chroma-js": "3.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
@@ -45,7 +39,6 @@
|
||||
"@playwright/test": "^1.41.1",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/chroma-js": "2.4.5",
|
||||
"@types/node": "^22.10.10",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
@@ -1034,55 +1027,6 @@
|
||||
"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": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
||||
@@ -1898,13 +1842,6 @@
|
||||
"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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||
@@ -2940,12 +2877,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": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
@@ -5209,16 +5140,6 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"@playwright/test": "^1.41.1",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/chroma-js": "2.4.5",
|
||||
"@types/node": "^22.10.10",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
@@ -40,11 +39,6 @@
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@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",
|
||||
"@rushstack/eslint-patch": "^1.10.5",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
@@ -56,7 +50,6 @@
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@vueuse/integrations": "^12.5.0",
|
||||
"chroma-js": "3.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
|
||||
2052
public/fonts/Inter-Variable.ttf
Normal file
2052
public/fonts/Inter-Variable.ttf
Normal file
File diff suppressed because one or more lines are too long
2052
public/fonts/Inter-Variable.woff2
Normal file
2052
public/fonts/Inter-Variable.woff2
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -46,16 +46,14 @@
|
||||
|
||||
--color-accent-default: rgba(var(--color-accent-300), 0.2);
|
||||
--color-accent-foreground: rgb(var(--color-accent-100));
|
||||
--theme-color-default-background: var(--color-bg-primary);
|
||||
|
||||
}
|
||||
|
||||
:root.light {
|
||||
--color-bg-primary: #FFFFFF;
|
||||
--color-bg-primary: #F5F5F5;
|
||||
--color-bg-secondary: #f7f7f8;
|
||||
--color-bg-tertiary: #eeeeef;
|
||||
--color-bg-quaternary: #e1e1e3;
|
||||
--color-bg-background: #F5F5F5;
|
||||
--color-bg-tertiary: #e1e1e3;
|
||||
--color-bg-quaternary: #ffffff;
|
||||
--color-bg-background: #ffffff;
|
||||
--color-text-primary: #18181b;
|
||||
--color-text-secondary: #3f3f46;
|
||||
--color-text-tertiary: #57575C;
|
||||
@@ -65,14 +63,14 @@
|
||||
--color-border-tertiary: #dfdfdf;
|
||||
--color-border-quaternary: #d1d1d1;
|
||||
--color-input-border-active: rgba(0,0,0,0.3);
|
||||
--theme-color-menu-active: var(--color-bg-quaternary);
|
||||
--theme-color-menu-active: var(--color-bg-tertiary);
|
||||
|
||||
--theme-color-card-background: var(--color-bg-primary);
|
||||
--theme-color-card-background-active: var(--color-bg-tertiary);
|
||||
--theme-color-card-background: var(--color-bg-quaternary);
|
||||
--theme-color-card-background-active: var(--color-bg-primary);
|
||||
|
||||
--theme-color-chart: var(--color-accent-400);
|
||||
|
||||
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
|
||||
--theme-shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
|
||||
--theme-color-row-background: var(--theme-color-card-background);
|
||||
@@ -87,18 +85,17 @@
|
||||
--theme-color-button-primary-border: rgba(var(--color-accent-600), 1);
|
||||
--theme-color-button-primary-text: #FFFFFF;
|
||||
|
||||
--theme-color-input-background: var(--color-bg-primary);
|
||||
--theme-color-input-background: var(--color-bg-quaternary);
|
||||
|
||||
--theme-color-input-select-active: rgb(var(--color-accent-400));
|
||||
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
|
||||
|
||||
--color-accent-default: rgb(var(--color-accent-100));
|
||||
--color-accent-foreground: rgb(var(--color-accent-800));
|
||||
--theme-color-default-background: #FCFCFC;
|
||||
|
||||
}
|
||||
|
||||
:root {
|
||||
--theme-color-default-background: var(--color-bg-primary);
|
||||
--theme-color-icon-active: rgb(var(--color-text-tertiary));
|
||||
--theme-color-card-background-separator: var(--color-border-tertiary);
|
||||
--theme-color-card-border: var(--color-border-secondary);
|
||||
@@ -166,8 +163,10 @@ body {
|
||||
/* Inter Variable Font with browser compatibility considerations */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/InterVariable.woff2') format('woff2'),
|
||||
url('/fonts/InterVariable.ttf') format('truetype');
|
||||
src: url('/fonts/Inter-Variable.woff2') format('woff2 supports variations'),
|
||||
url('/fonts/Inter-Variable.woff2') format('woff2-variations'),
|
||||
url('/fonts/Inter-Variable.ttf') format('truetype supports variations'),
|
||||
url('/fonts/Inter-Variable.ttf') format('truetype-variations');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
import { useForm, usePage } from '@inertiajs/vue3';
|
||||
import type { User } from '@/types/models';
|
||||
import { useSessionStorage } from '@vueuse/core';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = defineModel('saving', { default: false });
|
||||
|
||||
const timezone = ref('');
|
||||
const userTimezone = ref('');
|
||||
|
||||
const page = usePage<{
|
||||
auth: {
|
||||
user: User;
|
||||
};
|
||||
}>();
|
||||
|
||||
const hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false);
|
||||
|
||||
onMounted(() => {
|
||||
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
userTimezone.value = getUserTimezone();
|
||||
|
||||
if (
|
||||
getDayJsInstance()().tz(timezone.value).format() !==
|
||||
getDayJsInstance()().tz(userTimezone.value).format() &&
|
||||
!hideTimezoneMismatchModal.value
|
||||
) {
|
||||
show.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
function submit() {
|
||||
saving.value = true;
|
||||
const form = useForm({
|
||||
_method: 'PUT',
|
||||
timezone: timezone.value,
|
||||
name: page.props.auth.user.name,
|
||||
email: page.props.auth.user.email,
|
||||
week_start: page.props.auth.user.week_start,
|
||||
});
|
||||
|
||||
form.post(route('user-profile-information.update'), {
|
||||
errorBag: 'updateProfileInformation',
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
saving.value = false;
|
||||
show.value = false;
|
||||
location.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
show.value = false;
|
||||
hideTimezoneMismatchModal.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex justify-center">
|
||||
<span> Timezone mismatch detected </span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="col-span-6 sm:col-span-4 flex-1 space-y-2">
|
||||
<p>
|
||||
The timezone of your device does not match the timezone in your user
|
||||
settings. <br />
|
||||
<strong
|
||||
>We highly recommend that you update your timezone settings to your
|
||||
current timezone.</strong
|
||||
>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Want to change your timezone setting from
|
||||
<strong>{{ userTimezone }}</strong> to <strong>{{ timezone }}</strong
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="cancel"> Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving"
|
||||
@click="submit()">
|
||||
Update timezone
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -4,7 +4,9 @@ import { computed } from 'vue';
|
||||
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
|
||||
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { api } from '@/packages/api/src';
|
||||
import { LoadingSpinner } from '@/packages/ui/src';
|
||||
@@ -88,8 +90,23 @@ window.addEventListener('dashboard:refresh', () => {
|
||||
<div v-else class="text-center flex flex-1 justify-center items-center">
|
||||
<div>
|
||||
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
|
||||
<h3 class="text-text-primary font-semibold text-sm">No recent time entries</h3>
|
||||
<p class="pb-5 text-sm">Start tracking your time!</p>
|
||||
<h3 class="text-text-primary font-semibold text-sm">No recent tasks found</h3>
|
||||
<p class="pb-5 text-sm">Create tasks inside of a project!</p>
|
||||
<SecondaryButton @click="router.visit(route('projects'))"
|
||||
>Go to Projects
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="latestTasks && latestTasks.length === 1"
|
||||
class="text-center flex flex-1 justify-center items-center text-sm">
|
||||
<div>
|
||||
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
|
||||
<h3 class="text-text-primary font-semibold">Add more tasks</h3>
|
||||
<p class="pb-5">Create tasks inside of a project!</p>
|
||||
<SecondaryButton @click="router.visit(route('projects'))"
|
||||
>Go to Projects
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardCard>
|
||||
|
||||
@@ -5,7 +5,6 @@ import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
|
||||
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
||||
import {
|
||||
Bars3Icon,
|
||||
CalendarIcon,
|
||||
ChartBarIcon,
|
||||
ClockIcon,
|
||||
Cog6ToothIcon,
|
||||
@@ -40,17 +39,14 @@ import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
||||
import { fetchToken, isTokenValid } from '@/utils/session';
|
||||
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
||||
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
||||
import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';
|
||||
import { useTheme } from '@/utils/theme';
|
||||
import { useQuery } from '@tanstack/vue-query';
|
||||
import { api } from '@/packages/api/src';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
defineProps({
|
||||
title: String,
|
||||
mainClass: String,
|
||||
});
|
||||
|
||||
const showSidebarMenu = ref(false);
|
||||
@@ -135,11 +131,6 @@ const page = usePage<{
|
||||
:icon="ClockIcon"
|
||||
:current="route().current('time')"
|
||||
:href="route('time')"></NavigationSidebarItem>
|
||||
<NavigationSidebarItem
|
||||
title="Calendar"
|
||||
:icon="CalendarIcon"
|
||||
:current="route().current('calendar')"
|
||||
:href="route('calendar')"></NavigationSidebarItem>
|
||||
<NavigationSidebarItem
|
||||
title="Reporting"
|
||||
:icon="ChartBarIcon"
|
||||
@@ -256,21 +247,21 @@ const page = usePage<{
|
||||
</div>
|
||||
<div class="flex-1 lg:ml-[230px] 2xl:ml-[250px] min-w-0">
|
||||
<div
|
||||
class="h-screen overflow-y-auto flex flex-col bg-default-background border-l border-default-background-separator">
|
||||
<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">
|
||||
<Bars3Icon
|
||||
class="w-7 text-text-secondary"
|
||||
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
|
||||
<OrganizationSwitcher></OrganizationSwitcher>
|
||||
</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">
|
||||
<Bars3Icon
|
||||
class="w-7 text-text-secondary"
|
||||
@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 -->
|
||||
<Banner />
|
||||
<BillingBanner v-if="isBillingActivated()" />
|
||||
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="bg-default-background border-b border-default-background-separator shadow">
|
||||
@@ -282,7 +273,7 @@ const page = usePage<{
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main :class="twMerge('pb-28 relative flex-1', mainClass)">
|
||||
<main class="pb-28 flex-1">
|
||||
<div
|
||||
v-if="isOrganizationLoading"
|
||||
class="flex items-center justify-center h-screen">
|
||||
@@ -294,5 +285,4 @@ const page = usePage<{
|
||||
</div>
|
||||
</div>
|
||||
<NotificationContainer></NotificationContainer>
|
||||
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
||||
</template>
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||
import {
|
||||
api,
|
||||
type Client,
|
||||
type CreateClientBody,
|
||||
type CreateProjectBody,
|
||||
type Project,
|
||||
type TimeEntryResponse,
|
||||
} from '@/packages/api/src';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { computed, ref } from 'vue';
|
||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
import { TimeEntryCalendar } from '@/packages/ui/src';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||
|
||||
const calendarStart = ref<Date | undefined>(undefined);
|
||||
const calendarEnd = ref<Date | undefined>(undefined);
|
||||
|
||||
const enableCalendarQuery = computed(() => {
|
||||
return !!getCurrentOrganizationId() && !!calendarStart.value && !!calendarEnd.value;
|
||||
});
|
||||
|
||||
// Calculate expanded date range to include previous and next periods with timezone transformations
|
||||
const expandedDateRange = computed(() => {
|
||||
if (!calendarStart.value || !calendarEnd.value) {
|
||||
return { start: null, end: null };
|
||||
}
|
||||
|
||||
const dayjs = getDayJsInstance();
|
||||
const duration = dayjs(calendarEnd.value).diff(dayjs(calendarStart.value), 'milliseconds');
|
||||
|
||||
// Calculate previous period
|
||||
const previousStart = dayjs(calendarStart.value).subtract(duration, 'milliseconds');
|
||||
// Calculate next period
|
||||
const nextEnd = dayjs(calendarEnd.value).add(duration, 'milliseconds');
|
||||
|
||||
// Apply timezone transformations
|
||||
const formattedStart = previousStart.utc().tz(getUserTimezone(), true).utc().format();
|
||||
const formattedEnd = nextEnd.utc().tz(getUserTimezone(), true).utc().format();
|
||||
|
||||
return {
|
||||
start: formattedStart,
|
||||
end: formattedEnd,
|
||||
};
|
||||
});
|
||||
|
||||
const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useQuery<TimeEntryResponse>({
|
||||
queryKey: computed(() => [
|
||||
'timeEntry',
|
||||
'calendar',
|
||||
{
|
||||
start: expandedDateRange.value.start,
|
||||
end: expandedDateRange.value.end,
|
||||
organization: getCurrentOrganizationId(),
|
||||
},
|
||||
]),
|
||||
enabled: enableCalendarQuery,
|
||||
placeholderData: (previousData) => previousData,
|
||||
queryFn: () =>
|
||||
api.getTimeEntries({
|
||||
params: {
|
||||
organization: getCurrentOrganizationId() || '',
|
||||
},
|
||||
queries: {
|
||||
start: expandedDateRange.value.start!,
|
||||
end: expandedDateRange.value.end!,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const currentTimeEntries = computed(() => {
|
||||
return timeEntryResponse?.value?.data || [];
|
||||
});
|
||||
|
||||
const { createTimeEntry, updateTimeEntry, deleteTimeEntry } = useTimeEntriesStore();
|
||||
|
||||
async function createTag(name: string) {
|
||||
return await useTagsStore().createTag(name);
|
||||
}
|
||||
|
||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||
return await useProjectsStore().createProject(project);
|
||||
}
|
||||
|
||||
async function createClient(body: CreateClientBody): Promise<Client | undefined> {
|
||||
return await useClientsStore().createClient(body);
|
||||
}
|
||||
|
||||
const projectStore = useProjectsStore();
|
||||
const { projects } = storeToRefs(projectStore);
|
||||
const taskStore = useTasksStore();
|
||||
const { tasks } = storeToRefs(taskStore);
|
||||
const clientStore = useClientsStore();
|
||||
const { clients } = storeToRefs(clientStore);
|
||||
const tagsStore = useTagsStore();
|
||||
const { tags } = storeToRefs(tagsStore);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function onDatesChange({ start, end }: { start: Date; end: Date }) {
|
||||
calendarStart.value = start;
|
||||
calendarEnd.value = end;
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['timeEntry', 'calendar'],
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Calendar" data-testid="calendar_view" main-class="p-0">
|
||||
<TimeEntryCalendar
|
||||
:time-entries="currentTimeEntries"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:clients="clients"
|
||||
:tags="tags"
|
||||
:loading="timeEntriesLoading"
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
:create-time-entry="createTimeEntry"
|
||||
:update-time-entry="updateTimeEntry"
|
||||
:delete-time-entry="deleteTimeEntry"
|
||||
:create-client="createClient"
|
||||
:create-project="createProject"
|
||||
:create-tag="createTag"
|
||||
@dates-change="onDatesChange"
|
||||
@refresh="onRefresh" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -369,7 +369,6 @@ async function downloadExport(format: ExportFormat) {
|
||||
:tags="tags"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:clients="clients"
|
||||
class="border-b border-default-background-separator"
|
||||
:update-time-entries="
|
||||
(args) =>
|
||||
updateTimeEntries(
|
||||
|
||||
@@ -144,7 +144,6 @@ function deleteSelected() {
|
||||
:tags="tags"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:clients="clients"
|
||||
class="border-t border-default-background-separator"
|
||||
:update-time-entries="
|
||||
(args) =>
|
||||
updateTimeEntries(
|
||||
|
||||
@@ -10,8 +10,7 @@ const props = withDefaults(
|
||||
icon?: Component;
|
||||
size?: 'small' | 'base';
|
||||
loading?: boolean;
|
||||
// Accept any valid Vue class binding shape (string | object | array)
|
||||
class?: Parameters<typeof twMerge>[0];
|
||||
class?: string;
|
||||
}>(),
|
||||
{
|
||||
type: 'button',
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, type ComputedRef } from 'vue';
|
||||
import { formatDate, formatHumanReadableDuration } from '../utils/time';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
|
||||
const props = defineProps<{
|
||||
date: Date;
|
||||
totalMinutes?: number;
|
||||
}>();
|
||||
|
||||
const totalSeconds = computed(() => (props.totalMinutes ?? 0) * 60);
|
||||
|
||||
// Injected organization for formatting settings
|
||||
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
||||
const intervalFormat = computed(() => organization?.value?.interval_format);
|
||||
const numberFormat = computed(() => organization?.value?.number_format);
|
||||
const dateFormat = computed(() => organization?.value?.date_format);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fc-day-header-custom">
|
||||
<div class="text-xs text-muted-foreground font-medium">
|
||||
{{ date.toLocaleDateString('en-US', { weekday: 'short' }) }}
|
||||
</div>
|
||||
<span>{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
||||
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
||||
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,58 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, type ComputedRef } from 'vue';
|
||||
import { formatHumanReadableDuration, getDayJsInstance } from '../utils/time';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
projectName?: string | null;
|
||||
taskName?: string | null;
|
||||
clientName?: string | null;
|
||||
durationSeconds?: number;
|
||||
start?: string | Date | null;
|
||||
end?: string | Date | null;
|
||||
}>();
|
||||
|
||||
const effectiveDurationSeconds = computed(() => {
|
||||
if (typeof props.durationSeconds === 'number') {
|
||||
return props.durationSeconds;
|
||||
}
|
||||
if (props.start && props.end) {
|
||||
const end = getDayJsInstance()(props.end as unknown as string | Date);
|
||||
const start = getDayJsInstance()(props.start as unknown as string | Date);
|
||||
const minutes = end.diff(start, 'minutes');
|
||||
return minutes * 60;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
||||
const intervalFormat = computed(() => organization?.value?.interval_format);
|
||||
const numberFormat = computed(() => organization?.value?.number_format);
|
||||
|
||||
const formattedDuration = computed(() =>
|
||||
formatHumanReadableDuration(
|
||||
effectiveDurationSeconds.value,
|
||||
intervalFormat.value,
|
||||
numberFormat.value
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-xs leading-tight">
|
||||
<div class="font-semibold mb-0.5">{{ title }}</div>
|
||||
<div v-if="projectName" class="font-medium text-[0.6875rem] opacity-90">
|
||||
{{ projectName }}
|
||||
</div>
|
||||
<div v-if="taskName" class="font-medium text-[0.6875rem] opacity-90">
|
||||
{{ taskName }}
|
||||
</div>
|
||||
<div v-if="clientName" class="text-[0.625rem] italic opacity-85">
|
||||
{{ clientName }}
|
||||
</div>
|
||||
<div class="text-[0.625rem] font-semibold opacity-90 mt-0.5">
|
||||
{{ formattedDuration }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,595 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import FullCalendar from '@fullcalendar/vue3';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';
|
||||
import { computed, ref, watch, inject, type ComputedRef } from 'vue';
|
||||
import chroma from 'chroma-js';
|
||||
import { useCssVariable } from '@/utils/useCssVariable';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
||||
import { getUserTimezone, getWeekStart } from '../utils/settings';
|
||||
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
||||
import FullCalendarEventContent from './FullCalendarEventContent.vue';
|
||||
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
||||
import type {
|
||||
TimeEntry,
|
||||
Project,
|
||||
Client,
|
||||
Task,
|
||||
CreateProjectBody,
|
||||
CreateClientBody,
|
||||
Tag,
|
||||
Organization,
|
||||
} from '@/packages/api/src';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
type CalendarExtendedProps = { timeEntry: TimeEntry } & Record<string, unknown>;
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'dates-change', payload: { start: Date; end: Date }): void;
|
||||
(e: 'refresh'): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntries: TimeEntry[];
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
clients: Client[];
|
||||
tags: Tag[];
|
||||
loading?: boolean;
|
||||
|
||||
// Permissions / feature flags
|
||||
enableEstimatedTime: boolean;
|
||||
|
||||
createTimeEntry: (
|
||||
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
|
||||
) => Promise<void>;
|
||||
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
|
||||
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
}>();
|
||||
|
||||
// Local component state
|
||||
const newEventStart = ref<Dayjs | null>(null);
|
||||
const newEventEnd = ref<Dayjs | null>(null);
|
||||
const showCreateTimeEntryModal = ref<boolean>(false);
|
||||
const showEditTimeEntryModal = ref<boolean>(false);
|
||||
const selectedTimeEntry = ref<TimeEntry | null>(null);
|
||||
|
||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||
|
||||
// Inject organization data for settings
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
// Helper function to convert week start to FullCalendar firstDay value
|
||||
const getFirstDay = () => {
|
||||
const weekStart = getWeekStart();
|
||||
const weekStartMap: Record<string, number> = {
|
||||
'sunday': 0,
|
||||
'monday': 1,
|
||||
'tuesday': 2,
|
||||
'wednesday': 3,
|
||||
'thursday': 4,
|
||||
'friday': 5,
|
||||
'saturday': 6,
|
||||
};
|
||||
return weekStartMap[weekStart] ?? 1; // Default to Monday if not found
|
||||
};
|
||||
|
||||
// Helper function to get time format for slot labels
|
||||
const getSlotLabelFormat = () => {
|
||||
const timeFormat = organization?.value?.time_format || '24-hours';
|
||||
if (timeFormat === '12-hours') {
|
||||
return {
|
||||
hour: 'numeric' as const,
|
||||
hour12: true,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
hour: '2-digit' as const,
|
||||
minute: '2-digit' as const,
|
||||
hour12: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const cssBackground = useCssVariable('--color-bg-background');
|
||||
|
||||
const events = computed(() => {
|
||||
const themeBackground = (() => {
|
||||
return cssBackground.value?.trim();
|
||||
})();
|
||||
return props.timeEntries
|
||||
?.filter((timeEntry) => timeEntry.end !== null)
|
||||
?.map((timeEntry) => {
|
||||
const project = props.projects.find((p) => p.id === timeEntry.project_id);
|
||||
const client = props.clients.find((c) => c.id === project?.client_id);
|
||||
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
|
||||
const duration = getDayJsInstance()(timeEntry.end!).diff(
|
||||
getDayJsInstance()(timeEntry.start),
|
||||
'minutes'
|
||||
);
|
||||
|
||||
const title = timeEntry.description || 'No description';
|
||||
|
||||
const baseColor = project?.color || '#6B7280';
|
||||
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
|
||||
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
|
||||
|
||||
// For 0-duration events, display them with minimum visual duration but preserve actual duration
|
||||
const startTime = getLocalizedDayJs(timeEntry.start);
|
||||
const endTime =
|
||||
duration === 0
|
||||
? startTime.add(1, 'second') // Show as 1 second for minimal visibility
|
||||
: getLocalizedDayJs(timeEntry.end!);
|
||||
|
||||
return {
|
||||
id: timeEntry.id,
|
||||
start: startTime.format(),
|
||||
end: endTime.format(),
|
||||
title,
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
textColor: 'var(--foreground)',
|
||||
extendedProps: {
|
||||
timeEntry,
|
||||
project,
|
||||
client,
|
||||
task,
|
||||
duration,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Daily totals used in day header
|
||||
const dailyTotals = computed(() => {
|
||||
const totals: Record<string, number> = {};
|
||||
props.timeEntries
|
||||
.filter((entry) => entry.end !== null)
|
||||
.forEach((entry) => {
|
||||
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
|
||||
const duration = getDayJsInstance()(entry.end!).diff(
|
||||
getDayJsInstance()(entry.start),
|
||||
'minutes'
|
||||
);
|
||||
totals[date] = (totals[date] || 0) + duration;
|
||||
});
|
||||
return totals;
|
||||
});
|
||||
|
||||
function emitDatesChange(arg: DatesSetArg) {
|
||||
emit('dates-change', { start: arg.start, end: arg.end });
|
||||
}
|
||||
|
||||
function handleDateSelect(arg: { start: Date; end: Date }) {
|
||||
const startTime = getDayJsInstance()(arg.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc();
|
||||
const endTime = getDayJsInstance()(arg.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc();
|
||||
newEventStart.value = startTime;
|
||||
newEventEnd.value = endTime;
|
||||
showCreateTimeEntryModal.value = true;
|
||||
}
|
||||
|
||||
function handleEventClick(arg: EventClickArg) {
|
||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||
selectedTimeEntry.value = ext.timeEntry;
|
||||
showEditTimeEntryModal.value = true;
|
||||
}
|
||||
|
||||
async function handleEventDrop(arg: EventDropArg) {
|
||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||
const timeEntry = ext.timeEntry;
|
||||
if (!arg.event.start || !arg.event.end) return;
|
||||
const updatedTimeEntry = {
|
||||
...timeEntry,
|
||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc()
|
||||
.format(),
|
||||
end: getDayJsInstance()(arg.event.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc()
|
||||
.format(),
|
||||
} as TimeEntry;
|
||||
await props.updateTimeEntry(updatedTimeEntry);
|
||||
emit('refresh');
|
||||
}
|
||||
|
||||
async function handleEventResize(arg: EventChangeArg) {
|
||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||
const timeEntry = ext.timeEntry;
|
||||
if (!arg.event.start || !arg.event.end) return;
|
||||
const updatedTimeEntry = {
|
||||
...timeEntry,
|
||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc()
|
||||
.format(),
|
||||
end: getDayJsInstance()(arg.event.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc()
|
||||
.format(),
|
||||
} as TimeEntry;
|
||||
await props.updateTimeEntry(updatedTimeEntry);
|
||||
emit('refresh');
|
||||
}
|
||||
|
||||
const calendarOptions = computed(() => ({
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||
initialView: 'timeGridWeek',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'timeGridWeek,timeGridDay',
|
||||
},
|
||||
height: 'parent',
|
||||
slotMinTime: '00:00:00',
|
||||
slotMaxTime: '24:00:00',
|
||||
slotDuration: '00:15:00',
|
||||
slotLabelInterval: '01:00:00',
|
||||
slotLabelFormat: getSlotLabelFormat(),
|
||||
snapDuration: '00:15:00',
|
||||
firstDay: getFirstDay(),
|
||||
allDaySlot: false,
|
||||
nowIndicator: true,
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
editable: true,
|
||||
eventResizableFromStart: true,
|
||||
eventDurationEditable: true,
|
||||
timeZone: 'America/Adak',
|
||||
eventStartEditable: true,
|
||||
select: handleDateSelect,
|
||||
eventClick: handleEventClick,
|
||||
eventDrop: handleEventDrop,
|
||||
eventResize: handleEventResize,
|
||||
datesSet: emitDatesChange,
|
||||
|
||||
events: events.value,
|
||||
}));
|
||||
|
||||
watch(showCreateTimeEntryModal, (value) => {
|
||||
if (!value) {
|
||||
newEventStart.value = null;
|
||||
newEventEnd.value = null;
|
||||
// Ensure FullCalendar clears the selection mirror when modal closes
|
||||
calendarRef.value?.getApi().unselect();
|
||||
emit('refresh');
|
||||
}
|
||||
});
|
||||
|
||||
watch(showEditTimeEntryModal, (value) => {
|
||||
if (!value) {
|
||||
selectedTimeEntry.value = null;
|
||||
emit('refresh');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full relative h-full flex-1">
|
||||
<div v-if="loading" class="flex items-center justify-center h-full">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<LoadingSpinner class="h-8 w-8" />
|
||||
<p class="text-muted-foreground">Loading calendar data...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TimeEntryCreateModal
|
||||
v-model:show="showCreateTimeEntryModal"
|
||||
:enable-estimated-time="enableEstimatedTime"
|
||||
:create-time-entry="createTimeEntry"
|
||||
:create-client="createClient"
|
||||
:create-project="createProject"
|
||||
:create-tag="createTag"
|
||||
:tags="tags as any"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:clients="clients"
|
||||
:start="newEventStart ? newEventStart.toISOString() : undefined"
|
||||
:end="newEventEnd ? newEventEnd.toISOString() : undefined" />
|
||||
|
||||
<TimeEntryEditModal
|
||||
v-model:show="showEditTimeEntryModal"
|
||||
:time-entry="selectedTimeEntry as any"
|
||||
:enable-estimated-time="enableEstimatedTime"
|
||||
:update-time-entry="updateTimeEntry"
|
||||
:delete-time-entry="deleteTimeEntry"
|
||||
:create-client="createClient"
|
||||
:create-project="createProject"
|
||||
:create-tag="createTag"
|
||||
:tags="tags as any"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:clients="clients" />
|
||||
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
|
||||
<template #eventContent="arg">
|
||||
<FullCalendarEventContent
|
||||
:title="arg.event.title"
|
||||
:project-name="(arg.event.extendedProps as any).project?.name"
|
||||
:task-name="(arg.event.extendedProps as any).task?.name"
|
||||
:client-name="(arg.event.extendedProps as any).client?.name"
|
||||
:duration-seconds="
|
||||
((arg.event.extendedProps as any).duration ?? undefined)
|
||||
? (arg.event.extendedProps as any).duration * 60
|
||||
: undefined
|
||||
"
|
||||
:start="arg.event.start as any"
|
||||
:end="arg.event.end as any" />
|
||||
</template>
|
||||
<template #dayHeaderContent="arg">
|
||||
<FullCalendarDayHeader
|
||||
:date="arg.date"
|
||||
:total-minutes="
|
||||
dailyTotals[getDayJsInstance()(arg.date).format('YYYY-MM-DD')] || 0
|
||||
" />
|
||||
</template>
|
||||
</FullCalendar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fullcalendar {
|
||||
height: 100%;
|
||||
--fc-border-color: var(--border);
|
||||
}
|
||||
|
||||
/* FullCalendar theme customization */
|
||||
.fullcalendar :deep(.fc) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
color: var(--foreground);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-slot) {
|
||||
height: 25px;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-slot-label) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-toolbar) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-toolbar-title) {
|
||||
color: var(--foreground);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-button) {
|
||||
background-color: var(--secondary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--foreground);
|
||||
font-weight: 500;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-button:hover) {
|
||||
background-color: var(--muted);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-button:focus) {
|
||||
box-shadow: 0 0 0 2px var(--ring);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-button-active) {
|
||||
background-color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-col-header) {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-col-header-cell) {
|
||||
border-right: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.75rem 0.5rem;
|
||||
background-color: var(--theme-color-default-background);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-axis) {
|
||||
background-color: var(--theme-color-default-background) !important;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-col-header-cell .fc-col-header-cell-cushion) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-axis) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Quarter-hour slots - transparent borders */
|
||||
.fullcalendar :deep(.fc-timegrid-slot-minor.fc-timegrid-slot-label) {
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-slot-minor.fc-timegrid-slot-lane) {
|
||||
--tw-border-opacity: 0;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-day-today.fc-col-header-cell) {
|
||||
background-color: var(--color-accent-default);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-day-today) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-now-indicator) {
|
||||
border-color: var(--primary);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event) {
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--theme-shadow-card);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.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"
|
||||
data-testid="time_entry_row">
|
||||
<MainContainer class="min-w-0">
|
||||
<div class="sm:flex py-2 items-center min-w-0 justify-between group">
|
||||
<div class="sm:flex py-1.5 items-center min-w-0 justify-between group">
|
||||
<div class="flex space-x-3 items-center min-w-0">
|
||||
<Checkbox
|
||||
:checked="
|
||||
@@ -172,7 +172,6 @@ function onSelectChange(checked: boolean) {
|
||||
class="opacity-20 flex group-hover:opacity-100 focus-visible:opacity-100"
|
||||
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
:show-edit="false"
|
||||
@delete="
|
||||
deleteTimeEntries(timeEntry?.timeEntries ?? [])
|
||||
"></TimeEntryMoreOptionsDropdown>
|
||||
|
||||
@@ -41,8 +41,6 @@ const props = defineProps<{
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
clients: Client[];
|
||||
start?: string;
|
||||
end?: string;
|
||||
}>();
|
||||
|
||||
const description = ref<HTMLInputElement | null>(null);
|
||||
@@ -65,27 +63,7 @@ const timeEntryDefaultValues = {
|
||||
end: getDayJsInstance().utc().format(),
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
);
|
||||
const timeEntry = ref({ ...timeEntryDefaultValues });
|
||||
|
||||
watch(
|
||||
() => timeEntry.value.project_id,
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
||||
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import type {
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
Project,
|
||||
Client,
|
||||
TimeEntry,
|
||||
} from '@/packages/api/src';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { Badge } from '@/packages/ui/src';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
||||
|
||||
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import type { Tag, Task } from '@/packages/api/src';
|
||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
const deleting = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntry: TimeEntry | null;
|
||||
enableEstimatedTime: boolean;
|
||||
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
|
||||
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
tags: Tag[];
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
clients: Client[];
|
||||
}>();
|
||||
|
||||
const description = ref<HTMLInputElement | null>(null);
|
||||
|
||||
watch(show, (value) => {
|
||||
if (value) {
|
||||
nextTick(() => {
|
||||
description.value?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const editableTimeEntry = ref<TimeEntry | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.timeEntry,
|
||||
(newTimeEntry) => {
|
||||
if (newTimeEntry) {
|
||||
editableTimeEntry.value = { ...newTimeEntry };
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => editableTimeEntry.value?.project_id,
|
||||
(value) => {
|
||||
if (value && editableTimeEntry.value) {
|
||||
// check if project is billable by default and set billable accordingly
|
||||
const project = props.projects.find((p) => p.id === value);
|
||||
if (project) {
|
||||
editableTimeEntry.value.billable = project.is_billable;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const localStart = computed({
|
||||
get: () =>
|
||||
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '',
|
||||
set: (value: string) => {
|
||||
if (editableTimeEntry.value) {
|
||||
editableTimeEntry.value.start = getLocalizedDayJs(value).utc().format();
|
||||
if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {
|
||||
localEnd.value = value;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const localEnd = computed({
|
||||
get: () =>
|
||||
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.end).format() : '',
|
||||
set: (value: string) => {
|
||||
if (editableTimeEntry.value) {
|
||||
editableTimeEntry.value.end = getLocalizedDayJs(value).utc().format();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (editableTimeEntry.value) {
|
||||
saving.value = true;
|
||||
try {
|
||||
await props.updateTimeEntry(editableTimeEntry.value);
|
||||
show.value = false;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEntry() {
|
||||
if (editableTimeEntry.value) {
|
||||
deleting.value = true;
|
||||
try {
|
||||
await props.deleteTimeEntry(editableTimeEntry.value.id);
|
||||
show.value = false;
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const billableProxy = computed({
|
||||
get: () =>
|
||||
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
|
||||
set: (value: string) => {
|
||||
if (editableTimeEntry.value) {
|
||||
editableTimeEntry.value.billable = value === 'true';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
type BillableOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Edit time entry </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="editableTimeEntry" class="space-y-4">
|
||||
<div class="sm:flex items-end space-y-2 sm:space-y-0 sm:space-x-4">
|
||||
<div class="flex-1">
|
||||
<TextInput
|
||||
id="description"
|
||||
ref="description"
|
||||
v-model="editableTimeEntry.description"
|
||||
placeholder="What did you work on?"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
@keydown.enter="submit" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sm:flex justify-between items-end space-y-2 sm:space-y-0 pt-4 sm:space-x-4">
|
||||
<div class="flex w-full items-center space-x-2 justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="editableTimeEntry.project_id"
|
||||
v-model:task="editableTimeEntry.task_id"
|
||||
:clients
|
||||
:create-project
|
||||
:create-client
|
||||
:can-create-project="canCreateProjects()"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
size="xlarge"
|
||||
class="bg-input-background"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:enable-estimated-time="
|
||||
enableEstimatedTime
|
||||
"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex-col">
|
||||
<TagDropdown
|
||||
v-model="editableTimeEntry.tags"
|
||||
:create-tag
|
||||
:tags="tags">
|
||||
<template #trigger>
|
||||
<Badge
|
||||
class="bg-input-background"
|
||||
tag="button"
|
||||
size="xlarge">
|
||||
<TagIcon
|
||||
v-if="editableTimeEntry.tags.length === 0"
|
||||
class="w-4"></TagIcon>
|
||||
<div
|
||||
v-else
|
||||
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
|
||||
{{ editableTimeEntry.tags.length }}
|
||||
</div>
|
||||
<span>Tags</span>
|
||||
</Badge>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
</div>
|
||||
<div class="flex-col">
|
||||
<SelectDropdown
|
||||
v-model="billableProxy"
|
||||
:get-key-from-item="(item: BillableOption) => item.value"
|
||||
:get-name-for-item="(item: BillableOption) => item.label"
|
||||
:items="[
|
||||
{
|
||||
label: 'Billable',
|
||||
value: 'true',
|
||||
},
|
||||
{
|
||||
label: 'Non Billable',
|
||||
value: 'false',
|
||||
},
|
||||
]">
|
||||
<template #trigger>
|
||||
<Badge
|
||||
class="bg-input-background"
|
||||
tag="button"
|
||||
size="xlarge">
|
||||
<BillableIcon class="h-4"></BillableIcon>
|
||||
<span>{{
|
||||
editableTimeEntry.billable
|
||||
? 'Billable'
|
||||
: 'Non-Billable'
|
||||
}}</span>
|
||||
</Badge>
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex pt-4 space-x-4">
|
||||
<div class="flex-1">
|
||||
<InputLabel>Duration</InputLabel>
|
||||
<div class="space-y-2 mt-1 flex flex-col">
|
||||
<DurationHumanInput
|
||||
v-model:start="localStart"
|
||||
v-model:end="localEnd"
|
||||
name="Duration"></DurationHumanInput>
|
||||
<div class="text-sm flex space-x-1">
|
||||
<InformationCircleIcon
|
||||
class="w-4 text-text-quaternary"></InformationCircleIcon>
|
||||
<span class="text-text-secondary text-xs">
|
||||
You can type natural language here f.e.
|
||||
<span class="font-semibold"> 2h 30m</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
<InputLabel>Start</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||
<TimePickerSimple v-model="localStart" size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="localStart"
|
||||
tabindex="1"
|
||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
<InputLabel>End</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||
<TimePickerSimple v-model="localEnd" size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="localEnd"
|
||||
tabindex="1"
|
||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-between w-full">
|
||||
<SecondaryButton
|
||||
tabindex="2"
|
||||
class="bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700"
|
||||
:disabled="deleting || saving"
|
||||
@click="deleteEntry">
|
||||
{{ deleting ? 'Deleting...' : 'Delete' }}
|
||||
</SecondaryButton>
|
||||
<div class="flex space-x-3">
|
||||
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
tabindex="2"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving || deleting"
|
||||
@click="submit">
|
||||
{{ saving ? 'Updating...' : 'Update Time Entry' }}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -63,7 +63,7 @@ const showMassUpdateModal = ref(false);
|
||||
:class="
|
||||
twMerge(
|
||||
props.class,
|
||||
'text-sm py-1.5 font-medium bg-secondary flex items-center space-x-3'
|
||||
'text-sm py-1.5 font-medium border-t border-b bg-secondary border-border-secondary flex items-center space-x-3'
|
||||
)
|
||||
">
|
||||
<Checkbox
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, PencilIcon } from '@heroicons/vue/20/solid';
|
||||
import { TrashIcon } from '@heroicons/vue/20/solid';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -7,17 +7,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/Components/ui/dropdown-menu';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showEdit?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showEdit: true,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [];
|
||||
delete: [];
|
||||
}>();
|
||||
</script>
|
||||
@@ -43,14 +33,6 @@ const emit = defineEmits<{
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<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
|
||||
data-testid="time_entry_delete"
|
||||
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
|
||||
|
||||
@@ -16,9 +16,8 @@ import TimeEntryDescriptionInput from '@/packages/ui/src/TimeEntry/TimeEntryDesc
|
||||
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
|
||||
import TimeEntryRowDurationInput from '@/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.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 { computed, ref } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import { Checkbox } from '@/packages/ui/src';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -47,8 +46,6 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{ selected: []; unselected: [] }>();
|
||||
|
||||
const showEditModal = ref(false);
|
||||
|
||||
function updateTimeEntryDescription(description: string) {
|
||||
props.updateTimeEntry({ ...props.timeEntry, description });
|
||||
}
|
||||
@@ -90,20 +87,6 @@ function onSelectChange(checked: boolean) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -165,26 +148,11 @@ async function handleDeleteTimeEntry() {
|
||||
class="opacity-20 flex focus-visible:opacity-100 group-hover:opacity-100"
|
||||
@changed="onStartStopClick"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
@edit="handleEdit"
|
||||
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</MainContainer>
|
||||
</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>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -27,11 +27,7 @@ import Checkbox from './Input/Checkbox.vue';
|
||||
import TimeEntryGroupedTable from './TimeEntry/TimeEntryGroupedTable.vue';
|
||||
import TimeEntryMassActionRow from './TimeEntry/TimeEntryMassActionRow.vue';
|
||||
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
|
||||
import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.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 {
|
||||
money,
|
||||
@@ -56,8 +52,4 @@ export {
|
||||
TimeEntryMassActionRow,
|
||||
MoreOptionsDropdown,
|
||||
TimeEntryCreateModal,
|
||||
TimeEntryEditModal,
|
||||
FullCalendarEventContent,
|
||||
FullCalendarDayHeader,
|
||||
TimeEntryCalendar,
|
||||
};
|
||||
|
||||
@@ -36,10 +36,6 @@ Route::middleware([
|
||||
return Inertia::render('Time');
|
||||
})->name('time');
|
||||
|
||||
Route::get('/calendar', function () {
|
||||
return Inertia::render('Calendar');
|
||||
})->name('calendar');
|
||||
|
||||
Route::get('/reporting', function () {
|
||||
return Inertia::render('Reporting');
|
||||
})->name('reporting');
|
||||
|
||||
Reference in New Issue
Block a user