Compare commits

..

3 Commits

Author SHA1 Message Date
Constantin Graf
cdf783ce24 Updated the PR template 2025-07-31 13:56:00 +02:00
Gregor Vostrak
480c44771e add contributing notice that you need to run the format command 2025-07-31 13:56:00 +02:00
Gregor Vostrak
ed1ffaf0db add CONTRIBUTING.md 2025-07-31 13:56:00 +02:00
110 changed files with 1727 additions and 5542 deletions

View File

@@ -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 }}

View File

@@ -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'))

View File

@@ -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
{ {

View File

@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class OverlappingTimeEntryApiException extends ApiException
{
public const string KEY = 'overlapping_time_entry';
}

View File

@@ -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');

View File

@@ -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;

View File

@@ -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;

View File

@@ -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),

View File

@@ -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;
}
} }

View File

@@ -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' => [

View File

@@ -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' => [

View File

@@ -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' => [

View File

@@ -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 */

View File

@@ -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>
*/ */

View File

@@ -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,

View File

@@ -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.');

View File

@@ -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'];

View File

@@ -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'];

View File

@@ -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'];

View File

@@ -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';
} }
} }

View File

@@ -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');
});
}
};

View File

@@ -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();
});
}
};

View File

@@ -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,

View File

@@ -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};

View File

@@ -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/ \

View File

@@ -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');

View File

@@ -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

View File

@@ -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.',
]; ];

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}
}

View File

@@ -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',

View File

@@ -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;

View File

@@ -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,

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>

View File

@@ -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,

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 &#x60;time-entries:view:own&#x60; 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 &#x60;time-entries:view:own&#x60; 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 &#x60;time-entries:view:own&#x60; 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 &#x60;time-entries:view:own&#x60; 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 &#x60;time-entries:view:own&#x60; 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 &#x60;time-entries:view:own&#x60; 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 &#x60;time-entries:view:own&#x60; 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 &#x60;time-entries:view:own&#x60; 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 &#x60;null&#x60; 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 &#x60;null&#x60; 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 &#x60;null&#x60; 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 &#x60;null&#x60; 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 &#x60;null&#x60; or are all missing, the
'client', 'client',
'billable', 'billable',
'description', 'description',
'tag',
]), ]),
}, },
{ {
@@ -4037,7 +4174,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client', 'client',
'billable', 'billable',
'description', 'description',
'tag',
]), ]),
}, },
{ {
@@ -4085,16 +4221,6 @@ If the group parameters are all set to &#x60;null&#x60; 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 &#x60;null&#x60; 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 &#x60;null&#x60; 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 &#x60;null&#x60; 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 &#x60;null&#x60; 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 &#x60;null&#x60; 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(),
}, },
{ {

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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>

View File

@@ -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',

View File

@@ -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>;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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';

View File

@@ -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()"

View File

@@ -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>

View File

@@ -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"

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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"

View File

@@ -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'
) )
"> ">

View File

@@ -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>

View File

@@ -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"

View File

@@ -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(

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
}; };

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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';

View File

@@ -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)));
}

View File

@@ -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 };
} }

View File

@@ -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;
}
}

View File

@@ -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)',
},
};

View File

@@ -1,9 +0,0 @@
export function openFeedback(): void {
if (
typeof window !== 'undefined' &&
'showChatWindow' in window &&
typeof window.showChatWindow === 'function'
) {
window.showChatWindow();
}
}

View File

@@ -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