mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Compare commits
84 Commits
feature/ad
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb2d6d05e9 | ||
|
|
ec815d8c26 | ||
|
|
51af3db305 | ||
|
|
f242ce48b5 | ||
|
|
19064cdc3d | ||
|
|
5a05ee35e0 | ||
|
|
00d9d1488e | ||
|
|
9bbbfdfafe | ||
|
|
d27f023e16 | ||
|
|
db57055941 | ||
|
|
743c64909a | ||
|
|
de97d15925 | ||
|
|
0691fe10ef | ||
|
|
513b2048ee | ||
|
|
3acf9b8b07 | ||
|
|
814d539fb0 | ||
|
|
7a51fca2f9 | ||
|
|
280032ee02 | ||
|
|
b1bb7245b0 | ||
|
|
6f37ad500a | ||
|
|
500ccd5719 | ||
|
|
bacd6f4222 | ||
|
|
022caf59ee | ||
|
|
f955ab3135 | ||
|
|
5b491b0da2 | ||
|
|
249ab67ac8 | ||
|
|
1bd2c28b37 | ||
|
|
33ac994cc0 | ||
|
|
8d3ee58bed | ||
|
|
8a2c260533 | ||
|
|
95ab1699c4 | ||
|
|
306a081a3d | ||
|
|
878ac4ab81 | ||
|
|
947550d639 | ||
|
|
09fb5aa48e | ||
|
|
9b9371e5a5 | ||
|
|
0648437478 | ||
|
|
8ba04eca0c | ||
|
|
8a2f35de0c | ||
|
|
b7dafb0892 | ||
|
|
6eca0c2c76 | ||
|
|
3417b60585 | ||
|
|
0f21fabd37 | ||
|
|
df00200464 | ||
|
|
3b41de7135 | ||
|
|
9fe0ea5a0f | ||
|
|
f8f708a664 | ||
|
|
c359259e45 | ||
|
|
55d12aaae1 | ||
|
|
9a1dd4861c | ||
|
|
1e985b71ec | ||
|
|
93d6a86f74 | ||
|
|
19a206d57c | ||
|
|
c0788c270b | ||
|
|
7765056074 | ||
|
|
639f5332e4 | ||
|
|
4a50145329 | ||
|
|
8aabffd1e7 | ||
|
|
b373427dc7 | ||
|
|
d2a4d60441 | ||
|
|
c3305b3df6 | ||
|
|
7584e59d0b | ||
|
|
d2f75cca6e | ||
|
|
250379d4bd | ||
|
|
7f89fd8ea1 | ||
|
|
0b45f3b473 | ||
|
|
9827a74ae2 | ||
|
|
3425847a44 | ||
|
|
47b778fab9 | ||
|
|
85d69f1f16 | ||
|
|
fca55fe0e1 | ||
|
|
f19abb9db6 | ||
|
|
e3bd50ed6b | ||
|
|
c582530899 | ||
|
|
fb5185a32f | ||
|
|
0a0854f771 | ||
|
|
4e635cde83 | ||
|
|
9fa9522237 | ||
|
|
04c44097d0 | ||
|
|
3d5a0cb974 | ||
|
|
da98e0571c | ||
|
|
f68f05d1aa | ||
|
|
8fdc4c1219 | ||
|
|
93148299a9 |
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,8 +1,11 @@
|
||||
<!--
|
||||
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.
|
||||
## What does this PR do?
|
||||
|
||||
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
||||
<!-- 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. -->
|
||||
|
||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||
-->
|
||||
- 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
|
||||
|
||||
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 }}
|
||||
81
CONTRIBUTING.md
Normal file
81
CONTRIBUTING.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 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,10 +35,9 @@ If you have a **feature request**, please [**create a discussion**](https://gith
|
||||
|
||||
## Contributing
|
||||
|
||||
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 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.
|
||||
|
||||
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
||||
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
|
||||
|
||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||
|
||||
|
||||
@@ -22,13 +22,27 @@ class Kernel extends ConsoleKernel
|
||||
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
|
||||
->everyTenMinutes();
|
||||
|
||||
$schedule->command('self-host:check-for-update')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
||||
->twiceDaily();
|
||||
if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) {
|
||||
// Convert string to a stable integer for seeding
|
||||
/** @var int $seed Take the first 8 hex chars → 32-bit int */
|
||||
$seed = hexdec(substr(hash('md5', config('app.key')), 0, 8));
|
||||
$seed = abs($seed); // Ensure it's positive
|
||||
mt_srand($seed);
|
||||
$firstHour = mt_rand(0, 23);
|
||||
$secondHour = ($firstHour + 12) % 24;
|
||||
$minuteOffset = mt_rand(0, 59);
|
||||
mt_srand(null); // Reset the random number generator
|
||||
|
||||
$schedule->command('self-host:telemetry')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
|
||||
->twiceDaily();
|
||||
if (config('scheduling.tasks.self_hosting_check_for_update')) {
|
||||
$schedule->command('self-host:check-for-update')
|
||||
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
|
||||
}
|
||||
|
||||
if (config('scheduling.tasks.self_hosting_telemetry')) {
|
||||
$schedule->command('self-host:telemetry')
|
||||
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
|
||||
}
|
||||
}
|
||||
|
||||
$schedule->command('self-host:database-consistency')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
|
||||
|
||||
@@ -20,6 +20,7 @@ enum TimeEntryAggregationType: string
|
||||
case Client = 'client';
|
||||
case Billable = 'billable';
|
||||
case Description = 'description';
|
||||
case Tag = 'tag';
|
||||
|
||||
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
||||
{
|
||||
|
||||
10
app/Exceptions/Api/OverlappingTimeEntryApiException.php
Normal file
10
app/Exceptions/Api/OverlappingTimeEntryApiException.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class OverlappingTimeEntryApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'overlapping_time_entry';
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\TimeEntryResource\Pages;
|
||||
use App\Models\Member;
|
||||
use App\Models\TimeEntry;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
@@ -16,6 +17,7 @@ use Filament\Tables;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class TimeEntryResource extends Resource
|
||||
{
|
||||
@@ -51,6 +53,20 @@ class TimeEntryResource extends Resource
|
||||
->rules([
|
||||
'after_or_equal:start',
|
||||
]),
|
||||
Select::make('organization_id')
|
||||
->relationship(name: 'organization', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
->required(),
|
||||
Select::make('member_id')
|
||||
->relationship(
|
||||
name: 'member',
|
||||
titleAttribute: 'id',
|
||||
modifyQueryUsing: fn (Builder $query) => $query->with(['user', 'organization'])
|
||||
)
|
||||
->getOptionLabelFromRecordUsing(fn (Member $record): string => $record->user->email.' ('.$record->organization->name.')')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
Select::make('user_id')
|
||||
->relationship(name: 'user', titleAttribute: 'email')
|
||||
->searchable(['name', 'email'])
|
||||
@@ -59,7 +75,10 @@ class TimeEntryResource extends Resource
|
||||
->relationship(name: 'project', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
->nullable(),
|
||||
// TODO
|
||||
Select::make('task_id')
|
||||
->relationship(name: 'task', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
->nullable(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,28 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TimeEntryResource;
|
||||
use App\Models\Member;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTimeEntry extends CreateRecord
|
||||
{
|
||||
protected static string $resource = TimeEntryResource::class;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
if (isset($data['member_id'])) {
|
||||
/** @var Member|null $member */
|
||||
$member = Member::query()->find($data['member_id']);
|
||||
if ($member !== null) {
|
||||
$data['user_id'] = $member->user_id;
|
||||
$data['organization_id'] = $member->organization_id;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TimeEntryResource;
|
||||
use App\Models\Member;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
@@ -19,4 +20,22 @@ class EditTimeEntry extends EditRecord
|
||||
->icon('heroicon-m-trash'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
if (isset($data['member_id'])) {
|
||||
/** @var Member|null $member */
|
||||
$member = Member::query()->find($data['member_id']);
|
||||
if ($member !== null) {
|
||||
$data['user_id'] = $member->user_id;
|
||||
$data['organization_id'] = $member->organization_id;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,11 +38,17 @@ class ClientController extends Controller
|
||||
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'clients:view');
|
||||
$canViewAllClients = $this->hasPermission($organization, 'clients:view:all');
|
||||
$user = $this->user();
|
||||
|
||||
$clientsQuery = Client::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if (! $canViewAllClients) {
|
||||
$clientsQuery->visibleByEmployee($user);
|
||||
}
|
||||
|
||||
$filterArchived = $request->getFilterArchived();
|
||||
if ($filterArchived === 'true') {
|
||||
$clientsQuery->whereNotNull('archived_at');
|
||||
|
||||
@@ -46,6 +46,9 @@ class OrganizationController extends Controller
|
||||
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
||||
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
||||
}
|
||||
if ($request->getEmployeesCanManageTasks() !== null) {
|
||||
$organization->employees_can_manage_tasks = $request->getEmployeesCanManageTasks();
|
||||
}
|
||||
if ($request->getNumberFormat() !== null) {
|
||||
$organization->number_format = $request->getNumberFormat();
|
||||
}
|
||||
@@ -61,6 +64,9 @@ class OrganizationController extends Controller
|
||||
if ($request->getTimeFormat() !== null) {
|
||||
$organization->time_format = $request->getTimeFormat();
|
||||
}
|
||||
if ($request->getPreventOverlappingTimeEntries() !== null) {
|
||||
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
|
||||
}
|
||||
$hasBillableRate = $request->has('billable_rate');
|
||||
if ($hasBillableRate) {
|
||||
$oldBillableRate = $organization->billable_rate;
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Http\Requests\V1\Task\TaskUpdateRequest;
|
||||
use App\Http\Resources\V1\Task\TaskCollection;
|
||||
use App\Http\Resources\V1\Task\TaskResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -27,6 +28,26 @@ class TaskController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check scoped permission and verify user has access to the project
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
private function checkScopedPermissionForProject(Organization $organization, Project $project, string $permission): void
|
||||
{
|
||||
$this->checkPermission($organization, $permission);
|
||||
|
||||
$user = $this->user();
|
||||
$hasAccess = Project::query()
|
||||
->where('id', $project->id)
|
||||
->visibleByEmployee($user)
|
||||
->exists();
|
||||
|
||||
if (! $hasAccess) {
|
||||
throw new AuthorizationException('You do not have permission to '.$permission.' in this project.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks
|
||||
*
|
||||
@@ -75,7 +96,15 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:create');
|
||||
/** @var Project $project */
|
||||
$project = Project::query()->findOrFail($request->input('project_id'));
|
||||
|
||||
if ($this->hasPermission($organization, 'tasks:create:all')) {
|
||||
$this->checkPermission($organization, 'tasks:create:all');
|
||||
} else {
|
||||
$this->checkScopedPermissionForProject($organization, $project, 'tasks:create');
|
||||
}
|
||||
|
||||
$task = new Task;
|
||||
$task->name = $request->input('name');
|
||||
$task->project_id = $request->input('project_id');
|
||||
@@ -97,7 +126,17 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:update', $task);
|
||||
// Check task belongs to organization
|
||||
if ($task->organization_id !== $organization->id) {
|
||||
throw new AuthorizationException('Task does not belong to organization');
|
||||
}
|
||||
|
||||
if ($this->hasPermission($organization, 'tasks:update:all')) {
|
||||
$this->checkPermission($organization, 'tasks:update:all');
|
||||
} else {
|
||||
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:update');
|
||||
}
|
||||
|
||||
$task->name = $request->input('name');
|
||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||
$task->estimated_time = $request->getEstimatedTime();
|
||||
@@ -119,7 +158,16 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function destroy(Organization $organization, Task $task): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:delete', $task);
|
||||
// Check task belongs to organization
|
||||
if ($task->organization_id !== $organization->id) {
|
||||
throw new AuthorizationException('Task does not belong to organization');
|
||||
}
|
||||
|
||||
if ($this->hasPermission($organization, 'tasks:delete:all')) {
|
||||
$this->checkPermission($organization, 'tasks:delete:all');
|
||||
} else {
|
||||
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:delete');
|
||||
}
|
||||
|
||||
if ($task->timeEntries()->exists()) {
|
||||
throw new EntityStillInUseApiException('task', 'time_entry');
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api\V1;
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||
@@ -45,6 +46,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\File;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
@@ -56,6 +58,43 @@ use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
|
||||
class TimeEntryController extends Controller
|
||||
{
|
||||
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
|
||||
{
|
||||
if (! $organization->prevent_overlapping_time_entries) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query = TimeEntry::query()
|
||||
->where('organization_id', $organization->getKey())
|
||||
->where('user_id', $member->user_id)
|
||||
->when($exclude !== null, function (Builder $q) use ($exclude): void {
|
||||
$q->where('id', '!=', $exclude->getKey());
|
||||
})
|
||||
->where(function (Builder $q) use ($start, $end): void {
|
||||
$q->where(function (Builder $q2) use ($start): void {
|
||||
$q2->where('end', '>', $start)
|
||||
->where('start', '<', $start);
|
||||
});
|
||||
|
||||
if ($end !== null) {
|
||||
$q->orWhere(function (Builder $q4) use ($end): void {
|
||||
$q4->where('start', '<', $end)
|
||||
->where('end', '>', $end);
|
||||
});
|
||||
// Check if the new entry completely surrounds an existing entry
|
||||
$q->orWhere(function (Builder $q6) use ($start, $end): void {
|
||||
$q6->where('start', '>=', $start)
|
||||
->where('end', '<=', $end);
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
if ($query->exists()) {
|
||||
throw new OverlappingTimeEntryApiException;
|
||||
}
|
||||
}
|
||||
|
||||
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
|
||||
{
|
||||
parent::checkPermission($organization, $permission);
|
||||
@@ -549,17 +588,15 @@ class TimeEntryController extends Controller
|
||||
throw new TimeEntryStillRunningApiException;
|
||||
}
|
||||
|
||||
// Overlap check for create
|
||||
$start = Carbon::parse($request->input('start'));
|
||||
$end = $request->input('end') !== null ? Carbon::parse($request->input('end')) : null;
|
||||
$this->assertNoOverlap($organization, $member, $start, $end);
|
||||
|
||||
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
|
||||
$client = $project?->client;
|
||||
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
|
||||
|
||||
if ($project !== null) {
|
||||
RecalculateSpentTimeForProject::dispatch($project);
|
||||
}
|
||||
if ($task !== null) {
|
||||
RecalculateSpentTimeForTask::dispatch($task);
|
||||
}
|
||||
|
||||
$timeEntry = new TimeEntry;
|
||||
$timeEntry->fill($request->validated());
|
||||
$timeEntry->client()->associate($client);
|
||||
@@ -569,6 +606,13 @@ class TimeEntryController extends Controller
|
||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||
$timeEntry->save();
|
||||
|
||||
if ($project !== null) {
|
||||
RecalculateSpentTimeForProject::dispatch($project);
|
||||
}
|
||||
if ($task !== null) {
|
||||
RecalculateSpentTimeForTask::dispatch($task);
|
||||
}
|
||||
|
||||
return new TimeEntryResource($timeEntry);
|
||||
}
|
||||
|
||||
@@ -593,6 +637,13 @@ class TimeEntryController extends Controller
|
||||
throw new TimeEntryCanNotBeRestartedApiException;
|
||||
}
|
||||
|
||||
// Overlap check for update (exclude current)
|
||||
/** @var Member $effectiveMember */
|
||||
$effectiveMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : $timeEntry->member;
|
||||
$effectiveStart = $request->has('start') ? Carbon::parse($request->input('start')) : $timeEntry->start;
|
||||
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
|
||||
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
|
||||
|
||||
$oldProject = $timeEntry->project;
|
||||
$oldTask = $timeEntry->task;
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
||||
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
||||
|
||||
/** @var BillingContract $billing */
|
||||
$billing = app(BillingContract::class);
|
||||
@@ -50,6 +51,7 @@ class HandleInertiaRequests extends Middleware
|
||||
return array_merge(parent::share($request), [
|
||||
'has_billing_extension' => $hasBilling,
|
||||
'has_invoicing_extension' => $hasInvoicing,
|
||||
'has_services_extension' => $hasServices,
|
||||
'billing' => $currentOrganization !== null ? [
|
||||
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||
|
||||
@@ -39,6 +39,12 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
||||
'employees_can_see_billable_rates' => [
|
||||
'boolean',
|
||||
],
|
||||
'employees_can_manage_tasks' => [
|
||||
'boolean',
|
||||
],
|
||||
'prevent_overlapping_time_entries' => [
|
||||
'boolean',
|
||||
],
|
||||
'number_format' => [
|
||||
Rule::enum(NumberFormat::class),
|
||||
],
|
||||
@@ -98,4 +104,14 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
||||
{
|
||||
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
|
||||
}
|
||||
|
||||
public function getEmployeesCanManageTasks(): ?bool
|
||||
{
|
||||
return $this->has('employees_can_manage_tasks') ? $this->boolean('employees_can_manage_tasks') : null;
|
||||
}
|
||||
|
||||
public function getPreventOverlappingTimeEntries(): ?bool
|
||||
{
|
||||
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
@@ -42,7 +44,16 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
||||
'required_with:task_id',
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||
|
||||
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
||||
$permissionStore = app(PermissionStore::class);
|
||||
if (! $permissionStore->has($this->organization, 'time-entries:create:all')
|
||||
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
||||
$builder = $builder->visibleByEmployee(Auth::user());
|
||||
}
|
||||
|
||||
return $builder;
|
||||
})->uuid(),
|
||||
],
|
||||
// ID of the task that the time entry should belong to
|
||||
@@ -79,7 +90,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
||||
'description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:500',
|
||||
'max:5000',
|
||||
],
|
||||
// List of tag IDs
|
||||
'tags' => [
|
||||
|
||||
@@ -10,8 +10,10 @@ use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
@@ -54,7 +56,16 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
|
||||
'required_with:task_id',
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||
|
||||
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
||||
$permissionStore = app(PermissionStore::class);
|
||||
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
|
||||
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
||||
$builder = $builder->visibleByEmployee(Auth::user());
|
||||
}
|
||||
|
||||
return $builder;
|
||||
})->uuid(),
|
||||
],
|
||||
// ID of the task that the time entry should belong to
|
||||
@@ -79,7 +90,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
|
||||
'changes.description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:500',
|
||||
'max:5000',
|
||||
],
|
||||
// List of tag IDs
|
||||
'changes.tags' => [
|
||||
|
||||
@@ -10,8 +10,10 @@ use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
@@ -42,7 +44,16 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
||||
'required_with:task_id',
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||
|
||||
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
||||
$permissionStore = app(PermissionStore::class);
|
||||
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
|
||||
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
||||
$builder = $builder->visibleByEmployee(Auth::user());
|
||||
}
|
||||
|
||||
return $builder;
|
||||
})->uuid(),
|
||||
],
|
||||
// ID of the task that the time entry should belong to
|
||||
@@ -77,7 +88,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
||||
'description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:500',
|
||||
'max:5000',
|
||||
],
|
||||
// List of tag IDs
|
||||
'tags' => [
|
||||
|
||||
@@ -53,6 +53,10 @@ class OrganizationResource extends BaseResource
|
||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
|
||||
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
||||
/** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
||||
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
|
||||
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
|
||||
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
|
||||
/** @var string $currency Currency code (ISO 4217) */
|
||||
'currency' => $this->resource->currency,
|
||||
/** @var string $currency_symbol Currency symbol */
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\ClientFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -62,6 +63,18 @@ class Client extends Model implements AuditableContract
|
||||
return $this->hasMany(Project::class, 'client_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Client> $builder
|
||||
* @return Builder<Client>
|
||||
*/
|
||||
public function scopeVisibleByEmployee(Builder $builder, User $user): Builder
|
||||
{
|
||||
return $builder->whereHas('projects', function (Builder $builder) use ($user): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->visibleByEmployee($user);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Attribute<bool, never>
|
||||
*/
|
||||
|
||||
@@ -35,6 +35,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property int|null $billable_rate
|
||||
* @property string $user_id
|
||||
* @property bool $employees_can_see_billable_rates
|
||||
* @property bool $employees_can_manage_tasks
|
||||
* @property User $owner
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
@@ -70,6 +71,8 @@ class Organization extends JetstreamTeam implements AuditableContract
|
||||
'personal_team' => 'boolean',
|
||||
'currency' => 'string',
|
||||
'employees_can_see_billable_rates' => 'boolean',
|
||||
'employees_can_manage_tasks' => 'boolean',
|
||||
'prevent_overlapping_time_entries' => 'boolean',
|
||||
'number_format' => NumberFormat::class,
|
||||
'currency_format' => CurrencyFormat::class,
|
||||
'date_format' => DateFormat::class,
|
||||
|
||||
@@ -94,8 +94,11 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tasks:view',
|
||||
'tasks:view:all',
|
||||
'tasks:create',
|
||||
'tasks:create:all',
|
||||
'tasks:update',
|
||||
'tasks:update:all',
|
||||
'tasks:delete',
|
||||
'tasks:delete:all',
|
||||
'time-entries:view:all',
|
||||
'time-entries:create:all',
|
||||
'time-entries:update:all',
|
||||
@@ -109,6 +112,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
@@ -157,8 +161,11 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tasks:view',
|
||||
'tasks:view:all',
|
||||
'tasks:create',
|
||||
'tasks:create:all',
|
||||
'tasks:update',
|
||||
'tasks:update:all',
|
||||
'tasks:delete',
|
||||
'tasks:delete:all',
|
||||
'time-entries:view:all',
|
||||
'time-entries:create:all',
|
||||
'time-entries:update:all',
|
||||
@@ -172,6 +179,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
@@ -217,8 +225,11 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tasks:view',
|
||||
'tasks:view:all',
|
||||
'tasks:create',
|
||||
'tasks:create:all',
|
||||
'tasks:update',
|
||||
'tasks:update:all',
|
||||
'tasks:delete',
|
||||
'tasks:delete:all',
|
||||
'time-entries:view:all',
|
||||
'time-entries:create:all',
|
||||
'time-entries:update:all',
|
||||
@@ -232,6 +243,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
@@ -256,12 +268,13 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'projects:view',
|
||||
'tags:view',
|
||||
'tasks:view',
|
||||
'clients:view',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'organizations:view',
|
||||
])->description('Employees have the ability to read, create, and update their own time entries and they can see the projects that they are members of.');
|
||||
])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
|
||||
|
||||
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
|
||||
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
||||
|
||||
@@ -266,7 +266,8 @@ class DashboardService
|
||||
) as aggregate'))
|
||||
->where('billable', '=', true)
|
||||
->whereNotNull('billable_rate')
|
||||
->where('user_id', '=', $user->id);
|
||||
->where('user_id', '=', $user->getKey())
|
||||
->where('organization_id', '=', $organization->getKey());
|
||||
|
||||
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
||||
/** @var Collection<int, object{aggregate: int}> $resultDb */
|
||||
|
||||
@@ -167,7 +167,7 @@ class ExportService
|
||||
$client->id,
|
||||
$client->name,
|
||||
$client->organization_id,
|
||||
$client->archived_at ?? '',
|
||||
$client->archived_at?->toIso8601ZuluString() ?? '',
|
||||
$client->created_at?->toIso8601ZuluString() ?? '',
|
||||
$client->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
|
||||
@@ -112,7 +112,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
if (strlen($record['Description']) > 500) {
|
||||
if (strlen($record['Description']) > 5000) {
|
||||
throw new ImportException('Time entry description is too long');
|
||||
}
|
||||
$timeEntry->description = $record['Description'];
|
||||
|
||||
@@ -107,7 +107,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
if (strlen($record['Notes']) > 500) {
|
||||
if (strlen($record['Notes']) > 5000) {
|
||||
throw new ImportException('Time entry note is too long');
|
||||
}
|
||||
$timeEntry->description = $record['Notes'];
|
||||
|
||||
@@ -247,7 +247,7 @@ class SolidtimeImporter extends DefaultImporter
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
if (strlen($timeEntryRow['description']) > 500) {
|
||||
if (strlen($timeEntryRow['description']) > 5000) {
|
||||
throw new ImportException('Time entry description is too long');
|
||||
}
|
||||
$timeEntry->description = $timeEntryRow['description'];
|
||||
|
||||
@@ -71,7 +71,19 @@ class PermissionStore
|
||||
/** @var Role|null $roleObj */
|
||||
$roleObj = Jetstream::findRole($role);
|
||||
|
||||
return $roleObj->permissions ?? [];
|
||||
$permissions = $roleObj->permissions ?? [];
|
||||
|
||||
// If the organization allows employees to manage tasks and the user is an employee,
|
||||
// add the task management permissions for accessible projects
|
||||
if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) {
|
||||
$permissions = array_merge($permissions, [
|
||||
'tasks:create',
|
||||
'tasks:update',
|
||||
'tasks:delete',
|
||||
]);
|
||||
}
|
||||
|
||||
return $permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Enums\TimeEntryRoundingType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Client;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
@@ -17,6 +18,7 @@ use Carbon\CarbonTimeZone;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TimeEntryAggregationService
|
||||
@@ -45,9 +47,21 @@ class TimeEntryAggregationService
|
||||
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
|
||||
{
|
||||
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
||||
/** @var Builder<TimeEntry> $baseTotalsQuery */
|
||||
$baseTotalsQuery = $timeEntriesQuery->clone();
|
||||
$group1Select = null;
|
||||
$group2Select = null;
|
||||
$groupBy = null;
|
||||
// If any grouping is by tag, expand rows per tag and ensure a NULL row for entries without tags
|
||||
if (($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag)) {
|
||||
$timeEntriesQuery->crossJoin(DB::raw(
|
||||
"LATERAL (\n".
|
||||
" SELECT jsonb_array_elements_text(coalesce(tags, '[]'::jsonb)) AS tag\n".
|
||||
" UNION ALL\n".
|
||||
" SELECT ''::text AS tag WHERE coalesce(jsonb_array_length(tags), 0) = 0\n".
|
||||
') AS tag(tag)'
|
||||
));
|
||||
}
|
||||
if ($group1Type !== null) {
|
||||
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
|
||||
$groupBy = ['group_1'];
|
||||
@@ -84,6 +98,26 @@ class TimeEntryAggregationService
|
||||
$group1Response = [];
|
||||
$group1ResponseSum = 0;
|
||||
$group1ResponseCost = 0;
|
||||
// If Tag is subgroup, prepare base totals per primary group without tag expansion
|
||||
$baseTotalsPerGroup1Map = [];
|
||||
if ($group2Type === TimeEntryAggregationType::Tag) {
|
||||
$baseTotalsPerGroup1Query = $baseTotalsQuery->clone();
|
||||
$baseTotalsPerGroup1 = $baseTotalsPerGroup1Query
|
||||
->selectRaw(
|
||||
$group1Select.' as group_1,'.
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
||||
)
|
||||
->groupBy('group_1')
|
||||
->get();
|
||||
foreach ($baseTotalsPerGroup1 as $row) {
|
||||
/** @var object{group_1: mixed, aggregate: int|null, cost: int|null} $row */
|
||||
$baseTotalsPerGroup1Map[(string) ($row->group_1 ?? '')] = [
|
||||
'aggregate' => (int) ($row->aggregate ?? 0),
|
||||
'cost' => (int) ($row->cost ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
foreach ($groupedAggregates as $group1 => $group1Aggregates) {
|
||||
/** @var string|int $group1 */
|
||||
$group2Response = [];
|
||||
@@ -103,6 +137,14 @@ class TimeEntryAggregationService
|
||||
$group2ResponseSum += (int) $aggregate->get(0)->aggregate;
|
||||
$group2ResponseCost += (int) $aggregate->get(0)->cost;
|
||||
}
|
||||
// Override primary group totals when Tag is subgroup to avoid double counting
|
||||
if ($group2Type === TimeEntryAggregationType::Tag) {
|
||||
$keyForMap = (string) $group1;
|
||||
if (array_key_exists($keyForMap, $baseTotalsPerGroup1Map)) {
|
||||
$group2ResponseSum = $baseTotalsPerGroup1Map[$keyForMap]['aggregate'];
|
||||
$group2ResponseCost = $baseTotalsPerGroup1Map[$keyForMap]['cost'];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/** @var Collection<int, object{aggregate: int, cost: int}> $group1Aggregates */
|
||||
$group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate;
|
||||
@@ -121,6 +163,23 @@ class TimeEntryAggregationService
|
||||
$group1ResponseCost += $group2ResponseCost;
|
||||
}
|
||||
|
||||
// If Tag is selected in any grouping, compute overall totals from base (non-tag-expanded) query to avoid double counting
|
||||
$hasTagGrouping = ($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag);
|
||||
if ($hasTagGrouping) {
|
||||
// Reset selects and ordering on the cloned base query
|
||||
$baseTotals = $baseTotalsQuery
|
||||
->selectRaw(
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
||||
)
|
||||
->first();
|
||||
if ($baseTotals !== null) {
|
||||
/** @var object{aggregate: int|null, cost: int|null} $baseTotals */
|
||||
$group1ResponseSum = (int) ($baseTotals->aggregate ?? 0);
|
||||
$group1ResponseCost = (int) ($baseTotals->cost ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
if ($fillGapsInTimeGroupsIsPossible) {
|
||||
$group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end);
|
||||
}
|
||||
@@ -294,6 +353,17 @@ class TimeEntryAggregationService
|
||||
'color' => null,
|
||||
];
|
||||
}
|
||||
} elseif ($type === TimeEntryAggregationType::Tag) {
|
||||
$tags = Tag::query()
|
||||
->whereIn('id', $keys)
|
||||
->select('id', 'name')
|
||||
->get();
|
||||
foreach ($tags as $tag) {
|
||||
$descriptorMap[$tag->id] = [
|
||||
'description' => $tag->name,
|
||||
'color' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $descriptorMap;
|
||||
@@ -436,6 +506,8 @@ class TimeEntryAggregationService
|
||||
return 'billable';
|
||||
} elseif ($group === TimeEntryAggregationType::Description) {
|
||||
return 'description';
|
||||
} elseif ($group === TimeEntryAggregationType::Tag) {
|
||||
return 'tag';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,12 +31,17 @@ class TimeEntryService
|
||||
throw new LogicException('Rounding minutes must be greater than 0');
|
||||
}
|
||||
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
||||
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
|
||||
if ($roundingType === TimeEntryRoundingType::Down) {
|
||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')';
|
||||
} elseif ($roundingType === TimeEntryRoundingType::Up) {
|
||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||
// If end is already on a boundary, keep it; otherwise round up to next boundary
|
||||
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
|
||||
'THEN '.$end.' '.
|
||||
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
|
||||
'END';
|
||||
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
|
||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('organizations', function (Blueprint $table): void {
|
||||
$table->boolean('prevent_overlapping_time_entries')->default(false)->after('employees_can_see_billable_rates');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('organizations', function (Blueprint $table): void {
|
||||
$table->dropColumn('prevent_overlapping_time_entries');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('time_entries', function (Blueprint $table): void {
|
||||
$table->string('description', 5000)->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('time_entries', function (Blueprint $table): void {
|
||||
$table->string('description', 500)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('organizations', function (Blueprint $table): void {
|
||||
$table->boolean('employees_can_manage_tasks')->default(false)->after('employees_can_see_billable_rates');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('organizations', function (Blueprint $table): void {
|
||||
$table->dropColumn('employees_can_manage_tasks');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -435,7 +435,7 @@ CREATE TABLE public.tasks (
|
||||
|
||||
CREATE TABLE public.time_entries (
|
||||
id uuid NOT NULL,
|
||||
description character varying(500) NOT NULL,
|
||||
description character varying(5000) NOT NULL,
|
||||
start timestamp(0) without time zone NOT NULL,
|
||||
"end" timestamp(0) without time zone,
|
||||
billable_rate integer,
|
||||
|
||||
@@ -107,7 +107,7 @@ services:
|
||||
- sail
|
||||
- reverse-proxy
|
||||
playwright:
|
||||
image: mcr.microsoft.com/playwright:v1.51.1-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
||||
working_dir: /src
|
||||
extra_hosts:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# 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 mb --ignore-existing 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" \
|
||||
CGO_CFLAGS=$(php-config --includes) \
|
||||
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
||||
xcaddy build \
|
||||
xcaddy build v2.10.0 \
|
||||
--output /usr/local/bin/frankenphp \
|
||||
--with github.com/dunglas/frankenphp=./ \
|
||||
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
||||
|
||||
@@ -9,7 +9,10 @@ async function goToOrganizationSettings(page) {
|
||||
|
||||
async function createTimeEntry(page, duration: string) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Open the dropdown menu and click "Manual time entry"
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Fill in the time entry details
|
||||
await page.getByTestId('time_entry_description').fill('Test time entry');
|
||||
|
||||
@@ -8,6 +8,13 @@ async function goToProjectsOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
}
|
||||
|
||||
// Helper to clear localStorage before tests that check persistence
|
||||
async function clearProjectTableState(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('project-table-state');
|
||||
});
|
||||
}
|
||||
|
||||
// Create new project via modal
|
||||
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
|
||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
@@ -45,34 +52,62 @@ test('test that creating and deleting a new project via the modal works', async
|
||||
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
|
||||
});
|
||||
|
||||
// Helper to select a status filter using the new dropdown UI
|
||||
async function selectStatusFilter(page: Page, status: 'Active' | 'Archived') {
|
||||
// Click the Filter button to open the dropdown
|
||||
await page.getByRole('button', { name: 'Filter projects' }).click();
|
||||
// Click on Status submenu
|
||||
await page.getByRole('menuitem', { name: 'Status' }).click();
|
||||
// Select the status option
|
||||
await page.getByRole('menuitem', { name: status }).click();
|
||||
}
|
||||
|
||||
// Helper to remove status filter by clicking the X on the badge
|
||||
async function removeStatusFilter(page: Page) {
|
||||
const statusBadge = page.getByTestId('status-filter-badge');
|
||||
// Click the remove button (second button in the badge, contains XMarkIcon)
|
||||
await statusBadge.locator('button').last().click();
|
||||
}
|
||||
|
||||
test('test that archiving and unarchiving projects works', async ({ page }) => {
|
||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await clearProjectTableState(page);
|
||||
await page.reload();
|
||||
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(newProjectName);
|
||||
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||
|
||||
// Archive the project
|
||||
await page.getByRole('row').first().getByRole('button').click();
|
||||
await Promise.all([
|
||||
page.getByRole('menuitem').getByText('Archive').first().click(),
|
||||
expect(page.getByText(newProjectName)).not.toBeVisible(),
|
||||
]);
|
||||
await Promise.all([
|
||||
page.getByRole('tab', { name: 'Archived' }).click(),
|
||||
expect(page.getByText(newProjectName)).toBeVisible(),
|
||||
]);
|
||||
await page.getByRole('menuitem').getByText('Archive').first().click();
|
||||
|
||||
// Project should still be visible since default is "all" (no filter)
|
||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||
|
||||
// Apply Active filter - archived project should disappear
|
||||
await selectStatusFilter(page, 'Active');
|
||||
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
||||
|
||||
// Remove Active filter and apply Archived filter
|
||||
await removeStatusFilter(page);
|
||||
await selectStatusFilter(page, 'Archived');
|
||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||
|
||||
// Unarchive the project
|
||||
await page.getByRole('row').first().getByRole('button').click();
|
||||
await Promise.all([
|
||||
page.getByRole('menuitem').getByText('Unarchive').first().click(),
|
||||
expect(page.getByText(newProjectName)).not.toBeVisible(),
|
||||
]);
|
||||
await Promise.all([
|
||||
page.getByRole('tab', { name: 'Active' }).click(),
|
||||
expect(page.getByText(newProjectName)).toBeVisible(),
|
||||
]);
|
||||
await page.getByRole('menuitem').getByText('Unarchive').first().click();
|
||||
|
||||
// Project should disappear from Archived view
|
||||
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
||||
|
||||
// Remove Archived filter and apply Active filter to see the project
|
||||
await removeStatusFilter(page);
|
||||
await selectStatusFilter(page, 'Active');
|
||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that updating billable rate works with existing time entries', async ({ page }) => {
|
||||
@@ -116,6 +151,147 @@ test('test that updating billable rate works with existing time entries', async
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// Sorting tests
|
||||
test('test that sorting projects by name works', async ({ page }) => {
|
||||
await goToProjectsOverview(page);
|
||||
await clearProjectTableState(page);
|
||||
await page.reload();
|
||||
|
||||
// Wait for the table to load
|
||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
||||
|
||||
// Get initial project names
|
||||
const getProjectNames = async () => {
|
||||
const rows = page
|
||||
.getByTestId('project_table')
|
||||
.locator('[data-testid="project_table"] > div')
|
||||
.filter({ hasNot: page.locator('.border-t') });
|
||||
const names: string[] = [];
|
||||
const count = await page.getByTestId('project_table').getByRole('row').count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const row = page.getByTestId('project_table').getByRole('row').nth(i);
|
||||
const nameCell = row.locator('div').first();
|
||||
const text = await nameCell.textContent();
|
||||
if (text) {
|
||||
names.push(text.trim());
|
||||
}
|
||||
}
|
||||
return names;
|
||||
};
|
||||
|
||||
// Click on Name header to sort ascending (default should already be ascending)
|
||||
const nameHeader = page.getByText('Name').first();
|
||||
await nameHeader.click();
|
||||
|
||||
// Wait for sort to apply
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Click again to sort descending
|
||||
await nameHeader.click();
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Verify the sort indicator is showing descending
|
||||
await expect(page.locator('svg').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that sorting projects by status works', async ({ page }) => {
|
||||
await goToProjectsOverview(page);
|
||||
await clearProjectTableState(page);
|
||||
await page.reload();
|
||||
|
||||
// Default is "all" so no filter needed - Wait for the table to load
|
||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
||||
|
||||
// Click on Status header to sort
|
||||
const statusHeader = page.getByText('Status').first();
|
||||
await statusHeader.click();
|
||||
|
||||
// Wait for sort to apply
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Sort indicator should be visible
|
||||
await expect(statusHeader.locator('svg')).toBeVisible();
|
||||
});
|
||||
|
||||
// Filter tests
|
||||
test('test that filtering projects by status works', async ({ page }) => {
|
||||
const newProjectName = 'Filter Test Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await clearProjectTableState(page);
|
||||
await page.reload();
|
||||
|
||||
// Create a new project
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(newProjectName);
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||
|
||||
// Archive the project
|
||||
await page.getByRole('row').first().getByRole('button').click();
|
||||
await page.getByRole('menuitem').getByText('Archive').first().click();
|
||||
|
||||
// Project should still be visible (default is "all" - no filter)
|
||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||
|
||||
// Apply Active filter - archived project should disappear
|
||||
await selectStatusFilter(page, 'Active');
|
||||
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
||||
|
||||
// Remove Active filter - project should reappear (back to "all")
|
||||
await removeStatusFilter(page);
|
||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||
|
||||
// Apply Archived filter - project should still be visible
|
||||
await selectStatusFilter(page, 'Archived');
|
||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||
|
||||
// Remove Archived filter and apply Active filter - project should not be visible
|
||||
await removeStatusFilter(page);
|
||||
await selectStatusFilter(page, 'Active');
|
||||
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that filter state persists after page reload', async ({ page }) => {
|
||||
await goToProjectsOverview(page);
|
||||
await clearProjectTableState(page);
|
||||
await page.reload();
|
||||
|
||||
// Apply Active status filter
|
||||
await selectStatusFilter(page, 'Active');
|
||||
|
||||
// Verify the filter badge is visible
|
||||
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
|
||||
|
||||
// Wait for the state to be saved
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
|
||||
// Verify the filter badge is still visible after reload
|
||||
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that sort state persists after page reload', async ({ page }) => {
|
||||
await goToProjectsOverview(page);
|
||||
await clearProjectTableState(page);
|
||||
await page.reload();
|
||||
|
||||
// Click on Name header twice to sort descending
|
||||
const nameHeader = page.getByText('Name').first();
|
||||
await nameHeader.click();
|
||||
await nameHeader.click();
|
||||
|
||||
// Wait for the state to be saved
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
|
||||
// Verify descending sort indicator is visible on Name column
|
||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
||||
});
|
||||
|
||||
// Create new project with new Client
|
||||
|
||||
// Create new project with existing Client
|
||||
@@ -124,8 +300,6 @@ test('test that updating billable rate works with existing time entries', async
|
||||
|
||||
// Test that project task count is displayed correctly
|
||||
|
||||
// Test that active / archive / all filter works (once implemented)
|
||||
|
||||
// Edit Project Modal Test
|
||||
|
||||
// Add Project with billable rate
|
||||
|
||||
@@ -26,7 +26,10 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
|
||||
|
||||
// Then create the time entry
|
||||
await goToTimeOverview(page);
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Open the dropdown menu and click "Manual time entry"
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Fill in the time entry details
|
||||
await page
|
||||
@@ -52,7 +55,10 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
|
||||
|
||||
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
|
||||
await goToTimeOverview(page);
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Open the dropdown menu and click "Manual time entry"
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Fill in the time entry details
|
||||
await page
|
||||
@@ -81,7 +87,10 @@ async function createTimeEntryWithBillableStatus(
|
||||
duration: string
|
||||
) {
|
||||
await goToTimeOverview(page);
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Open the dropdown menu and click "Manual time entry"
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Fill in the time entry details
|
||||
await page
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
||||
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||
@@ -47,6 +48,7 @@ return [
|
||||
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
|
||||
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
|
||||
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
|
||||
OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.',
|
||||
],
|
||||
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||
];
|
||||
|
||||
@@ -203,6 +203,7 @@ return [
|
||||
'organization' => 'The :attribute does not exist.',
|
||||
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
|
||||
'project_name_already_exists' => 'A project with the same name and client already exists in the organization.',
|
||||
'overlapping_time_entry' => 'Overlapping time entries are not allowed.',
|
||||
'tag_name_already_exists' => 'A tag with the same name already exists in the organization.',
|
||||
'client_name_already_exists' => 'A client with the same name already exists in the organization.',
|
||||
'task_name_already_exists' => 'A task with the same name already exists in the project.',
|
||||
|
||||
79
package-lock.json
generated
79
package-lock.json
generated
@@ -7,6 +7,11 @@
|
||||
"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",
|
||||
@@ -18,6 +23,7 @@
|
||||
"@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",
|
||||
@@ -39,6 +45,7 @@
|
||||
"@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",
|
||||
@@ -1027,6 +1034,55 @@
|
||||
"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",
|
||||
@@ -1842,6 +1898,13 @@
|
||||
"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",
|
||||
@@ -2877,6 +2940,12 @@
|
||||
"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",
|
||||
@@ -5140,6 +5209,16 @@
|
||||
"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,6 +19,7 @@
|
||||
"@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",
|
||||
@@ -39,6 +40,11 @@
|
||||
"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",
|
||||
@@ -50,6 +56,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,237 +1,14 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
:root.dark {
|
||||
--color-bg-primary: #101012;
|
||||
--color-bg-secondary: #17181B;
|
||||
--color-bg-tertiary: #2A2C32;
|
||||
--color-bg-quaternary: #141518;
|
||||
--color-bg-background: #090909;
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #e3e4e6;
|
||||
--color-text-tertiary: #969799;
|
||||
--color-text-quaternary: #595a5c;
|
||||
/* Import shared solidtime styles from UI package */
|
||||
@import '../js/packages/ui/styles.css';
|
||||
|
||||
--color-border-primary: #191b1f;
|
||||
--color-border-secondary: #23252a;
|
||||
--color-border-tertiary: #2c2e33;
|
||||
--color-border-quaternary: #393B42;
|
||||
--color-input-border-active: rgba(255,255,255,0.3);
|
||||
|
||||
--theme-color-chart: var(--color-accent-200);
|
||||
|
||||
--theme-color-menu-active: var(--color-bg-secondary);
|
||||
--theme-color-card-background: var(--color-bg-secondary);
|
||||
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
|
||||
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
|
||||
|
||||
--theme-color-card-background-active: var(--color-bg-tertiary);
|
||||
|
||||
--theme-color-row-background: var(--color-bg-primary);
|
||||
--theme-color-row-heading-background: var(--theme-color-card-background);
|
||||
--theme-color-row-heading-border: var(--theme-color-card-border);
|
||||
--theme-color-icon-default: var(--color-text-tertiary);
|
||||
|
||||
--theme-color-ring: rgba(255,255,255,0.5);
|
||||
|
||||
--theme-color-button-primary-background: rgba(var(--color-accent-300), 0.1);
|
||||
--theme-color-button-primary-background-hover: rgba(var(--color-accent-300), 0.2);
|
||||
--theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);
|
||||
--theme-color-button-primary-text: var(--color-text-primary);
|
||||
|
||||
--theme-color-input-background: var(--color-bg-secondary);
|
||||
|
||||
--theme-color-input-select-active: rgb(var(--color-accent-300));
|
||||
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
|
||||
|
||||
--color-accent-default: rgba(var(--color-accent-300), 0.2);
|
||||
--color-accent-foreground: rgb(var(--color-accent-100));
|
||||
}
|
||||
|
||||
:root.light {
|
||||
--color-bg-primary: #F5F5F5;
|
||||
--color-bg-secondary: #f7f7f8;
|
||||
--color-bg-tertiary: #e1e1e3;
|
||||
--color-bg-quaternary: #ffffff;
|
||||
--color-bg-background: #ffffff;
|
||||
--color-text-primary: #18181b;
|
||||
--color-text-secondary: #3f3f46;
|
||||
--color-text-tertiary: #57575C;
|
||||
--color-text-quaternary: #a1a1aa;
|
||||
--color-border-primary: #e7e7e7;
|
||||
--color-border-secondary: #e5e5e5;
|
||||
--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-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: 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);
|
||||
--theme-color-row-heading-background: var(--color-bg-secondary);
|
||||
--theme-color-row-heading-border: var(--color-border-tertiary);
|
||||
--theme-color-icon-default: var(--color-text-quaternary);
|
||||
|
||||
--theme-color-ring: rgba(0,0,0, 0.7);
|
||||
|
||||
--theme-color-button-primary-background: rgba(var(--color-accent-600), 0.9);
|
||||
--theme-color-button-primary-background-hover: 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-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));
|
||||
}
|
||||
|
||||
: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);
|
||||
--theme-color-card-border-active: var(--color-border-tertiary);
|
||||
--theme-color-default-background-separator: var(--color-border-primary);
|
||||
--theme-color-primary-text: var(--color-text-primary);
|
||||
--theme-color-input-border: var(--color-border-quaternary);
|
||||
--theme-color-tab-background: var(--theme-color-card-background);
|
||||
--theme-color-tab-background-active: var(--theme-color-card-background-active);
|
||||
--theme-color-tab-border: var(--theme-color-card-border);
|
||||
--theme-color-row-separator-background: var(--theme-color-default-background-separator);
|
||||
--theme-color-row-border: var(--theme-color-card-border);
|
||||
|
||||
--color-accent-50: 240, 249, 255; /* sky-50 */
|
||||
--color-accent-100: 224, 242, 254; /* sky-100 */
|
||||
--color-accent-200: 186, 230, 253; /* sky-200 */
|
||||
--color-accent-300: 125, 211, 252; /* sky-300 */
|
||||
--color-accent-400: 56, 189, 248; /* sky-400 */
|
||||
--color-accent-500: 14, 165, 233; /* sky-500 */
|
||||
--color-accent-600: 2, 132, 199; /* sky-600 */
|
||||
--color-accent-700: 3, 105, 161; /* sky-700 */
|
||||
--color-accent-800: 7, 89, 133; /* sky-800 */
|
||||
--color-accent-900: 12, 74, 110; /* sky-900 */
|
||||
--color-accent-950: 8, 47, 73; /* sky-950 */
|
||||
|
||||
--theme-button-secondary-background: var(--theme-color-card-background);
|
||||
--theme-button-secondary-background-active: var(--theme-color-card-background-active);
|
||||
--popover-border: var(--color-border-secondary);
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* width */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track, ::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Handle */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Handle on hover */
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
[x-cloak] {
|
||||
display: none;
|
||||
}
|
||||
body {
|
||||
background-color: var(--theme-color-default-background);
|
||||
}
|
||||
|
||||
|
||||
/* Inter Variable Font with browser compatibility considerations */
|
||||
/* Main app specific styles - Inter font */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/InterVariable.woff2') format('woff2'),
|
||||
url('/fonts/InterVariable.ttf') format('truetype');
|
||||
src:
|
||||
url('/fonts/InterVariable.woff2') format('woff2'),
|
||||
url('/fonts/InterVariable.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: var(--color-bg-background);
|
||||
--foreground: var(--color-text-primary);
|
||||
--card: var(--theme-color-card-background);
|
||||
--card-foreground: var(--color-text-primary);
|
||||
--popover: var(--theme-color-card-background);
|
||||
--popover-foreground: var(--color-text-primary);
|
||||
--primary: var(--theme-color-button-primary-background);
|
||||
--primary-foreground: var(--theme-color-button-primary-text);
|
||||
--secondary: var(--color-bg-secondary);
|
||||
--secondary-foreground: var(--color-text-primary);
|
||||
--muted: var(--color-bg-tertiary);
|
||||
--muted-foreground: var(--color-text-tertiary);
|
||||
--accent: var(--theme-color-button-primary-background);
|
||||
--accent-foreground: var(--theme-color-button-primary-text);
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: var(--color-text-primary);
|
||||
--border: var(--color-border-primary);
|
||||
--input: var(--color-border-tertiary);
|
||||
--ring: var(--theme-color-ring);
|
||||
--chart-1: var(--color-accent-400);
|
||||
--chart-2: var(--color-accent-500);
|
||||
--chart-3: var(--color-accent-600);
|
||||
--chart-4: var(--color-accent-700);
|
||||
--chart-5: var(--color-accent-800);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: var(--color-bg-background);
|
||||
--foreground: var(--color-text-primary);
|
||||
--card: var(--theme-color-card-background);
|
||||
--card-foreground: var(--color-text-primary);
|
||||
--popover: var(--theme-color-card-background);
|
||||
--popover-foreground: var(--color-text-primary);
|
||||
--primary: var(--theme-color-button-primary-background);
|
||||
--primary-foreground: var(--theme-color-button-primary-text);
|
||||
--secondary: var(--color-bg-secondary);
|
||||
--secondary-foreground: var(--color-text-primary);
|
||||
--muted: var(--color-bg-tertiary);
|
||||
--muted-foreground: var(--color-text-tertiary);
|
||||
--accent: var(--theme-color-button-primary-background);
|
||||
--accent-foreground: var(--theme-color-button-primary-text);
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: var(--color-text-primary);
|
||||
--border: var(--color-border-primary);
|
||||
--input: var(--color-border-tertiary);
|
||||
--ring: var(--theme-color-ring);
|
||||
--chart-1: var(--color-accent-200);
|
||||
--chart-2: var(--color-accent-300);
|
||||
--chart-3: var(--color-accent-400);
|
||||
--chart-4: var(--color-accent-500);
|
||||
--chart-5: var(--color-accent-600);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,11 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
|
||||
<template>
|
||||
<TableHeading>
|
||||
<div
|
||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Name
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary"></div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary"></div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,10 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
|
||||
<template>
|
||||
<TableHeading>
|
||||
<div
|
||||
class="px-3 py-1.5 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Email
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,13 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
|
||||
<template>
|
||||
<TableHeading>
|
||||
<div
|
||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Name
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Email</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Email</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
|
||||
48
resources/js/Components/Common/Project/BaseFilterBadge.vue
Normal file
48
resources/js/Components/Common/Project/BaseFilterBadge.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { XMarkIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
||||
import type { Component } from 'vue';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/Components/ui/dropdown-menu';
|
||||
|
||||
defineProps<{
|
||||
icon: Component;
|
||||
label: string;
|
||||
filterName: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
remove: [];
|
||||
}>();
|
||||
|
||||
defineSlots<{
|
||||
default(): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center gap-0.5 rounded-md bg-tertiary dark:bg-secondary border border-border-secondary">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
class="inline-flex items-center gap-1.5 px-2 py-1 text-sm hover:bg-quaternary dark:hover:bg-tertiary rounded-l-md transition-colors whitespace-nowrap">
|
||||
<component :is="icon" class="h-3.5 w-3.5 text-icon-default" />
|
||||
<span class="font-medium text-foreground">{{ filterName }}</span>
|
||||
<span class="text-muted-foreground">is</span>
|
||||
<span class="text-foreground">{{ label }}</span>
|
||||
<ChevronDownIcon class="h-3 w-3 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<button
|
||||
class="px-1.5 py-1 hover:bg-quaternary dark:hover:bg-tertiary h-full rounded-r-md transition-colors group border-l border-border-secondary"
|
||||
@click="$emit('remove')">
|
||||
<XMarkIcon class="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { UserGroupIcon } from '@heroicons/vue/16/solid';
|
||||
import { DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@/Components/ui/dropdown-menu';
|
||||
import BaseFilterBadge from './BaseFilterBadge.vue';
|
||||
import type { Client } from '@/packages/api/src';
|
||||
import { NO_CLIENT_ID } from './constants';
|
||||
|
||||
const props = defineProps<{
|
||||
value: string[];
|
||||
clients: Client[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [];
|
||||
'update:value': [value: string[]];
|
||||
}>();
|
||||
|
||||
const hasNoClient = computed(() => props.value.includes(NO_CLIENT_ID));
|
||||
|
||||
const label = computed(() => {
|
||||
const count = props.value.length;
|
||||
|
||||
if (count === 0) return 'None';
|
||||
if (count === 1) {
|
||||
if (hasNoClient.value) return 'No client';
|
||||
const client = props.clients.find((c) => c.id === props.value[0]);
|
||||
return client?.name ?? 'Client';
|
||||
}
|
||||
return `${count} selected`;
|
||||
});
|
||||
|
||||
function toggleClient(clientId: string) {
|
||||
const clientIds = props.value.includes(clientId)
|
||||
? props.value.filter((id) => id !== clientId)
|
||||
: [...props.value, clientId];
|
||||
|
||||
emit('update:value', clientIds);
|
||||
}
|
||||
|
||||
function toggleNoClient() {
|
||||
const clientIds = hasNoClient.value
|
||||
? props.value.filter((id) => id !== NO_CLIENT_ID)
|
||||
: [...props.value, NO_CLIENT_ID];
|
||||
|
||||
emit('update:value', clientIds);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseFilterBadge
|
||||
:icon="UserGroupIcon"
|
||||
:label="label"
|
||||
filter-name="Client"
|
||||
@remove="emit('remove')">
|
||||
<DropdownMenuCheckboxItem :model-value="hasNoClient" @select.prevent="toggleNoClient">
|
||||
No client
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:model-value="value.includes(client.id)"
|
||||
@select.prevent="toggleClient(client.id)">
|
||||
{{ client.name }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</BaseFilterBadge>
|
||||
</template>
|
||||
@@ -130,7 +130,7 @@ function updateValue(project: Project) {
|
||||
<ComboboxAnchor>
|
||||
<ComboboxInput
|
||||
ref="searchInput"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
placeholder="Search for a project..."
|
||||
@keydown.enter="addProjectIfNoneExists" />
|
||||
</ComboboxAnchor>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { CircleStackIcon } from '@heroicons/vue/16/solid';
|
||||
import { DropdownMenuItem } from '@/Components/ui/dropdown-menu';
|
||||
import BaseFilterBadge from './BaseFilterBadge.vue';
|
||||
|
||||
type StatusValue = 'active' | 'archived' | 'all';
|
||||
|
||||
const props = defineProps<{
|
||||
value: StatusValue;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [];
|
||||
'update:value': [value: StatusValue];
|
||||
}>();
|
||||
|
||||
const statusOptions = [
|
||||
{ id: 'active' as const, name: 'Active' },
|
||||
{ id: 'archived' as const, name: 'Archived' },
|
||||
];
|
||||
|
||||
const label = computed(() => {
|
||||
return statusOptions.find((opt) => opt.id === props.value)?.name ?? 'Status';
|
||||
});
|
||||
|
||||
function updateStatus(status: StatusValue) {
|
||||
emit('update:value', status);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseFilterBadge
|
||||
:icon="CircleStackIcon"
|
||||
:label="label"
|
||||
filter-name="Status"
|
||||
@remove="emit('remove')">
|
||||
<DropdownMenuItem
|
||||
v-for="option in statusOptions"
|
||||
:key="option.id"
|
||||
:class="[value === option.id && 'bg-accent text-accent-foreground']"
|
||||
@click="updateStatus(option.id)">
|
||||
{{ option.name }}
|
||||
</DropdownMenuItem>
|
||||
</BaseFilterBadge>
|
||||
</template>
|
||||
@@ -4,7 +4,10 @@ import { FolderPlusIcon } from '@heroicons/vue/24/solid';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import { computed, ref } from 'vue';
|
||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||
import ProjectTableHeading, {
|
||||
type SortColumn,
|
||||
type SortDirection,
|
||||
} from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
||||
@@ -12,13 +15,96 @@ import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import {
|
||||
useVueTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
type SortingState,
|
||||
} from '@tanstack/vue-table';
|
||||
|
||||
const props = defineProps<{
|
||||
projects: Project[];
|
||||
showBillableRate: boolean;
|
||||
sortColumn: SortColumn;
|
||||
sortDirection: SortDirection;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
sort: [column: SortColumn];
|
||||
}>();
|
||||
|
||||
const { clients } = storeToRefs(useClientsStore());
|
||||
|
||||
// Create a map of client names for sorting
|
||||
const clientNameMap = computed(() => {
|
||||
const map = new Map<string, string>();
|
||||
clients.value.forEach((client) => {
|
||||
map.set(client.id, client.name);
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
// Convert our sort state to TanStack Table format
|
||||
const sorting = computed<SortingState>(() => [
|
||||
{
|
||||
id: props.sortColumn,
|
||||
desc: props.sortDirection === 'desc',
|
||||
},
|
||||
]);
|
||||
|
||||
// Define column accessors for sorting
|
||||
const columns = [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row: Project) => row.name.toLowerCase(),
|
||||
},
|
||||
{
|
||||
id: 'client_name',
|
||||
accessorFn: (row: Project) => {
|
||||
if (!row.client_id) return '';
|
||||
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'spent_time',
|
||||
accessorFn: (row: Project) => row.spent_time ?? 0,
|
||||
},
|
||||
{
|
||||
id: 'billable_rate',
|
||||
accessorFn: (row: Project) => row.billable_rate ?? 0,
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
|
||||
},
|
||||
];
|
||||
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
return props.projects;
|
||||
},
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value;
|
||||
},
|
||||
},
|
||||
manualSorting: false,
|
||||
});
|
||||
|
||||
const sortedProjects = computed(() => {
|
||||
return table.getRowModel().rows.map((row) => row.original);
|
||||
});
|
||||
|
||||
function handleSort(column: SortColumn) {
|
||||
emit('sort', column);
|
||||
}
|
||||
|
||||
const showCreateProjectModal = ref(false);
|
||||
|
||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||
return await useProjectsStore().createProject(project);
|
||||
}
|
||||
@@ -26,11 +112,10 @@ async function createProject(project: CreateProjectBody): Promise<Project | unde
|
||||
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
|
||||
return await useClientsStore().createClient(client);
|
||||
}
|
||||
const { clients } = storeToRefs(useClientsStore());
|
||||
|
||||
const gridTemplate = computed(() => {
|
||||
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;
|
||||
});
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -45,8 +130,11 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
<div class="inline-block min-w-full align-middle">
|
||||
<div data-testid="project_table" class="grid min-w-full" :style="gridTemplate">
|
||||
<ProjectTableHeading
|
||||
:show-billable-rate="props.showBillableRate"></ProjectTableHeading>
|
||||
<div v-if="projects.length === 0" class="col-span-5 py-24 text-center">
|
||||
:show-billable-rate="props.showBillableRate"
|
||||
:sort-column="props.sortColumn"
|
||||
:sort-direction="props.sortDirection"
|
||||
@sort="handleSort"></ProjectTableHeading>
|
||||
<div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
|
||||
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||
<h3 class="text-text-primary font-semibold">
|
||||
{{
|
||||
@@ -69,7 +157,7 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
>Create your First Project
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
<template v-for="project in projects" :key="project.id">
|
||||
<template v-for="project in sortedProjects" :key="project.id">
|
||||
<ProjectTableRow
|
||||
:show-billable-rate="props.showBillableRate"
|
||||
:project="project"></ProjectTableRow>
|
||||
|
||||
@@ -1,23 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
defineProps<{
|
||||
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
||||
|
||||
export type SortColumn = 'name' | 'client_name' | 'spent_time' | 'billable_rate' | 'status';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
const props = defineProps<{
|
||||
showBillableRate: boolean;
|
||||
sortColumn: SortColumn;
|
||||
sortDirection: SortDirection;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
sort: [column: SortColumn];
|
||||
}>();
|
||||
|
||||
function handleSort(column: SortColumn) {
|
||||
emit('sort', column);
|
||||
}
|
||||
|
||||
function isSorted(column: SortColumn): boolean {
|
||||
return props.sortColumn === column;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableHeading>
|
||||
<div
|
||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||
@click="handleSort('name')">
|
||||
Name
|
||||
<ChevronDownIcon v-if="isSorted('name') && sortDirection === 'asc'" class="w-4 h-4" />
|
||||
<ChevronUpIcon
|
||||
v-else-if="isSorted('name') && sortDirection === 'desc'"
|
||||
class="w-4 h-4" />
|
||||
<span v-else class="w-4 h-4"></span>
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Client</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
|
||||
<div v-if="showBillableRate" class="px-3 py-1.5 text-left font-semibold text-text-primary">
|
||||
<div
|
||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||
@click="handleSort('client_name')">
|
||||
Client
|
||||
<ChevronDownIcon
|
||||
v-if="isSorted('client_name') && sortDirection === 'asc'"
|
||||
class="w-4 h-4" />
|
||||
<ChevronUpIcon
|
||||
v-else-if="isSorted('client_name') && sortDirection === 'desc'"
|
||||
class="w-4 h-4" />
|
||||
<span v-else class="w-4 h-4"></span>
|
||||
</div>
|
||||
<div
|
||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||
@click="handleSort('spent_time')">
|
||||
Total Time
|
||||
<ChevronDownIcon
|
||||
v-if="isSorted('spent_time') && sortDirection === 'asc'"
|
||||
class="w-4 h-4" />
|
||||
<ChevronUpIcon
|
||||
v-else-if="isSorted('spent_time') && sortDirection === 'desc'"
|
||||
class="w-4 h-4" />
|
||||
<span v-else class="w-4 h-4"></span>
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
|
||||
<div
|
||||
v-if="showBillableRate"
|
||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||
@click="handleSort('billable_rate')">
|
||||
Billable Rate
|
||||
<ChevronDownIcon
|
||||
v-if="isSorted('billable_rate') && sortDirection === 'asc'"
|
||||
class="w-4 h-4" />
|
||||
<ChevronUpIcon
|
||||
v-else-if="isSorted('billable_rate') && sortDirection === 'desc'"
|
||||
class="w-4 h-4" />
|
||||
<span v-else class="w-4 h-4"></span>
|
||||
</div>
|
||||
<div
|
||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||
@click="handleSort('status')">
|
||||
Status
|
||||
<ChevronDownIcon v-if="isSorted('status') && sortDirection === 'asc'" class="w-4 h-4" />
|
||||
<ChevronUpIcon
|
||||
v-else-if="isSorted('status') && sortDirection === 'desc'"
|
||||
class="w-4 h-4" />
|
||||
<span v-else class="w-4 h-4"></span>
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
|
||||
import type { Project } from '@/packages/api/src';
|
||||
import { computed, ref, inject, type ComputedRef } from 'vue';
|
||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
@@ -116,9 +116,15 @@ const showEditProjectModal = ref(false);
|
||||
{{ billableRateInfo }}
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
|
||||
<CheckCircleIcon class="w-5"></CheckCircleIcon>
|
||||
<span>Active</span>
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
|
||||
<template v-if="project.is_archived">
|
||||
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
|
||||
<span>Archived</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
|
||||
<span>Active</span>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { UserGroupIcon, CheckCircleIcon } from '@heroicons/vue/16/solid';
|
||||
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/Components/ui/dropdown-menu';
|
||||
import { Button } from '@/packages/ui/src';
|
||||
import type { Client } from '@/packages/api/src';
|
||||
import { NO_CLIENT_ID } from './constants';
|
||||
|
||||
export interface ProjectFilters {
|
||||
status: 'active' | 'archived' | 'all';
|
||||
clientIds: string[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
filters: ProjectFilters;
|
||||
clients: Client[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:filters': [filters: ProjectFilters];
|
||||
}>();
|
||||
|
||||
const statusOptions = [
|
||||
{ id: 'active' as const, name: 'Active' },
|
||||
{ id: 'archived' as const, name: 'Archived' },
|
||||
];
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
function updateStatus(status: 'active' | 'archived' | 'all') {
|
||||
emit('update:filters', {
|
||||
...props.filters,
|
||||
status,
|
||||
});
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function toggleClient(clientId: string) {
|
||||
const clientIds = props.filters.clientIds.includes(clientId)
|
||||
? props.filters.clientIds.filter((id) => id !== clientId)
|
||||
: [...props.filters.clientIds, clientId];
|
||||
|
||||
emit('update:filters', {
|
||||
...props.filters,
|
||||
clientIds,
|
||||
});
|
||||
}
|
||||
|
||||
function toggleNoClient() {
|
||||
const clientIds = props.filters.clientIds.includes(NO_CLIENT_ID)
|
||||
? props.filters.clientIds.filter((id) => id !== NO_CLIENT_ID)
|
||||
: [...props.filters.clientIds, NO_CLIENT_ID];
|
||||
|
||||
emit('update:filters', {
|
||||
...props.filters,
|
||||
clientIds,
|
||||
});
|
||||
}
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return props.filters.status !== 'all' || props.filters.clientIds.length > 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu v-model:open="open">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="xs" aria-label="Filter projects">
|
||||
<ListFilterIcon
|
||||
:class="[hasActiveFilters ? '' : '-ml-0.5', 'h-4 w-4 text-icon-default']" />
|
||||
<span v-if="!hasActiveFilters" class="text-nowrap">Filter</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" class="w-56">
|
||||
<!-- Status Filter -->
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger class="gap-2">
|
||||
<CheckCircleIcon class="h-4 w-4 text-icon-default" />
|
||||
<span>Status</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
v-for="option in statusOptions"
|
||||
:key="option.id"
|
||||
:class="[
|
||||
filters.status === option.id && 'bg-accent text-accent-foreground',
|
||||
]"
|
||||
@click="updateStatus(option.id)">
|
||||
{{ option.name }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<!-- Client Filter -->
|
||||
<DropdownMenuSub v-if="clients.length > 0">
|
||||
<DropdownMenuSubTrigger class="gap-2">
|
||||
<UserGroupIcon class="h-4 w-4 text-icon-default" />
|
||||
<span>Client</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent class="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuCheckboxItem
|
||||
:model-value="filters.clientIds.includes(NO_CLIENT_ID)"
|
||||
@select.prevent="toggleNoClient">
|
||||
No client
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:model-value="filters.clientIds.includes(client.id)"
|
||||
@select.prevent="toggleClient(client.id)">
|
||||
{{ client.name }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
1
resources/js/Components/Common/Project/constants.ts
Normal file
1
resources/js/Components/Common/Project/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const NO_CLIENT_ID = '__no_client__';
|
||||
@@ -4,12 +4,11 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
|
||||
<template>
|
||||
<TableHeading>
|
||||
<div
|
||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Name
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,12 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
|
||||
<template>
|
||||
<TableHeading>
|
||||
<div
|
||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Name
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Description</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Visibility</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Public URL</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Description</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Visibility</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Public URL</div>
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,7 @@ const option = computed(() => ({
|
||||
},
|
||||
axisLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fontWeight: 400,
|
||||
color: labelColor.value,
|
||||
margin: 16,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Button } from '@/packages/ui/src';
|
||||
|
||||
const props = defineProps<{
|
||||
icon: Component;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Switch } from '@/Components/ui/switch';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
|
||||
import { Button } from '@/packages/ui/src';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
||||
@@ -30,10 +30,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
||||
<template>
|
||||
<div
|
||||
class="contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
|
||||
<div
|
||||
:class="
|
||||
twMerge('pl-6 font-medium flex items-center space-x-3', props.indent ? 'pl-16' : '')
|
||||
">
|
||||
<div :class="twMerge('pl-6 flex items-center space-x-3', props.indent ? 'pl-16' : '')">
|
||||
<GroupedItemsCountButton
|
||||
v-if="entry.grouped_data && entry.grouped_data?.length > 0"
|
||||
:expanded="expanded"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="contents [&>*]:border-row-separator text-xs sm:text-sm [&>*]:border-b [&>*]:border-t [&>*]:bg-row-heading-background">
|
||||
class="contents [&>*]:border-row-separator text-xs [&>*]:border-b [&>*]:border-t [&>*]:bg-row-heading-background">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,8 +4,7 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
|
||||
<template>
|
||||
<TableHeading>
|
||||
<div
|
||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Name
|
||||
</div>
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
|
||||
@@ -4,13 +4,12 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
|
||||
<template>
|
||||
<TableHeading>
|
||||
<div
|
||||
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Task Name
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Total Time</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
|
||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useForm, usePage } from '@inertiajs/vue3';
|
||||
import type { User } from '@/types/models';
|
||||
import TimezoneMismatchModal from '@/packages/ui/src/TimezoneMismatchModal.vue';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
|
||||
const page = usePage<{
|
||||
auth: {
|
||||
user: User;
|
||||
};
|
||||
}>();
|
||||
|
||||
function handleUpdate(timezone: string) {
|
||||
saving.value = true;
|
||||
const form = useForm({
|
||||
_method: 'PUT',
|
||||
timezone: timezone,
|
||||
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();
|
||||
},
|
||||
onError: () => {
|
||||
saving.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TimezoneMismatchModal v-model:show="show" :saving="saving" @update="handleUpdate" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<section class="flex flex-col">
|
||||
<CardTitle :title="title" :icon="icon"></CardTitle>
|
||||
<div
|
||||
class="rounded-lg bg-card-background border border-card-border flex-1 flex items-stretch shadow-card">
|
||||
<div class="rounded-lg border border-card-border flex-1 flex items-stretch">
|
||||
<div class="w-full flex flex-col">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,7 @@ 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';
|
||||
@@ -90,23 +88,8 @@ 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 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardCard>
|
||||
|
||||
@@ -10,7 +10,8 @@ defineProps<{
|
||||
<div class="px-4 py-2 2xl:py-3 border-b border-b-background-separator">
|
||||
<div class="col-span-2">
|
||||
<div class="flex justify-between">
|
||||
<p class="font-semibold text-sm text-text-primary">
|
||||
<p
|
||||
class="font-semibold text-sm min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
|
||||
{{ name }}
|
||||
</p>
|
||||
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
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">
|
||||
<Notification
|
||||
v-for="notification in notifications"
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import DropdownLink from '@/Components/DropdownLink.vue';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import {
|
||||
Cog6ToothIcon,
|
||||
PlusCircleIcon,
|
||||
CheckCircleIcon,
|
||||
ArrowRightIcon,
|
||||
} from '@heroicons/vue/24/solid';
|
||||
import type { Organization, User } from '@/types/models';
|
||||
import { isBillingActivated } from '@/utils/billing';
|
||||
import { canManageBilling } from '@/utils/permissions';
|
||||
import { switchOrganization } from '@/utils/useOrganization';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
} from '@/Components/ui/dropdown-menu';
|
||||
|
||||
const page = usePage<{
|
||||
jetstream: {
|
||||
@@ -28,84 +39,79 @@ const switchToTeam = (organization: Organization) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown v-if="page.props.jetstream.hasTeamFeatures" align="center" width="60">
|
||||
<template #trigger>
|
||||
<div
|
||||
data-testid="organization_switcher"
|
||||
class="flex hover:bg-white/10 cursor-pointer transition px-2 py-1 rounded-lg w-full items-center justify-between font-medium">
|
||||
<DropdownMenu v-if="page.props.jetstream.hasTeamFeatures">
|
||||
<DropdownMenuTrigger
|
||||
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"
|
||||
as-child>
|
||||
<button data-testid="organization_switcher">
|
||||
<div class="flex flex-1 space-x-2 items-center w-[calc(100%-30px)]">
|
||||
<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() }}
|
||||
</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 }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-[30px]">
|
||||
<button
|
||||
class="p-1 transition hover:bg-white/10 rounded-full flex items-center w-8 h-8">
|
||||
<ChevronDownIcon class="w-5 sm:w-full mt-[1px]"></ChevronDownIcon>
|
||||
</button>
|
||||
<div class="p-1 rounded-full flex items-center w-6 h-6">
|
||||
<ChevronDownIcon class="w-4 sm:w-full mt-[1px]"></ChevronDownIcon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<template #content>
|
||||
<DropdownMenuContent align="start">
|
||||
<div class="w-60">
|
||||
<!-- Organization Management -->
|
||||
<div class="block px-4 py-2 text-xs text-text-secondary">Manage Organization</div>
|
||||
<DropdownMenuLabel>Manage Organization</DropdownMenuLabel>
|
||||
|
||||
<!-- Organization Settings -->
|
||||
<DropdownLink :href="route('teams.show', page.props.auth.user.current_team.id)">
|
||||
Organization Settings
|
||||
</DropdownLink>
|
||||
<DropdownMenuItem as-child>
|
||||
<Link
|
||||
:href="route('teams.show', page.props.auth.user.current_team.id)"
|
||||
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">
|
||||
Billing
|
||||
</DropdownLink>
|
||||
<DropdownMenuItem v-if="canManageBilling() && isBillingActivated()" as-child>
|
||||
<Link href="/billing" class="inline-flex items-center w-full"> Billing </Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownLink
|
||||
v-if="page.props.jetstream.canCreateTeams"
|
||||
:href="route('teams.create')">
|
||||
Create new organization
|
||||
</DropdownLink>
|
||||
<DropdownMenuItem v-if="page.props.jetstream.canCreateTeams" as-child>
|
||||
<Link
|
||||
:href="route('teams.create')"
|
||||
class="inline-flex items-center gap-2.5 w-full">
|
||||
<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">
|
||||
<div class="border-t border-card-background-separator" />
|
||||
|
||||
<div class="block px-4 py-2 text-xs text-text-secondary">
|
||||
Switch Organizations
|
||||
</div>
|
||||
<DropdownMenuLabel>Switch Organizations</DropdownMenuLabel>
|
||||
|
||||
<template v-for="team in page.props.auth.user.all_teams" :key="team.id">
|
||||
<form @submit.prevent="switchToTeam(team)">
|
||||
<DropdownLink as="button">
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
<DropdownMenuItem
|
||||
as-child
|
||||
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"
|
||||
class="me-2 h-5 w-5 text-green-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
class="h-5 w-5 text-green-400" />
|
||||
<ArrowRightIcon v-else class="h-5 w-5 text-icon-default" />
|
||||
|
||||
<div>
|
||||
<div class="w-full truncate text-left">
|
||||
{{ team.name }}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownLink>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</form>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
@@ -16,12 +16,25 @@ import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';
|
||||
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
|
||||
import type {
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
CreateTimeEntryBody,
|
||||
Project,
|
||||
Tag,
|
||||
} from '@/packages/api/src';
|
||||
import TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
|
||||
import TimeTrackerMoreOptionsDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
|
||||
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import { ref } from 'vue';
|
||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
||||
import { api } from '@/packages/api/src';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
|
||||
const page = usePage<{
|
||||
auth: {
|
||||
@@ -47,6 +60,8 @@ const emit = defineEmits<{
|
||||
change: [];
|
||||
}>();
|
||||
|
||||
const showManualTimeEntryModal = ref(false);
|
||||
|
||||
watch(isActive, () => {
|
||||
if (isActive.value) {
|
||||
startLiveTimer();
|
||||
@@ -93,14 +108,73 @@ function switchToTimeEntryOrganization() {
|
||||
switchOrganization(currentTimeEntry.value.organization_id);
|
||||
}
|
||||
}
|
||||
async function createTag(tag: string) {
|
||||
async function createTag(tag: string): Promise<Tag | undefined> {
|
||||
return await useTagsStore().createTag(tag);
|
||||
}
|
||||
|
||||
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
|
||||
await useTimeEntriesStore().createTimeEntry(timeEntry);
|
||||
showManualTimeEntryModal.value = false;
|
||||
}
|
||||
|
||||
async function createTimeEntryFromCurrentEntry() {
|
||||
const { start, end, description, project_id, task_id, billable, tags } = currentTimeEntry.value;
|
||||
await createTimeEntry({ start, end, description, project_id, task_id, billable, tags });
|
||||
currentTimeEntryStore.$reset();
|
||||
}
|
||||
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const deleteTimeEntryMutation = useMutation({
|
||||
mutationFn: async (timeEntryId: string) => {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (!organizationId) {
|
||||
throw new Error('No organization selected');
|
||||
}
|
||||
return await api.deleteTimeEntry(undefined, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
timeEntry: timeEntryId,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await currentTimeEntryStore.fetchCurrentTimeEntry();
|
||||
await useTimeEntriesStore().fetchTimeEntries();
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
},
|
||||
});
|
||||
|
||||
async function discardCurrentTimeEntry() {
|
||||
if (currentTimeEntry.value.id) {
|
||||
await handleApiRequestNotifications(
|
||||
() => deleteTimeEntryMutation.mutateAsync(currentTimeEntry.value.id),
|
||||
'Time entry discarded successfully',
|
||||
'Failed to discard time entry'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { tags } = storeToRefs(useTagsStore());
|
||||
const { timeEntries } = storeToRefs(useTimeEntriesStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TimeEntryCreateModal
|
||||
v-model:show="showManualTimeEntryModal"
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
:create-project="createProject"
|
||||
:create-client="createClient"
|
||||
:create-tag="createTag"
|
||||
:create-time-entry="createTimeEntry"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:can-create-project="canCreateProjects()"
|
||||
:projects
|
||||
:tasks
|
||||
:tags
|
||||
:clients></TimeEntryCreateModal>
|
||||
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
|
||||
<div class="relative">
|
||||
<TimeTrackerRunningInDifferentOrganizationOverlay
|
||||
@@ -109,24 +183,36 @@ const { tags } = storeToRefs(useTagsStore());
|
||||
switchToTimeEntryOrganization
|
||||
"></TimeTrackerRunningInDifferentOrganizationOverlay>
|
||||
|
||||
<TimeTrackerControls
|
||||
v-model:current-time-entry="currentTimeEntry"
|
||||
v-model:live-timer="now"
|
||||
:create-project
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
:can-create-project="canCreateProjects()"
|
||||
:create-client
|
||||
:clients
|
||||
:tags
|
||||
:tasks
|
||||
:projects
|
||||
:create-tag
|
||||
:is-active
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
@start-live-timer="startLiveTimer"
|
||||
@stop-live-timer="stopLiveTimer"
|
||||
@start-timer="setActiveState(true)"
|
||||
@stop-timer="setActiveState(false)"
|
||||
@update-time-entry="updateTimeEntry"></TimeTrackerControls>
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<div class="flex-1">
|
||||
<TimeTrackerControls
|
||||
v-model:current-time-entry="currentTimeEntry"
|
||||
v-model:live-timer="now"
|
||||
:create-project
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
:can-create-project="canCreateProjects()"
|
||||
:create-client
|
||||
:clients
|
||||
:tags
|
||||
:tasks
|
||||
:projects
|
||||
:time-entries
|
||||
:create-tag
|
||||
:is-active
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
@start-live-timer="startLiveTimer"
|
||||
@stop-live-timer="stopLiveTimer"
|
||||
@start-timer="setActiveState(true)"
|
||||
@stop-timer="setActiveState(false)"
|
||||
@update-time-entry="updateTimeEntry"
|
||||
@create-time-entry="createTimeEntryFromCurrentEntry"></TimeTrackerControls>
|
||||
</div>
|
||||
<TimeTrackerMoreOptionsDropdown
|
||||
:has-active-timer="isActive"
|
||||
@manual-entry="showManualTimeEntryModal = true"
|
||||
@discard="discardCurrentTimeEntry"></TimeTrackerMoreOptionsDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
<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 DropdownLink from '@/Components/DropdownLink.vue';
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import {
|
||||
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<{
|
||||
has_services_extension?: boolean;
|
||||
has_billing_extension?: boolean;
|
||||
jetstream: {
|
||||
canCreateTeams: boolean;
|
||||
hasTeamFeatures: boolean;
|
||||
@@ -23,60 +37,58 @@ const logout = () => {
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div class="ms-3 relative">
|
||||
<Dropdown align="center" width="48">
|
||||
<template #trigger>
|
||||
<button
|
||||
v-if="page.props.jetstream.managesProfilePhotos"
|
||||
data-testid="current_user_button"
|
||||
class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
|
||||
<div class="relative">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
class="flex text-sm border-2 outline-none border-transparent rounded-full focus-visible:ring-2 focus-visible:ring-ring transition"
|
||||
as-child>
|
||||
<button data-testid="current_user_button">
|
||||
<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"
|
||||
:alt="page.props.auth.user.name" />
|
||||
</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
|
||||
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">
|
||||
{{ page.props.auth.user.name }}
|
||||
|
||||
<svg
|
||||
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>
|
||||
class="inline-flex items-center gap-2.5 w-full"
|
||||
@click="openFeedback">
|
||||
<ChatBubbleLeftRightIcon class="w-5 h-5 text-icon-default" />
|
||||
<span>Feedback</span>
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<template #content>
|
||||
<!-- Account Management -->
|
||||
<div class="block px-4 py-2 text-xs text-gray-400">Manage Account</div>
|
||||
|
||||
<DropdownLink :href="route('profile.show')"> Profile </DropdownLink>
|
||||
|
||||
<DropdownLink
|
||||
v-if="page.props.jetstream.hasApiFeatures"
|
||||
:href="route('api-tokens.index')">
|
||||
API Tokens
|
||||
</DropdownLink>
|
||||
|
||||
<div class="border-t border-card-border" />
|
||||
|
||||
<!-- Authentication -->
|
||||
<form @submit.prevent="logout">
|
||||
<DropdownLink as="button" data-testid="logout_button"> Log Out </DropdownLink>
|
||||
<form class="w-full" @submit.prevent="logout">
|
||||
<DropdownMenuItem as-child class="inline-flex items-center gap-2.5 w-full">
|
||||
<button type="submit" data-testid="logout_button">
|
||||
<ArrowLeftOnRectangleIcon class="w-5 h-5 text-icon-default" />
|
||||
<span>Log Out</span>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</form>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { buttonVariants } from '@/packages/ui/src';
|
||||
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { buttonVariants } from '@/packages/ui/src';
|
||||
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
||||
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
|
||||
import { Button } from '@/packages/ui/src';
|
||||
import { Calendar } from '@/Components/ui/calendar';
|
||||
import { CalendarIcon, XIcon } from 'lucide-vue-next';
|
||||
import { formatDate } from '@/packages/ui/src/utils/time';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { cn, buttonVariants } from '@/packages/ui/src/index';
|
||||
import { ChevronRight } from 'lucide-vue-next';
|
||||
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
||||
import { ChevronLeft } from 'lucide-vue-next';
|
||||
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
@@ -19,7 +19,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
||||
<template>
|
||||
<DropdownMenuLabel
|
||||
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 />
|
||||
</DropdownMenuLabel>
|
||||
</template>
|
||||
|
||||
@@ -15,9 +15,7 @@ const delegatedProps = computed(() => {
|
||||
<template>
|
||||
<TabsList
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn('inline-flex items-center rounded-lg bg-muted text-muted-foreground', props.class)
|
||||
">
|
||||
:class="cn('inline-flex items-center rounded-lg text-muted-foreground', props.class)">
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
||||
|
||||
@@ -5,6 +5,7 @@ import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
|
||||
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
||||
import {
|
||||
Bars3Icon,
|
||||
CalendarIcon,
|
||||
ChartBarIcon,
|
||||
ClockIcon,
|
||||
Cog6ToothIcon,
|
||||
@@ -39,14 +40,19 @@ 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';
|
||||
import { Button } from '@/packages/ui/src';
|
||||
import { openFeedback } from '@/utils/feedback';
|
||||
|
||||
defineProps({
|
||||
title: String,
|
||||
mainClass: String,
|
||||
});
|
||||
|
||||
const showSidebarMenu = ref(false);
|
||||
@@ -90,8 +96,8 @@ onMounted(async () => {
|
||||
}, 100);
|
||||
};
|
||||
});
|
||||
|
||||
const page = usePage<{
|
||||
has_services_extension?: boolean;
|
||||
auth: {
|
||||
user: User;
|
||||
};
|
||||
@@ -102,7 +108,7 @@ const page = usePage<{
|
||||
<div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary">
|
||||
<div
|
||||
: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">
|
||||
<div class="flex flex-col h-full">
|
||||
@@ -131,6 +137,11 @@ 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"
|
||||
@@ -233,35 +244,44 @@ const page = usePage<{
|
||||
<div class="justify-self-end">
|
||||
<UpdateSidebarNotification></UpdateSidebarNotification>
|
||||
<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
|
||||
class="flex-1"
|
||||
title="Profile Settings"
|
||||
:icon="Cog6ToothIcon"
|
||||
: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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 lg:ml-[230px] 2xl:ml-[250px] min-w-0">
|
||||
<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="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>
|
||||
|
||||
<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">
|
||||
@@ -273,7 +293,7 @@ const page = usePage<{
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main class="pb-28 flex-1">
|
||||
<main :class="twMerge('pb-28 relative flex-1', mainClass)">
|
||||
<div
|
||||
v-if="isOrganizationLoading"
|
||||
class="flex items-center justify-center h-screen">
|
||||
@@ -285,4 +305,5 @@ const page = usePage<{
|
||||
</div>
|
||||
</div>
|
||||
<NotificationContainer></NotificationContainer>
|
||||
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
||||
</template>
|
||||
|
||||
145
resources/js/Pages/Calendar.vue
Normal file
145
resources/js/Pages/Calendar.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<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, getCurrentMembershipId } 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';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
|
||||
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!,
|
||||
member_id: getCurrentMembershipId(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
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()"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:can-create-project="canCreateProjects()"
|
||||
: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>
|
||||
@@ -35,7 +35,7 @@ const refreshDashboardData = () => {
|
||||
</MainContainer>
|
||||
|
||||
<MainContainer
|
||||
class="grid gap-5 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pt-3 sm:pt-5 pb-4 sm:pb-6 border-b border-default-background-separator items-stretch">
|
||||
class="grid gap-2 sm:gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pt-3 sm:pt-5 pb-4 sm:pb-6 border-b border-default-background-separator items-stretch">
|
||||
<RecentlyTrackedTasksCard></RecentlyTrackedTasksCard>
|
||||
<LastSevenDaysCard></LastSevenDaysCard>
|
||||
<ActivityGraphCard></ActivityGraphCard>
|
||||
|
||||
@@ -4,13 +4,16 @@ import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type {
|
||||
SortColumn,
|
||||
SortDirection,
|
||||
} from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
||||
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import type { CreateClientBody, Client, CreateProjectBody, Project } from '@/packages/api/src';
|
||||
@@ -18,31 +21,95 @@ import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { getCurrentRole } from '@/utils/useUser';
|
||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue';
|
||||
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
|
||||
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
|
||||
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
|
||||
|
||||
// Fetch data using TanStack Query
|
||||
const { projects } = useProjectsQuery();
|
||||
|
||||
onMounted(() => {
|
||||
useProjectsStore().fetchProjects();
|
||||
useOrganizationStore().fetchOrganization();
|
||||
});
|
||||
const { clients } = storeToRefs(useClientsStore());
|
||||
const showCreateProjectModal = ref(false);
|
||||
|
||||
const { organization } = storeToRefs(useOrganizationStore());
|
||||
|
||||
const activeTab = ref<'active' | 'archived'>('active');
|
||||
// Table state persisted in localStorage
|
||||
interface ProjectTableState {
|
||||
sortColumn: SortColumn;
|
||||
sortDirection: SortDirection;
|
||||
filters: {
|
||||
clientIds: string[];
|
||||
status: 'active' | 'archived' | 'all';
|
||||
};
|
||||
}
|
||||
|
||||
const { projects } = storeToRefs(useProjectsStore());
|
||||
const tableState = useStorage<ProjectTableState>(
|
||||
'project-table-state',
|
||||
{
|
||||
sortColumn: 'name',
|
||||
sortDirection: 'asc',
|
||||
filters: {
|
||||
clientIds: [],
|
||||
status: 'all',
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ mergeDefaults: true }
|
||||
);
|
||||
|
||||
const shownProjects = computed(() => {
|
||||
// Handle sorting - toggle direction if same column, otherwise set new column with asc
|
||||
function handleSort(column: SortColumn) {
|
||||
if (tableState.value.sortColumn === column) {
|
||||
tableState.value.sortDirection = tableState.value.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
tableState.value.sortColumn = column;
|
||||
tableState.value.sortDirection = 'asc';
|
||||
}
|
||||
}
|
||||
|
||||
// Filter projects based on current filters
|
||||
const filteredProjects = computed(() => {
|
||||
return projects.value.filter((project) => {
|
||||
if (activeTab.value === 'active') {
|
||||
return !project.is_archived;
|
||||
// Status filter
|
||||
if (tableState.value.filters.status === 'active' && project.is_archived) {
|
||||
return false;
|
||||
}
|
||||
return project.is_archived;
|
||||
if (tableState.value.filters.status === 'archived' && !project.is_archived) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Client filter
|
||||
const hasClientFilter = tableState.value.filters.clientIds.length > 0;
|
||||
if (hasClientFilter) {
|
||||
const matchesNoClient =
|
||||
tableState.value.filters.clientIds.includes(NO_CLIENT_ID) && !project.client_id;
|
||||
const matchesClientId =
|
||||
project.client_id && tableState.value.filters.clientIds.includes(project.client_id);
|
||||
|
||||
if (!matchesNoClient && !matchesClientId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions for active filters
|
||||
function removeStatusFilter() {
|
||||
tableState.value.filters.status = 'all';
|
||||
}
|
||||
|
||||
function removeClientFilter() {
|
||||
tableState.value.filters.clientIds = [];
|
||||
}
|
||||
|
||||
const showCreateProjectModal = useStorage('project-create-modal-open', false);
|
||||
|
||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||
return await useProjectsStore().createProject(project);
|
||||
}
|
||||
|
||||
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
|
||||
return await useClientsStore().createClient(client);
|
||||
}
|
||||
@@ -57,13 +124,9 @@ const showBillableRate = computed(() => {
|
||||
<template>
|
||||
<AppLayout title="Projects" data-testid="projects_view">
|
||||
<MainContainer
|
||||
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
|
||||
class="py-3 sm:pt-5 border-b border-default-background-separator flex justify-between items-center">
|
||||
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||
<PageTitle :icon="FolderIcon" title="Projects"></PageTitle>
|
||||
<TabBar v-model="activeTab">
|
||||
<TabBarItem value="active">Active</TabBarItem>
|
||||
<TabBarItem value="archived">Archived</TabBarItem>
|
||||
</TabBar>
|
||||
</div>
|
||||
<SecondaryButton
|
||||
v-if="canCreateProjects()"
|
||||
@@ -80,8 +143,38 @@ const showBillableRate = computed(() => {
|
||||
:clients="clients"
|
||||
@submit="createProject"></ProjectCreateModal>
|
||||
</MainContainer>
|
||||
<MainContainer>
|
||||
<div class="flex items-center gap-2 py-1">
|
||||
<ProjectsFilterDropdown
|
||||
:filters="tableState.filters"
|
||||
:clients="clients"
|
||||
@update:filters="tableState.filters = $event" />
|
||||
|
||||
<!-- Active Filters -->
|
||||
<ProjectStatusFilterBadge
|
||||
v-if="tableState.filters.status !== 'all'"
|
||||
data-testid="status-filter-badge"
|
||||
:value="tableState.filters.status"
|
||||
@remove="removeStatusFilter"
|
||||
@update:value="
|
||||
tableState.filters.status = $event as 'active' | 'archived' | 'all'
|
||||
" />
|
||||
|
||||
<ProjectClientFilterBadge
|
||||
v-if="tableState.filters.clientIds.length > 0"
|
||||
data-testid="client-filter-badge"
|
||||
:value="tableState.filters.clientIds"
|
||||
:clients="clients"
|
||||
@remove="removeClientFilter"
|
||||
@update:value="tableState.filters.clientIds = $event as string[]" />
|
||||
</div>
|
||||
</MainContainer>
|
||||
|
||||
<ProjectTable
|
||||
:show-billable-rate="showBillableRate"
|
||||
:projects="shownProjects"></ProjectTable>
|
||||
:projects="filteredProjects"
|
||||
:sort-column="tableState.sortColumn"
|
||||
:sort-direction="tableState.sortDirection"
|
||||
@sort="handleSort"></ProjectTable>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -369,6 +369,7 @@ async function downloadExport(format: ExportFormat) {
|
||||
:tags="tags"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:clients="clients"
|
||||
class="border-b border-default-background-separator"
|
||||
:update-time-entries="
|
||||
(args) =>
|
||||
updateTimeEntries(
|
||||
@@ -382,7 +383,7 @@ async function downloadExport(format: ExportFormat) {
|
||||
@submit="clearSelectionAndState"
|
||||
@select-all="selectedTimeEntries = [...timeEntries]"
|
||||
@unselect-all="selectedTimeEntries = []"></TimeEntryMassActionRow>
|
||||
<div class="w-full relative">
|
||||
<div class="w-full relative @container">
|
||||
<div v-for="entry in timeEntries" :key="entry.id">
|
||||
<TimeEntryRow
|
||||
:selected="selectedTimeEntries.includes(entry)"
|
||||
@@ -399,6 +400,7 @@ async function downloadExport(format: ExportFormat) {
|
||||
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
||||
:delete-time-entry="() => deleteTimeEntries([entry])"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:duplicate-time-entry="() => createTimeEntry(entry)"
|
||||
:members="members"
|
||||
show-date
|
||||
show-member
|
||||
|
||||
@@ -27,7 +27,7 @@ interface FormValues {
|
||||
}
|
||||
|
||||
const store = useOrganizationStore();
|
||||
const { fetchOrganization, updateOrganization } = store;
|
||||
const { updateOrganization } = store;
|
||||
const { organization } = storeToRefs(store);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -47,7 +47,6 @@ const mutation = useMutation({
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchOrganization();
|
||||
if (organization.value) {
|
||||
form.value = {
|
||||
number_format: organization.value.number_format as NumberFormat,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import { Checkbox } from '@/packages/ui/src';
|
||||
import type { UpdateOrganizationBody } from '@/packages/api/src';
|
||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
||||
|
||||
const store = useOrganizationStore();
|
||||
const { updateOrganization } = store;
|
||||
const { organization } = storeToRefs(store);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = ref<{
|
||||
prevent_overlapping_time_entries: boolean;
|
||||
employees_can_manage_tasks: boolean;
|
||||
}>({
|
||||
prevent_overlapping_time_entries: false,
|
||||
employees_can_manage_tasks: false,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
form.value.prevent_overlapping_time_entries =
|
||||
organization.value?.prevent_overlapping_time_entries ?? false;
|
||||
form.value.employees_can_manage_tasks = organization.value?.employees_can_manage_tasks ?? false;
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: Partial<UpdateOrganizationBody>) => updateOrganization(values),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['organization'] });
|
||||
},
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
await mutation.mutateAsync({
|
||||
prevent_overlapping_time_entries: form.value.prevent_overlapping_time_entries,
|
||||
employees_can_manage_tasks: form.value.employees_can_manage_tasks,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormSection>
|
||||
<template #title>Organization Settings</template>
|
||||
<template #description>
|
||||
Configure various settings for your organization, including time entry and task
|
||||
management permissions.
|
||||
</template>
|
||||
|
||||
<template #form>
|
||||
<div class="col-span-6">
|
||||
<div class="col-span-6 sm:col-span-4 space-y-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="preventOverlappingTimeEntries"
|
||||
v-model:checked="form.prevent_overlapping_time_entries" />
|
||||
<InputLabel
|
||||
for="preventOverlappingTimeEntries"
|
||||
value="Prevent overlapping time entries (new entries only)" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="employeesCanManageTasks"
|
||||
v-model:checked="form.employees_can_manage_tasks" />
|
||||
<InputLabel
|
||||
for="employeesCanManageTasks"
|
||||
value="Allow Employees to manage tasks" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<PrimaryButton :disabled="mutation.isPending.value" @click="submit">Save</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
</template>
|
||||
@@ -8,12 +8,25 @@ import type { Permissions, Role } from '@/types/jetstream';
|
||||
import { canUpdateOrganization } from '@/utils/permissions';
|
||||
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
|
||||
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
|
||||
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
defineProps<{
|
||||
team: Organization;
|
||||
availableRoles: Role[];
|
||||
permissions: Permissions;
|
||||
}>();
|
||||
|
||||
const loading = ref(true);
|
||||
const orgStore = useOrganizationStore();
|
||||
const { organization } = storeToRefs(orgStore);
|
||||
|
||||
onMounted(async () => {
|
||||
await orgStore.fetchOrganization();
|
||||
loading.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,17 +39,25 @@ defineProps<{
|
||||
|
||||
<div>
|
||||
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
|
||||
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
||||
<div v-if="loading || !organization" class="py-16 text-center text-text-secondary">
|
||||
Loading organization settings...
|
||||
</div>
|
||||
<template v-else>
|
||||
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
||||
|
||||
<SectionBorder />
|
||||
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
|
||||
<SectionBorder />
|
||||
<SectionBorder />
|
||||
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
|
||||
<SectionBorder />
|
||||
|
||||
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
|
||||
<SectionBorder />
|
||||
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
|
||||
<SectionBorder />
|
||||
|
||||
<template v-if="permissions.canDeleteTeam && !team.personal_team">
|
||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||
<OrganizationTimeEntrySettings v-if="canUpdateOrganization()" />
|
||||
<SectionBorder />
|
||||
|
||||
<template v-if="permissions.canDeleteTeam && !team.personal_team">
|
||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,8 +15,6 @@ import type {
|
||||
} from '@/packages/api/src';
|
||||
import { useElementVisibility } from '@vueuse/core';
|
||||
import { ClockIcon } from '@heroicons/vue/20/solid';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
@@ -24,7 +22,6 @@ import { useProjectsStore } from '@/utils/useProjects';
|
||||
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
||||
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
|
||||
@@ -73,7 +70,6 @@ onMounted(async () => {
|
||||
await timeEntriesStore.fetchTimeEntries();
|
||||
});
|
||||
|
||||
const showManualTimeEntryModal = ref(false);
|
||||
const projectStore = useProjectsStore();
|
||||
const { projects } = storeToRefs(projectStore);
|
||||
const taskStore = useTasksStore();
|
||||
@@ -105,33 +101,9 @@ function deleteSelected() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TimeEntryCreateModal
|
||||
v-model:show="showManualTimeEntryModal"
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
:create-project="createProject"
|
||||
:create-client="createClient"
|
||||
:create-tag="createTag"
|
||||
:create-time-entry="createTimeEntry"
|
||||
:projects
|
||||
:tasks
|
||||
:tags
|
||||
:clients></TimeEntryCreateModal>
|
||||
<AppLayout title="Dashboard" data-testid="time_view">
|
||||
<MainContainer class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
||||
<div
|
||||
class="lg:flex items-end lg:divide-x divide-default-background-separator divide-y lg:divide-y-0 space-y-2 lg:space-y-0 lg:space-x-2">
|
||||
<div class="flex-1">
|
||||
<TimeTracker></TimeTracker>
|
||||
</div>
|
||||
<div class="pb-2 pt-2 lg:pt-0 lg:pl-4 flex justify-center">
|
||||
<SecondaryButton
|
||||
class="w-full text-center flex justify-center"
|
||||
:icon="PlusIcon"
|
||||
@click="showManualTimeEntryModal = true"
|
||||
>Manual time entry
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
<TimeTracker></TimeTracker>
|
||||
</MainContainer>
|
||||
<TimeEntryMassActionRow
|
||||
:selected-time-entries="selectedTimeEntries"
|
||||
@@ -144,6 +116,7 @@ function deleteSelected() {
|
||||
:tags="tags"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:clients="clients"
|
||||
class="border-t border-default-background-separator"
|
||||
:update-time-entries="
|
||||
(args) =>
|
||||
updateTimeEntries(
|
||||
|
||||
193
resources/js/packages/api/package-lock.json
generated
193
resources/js/packages/api/package-lock.json
generated
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "@solidtime/api",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@solidtime/api",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.6",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@zodios/core": "^10.9.6",
|
||||
"axios": "^1.13.2",
|
||||
"typescript": "^5.5.4",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -1094,18 +1095,16 @@
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz",
|
||||
"integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==",
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -1127,12 +1126,24 @@
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
@@ -1198,11 +1209,24 @@
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
@@ -1216,6 +1240,51 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -1280,7 +1349,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
@@ -1291,14 +1359,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1339,12 +1408,60 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -1362,11 +1479,37 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -1489,12 +1632,20 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -1504,7 +1655,6 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
@@ -1657,8 +1807,7 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@solidtime/api",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"description": "Package containing the solidtime api client and type declarations",
|
||||
"main": "./dist/solidtime-api.umd.cjs",
|
||||
"module": "./dist/solidtime-api.js",
|
||||
@@ -29,6 +29,7 @@
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@zodios/core": "^10.9.6",
|
||||
"axios": "^1.13.2",
|
||||
"typescript": "^5.5.4",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
||||
@@ -36,20 +36,14 @@ const ClientResource = z
|
||||
const ClientCollection = z.array(ClientResource);
|
||||
const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
||||
const ClientUpdateRequest = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
is_archived: z.boolean().optional(),
|
||||
})
|
||||
.object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })
|
||||
.passthrough();
|
||||
const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();
|
||||
const InvitationResource = z
|
||||
.object({ id: z.string(), email: z.string(), role: z.string() })
|
||||
.passthrough();
|
||||
const InvitationStoreRequest = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(['admin', 'manager', 'employee']),
|
||||
})
|
||||
.object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) })
|
||||
.passthrough();
|
||||
const InvoiceResource = z
|
||||
.object({
|
||||
@@ -97,6 +91,7 @@ const InvoiceStoreRequest = z
|
||||
billing_period_end: z.union([z.string(), z.null()]).optional(),
|
||||
reference: z.string(),
|
||||
currency: z.string(),
|
||||
payment_iban: z.union([z.string(), z.null()]).optional(),
|
||||
tax_rate: z.number().int().gte(0).lte(2147483647).optional(),
|
||||
discount_amount: z.number().int().gte(0).lte(9223372036854776000).optional(),
|
||||
discount_type: InvoiceDiscountType.optional(),
|
||||
@@ -161,6 +156,7 @@ const DetailedInvoiceResource = z
|
||||
discount_type: z.string(),
|
||||
discount_amount: z.number().int(),
|
||||
tax_rate: z.number().int(),
|
||||
payment_iban: z.string(),
|
||||
status: z.string(),
|
||||
currency: z.string(),
|
||||
date: z.string(),
|
||||
@@ -206,6 +202,7 @@ const InvoiceUpdateRequest = z
|
||||
billing_period_end: z.union([z.string(), z.null()]),
|
||||
reference: z.string(),
|
||||
currency: z.string(),
|
||||
payment_iban: z.union([z.string(), z.null()]),
|
||||
tax_rate: z.number().int().gte(0).lte(2147483647),
|
||||
discount_amount: z.number().int().gte(0).lte(9223372036854776000),
|
||||
discount_type: InvoiceDiscountType,
|
||||
@@ -320,6 +317,8 @@ const OrganizationResource = z
|
||||
is_personal: z.boolean(),
|
||||
billable_rate: z.union([z.number(), z.null()]),
|
||||
employees_can_see_billable_rates: z.boolean(),
|
||||
employees_can_manage_tasks: z.boolean(),
|
||||
prevent_overlapping_time_entries: z.boolean(),
|
||||
currency: z.string(),
|
||||
currency_symbol: z.string(),
|
||||
number_format: NumberFormat,
|
||||
@@ -334,6 +333,8 @@ const OrganizationUpdateRequest = z
|
||||
name: z.string().max(255),
|
||||
billable_rate: z.union([z.number(), z.null()]),
|
||||
employees_can_see_billable_rates: z.boolean(),
|
||||
employees_can_manage_tasks: z.boolean(),
|
||||
prevent_overlapping_time_entries: z.boolean(),
|
||||
number_format: NumberFormat,
|
||||
currency_format: CurrencyFormat,
|
||||
date_format: DateFormat,
|
||||
@@ -388,10 +389,7 @@ const ProjectMemberResource = z
|
||||
})
|
||||
.passthrough();
|
||||
const ProjectMemberStoreRequest = z
|
||||
.object({
|
||||
member_id: z.string(),
|
||||
billable_rate: z.union([z.number(), z.null()]).optional(),
|
||||
})
|
||||
.object({ member_id: z.string(), billable_rate: z.union([z.number(), z.null()]).optional() })
|
||||
.passthrough();
|
||||
const ProjectMemberUpdateRequest = z
|
||||
.object({ billable_rate: z.union([z.number(), z.null()]) })
|
||||
@@ -420,6 +418,7 @@ const TimeEntryAggregationType = z.enum([
|
||||
'client',
|
||||
'billable',
|
||||
'description',
|
||||
'tag',
|
||||
]);
|
||||
const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);
|
||||
const Weekday = z.enum([
|
||||
@@ -431,6 +430,7 @@ const Weekday = z.enum([
|
||||
'saturday',
|
||||
'sunday',
|
||||
]);
|
||||
const TimeEntryRoundingType = z.enum(['up', 'down', 'nearest']);
|
||||
const ReportStoreRequest = z
|
||||
.object({
|
||||
name: z.string().max(255),
|
||||
@@ -453,6 +453,8 @@ const ReportStoreRequest = z
|
||||
history_group: TimeEntryAggregationTypeInterval,
|
||||
week_start: Weekday.optional(),
|
||||
timezone: z.union([z.string(), z.null()]).optional(),
|
||||
rounding_type: TimeEntryRoundingType.optional(),
|
||||
rounding_minutes: z.union([z.number(), z.null()]).optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
@@ -479,6 +481,8 @@ const DetailedReportResource = z
|
||||
project_ids: z.union([z.array(z.string()), z.null()]),
|
||||
tag_ids: z.union([z.array(z.string()), z.null()]),
|
||||
task_ids: z.union([z.array(z.string()), z.null()]),
|
||||
rounding_type: z.union([z.string(), z.null()]),
|
||||
rounding_minutes: z.union([z.number(), z.null()]),
|
||||
})
|
||||
.passthrough(),
|
||||
created_at: z.string(),
|
||||
@@ -592,12 +596,7 @@ const DetailedWithDataReportResource = z
|
||||
})
|
||||
.passthrough();
|
||||
const TagResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
.object({ id: z.string(), name: z.string(), created_at: z.string(), updated_at: z.string() })
|
||||
.passthrough();
|
||||
const TagCollection = z.array(TagResource);
|
||||
const TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
||||
@@ -629,6 +628,7 @@ const TaskUpdateRequest = z
|
||||
})
|
||||
.passthrough();
|
||||
const start = z.union([z.string(), z.null()]).optional();
|
||||
const rounding_minutes = z.union([z.number(), z.null()]).optional();
|
||||
const TimeEntryResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -749,6 +749,7 @@ export const schemas = {
|
||||
TimeEntryAggregationType,
|
||||
TimeEntryAggregationTypeInterval,
|
||||
Weekday,
|
||||
TimeEntryRoundingType,
|
||||
ReportStoreRequest,
|
||||
DetailedReportResource,
|
||||
ReportUpdateRequest,
|
||||
@@ -761,6 +762,7 @@ export const schemas = {
|
||||
TaskStoreRequest,
|
||||
TaskUpdateRequest,
|
||||
start,
|
||||
rounding_minutes,
|
||||
TimeEntryResource,
|
||||
TimeEntryStoreRequest,
|
||||
TimeEntryUpdateMultipleRequest,
|
||||
@@ -790,13 +792,7 @@ const endpoints = makeApi([
|
||||
alias: 'getCurrencies',
|
||||
requestFormat: 'json',
|
||||
response: z.array(
|
||||
z
|
||||
.object({
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
symbol: z.string(),
|
||||
})
|
||||
.passthrough()
|
||||
z.object({ code: z.string(), name: z.string(), symbol: z.string() }).passthrough()
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -868,10 +864,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1166,13 +1159,7 @@ const endpoints = makeApi([
|
||||
},
|
||||
],
|
||||
response: z.array(
|
||||
z
|
||||
.object({
|
||||
value: z.number().int(),
|
||||
name: z.string(),
|
||||
color: z.string(),
|
||||
})
|
||||
.passthrough()
|
||||
z.object({ value: z.number().int(), name: z.string(), color: z.string() }).passthrough()
|
||||
),
|
||||
errors: [
|
||||
{
|
||||
@@ -1235,10 +1222,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1281,10 +1265,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1332,10 +1313,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1363,11 +1341,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -1405,11 +1379,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -1465,7 +1435,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
schema: z.union([
|
||||
z.object({ message: z.string() }).passthrough(),
|
||||
z.object({ message: z.string() }).passthrough(),
|
||||
z.object({ message: z.literal('Invalid base64 encoded data') }).passthrough(),
|
||||
]),
|
||||
},
|
||||
{
|
||||
@@ -1487,10 +1457,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1511,11 +1478,7 @@ const endpoints = makeApi([
|
||||
.object({
|
||||
data: z.array(
|
||||
z
|
||||
.object({
|
||||
key: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
})
|
||||
.object({ key: z.string(), name: z.string(), description: z.string() })
|
||||
.passthrough()
|
||||
),
|
||||
})
|
||||
@@ -1603,10 +1566,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1634,11 +1594,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -1660,10 +1616,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1809,10 +1762,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1855,10 +1805,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1901,10 +1848,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1988,10 +1932,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2056,6 +1997,13 @@ const endpoints = makeApi([
|
||||
],
|
||||
response: z.object({ download_link: z.string() }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
@@ -2075,10 +2023,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2102,6 +2047,13 @@ const endpoints = makeApi([
|
||||
],
|
||||
response: z.object({ download_link: z.string() }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
@@ -2147,11 +2099,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -2173,10 +2121,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2246,10 +2191,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2282,11 +2224,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -2308,10 +2246,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2344,11 +2279,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -2370,10 +2301,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2401,11 +2329,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -2448,11 +2372,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -2515,10 +2435,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2634,10 +2551,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2680,10 +2594,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2767,10 +2678,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2798,11 +2706,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -2918,11 +2822,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -2944,10 +2844,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3053,10 +2950,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3140,10 +3034,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3253,10 +3144,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3304,10 +3192,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3335,11 +3220,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -3434,10 +3315,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3480,10 +3358,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3531,10 +3406,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3562,11 +3434,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -3639,6 +3507,16 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
type: 'Query',
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['up', 'down', 'nearest']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_minutes',
|
||||
type: 'Query',
|
||||
schema: rounding_minutes,
|
||||
},
|
||||
{
|
||||
name: 'user_id',
|
||||
type: 'Query',
|
||||
@@ -3696,10 +3574,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3727,11 +3602,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -3753,10 +3624,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3799,10 +3667,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3845,10 +3710,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3881,11 +3743,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -3907,10 +3765,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3980,6 +3835,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
'client',
|
||||
'billable',
|
||||
'description',
|
||||
'tag',
|
||||
])
|
||||
.optional(),
|
||||
},
|
||||
@@ -3998,6 +3854,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
'client',
|
||||
'billable',
|
||||
'description',
|
||||
'tag',
|
||||
])
|
||||
.optional(),
|
||||
},
|
||||
@@ -4036,6 +3893,16 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
type: 'Query',
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['up', 'down', 'nearest']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_minutes',
|
||||
type: 'Query',
|
||||
schema: rounding_minutes,
|
||||
},
|
||||
{
|
||||
name: 'member_ids',
|
||||
type: 'Query',
|
||||
@@ -4120,10 +3987,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -4158,6 +4022,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
'client',
|
||||
'billable',
|
||||
'description',
|
||||
'tag',
|
||||
]),
|
||||
},
|
||||
{
|
||||
@@ -4174,6 +4039,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
'client',
|
||||
'billable',
|
||||
'description',
|
||||
'tag',
|
||||
]),
|
||||
},
|
||||
{
|
||||
@@ -4221,6 +4087,16 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
type: 'Query',
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['up', 'down', 'nearest']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_minutes',
|
||||
type: 'Query',
|
||||
schema: rounding_minutes,
|
||||
},
|
||||
{
|
||||
name: 'member_ids',
|
||||
type: 'Query',
|
||||
@@ -4256,11 +4132,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -4282,10 +4154,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -4346,6 +4215,16 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
type: 'Query',
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['up', 'down', 'nearest']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_minutes',
|
||||
type: 'Query',
|
||||
schema: rounding_minutes,
|
||||
},
|
||||
{
|
||||
name: 'member_ids',
|
||||
type: 'Query',
|
||||
@@ -4376,11 +4255,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -4402,10 +4277,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -4487,11 +4359,7 @@ Please note that the access token is only shown in this response and cannot be r
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -4508,10 +4376,7 @@ Please note that the access token is only shown in this response and cannot be r
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -4534,11 +4399,7 @@ Please note that the access token is only shown in this response and cannot be r
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -4576,11 +4437,7 @@ Please note that the access token is only shown in this response and cannot be r
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
|
||||
2014
resources/js/packages/ui/package-lock.json
generated
2014
resources/js/packages/ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@solidtime/ui",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.15",
|
||||
"description": "Package containing the solidtime ui components",
|
||||
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
||||
"module": "./dist/solidtime-ui-lib.js",
|
||||
@@ -21,16 +21,21 @@
|
||||
"default": "./dist/solidtime-ui-lib.umd.cjs"
|
||||
}
|
||||
},
|
||||
"./style.css": "./dist/style.css"
|
||||
"./style.css": "./dist/style.css",
|
||||
"./styles.css": "./styles.css",
|
||||
"./tailwind.theme.js": "./tailwind.theme.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && vue-tsc --emitDeclarationOnly",
|
||||
"watch": "vite build --watch",
|
||||
"types": "vue-tsc ",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"styles.css",
|
||||
"tailwind.theme.js"
|
||||
],
|
||||
"keywords": [
|
||||
"solidtime",
|
||||
@@ -59,8 +64,11 @@
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@zodios/core": "^10.9.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"parse-duration": "^2.0.1",
|
||||
"reka-ui": "^2.2.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss": "^3.1.0",
|
||||
"vue": "^3.5.0",
|
||||
|
||||
25
resources/js/packages/ui/src/Buttons/Button.vue
Normal file
25
resources/js/packages/ui/src/Buttons/Button.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
import { cn } from '../utils/cn';
|
||||
import { Primitive, type PrimitiveProps } from 'reka-ui';
|
||||
import { type ButtonVariants, buttonVariants } from '.';
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants['variant'];
|
||||
size?: ButtonVariants['size'];
|
||||
class?: HTMLAttributes['class'];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -10,7 +10,8 @@ const props = withDefaults(
|
||||
icon?: Component;
|
||||
size?: 'small' | 'base';
|
||||
loading?: boolean;
|
||||
class?: string;
|
||||
// Accept any valid Vue class binding shape (string | object | array)
|
||||
class?: Parameters<typeof twMerge>[0];
|
||||
}>(),
|
||||
{
|
||||
type: 'button',
|
||||
|
||||
36
resources/js/packages/ui/src/Buttons/index.ts
Normal file
36
resources/js/packages/ui/src/Buttons/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export { default as Button } from './Button.vue';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border shadow-xs hover:text-text-primary bg-card-background dark:bg-transparent border-input dark:border-input hover:bg-white/5',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-white/5',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
input: 'border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent shadow-sm',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
xs: 'h-7 rounded px-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
input: 'h-[42px] px-3 py-2 text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user