mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-10 17:22:15 +01:00
Compare commits
3 Commits
feature/up
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdf783ce24 | ||
|
|
480c44771e | ||
|
|
ed1ffaf0db |
216
.github/workflows/build-onpremise.yml
vendored
216
.github/workflows/build-onpremise.yml
vendored
@@ -1,216 +0,0 @@
|
|||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/build-onpremise.yml'
|
|
||||||
- 'docker/prod/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
contents: read
|
|
||||||
attestations: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOCKER_REPO: registry.on-premise.solidtime.io/solidtime/solidtime
|
|
||||||
|
|
||||||
name: Build - On Premise
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- runs-on: "ubuntu-24.04-arm"
|
|
||||||
platform: "linux/arm64"
|
|
||||||
- runs-on: "ubuntu-24.04"
|
|
||||||
platform: "linux/amd64"
|
|
||||||
runs-on: ${{ matrix.runs-on }}
|
|
||||||
timeout-minutes: 90
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: "Check out code"
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
|
||||||
|
|
||||||
- name: "Get build"
|
|
||||||
id: release-build
|
|
||||||
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: "Get Previous tag (normal push)"
|
|
||||||
id: previoustag
|
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
|
||||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
|
||||||
with:
|
|
||||||
prefix: "v"
|
|
||||||
|
|
||||||
- name: "Get version"
|
|
||||||
id: release-version
|
|
||||||
run: |
|
|
||||||
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
|
||||||
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
|
||||||
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
|
|
||||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "ERROR: No previous tag found";
|
|
||||||
exit 1;
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
version=$(echo "${{ github.ref }}" | cut -c 12-)
|
|
||||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: "Copy .env template for production"
|
|
||||||
run: |
|
|
||||||
cp .env.production .env
|
|
||||||
rm .env.production .env.ci .env.example
|
|
||||||
|
|
||||||
- name: "Add version to .env"
|
|
||||||
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
|
|
||||||
|
|
||||||
- name: "Add build to .env"
|
|
||||||
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
|
|
||||||
|
|
||||||
- name: "Output .env"
|
|
||||||
run: cat .env
|
|
||||||
|
|
||||||
- name: "Setup PHP with PECL extension"
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.3'
|
|
||||||
extensions: mbstring, dom, fileinfo, pgsql
|
|
||||||
|
|
||||||
- name: "Install dependencies"
|
|
||||||
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
|
||||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
|
||||||
|
|
||||||
- name: "Use Node.js"
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20.x'
|
|
||||||
|
|
||||||
- name: "Checkout invoicing extension"
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: solidtime-io/extension-invoicing
|
|
||||||
path: extensions/Invoicing
|
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
|
||||||
|
|
||||||
- name: "Install composer dependencies in invoicing extension"
|
|
||||||
run: cd extensions/Invoicing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
|
||||||
|
|
||||||
- name: "Install npm dependencies in invoicing extension"
|
|
||||||
run: cd extensions/Invoicing && npm ci
|
|
||||||
|
|
||||||
- name: "Activate invoicing extension"
|
|
||||||
run: php artisan module:enable Invoicing
|
|
||||||
|
|
||||||
- name: "Install npm dependencies"
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: "Build"
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: "Prepare"
|
|
||||||
run: |
|
|
||||||
platform=${{ matrix.platform }}
|
|
||||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: "Docker meta"
|
|
||||||
id: "meta"
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
${{ env.DOCKER_REPO }}
|
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: registry.on-premise.solidtime.io
|
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
|
||||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
|
||||||
|
|
||||||
- name: "Set up QEMU"
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: "Build and push by digest"
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/prod/Dockerfile
|
|
||||||
build-args: |
|
|
||||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
outputs: type=image,"name=${{ env.DOCKER_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: "Export digest"
|
|
||||||
run: |
|
|
||||||
mkdir -p ${{ runner.temp }}/digests
|
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
|
||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
|
||||||
|
|
||||||
- name: "Upload digest"
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}
|
|
||||||
path: ${{ runner.temp }}/digests/*
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
merge:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 90
|
|
||||||
needs:
|
|
||||||
- build
|
|
||||||
steps:
|
|
||||||
- name: "Download digests"
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: ${{ runner.temp }}/digests
|
|
||||||
pattern: digests-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: registry.on-premise.solidtime.io
|
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
|
||||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: "Docker meta"
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
${{ env.DOCKER_REPO }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
|
|
||||||
- name: "Create manifest list and push"
|
|
||||||
working-directory: ${{ runner.temp }}/digests
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
|
|
||||||
|
|
||||||
- name: "Inspect image"
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools inspect ${{ env.DOCKER_REPO }}:${{ steps.meta.outputs.version }}
|
|
||||||
@@ -22,27 +22,13 @@ class Kernel extends ConsoleKernel
|
|||||||
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
|
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
|
||||||
->everyTenMinutes();
|
->everyTenMinutes();
|
||||||
|
|
||||||
if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) {
|
$schedule->command('self-host:check-for-update')
|
||||||
// Convert string to a stable integer for seeding
|
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
||||||
/** @var int $seed Take the first 8 hex chars → 32-bit int */
|
->twiceDaily();
|
||||||
$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
|
|
||||||
|
|
||||||
if (config('scheduling.tasks.self_hosting_check_for_update')) {
|
$schedule->command('self-host:telemetry')
|
||||||
$schedule->command('self-host:check-for-update')
|
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
|
||||||
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
|
->twiceDaily();
|
||||||
}
|
|
||||||
|
|
||||||
if (config('scheduling.tasks.self_hosting_telemetry')) {
|
|
||||||
$schedule->command('self-host:telemetry')
|
|
||||||
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$schedule->command('self-host:database-consistency')
|
$schedule->command('self-host:database-consistency')
|
||||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
|
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ enum TimeEntryAggregationType: string
|
|||||||
case Client = 'client';
|
case Client = 'client';
|
||||||
case Billable = 'billable';
|
case Billable = 'billable';
|
||||||
case Description = 'description';
|
case Description = 'description';
|
||||||
case Tag = 'tag';
|
|
||||||
|
|
||||||
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions\Api;
|
|
||||||
|
|
||||||
class OverlappingTimeEntryApiException extends ApiException
|
|
||||||
{
|
|
||||||
public const string KEY = 'overlapping_time_entry';
|
|
||||||
}
|
|
||||||
@@ -38,17 +38,11 @@ class ClientController extends Controller
|
|||||||
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
|
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'clients:view');
|
$this->checkPermission($organization, 'clients:view');
|
||||||
$canViewAllClients = $this->hasPermission($organization, 'clients:view:all');
|
|
||||||
$user = $this->user();
|
|
||||||
|
|
||||||
$clientsQuery = Client::query()
|
$clientsQuery = Client::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->orderBy('created_at', 'desc');
|
->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
if (! $canViewAllClients) {
|
|
||||||
$clientsQuery->visibleByEmployee($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
$filterArchived = $request->getFilterArchived();
|
$filterArchived = $request->getFilterArchived();
|
||||||
if ($filterArchived === 'true') {
|
if ($filterArchived === 'true') {
|
||||||
$clientsQuery->whereNotNull('archived_at');
|
$clientsQuery->whereNotNull('archived_at');
|
||||||
|
|||||||
@@ -61,9 +61,6 @@ class OrganizationController extends Controller
|
|||||||
if ($request->getTimeFormat() !== null) {
|
if ($request->getTimeFormat() !== null) {
|
||||||
$organization->time_format = $request->getTimeFormat();
|
$organization->time_format = $request->getTimeFormat();
|
||||||
}
|
}
|
||||||
if ($request->getPreventOverlappingTimeEntries() !== null) {
|
|
||||||
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
|
|
||||||
}
|
|
||||||
$hasBillableRate = $request->has('billable_rate');
|
$hasBillableRate = $request->has('billable_rate');
|
||||||
if ($hasBillableRate) {
|
if ($hasBillableRate) {
|
||||||
$oldBillableRate = $organization->billable_rate;
|
$oldBillableRate = $organization->billable_rate;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api\V1;
|
|||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
@@ -46,7 +45,6 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
use Illuminate\Http\File;
|
use Illuminate\Http\File;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
@@ -58,43 +56,6 @@ use Spatie\TemporaryDirectory\TemporaryDirectory;
|
|||||||
|
|
||||||
class TimeEntryController extends Controller
|
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
|
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
|
||||||
{
|
{
|
||||||
parent::checkPermission($organization, $permission);
|
parent::checkPermission($organization, $permission);
|
||||||
@@ -588,15 +549,17 @@ class TimeEntryController extends Controller
|
|||||||
throw new TimeEntryStillRunningApiException;
|
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;
|
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
|
||||||
$client = $project?->client;
|
$client = $project?->client;
|
||||||
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
|
$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 = new TimeEntry;
|
||||||
$timeEntry->fill($request->validated());
|
$timeEntry->fill($request->validated());
|
||||||
$timeEntry->client()->associate($client);
|
$timeEntry->client()->associate($client);
|
||||||
@@ -606,13 +569,6 @@ class TimeEntryController extends Controller
|
|||||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||||
$timeEntry->save();
|
$timeEntry->save();
|
||||||
|
|
||||||
if ($project !== null) {
|
|
||||||
RecalculateSpentTimeForProject::dispatch($project);
|
|
||||||
}
|
|
||||||
if ($task !== null) {
|
|
||||||
RecalculateSpentTimeForTask::dispatch($task);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TimeEntryResource($timeEntry);
|
return new TimeEntryResource($timeEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,13 +593,6 @@ class TimeEntryController extends Controller
|
|||||||
throw new TimeEntryCanNotBeRestartedApiException;
|
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;
|
$oldProject = $timeEntry->project;
|
||||||
$oldTask = $timeEntry->task;
|
$oldTask = $timeEntry->task;
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ class HandleInertiaRequests extends Middleware
|
|||||||
{
|
{
|
||||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||||
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
||||||
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
|
||||||
|
|
||||||
/** @var BillingContract $billing */
|
/** @var BillingContract $billing */
|
||||||
$billing = app(BillingContract::class);
|
$billing = app(BillingContract::class);
|
||||||
@@ -51,7 +50,6 @@ class HandleInertiaRequests extends Middleware
|
|||||||
return array_merge(parent::share($request), [
|
return array_merge(parent::share($request), [
|
||||||
'has_billing_extension' => $hasBilling,
|
'has_billing_extension' => $hasBilling,
|
||||||
'has_invoicing_extension' => $hasInvoicing,
|
'has_invoicing_extension' => $hasInvoicing,
|
||||||
'has_services_extension' => $hasServices,
|
|
||||||
'billing' => $currentOrganization !== null ? [
|
'billing' => $currentOrganization !== null ? [
|
||||||
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
||||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
|||||||
'employees_can_see_billable_rates' => [
|
'employees_can_see_billable_rates' => [
|
||||||
'boolean',
|
'boolean',
|
||||||
],
|
],
|
||||||
'prevent_overlapping_time_entries' => [
|
|
||||||
'boolean',
|
|
||||||
],
|
|
||||||
'number_format' => [
|
'number_format' => [
|
||||||
Rule::enum(NumberFormat::class),
|
Rule::enum(NumberFormat::class),
|
||||||
],
|
],
|
||||||
@@ -101,9 +98,4 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
|||||||
{
|
{
|
||||||
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
|
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPreventOverlappingTimeEntries(): ?bool
|
|
||||||
{
|
|
||||||
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
|||||||
'description' => [
|
'description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:5000',
|
'max:500',
|
||||||
],
|
],
|
||||||
// List of tag IDs
|
// List of tag IDs
|
||||||
'tags' => [
|
'tags' => [
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
|
|||||||
'changes.description' => [
|
'changes.description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:5000',
|
'max:500',
|
||||||
],
|
],
|
||||||
// List of tag IDs
|
// List of tag IDs
|
||||||
'changes.tags' => [
|
'changes.tags' => [
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
|||||||
'description' => [
|
'description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:5000',
|
'max:500',
|
||||||
],
|
],
|
||||||
// List of tag IDs
|
// List of tag IDs
|
||||||
'tags' => [
|
'tags' => [
|
||||||
|
|||||||
@@ -53,8 +53,6 @@ class OrganizationResource extends BaseResource
|
|||||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
'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 */
|
/** @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,
|
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
||||||
/** @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) */
|
/** @var string $currency Currency code (ISO 4217) */
|
||||||
'currency' => $this->resource->currency,
|
'currency' => $this->resource->currency,
|
||||||
/** @var string $currency_symbol Currency symbol */
|
/** @var string $currency_symbol Currency symbol */
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ namespace App\Models;
|
|||||||
use App\Models\Concerns\CustomAuditable;
|
use App\Models\Concerns\CustomAuditable;
|
||||||
use App\Models\Concerns\HasUuids;
|
use App\Models\Concerns\HasUuids;
|
||||||
use Database\Factories\ClientFactory;
|
use Database\Factories\ClientFactory;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -63,18 +62,6 @@ class Client extends Model implements AuditableContract
|
|||||||
return $this->hasMany(Project::class, 'client_id');
|
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>
|
* @return Attribute<bool, never>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ class Organization extends JetstreamTeam implements AuditableContract
|
|||||||
'personal_team' => 'boolean',
|
'personal_team' => 'boolean',
|
||||||
'currency' => 'string',
|
'currency' => 'string',
|
||||||
'employees_can_see_billable_rates' => 'boolean',
|
'employees_can_see_billable_rates' => 'boolean',
|
||||||
'prevent_overlapping_time_entries' => 'boolean',
|
|
||||||
'number_format' => NumberFormat::class,
|
'number_format' => NumberFormat::class,
|
||||||
'currency_format' => CurrencyFormat::class,
|
'currency_format' => CurrencyFormat::class,
|
||||||
'date_format' => DateFormat::class,
|
'date_format' => DateFormat::class,
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tags:update',
|
'tags:update',
|
||||||
'tags:delete',
|
'tags:delete',
|
||||||
'clients:view',
|
'clients:view',
|
||||||
'clients:view:all',
|
|
||||||
'clients:create',
|
'clients:create',
|
||||||
'clients:update',
|
'clients:update',
|
||||||
'clients:delete',
|
'clients:delete',
|
||||||
@@ -173,7 +172,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tags:update',
|
'tags:update',
|
||||||
'tags:delete',
|
'tags:delete',
|
||||||
'clients:view',
|
'clients:view',
|
||||||
'clients:view:all',
|
|
||||||
'clients:create',
|
'clients:create',
|
||||||
'clients:update',
|
'clients:update',
|
||||||
'clients:delete',
|
'clients:delete',
|
||||||
@@ -234,7 +232,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tags:update',
|
'tags:update',
|
||||||
'tags:delete',
|
'tags:delete',
|
||||||
'clients:view',
|
'clients:view',
|
||||||
'clients:view:all',
|
|
||||||
'clients:create',
|
'clients:create',
|
||||||
'clients:update',
|
'clients:update',
|
||||||
'clients:delete',
|
'clients:delete',
|
||||||
@@ -259,13 +256,12 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'projects:view',
|
'projects:view',
|
||||||
'tags:view',
|
'tags:view',
|
||||||
'tasks:view',
|
'tasks:view',
|
||||||
'clients:view',
|
|
||||||
'time-entries:view:own',
|
'time-entries:view:own',
|
||||||
'time-entries:create:own',
|
'time-entries:create:own',
|
||||||
'time-entries:update:own',
|
'time-entries:update:own',
|
||||||
'time-entries:delete:own',
|
'time-entries:delete:own',
|
||||||
'organizations:view',
|
'organizations:view',
|
||||||
])->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.');
|
])->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.');
|
||||||
|
|
||||||
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
|
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
|
||||||
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
$timeEntry->project_id = $projectId;
|
$timeEntry->project_id = $projectId;
|
||||||
$timeEntry->client_id = $clientId;
|
$timeEntry->client_id = $clientId;
|
||||||
$timeEntry->organization_id = $this->organization->id;
|
$timeEntry->organization_id = $this->organization->id;
|
||||||
if (strlen($record['Description']) > 5000) {
|
if (strlen($record['Description']) > 500) {
|
||||||
throw new ImportException('Time entry description is too long');
|
throw new ImportException('Time entry description is too long');
|
||||||
}
|
}
|
||||||
$timeEntry->description = $record['Description'];
|
$timeEntry->description = $record['Description'];
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
|||||||
$timeEntry->project_id = $projectId;
|
$timeEntry->project_id = $projectId;
|
||||||
$timeEntry->client_id = $clientId;
|
$timeEntry->client_id = $clientId;
|
||||||
$timeEntry->organization_id = $this->organization->id;
|
$timeEntry->organization_id = $this->organization->id;
|
||||||
if (strlen($record['Notes']) > 5000) {
|
if (strlen($record['Notes']) > 500) {
|
||||||
throw new ImportException('Time entry note is too long');
|
throw new ImportException('Time entry note is too long');
|
||||||
}
|
}
|
||||||
$timeEntry->description = $record['Notes'];
|
$timeEntry->description = $record['Notes'];
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$timeEntry->project_id = $projectId;
|
$timeEntry->project_id = $projectId;
|
||||||
$timeEntry->client_id = $clientId;
|
$timeEntry->client_id = $clientId;
|
||||||
$timeEntry->organization_id = $this->organization->id;
|
$timeEntry->organization_id = $this->organization->id;
|
||||||
if (strlen($timeEntryRow['description']) > 5000) {
|
if (strlen($timeEntryRow['description']) > 500) {
|
||||||
throw new ImportException('Time entry description is too long');
|
throw new ImportException('Time entry description is too long');
|
||||||
}
|
}
|
||||||
$timeEntry->description = $timeEntryRow['description'];
|
$timeEntry->description = $timeEntryRow['description'];
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use App\Enums\TimeEntryRoundingType;
|
|||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -18,7 +17,6 @@ use Carbon\CarbonTimeZone;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class TimeEntryAggregationService
|
class TimeEntryAggregationService
|
||||||
@@ -47,21 +45,9 @@ 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
|
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;
|
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
||||||
/** @var Builder<TimeEntry> $baseTotalsQuery */
|
|
||||||
$baseTotalsQuery = $timeEntriesQuery->clone();
|
|
||||||
$group1Select = null;
|
$group1Select = null;
|
||||||
$group2Select = null;
|
$group2Select = null;
|
||||||
$groupBy = 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) {
|
if ($group1Type !== null) {
|
||||||
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
|
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
|
||||||
$groupBy = ['group_1'];
|
$groupBy = ['group_1'];
|
||||||
@@ -98,26 +84,6 @@ class TimeEntryAggregationService
|
|||||||
$group1Response = [];
|
$group1Response = [];
|
||||||
$group1ResponseSum = 0;
|
$group1ResponseSum = 0;
|
||||||
$group1ResponseCost = 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) {
|
foreach ($groupedAggregates as $group1 => $group1Aggregates) {
|
||||||
/** @var string|int $group1 */
|
/** @var string|int $group1 */
|
||||||
$group2Response = [];
|
$group2Response = [];
|
||||||
@@ -137,14 +103,6 @@ class TimeEntryAggregationService
|
|||||||
$group2ResponseSum += (int) $aggregate->get(0)->aggregate;
|
$group2ResponseSum += (int) $aggregate->get(0)->aggregate;
|
||||||
$group2ResponseCost += (int) $aggregate->get(0)->cost;
|
$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 {
|
} else {
|
||||||
/** @var Collection<int, object{aggregate: int, cost: int}> $group1Aggregates */
|
/** @var Collection<int, object{aggregate: int, cost: int}> $group1Aggregates */
|
||||||
$group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate;
|
$group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate;
|
||||||
@@ -163,23 +121,6 @@ class TimeEntryAggregationService
|
|||||||
$group1ResponseCost += $group2ResponseCost;
|
$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) {
|
if ($fillGapsInTimeGroupsIsPossible) {
|
||||||
$group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end);
|
$group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end);
|
||||||
}
|
}
|
||||||
@@ -353,17 +294,6 @@ class TimeEntryAggregationService
|
|||||||
'color' => null,
|
'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;
|
return $descriptorMap;
|
||||||
@@ -506,8 +436,6 @@ class TimeEntryAggregationService
|
|||||||
return 'billable';
|
return 'billable';
|
||||||
} elseif ($group === TimeEntryAggregationType::Description) {
|
} elseif ($group === TimeEntryAggregationType::Description) {
|
||||||
return 'description';
|
return 'description';
|
||||||
} elseif ($group === TimeEntryAggregationType::Tag) {
|
|
||||||
return 'tag';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<?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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<?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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -435,7 +435,7 @@ CREATE TABLE public.tasks (
|
|||||||
|
|
||||||
CREATE TABLE public.time_entries (
|
CREATE TABLE public.time_entries (
|
||||||
id uuid NOT NULL,
|
id uuid NOT NULL,
|
||||||
description character varying(5000) NOT NULL,
|
description character varying(500) NOT NULL,
|
||||||
start timestamp(0) without time zone NOT NULL,
|
start timestamp(0) without time zone NOT NULL,
|
||||||
"end" timestamp(0) without time zone,
|
"end" timestamp(0) without time zone,
|
||||||
billable_rate integer,
|
billable_rate integer,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Source: https://helgesver.re/articles/laravel-sail-create-minio-bucket-automatically
|
# Source: https://helgesver.re/articles/laravel-sail-create-minio-bucket-automatically
|
||||||
|
|
||||||
/usr/bin/mc alias set local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
|
/usr/bin/mc config host add local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
|
||||||
/usr/bin/mc rm -r --force local/${S3_BUCKET};
|
/usr/bin/mc rm -r --force local/${S3_BUCKET};
|
||||||
/usr/bin/mc mb --ignore-existing local/${S3_BUCKET};
|
/usr/bin/mc mb --ignore-existing local/${S3_BUCKET};
|
||||||
/usr/bin/mc anonymous set public local/${S3_BUCKET};
|
/usr/bin/mc anonymous set public local/${S3_BUCKET};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ RUN CGO_ENABLED=1 \
|
|||||||
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
||||||
CGO_CFLAGS=$(php-config --includes) \
|
CGO_CFLAGS=$(php-config --includes) \
|
||||||
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
||||||
xcaddy build v2.10.0 \
|
xcaddy build \
|
||||||
--output /usr/local/bin/frankenphp \
|
--output /usr/local/bin/frankenphp \
|
||||||
--with github.com/dunglas/frankenphp=./ \
|
--with github.com/dunglas/frankenphp=./ \
|
||||||
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ async function goToOrganizationSettings(page) {
|
|||||||
|
|
||||||
async function createTimeEntry(page, duration: string) {
|
async function createTimeEntry(page, duration: string) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
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
|
// Fill in the time entry details
|
||||||
await page.getByTestId('time_entry_description').fill('Test time entry');
|
await page.getByTestId('time_entry_description').fill('Test time entry');
|
||||||
|
|||||||
@@ -26,10 +26,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
|
|||||||
|
|
||||||
// Then create the time entry
|
// Then create the time entry
|
||||||
await goToTimeOverview(page);
|
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
|
// Fill in the time entry details
|
||||||
await page
|
await page
|
||||||
@@ -55,10 +52,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
|
|||||||
|
|
||||||
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
|
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
|
||||||
await goToTimeOverview(page);
|
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
|
// Fill in the time entry details
|
||||||
await page
|
await page
|
||||||
@@ -87,10 +81,7 @@ async function createTimeEntryWithBillableStatus(
|
|||||||
duration: string
|
duration: string
|
||||||
) {
|
) {
|
||||||
await goToTimeOverview(page);
|
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
|
// Fill in the time entry details
|
||||||
await page
|
await page
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
|||||||
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
||||||
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||||
@@ -48,7 +47,6 @@ return [
|
|||||||
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
|
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
|
||||||
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
|
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.',
|
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.',
|
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -203,7 +203,6 @@ return [
|
|||||||
'organization' => 'The :attribute does not exist.',
|
'organization' => 'The :attribute does not exist.',
|
||||||
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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,11 +7,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.6.0",
|
"@floating-ui/core": "^1.6.0",
|
||||||
"@floating-ui/vue": "^1.0.6",
|
"@floating-ui/vue": "^1.0.6",
|
||||||
"@fullcalendar/core": "^6.1.18",
|
|
||||||
"@fullcalendar/daygrid": "^6.1.18",
|
|
||||||
"@fullcalendar/interaction": "^6.1.18",
|
|
||||||
"@fullcalendar/timegrid": "^6.1.18",
|
|
||||||
"@fullcalendar/vue3": "^6.1.18",
|
|
||||||
"@heroicons/vue": "^2.1.1",
|
"@heroicons/vue": "^2.1.1",
|
||||||
"@rushstack/eslint-patch": "^1.10.5",
|
"@rushstack/eslint-patch": "^1.10.5",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
@@ -23,7 +18,6 @@
|
|||||||
"@vue/eslint-config-typescript": "^14.3.0",
|
"@vue/eslint-config-typescript": "^14.3.0",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.8.2",
|
||||||
"@vueuse/integrations": "^12.5.0",
|
"@vueuse/integrations": "^12.5.0",
|
||||||
"chroma-js": "3.1.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
@@ -45,7 +39,6 @@
|
|||||||
"@playwright/test": "^1.41.1",
|
"@playwright/test": "^1.41.1",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/chroma-js": "2.4.5",
|
|
||||||
"@types/node": "^22.10.10",
|
"@types/node": "^22.10.10",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"@vue/tsconfig": "^0.5.1",
|
"@vue/tsconfig": "^0.5.1",
|
||||||
@@ -1034,55 +1027,6 @@
|
|||||||
"vue-demi": ">=0.13.0"
|
"vue-demi": ">=0.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fullcalendar/core": {
|
|
||||||
"version": "6.1.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.18.tgz",
|
|
||||||
"integrity": "sha512-cD7XtZIZZ87Cg2+itnpsONCsZ89VIfLLDZ22pQX4IQVWlpYUB3bcCf878DhWkqyEen6dhi5ePtBoqYgm5K+0fQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"preact": "~10.12.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fullcalendar/daygrid": {
|
|
||||||
"version": "6.1.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.18.tgz",
|
|
||||||
"integrity": "sha512-s452Zle1SdMEzZDw+pDczm8m3JLIZzS9ANMThXTnqeqJewW1gqNFYas18aHypJSgF9Fh9rDJjTSUw04BpXB/Mg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@fullcalendar/core": "~6.1.18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fullcalendar/interaction": {
|
|
||||||
"version": "6.1.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.18.tgz",
|
|
||||||
"integrity": "sha512-f/mD5RTjzw+Q6MGTMZrLCgIrQLIUUO9NV/58aM2J6ZBQZeRlNizDqmqldqyG+j49zj2vFhUfZibPrVKWm5yA4Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@fullcalendar/core": "~6.1.18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fullcalendar/timegrid": {
|
|
||||||
"version": "6.1.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.18.tgz",
|
|
||||||
"integrity": "sha512-T/ouhs+T1tM8JcW7Cjx+KiohL/qQWKqvRITwjol8ktJ1e1N/6noC40/obR1tyolqOxMRWHjJkYoj9fUqfoez9A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@fullcalendar/daygrid": "~6.1.18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@fullcalendar/core": "~6.1.18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fullcalendar/vue3": {
|
|
||||||
"version": "6.1.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.18.tgz",
|
|
||||||
"integrity": "sha512-YMagwTumxsIx3GFYWLa9Yr73EMA+JuH6S3EeZGS+rEjvG5fDGdf+33rxGMzmw+LdO7SWi3ctbzRnJlv3fnm3RQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@fullcalendar/core": "~6.1.18",
|
|
||||||
"vue": "^3.0.11"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@heroicons/vue": {
|
"node_modules/@heroicons/vue": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
||||||
@@ -1898,13 +1842,6 @@
|
|||||||
"vue": "^2.7.0 || ^3.0.0"
|
"vue": "^2.7.0 || ^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/chroma-js": {
|
|
||||||
"version": "2.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.5.tgz",
|
|
||||||
"integrity": "sha512-6ISjhzJViaPCy2q2e6PgK+8HcHQDQ0V2LDiKmYAh+jJlLqDa6HbwDh0wOevHY0kHHUx0iZwjSRbVD47WOUx5EQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||||
@@ -2940,12 +2877,6 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chroma-js": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==",
|
|
||||||
"license": "(BSD-3-Clause AND Apache-2.0)"
|
|
||||||
},
|
|
||||||
"node_modules/class-variance-authority": {
|
"node_modules/class-variance-authority": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||||
@@ -5209,16 +5140,6 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/preact": {
|
|
||||||
"version": "10.12.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
|
||||||
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/preact"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
"@playwright/test": "^1.41.1",
|
"@playwright/test": "^1.41.1",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/chroma-js": "2.4.5",
|
|
||||||
"@types/node": "^22.10.10",
|
"@types/node": "^22.10.10",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"@vue/tsconfig": "^0.5.1",
|
"@vue/tsconfig": "^0.5.1",
|
||||||
@@ -40,11 +39,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.6.0",
|
"@floating-ui/core": "^1.6.0",
|
||||||
"@floating-ui/vue": "^1.0.6",
|
"@floating-ui/vue": "^1.0.6",
|
||||||
"@fullcalendar/core": "^6.1.18",
|
|
||||||
"@fullcalendar/daygrid": "^6.1.18",
|
|
||||||
"@fullcalendar/interaction": "^6.1.18",
|
|
||||||
"@fullcalendar/timegrid": "^6.1.18",
|
|
||||||
"@fullcalendar/vue3": "^6.1.18",
|
|
||||||
"@heroicons/vue": "^2.1.1",
|
"@heroicons/vue": "^2.1.1",
|
||||||
"@rushstack/eslint-patch": "^1.10.5",
|
"@rushstack/eslint-patch": "^1.10.5",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
@@ -56,7 +50,6 @@
|
|||||||
"@vue/eslint-config-typescript": "^14.3.0",
|
"@vue/eslint-config-typescript": "^14.3.0",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.8.2",
|
||||||
"@vueuse/integrations": "^12.5.0",
|
"@vueuse/integrations": "^12.5.0",
|
||||||
"chroma-js": "3.1.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
|
|||||||
@@ -1,14 +1,237 @@
|
|||||||
/* Import shared solidtime styles from UI package */
|
@tailwind base;
|
||||||
@import '../js/packages/ui/styles.css';
|
@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;
|
||||||
|
|
||||||
/* Main app specific styles - Inter font */
|
--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 */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
src:
|
src: url('/fonts/InterVariable.woff2') format('woff2'),
|
||||||
url('/fonts/InterVariable.woff2') format('woff2'),
|
url('/fonts/InterVariable.ttf') format('truetype');
|
||||||
url('/fonts/InterVariable.ttf') format('truetype');
|
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ const option = computed(() => ({
|
|||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 400,
|
fontWeight: 600,
|
||||||
color: labelColor.value,
|
color: labelColor.value,
|
||||||
margin: 16,
|
margin: 16,
|
||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Button } from '@/packages/ui/src';
|
import { Button } from '@/Components/ui/button';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
icon: Component;
|
icon: Component;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Switch } from '@/Components/ui/switch';
|
import { Switch } from '@/Components/ui/switch';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||||
import { Button } from '@/packages/ui/src';
|
import { Button } from '@/Components/ui/button';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
|
class="contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
|
||||||
<div :class="twMerge('pl-6 flex items-center space-x-3', props.indent ? 'pl-16' : '')">
|
<div
|
||||||
|
:class="
|
||||||
|
twMerge('pl-6 font-medium flex items-center space-x-3', props.indent ? 'pl-16' : '')
|
||||||
|
">
|
||||||
<GroupedItemsCountButton
|
<GroupedItemsCountButton
|
||||||
v-if="entry.grouped_data && entry.grouped_data?.length > 0"
|
v-if="entry.grouped_data && entry.grouped_data?.length > 0"
|
||||||
:expanded="expanded"
|
:expanded="expanded"
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
|
||||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
|
||||||
import { onMounted, ref } from 'vue';
|
|
||||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
|
||||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
|
||||||
import { useForm, usePage } from '@inertiajs/vue3';
|
|
||||||
import type { User } from '@/types/models';
|
|
||||||
import { useSessionStorage } from '@vueuse/core';
|
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
|
||||||
const saving = defineModel('saving', { default: false });
|
|
||||||
|
|
||||||
const timezone = ref('');
|
|
||||||
const userTimezone = ref('');
|
|
||||||
|
|
||||||
const page = usePage<{
|
|
||||||
auth: {
|
|
||||||
user: User;
|
|
||||||
};
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false);
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
||||||
userTimezone.value = getUserTimezone();
|
|
||||||
|
|
||||||
const now = getDayJsInstance()();
|
|
||||||
|
|
||||||
if (
|
|
||||||
now.tz(timezone.value).format() !== now.tz(userTimezone.value).format() &&
|
|
||||||
!hideTimezoneMismatchModal.value
|
|
||||||
) {
|
|
||||||
show.value = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
saving.value = true;
|
|
||||||
const form = useForm({
|
|
||||||
_method: 'PUT',
|
|
||||||
timezone: timezone.value,
|
|
||||||
name: page.props.auth.user.name,
|
|
||||||
email: page.props.auth.user.email,
|
|
||||||
week_start: page.props.auth.user.week_start,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.post(route('user-profile-information.update'), {
|
|
||||||
errorBag: 'updateProfileInformation',
|
|
||||||
preserveScroll: true,
|
|
||||||
onSuccess: () => {
|
|
||||||
saving.value = false;
|
|
||||||
show.value = false;
|
|
||||||
location.reload();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
show.value = false;
|
|
||||||
hideTimezoneMismatchModal.value = true;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
|
||||||
<template #title>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<span> Timezone mismatch detected </span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #content>
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<div class="col-span-6 sm:col-span-4 flex-1 space-y-2">
|
|
||||||
<p>
|
|
||||||
The timezone of your device does not match the timezone in your user
|
|
||||||
settings. <br />
|
|
||||||
<strong
|
|
||||||
>We highly recommend that you update your timezone settings to your
|
|
||||||
current timezone.</strong
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Want to change your timezone setting from
|
|
||||||
<strong>{{ userTimezone }}</strong> to <strong>{{ timezone }}</strong
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<SecondaryButton @click="cancel"> Cancel</SecondaryButton>
|
|
||||||
<PrimaryButton
|
|
||||||
class="ms-3"
|
|
||||||
:class="{ 'opacity-25': saving }"
|
|
||||||
:disabled="saving"
|
|
||||||
@click="submit()">
|
|
||||||
Update timezone
|
|
||||||
</PrimaryButton>
|
|
||||||
</template>
|
|
||||||
</DialogModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -4,7 +4,9 @@ import { computed } from 'vue';
|
|||||||
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
|
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
|
||||||
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
||||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
|
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
|
||||||
|
import { router } from '@inertiajs/vue3';
|
||||||
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import { LoadingSpinner } from '@/packages/ui/src';
|
import { LoadingSpinner } from '@/packages/ui/src';
|
||||||
@@ -88,8 +90,23 @@ window.addEventListener('dashboard:refresh', () => {
|
|||||||
<div v-else class="text-center flex flex-1 justify-center items-center">
|
<div v-else class="text-center flex flex-1 justify-center items-center">
|
||||||
<div>
|
<div>
|
||||||
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
|
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
|
||||||
<h3 class="text-text-primary font-semibold text-sm">No recent time entries</h3>
|
<h3 class="text-text-primary font-semibold text-sm">No recent tasks found</h3>
|
||||||
<p class="pb-5 text-sm">Start tracking your time!</p>
|
<p class="pb-5 text-sm">Create tasks inside of a project!</p>
|
||||||
|
<SecondaryButton @click="router.visit(route('projects'))"
|
||||||
|
>Go to Projects
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="latestTasks && latestTasks.length === 1"
|
||||||
|
class="text-center flex flex-1 justify-center items-center text-sm">
|
||||||
|
<div>
|
||||||
|
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
|
||||||
|
<h3 class="text-text-primary font-semibold">Add more tasks</h3>
|
||||||
|
<p class="pb-5">Create tasks inside of a project!</p>
|
||||||
|
<SecondaryButton @click="router.visit(route('projects'))"
|
||||||
|
>Go to Projects
|
||||||
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ defineProps<{
|
|||||||
<div class="px-4 py-2 2xl:py-3 border-b border-b-background-separator">
|
<div class="px-4 py-2 2xl:py-3 border-b border-b-background-separator">
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<p
|
<p class="font-semibold text-sm text-text-primary">
|
||||||
class="font-semibold text-sm min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
|
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</p>
|
</p>
|
||||||
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
|
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-end sm:p-6 z-[70]">
|
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-end sm:p-6 sm:pb-24 z-[70]">
|
||||||
<div class="flex w-full flex-col items-center space-y-4 sm:items-end">
|
<div class="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||||
<Notification
|
<Notification
|
||||||
v-for="notification in notifications"
|
v-for="notification in notifications"
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||||
import {
|
import DropdownLink from '@/Components/DropdownLink.vue';
|
||||||
Cog6ToothIcon,
|
import { usePage } from '@inertiajs/vue3';
|
||||||
PlusCircleIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
ArrowRightIcon,
|
|
||||||
} from '@heroicons/vue/24/solid';
|
|
||||||
import type { Organization, User } from '@/types/models';
|
import type { Organization, User } from '@/types/models';
|
||||||
import { isBillingActivated } from '@/utils/billing';
|
import { isBillingActivated } from '@/utils/billing';
|
||||||
import { canManageBilling } from '@/utils/permissions';
|
import { canManageBilling } from '@/utils/permissions';
|
||||||
import { switchOrganization } from '@/utils/useOrganization';
|
import { switchOrganization } from '@/utils/useOrganization';
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
} from '@/Components/ui/dropdown-menu';
|
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
jetstream: {
|
jetstream: {
|
||||||
@@ -39,79 +28,84 @@ const switchToTeam = (organization: Organization) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenu v-if="page.props.jetstream.hasTeamFeatures">
|
<Dropdown v-if="page.props.jetstream.hasTeamFeatures" align="center" width="60">
|
||||||
<DropdownMenuTrigger
|
<template #trigger>
|
||||||
class="flex w-full text-left hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-ring cursor-pointer transition pl-2 py-1 rounded w-full items-center justify-between"
|
<div
|
||||||
as-child>
|
data-testid="organization_switcher"
|
||||||
<button data-testid="organization_switcher">
|
class="flex hover:bg-white/10 cursor-pointer transition px-2 py-1 rounded-lg w-full items-center justify-between font-medium">
|
||||||
<div class="flex flex-1 space-x-2 items-center w-[calc(100%-30px)]">
|
<div class="flex flex-1 space-x-2 items-center w-[calc(100%-30px)]">
|
||||||
<div
|
<div
|
||||||
class="rounded bg-blue-900 font-medium text-xs flex-shrink-0 text-white w-5 h-5 flex items-center justify-center">
|
class="rounded sm:rounded-lg bg-blue-900 font-semibold text-xs sm:text-sm flex-shrink-0 text-white w-5 sm:w-6 h-5 sm:h-6 flex items-center justify-center">
|
||||||
{{ page.props.auth.user.current_team.name.slice(0, 1).toUpperCase() }}
|
{{ page.props.auth.user.current_team.name.slice(0, 1).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs flex-1 truncate font-medium">
|
<span class="text-sm flex-1 truncate font-semibold">
|
||||||
{{ page.props.auth.user.current_team.name }}
|
{{ page.props.auth.user.current_team.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-[30px]">
|
<div class="w-[30px]">
|
||||||
<div class="p-1 rounded-full flex items-center w-6 h-6">
|
<button
|
||||||
<ChevronDownIcon class="w-4 sm:w-full mt-[1px]"></ChevronDownIcon>
|
class="p-1 transition hover:bg-white/10 rounded-full flex items-center w-8 h-8">
|
||||||
</div>
|
<ChevronDownIcon class="w-5 sm:w-full mt-[1px]"></ChevronDownIcon>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</template>
|
||||||
|
|
||||||
<DropdownMenuContent align="start">
|
<template #content>
|
||||||
<div class="w-60">
|
<div class="w-60">
|
||||||
<DropdownMenuLabel>Manage Organization</DropdownMenuLabel>
|
<!-- Organization Management -->
|
||||||
|
<div class="block px-4 py-2 text-xs text-text-secondary">Manage Organization</div>
|
||||||
|
|
||||||
<DropdownMenuItem as-child>
|
<!-- Organization Settings -->
|
||||||
<Link
|
<DropdownLink :href="route('teams.show', page.props.auth.user.current_team.id)">
|
||||||
:href="route('teams.show', page.props.auth.user.current_team.id)"
|
Organization Settings
|
||||||
class="inline-flex items-center gap-2.5 w-full">
|
</DropdownLink>
|
||||||
<Cog6ToothIcon class="w-5 h-5 text-icon-default" />
|
|
||||||
<span>Organization Settings</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem v-if="canManageBilling() && isBillingActivated()" as-child>
|
<DropdownLink v-if="canManageBilling() && isBillingActivated()" href="/billing">
|
||||||
<Link href="/billing" class="inline-flex items-center w-full"> Billing </Link>
|
Billing
|
||||||
</DropdownMenuItem>
|
</DropdownLink>
|
||||||
|
|
||||||
<DropdownMenuItem v-if="page.props.jetstream.canCreateTeams" as-child>
|
<DropdownLink
|
||||||
<Link
|
v-if="page.props.jetstream.canCreateTeams"
|
||||||
:href="route('teams.create')"
|
:href="route('teams.create')">
|
||||||
class="inline-flex items-center gap-2.5 w-full">
|
Create new organization
|
||||||
<PlusCircleIcon class="w-5 h-5 text-icon-default" />
|
</DropdownLink>
|
||||||
<span>Create new organization</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
|
<!-- Organization Switcher -->
|
||||||
<template v-if="page.props.auth.user.all_teams.length > 1">
|
<template v-if="page.props.auth.user.all_teams.length > 1">
|
||||||
<div class="border-t border-card-background-separator" />
|
<div class="border-t border-card-background-separator" />
|
||||||
|
|
||||||
<DropdownMenuLabel>Switch Organizations</DropdownMenuLabel>
|
<div class="block px-4 py-2 text-xs text-text-secondary">
|
||||||
|
Switch Organizations
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-for="team in page.props.auth.user.all_teams" :key="team.id">
|
<template v-for="team in page.props.auth.user.all_teams" :key="team.id">
|
||||||
<form @submit.prevent="switchToTeam(team)">
|
<form @submit.prevent="switchToTeam(team)">
|
||||||
<DropdownMenuItem
|
<DropdownLink as="button">
|
||||||
as-child
|
<div class="flex items-center">
|
||||||
class="inline-flex gap-2.5 items-center w-full">
|
<svg
|
||||||
<button type="submit">
|
|
||||||
<CheckCircleIcon
|
|
||||||
v-if="team.id == page.props.auth.user.current_team_id"
|
v-if="team.id == page.props.auth.user.current_team_id"
|
||||||
class="h-5 w-5 text-green-400" />
|
class="me-2 h-5 w-5 text-green-400"
|
||||||
<ArrowRightIcon v-else class="h-5 w-5 text-icon-default" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
<div class="w-full truncate text-left">
|
<div>
|
||||||
{{ team.name }}
|
{{ team.name }}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownLink>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuContent>
|
</template>
|
||||||
</DropdownMenu>
|
</Dropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -16,25 +16,12 @@ import { useProjectsStore } from '@/utils/useProjects';
|
|||||||
import { useTasksStore } from '@/utils/useTasks';
|
import { useTasksStore } from '@/utils/useTasks';
|
||||||
import { useTagsStore } from '@/utils/useTags';
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';
|
import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';
|
||||||
import type {
|
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
|
||||||
CreateClientBody,
|
|
||||||
CreateProjectBody,
|
|
||||||
CreateTimeEntryBody,
|
|
||||||
Project,
|
|
||||||
Tag,
|
|
||||||
} from '@/packages/api/src';
|
|
||||||
import TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
|
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 { useClientsStore } from '@/utils/useClients';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
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<{
|
const page = usePage<{
|
||||||
auth: {
|
auth: {
|
||||||
@@ -60,8 +47,6 @@ const emit = defineEmits<{
|
|||||||
change: [];
|
change: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showManualTimeEntryModal = ref(false);
|
|
||||||
|
|
||||||
watch(isActive, () => {
|
watch(isActive, () => {
|
||||||
if (isActive.value) {
|
if (isActive.value) {
|
||||||
startLiveTimer();
|
startLiveTimer();
|
||||||
@@ -108,73 +93,14 @@ function switchToTimeEntryOrganization() {
|
|||||||
switchOrganization(currentTimeEntry.value.organization_id);
|
switchOrganization(currentTimeEntry.value.organization_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function createTag(tag: string): Promise<Tag | undefined> {
|
async function createTag(tag: string) {
|
||||||
return await useTagsStore().createTag(tag);
|
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 { tags } = storeToRefs(useTagsStore());
|
||||||
const { timeEntries } = storeToRefs(useTimeEntriesStore());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<TimeTrackerRunningInDifferentOrganizationOverlay
|
<TimeTrackerRunningInDifferentOrganizationOverlay
|
||||||
@@ -183,36 +109,24 @@ const { timeEntries } = storeToRefs(useTimeEntriesStore());
|
|||||||
switchToTimeEntryOrganization
|
switchToTimeEntryOrganization
|
||||||
"></TimeTrackerRunningInDifferentOrganizationOverlay>
|
"></TimeTrackerRunningInDifferentOrganizationOverlay>
|
||||||
|
|
||||||
<div class="flex w-full items-center gap-2">
|
<TimeTrackerControls
|
||||||
<div class="flex w-full items-center gap-2">
|
v-model:current-time-entry="currentTimeEntry"
|
||||||
<div class="flex-1">
|
v-model:live-timer="now"
|
||||||
<TimeTrackerControls
|
:create-project
|
||||||
v-model:current-time-entry="currentTimeEntry"
|
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||||
v-model:live-timer="now"
|
:can-create-project="canCreateProjects()"
|
||||||
:create-project
|
:create-client
|
||||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
:clients
|
||||||
:can-create-project="canCreateProjects()"
|
:tags
|
||||||
:create-client
|
:tasks
|
||||||
:clients
|
:projects
|
||||||
:tags
|
:create-tag
|
||||||
:tasks
|
:is-active
|
||||||
:projects
|
:currency="getOrganizationCurrencyString()"
|
||||||
:time-entries
|
@start-live-timer="startLiveTimer"
|
||||||
:create-tag
|
@stop-live-timer="stopLiveTimer"
|
||||||
:is-active
|
@start-timer="setActiveState(true)"
|
||||||
:currency="getOrganizationCurrencyString()"
|
@stop-timer="setActiveState(false)"
|
||||||
@start-live-timer="startLiveTimer"
|
@update-time-entry="updateTimeEntry"></TimeTrackerControls>
|
||||||
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
import { router, usePage } from '@inertiajs/vue3';
|
||||||
import type { Organization, User } from '@/types/models';
|
import type { Organization, User } from '@/types/models';
|
||||||
import {
|
import DropdownLink from '@/Components/DropdownLink.vue';
|
||||||
DropdownMenu,
|
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
} from '@/Components/ui/dropdown-menu';
|
|
||||||
import {
|
|
||||||
UserCircleIcon,
|
|
||||||
KeyIcon,
|
|
||||||
ArrowLeftOnRectangleIcon,
|
|
||||||
ChatBubbleLeftRightIcon,
|
|
||||||
} from '@heroicons/vue/24/solid';
|
|
||||||
import { openFeedback } from '@/utils/feedback';
|
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
has_services_extension?: boolean;
|
|
||||||
has_billing_extension?: boolean;
|
|
||||||
jetstream: {
|
jetstream: {
|
||||||
canCreateTeams: boolean;
|
canCreateTeams: boolean;
|
||||||
hasTeamFeatures: boolean;
|
hasTeamFeatures: boolean;
|
||||||
@@ -37,58 +23,60 @@ const logout = () => {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="ms-3 relative">
|
||||||
<DropdownMenu>
|
<Dropdown align="center" width="48">
|
||||||
<DropdownMenuTrigger
|
<template #trigger>
|
||||||
class="flex text-sm border-2 outline-none border-transparent rounded-full focus-visible:ring-2 focus-visible:ring-ring transition"
|
<button
|
||||||
as-child>
|
v-if="page.props.jetstream.managesProfilePhotos"
|
||||||
<button data-testid="current_user_button">
|
data-testid="current_user_button"
|
||||||
|
class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
|
||||||
<img
|
<img
|
||||||
class="h-7 w-7 rounded-full object-cover"
|
class="h-8 w-8 rounded-full object-cover"
|
||||||
:src="page.props.auth.user.profile_photo_url"
|
:src="page.props.auth.user.profile_photo_url"
|
||||||
:alt="page.props.auth.user.name" />
|
:alt="page.props.auth.user.name" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="center" class="max-w-48">
|
|
||||||
<DropdownMenuLabel>Manage Account</DropdownMenuLabel>
|
|
||||||
|
|
||||||
<DropdownMenuItem as-child>
|
<span v-else class="inline-flex rounded-md">
|
||||||
<Link
|
|
||||||
:href="route('profile.show')"
|
|
||||||
class="inline-flex items-center gap-2.5 w-full">
|
|
||||||
<UserCircleIcon class="w-5 h-5 text-icon-default" />
|
|
||||||
<span>Profile Settings</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem v-if="page.props.jetstream.hasApiFeatures" as-child>
|
|
||||||
<Link
|
|
||||||
:href="route('api-tokens.index')"
|
|
||||||
class="inline-flex items-center gap-2.5 w-full">
|
|
||||||
<KeyIcon class="w-5 h-5 text-icon-default" />
|
|
||||||
<span>API Tokens</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem v-if="page.props.has_services_extension" as-child>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2.5 w-full"
|
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:bg-gray-50 active:bg-gray-50 transition ease-in-out duration-150">
|
||||||
@click="openFeedback">
|
{{ page.props.auth.user.name }}
|
||||||
<ChatBubbleLeftRightIcon class="w-5 h-5 text-icon-default" />
|
|
||||||
<span>Feedback</span>
|
|
||||||
</button>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<form class="w-full" @submit.prevent="logout">
|
<svg
|
||||||
<DropdownMenuItem as-child class="inline-flex items-center gap-2.5 w-full">
|
class="ms-2 -me-0.5 h-4 w-4"
|
||||||
<button type="submit" data-testid="logout_button">
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<ArrowLeftOnRectangleIcon class="w-5 h-5 text-icon-default" />
|
fill="none"
|
||||||
<span>Log Out</span>
|
viewBox="0 0 24 24"
|
||||||
</button>
|
stroke-width="1.5"
|
||||||
</DropdownMenuItem>
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<!-- Account Management -->
|
||||||
|
<div class="block px-4 py-2 text-xs text-gray-400">Manage Account</div>
|
||||||
|
|
||||||
|
<DropdownLink :href="route('profile.show')"> Profile </DropdownLink>
|
||||||
|
|
||||||
|
<DropdownLink
|
||||||
|
v-if="page.props.jetstream.hasApiFeatures"
|
||||||
|
:href="route('api-tokens.index')">
|
||||||
|
API Tokens
|
||||||
|
</DropdownLink>
|
||||||
|
|
||||||
|
<div class="border-t border-card-border" />
|
||||||
|
|
||||||
|
<!-- Authentication -->
|
||||||
|
<form @submit.prevent="logout">
|
||||||
|
<DropdownLink as="button" data-testid="logout_button"> Log Out </DropdownLink>
|
||||||
</form>
|
</form>
|
||||||
</DropdownMenuContent>
|
</template>
|
||||||
</DropdownMenu>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { buttonVariants } from '@/packages/ui/src';
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui';
|
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { buttonVariants } from '@/packages/ui/src';
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui';
|
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'reka-ui';
|
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||||
import { Button } from '@/packages/ui/src';
|
import { Button } from '@/Components/ui/button';
|
||||||
import { Calendar } from '@/Components/ui/calendar';
|
import { Calendar } from '@/Components/ui/calendar';
|
||||||
import { CalendarIcon, XIcon } from 'lucide-vue-next';
|
import { CalendarIcon, XIcon } from 'lucide-vue-next';
|
||||||
import { formatDate } from '@/packages/ui/src/utils/time';
|
import { formatDate } from '@/packages/ui/src/utils/time';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cn, buttonVariants } from '@/packages/ui/src/index';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import { ChevronRight } from 'lucide-vue-next';
|
import { ChevronRight } from 'lucide-vue-next';
|
||||||
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'reka-ui';
|
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import { ChevronLeft } from 'lucide-vue-next';
|
import { ChevronLeft } from 'lucide-vue-next';
|
||||||
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'reka-ui';
|
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
|||||||
<template>
|
<template>
|
||||||
<DropdownMenuLabel
|
<DropdownMenuLabel
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
:class="cn('block px-2 py-2 text-xs text-gray-400', inset && 'pl-8', props.class)">
|
:class="cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)">
|
||||||
<slot />
|
<slot />
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import {
|
import {
|
||||||
RangeCalendarCellTrigger,
|
RangeCalendarCellTrigger,
|
||||||
type RangeCalendarCellTriggerProps,
|
type RangeCalendarCellTriggerProps,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import { ChevronRight } from 'lucide-vue-next';
|
import { ChevronRight } from 'lucide-vue-next';
|
||||||
import { RangeCalendarNext, type RangeCalendarNextProps, useForwardProps } from 'reka-ui';
|
import { RangeCalendarNext, type RangeCalendarNextProps, useForwardProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/Components/ui/button';
|
||||||
import { ChevronLeft } from 'lucide-vue-next';
|
import { ChevronLeft } from 'lucide-vue-next';
|
||||||
import { RangeCalendarPrev, type RangeCalendarPrevProps, useForwardProps } from 'reka-ui';
|
import { RangeCalendarPrev, type RangeCalendarPrevProps, useForwardProps } from 'reka-ui';
|
||||||
import { computed, type HTMLAttributes } from 'vue';
|
import { computed, type HTMLAttributes } from 'vue';
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
|
|||||||
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
||||||
import {
|
import {
|
||||||
Bars3Icon,
|
Bars3Icon,
|
||||||
CalendarIcon,
|
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
@@ -40,19 +39,14 @@ import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
|||||||
import { fetchToken, isTokenValid } from '@/utils/session';
|
import { fetchToken, isTokenValid } from '@/utils/session';
|
||||||
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
||||||
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
||||||
import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';
|
|
||||||
import { useTheme } from '@/utils/theme';
|
import { useTheme } from '@/utils/theme';
|
||||||
import { useQuery } from '@tanstack/vue-query';
|
import { useQuery } from '@tanstack/vue-query';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
import { Button } from '@/packages/ui/src';
|
|
||||||
import { openFeedback } from '@/utils/feedback';
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
title: String,
|
title: String,
|
||||||
mainClass: String,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const showSidebarMenu = ref(false);
|
const showSidebarMenu = ref(false);
|
||||||
@@ -96,8 +90,8 @@ onMounted(async () => {
|
|||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
has_services_extension?: boolean;
|
|
||||||
auth: {
|
auth: {
|
||||||
user: User;
|
user: User;
|
||||||
};
|
};
|
||||||
@@ -108,7 +102,7 @@ const page = usePage<{
|
|||||||
<div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary">
|
<div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary">
|
||||||
<div
|
<div
|
||||||
:class="{
|
:class="{
|
||||||
'!flex bg-default-background w-full z-30': showSidebarMenu,
|
'!flex bg-default-background w-full z-[9999999999]': showSidebarMenu,
|
||||||
}"
|
}"
|
||||||
class="flex-shrink-0 h-screen hidden fixed w-[230px] 2xl:w-[250px] px-2.5 2xl:px-3 py-4 lg:flex flex-col justify-between">
|
class="flex-shrink-0 h-screen hidden fixed w-[230px] 2xl:w-[250px] px-2.5 2xl:px-3 py-4 lg:flex flex-col justify-between">
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
@@ -137,11 +131,6 @@ const page = usePage<{
|
|||||||
:icon="ClockIcon"
|
:icon="ClockIcon"
|
||||||
:current="route().current('time')"
|
:current="route().current('time')"
|
||||||
:href="route('time')"></NavigationSidebarItem>
|
:href="route('time')"></NavigationSidebarItem>
|
||||||
<NavigationSidebarItem
|
|
||||||
title="Calendar"
|
|
||||||
:icon="CalendarIcon"
|
|
||||||
:current="route().current('calendar')"
|
|
||||||
:href="route('calendar')"></NavigationSidebarItem>
|
|
||||||
<NavigationSidebarItem
|
<NavigationSidebarItem
|
||||||
title="Reporting"
|
title="Reporting"
|
||||||
:icon="ChartBarIcon"
|
:icon="ChartBarIcon"
|
||||||
@@ -244,44 +233,35 @@ const page = usePage<{
|
|||||||
<div class="justify-self-end">
|
<div class="justify-self-end">
|
||||||
<UpdateSidebarNotification></UpdateSidebarNotification>
|
<UpdateSidebarNotification></UpdateSidebarNotification>
|
||||||
<ul
|
<ul
|
||||||
class="border-t border-default-background-separator pt-3 gap-1 pr-2 flex justify-between items-center">
|
class="border-t border-default-background-separator pt-3 flex justify-between pr-4 items-center">
|
||||||
<UserSettingsIcon></UserSettingsIcon>
|
|
||||||
|
|
||||||
<NavigationSidebarItem
|
<NavigationSidebarItem
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
title="Profile Settings"
|
title="Profile Settings"
|
||||||
:icon="Cog6ToothIcon"
|
:icon="Cog6ToothIcon"
|
||||||
:href="route('profile.show')"></NavigationSidebarItem>
|
:href="route('profile.show')"></NavigationSidebarItem>
|
||||||
|
|
||||||
<Button
|
<UserSettingsIcon></UserSettingsIcon>
|
||||||
v-if="page.props.has_services_extension"
|
|
||||||
variant="outline"
|
|
||||||
size="xs"
|
|
||||||
class="rounded-full ml-2 flex h-6 w-6 items-center text-xs text-icon-default justify-center"
|
|
||||||
@click="openFeedback">
|
|
||||||
?
|
|
||||||
</Button>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 lg:ml-[230px] 2xl:ml-[250px] min-w-0">
|
<div class="flex-1 lg:ml-[230px] 2xl:ml-[250px] min-w-0">
|
||||||
<div
|
<div
|
||||||
class="h-screen overflow-y-auto flex flex-col bg-default-background border-l border-default-background-separator">
|
class="lg:hidden w-full px-3 py-1 border-b border-b-default-background-separator text-text-secondary flex justify-between items-center">
|
||||||
<div
|
<Bars3Icon
|
||||||
class="lg:hidden w-full px-3 py-1 border-b border-b-default-background-separator text-text-secondary flex justify-between items-center">
|
class="w-7 text-text-secondary"
|
||||||
<Bars3Icon
|
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
|
||||||
class="w-7 text-text-secondary"
|
<OrganizationSwitcher></OrganizationSwitcher>
|
||||||
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
|
</div>
|
||||||
<OrganizationSwitcher></OrganizationSwitcher>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Head :title="title" />
|
<Head :title="title" />
|
||||||
|
|
||||||
|
<Banner />
|
||||||
|
<BillingBanner v-if="isBillingActivated()" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="min-h-screen flex flex-col bg-default-background border-l border-default-background-separator">
|
||||||
<!-- Page Heading -->
|
<!-- Page Heading -->
|
||||||
<Banner />
|
|
||||||
<BillingBanner v-if="isBillingActivated()" />
|
|
||||||
|
|
||||||
<header
|
<header
|
||||||
v-if="$slots.header"
|
v-if="$slots.header"
|
||||||
class="bg-default-background border-b border-default-background-separator shadow">
|
class="bg-default-background border-b border-default-background-separator shadow">
|
||||||
@@ -293,7 +273,7 @@ const page = usePage<{
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<main :class="twMerge('pb-28 relative flex-1', mainClass)">
|
<main class="pb-28 flex-1">
|
||||||
<div
|
<div
|
||||||
v-if="isOrganizationLoading"
|
v-if="isOrganizationLoading"
|
||||||
class="flex items-center justify-center h-screen">
|
class="flex items-center justify-center h-screen">
|
||||||
@@ -305,5 +285,4 @@ const page = usePage<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NotificationContainer></NotificationContainer>
|
<NotificationContainer></NotificationContainer>
|
||||||
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
|
||||||
import {
|
|
||||||
api,
|
|
||||||
type Client,
|
|
||||||
type CreateClientBody,
|
|
||||||
type CreateProjectBody,
|
|
||||||
type Project,
|
|
||||||
type TimeEntryResponse,
|
|
||||||
} from '@/packages/api/src';
|
|
||||||
import { getCurrentOrganizationId, 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>
|
|
||||||
@@ -369,7 +369,6 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
:tags="tags"
|
:tags="tags"
|
||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
:clients="clients"
|
:clients="clients"
|
||||||
class="border-b border-default-background-separator"
|
|
||||||
:update-time-entries="
|
:update-time-entries="
|
||||||
(args) =>
|
(args) =>
|
||||||
updateTimeEntries(
|
updateTimeEntries(
|
||||||
@@ -400,7 +399,6 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
||||||
:delete-time-entry="() => deleteTimeEntries([entry])"
|
:delete-time-entry="() => deleteTimeEntries([entry])"
|
||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
:duplicate-time-entry="() => createTimeEntry(entry)"
|
|
||||||
:members="members"
|
:members="members"
|
||||||
show-date
|
show-date
|
||||||
show-member
|
show-member
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ interface FormValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const store = useOrganizationStore();
|
const store = useOrganizationStore();
|
||||||
const { updateOrganization } = store;
|
const { fetchOrganization, updateOrganization } = store;
|
||||||
const { organization } = storeToRefs(store);
|
const { organization } = storeToRefs(store);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ const mutation = useMutation({
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await fetchOrganization();
|
||||||
if (organization.value) {
|
if (organization.value) {
|
||||||
form.value = {
|
form.value = {
|
||||||
number_format: organization.value.number_format as NumberFormat,
|
number_format: organization.value.number_format as NumberFormat,
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
<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 }>({
|
|
||||||
prevent_overlapping_time_entries: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
form.value.prevent_overlapping_time_entries =
|
|
||||||
organization.value?.prevent_overlapping_time_entries ?? 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<FormSection>
|
|
||||||
<template #title>Time Entry Settings</template>
|
|
||||||
<template #description>
|
|
||||||
Disallow overlapping time entries for members of this organization. When enabled, users
|
|
||||||
cannot create new time entries that overlap with their existing ones. This only affects
|
|
||||||
newly created entries.
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #form>
|
|
||||||
<div class="col-span-6">
|
|
||||||
<div class="col-span-6 sm:col-span-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>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<PrimaryButton :disabled="mutation.isPending.value" @click="submit">Save</PrimaryButton>
|
|
||||||
</template>
|
|
||||||
</FormSection>
|
|
||||||
</template>
|
|
||||||
@@ -8,25 +8,12 @@ import type { Permissions, Role } from '@/types/jetstream';
|
|||||||
import { canUpdateOrganization } from '@/utils/permissions';
|
import { canUpdateOrganization } from '@/utils/permissions';
|
||||||
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
|
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
|
||||||
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.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<{
|
defineProps<{
|
||||||
team: Organization;
|
team: Organization;
|
||||||
availableRoles: Role[];
|
availableRoles: Role[];
|
||||||
permissions: Permissions;
|
permissions: Permissions;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const loading = ref(true);
|
|
||||||
const orgStore = useOrganizationStore();
|
|
||||||
const { organization } = storeToRefs(orgStore);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await orgStore.fetchOrganization();
|
|
||||||
loading.value = false;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -39,25 +26,17 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
|
||||||
<div v-if="loading || !organization" class="py-16 text-center text-text-secondary">
|
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
||||||
Loading organization settings...
|
|
||||||
</div>
|
|
||||||
<template v-else>
|
|
||||||
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
|
||||||
|
|
||||||
<SectionBorder />
|
<SectionBorder />
|
||||||
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
|
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
|
||||||
<SectionBorder />
|
<SectionBorder />
|
||||||
|
|
||||||
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
|
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
|
||||||
<SectionBorder />
|
<SectionBorder />
|
||||||
|
|
||||||
<OrganizationTimeEntrySettings v-if="canUpdateOrganization()" />
|
<template v-if="permissions.canDeleteTeam && !team.personal_team">
|
||||||
<SectionBorder />
|
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||||
|
|
||||||
<template v-if="permissions.canDeleteTeam && !team.personal_team">
|
|
||||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
|
||||||
</template>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import type {
|
|||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import { useElementVisibility } from '@vueuse/core';
|
import { useElementVisibility } from '@vueuse/core';
|
||||||
import { ClockIcon } from '@heroicons/vue/20/solid';
|
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 LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
import { useTasksStore } from '@/utils/useTasks';
|
import { useTasksStore } from '@/utils/useTasks';
|
||||||
@@ -22,6 +24,7 @@ import { useProjectsStore } from '@/utils/useProjects';
|
|||||||
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
|
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
|
||||||
import { useTagsStore } from '@/utils/useTags';
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
|
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
||||||
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
|
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
|
||||||
@@ -70,6 +73,7 @@ onMounted(async () => {
|
|||||||
await timeEntriesStore.fetchTimeEntries();
|
await timeEntriesStore.fetchTimeEntries();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showManualTimeEntryModal = ref(false);
|
||||||
const projectStore = useProjectsStore();
|
const projectStore = useProjectsStore();
|
||||||
const { projects } = storeToRefs(projectStore);
|
const { projects } = storeToRefs(projectStore);
|
||||||
const taskStore = useTasksStore();
|
const taskStore = useTasksStore();
|
||||||
@@ -101,9 +105,33 @@ function deleteSelected() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<AppLayout title="Dashboard" data-testid="time_view">
|
||||||
<MainContainer class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
<MainContainer class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
||||||
<TimeTracker></TimeTracker>
|
<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>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<TimeEntryMassActionRow
|
<TimeEntryMassActionRow
|
||||||
:selected-time-entries="selectedTimeEntries"
|
:selected-time-entries="selectedTimeEntries"
|
||||||
@@ -116,7 +144,6 @@ function deleteSelected() {
|
|||||||
:tags="tags"
|
:tags="tags"
|
||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
:clients="clients"
|
:clients="clients"
|
||||||
class="border-t border-default-background-separator"
|
|
||||||
:update-time-entries="
|
:update-time-entries="
|
||||||
(args) =>
|
(args) =>
|
||||||
updateTimeEntries(
|
updateTimeEntries(
|
||||||
|
|||||||
4
resources/js/packages/api/package-lock.json
generated
4
resources/js/packages/api/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@solidtime/api",
|
"name": "@solidtime/api",
|
||||||
"version": "0.0.5",
|
"version": "0.0.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@solidtime/api",
|
"name": "@solidtime/api",
|
||||||
"version": "0.0.5",
|
"version": "0.0.3",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@solidtime/api",
|
"name": "@solidtime/api",
|
||||||
"version": "0.0.5",
|
"version": "0.0.4",
|
||||||
"description": "Package containing the solidtime api client and type declarations",
|
"description": "Package containing the solidtime api client and type declarations",
|
||||||
"main": "./dist/solidtime-api.umd.cjs",
|
"main": "./dist/solidtime-api.umd.cjs",
|
||||||
"module": "./dist/solidtime-api.js",
|
"module": "./dist/solidtime-api.js",
|
||||||
|
|||||||
@@ -36,14 +36,20 @@ const ClientResource = z
|
|||||||
const ClientCollection = z.array(ClientResource);
|
const ClientCollection = z.array(ClientResource);
|
||||||
const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
||||||
const ClientUpdateRequest = z
|
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();
|
.passthrough();
|
||||||
const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();
|
const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();
|
||||||
const InvitationResource = z
|
const InvitationResource = z
|
||||||
.object({ id: z.string(), email: z.string(), role: z.string() })
|
.object({ id: z.string(), email: z.string(), role: z.string() })
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const InvitationStoreRequest = z
|
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();
|
.passthrough();
|
||||||
const InvoiceResource = z
|
const InvoiceResource = z
|
||||||
.object({
|
.object({
|
||||||
@@ -91,7 +97,6 @@ const InvoiceStoreRequest = z
|
|||||||
billing_period_end: z.union([z.string(), z.null()]).optional(),
|
billing_period_end: z.union([z.string(), z.null()]).optional(),
|
||||||
reference: z.string(),
|
reference: z.string(),
|
||||||
currency: z.string(),
|
currency: z.string(),
|
||||||
payment_iban: z.union([z.string(), z.null()]).optional(),
|
|
||||||
tax_rate: z.number().int().gte(0).lte(2147483647).optional(),
|
tax_rate: z.number().int().gte(0).lte(2147483647).optional(),
|
||||||
discount_amount: z.number().int().gte(0).lte(9223372036854776000).optional(),
|
discount_amount: z.number().int().gte(0).lte(9223372036854776000).optional(),
|
||||||
discount_type: InvoiceDiscountType.optional(),
|
discount_type: InvoiceDiscountType.optional(),
|
||||||
@@ -156,7 +161,6 @@ const DetailedInvoiceResource = z
|
|||||||
discount_type: z.string(),
|
discount_type: z.string(),
|
||||||
discount_amount: z.number().int(),
|
discount_amount: z.number().int(),
|
||||||
tax_rate: z.number().int(),
|
tax_rate: z.number().int(),
|
||||||
payment_iban: z.string(),
|
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
currency: z.string(),
|
currency: z.string(),
|
||||||
date: z.string(),
|
date: z.string(),
|
||||||
@@ -202,7 +206,6 @@ const InvoiceUpdateRequest = z
|
|||||||
billing_period_end: z.union([z.string(), z.null()]),
|
billing_period_end: z.union([z.string(), z.null()]),
|
||||||
reference: z.string(),
|
reference: z.string(),
|
||||||
currency: z.string(),
|
currency: z.string(),
|
||||||
payment_iban: z.union([z.string(), z.null()]),
|
|
||||||
tax_rate: z.number().int().gte(0).lte(2147483647),
|
tax_rate: z.number().int().gte(0).lte(2147483647),
|
||||||
discount_amount: z.number().int().gte(0).lte(9223372036854776000),
|
discount_amount: z.number().int().gte(0).lte(9223372036854776000),
|
||||||
discount_type: InvoiceDiscountType,
|
discount_type: InvoiceDiscountType,
|
||||||
@@ -317,7 +320,6 @@ const OrganizationResource = z
|
|||||||
is_personal: z.boolean(),
|
is_personal: z.boolean(),
|
||||||
billable_rate: z.union([z.number(), z.null()]),
|
billable_rate: z.union([z.number(), z.null()]),
|
||||||
employees_can_see_billable_rates: z.boolean(),
|
employees_can_see_billable_rates: z.boolean(),
|
||||||
prevent_overlapping_time_entries: z.boolean(),
|
|
||||||
currency: z.string(),
|
currency: z.string(),
|
||||||
currency_symbol: z.string(),
|
currency_symbol: z.string(),
|
||||||
number_format: NumberFormat,
|
number_format: NumberFormat,
|
||||||
@@ -332,7 +334,6 @@ const OrganizationUpdateRequest = z
|
|||||||
name: z.string().max(255),
|
name: z.string().max(255),
|
||||||
billable_rate: z.union([z.number(), z.null()]),
|
billable_rate: z.union([z.number(), z.null()]),
|
||||||
employees_can_see_billable_rates: z.boolean(),
|
employees_can_see_billable_rates: z.boolean(),
|
||||||
prevent_overlapping_time_entries: z.boolean(),
|
|
||||||
number_format: NumberFormat,
|
number_format: NumberFormat,
|
||||||
currency_format: CurrencyFormat,
|
currency_format: CurrencyFormat,
|
||||||
date_format: DateFormat,
|
date_format: DateFormat,
|
||||||
@@ -387,7 +388,10 @@ const ProjectMemberResource = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const ProjectMemberStoreRequest = z
|
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();
|
.passthrough();
|
||||||
const ProjectMemberUpdateRequest = z
|
const ProjectMemberUpdateRequest = z
|
||||||
.object({ billable_rate: z.union([z.number(), z.null()]) })
|
.object({ billable_rate: z.union([z.number(), z.null()]) })
|
||||||
@@ -416,7 +420,6 @@ const TimeEntryAggregationType = z.enum([
|
|||||||
'client',
|
'client',
|
||||||
'billable',
|
'billable',
|
||||||
'description',
|
'description',
|
||||||
'tag',
|
|
||||||
]);
|
]);
|
||||||
const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);
|
const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);
|
||||||
const Weekday = z.enum([
|
const Weekday = z.enum([
|
||||||
@@ -428,7 +431,6 @@ const Weekday = z.enum([
|
|||||||
'saturday',
|
'saturday',
|
||||||
'sunday',
|
'sunday',
|
||||||
]);
|
]);
|
||||||
const TimeEntryRoundingType = z.enum(['up', 'down', 'nearest']);
|
|
||||||
const ReportStoreRequest = z
|
const ReportStoreRequest = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().max(255),
|
name: z.string().max(255),
|
||||||
@@ -451,8 +453,6 @@ const ReportStoreRequest = z
|
|||||||
history_group: TimeEntryAggregationTypeInterval,
|
history_group: TimeEntryAggregationTypeInterval,
|
||||||
week_start: Weekday.optional(),
|
week_start: Weekday.optional(),
|
||||||
timezone: z.union([z.string(), z.null()]).optional(),
|
timezone: z.union([z.string(), z.null()]).optional(),
|
||||||
rounding_type: TimeEntryRoundingType.optional(),
|
|
||||||
rounding_minutes: z.union([z.number(), z.null()]).optional(),
|
|
||||||
})
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
})
|
})
|
||||||
@@ -479,8 +479,6 @@ const DetailedReportResource = z
|
|||||||
project_ids: z.union([z.array(z.string()), z.null()]),
|
project_ids: z.union([z.array(z.string()), z.null()]),
|
||||||
tag_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()]),
|
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(),
|
.passthrough(),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
@@ -594,7 +592,12 @@ const DetailedWithDataReportResource = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const TagResource = z
|
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();
|
.passthrough();
|
||||||
const TagCollection = z.array(TagResource);
|
const TagCollection = z.array(TagResource);
|
||||||
const TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
const TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
||||||
@@ -626,7 +629,6 @@ const TaskUpdateRequest = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const start = z.union([z.string(), z.null()]).optional();
|
const start = z.union([z.string(), z.null()]).optional();
|
||||||
const rounding_minutes = z.union([z.number(), z.null()]).optional();
|
|
||||||
const TimeEntryResource = z
|
const TimeEntryResource = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -747,7 +749,6 @@ export const schemas = {
|
|||||||
TimeEntryAggregationType,
|
TimeEntryAggregationType,
|
||||||
TimeEntryAggregationTypeInterval,
|
TimeEntryAggregationTypeInterval,
|
||||||
Weekday,
|
Weekday,
|
||||||
TimeEntryRoundingType,
|
|
||||||
ReportStoreRequest,
|
ReportStoreRequest,
|
||||||
DetailedReportResource,
|
DetailedReportResource,
|
||||||
ReportUpdateRequest,
|
ReportUpdateRequest,
|
||||||
@@ -760,7 +761,6 @@ export const schemas = {
|
|||||||
TaskStoreRequest,
|
TaskStoreRequest,
|
||||||
TaskUpdateRequest,
|
TaskUpdateRequest,
|
||||||
start,
|
start,
|
||||||
rounding_minutes,
|
|
||||||
TimeEntryResource,
|
TimeEntryResource,
|
||||||
TimeEntryStoreRequest,
|
TimeEntryStoreRequest,
|
||||||
TimeEntryUpdateMultipleRequest,
|
TimeEntryUpdateMultipleRequest,
|
||||||
@@ -790,7 +790,13 @@ const endpoints = makeApi([
|
|||||||
alias: 'getCurrencies',
|
alias: 'getCurrencies',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
response: z.array(
|
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()
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -862,7 +868,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1157,7 +1166,13 @@ const endpoints = makeApi([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.array(
|
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: [
|
errors: [
|
||||||
{
|
{
|
||||||
@@ -1220,7 +1235,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1263,7 +1281,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1311,7 +1332,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1339,7 +1363,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1377,7 +1405,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1433,7 +1465,7 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
schema: z.union([
|
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(),
|
z.object({ message: z.string() }).passthrough(),
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1455,7 +1487,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1476,7 +1511,11 @@ const endpoints = makeApi([
|
|||||||
.object({
|
.object({
|
||||||
data: z.array(
|
data: z.array(
|
||||||
z
|
z
|
||||||
.object({ key: z.string(), name: z.string(), description: z.string() })
|
.object({
|
||||||
|
key: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -1564,7 +1603,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1592,7 +1634,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1614,7 +1660,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1760,7 +1809,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1803,7 +1855,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1846,7 +1901,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1930,7 +1988,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1995,13 +2056,6 @@ const endpoints = makeApi([
|
|||||||
],
|
],
|
||||||
response: z.object({ download_link: z.string() }).passthrough(),
|
response: z.object({ download_link: z.string() }).passthrough(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
description: `API exception`,
|
|
||||||
schema: z
|
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
description: `Unauthenticated`,
|
description: `Unauthenticated`,
|
||||||
@@ -2021,7 +2075,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2045,13 +2102,6 @@ const endpoints = makeApi([
|
|||||||
],
|
],
|
||||||
response: z.object({ download_link: z.string() }).passthrough(),
|
response: z.object({ download_link: z.string() }).passthrough(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
description: `API exception`,
|
|
||||||
schema: z
|
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
description: `Unauthenticated`,
|
description: `Unauthenticated`,
|
||||||
@@ -2097,7 +2147,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2119,7 +2173,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2189,7 +2246,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2222,7 +2282,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2244,7 +2308,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2277,7 +2344,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2299,7 +2370,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2327,7 +2401,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2370,7 +2448,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2433,7 +2515,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2549,7 +2634,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2592,7 +2680,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2676,7 +2767,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2704,7 +2798,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2820,7 +2918,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2842,7 +2944,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2948,7 +3053,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3032,7 +3140,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3142,7 +3253,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3190,7 +3304,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3218,7 +3335,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3313,7 +3434,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3356,7 +3480,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3404,7 +3531,10 @@ const endpoints = makeApi([
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3432,7 +3562,11 @@ const endpoints = makeApi([
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3505,16 +3639,6 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.enum(['true', 'false']).optional(),
|
schema: z.enum(['true', 'false']).optional(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'rounding_type',
|
|
||||||
type: 'Query',
|
|
||||||
schema: z.enum(['up', 'down', 'nearest']).optional(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'rounding_minutes',
|
|
||||||
type: 'Query',
|
|
||||||
schema: rounding_minutes,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'user_id',
|
name: 'user_id',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -3572,7 +3696,10 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3600,7 +3727,11 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3622,7 +3753,10 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3665,7 +3799,10 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3708,7 +3845,10 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3741,7 +3881,11 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3763,7 +3907,10 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3833,7 +3980,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
'client',
|
'client',
|
||||||
'billable',
|
'billable',
|
||||||
'description',
|
'description',
|
||||||
'tag',
|
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
},
|
},
|
||||||
@@ -3852,7 +3998,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
'client',
|
'client',
|
||||||
'billable',
|
'billable',
|
||||||
'description',
|
'description',
|
||||||
'tag',
|
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
},
|
},
|
||||||
@@ -3891,16 +4036,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.enum(['true', 'false']).optional(),
|
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',
|
name: 'member_ids',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -3985,7 +4120,10 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -4020,7 +4158,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
'client',
|
'client',
|
||||||
'billable',
|
'billable',
|
||||||
'description',
|
'description',
|
||||||
'tag',
|
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4037,7 +4174,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
'client',
|
'client',
|
||||||
'billable',
|
'billable',
|
||||||
'description',
|
'description',
|
||||||
'tag',
|
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4085,16 +4221,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.enum(['true', 'false']).optional(),
|
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',
|
name: 'member_ids',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -4130,7 +4256,11 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4152,7 +4282,10 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -4213,16 +4346,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.enum(['true', 'false']).optional(),
|
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',
|
name: 'member_ids',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -4253,7 +4376,11 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4275,7 +4402,10 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -4357,7 +4487,11 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4374,7 +4508,10 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
status: 422,
|
status: 422,
|
||||||
description: `Validation error`,
|
description: `Validation error`,
|
||||||
schema: z
|
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(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -4397,7 +4534,11 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4435,7 +4576,11 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: `API exception`,
|
description: `API exception`,
|
||||||
schema: z
|
schema: z
|
||||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
.object({
|
||||||
|
error: z.boolean(),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
1497
resources/js/packages/ui/package-lock.json
generated
1497
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",
|
"name": "@solidtime/ui",
|
||||||
"version": "0.0.13",
|
"version": "0.0.10",
|
||||||
"description": "Package containing the solidtime ui components",
|
"description": "Package containing the solidtime ui components",
|
||||||
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
||||||
"module": "./dist/solidtime-ui-lib.js",
|
"module": "./dist/solidtime-ui-lib.js",
|
||||||
@@ -21,14 +21,11 @@
|
|||||||
"default": "./dist/solidtime-ui-lib.umd.cjs"
|
"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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build && vue-tsc --emitDeclarationOnly",
|
"build": "vite build && vue-tsc --emitDeclarationOnly",
|
||||||
"watch": "vite build --watch",
|
|
||||||
"types": "vue-tsc ",
|
"types": "vue-tsc ",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
@@ -62,11 +59,8 @@
|
|||||||
"@heroicons/vue": "^2.1.5",
|
"@heroicons/vue": "^2.1.5",
|
||||||
"@vueuse/core": "^12.5.0",
|
"@vueuse/core": "^12.5.0",
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"parse-duration": "^2.0.1",
|
"parse-duration": "^2.0.1",
|
||||||
"reka-ui": "^2.2.0",
|
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss": "^3.1.0",
|
"tailwindcss": "^3.1.0",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<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,8 +10,7 @@ const props = withDefaults(
|
|||||||
icon?: Component;
|
icon?: Component;
|
||||||
size?: 'small' | 'base';
|
size?: 'small' | 'base';
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
// Accept any valid Vue class binding shape (string | object | array)
|
class?: string;
|
||||||
class?: Parameters<typeof twMerge>[0];
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
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>;
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, inject, type ComputedRef } from 'vue';
|
|
||||||
import { formatDate, formatHumanReadableDuration } from '../utils/time';
|
|
||||||
import type { Organization } from '@/packages/api/src';
|
|
||||||
import type { Dayjs } from 'dayjs';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
date: Dayjs;
|
|
||||||
totalMinutes?: number;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const totalSeconds = computed(() => (props.totalMinutes ?? 0) * 60);
|
|
||||||
|
|
||||||
// Injected organization for formatting settings
|
|
||||||
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
|
||||||
const intervalFormat = computed(() => organization?.value?.interval_format);
|
|
||||||
const numberFormat = computed(() => organization?.value?.number_format);
|
|
||||||
const dateFormat = computed(() => organization?.value?.date_format);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="fc-day-header-custom">
|
|
||||||
<div class="text-xs text-muted-foreground font-medium">
|
|
||||||
{{ date.format('ddd') }}
|
|
||||||
</div>
|
|
||||||
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
|
||||||
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
|
||||||
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, inject, type ComputedRef } from 'vue';
|
|
||||||
import { formatHumanReadableDuration, getDayJsInstance } from '../utils/time';
|
|
||||||
import type { Organization } from '@/packages/api/src';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
title: string;
|
|
||||||
projectName?: string | null;
|
|
||||||
taskName?: string | null;
|
|
||||||
clientName?: string | null;
|
|
||||||
durationSeconds?: number;
|
|
||||||
start?: string | Date | null;
|
|
||||||
end?: string | Date | null;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const effectiveDurationSeconds = computed(() => {
|
|
||||||
if (typeof props.durationSeconds === 'number') {
|
|
||||||
return props.durationSeconds;
|
|
||||||
}
|
|
||||||
if (props.start && props.end) {
|
|
||||||
const end = getDayJsInstance()(props.end as unknown as string | Date);
|
|
||||||
const start = getDayJsInstance()(props.start as unknown as string | Date);
|
|
||||||
const minutes = end.diff(start, 'minutes');
|
|
||||||
return minutes * 60;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
|
||||||
const intervalFormat = computed(() => organization?.value?.interval_format);
|
|
||||||
const numberFormat = computed(() => organization?.value?.number_format);
|
|
||||||
|
|
||||||
const formattedDuration = computed(() =>
|
|
||||||
formatHumanReadableDuration(
|
|
||||||
effectiveDurationSeconds.value,
|
|
||||||
intervalFormat.value,
|
|
||||||
numberFormat.value
|
|
||||||
)
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="text-2xs leading-tight px-0.5 py-1.5">
|
|
||||||
<div class="font-semibold">{{ title }}</div>
|
|
||||||
<div v-if="projectName" class="font-medium opacity-90">
|
|
||||||
{{ projectName }}
|
|
||||||
</div>
|
|
||||||
<div v-if="taskName" class="font-medium">
|
|
||||||
{{ taskName }}
|
|
||||||
</div>
|
|
||||||
<div v-if="clientName" class="opacity-85">
|
|
||||||
{{ clientName }}
|
|
||||||
</div>
|
|
||||||
<div class="opacity-90">
|
|
||||||
{{ formattedDuration }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,745 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import FullCalendar from '@fullcalendar/vue3';
|
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
|
||||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
|
||||||
import interactionPlugin from '@fullcalendar/interaction';
|
|
||||||
import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';
|
|
||||||
import {
|
|
||||||
computed,
|
|
||||||
ref,
|
|
||||||
watch,
|
|
||||||
inject,
|
|
||||||
type ComputedRef,
|
|
||||||
nextTick,
|
|
||||||
onMounted,
|
|
||||||
onActivated,
|
|
||||||
onUnmounted,
|
|
||||||
} from 'vue';
|
|
||||||
import chroma from 'chroma-js';
|
|
||||||
import { useCssVariable } from '@/utils/useCssVariable';
|
|
||||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
|
||||||
import { getUserTimezone, getWeekStart } from '../utils/settings';
|
|
||||||
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
|
||||||
import FullCalendarEventContent from './FullCalendarEventContent.vue';
|
|
||||||
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
|
||||||
import activityStatusPlugin, {
|
|
||||||
type ActivityPeriod,
|
|
||||||
renderActivityStatusBoxes,
|
|
||||||
} from './idleStatusPlugin';
|
|
||||||
import type {
|
|
||||||
TimeEntry,
|
|
||||||
Project,
|
|
||||||
Client,
|
|
||||||
Task,
|
|
||||||
CreateProjectBody,
|
|
||||||
CreateClientBody,
|
|
||||||
Tag,
|
|
||||||
Organization,
|
|
||||||
} from '@/packages/api/src';
|
|
||||||
import type { Dayjs } from 'dayjs';
|
|
||||||
|
|
||||||
type CalendarExtendedProps = { timeEntry: TimeEntry; isRunning?: boolean } & Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'dates-change', payload: { start: Date; end: Date }): void;
|
|
||||||
(e: 'refresh'): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
timeEntries: TimeEntry[];
|
|
||||||
projects: Project[];
|
|
||||||
tasks: Task[];
|
|
||||||
clients: Client[];
|
|
||||||
tags: Tag[];
|
|
||||||
activityPeriods?: ActivityPeriod[];
|
|
||||||
loading?: boolean;
|
|
||||||
|
|
||||||
// Permissions / feature flags
|
|
||||||
enableEstimatedTime: boolean;
|
|
||||||
currency: string;
|
|
||||||
canCreateProject: boolean;
|
|
||||||
|
|
||||||
createTimeEntry: (
|
|
||||||
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
|
|
||||||
) => Promise<void>;
|
|
||||||
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
|
|
||||||
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
|
|
||||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
|
||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
|
||||||
createTag: (name: string) => Promise<Tag | undefined>;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// Local component state
|
|
||||||
const newEventStart = ref<Dayjs | null>(null);
|
|
||||||
const newEventEnd = ref<Dayjs | null>(null);
|
|
||||||
const showCreateTimeEntryModal = ref<boolean>(false);
|
|
||||||
const showEditTimeEntryModal = ref<boolean>(false);
|
|
||||||
const selectedTimeEntry = ref<TimeEntry | null>(null);
|
|
||||||
|
|
||||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
|
||||||
|
|
||||||
// Reactive "now" for running time entry - updates every minute
|
|
||||||
const currentTime = ref(getDayJsInstance()());
|
|
||||||
let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
// Inject organization data for settings
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
|
||||||
|
|
||||||
// Helper function to convert week start to FullCalendar firstDay value
|
|
||||||
const getFirstDay = () => {
|
|
||||||
const weekStart = getWeekStart();
|
|
||||||
const weekStartMap: Record<string, number> = {
|
|
||||||
'sunday': 0,
|
|
||||||
'monday': 1,
|
|
||||||
'tuesday': 2,
|
|
||||||
'wednesday': 3,
|
|
||||||
'thursday': 4,
|
|
||||||
'friday': 5,
|
|
||||||
'saturday': 6,
|
|
||||||
};
|
|
||||||
return weekStartMap[weekStart] ?? 1; // Default to Monday if not found
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to get time format for slot labels
|
|
||||||
const getSlotLabelFormat = () => {
|
|
||||||
const timeFormat = organization?.value?.time_format || '24-hours';
|
|
||||||
if (timeFormat === '12-hours') {
|
|
||||||
return {
|
|
||||||
hour: 'numeric' as const,
|
|
||||||
hour12: true,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
hour: '2-digit' as const,
|
|
||||||
minute: '2-digit' as const,
|
|
||||||
hour12: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cssBackground = useCssVariable('--color-bg-background');
|
|
||||||
|
|
||||||
const events = computed(() => {
|
|
||||||
const themeBackground = (() => {
|
|
||||||
return cssBackground.value?.trim();
|
|
||||||
})();
|
|
||||||
return props.timeEntries?.map((timeEntry) => {
|
|
||||||
const isRunning = timeEntry.end === null;
|
|
||||||
const project = props.projects.find((p) => p.id === timeEntry.project_id);
|
|
||||||
const client = props.clients.find((c) => c.id === project?.client_id);
|
|
||||||
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
|
|
||||||
|
|
||||||
// For running entries, use current time as end
|
|
||||||
const effectiveEnd = isRunning ? currentTime.value : getDayJsInstance()(timeEntry.end!);
|
|
||||||
const duration = effectiveEnd.diff(getDayJsInstance()(timeEntry.start), 'minutes');
|
|
||||||
|
|
||||||
const title = timeEntry.description || 'No description';
|
|
||||||
|
|
||||||
const baseColor = project?.color || '#6B7280';
|
|
||||||
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
|
|
||||||
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
|
|
||||||
|
|
||||||
// For 0-duration events, display them with minimum visual duration but preserve actual duration
|
|
||||||
const startTime = getLocalizedDayJs(timeEntry.start);
|
|
||||||
const endTime =
|
|
||||||
duration === 0
|
|
||||||
? startTime.add(1, 'second') // Show as 1 second for minimal visibility
|
|
||||||
: isRunning
|
|
||||||
? getLocalizedDayJs(currentTime.value.toISOString())
|
|
||||||
: getLocalizedDayJs(timeEntry.end!);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: timeEntry.id,
|
|
||||||
start: startTime.format(),
|
|
||||||
end: endTime.format(),
|
|
||||||
title,
|
|
||||||
backgroundColor,
|
|
||||||
borderColor,
|
|
||||||
textColor: 'var(--foreground)',
|
|
||||||
// For running entries: disable dragging and resizing
|
|
||||||
startEditable: !isRunning,
|
|
||||||
classNames: isRunning ? ['running-entry'] : [],
|
|
||||||
extendedProps: {
|
|
||||||
timeEntry,
|
|
||||||
project,
|
|
||||||
client,
|
|
||||||
task,
|
|
||||||
duration,
|
|
||||||
isRunning,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Daily totals used in day header
|
|
||||||
const dailyTotals = computed(() => {
|
|
||||||
const totals: Record<string, number> = {};
|
|
||||||
props.timeEntries
|
|
||||||
.filter((entry) => entry.end !== null)
|
|
||||||
.forEach((entry) => {
|
|
||||||
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
|
|
||||||
const duration = getDayJsInstance()(entry.end!).diff(
|
|
||||||
getDayJsInstance()(entry.start),
|
|
||||||
'minutes'
|
|
||||||
);
|
|
||||||
totals[date] = (totals[date] || 0) + duration;
|
|
||||||
});
|
|
||||||
return totals;
|
|
||||||
});
|
|
||||||
|
|
||||||
function emitDatesChange(arg: DatesSetArg) {
|
|
||||||
emit('dates-change', { start: arg.start, end: arg.end });
|
|
||||||
// Render activity boxes after calendar view has been rendered
|
|
||||||
renderActivityBoxes();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDateSelect(arg: { start: Date; end: Date }) {
|
|
||||||
const startTime = getDayJsInstance()(arg.start.toISOString())
|
|
||||||
.utc()
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.utc();
|
|
||||||
const endTime = getDayJsInstance()(arg.end.toISOString())
|
|
||||||
.utc()
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.utc();
|
|
||||||
newEventStart.value = startTime;
|
|
||||||
newEventEnd.value = endTime;
|
|
||||||
showCreateTimeEntryModal.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEventClick(arg: EventClickArg) {
|
|
||||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
|
||||||
// Don't open edit modal for running time entries
|
|
||||||
if (ext.isRunning) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectedTimeEntry.value = ext.timeEntry;
|
|
||||||
showEditTimeEntryModal.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEventDrop(arg: EventDropArg) {
|
|
||||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
|
||||||
const timeEntry = ext.timeEntry;
|
|
||||||
if (!arg.event.start || !arg.event.end) return;
|
|
||||||
const updatedTimeEntry = {
|
|
||||||
...timeEntry,
|
|
||||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
|
||||||
.utc()
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.second(0)
|
|
||||||
.utc()
|
|
||||||
.format(),
|
|
||||||
end: getDayJsInstance()(arg.event.end.toISOString())
|
|
||||||
.utc()
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.second(0)
|
|
||||||
.utc()
|
|
||||||
.format(),
|
|
||||||
} as TimeEntry;
|
|
||||||
await props.updateTimeEntry(updatedTimeEntry);
|
|
||||||
emit('refresh');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEventResize(arg: EventChangeArg) {
|
|
||||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
|
||||||
const timeEntry = ext.timeEntry;
|
|
||||||
if (!arg.event.start || !arg.event.end) return;
|
|
||||||
const updatedTimeEntry = {
|
|
||||||
...timeEntry,
|
|
||||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
|
||||||
.utc()
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.second(0)
|
|
||||||
.utc()
|
|
||||||
.format(),
|
|
||||||
// Preserve null end for running entries
|
|
||||||
end: ext.isRunning
|
|
||||||
? null
|
|
||||||
: getDayJsInstance()(arg.event.end.toISOString())
|
|
||||||
.utc()
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.second(0)
|
|
||||||
.utc()
|
|
||||||
.format(),
|
|
||||||
} as TimeEntry;
|
|
||||||
await props.updateTimeEntry(updatedTimeEntry);
|
|
||||||
emit('refresh');
|
|
||||||
}
|
|
||||||
|
|
||||||
const calendarOptions = computed(() => ({
|
|
||||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
|
|
||||||
initialView: 'timeGridWeek',
|
|
||||||
headerToolbar: {
|
|
||||||
left: 'prev,next today',
|
|
||||||
center: 'title',
|
|
||||||
right: 'timeGridWeek,timeGridDay',
|
|
||||||
},
|
|
||||||
height: 'parent',
|
|
||||||
slotMinTime: '00:00:00',
|
|
||||||
slotMaxTime: '24:00:00',
|
|
||||||
slotDuration: '00:15:00',
|
|
||||||
slotLabelInterval: '01:00:00',
|
|
||||||
slotLabelFormat: getSlotLabelFormat(),
|
|
||||||
snapDuration: '00:01:00',
|
|
||||||
firstDay: getFirstDay(),
|
|
||||||
allDaySlot: false,
|
|
||||||
nowIndicator: true,
|
|
||||||
eventMinHeight: 1,
|
|
||||||
selectable: true,
|
|
||||||
selectMirror: true,
|
|
||||||
editable: true,
|
|
||||||
eventResizableFromStart: true,
|
|
||||||
eventDurationEditable: true,
|
|
||||||
timeZone: getUserTimezone(),
|
|
||||||
eventStartEditable: true,
|
|
||||||
select: handleDateSelect,
|
|
||||||
eventClick: handleEventClick,
|
|
||||||
eventDrop: handleEventDrop,
|
|
||||||
eventResize: handleEventResize,
|
|
||||||
datesSet: emitDatesChange,
|
|
||||||
|
|
||||||
events: events.value,
|
|
||||||
activityPeriods: props.activityPeriods || [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
watch(showCreateTimeEntryModal, (value) => {
|
|
||||||
if (!value) {
|
|
||||||
newEventStart.value = null;
|
|
||||||
newEventEnd.value = null;
|
|
||||||
// Ensure FullCalendar clears the selection mirror when modal closes
|
|
||||||
calendarRef.value?.getApi().unselect();
|
|
||||||
emit('refresh');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(showEditTimeEntryModal, (value) => {
|
|
||||||
if (!value) {
|
|
||||||
selectedTimeEntry.value = null;
|
|
||||||
emit('refresh');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render activity status boxes after FullCalendar has rendered
|
|
||||||
const renderActivityBoxes = () => {
|
|
||||||
if (!calendarRef.value || !props.activityPeriods) return;
|
|
||||||
|
|
||||||
const calendarEl = calendarRef.value.$el as HTMLElement;
|
|
||||||
if (calendarEl && props.activityPeriods.length > 0) {
|
|
||||||
renderActivityStatusBoxes(calendarEl, props.activityPeriods);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Watch for activity periods changes - re-render when data changes
|
|
||||||
watch(
|
|
||||||
() => props.activityPeriods,
|
|
||||||
() => {
|
|
||||||
renderActivityBoxes();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const scrollToCurrentTime = () => {
|
|
||||||
nextTick(() => {
|
|
||||||
if (calendarRef.value) {
|
|
||||||
const now = getDayJsInstance()();
|
|
||||||
const oneHourBefore = now.subtract(1, 'hour');
|
|
||||||
|
|
||||||
// If subtracting 1 hour keeps us on the same day, scroll to 1 hour before
|
|
||||||
const scrollTime = now.isSame(oneHourBefore, 'day')
|
|
||||||
? oneHourBefore.format('HH:mm:ss')
|
|
||||||
: now.format('HH:mm:ss');
|
|
||||||
|
|
||||||
calendarRef.value.getApi().scrollToTime(scrollTime);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
scrollToCurrentTime();
|
|
||||||
// Start interval to update running time entry
|
|
||||||
currentTimeInterval = setInterval(() => {
|
|
||||||
currentTime.value = getDayJsInstance()();
|
|
||||||
}, 60000); // Update every minute
|
|
||||||
});
|
|
||||||
|
|
||||||
onActivated(() => {
|
|
||||||
scrollToCurrentTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
// Clean up interval
|
|
||||||
if (currentTimeInterval) {
|
|
||||||
clearInterval(currentTimeInterval);
|
|
||||||
currentTimeInterval = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="w-full relative h-full flex-1">
|
|
||||||
<div v-if="loading" class="flex items-center justify-center h-full">
|
|
||||||
<div class="flex flex-col items-center space-y-4">
|
|
||||||
<LoadingSpinner class="h-8 w-8" />
|
|
||||||
<p class="text-muted-foreground">Loading calendar data...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TimeEntryCreateModal
|
|
||||||
v-model:show="showCreateTimeEntryModal"
|
|
||||||
:enable-estimated-time="enableEstimatedTime"
|
|
||||||
:create-time-entry="createTimeEntry"
|
|
||||||
:create-client="createClient"
|
|
||||||
:create-project="createProject"
|
|
||||||
:create-tag="createTag"
|
|
||||||
:currency="currency"
|
|
||||||
:can-create-project="canCreateProject"
|
|
||||||
:tags="tags as any"
|
|
||||||
:projects="projects"
|
|
||||||
:tasks="tasks"
|
|
||||||
:clients="clients"
|
|
||||||
:start="newEventStart ? newEventStart.toISOString() : undefined"
|
|
||||||
:end="newEventEnd ? newEventEnd.toISOString() : undefined" />
|
|
||||||
|
|
||||||
<TimeEntryEditModal
|
|
||||||
v-model:show="showEditTimeEntryModal"
|
|
||||||
:time-entry="selectedTimeEntry as any"
|
|
||||||
:enable-estimated-time="enableEstimatedTime"
|
|
||||||
:update-time-entry="updateTimeEntry"
|
|
||||||
:delete-time-entry="deleteTimeEntry"
|
|
||||||
:create-client="createClient"
|
|
||||||
:create-project="createProject"
|
|
||||||
:create-tag="createTag"
|
|
||||||
:tags="tags as any"
|
|
||||||
:projects="projects"
|
|
||||||
:tasks="tasks"
|
|
||||||
:clients="clients"
|
|
||||||
:currency="currency"
|
|
||||||
:can-create-project="canCreateProject" />
|
|
||||||
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
|
|
||||||
<template #eventContent="arg">
|
|
||||||
<FullCalendarEventContent
|
|
||||||
:title="arg.event.title"
|
|
||||||
:project-name="(arg.event.extendedProps as any).project?.name"
|
|
||||||
:task-name="(arg.event.extendedProps as any).task?.name"
|
|
||||||
:client-name="(arg.event.extendedProps as any).client?.name"
|
|
||||||
:duration-seconds="
|
|
||||||
((arg.event.extendedProps as any).duration ?? undefined)
|
|
||||||
? (arg.event.extendedProps as any).duration * 60
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
:start="arg.event.start as any"
|
|
||||||
:end="arg.event.end as any" />
|
|
||||||
</template>
|
|
||||||
<template #dayHeaderContent="arg">
|
|
||||||
<FullCalendarDayHeader
|
|
||||||
:date="
|
|
||||||
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
|
|
||||||
"
|
|
||||||
:total-minutes="
|
|
||||||
dailyTotals[
|
|
||||||
getDayJsInstance()(arg.date)
|
|
||||||
.utc()
|
|
||||||
.tz(getUserTimezone(), true)
|
|
||||||
.format('YYYY-MM-DD')
|
|
||||||
] || 0
|
|
||||||
" />
|
|
||||||
</template>
|
|
||||||
</FullCalendar>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.fullcalendar {
|
|
||||||
height: 100%;
|
|
||||||
--fc-border-color: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* FullCalendar theme customization */
|
|
||||||
.fullcalendar :deep(.fc) {
|
|
||||||
background-color: var(--theme-color-default-background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-slot) {
|
|
||||||
height: 25px;
|
|
||||||
transition: height 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-slot-label) {
|
|
||||||
background-color: var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-toolbar) {
|
|
||||||
background-color: var(--background);
|
|
||||||
padding: 0.5rem;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-toolbar-title) {
|
|
||||||
color: var(--foreground);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-button) {
|
|
||||||
background-color: var(--secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 14px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-button:hover) {
|
|
||||||
background-color: var(--muted);
|
|
||||||
border-color: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-button:focus) {
|
|
||||||
box-shadow: 0 0 0 2px var(--ring);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-button-active) {
|
|
||||||
background-color: var(--primary);
|
|
||||||
border-color: var(--primary);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-col-header) {
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-col-header-cell) {
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 0.75rem 0.5rem;
|
|
||||||
background-color: var(--theme-color-default-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-axis) {
|
|
||||||
background-color: var(--theme-color-default-background) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-col-header-cell .fc-col-header-cell-cushion) {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-axis) {
|
|
||||||
background-color: var(--theme-color-default-background);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Quarter-hour slots - transparent borders */
|
|
||||||
.fullcalendar :deep(.fc-timegrid-slot-minor.fc-timegrid-slot-label) {
|
|
||||||
border-top: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-slot-minor.fc-timegrid-slot-lane) {
|
|
||||||
--tw-border-opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-day-today.fc-col-header-cell) {
|
|
||||||
background-color: var(--color-accent-default);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-day-today) {
|
|
||||||
background-color: var(--theme-color-default-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-now-indicator) {
|
|
||||||
border-color: var(--primary);
|
|
||||||
border-width: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-event) {
|
|
||||||
border-radius: calc(var(--radius) - 4px);
|
|
||||||
padding: 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: var(--theme-shadow-card);
|
|
||||||
opacity: 0.9;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-v-event) {
|
|
||||||
background-color: var(--muted);
|
|
||||||
border-color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-event-title) {
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced FullCalendar resize handles */
|
|
||||||
.fullcalendar :deep(.fc-event-resizer) {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 99;
|
|
||||||
background: '#FFF';
|
|
||||||
border-radius: 2px;
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
left: 0;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-event-resizer-start) {
|
|
||||||
top: -2px;
|
|
||||||
cursor: n-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-event-resizer-end) {
|
|
||||||
bottom: -2px;
|
|
||||||
cursor: s-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-event:hover .fc-event-resizer) {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-event-resizer:hover) {
|
|
||||||
background: '#FFF';
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Update the earlier hover rule to include the shadow */
|
|
||||||
.fullcalendar :deep(.fc-event:hover) {
|
|
||||||
opacity: 1;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
box-shadow: var(--theme-shadow-dropdown);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-event-harness) {
|
|
||||||
margin: 0 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-highlight) {
|
|
||||||
background-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-select-mirror) {
|
|
||||||
background-color: var(--accent);
|
|
||||||
border: 1px solid var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-scrollgrid) {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-left: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-scrollgrid-section > td) {
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-body) {
|
|
||||||
background-color: var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-col) {
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-axis-cushion) {
|
|
||||||
color: var(--theme-text-secondary);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-slot-label-cushion) {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-col-header-cell-cushion) {
|
|
||||||
color: var(--foreground);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Daily totals styling */
|
|
||||||
.fullcalendar :deep(.fc-col-header-cell .text-muted-foreground) {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reduce visibility of time slot dividers */
|
|
||||||
.fullcalendar :deep(.fc-timegrid-divider) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make scrollbars gray */
|
|
||||||
.fullcalendar :deep(.fc-scroller) {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--muted-foreground) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar) {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-track) {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-thumb) {
|
|
||||||
background-color: var(--muted-foreground);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-thumb:hover) {
|
|
||||||
background-color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Improve time axis styling */
|
|
||||||
.fullcalendar :deep(.fc-timegrid-axis-chunk) {
|
|
||||||
background-color: var(--theme-color-default-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Simple event main styling */
|
|
||||||
.fullcalendar :deep(.fc-event-main) {
|
|
||||||
padding: 0.125rem 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Activity status plugin styles */
|
|
||||||
.fullcalendar :deep(.activity-status-box) {
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.activity-status-box.idle) {
|
|
||||||
background-color: rgba(156, 163, 175, 0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.activity-status-box.idle):hover {
|
|
||||||
background-color: rgba(156, 163, 175, 0.5) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.activity-status-box.active) {
|
|
||||||
background-color: rgba(34, 197, 94, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.activity-status-box.active):hover {
|
|
||||||
background-color: rgba(34, 197, 94, 1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add left margin to events only on days with activity status data */
|
|
||||||
.fullcalendar :deep(.has-activity-status .fc-timegrid-event-harness) {
|
|
||||||
margin-left: 15px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-timegrid-event) {
|
|
||||||
margin-left: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide end resizer for running time entries */
|
|
||||||
.fullcalendar :deep(.running-entry .fc-event-resizer-end) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullcalendar :deep(.running-entry) {
|
|
||||||
border-bottom-left-radius: 0px;
|
|
||||||
border-bottom-right-radius: 0px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
import { createPlugin, type PluginDef } from '@fullcalendar/core';
|
|
||||||
import { computePosition, flip, shift, offset } from '@floating-ui/dom';
|
|
||||||
|
|
||||||
export interface ActivityPeriod {
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
isIdle: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActivityStatusPluginOptions {
|
|
||||||
activityPeriods?: ActivityPeriod[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and manages a tooltip element for activity status boxes
|
|
||||||
*/
|
|
||||||
function createTooltip(): HTMLElement {
|
|
||||||
const tooltip = document.createElement('div');
|
|
||||||
tooltip.className =
|
|
||||||
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground';
|
|
||||||
tooltip.style.position = 'fixed';
|
|
||||||
tooltip.style.pointerEvents = 'none';
|
|
||||||
tooltip.style.opacity = '0';
|
|
||||||
tooltip.style.whiteSpace = 'nowrap';
|
|
||||||
tooltip.style.transform = 'scale(0.95)';
|
|
||||||
tooltip.style.transition = 'opacity 150ms, transform 150ms';
|
|
||||||
document.body.appendChild(tooltip);
|
|
||||||
return tooltip;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows tooltip for an activity status box
|
|
||||||
*/
|
|
||||||
function showTooltip(box: HTMLElement, tooltip: HTMLElement, text: string) {
|
|
||||||
tooltip.textContent = text;
|
|
||||||
tooltip.style.opacity = '1';
|
|
||||||
tooltip.style.transform = 'scale(1)';
|
|
||||||
|
|
||||||
const updatePosition = () => {
|
|
||||||
computePosition(box, tooltip, {
|
|
||||||
placement: 'right',
|
|
||||||
middleware: [offset(8), flip(), shift({ padding: 5 })],
|
|
||||||
}).then(({ x, y }) => {
|
|
||||||
tooltip.style.left = `${x}px`;
|
|
||||||
tooltip.style.top = `${y}px`;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
updatePosition();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hides the tooltip
|
|
||||||
*/
|
|
||||||
function hideTooltip(tooltip: HTMLElement) {
|
|
||||||
tooltip.style.opacity = '0';
|
|
||||||
tooltip.style.transform = 'scale(0.95)';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders activity status boxes in the calendar time grid
|
|
||||||
*/
|
|
||||||
export function renderActivityStatusBoxes(
|
|
||||||
calendarEl: HTMLElement,
|
|
||||||
activityPeriods: ActivityPeriod[]
|
|
||||||
) {
|
|
||||||
if (!calendarEl) return;
|
|
||||||
|
|
||||||
// Clean up existing activity boxes and markers first
|
|
||||||
const existingBoxes = calendarEl.querySelectorAll('.activity-status-box');
|
|
||||||
existingBoxes.forEach((box) => box.remove());
|
|
||||||
|
|
||||||
// Clean up existing tooltips
|
|
||||||
const existingTooltips = document.querySelectorAll('.activity-status-tooltip');
|
|
||||||
existingTooltips.forEach((tooltip) => tooltip.remove());
|
|
||||||
|
|
||||||
// Remove has-activity-status class from all lanes
|
|
||||||
const allLanes = calendarEl.querySelectorAll('.fc-timegrid-col');
|
|
||||||
allLanes.forEach((lane) => lane.classList.remove('has-activity-status'));
|
|
||||||
|
|
||||||
const timeGrid = calendarEl.querySelector('.fc-timegrid-body');
|
|
||||||
if (!timeGrid) {
|
|
||||||
console.log('No timegrid found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lanes = timeGrid.querySelectorAll('.fc-timegrid-col');
|
|
||||||
if (lanes.length === 0) {
|
|
||||||
console.log('No lanes found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
'Rendering activity status boxes, lanes:',
|
|
||||||
lanes.length,
|
|
||||||
'periods:',
|
|
||||||
activityPeriods.length
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create a single tooltip instance to be reused
|
|
||||||
const tooltip = createTooltip();
|
|
||||||
|
|
||||||
lanes.forEach((lane: Element, dayIndex: number) => {
|
|
||||||
// Get the date for this lane from the data attribute
|
|
||||||
const laneEl = lane as HTMLElement;
|
|
||||||
const dateStr = laneEl.getAttribute('data-date');
|
|
||||||
|
|
||||||
if (!dateStr) {
|
|
||||||
console.log('No date attribute found for lane', dayIndex);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const laneDate = new Date(dateStr);
|
|
||||||
const laneDateStart = new Date(laneDate);
|
|
||||||
laneDateStart.setHours(0, 0, 0, 0);
|
|
||||||
const laneDateEnd = new Date(laneDate);
|
|
||||||
laneDateEnd.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
let hasActivityStatusForThisDay = false;
|
|
||||||
|
|
||||||
activityPeriods.forEach((period) => {
|
|
||||||
const periodStart = new Date(period.start);
|
|
||||||
const periodEnd = new Date(period.end);
|
|
||||||
|
|
||||||
// Check if period overlaps with this day
|
|
||||||
if (periodEnd < laneDateStart || periodStart > laneDateEnd) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the position and height of the idle box
|
|
||||||
const { top, height } = calculateBoxPosition(
|
|
||||||
calendarEl,
|
|
||||||
periodStart > laneDateStart ? periodStart : laneDateStart,
|
|
||||||
periodEnd < laneDateEnd ? periodEnd : laneDateEnd
|
|
||||||
);
|
|
||||||
|
|
||||||
if (height <= 0) return;
|
|
||||||
|
|
||||||
hasActivityStatusForThisDay = true;
|
|
||||||
|
|
||||||
// Create and append the activity status box
|
|
||||||
const box = document.createElement('div');
|
|
||||||
box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`;
|
|
||||||
box.style.position = 'absolute';
|
|
||||||
box.style.top = `${top}px`;
|
|
||||||
box.style.height = `${height}px`;
|
|
||||||
box.style.width = '8px';
|
|
||||||
box.style.left = '4px';
|
|
||||||
box.style.right = '4px';
|
|
||||||
box.style.zIndex = '10';
|
|
||||||
box.style.cursor = 'default';
|
|
||||||
|
|
||||||
// Calculate duration in minutes
|
|
||||||
const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart;
|
|
||||||
const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd;
|
|
||||||
const durationMs = actualEnd.getTime() - actualStart.getTime();
|
|
||||||
const durationMinutes = Math.round(durationMs / 60000);
|
|
||||||
|
|
||||||
// Format duration
|
|
||||||
const hours = Math.floor(durationMinutes / 60);
|
|
||||||
const minutes = durationMinutes % 60;
|
|
||||||
const durationText = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
||||||
|
|
||||||
// Add tooltip text based on status
|
|
||||||
const status = period.isIdle ? 'Idling' : 'Active';
|
|
||||||
const tooltipText = `${status} (${durationText})`;
|
|
||||||
|
|
||||||
// Add hover event listeners for tooltip
|
|
||||||
box.addEventListener('mouseenter', () => {
|
|
||||||
showTooltip(box, tooltip, tooltipText);
|
|
||||||
});
|
|
||||||
|
|
||||||
box.addEventListener('mouseleave', () => {
|
|
||||||
hideTooltip(tooltip);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Position relative to the lane
|
|
||||||
const laneFrame = lane.querySelector('.fc-timegrid-col-frame');
|
|
||||||
if (laneFrame) {
|
|
||||||
laneFrame.appendChild(box);
|
|
||||||
} else {
|
|
||||||
console.log('No lane frame found');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark this lane as having activity status if any periods were rendered
|
|
||||||
if (hasActivityStatusForThisDay) {
|
|
||||||
laneEl.classList.add('has-activity-status');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the pixel position and height for an activity status box
|
|
||||||
*/
|
|
||||||
function calculateBoxPosition(
|
|
||||||
calendarEl: HTMLElement,
|
|
||||||
startTime: Date,
|
|
||||||
endTime: Date
|
|
||||||
): { top: number; height: number } {
|
|
||||||
// Get the slot duration and slot height
|
|
||||||
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
|
|
||||||
if (slotsEl.length === 0) {
|
|
||||||
console.log('No slots found');
|
|
||||||
return { top: 0, height: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate slot height (assuming all slots are equal height)
|
|
||||||
const firstSlot = slotsEl[0] as HTMLElement;
|
|
||||||
const slotHeight = firstSlot.offsetHeight;
|
|
||||||
|
|
||||||
// Each slot is 15 minutes by default (configured in TimeEntryCalendar)
|
|
||||||
const slotDurationMinutes = 15;
|
|
||||||
const pixelsPerMinute = slotHeight / slotDurationMinutes;
|
|
||||||
|
|
||||||
// Calculate start position (minutes from midnight)
|
|
||||||
const startMinutes = startTime.getHours() * 60 + startTime.getMinutes();
|
|
||||||
const endMinutes = endTime.getHours() * 60 + endTime.getMinutes();
|
|
||||||
|
|
||||||
// Calculate pixel positions
|
|
||||||
const top = startMinutes * pixelsPerMinute;
|
|
||||||
const height = (endMinutes - startMinutes) * pixelsPerMinute;
|
|
||||||
|
|
||||||
return { top, height };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FullCalendar plugin to display idle/active status boxes in the time grid
|
|
||||||
*/
|
|
||||||
const activityStatusPlugin: PluginDef = createPlugin({
|
|
||||||
name: '@solidtime/activity-status',
|
|
||||||
|
|
||||||
optionRefiners: {
|
|
||||||
activityPeriods: (rawVal: unknown): ActivityPeriod[] => {
|
|
||||||
if (!Array.isArray(rawVal)) return [];
|
|
||||||
return rawVal as ActivityPeriod[];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default activityStatusPlugin;
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||||
import Button from '../Buttons/Button.vue';
|
import { Button } from '@/Components/ui/button';
|
||||||
import { RangeCalendar } from '@/Components/ui/range-calendar';
|
import { RangeCalendar } from '@/Components/ui/range-calendar';
|
||||||
import { CalendarDate } from '@internationalized/date';
|
import { CalendarDate } from '@internationalized/date';
|
||||||
import { CalendarIcon } from 'lucide-vue-next';
|
import { CalendarIcon } from 'lucide-vue-next';
|
||||||
|
|||||||
@@ -27,19 +27,11 @@ function updateTime(event: Event) {
|
|||||||
if (newValue.split(':').length === 2) {
|
if (newValue.split(':').length === 2) {
|
||||||
const [hours, minutes] = newValue.split(':');
|
const [hours, minutes] = newValue.split(':');
|
||||||
if (!isNaN(parseInt(hours)) && !isNaN(parseInt(minutes))) {
|
if (!isNaN(parseInt(hours)) && !isNaN(parseInt(minutes))) {
|
||||||
const currentTime = getLocalizedDayJs(model.value);
|
model.value = getLocalizedDayJs(model.value)
|
||||||
const newHours = Math.min(parseInt(hours), 23);
|
.set('hours', Math.min(parseInt(hours), 23))
|
||||||
const newMinutes = Math.min(parseInt(minutes), 59);
|
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||||
|
.format();
|
||||||
// Only update if hours or minutes are different
|
emit('changed', model.value);
|
||||||
if (currentTime.hour() !== newHours || currentTime.minute() !== newMinutes) {
|
|
||||||
model.value = currentTime
|
|
||||||
.set('hours', newHours)
|
|
||||||
.set('minutes', newMinutes)
|
|
||||||
.set('seconds', 0)
|
|
||||||
.format();
|
|
||||||
emit('changed', model.value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// check if input is only numbers
|
// check if input is only numbers
|
||||||
@@ -50,7 +42,6 @@ function updateTime(event: Event) {
|
|||||||
model.value = getLocalizedDayJs(model.value)
|
model.value = getLocalizedDayJs(model.value)
|
||||||
.set('hours', Math.min(parseInt(hours), 23))
|
.set('hours', Math.min(parseInt(hours), 23))
|
||||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||||
.set('seconds', 0)
|
|
||||||
.format();
|
.format();
|
||||||
emit('changed', model.value);
|
emit('changed', model.value);
|
||||||
} else if (newValue.length === 3) {
|
} else if (newValue.length === 3) {
|
||||||
@@ -59,7 +50,6 @@ function updateTime(event: Event) {
|
|||||||
model.value = getLocalizedDayJs(model.value)
|
model.value = getLocalizedDayJs(model.value)
|
||||||
.set('hours', Math.min(parseInt(hours), 23))
|
.set('hours', Math.min(parseInt(hours), 23))
|
||||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||||
.set('seconds', 0)
|
|
||||||
.format();
|
.format();
|
||||||
emit('changed', model.value);
|
emit('changed', model.value);
|
||||||
} else if (newValue.length === 2) {
|
} else if (newValue.length === 2) {
|
||||||
@@ -67,7 +57,6 @@ function updateTime(event: Event) {
|
|||||||
model.value = getLocalizedDayJs(model.value)
|
model.value = getLocalizedDayJs(model.value)
|
||||||
.set('hours', Math.min(parseInt(newValue), 23))
|
.set('hours', Math.min(parseInt(newValue), 23))
|
||||||
.set('minutes', 0)
|
.set('minutes', 0)
|
||||||
.set('seconds', 0)
|
|
||||||
.format();
|
.format();
|
||||||
emit('changed', model.value);
|
emit('changed', model.value);
|
||||||
} else if (newValue.length === 1) {
|
} else if (newValue.length === 1) {
|
||||||
@@ -75,7 +64,6 @@ function updateTime(event: Event) {
|
|||||||
model.value = getLocalizedDayJs(model.value)
|
model.value = getLocalizedDayJs(model.value)
|
||||||
.set('hours', Math.min(parseInt(newValue), 23))
|
.set('hours', Math.min(parseInt(newValue), 23))
|
||||||
.set('minutes', 0)
|
.set('minutes', 0)
|
||||||
.set('seconds', 0)
|
|
||||||
.format();
|
.format();
|
||||||
emit('changed', model.value);
|
emit('changed', model.value);
|
||||||
}
|
}
|
||||||
@@ -105,7 +93,6 @@ const inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:m
|
|||||||
data-testid="time_picker_input"
|
data-testid="time_picker_input"
|
||||||
type="text"
|
type="text"
|
||||||
@blur="updateTime"
|
@blur="updateTime"
|
||||||
@keydown.enter.prevent="updateTime"
|
|
||||||
@focus="($event.target as HTMLInputElement).select()"
|
@focus="($event.target as HTMLInputElement).select()"
|
||||||
@mouseup="($event.target as HTMLInputElement).select()"
|
@mouseup="($event.target as HTMLInputElement).select()"
|
||||||
@click="($event.target as HTMLInputElement).select()"
|
@click="($event.target as HTMLInputElement).select()"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps, nextTick, ref, watch } from 'vue';
|
import { defineProps, nextTick, ref, watch } from 'vue';
|
||||||
|
import { useFocusWithin } from '@vueuse/core';
|
||||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||||
import Button from '../Buttons/Button.vue';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
start: string;
|
start: string;
|
||||||
@@ -17,42 +17,31 @@ const emit = defineEmits(['changed', 'close']);
|
|||||||
|
|
||||||
const tempStart = ref(props.start ? getLocalizedDayJs(props.start).format() : dayjs().format());
|
const tempStart = ref(props.start ? getLocalizedDayJs(props.start).format() : dayjs().format());
|
||||||
const tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null);
|
const tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null);
|
||||||
const showEndTimePicker = ref(false);
|
|
||||||
|
|
||||||
watch(props, () => {
|
watch(props, () => {
|
||||||
tempStart.value = getLocalizedDayJs(props.start).format();
|
tempStart.value = getLocalizedDayJs(props.start).format();
|
||||||
tempEnd.value = props.end ? getLocalizedDayJs(props.end).format() : null;
|
tempEnd.value = props.end ? getLocalizedDayJs(props.end).format() : null;
|
||||||
showEndTimePicker.value = false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateTimeEntry() {
|
function updateTimeEntry() {
|
||||||
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
|
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
|
||||||
const tempEndUtc = tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null;
|
const tempEndUtc = tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null;
|
||||||
|
|
||||||
if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
|
if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
|
||||||
emit(
|
emit(
|
||||||
'changed',
|
'changed',
|
||||||
getDayJsInstance()(tempStart.value).utc().format(),
|
getDayJsInstance()(tempStart.value).utc().format(),
|
||||||
tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null
|
getDayJsInstance()(tempEnd.value).utc().format()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setEndTime() {
|
|
||||||
showEndTimePicker.value = true;
|
|
||||||
tempEnd.value = getDayJsInstance()().format();
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmEndTime() {
|
|
||||||
// wait for the v-model for the end time to update
|
|
||||||
nextTick(() => {
|
|
||||||
updateTimeEntry();
|
|
||||||
showEndTimePicker.value = false;
|
|
||||||
emit('close');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dropdownContent = ref();
|
const dropdownContent = ref();
|
||||||
|
const { focused } = useFocusWithin(dropdownContent);
|
||||||
|
|
||||||
|
watch(focused, (newValue, oldValue) => {
|
||||||
|
if (oldValue === true && newValue === false) {
|
||||||
|
updateTimeEntry();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -78,7 +67,7 @@ const dropdownContent = ref();
|
|||||||
</div>
|
</div>
|
||||||
<div class="px-2">
|
<div class="px-2">
|
||||||
<div class="font-semibold text-text-primary text-sm pb-2">End</div>
|
<div class="font-semibold text-text-primary text-sm pb-2">End</div>
|
||||||
<div v-if="end !== null && tempEnd !== null" class="space-y-2">
|
<div v-if="tempEnd !== null" class="space-y-2">
|
||||||
<TimePickerSimple
|
<TimePickerSimple
|
||||||
v-model="tempEnd"
|
v-model="tempEnd"
|
||||||
data-testid="time_entry_range_end"
|
data-testid="time_entry_range_end"
|
||||||
@@ -88,22 +77,6 @@ const dropdownContent = ref();
|
|||||||
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
|
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
|
||||||
@changed="updateTimeEntry"></DatePicker>
|
@changed="updateTimeEntry"></DatePicker>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="end === null && !showEndTimePicker">
|
|
||||||
<Button variant="outline" size="sm" @click="setEndTime"> Set End Time </Button>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="showEndTimePicker && tempEnd !== null" class="space-y-2">
|
|
||||||
<TimePickerSimple
|
|
||||||
v-model="tempEnd"
|
|
||||||
data-testid="time_entry_range_end"
|
|
||||||
@keydown.enter.prevent.stop="confirmEndTime"></TimePickerSimple>
|
|
||||||
<DatePicker
|
|
||||||
v-model="tempEnd"
|
|
||||||
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
|
|
||||||
@keydown.enter.prevent="confirmEndTime"></DatePicker>
|
|
||||||
<Button variant="outline" size="sm" class="w-full" @click="confirmEndTime">
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-text-secondary">-- : --</div>
|
<div v-else class="text-text-secondary">-- : --</div>
|
||||||
<div tabindex="0" @focusin="emit('close')"></div>
|
<div tabindex="0" @focusin="emit('close')"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ const props = defineProps<{
|
|||||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
onStartStopClick: (timeEntry: TimeEntry) => void;
|
onStartStopClick: (timeEntry: TimeEntry) => void;
|
||||||
duplicateTimeEntry: (timeEntry: TimeEntry) => void;
|
|
||||||
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
|
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
|
||||||
updateTimeEntry: (timeEntry: TimeEntry) => void;
|
updateTimeEntry: (timeEntry: TimeEntry) => void;
|
||||||
deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
|
deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
|
||||||
@@ -93,7 +92,7 @@ function onSelectChange(checked: boolean) {
|
|||||||
class="border-b border-default-background-separator bg-row-background min-w-0 transition"
|
class="border-b border-default-background-separator bg-row-background min-w-0 transition"
|
||||||
data-testid="time_entry_row">
|
data-testid="time_entry_row">
|
||||||
<MainContainer class="min-w-0">
|
<MainContainer class="min-w-0">
|
||||||
<div class="@sm:flex py-2 items-center min-w-0 justify-between group">
|
<div class="sm:flex py-1.5 items-center min-w-0 justify-between group">
|
||||||
<div class="flex space-x-3 items-center min-w-0">
|
<div class="flex space-x-3 items-center min-w-0">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:checked="
|
:checked="
|
||||||
@@ -125,7 +124,7 @@ function onSelectChange(checked: boolean) {
|
|||||||
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
|
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center font-medium space-x-1 @lg:space-x-2">
|
<div class="flex items-center font-medium lg:space-x-2">
|
||||||
<TimeEntryRowTagDropdown
|
<TimeEntryRowTagDropdown
|
||||||
:create-tag
|
:create-tag
|
||||||
:tags="tags"
|
:tags="tags"
|
||||||
@@ -142,8 +141,8 @@ function onSelectChange(checked: boolean) {
|
|||||||
twMerge(
|
twMerge(
|
||||||
'text-text-secondary px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary',
|
'text-text-secondary px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary',
|
||||||
organization?.time_format === '12-hours'
|
organization?.time_format === '12-hours'
|
||||||
? 'w-[160px]'
|
? 'w-[170px]'
|
||||||
: 'w-[100px]'
|
: 'w-[120px]'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
@click="expanded = !expanded">
|
@click="expanded = !expanded">
|
||||||
@@ -157,7 +156,7 @@ function onSelectChange(checked: boolean) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="text-text-primary !mr-2 min-w-[80px] px-1.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
|
class="text-text-primary min-w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
|
||||||
@click="expanded = !expanded">
|
@click="expanded = !expanded">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
@@ -173,8 +172,6 @@ function onSelectChange(checked: boolean) {
|
|||||||
class="opacity-20 flex group-hover:opacity-100 focus-visible:opacity-100"
|
class="opacity-20 flex group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
|
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
|
||||||
<TimeEntryMoreOptionsDropdown
|
<TimeEntryMoreOptionsDropdown
|
||||||
:show-edit="false"
|
|
||||||
:show-duplicate="false"
|
|
||||||
@delete="
|
@delete="
|
||||||
deleteTimeEntries(timeEntry?.timeEntries ?? [])
|
deleteTimeEntries(timeEntry?.timeEntries ?? [])
|
||||||
"></TimeEntryMoreOptionsDropdown>
|
"></TimeEntryMoreOptionsDropdown>
|
||||||
@@ -204,7 +201,6 @@ function onSelectChange(checked: boolean) {
|
|||||||
:update-time-entry="(timeEntry: TimeEntry) => updateTimeEntry(timeEntry)"
|
:update-time-entry="(timeEntry: TimeEntry) => updateTimeEntry(timeEntry)"
|
||||||
:on-start-stop-click="() => onStartStopClick(subEntry)"
|
:on-start-stop-click="() => onStartStopClick(subEntry)"
|
||||||
:delete-time-entry="() => deleteTimeEntries([subEntry])"
|
:delete-time-entry="() => deleteTimeEntries([subEntry])"
|
||||||
:duplicate-time-entry="() => duplicateTimeEntry(subEntry)"
|
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
:create-tag
|
:create-tag
|
||||||
:time-entry="subEntry"
|
:time-entry="subEntry"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import type {
|
|||||||
Client,
|
Client,
|
||||||
CreateTimeEntryBody,
|
CreateTimeEntryBody,
|
||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
import { Badge } from '@/packages/ui/src';
|
import { Badge } from '@/packages/ui/src';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
@@ -39,10 +41,6 @@ const props = defineProps<{
|
|||||||
projects: Project[];
|
projects: Project[];
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
start?: string;
|
|
||||||
end?: string;
|
|
||||||
currency: string;
|
|
||||||
canCreateProject: boolean;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const description = ref<HTMLInputElement | null>(null);
|
const description = ref<HTMLInputElement | null>(null);
|
||||||
@@ -61,31 +59,11 @@ const timeEntryDefaultValues = {
|
|||||||
task_id: null,
|
task_id: null,
|
||||||
tags: [],
|
tags: [],
|
||||||
billable: false,
|
billable: false,
|
||||||
start: getDayJsInstance().utc().subtract(1, 'h').second(0).format(),
|
start: getDayJsInstance().utc().subtract(1, 'h').format(),
|
||||||
end: getDayJsInstance().utc().second(0).format(),
|
end: getDayJsInstance().utc().format(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeEntry = ref({
|
const timeEntry = ref({ ...timeEntryDefaultValues });
|
||||||
...timeEntryDefaultValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
// update the localStart and localEnd when props.start or props.end get updates
|
|
||||||
watch(
|
|
||||||
() => props.start,
|
|
||||||
(value) => {
|
|
||||||
if (value) {
|
|
||||||
localStart.value = getLocalizedDayJs(value).format();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
watch(
|
|
||||||
() => props.end,
|
|
||||||
(value) => {
|
|
||||||
if (value) {
|
|
||||||
localEnd.value = getLocalizedDayJs(value).format();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => timeEntry.value.project_id,
|
() => timeEntry.value.project_id,
|
||||||
@@ -167,8 +145,8 @@ type BillableOption = {
|
|||||||
:clients
|
:clients
|
||||||
:create-project
|
:create-project
|
||||||
:create-client
|
:create-client
|
||||||
:can-create-project
|
:can-create-project="canCreateProjects()"
|
||||||
:currency
|
:currency="getOrganizationCurrencyString()"
|
||||||
size="xlarge"
|
size="xlarge"
|
||||||
class="bg-input-background"
|
class="bg-input-background"
|
||||||
:projects="projects"
|
:projects="projects"
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
|
||||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
|
||||||
import { computed, nextTick, ref, watch } from 'vue';
|
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
|
||||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
|
||||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
|
||||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
|
||||||
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
|
||||||
import type {
|
|
||||||
CreateClientBody,
|
|
||||||
CreateProjectBody,
|
|
||||||
Project,
|
|
||||||
Client,
|
|
||||||
TimeEntry,
|
|
||||||
} from '@/packages/api/src';
|
|
||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
|
||||||
import { Badge } from '@/packages/ui/src';
|
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
|
||||||
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
|
|
||||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
|
||||||
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
|
||||||
|
|
||||||
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
|
|
||||||
import type { Tag, Task } from '@/packages/api/src';
|
|
||||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
|
||||||
const saving = ref(false);
|
|
||||||
const deleting = ref(false);
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
timeEntry: TimeEntry | null;
|
|
||||||
enableEstimatedTime: boolean;
|
|
||||||
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
|
|
||||||
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
|
|
||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
|
||||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
|
||||||
createTag: (name: string) => Promise<Tag | undefined>;
|
|
||||||
tags: Tag[];
|
|
||||||
projects: Project[];
|
|
||||||
tasks: Task[];
|
|
||||||
clients: Client[];
|
|
||||||
currency: string;
|
|
||||||
canCreateProject: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const description = ref<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
watch(show, (value) => {
|
|
||||||
if (value) {
|
|
||||||
nextTick(() => {
|
|
||||||
description.value?.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const editableTimeEntry = ref<TimeEntry | null>(null);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.timeEntry,
|
|
||||||
(newTimeEntry) => {
|
|
||||||
if (newTimeEntry) {
|
|
||||||
editableTimeEntry.value = { ...newTimeEntry };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const localStart = computed({
|
|
||||||
get: () =>
|
|
||||||
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '',
|
|
||||||
set: (value: string) => {
|
|
||||||
if (editableTimeEntry.value) {
|
|
||||||
editableTimeEntry.value.start = getLocalizedDayJs(value).utc().format();
|
|
||||||
if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {
|
|
||||||
localEnd.value = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const localEnd = computed({
|
|
||||||
get: () =>
|
|
||||||
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.end).format() : '',
|
|
||||||
set: (value: string) => {
|
|
||||||
if (editableTimeEntry.value) {
|
|
||||||
editableTimeEntry.value.end = getLocalizedDayJs(value).utc().format();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (editableTimeEntry.value) {
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
await props.updateTimeEntry(editableTimeEntry.value);
|
|
||||||
show.value = false;
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteEntry() {
|
|
||||||
if (editableTimeEntry.value) {
|
|
||||||
deleting.value = true;
|
|
||||||
try {
|
|
||||||
await props.deleteTimeEntry(editableTimeEntry.value.id);
|
|
||||||
show.value = false;
|
|
||||||
} finally {
|
|
||||||
deleting.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const billableProxy = computed({
|
|
||||||
get: () =>
|
|
||||||
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
|
|
||||||
set: (value: string) => {
|
|
||||||
if (editableTimeEntry.value) {
|
|
||||||
editableTimeEntry.value.billable = value === 'true';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
type BillableOption = {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
|
||||||
<template #title>
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<span> Edit time entry </span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #content>
|
|
||||||
<div v-if="editableTimeEntry" class="space-y-4">
|
|
||||||
<div class="sm:flex items-end space-y-2 sm:space-y-0 sm:space-x-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<TextInput
|
|
||||||
id="description"
|
|
||||||
ref="description"
|
|
||||||
v-model="editableTimeEntry.description"
|
|
||||||
placeholder="What did you work on?"
|
|
||||||
type="text"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
@keydown.enter="submit" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="sm:flex justify-between items-end space-y-2 sm:space-y-0 pt-4 sm:space-x-4">
|
|
||||||
<div class="flex w-full items-center space-x-2 justify-between">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<TimeTrackerProjectTaskDropdown
|
|
||||||
v-model:project="editableTimeEntry.project_id"
|
|
||||||
v-model:task="editableTimeEntry.task_id"
|
|
||||||
:clients
|
|
||||||
:create-project
|
|
||||||
:create-client
|
|
||||||
:can-create-project="canCreateProject"
|
|
||||||
:currency="currency"
|
|
||||||
size="xlarge"
|
|
||||||
class="bg-input-background"
|
|
||||||
:projects="projects"
|
|
||||||
:tasks="tasks"
|
|
||||||
:enable-estimated-time="
|
|
||||||
enableEstimatedTime
|
|
||||||
"></TimeTrackerProjectTaskDropdown>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div class="flex-col">
|
|
||||||
<TagDropdown
|
|
||||||
v-model="editableTimeEntry.tags"
|
|
||||||
:create-tag
|
|
||||||
:tags="tags">
|
|
||||||
<template #trigger>
|
|
||||||
<Badge
|
|
||||||
class="bg-input-background"
|
|
||||||
tag="button"
|
|
||||||
size="xlarge">
|
|
||||||
<TagIcon
|
|
||||||
v-if="editableTimeEntry.tags.length === 0"
|
|
||||||
class="w-4"></TagIcon>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
|
|
||||||
{{ editableTimeEntry.tags.length }}
|
|
||||||
</div>
|
|
||||||
<span>Tags</span>
|
|
||||||
</Badge>
|
|
||||||
</template>
|
|
||||||
</TagDropdown>
|
|
||||||
</div>
|
|
||||||
<div class="flex-col">
|
|
||||||
<SelectDropdown
|
|
||||||
v-model="billableProxy"
|
|
||||||
:get-key-from-item="(item: BillableOption) => item.value"
|
|
||||||
:get-name-for-item="(item: BillableOption) => item.label"
|
|
||||||
:items="[
|
|
||||||
{
|
|
||||||
label: 'Billable',
|
|
||||||
value: 'true',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Non Billable',
|
|
||||||
value: 'false',
|
|
||||||
},
|
|
||||||
]">
|
|
||||||
<template #trigger>
|
|
||||||
<Badge
|
|
||||||
class="bg-input-background"
|
|
||||||
tag="button"
|
|
||||||
size="xlarge">
|
|
||||||
<BillableIcon class="h-4"></BillableIcon>
|
|
||||||
<span>{{
|
|
||||||
editableTimeEntry.billable
|
|
||||||
? 'Billable'
|
|
||||||
: 'Non-Billable'
|
|
||||||
}}</span>
|
|
||||||
</Badge>
|
|
||||||
</template>
|
|
||||||
</SelectDropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex pt-4 space-x-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<InputLabel>Duration</InputLabel>
|
|
||||||
<div class="space-y-2 mt-1 flex flex-col">
|
|
||||||
<DurationHumanInput
|
|
||||||
v-model:start="localStart"
|
|
||||||
v-model:end="localEnd"
|
|
||||||
name="Duration"></DurationHumanInput>
|
|
||||||
<div class="text-sm flex space-x-1">
|
|
||||||
<InformationCircleIcon
|
|
||||||
class="w-4 text-text-quaternary"></InformationCircleIcon>
|
|
||||||
<span class="text-text-secondary text-xs">
|
|
||||||
You can type natural language here f.e.
|
|
||||||
<span class="font-semibold"> 2h 30m</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="">
|
|
||||||
<InputLabel>Start</InputLabel>
|
|
||||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
|
||||||
<TimePickerSimple v-model="localStart" size="large"></TimePickerSimple>
|
|
||||||
<DatePicker
|
|
||||||
v-model="localStart"
|
|
||||||
tabindex="1"
|
|
||||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="">
|
|
||||||
<InputLabel>End</InputLabel>
|
|
||||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
|
||||||
<TimePickerSimple v-model="localEnd" size="large"></TimePickerSimple>
|
|
||||||
<DatePicker
|
|
||||||
v-model="localEnd"
|
|
||||||
tabindex="1"
|
|
||||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<div class="flex justify-between w-full">
|
|
||||||
<SecondaryButton
|
|
||||||
tabindex="2"
|
|
||||||
class="bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700"
|
|
||||||
:disabled="deleting || saving"
|
|
||||||
@click="deleteEntry">
|
|
||||||
{{ deleting ? 'Deleting...' : 'Delete' }}
|
|
||||||
</SecondaryButton>
|
|
||||||
<div class="flex space-x-3">
|
|
||||||
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
|
|
||||||
<PrimaryButton
|
|
||||||
tabindex="2"
|
|
||||||
:class="{ 'opacity-25': saving }"
|
|
||||||
:disabled="saving || deleting"
|
|
||||||
@click="submit">
|
|
||||||
{{ saving ? 'Updating...' : 'Update Time Entry' }}
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</DialogModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed } from 'vue';
|
||||||
import type {
|
import type {
|
||||||
CreateClientBody,
|
CreateClientBody,
|
||||||
CreateProjectBody,
|
CreateProjectBody,
|
||||||
@@ -38,8 +38,6 @@ const props = defineProps<{
|
|||||||
canCreateProject: boolean;
|
canCreateProject: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const maxVisibleGroups = ref(7); // Start with 10 day groups, then show all
|
|
||||||
|
|
||||||
const groupedTimeEntries = computed(() => {
|
const groupedTimeEntries = computed(() => {
|
||||||
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
|
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
|
||||||
for (const entry of props.timeEntries) {
|
for (const entry of props.timeEntries) {
|
||||||
@@ -96,7 +94,6 @@ const groupedTimeEntries = computed(() => {
|
|||||||
|
|
||||||
groupedEntriesByDayAndType[dailyEntriesKey] = newDailyEntries;
|
groupedEntriesByDayAndType[dailyEntriesKey] = newDailyEntries;
|
||||||
}
|
}
|
||||||
|
|
||||||
return groupedEntriesByDayAndType;
|
return groupedEntriesByDayAndType;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,7 +108,6 @@ function startTimeEntryFromExisting(entry: TimeEntry) {
|
|||||||
tags: [...entry.tags],
|
tags: [...entry.tags],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sumDuration(timeEntries: TimeEntry[]) {
|
function sumDuration(timeEntries: TimeEntry[]) {
|
||||||
return timeEntries.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
|
return timeEntries.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
|
||||||
}
|
}
|
||||||
@@ -137,116 +133,80 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleGroupedEntries = computed(() => {
|
|
||||||
const allGroups = Object.entries(groupedTimeEntries.value);
|
|
||||||
return Object.fromEntries(allGroups.slice(0, maxVisibleGroups.value));
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalGroups = computed(() => Object.keys(groupedTimeEntries.value).length);
|
|
||||||
|
|
||||||
function startProgressiveLoading() {
|
|
||||||
const loadMoreGroups = () => {
|
|
||||||
if (maxVisibleGroups.value < totalGroups.value) {
|
|
||||||
maxVisibleGroups.value = Math.min(maxVisibleGroups.value + 5, totalGroups.value);
|
|
||||||
|
|
||||||
if (maxVisibleGroups.value < totalGroups.value) {
|
|
||||||
requestIdleCallback(loadMoreGroups);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
requestIdleCallback(loadMoreGroups);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch for changes to totalGroups and adjust maxVisibleGroups accordingly
|
|
||||||
watch(totalGroups, (newTotal, oldTotal) => {
|
|
||||||
if (newTotal !== oldTotal) {
|
|
||||||
maxVisibleGroups.value = newTotal;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
startProgressiveLoading();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="@container">
|
<div v-for="(value, key) in groupedTimeEntries" :key="key">
|
||||||
<div v-for="(value, key) in visibleGroupedEntries" :key="key">
|
<TimeEntryRowHeading
|
||||||
<TimeEntryRowHeading
|
:date="key"
|
||||||
:date="String(key)"
|
:duration="sumDuration(value)"
|
||||||
:duration="sumDuration(value)"
|
:checked="
|
||||||
:checked="
|
value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))
|
||||||
value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))
|
"
|
||||||
|
@select-all="selectAllTimeEntries(value)"
|
||||||
|
@unselect-all="unselectAllTimeEntries(value)"></TimeEntryRowHeading>
|
||||||
|
<template v-for="entry in value" :key="entry.id">
|
||||||
|
<TimeEntryAggregateRow
|
||||||
|
v-if="'timeEntries' in entry && entry.timeEntries.length > 1"
|
||||||
|
:create-project
|
||||||
|
:can-create-project
|
||||||
|
:enable-estimated-time
|
||||||
|
:selected-time-entries="selectedTimeEntries"
|
||||||
|
:create-client
|
||||||
|
:projects="projects"
|
||||||
|
:tasks="tasks"
|
||||||
|
:tags="tags"
|
||||||
|
:clients
|
||||||
|
:on-start-stop-click="startTimeEntryFromExisting"
|
||||||
|
:update-time-entries
|
||||||
|
:update-time-entry
|
||||||
|
:delete-time-entries
|
||||||
|
:create-tag
|
||||||
|
:currency="currency"
|
||||||
|
:time-entry="entry"
|
||||||
|
@selected="
|
||||||
|
(timeEntries: TimeEntry[]) => {
|
||||||
|
selectedTimeEntries = [...selectedTimeEntries, ...timeEntries];
|
||||||
|
}
|
||||||
"
|
"
|
||||||
@select-all="selectAllTimeEntries(value)"
|
@unselected="
|
||||||
@unselect-all="unselectAllTimeEntries(value)"></TimeEntryRowHeading>
|
(timeEntriesToUnselect: TimeEntry[]) => {
|
||||||
<template v-for="entry in value" :key="entry.id">
|
|
||||||
<TimeEntryAggregateRow
|
|
||||||
v-if="'timeEntries' in entry && entry.timeEntries.length > 1"
|
|
||||||
:create-project
|
|
||||||
:can-create-project
|
|
||||||
:enable-estimated-time
|
|
||||||
:selected-time-entries="selectedTimeEntries"
|
|
||||||
:create-client
|
|
||||||
:projects="projects"
|
|
||||||
:tasks="tasks"
|
|
||||||
:tags="tags"
|
|
||||||
:clients
|
|
||||||
:on-start-stop-click="startTimeEntryFromExisting"
|
|
||||||
:duplicate-time-entry="createTimeEntry"
|
|
||||||
:update-time-entries
|
|
||||||
:update-time-entry
|
|
||||||
:delete-time-entries
|
|
||||||
:create-tag
|
|
||||||
:currency="currency"
|
|
||||||
:time-entry="entry"
|
|
||||||
@selected="
|
|
||||||
(timeEntries: TimeEntry[]) => {
|
|
||||||
selectedTimeEntries = [...selectedTimeEntries, ...timeEntries];
|
|
||||||
}
|
|
||||||
"
|
|
||||||
@unselected="
|
|
||||||
(timeEntriesToUnselect: TimeEntry[]) => {
|
|
||||||
selectedTimeEntries = selectedTimeEntries.filter(
|
|
||||||
(item: TimeEntry) =>
|
|
||||||
!timeEntriesToUnselect.find(
|
|
||||||
(filterEntry: TimeEntry) => filterEntry.id === item.id
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"></TimeEntryAggregateRow>
|
|
||||||
<TimeEntryRow
|
|
||||||
v-else
|
|
||||||
:create-client
|
|
||||||
:enable-estimated-time
|
|
||||||
:can-create-project
|
|
||||||
:create-project
|
|
||||||
:projects="projects"
|
|
||||||
:selected="
|
|
||||||
!!selectedTimeEntries.find(
|
|
||||||
(filterEntry: TimeEntry) => filterEntry.id === entry.id
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:tasks="tasks"
|
|
||||||
:tags="tags"
|
|
||||||
:clients
|
|
||||||
:create-tag
|
|
||||||
:update-time-entry
|
|
||||||
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
|
||||||
:delete-time-entry="() => deleteTimeEntries([entry])"
|
|
||||||
:duplicate-time-entry="() => createTimeEntry(entry)"
|
|
||||||
:currency="currency"
|
|
||||||
:time-entry="entry.timeEntries[0]"
|
|
||||||
@selected="selectedTimeEntries.push(entry)"
|
|
||||||
@unselected="
|
|
||||||
selectedTimeEntries = selectedTimeEntries.filter(
|
selectedTimeEntries = selectedTimeEntries.filter(
|
||||||
(item: TimeEntry) => item.id !== entry.id
|
(item: TimeEntry) =>
|
||||||
)
|
!timeEntriesToUnselect.find(
|
||||||
"></TimeEntryRow>
|
(filterEntry: TimeEntry) => filterEntry.id === item.id
|
||||||
</template>
|
)
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
"></TimeEntryAggregateRow>
|
||||||
|
<TimeEntryRow
|
||||||
|
v-else
|
||||||
|
:create-client
|
||||||
|
:enable-estimated-time
|
||||||
|
:can-create-project
|
||||||
|
:create-project
|
||||||
|
:projects="projects"
|
||||||
|
:selected="
|
||||||
|
!!selectedTimeEntries.find(
|
||||||
|
(filterEntry: TimeEntry) => filterEntry.id === entry.id
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:tasks="tasks"
|
||||||
|
:tags="tags"
|
||||||
|
:clients
|
||||||
|
:create-tag
|
||||||
|
:update-time-entry
|
||||||
|
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
||||||
|
:delete-time-entry="() => deleteTimeEntries([entry])"
|
||||||
|
:currency="currency"
|
||||||
|
:time-entry="entry.timeEntries[0]"
|
||||||
|
@selected="selectedTimeEntries.push(entry)"
|
||||||
|
@unselected="
|
||||||
|
selectedTimeEntries = selectedTimeEntries.filter(
|
||||||
|
(item: TimeEntry) => item.id !== entry.id
|
||||||
|
)
|
||||||
|
"></TimeEntryRow>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const showMassUpdateModal = ref(false);
|
|||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
props.class,
|
props.class,
|
||||||
'text-sm py-1.5 font-medium bg-secondary flex items-center space-x-3'
|
'text-sm py-1.5 font-medium border-t border-b bg-secondary border-border-secondary flex items-center space-x-3'
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TrashIcon, PencilIcon, DocumentDuplicateIcon } from '@heroicons/vue/20/solid';
|
import { TrashIcon } from '@heroicons/vue/20/solid';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -7,21 +7,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/Components/ui/dropdown-menu';
|
} from '@/Components/ui/dropdown-menu';
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
showEdit?: boolean;
|
|
||||||
showDuplicate?: boolean;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
showDuplicate: true,
|
|
||||||
showEdit: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
edit: [];
|
|
||||||
delete: [];
|
delete: [];
|
||||||
duplicate: [];
|
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -46,22 +33,6 @@ const emit = defineEmits<{
|
|||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent class="min-w-[150px]" align="end">
|
<DropdownMenuContent class="min-w-[150px]" align="end">
|
||||||
<DropdownMenuItem
|
|
||||||
v-if="props.showEdit"
|
|
||||||
data-testid="time_entry_edit"
|
|
||||||
class="flex items-center space-x-3 cursor-pointer"
|
|
||||||
@click="emit('edit')">
|
|
||||||
<PencilIcon class="w-5" />
|
|
||||||
<span>Edit</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
v-if="props.showDuplicate"
|
|
||||||
data-testid="time_entry_duplicate"
|
|
||||||
class="flex items-center space-x-3 cursor-pointer"
|
|
||||||
@click="emit('duplicate')">
|
|
||||||
<DocumentDuplicateIcon class="w-5" />
|
|
||||||
<span>Duplicate</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
data-testid="time_entry_delete"
|
data-testid="time_entry_delete"
|
||||||
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
|
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
|||||||
data-testid="time_entry_range_selector"
|
data-testid="time_entry_range_selector"
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
'text-text-secondary px-1 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
|
'text-text-secondary px-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
|
||||||
showDate
|
showDate
|
||||||
? 'text-xs py-1.5 font-semibold'
|
? 'text-xs py-1.5 font-semibold'
|
||||||
: 'text-sm py-1.5 font-medium',
|
: 'text-sm py-1.5 font-medium',
|
||||||
organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[100px]',
|
organization?.time_format === '12-hours' ? 'w-[170px]' : 'w-[120px]',
|
||||||
open && 'border-card-border bg-card-background'
|
open && 'border-card-border bg-card-background'
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ import TimeEntryDescriptionInput from '@/packages/ui/src/TimeEntry/TimeEntryDesc
|
|||||||
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
|
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
|
||||||
import TimeEntryRowDurationInput from '@/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue';
|
import TimeEntryRowDurationInput from '@/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue';
|
||||||
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
||||||
import { TimeEntryEditModal } from '@/packages/ui/src';
|
|
||||||
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed } from 'vue';
|
||||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||||
import { Checkbox } from '@/packages/ui/src';
|
import { Checkbox } from '@/packages/ui/src';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
@@ -36,7 +35,6 @@ const props = defineProps<{
|
|||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
onStartStopClick: () => void;
|
onStartStopClick: () => void;
|
||||||
deleteTimeEntry: () => void;
|
deleteTimeEntry: () => void;
|
||||||
duplicateTimeEntry?: () => void;
|
|
||||||
updateTimeEntry: (timeEntry: TimeEntry) => void;
|
updateTimeEntry: (timeEntry: TimeEntry) => void;
|
||||||
currency: string;
|
currency: string;
|
||||||
showMember?: boolean;
|
showMember?: boolean;
|
||||||
@@ -48,8 +46,6 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{ selected: []; unselected: [] }>();
|
const emit = defineEmits<{ selected: []; unselected: [] }>();
|
||||||
|
|
||||||
const showEditModal = ref(false);
|
|
||||||
|
|
||||||
function updateTimeEntryDescription(description: string) {
|
function updateTimeEntryDescription(description: string) {
|
||||||
props.updateTimeEntry({ ...props.timeEntry, description });
|
props.updateTimeEntry({ ...props.timeEntry, description });
|
||||||
}
|
}
|
||||||
@@ -91,20 +87,6 @@ function onSelectChange(checked: boolean) {
|
|||||||
emit('unselected');
|
emit('unselected');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEdit() {
|
|
||||||
showEditModal.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdateTimeEntry(updatedEntry: TimeEntry) {
|
|
||||||
props.updateTimeEntry(updatedEntry);
|
|
||||||
showEditModal.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteTimeEntry() {
|
|
||||||
props.deleteTimeEntry();
|
|
||||||
showEditModal.value = false;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -112,7 +94,7 @@ async function handleDeleteTimeEntry() {
|
|||||||
class="border-b border-default-background-separator transition min-w-0 bg-row-background"
|
class="border-b border-default-background-separator transition min-w-0 bg-row-background"
|
||||||
data-testid="time_entry_row">
|
data-testid="time_entry_row">
|
||||||
<MainContainer class="min-w-0">
|
<MainContainer class="min-w-0">
|
||||||
<div class="@sm:flex py-2 min-w-0 items-center justify-between group">
|
<div class="sm:flex py-2 min-w-0 items-center justify-between group">
|
||||||
<div class="flex items-center min-w-0">
|
<div class="flex items-center min-w-0">
|
||||||
<Checkbox :checked="selected" @update:checked="onSelectChange" />
|
<Checkbox :checked="selected" @update:checked="onSelectChange" />
|
||||||
<div v-if="indent === true" class="w-10 h-7"></div>
|
<div v-if="indent === true" class="w-10 h-7"></div>
|
||||||
@@ -134,7 +116,7 @@ async function handleDeleteTimeEntry() {
|
|||||||
:task="timeEntry.task_id"
|
:task="timeEntry.task_id"
|
||||||
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
|
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center font-medium space-x-1 @lg:space-x-2">
|
<div class="flex items-center font-medium space-x-1 lg:space-x-2">
|
||||||
<div v-if="showMember && members" class="text-sm px-2">
|
<div v-if="showMember && members" class="text-sm px-2">
|
||||||
{{ memberName }}
|
{{ memberName }}
|
||||||
</div>
|
</div>
|
||||||
@@ -166,29 +148,11 @@ async function handleDeleteTimeEntry() {
|
|||||||
class="opacity-20 flex focus-visible:opacity-100 group-hover:opacity-100"
|
class="opacity-20 flex focus-visible:opacity-100 group-hover:opacity-100"
|
||||||
@changed="onStartStopClick"></TimeTrackerStartStop>
|
@changed="onStartStopClick"></TimeTrackerStartStop>
|
||||||
<TimeEntryMoreOptionsDropdown
|
<TimeEntryMoreOptionsDropdown
|
||||||
@edit="handleEdit"
|
|
||||||
@duplicate="duplicateTimeEntry"
|
|
||||||
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
|
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TimeEntryEditModal
|
|
||||||
v-model:show="showEditModal"
|
|
||||||
:time-entry="timeEntry"
|
|
||||||
:enable-estimated-time="enableEstimatedTime"
|
|
||||||
:update-time-entry="handleUpdateTimeEntry"
|
|
||||||
:delete-time-entry="handleDeleteTimeEntry"
|
|
||||||
:create-client="createClient"
|
|
||||||
:create-project="createProject"
|
|
||||||
:create-tag="createTag"
|
|
||||||
:tags="tags"
|
|
||||||
:projects="projects"
|
|
||||||
:tasks="tasks"
|
|
||||||
:clients="clients"
|
|
||||||
:currency="currency"
|
|
||||||
:can-create-project="canCreateProject" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const open = ref(false);
|
|||||||
function updateTimerAndStartLiveTimerUpdate() {
|
function updateTimerAndStartLiveTimerUpdate() {
|
||||||
const defaultUnit =
|
const defaultUnit =
|
||||||
organizationSettings?.value?.intervalFormat === 'decimal' ? 'hours' : 'minutes';
|
organizationSettings?.value?.intervalFormat === 'decimal' ? 'hours' : 'minutes';
|
||||||
const seconds = parseTimeInput(temporaryCustomTimerEntry.value, defaultUnit);
|
const { seconds } = parseTimeInput(temporaryCustomTimerEntry.value, defaultUnit);
|
||||||
if (seconds && seconds > 0) {
|
if (seconds && seconds > 0) {
|
||||||
let newEndDate = props.end;
|
let newEndDate = props.end;
|
||||||
let newStartDate = props.start;
|
let newStartDate = props.start;
|
||||||
@@ -77,7 +77,7 @@ function selectInput(event: Event) {
|
|||||||
v-model="currentTime"
|
v-model="currentTime"
|
||||||
data-testid="time_entry_duration_input"
|
data-testid="time_entry_duration_input"
|
||||||
name="Duration"
|
name="Duration"
|
||||||
class="text-text-primary w-[80px] !mr-2 px-1.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
|
class="text-text-primary w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
@focus="selectInput"
|
@focus="selectInput"
|
||||||
@keydown.tab="open = false"
|
@keydown.tab="open = false"
|
||||||
@blur="updateTimerAndStartLiveTimerUpdate"
|
@blur="updateTimerAndStartLiveTimerUpdate"
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ function selectUnselectAll(value: boolean) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="bg-row-heading-background border-t border-b border-row-heading-border py-1 text-xs @sm:text-sm">
|
class="bg-row-heading-background border-t border-b border-row-heading-border py-1 text-xs sm:text-sm">
|
||||||
<MainContainer>
|
<MainContainer>
|
||||||
<div class="flex group justify-between items-center">
|
<div class="flex group justify-between items-center">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<div class="w-5">
|
<div class="w-5">
|
||||||
<svg
|
<svg
|
||||||
class="w-3 @sm:w-4 text-icon-default group-hover:hidden block"
|
class="w-3 sm:w-4 text-icon-default group-hover:hidden block"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<g fill="none">
|
<g fill="none">
|
||||||
@@ -61,7 +61,7 @@ function selectUnselectAll(value: boolean) {
|
|||||||
{{ formatDate(date, organization?.date_format) }}
|
{{ formatDate(date, organization?.date_format) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-text-secondary pr-[87px] @lg:pr-[92px]">
|
<div class="text-text-secondary pr-[90px] lg:pr-[92px]">
|
||||||
<span class="font-medium">
|
<span class="font-medium">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import type {
|
|||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
import { useFocus } from '@vueuse/core';
|
import { useFocus } from '@vueuse/core';
|
||||||
import { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue';
|
import { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue';
|
||||||
import TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue';
|
import TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue';
|
||||||
@@ -32,7 +34,6 @@ const props = defineProps<{
|
|||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
timeEntries: TimeEntry[];
|
|
||||||
createTag: (name: string) => Promise<Tag | undefined>;
|
createTag: (name: string) => Promise<Tag | undefined>;
|
||||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
@@ -48,7 +49,6 @@ const emit = defineEmits<{
|
|||||||
updateTimeEntry: [];
|
updateTimeEntry: [];
|
||||||
startLiveTimer: [];
|
startLiveTimer: [];
|
||||||
stopLiveTimer: [];
|
stopLiveTimer: [];
|
||||||
createTimeEntry: [];
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function updateProject() {
|
function updateProject() {
|
||||||
@@ -130,9 +130,10 @@ function updateTimeEntryDescription() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { timeEntries } = storeToRefs(useTimeEntriesStore());
|
||||||
const filteredRecentlyTrackedTimeEntries = computed(() => {
|
const filteredRecentlyTrackedTimeEntries = computed(() => {
|
||||||
// do not include running time entries
|
// do not include running time entries
|
||||||
const finishedTimeEntries = props.timeEntries.filter((item) => item.end !== null);
|
const finishedTimeEntries = timeEntries.value.filter((item) => item.end !== null);
|
||||||
|
|
||||||
// filter out duplicates based on description, task, project, tags and billable
|
// filter out duplicates based on description, task, project, tags and billable
|
||||||
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
|
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
|
||||||
@@ -279,7 +280,6 @@ useSelectEvents(
|
|||||||
@stop-live-timer="emit('stopLiveTimer')"
|
@stop-live-timer="emit('stopLiveTimer')"
|
||||||
@update-timer="emit('updateTimeEntry')"
|
@update-timer="emit('updateTimeEntry')"
|
||||||
@start-timer="emit('startTimer')"
|
@start-timer="emit('startTimer')"
|
||||||
@create-time-entry="emit('createTimeEntry')"
|
|
||||||
@keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector>
|
@keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { PlusIcon, XMarkIcon } from '@heroicons/vue/20/solid';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/Components/ui/dropdown-menu';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
hasActiveTimer: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
manualEntry: [];
|
|
||||||
discard: [];
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger as-child>
|
|
||||||
<button
|
|
||||||
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring hover:bg-card-background hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
|
|
||||||
aria-label="Time entry actions">
|
|
||||||
<svg
|
|
||||||
class="h-8 w-8 p-1 rounded-full"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="1.5"
|
|
||||||
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent class="min-w-[150px]" align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
class="flex items-center space-x-3 cursor-pointer"
|
|
||||||
@click="emit('manualEntry')">
|
|
||||||
<PlusIcon class="w-5" />
|
|
||||||
<span>Manual time entry</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
v-if="props.hasActiveTimer"
|
|
||||||
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
|
|
||||||
@click="emit('discard')">
|
|
||||||
<XMarkIcon class="w-5" />
|
|
||||||
<span>Discard</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -16,7 +16,6 @@ const emit = defineEmits<{
|
|||||||
stopLiveTimer: [];
|
stopLiveTimer: [];
|
||||||
updateTimer: [];
|
updateTimer: [];
|
||||||
startTimer: [];
|
startTimer: [];
|
||||||
createTimeEntry: [];
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
@@ -56,7 +55,7 @@ const currentTime = computed({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function updateTimerAndStartLiveTimerUpdate() {
|
function updateTimerAndStartLiveTimerUpdate() {
|
||||||
const seconds = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes');
|
const { seconds } = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes');
|
||||||
|
|
||||||
if (seconds && seconds > 0) {
|
if (seconds && seconds > 0) {
|
||||||
const newStartDate = dayjs().subtract(seconds, 's');
|
const newStartDate = dayjs().subtract(seconds, 's');
|
||||||
@@ -74,16 +73,12 @@ function updateTimerAndStartLiveTimerUpdate() {
|
|||||||
|
|
||||||
const temporaryCustomTimerEntry = ref<string>('');
|
const temporaryCustomTimerEntry = ref<string>('');
|
||||||
|
|
||||||
async function updateTimeRange(newStart: string, newEnd: string | null) {
|
async function updateTimeRange(newStart: string) {
|
||||||
// prohibit updates in the future
|
// prohibit updates in the future
|
||||||
if (getDayJsInstance()(newStart).isBefore(getDayJsInstance()())) {
|
if (getDayJsInstance()(newStart).isBefore(getDayJsInstance()())) {
|
||||||
currentTimeEntry.value.start = newStart;
|
currentTimeEntry.value.start = newStart;
|
||||||
currentTimeEntry.value.end = newEnd;
|
|
||||||
if (currentTimeEntry.value.id) {
|
if (currentTimeEntry.value.id) {
|
||||||
emit('updateTimer');
|
emit('updateTimer');
|
||||||
} else if (newEnd !== null) {
|
|
||||||
// If there's no ID but we have both start and end, create a new time entry
|
|
||||||
emit('createTimeEntry');
|
|
||||||
} else {
|
} else {
|
||||||
emit('startTimer');
|
emit('startTimer');
|
||||||
}
|
}
|
||||||
@@ -96,21 +91,11 @@ const startTime = computed(() => {
|
|||||||
}
|
}
|
||||||
return dayjs().utc().format();
|
return dayjs().utc().format();
|
||||||
});
|
});
|
||||||
|
|
||||||
const endTime = computed(() => {
|
|
||||||
if (currentTimeEntry.value.end && currentTimeEntry.value.end !== '') {
|
|
||||||
return currentTimeEntry.value.end;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const inputField = ref<HTMLInputElement | null>(null);
|
const inputField = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const timeRangeSelector = ref<HTMLElement | null>(null);
|
const timeRangeSelector = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
function openModalOnTab(e: FocusEvent) {
|
function openModalOnTab(e: FocusEvent) {
|
||||||
pauseLiveTimerUpdate(e);
|
|
||||||
|
|
||||||
// check if the source is inside the dropdown
|
// check if the source is inside the dropdown
|
||||||
const source = e.relatedTarget as HTMLElement;
|
const source = e.relatedTarget as HTMLElement;
|
||||||
if (source && window.document.body.querySelector<HTMLElement>('#app')?.contains(source)) {
|
if (source && window.document.body.querySelector<HTMLElement>('#app')?.contains(source)) {
|
||||||
@@ -118,12 +103,6 @@ function openModalOnTab(e: FocusEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openModalOnClick(e: MouseEvent) {
|
|
||||||
pauseLiveTimerUpdate(e);
|
|
||||||
|
|
||||||
open.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusNextElement(e: KeyboardEvent) {
|
function focusNextElement(e: KeyboardEvent) {
|
||||||
if (open.value) {
|
if (open.value) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -156,8 +135,8 @@ function closeAndFocusInput() {
|
|||||||
data-testid="time_entry_time"
|
data-testid="time_entry_time"
|
||||||
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-muted focus:ring-0 transition"
|
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-muted focus:ring-0 transition"
|
||||||
type="text"
|
type="text"
|
||||||
|
@focus="pauseLiveTimerUpdate"
|
||||||
@focusin="openModalOnTab"
|
@focusin="openModalOnTab"
|
||||||
@click="openModalOnClick"
|
|
||||||
@keydown.exact.tab="focusNextElement"
|
@keydown.exact.tab="focusNextElement"
|
||||||
@keydown.exact.shift.tab="open = false"
|
@keydown.exact.shift.tab="open = false"
|
||||||
@blur="updateTimerAndStartLiveTimerUpdate"
|
@blur="updateTimerAndStartLiveTimerUpdate"
|
||||||
@@ -167,7 +146,7 @@ function closeAndFocusInput() {
|
|||||||
<div ref="timeRangeSelector">
|
<div ref="timeRangeSelector">
|
||||||
<TimeRangeSelector
|
<TimeRangeSelector
|
||||||
:start="startTime"
|
:start="startTime"
|
||||||
:end="endTime"
|
:end="null"
|
||||||
@changed="updateTimeRange"
|
@changed="updateTimeRange"
|
||||||
@close="closeAndFocusInput">
|
@close="closeAndFocusInput">
|
||||||
</TimeRangeSelector>
|
</TimeRangeSelector>
|
||||||
|
|||||||
@@ -10,12 +10,8 @@ import * as color from './utils/color';
|
|||||||
import * as random from './utils/random';
|
import * as random from './utils/random';
|
||||||
import * as time from './utils/time';
|
import * as time from './utils/time';
|
||||||
|
|
||||||
export { cn } from './utils/cn';
|
|
||||||
export { buttonVariants, type ButtonVariants } from './Buttons/index';
|
|
||||||
|
|
||||||
import PrimaryButton from './Buttons/PrimaryButton.vue';
|
import PrimaryButton from './Buttons/PrimaryButton.vue';
|
||||||
import SecondaryButton from './Buttons/SecondaryButton.vue';
|
import SecondaryButton from './Buttons/SecondaryButton.vue';
|
||||||
import Button from './Buttons/Button.vue';
|
|
||||||
import TimeTrackerStartStop from './TimeTrackerStartStop.vue';
|
import TimeTrackerStartStop from './TimeTrackerStartStop.vue';
|
||||||
import ProjectBadge from './Project/ProjectBadge.vue';
|
import ProjectBadge from './Project/ProjectBadge.vue';
|
||||||
import LoadingSpinner from './LoadingSpinner.vue';
|
import LoadingSpinner from './LoadingSpinner.vue';
|
||||||
@@ -24,7 +20,6 @@ import TextInput from './Input/TextInput.vue';
|
|||||||
import InputLabel from './Input/InputLabel.vue';
|
import InputLabel from './Input/InputLabel.vue';
|
||||||
import TimeTrackerRunningInDifferentOrganizationOverlay from './TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
|
import TimeTrackerRunningInDifferentOrganizationOverlay from './TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
|
||||||
import TimeTrackerControls from './TimeTracker/TimeTrackerControls.vue';
|
import TimeTrackerControls from './TimeTracker/TimeTrackerControls.vue';
|
||||||
import TimeTrackerMoreOptionsDropdown from './TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
|
|
||||||
import CardTitle from './CardTitle.vue';
|
import CardTitle from './CardTitle.vue';
|
||||||
import SelectDropdown from './Input/SelectDropdown.vue';
|
import SelectDropdown from './Input/SelectDropdown.vue';
|
||||||
import Badge from './Badge.vue';
|
import Badge from './Badge.vue';
|
||||||
@@ -32,20 +27,13 @@ import Checkbox from './Input/Checkbox.vue';
|
|||||||
import TimeEntryGroupedTable from './TimeEntry/TimeEntryGroupedTable.vue';
|
import TimeEntryGroupedTable from './TimeEntry/TimeEntryGroupedTable.vue';
|
||||||
import TimeEntryMassActionRow from './TimeEntry/TimeEntryMassActionRow.vue';
|
import TimeEntryMassActionRow from './TimeEntry/TimeEntryMassActionRow.vue';
|
||||||
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
|
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
|
||||||
import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
|
|
||||||
import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
|
import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
|
||||||
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
|
||||||
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
|
|
||||||
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
|
|
||||||
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
money,
|
money,
|
||||||
color,
|
color,
|
||||||
random,
|
random,
|
||||||
time,
|
time,
|
||||||
Button,
|
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
TimeTrackerStartStop,
|
TimeTrackerStartStop,
|
||||||
@@ -56,7 +44,6 @@ export {
|
|||||||
InputLabel,
|
InputLabel,
|
||||||
TimeTrackerRunningInDifferentOrganizationOverlay,
|
TimeTrackerRunningInDifferentOrganizationOverlay,
|
||||||
TimeTrackerControls,
|
TimeTrackerControls,
|
||||||
TimeTrackerMoreOptionsDropdown,
|
|
||||||
CardTitle,
|
CardTitle,
|
||||||
SelectDropdown,
|
SelectDropdown,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -65,12 +52,4 @@ export {
|
|||||||
TimeEntryMassActionRow,
|
TimeEntryMassActionRow,
|
||||||
MoreOptionsDropdown,
|
MoreOptionsDropdown,
|
||||||
TimeEntryCreateModal,
|
TimeEntryCreateModal,
|
||||||
TimeEntryEditModal,
|
|
||||||
FullCalendarEventContent,
|
|
||||||
FullCalendarDayHeader,
|
|
||||||
TimeEntryCalendar,
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui';
|
|
||||||
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui';
|
|
||||||
|
|
||||||
const props = defineProps<TooltipRootProps>();
|
|
||||||
const emits = defineEmits<TooltipRootEmits>();
|
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(props, emits);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<TooltipRoot v-bind="forwarded">
|
|
||||||
<slot />
|
|
||||||
</TooltipRoot>
|
|
||||||
</template>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui';
|
|
||||||
import type { HTMLAttributes } from 'vue';
|
|
||||||
import { reactiveOmit } from '@vueuse/core';
|
|
||||||
import { TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
inheritAttrs: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(),
|
|
||||||
{
|
|
||||||
sideOffset: 4,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const emits = defineEmits<TooltipContentEmits>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, 'class');
|
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<TooltipPortal>
|
|
||||||
<TooltipContent
|
|
||||||
v-bind="{ ...forwarded, ...$attrs }"
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
|
||||||
props.class
|
|
||||||
)
|
|
||||||
">
|
|
||||||
<slot />
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPortal>
|
|
||||||
</template>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { TooltipProviderProps } from 'reka-ui';
|
|
||||||
import { TooltipProvider } from 'reka-ui';
|
|
||||||
|
|
||||||
const props = defineProps<TooltipProviderProps>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<TooltipProvider v-bind="props">
|
|
||||||
<slot />
|
|
||||||
</TooltipProvider>
|
|
||||||
</template>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { TooltipTriggerProps } from 'reka-ui';
|
|
||||||
import { TooltipTrigger } from 'reka-ui';
|
|
||||||
|
|
||||||
const props = defineProps<TooltipTriggerProps>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<TooltipTrigger v-bind="props">
|
|
||||||
<slot />
|
|
||||||
</TooltipTrigger>
|
|
||||||
</template>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { default as Tooltip } from './Tooltip.vue';
|
|
||||||
export { default as TooltipContent } from './TooltipContent.vue';
|
|
||||||
export { default as TooltipProvider } from './TooltipProvider.vue';
|
|
||||||
export { default as TooltipTrigger } from './TooltipTrigger.vue';
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { type ClassValue, clsx } from 'clsx';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs.filter(Boolean)));
|
|
||||||
}
|
|
||||||
@@ -208,30 +208,22 @@ export function formatStartEnd(
|
|||||||
export function parseTimeInput(
|
export function parseTimeInput(
|
||||||
input: string,
|
input: string,
|
||||||
defaultUnit: TimeInputUnit = 'minutes'
|
defaultUnit: TimeInputUnit = 'minutes'
|
||||||
): number | null {
|
): {
|
||||||
|
seconds: number | null;
|
||||||
|
isHHMM: boolean;
|
||||||
|
} {
|
||||||
// Check if input is a decimal number (hours)
|
// Check if input is a decimal number (hours)
|
||||||
const decimalRegex = /^-?\d+[.,]\d+$/;
|
const decimalRegex = /^-?\d+[.,]\d+$/;
|
||||||
if (decimalRegex.test(input)) {
|
if (decimalRegex.test(input)) {
|
||||||
const hours = parseFloat(input.replace(',', '.'));
|
const hours = parseFloat(input.replace(',', '.'));
|
||||||
return Math.round(hours * 3600);
|
return { seconds: Math.round(hours * 3600), isHHMM: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if input is just a number (minutes or hours based on defaultUnit)
|
// Check if input is just a number (minutes or hours based on defaultUnit)
|
||||||
if (/^-?\d+$/.test(input)) {
|
if (/^-?\d+$/.test(input)) {
|
||||||
const value = parseInt(input);
|
const value = parseInt(input);
|
||||||
return defaultUnit === 'minutes' ? value * 60 : value * 3600;
|
const seconds = defaultUnit === 'minutes' ? value * 60 : value * 3600;
|
||||||
}
|
return { seconds, isHHMM: false };
|
||||||
|
|
||||||
// Check if input is in HH:MM:SS format
|
|
||||||
const HHMMSStimeRegex = /^([0-9]{1,2}):([0-5]?[0-9]):([0-5]?[0-9])$/;
|
|
||||||
if (HHMMSStimeRegex.test(input)) {
|
|
||||||
const match = input.match(HHMMSStimeRegex);
|
|
||||||
if (match) {
|
|
||||||
const hours = parseInt(match[1]);
|
|
||||||
const minutes = parseInt(match[2]);
|
|
||||||
const seconds = parseInt(match[3]);
|
|
||||||
return hours * 3600 + minutes * 60 + seconds;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if input is in HH:MM format
|
// Check if input is in HH:MM format
|
||||||
@@ -241,15 +233,15 @@ export function parseTimeInput(
|
|||||||
if (match) {
|
if (match) {
|
||||||
const hours = parseInt(match[1]);
|
const hours = parseInt(match[1]);
|
||||||
const minutes = parseInt(match[2]);
|
const minutes = parseInt(match[2]);
|
||||||
return (hours * 60 + minutes) * 60;
|
return { seconds: (hours * 60 + minutes) * 60, isHHMM: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse natural language like "1h 30m"
|
// Try to parse natural language like "1h 30m"
|
||||||
const parsedDuration = parse(input, 's');
|
const parsedDuration = parse(input, 's');
|
||||||
if (parsedDuration && parsedDuration > 0) {
|
if (parsedDuration && parsedDuration > 0) {
|
||||||
return parsedDuration;
|
return { seconds: parsedDuration, isHHMM: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return { seconds: null, isHHMM: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,240 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared styles for solidtime
|
|
||||||
* This CSS file contains all the shared theme variables and base styles
|
|
||||||
* used by both the main solidtime app and the desktop app.
|
|
||||||
*
|
|
||||||
* Font-face declarations are intentionally omitted here as they differ between apps:
|
|
||||||
* - Main app uses 'Inter'
|
|
||||||
* - Desktop app uses 'Outfit'
|
|
||||||
* Each app should include their own font-face declarations.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@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;
|
|
||||||
|
|
||||||
--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));
|
|
||||||
--theme-color-default-background: var(--color-bg-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light {
|
|
||||||
--color-bg-primary: #ffffff;
|
|
||||||
--color-bg-secondary: #f7f7f8;
|
|
||||||
--color-bg-tertiary: #eeeeef;
|
|
||||||
--color-bg-quaternary: #e1e1e3;
|
|
||||||
--color-bg-background: #f5f5f5;
|
|
||||||
--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-quaternary);
|
|
||||||
|
|
||||||
--theme-color-card-background: var(--color-bg-primary);
|
|
||||||
--theme-color-card-background-active: var(--color-bg-tertiary);
|
|
||||||
|
|
||||||
--theme-color-chart: var(--color-accent-400);
|
|
||||||
|
|
||||||
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
|
|
||||||
--theme-shadow-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-primary);
|
|
||||||
|
|
||||||
--theme-color-input-select-active: rgb(var(--color-accent-400));
|
|
||||||
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
|
|
||||||
|
|
||||||
--color-accent-default: rgb(var(--color-accent-100));
|
|
||||||
--color-accent-foreground: rgb(var(--color-accent-800));
|
|
||||||
--theme-color-default-background: #fcfcfc;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--theme-color-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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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(--color-bg-primary);
|
|
||||||
--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(--color-bg-primary);
|
|
||||||
--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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared Tailwind theme configuration for solidtime
|
|
||||||
* This configuration is used by both the main solidtime app and the desktop app
|
|
||||||
*
|
|
||||||
* Note: fontFamily is intentionally omitted here as it differs between apps:
|
|
||||||
* - Main app uses 'Inter'
|
|
||||||
* - Desktop app uses 'Outfit'
|
|
||||||
* Each app should override the fontFamily in their own config.
|
|
||||||
*/
|
|
||||||
export const solidtimeTheme = {
|
|
||||||
boxShadow: {
|
|
||||||
card: 'var(--theme-shadow-card)',
|
|
||||||
dropdown: 'var(--theme-shadow-dropdown)',
|
|
||||||
},
|
|
||||||
containers: {
|
|
||||||
'2xs': '16rem',
|
|
||||||
},
|
|
||||||
fontSize: {
|
|
||||||
'2xs': ['0.625rem', { lineHeight: '0.75rem' }],
|
|
||||||
xs: ['0.75rem', { lineHeight: '1rem' }],
|
|
||||||
sm: ['0.8125rem', { lineHeight: '1.125rem' }],
|
|
||||||
base: ['0.875rem', { lineHeight: '1.25rem' }],
|
|
||||||
lg: ['1rem', { lineHeight: '1.5rem' }],
|
|
||||||
xl: ['1.125rem', { lineHeight: '1.75rem' }],
|
|
||||||
'2xl': ['1.25rem', { lineHeight: '1.75rem' }],
|
|
||||||
'3xl': ['1.5rem', { lineHeight: '2rem' }],
|
|
||||||
'4xl': ['1.75rem', { lineHeight: '2.25rem' }],
|
|
||||||
'5xl': ['2rem', { lineHeight: '1' }],
|
|
||||||
'6xl': ['2.25rem', { lineHeight: '1' }],
|
|
||||||
'7xl': ['2.5rem', { lineHeight: '1' }],
|
|
||||||
'8xl': ['3rem', { lineHeight: '1' }],
|
|
||||||
'9xl': ['3.5rem', { lineHeight: '1' }],
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
ring: 'var(--ring)',
|
|
||||||
primary: {
|
|
||||||
DEFAULT: 'var(--primary)',
|
|
||||||
foreground: 'var(--primary-foreground)',
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
|
||||||
foreground: 'hsl(var(--secondary-foreground))',
|
|
||||||
},
|
|
||||||
tertiary: 'var(--color-bg-tertiary)',
|
|
||||||
quaternary: 'var(--color-bg-quaternary)',
|
|
||||||
background: 'var(--background)',
|
|
||||||
'text-primary': 'var(--color-text-primary)',
|
|
||||||
'text-secondary': 'var(--color-text-secondary)',
|
|
||||||
'text-tertiary': 'var(--color-text-tertiary)',
|
|
||||||
'text-quaternary': 'var(--color-text-quaternary)',
|
|
||||||
'border-primary': 'var(--color-border-primary)',
|
|
||||||
'border-secondary': 'var(--color-border-secondary)',
|
|
||||||
'border-tertiary': 'var(--color-border-tertiary)',
|
|
||||||
'default-background': 'var(--theme-color-default-background)',
|
|
||||||
'default-background-separator': 'var(--theme-color-default-background-separator)',
|
|
||||||
'row-background': 'var(--theme-color-row-background)',
|
|
||||||
'card-background': 'var(--theme-color-card-background)',
|
|
||||||
'card-background-active': 'var(--theme-color-card-background-active)',
|
|
||||||
'card-background-separator': 'var(--theme-color-card-background-separator)',
|
|
||||||
'card-border': 'var(--theme-color-card-border)',
|
|
||||||
'card-border-active': 'var(--theme-color-card-border-active)',
|
|
||||||
muted: {
|
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
|
||||||
foreground: 'hsl(var(--muted-foreground))',
|
|
||||||
},
|
|
||||||
'tab-background': 'var(--theme-color-tab-background)',
|
|
||||||
'tab-background-active': 'var(--theme-color-tab-background-active)',
|
|
||||||
'tab-border': 'var(--theme-color-tab-border)',
|
|
||||||
'icon-default': 'var(--theme-color-icon-default)',
|
|
||||||
'icon-active': 'var(--theme-color-icon-active)',
|
|
||||||
'menu-active': 'var(--theme-color-menu-active)',
|
|
||||||
'input-border': 'var(--theme-color-input-border)',
|
|
||||||
'input-border-active': 'var(--color-input-border-active)',
|
|
||||||
'input-background': 'var(--theme-color-input-background)',
|
|
||||||
'button-secondary-background': 'var(--theme-button-secondary-background)',
|
|
||||||
'button-secondary-background-hover': 'var(--theme-button-secondary-background-active)',
|
|
||||||
'button-secondary-border': 'var(--theme-color-card-border)',
|
|
||||||
'row-separator': 'var(--theme-color-row-separator-background)',
|
|
||||||
'row-heading-background': 'var(--theme-color-row-heading-background)',
|
|
||||||
'row-heading-border': 'var(--theme-color-row-heading-border)',
|
|
||||||
accent: {
|
|
||||||
'50': 'rgba(var(--color-accent-50), <alpha-value>)',
|
|
||||||
'100': 'rgba(var(--color-accent-100), <alpha-value>)',
|
|
||||||
'200': 'rgba(var(--color-accent-200), <alpha-value>)',
|
|
||||||
'300': 'rgba(var(--color-accent-300), <alpha-value>)',
|
|
||||||
'400': 'rgba(var(--color-accent-400), <alpha-value>)',
|
|
||||||
'500': 'rgba(var(--color-accent-500), <alpha-value>)',
|
|
||||||
'600': 'rgba(var(--color-accent-600), <alpha-value>)',
|
|
||||||
'700': 'rgba(var(--color-accent-700), <alpha-value>)',
|
|
||||||
'800': 'rgba(var(--color-accent-800), <alpha-value>)',
|
|
||||||
'900': 'rgba(var(--color-accent-900), <alpha-value>)',
|
|
||||||
'950': 'rgba(var(--color-accent-950), <alpha-value>)',
|
|
||||||
DEFAULT: 'var(--color-accent-default)',
|
|
||||||
foreground: 'var(--color-accent-foreground)',
|
|
||||||
},
|
|
||||||
'button-primary-background': 'var(--theme-color-button-primary-background)',
|
|
||||||
'button-primary-background-hover': 'var(--theme-color-button-primary-background-hover)',
|
|
||||||
'button-primary-border': 'var(--theme-color-button-primary-border)',
|
|
||||||
'button-primary-text': 'var(--theme-color-button-primary-text)',
|
|
||||||
'input-select-active': 'var(--theme-color-input-select-active)',
|
|
||||||
'input-select-active-hover': 'var(--theme-color-input-select-active-hover)',
|
|
||||||
foreground: 'var(--foreground)',
|
|
||||||
card: {
|
|
||||||
DEFAULT: 'var(--card))',
|
|
||||||
foreground: 'var(--card-foreground))',
|
|
||||||
},
|
|
||||||
popover: {
|
|
||||||
DEFAULT: 'var(--popover)',
|
|
||||||
foreground: 'var(--popover-foreground)',
|
|
||||||
border: 'var(--popover-border)',
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
DEFAULT: 'var(--destructive)',
|
|
||||||
foreground: 'var(--destructive-foreground)',
|
|
||||||
},
|
|
||||||
border: 'var(--border)',
|
|
||||||
input: 'var(--input)',
|
|
||||||
chart: {
|
|
||||||
'1': 'hsl(var(--chart-1))',
|
|
||||||
'2': 'hsl(var(--chart-2))',
|
|
||||||
'3': 'hsl(var(--chart-3))',
|
|
||||||
'4': 'hsl(var(--chart-4))',
|
|
||||||
'5': 'hsl(var(--chart-5))',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
lg: 'var(--radius)',
|
|
||||||
md: 'calc(var(--radius) - 2px)',
|
|
||||||
sm: 'calc(var(--radius) - 4px)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export function openFeedback(): void {
|
|
||||||
if (
|
|
||||||
typeof window !== 'undefined' &&
|
|
||||||
'showChatWindow' in window &&
|
|
||||||
typeof window.showChatWindow === 'function'
|
|
||||||
) {
|
|
||||||
window.showChatWindow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -81,7 +81,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
|||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch {
|
} catch {
|
||||||
router.get('/login');
|
router.get(route('login'));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
addNotification('error', 'The action failed. Please try again later.');
|
addNotification('error', 'The action failed. Please try again later.');
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user