Compare commits

...

64 Commits

Author SHA1 Message Date
Claude
bb2d6d05e9 add tests for admin time entry create and edit
https://claude.ai/code/session_01CVi5hmyiPDpvEzzTD7RPr7
2026-02-02 01:31:45 +00:00
Claude
ec815d8c26 add missing fields to admin time entry form and derive user/org from member, fixes #997
https://claude.ai/code/session_01CVi5hmyiPDpvEzzTD7RPr7
2026-02-02 01:31:37 +00:00
Constantin Graf
51af3db305 Add test to TimeEntryEndpointTest 2026-01-28 12:56:58 +01:00
Gregor Vostrak
f242ce48b5 change rounding up on boundaries so it does not round up but keeps the value, fixes #994 2026-01-28 12:56:58 +01:00
Gregor Vostrak
19064cdc3d make time entry calendar use seconds as a duration basis, fixes #996 2026-01-15 17:07:50 +01:00
Gregor Vostrak
5a05ee35e0 change dashboard card colors and input background colors 2026-01-09 01:16:23 +01:00
Gregor Vostrak
00d9d1488e improve time entry heading contrast in light mode 2026-01-08 20:17:54 +01:00
Gregor Vostrak
9bbbfdfafe improve visual hierarchy in time view 2026-01-08 19:53:53 +01:00
Gregor Vostrak
d27f023e16 refactor BaseFilterBadge to use DropdownMenuTrigger directly and avoid class merging conflicts 2026-01-08 19:14:59 +01:00
Gregor Vostrak
db57055941 add filters and sorting to projects table 2026-01-08 18:07:17 +01:00
Gregor Vostrak
743c64909a restrict time entries create endpoints for employees to only projects where they have access to 2025-12-17 12:54:07 +01:00
Gregor Vostrak
de97d15925 add tailwind theme and css variables to files export, bump ui package version 2025-12-09 16:44:55 +01:00
Gregor Vostrak
0691fe10ef add direct axios dependency to package, bump package versions 2025-12-09 16:44:55 +01:00
Gregor Vostrak
513b2048ee move TimezonMismatchModal to ui package 2025-12-09 16:44:55 +01:00
Gregor Vostrak
3acf9b8b07 add support for window activities in the calendar view plugin 2025-12-09 16:44:55 +01:00
Gregor Vostrak
814d539fb0 move rangecalendar, popover and daterangepicker to ui package 2025-12-09 16:44:55 +01:00
Gregor Vostrak
7a51fca2f9 only show Weekly Billable Amount of current organization on dashboard, fixes #977 2025-12-02 13:30:08 +01:00
Gregor Vostrak
280032ee02 allow employee manage task setting to organization 2025-11-25 15:39:20 +01:00
Gregor Vostrak
b1bb7245b0 use default api limit for fetching time entries 2025-11-20 17:30:13 +01:00
Gregor Vostrak
6f37ad500a limit initially loaded time entries on time page 2025-11-20 16:58:53 +01:00
Gregor Vostrak
500ccd5719 fix container queries for time entry rows 2025-11-20 16:52:08 +01:00
Gregor Vostrak
bacd6f4222 include the currently running time entry in the calendar header 2025-11-20 13:17:48 +01:00
Gregor Vostrak
022caf59ee bump solidtime ui package version to 0.0.13 2025-11-19 17:34:21 +01:00
Gregor Vostrak
f955ab3135 fix display problems caused by minimum height of calendar events 2025-11-19 17:34:21 +01:00
Gregor Vostrak
5b491b0da2 add support for currently running time entry 2025-11-19 17:34:21 +01:00
Gregor Vostrak
249ab67ac8 improve idle indicator colors, fix typescript issues 2025-11-19 17:34:21 +01:00
Gregor Vostrak
1bd2c28b37 add tooltips to idlestatus indicators 2025-11-19 17:34:21 +01:00
Gregor Vostrak
33ac994cc0 add activity status plugin to calendar 2025-11-19 17:34:21 +01:00
Gregor Vostrak
8d3ee58bed improve initial mount performance for groupedtimeentrytable by streaming in the rows
mounting the rows mounts lots of nested components which results in a delay on the initial mount.
2025-11-19 17:34:21 +01:00
Gregor Vostrak
8a2c260533 use container queries for time entry table 2025-11-19 17:34:21 +01:00
Gregor Vostrak
95ab1699c4 make sure that CreateTimeEntry modal always starts with times that have 0 seconds 2025-11-19 17:34:21 +01:00
Gregor Vostrak
306a081a3d prevent seconds update on timepicker when nothing else changes 2025-11-19 17:34:21 +01:00
Gregor Vostrak
878ac4ab81 add tooltip component 2025-11-19 17:34:21 +01:00
Gregor Vostrak
947550d639 move css variables and tailwind theme config into ui package 2025-11-19 17:34:21 +01:00
Gregor Vostrak
09fb5aa48e make sure that timepicker and calendar set seconds to 0 on update, fixes #968 2025-11-19 17:34:21 +01:00
Gregor Vostrak
9b9371e5a5 move button component to ui package 2025-11-19 17:34:21 +01:00
Gregor Vostrak
0648437478 design fixes, improve component encapsulation 2025-11-19 17:34:21 +01:00
Gregor Vostrak
8ba04eca0c move currency and cancreateproject permission to props to decouple TimeEntryCreateModal from web 2025-11-19 17:34:21 +01:00
Gregor Vostrak
8a2f35de0c fix package build error dependencies 2025-11-19 17:34:21 +01:00
Gregor Vostrak
b7dafb0892 bump api and ui package versions 2025-11-19 17:34:21 +01:00
Gregor Vostrak
6eca0c2c76 fix archived_at timestamp of client in exporter 2025-11-11 12:55:33 +01:00
Gregor Vostrak
3417b60585 only run self-hosting update and telemetry scheduler when app_key is set 2025-11-04 13:35:12 +01:00
Constantin Graf
0f21fabd37 Spread self-hosting update and telemetry requests over the day 2025-11-03 20:24:52 +01:00
Gregor Vostrak
df00200464 load current member time entries in calendar, to be consistent with time view 2025-10-22 14:36:21 +02:00
Gregor Vostrak
3b41de7135 remove project default listener in timeentry edit modal 2025-10-22 13:55:06 +02:00
Gregor Vostrak
9fe0ea5a0f add support for HH:mm:ss format for input time fields 2025-10-22 13:54:14 +02:00
Gregor Vostrak
f8f708a664 add set end time functionality to timetracker component 2025-10-21 17:24:46 +02:00
Gregor Vostrak
c359259e45 fix TimeRangeSelector dropdown behaviour when clicking after other input was focused before 2025-10-21 13:50:30 +02:00
Gregor Vostrak
55d12aaae1 add discard option for running timer 2025-10-21 12:49:49 +02:00
Alexander Groß
9a1dd4861c Extend description to 5000 chars, closes #914 2025-10-21 12:36:32 +02:00
Gregor Vostrak
1e985b71ec move Client visibleByEmployee logic from controller to model 2025-10-21 12:22:17 +02:00
Alexander Groß
93d6a86f74 Show clients that are assigned to the employee, closes #893 2025-10-21 12:20:28 +02:00
Gregor Vostrak
19a206d57c add prevent_overlapping_time_entries setting to organization
when enabled users are blocked from creating or editing new time entries that are overlapping with other time entries
2025-10-13 14:23:41 +02:00
Gregor Vostrak
c0788c270b fix typescript openapi mapping types 2025-10-07 17:42:44 +02:00
Gregor Vostrak
7765056074 add tag grouping 2025-10-07 17:15:20 +02:00
Kaspar Rosin
639f5332e4 feat: add duplicate time entry fields 2025-10-07 17:10:22 +02:00
Gregor Vostrak
4a50145329 fix calendar header timezone issue 2025-10-06 19:30:58 +02:00
Gregor Vostrak
8aabffd1e7 fix race condition in UserTimezoneMismatchModal 2025-10-06 18:33:57 +02:00
Gregor Vostrak
b373427dc7 add feedback button in sidebar 2025-10-01 13:20:23 +02:00
Gregor Vostrak
d2a4d60441 clarify UserSettingsIcon Dropdown Profile Settings Item Description 2025-10-01 13:20:23 +02:00
Gregor Vostrak
c3305b3df6 remove bottom padding for toast container
This became redundant due to the floating feedback bubble removal
2025-10-01 13:20:23 +02:00
Gregor Vostrak
7584e59d0b improve focus states and keyboard navigation for organization switcher and user settings dropdown 2025-10-01 13:20:23 +02:00
Gregor Vostrak
d2f75cca6e update organization switcher to use shadcn dropdownmenu 2025-10-01 13:20:23 +02:00
Gregor Vostrak
250379d4bd change profile dropdown to shadcn, add feedback entry 2025-10-01 13:20:23 +02:00
166 changed files with 6370 additions and 2487 deletions

View File

@@ -22,13 +22,27 @@ 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();
$schedule->command('self-host:check-for-update') if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) {
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update')) // Convert string to a stable integer for seeding
->twiceDaily(); /** @var int $seed Take the first 8 hex chars → 32-bit int */
$seed = hexdec(substr(hash('md5', config('app.key')), 0, 8));
$seed = abs($seed); // Ensure it's positive
mt_srand($seed);
$firstHour = mt_rand(0, 23);
$secondHour = ($firstHour + 12) % 24;
$minuteOffset = mt_rand(0, 59);
mt_srand(null); // Reset the random number generator
$schedule->command('self-host:telemetry') if (config('scheduling.tasks.self_hosting_check_for_update')) {
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry')) $schedule->command('self-host:check-for-update')
->twiceDaily(); ->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
}
if (config('scheduling.tasks.self_hosting_telemetry')) {
$schedule->command('self-host:telemetry')
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
}
}
$schedule->command('self-host:database-consistency') $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,6 +20,7 @@ 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

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\TimeEntryResource\Pages; use App\Filament\Resources\TimeEntryResource\Pages;
use App\Models\Member;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
@@ -16,6 +17,7 @@ use Filament\Tables;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class TimeEntryResource extends Resource class TimeEntryResource extends Resource
{ {
@@ -51,6 +53,20 @@ class TimeEntryResource extends Resource
->rules([ ->rules([
'after_or_equal:start', 'after_or_equal:start',
]), ]),
Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->required(),
Select::make('member_id')
->relationship(
name: 'member',
titleAttribute: 'id',
modifyQueryUsing: fn (Builder $query) => $query->with(['user', 'organization'])
)
->getOptionLabelFromRecordUsing(fn (Member $record): string => $record->user->email.' ('.$record->organization->name.')')
->searchable()
->preload()
->required(),
Select::make('user_id') Select::make('user_id')
->relationship(name: 'user', titleAttribute: 'email') ->relationship(name: 'user', titleAttribute: 'email')
->searchable(['name', 'email']) ->searchable(['name', 'email'])
@@ -59,7 +75,10 @@ class TimeEntryResource extends Resource
->relationship(name: 'project', titleAttribute: 'name') ->relationship(name: 'project', titleAttribute: 'name')
->searchable(['name']) ->searchable(['name'])
->nullable(), ->nullable(),
// TODO Select::make('task_id')
->relationship(name: 'task', titleAttribute: 'name')
->searchable(['name'])
->nullable(),
]); ]);
} }

View File

@@ -5,9 +5,28 @@ declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages; namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource; use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
class CreateTimeEntry extends CreateRecord class CreateTimeEntry extends CreateRecord
{ {
protected static string $resource = TimeEntryResource::class; protected static string $resource = TimeEntryResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
if (isset($data['member_id'])) {
/** @var Member|null $member */
$member = Member::query()->find($data['member_id']);
if ($member !== null) {
$data['user_id'] = $member->user_id;
$data['organization_id'] = $member->organization_id;
}
}
return $data;
}
} }

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages; namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource; use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
@@ -19,4 +20,22 @@ class EditTimeEntry extends EditRecord
->icon('heroicon-m-trash'), ->icon('heroicon-m-trash'),
]; ];
} }
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeSave(array $data): array
{
if (isset($data['member_id'])) {
/** @var Member|null $member */
$member = Member::query()->find($data['member_id']);
if ($member !== null) {
$data['user_id'] = $member->user_id;
$data['organization_id'] = $member->organization_id;
}
}
return $data;
}
} }

View File

@@ -38,11 +38,17 @@ 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

@@ -46,6 +46,9 @@ class OrganizationController extends Controller
if ($request->getEmployeesCanSeeBillableRates() !== null) { if ($request->getEmployeesCanSeeBillableRates() !== null) {
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates(); $organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
} }
if ($request->getEmployeesCanManageTasks() !== null) {
$organization->employees_can_manage_tasks = $request->getEmployeesCanManageTasks();
}
if ($request->getNumberFormat() !== null) { if ($request->getNumberFormat() !== null) {
$organization->number_format = $request->getNumberFormat(); $organization->number_format = $request->getNumberFormat();
} }
@@ -61,6 +64,9 @@ 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

@@ -11,6 +11,7 @@ use App\Http\Requests\V1\Task\TaskUpdateRequest;
use App\Http\Resources\V1\Task\TaskCollection; use App\Http\Resources\V1\Task\TaskCollection;
use App\Http\Resources\V1\Task\TaskResource; use App\Http\Resources\V1\Task\TaskResource;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project;
use App\Models\Task; use App\Models\Task;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -27,6 +28,26 @@ class TaskController extends Controller
} }
} }
/**
* Check scoped permission and verify user has access to the project
*
* @throws AuthorizationException
*/
private function checkScopedPermissionForProject(Organization $organization, Project $project, string $permission): void
{
$this->checkPermission($organization, $permission);
$user = $this->user();
$hasAccess = Project::query()
->where('id', $project->id)
->visibleByEmployee($user)
->exists();
if (! $hasAccess) {
throw new AuthorizationException('You do not have permission to '.$permission.' in this project.');
}
}
/** /**
* Get tasks * Get tasks
* *
@@ -75,7 +96,15 @@ class TaskController extends Controller
*/ */
public function store(Organization $organization, TaskStoreRequest $request): JsonResource public function store(Organization $organization, TaskStoreRequest $request): JsonResource
{ {
$this->checkPermission($organization, 'tasks:create'); /** @var Project $project */
$project = Project::query()->findOrFail($request->input('project_id'));
if ($this->hasPermission($organization, 'tasks:create:all')) {
$this->checkPermission($organization, 'tasks:create:all');
} else {
$this->checkScopedPermissionForProject($organization, $project, 'tasks:create');
}
$task = new Task; $task = new Task;
$task->name = $request->input('name'); $task->name = $request->input('name');
$task->project_id = $request->input('project_id'); $task->project_id = $request->input('project_id');
@@ -97,7 +126,17 @@ class TaskController extends Controller
*/ */
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
{ {
$this->checkPermission($organization, 'tasks:update', $task); // Check task belongs to organization
if ($task->organization_id !== $organization->id) {
throw new AuthorizationException('Task does not belong to organization');
}
if ($this->hasPermission($organization, 'tasks:update:all')) {
$this->checkPermission($organization, 'tasks:update:all');
} else {
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:update');
}
$task->name = $request->input('name'); $task->name = $request->input('name');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) { if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$task->estimated_time = $request->getEstimatedTime(); $task->estimated_time = $request->getEstimatedTime();
@@ -119,7 +158,16 @@ class TaskController extends Controller
*/ */
public function destroy(Organization $organization, Task $task): JsonResponse public function destroy(Organization $organization, Task $task): JsonResponse
{ {
$this->checkPermission($organization, 'tasks:delete', $task); // Check task belongs to organization
if ($task->organization_id !== $organization->id) {
throw new AuthorizationException('Task does not belong to organization');
}
if ($this->hasPermission($organization, 'tasks:delete:all')) {
$this->checkPermission($organization, 'tasks:delete:all');
} else {
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:delete');
}
if ($task->timeEntries()->exists()) { if ($task->timeEntries()->exists()) {
throw new EntityStillInUseApiException('task', 'time_entry'); throw new EntityStillInUseApiException('task', 'time_entry');

View File

@@ -7,6 +7,7 @@ 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;
@@ -45,6 +46,7 @@ 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;
@@ -56,6 +58,43 @@ 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);
@@ -549,17 +588,15 @@ 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);
@@ -569,6 +606,13 @@ 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);
} }
@@ -593,6 +637,13 @@ 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,6 +41,7 @@ class HandleInertiaRequests extends Middleware
{ {
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing'); $hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing'); $hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
$hasServices = Module::has('Services') && Module::isEnabled('Services');
/** @var BillingContract $billing */ /** @var BillingContract $billing */
$billing = app(BillingContract::class); $billing = app(BillingContract::class);
@@ -50,6 +51,7 @@ class HandleInertiaRequests extends Middleware
return array_merge(parent::share($request), [ return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling, 'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing, 'has_invoicing_extension' => $hasInvoicing,
'has_services_extension' => $hasServices,
'billing' => $currentOrganization !== null ? [ 'billing' => $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization), 'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization), 'has_trial' => $billing->hasTrial($currentOrganization),

View File

@@ -39,6 +39,12 @@ class OrganizationUpdateRequest extends BaseFormRequest
'employees_can_see_billable_rates' => [ 'employees_can_see_billable_rates' => [
'boolean', 'boolean',
], ],
'employees_can_manage_tasks' => [
'boolean',
],
'prevent_overlapping_time_entries' => [
'boolean',
],
'number_format' => [ 'number_format' => [
Rule::enum(NumberFormat::class), Rule::enum(NumberFormat::class),
], ],
@@ -98,4 +104,14 @@ class OrganizationUpdateRequest extends BaseFormRequest
{ {
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null; return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
} }
public function getEmployeesCanManageTasks(): ?bool
{
return $this->has('employees_can_manage_tasks') ? $this->boolean('employees_can_manage_tasks') : null;
}
public function getPreventOverlappingTimeEntries(): ?bool
{
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
}
} }

View File

@@ -10,8 +10,10 @@ use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
@@ -42,7 +44,16 @@ class TimeEntryStoreRequest extends BaseFormRequest
'required_with:task_id', 'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); $builder = $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:create:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
})->uuid(), })->uuid(),
], ],
// ID of the task that the time entry should belong to // ID of the task that the time entry should belong to
@@ -79,7 +90,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'description' => [ 'description' => [
'nullable', 'nullable',
'string', 'string',
'max:500', 'max:5000',
], ],
// List of tag IDs // List of tag IDs
'tags' => [ 'tags' => [

View File

@@ -10,8 +10,10 @@ use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
@@ -54,7 +56,16 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'required_with:task_id', 'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); $builder = $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
})->uuid(), })->uuid(),
], ],
// ID of the task that the time entry should belong to // ID of the task that the time entry should belong to
@@ -79,7 +90,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'changes.description' => [ 'changes.description' => [
'nullable', 'nullable',
'string', 'string',
'max:500', 'max:5000',
], ],
// List of tag IDs // List of tag IDs
'changes.tags' => [ 'changes.tags' => [

View File

@@ -10,8 +10,10 @@ use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
@@ -42,7 +44,16 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'required_with:task_id', 'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); $builder = $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
})->uuid(), })->uuid(),
], ],
// ID of the task that the time entry should belong to // ID of the task that the time entry should belong to
@@ -77,7 +88,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'description' => [ 'description' => [
'nullable', 'nullable',
'string', 'string',
'max:500', 'max:5000',
], ],
// List of tag IDs // List of tag IDs
'tags' => [ 'tags' => [

View File

@@ -53,6 +53,10 @@ 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 $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** @var string $currency Currency code (ISO 4217) */ /** @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,6 +7,7 @@ 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;
@@ -62,6 +63,18 @@ 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

@@ -35,6 +35,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property int|null $billable_rate * @property int|null $billable_rate
* @property string $user_id * @property string $user_id
* @property bool $employees_can_see_billable_rates * @property bool $employees_can_see_billable_rates
* @property bool $employees_can_manage_tasks
* @property User $owner * @property User $owner
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
@@ -70,6 +71,8 @@ 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',
'employees_can_manage_tasks' => '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

@@ -94,8 +94,11 @@ class JetstreamServiceProvider extends ServiceProvider
'tasks:view', 'tasks:view',
'tasks:view:all', 'tasks:view:all',
'tasks:create', 'tasks:create',
'tasks:create:all',
'tasks:update', 'tasks:update',
'tasks:update:all',
'tasks:delete', 'tasks:delete',
'tasks:delete:all',
'time-entries:view:all', 'time-entries:view:all',
'time-entries:create:all', 'time-entries:create:all',
'time-entries:update:all', 'time-entries:update:all',
@@ -109,6 +112,7 @@ 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',
@@ -157,8 +161,11 @@ class JetstreamServiceProvider extends ServiceProvider
'tasks:view', 'tasks:view',
'tasks:view:all', 'tasks:view:all',
'tasks:create', 'tasks:create',
'tasks:create:all',
'tasks:update', 'tasks:update',
'tasks:update:all',
'tasks:delete', 'tasks:delete',
'tasks:delete:all',
'time-entries:view:all', 'time-entries:view:all',
'time-entries:create:all', 'time-entries:create:all',
'time-entries:update:all', 'time-entries:update:all',
@@ -172,6 +179,7 @@ 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',
@@ -217,8 +225,11 @@ class JetstreamServiceProvider extends ServiceProvider
'tasks:view', 'tasks:view',
'tasks:view:all', 'tasks:view:all',
'tasks:create', 'tasks:create',
'tasks:create:all',
'tasks:update', 'tasks:update',
'tasks:update:all',
'tasks:delete', 'tasks:delete',
'tasks:delete:all',
'time-entries:view:all', 'time-entries:view:all',
'time-entries:create:all', 'time-entries:create:all',
'time-entries:update:all', 'time-entries:update:all',
@@ -232,6 +243,7 @@ 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',
@@ -256,12 +268,13 @@ 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 and they can see the projects that they are members of.'); ])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
Jetstream::role(Role::Placeholder->value, 'Placeholder', [ 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

@@ -266,7 +266,8 @@ class DashboardService
) as aggregate')) ) as aggregate'))
->where('billable', '=', true) ->where('billable', '=', true)
->whereNotNull('billable_rate') ->whereNotNull('billable_rate')
->where('user_id', '=', $user->id); ->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey());
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone); $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
/** @var Collection<int, object{aggregate: int}> $resultDb */ /** @var Collection<int, object{aggregate: int}> $resultDb */

View File

@@ -167,7 +167,7 @@ class ExportService
$client->id, $client->id,
$client->name, $client->name,
$client->organization_id, $client->organization_id,
$client->archived_at ?? '', $client->archived_at?->toIso8601ZuluString() ?? '',
$client->created_at?->toIso8601ZuluString() ?? '', $client->created_at?->toIso8601ZuluString() ?? '',
$client->updated_at?->toIso8601ZuluString() ?? '', $client->updated_at?->toIso8601ZuluString() ?? '',
]); ]);

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']) > 500) { if (strlen($record['Description']) > 5000) {
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']) > 500) { if (strlen($record['Notes']) > 5000) {
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']) > 500) { if (strlen($timeEntryRow['description']) > 5000) {
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

@@ -71,7 +71,19 @@ class PermissionStore
/** @var Role|null $roleObj */ /** @var Role|null $roleObj */
$roleObj = Jetstream::findRole($role); $roleObj = Jetstream::findRole($role);
return $roleObj->permissions ?? []; $permissions = $roleObj->permissions ?? [];
// If the organization allows employees to manage tasks and the user is an employee,
// add the task management permissions for accessible projects
if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) {
$permissions = array_merge($permissions, [
'tasks:create',
'tasks:update',
'tasks:delete',
]);
}
return $permissions;
} }
/** /**

View File

@@ -10,6 +10,7 @@ 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;
@@ -17,6 +18,7 @@ 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
@@ -45,9 +47,21 @@ class TimeEntryAggregationService
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array 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'];
@@ -84,6 +98,26 @@ 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 = [];
@@ -103,6 +137,14 @@ 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;
@@ -121,6 +163,23 @@ 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);
} }
@@ -294,6 +353,17 @@ 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;
@@ -436,6 +506,8 @@ 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

@@ -31,12 +31,17 @@ class TimeEntryService
throw new LogicException('Rounding minutes must be greater than 0'); throw new LogicException('Rounding minutes must be greater than 0');
} }
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')'; $end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
if ($roundingType === TimeEntryRoundingType::Down) { if ($roundingType === TimeEntryRoundingType::Down) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')'; return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')';
} elseif ($roundingType === TimeEntryRoundingType::Up) { } elseif ($roundingType === TimeEntryRoundingType::Up) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')'; // If end is already on a boundary, keep it; otherwise round up to next boundary
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
'THEN '.$end.' '.
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
'END';
} elseif ($roundingType === TimeEntryRoundingType::Nearest) { } elseif ($roundingType === TimeEntryRoundingType::Nearest) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')'; return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')';
} }
} }
} }

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->boolean('prevent_overlapping_time_entries')->default(false)->after('employees_can_see_billable_rates');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->dropColumn('prevent_overlapping_time_entries');
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('time_entries', function (Blueprint $table): void {
$table->string('description', 5000)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('time_entries', function (Blueprint $table): void {
$table->string('description', 500)->change();
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->boolean('employees_can_manage_tasks')->default(false)->after('employees_can_see_billable_rates');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->dropColumn('employees_can_manage_tasks');
});
}
};

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(500) NOT NULL, description character varying(5000) 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

@@ -107,7 +107,7 @@ services:
- sail - sail
- reverse-proxy - reverse-proxy
playwright: playwright:
image: mcr.microsoft.com/playwright:v1.51.1-jammy image: mcr.microsoft.com/playwright:v1.57.0-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0'] command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src working_dir: /src
extra_hosts: extra_hosts:

View File

@@ -9,7 +9,10 @@ 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

@@ -8,6 +8,13 @@ async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
} }
// Helper to clear localStorage before tests that check persistence
async function clearProjectTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('project-table-state');
});
}
// Create new project via modal // Create new project via modal
test('test that creating and deleting a new project via the modal works', async ({ page }) => { test('test that creating and deleting a new project via the modal works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000); const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
@@ -45,34 +52,62 @@ test('test that creating and deleting a new project via the modal works', async
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName); await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
}); });
// Helper to select a status filter using the new dropdown UI
async function selectStatusFilter(page: Page, status: 'Active' | 'Archived') {
// Click the Filter button to open the dropdown
await page.getByRole('button', { name: 'Filter projects' }).click();
// Click on Status submenu
await page.getByRole('menuitem', { name: 'Status' }).click();
// Select the status option
await page.getByRole('menuitem', { name: status }).click();
}
// Helper to remove status filter by clicking the X on the badge
async function removeStatusFilter(page: Page) {
const statusBadge = page.getByTestId('status-filter-badge');
// Click the remove button (second button in the badge, contains XMarkIcon)
await statusBadge.locator('button').last().click();
}
test('test that archiving and unarchiving projects works', async ({ page }) => { test('test that archiving and unarchiving projects works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000); const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await expect(page.getByText(newProjectName)).toBeVisible(); await expect(page.getByText(newProjectName)).toBeVisible();
// Archive the project
await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('row').first().getByRole('button').click();
await Promise.all([ await page.getByRole('menuitem').getByText('Archive').first().click();
page.getByRole('menuitem').getByText('Archive').first().click(),
expect(page.getByText(newProjectName)).not.toBeVisible(),
]);
await Promise.all([
page.getByRole('tab', { name: 'Archived' }).click(),
expect(page.getByText(newProjectName)).toBeVisible(),
]);
// Project should still be visible since default is "all" (no filter)
await expect(page.getByText(newProjectName)).toBeVisible();
// Apply Active filter - archived project should disappear
await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).not.toBeVisible();
// Remove Active filter and apply Archived filter
await removeStatusFilter(page);
await selectStatusFilter(page, 'Archived');
await expect(page.getByText(newProjectName)).toBeVisible();
// Unarchive the project
await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('row').first().getByRole('button').click();
await Promise.all([ await page.getByRole('menuitem').getByText('Unarchive').first().click();
page.getByRole('menuitem').getByText('Unarchive').first().click(),
expect(page.getByText(newProjectName)).not.toBeVisible(), // Project should disappear from Archived view
]); await expect(page.getByText(newProjectName)).not.toBeVisible();
await Promise.all([
page.getByRole('tab', { name: 'Active' }).click(), // Remove Archived filter and apply Active filter to see the project
expect(page.getByText(newProjectName)).toBeVisible(), await removeStatusFilter(page);
]); await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).toBeVisible();
}); });
test('test that updating billable rate works with existing time entries', async ({ page }) => { test('test that updating billable rate works with existing time entries', async ({ page }) => {
@@ -116,6 +151,147 @@ test('test that updating billable rate works with existing time entries', async
).toBeVisible(); ).toBeVisible();
}); });
// Sorting tests
test('test that sorting projects by name works', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Wait for the table to load
await expect(page.getByTestId('project_table')).toBeVisible();
// Get initial project names
const getProjectNames = async () => {
const rows = page
.getByTestId('project_table')
.locator('[data-testid="project_table"] > div')
.filter({ hasNot: page.locator('.border-t') });
const names: string[] = [];
const count = await page.getByTestId('project_table').getByRole('row').count();
for (let i = 0; i < count; i++) {
const row = page.getByTestId('project_table').getByRole('row').nth(i);
const nameCell = row.locator('div').first();
const text = await nameCell.textContent();
if (text) {
names.push(text.trim());
}
}
return names;
};
// Click on Name header to sort ascending (default should already be ascending)
const nameHeader = page.getByText('Name').first();
await nameHeader.click();
// Wait for sort to apply
await page.waitForTimeout(100);
// Click again to sort descending
await nameHeader.click();
await page.waitForTimeout(100);
// Verify the sort indicator is showing descending
await expect(page.locator('svg').first()).toBeVisible();
});
test('test that sorting projects by status works', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Default is "all" so no filter needed - Wait for the table to load
await expect(page.getByTestId('project_table')).toBeVisible();
// Click on Status header to sort
const statusHeader = page.getByText('Status').first();
await statusHeader.click();
// Wait for sort to apply
await page.waitForTimeout(100);
// Sort indicator should be visible
await expect(statusHeader.locator('svg')).toBeVisible();
});
// Filter tests
test('test that filtering projects by status works', async ({ page }) => {
const newProjectName = 'Filter Test Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Create a new project
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).click();
await expect(page.getByText(newProjectName)).toBeVisible();
// Archive the project
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Archive').first().click();
// Project should still be visible (default is "all" - no filter)
await expect(page.getByText(newProjectName)).toBeVisible();
// Apply Active filter - archived project should disappear
await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).not.toBeVisible();
// Remove Active filter - project should reappear (back to "all")
await removeStatusFilter(page);
await expect(page.getByText(newProjectName)).toBeVisible();
// Apply Archived filter - project should still be visible
await selectStatusFilter(page, 'Archived');
await expect(page.getByText(newProjectName)).toBeVisible();
// Remove Archived filter and apply Active filter - project should not be visible
await removeStatusFilter(page);
await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).not.toBeVisible();
});
test('test that filter state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Apply Active status filter
await selectStatusFilter(page, 'Active');
// Verify the filter badge is visible
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
// Wait for the state to be saved
await page.waitForTimeout(100);
// Reload the page
await page.reload();
// Verify the filter badge is still visible after reload
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
});
test('test that sort state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Click on Name header twice to sort descending
const nameHeader = page.getByText('Name').first();
await nameHeader.click();
await nameHeader.click();
// Wait for the state to be saved
await page.waitForTimeout(100);
// Reload the page
await page.reload();
// Verify descending sort indicator is visible on Name column
await expect(page.getByTestId('project_table')).toBeVisible();
});
// Create new project with new Client // Create new project with new Client
// Create new project with existing Client // Create new project with existing Client
@@ -124,8 +300,6 @@ test('test that updating billable rate works with existing time entries', async
// Test that project task count is displayed correctly // Test that project task count is displayed correctly
// Test that active / archive / all filter works (once implemented)
// Edit Project Modal Test // Edit Project Modal Test
// Add Project with billable rate // Add Project with billable rate

View File

@@ -26,7 +26,10 @@ 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
@@ -52,7 +55,10 @@ 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
@@ -81,7 +87,10 @@ 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,6 +14,7 @@ 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;
@@ -47,6 +48,7 @@ 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,6 +203,7 @@ 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.',

View File

@@ -1,240 +1,14 @@
@tailwind base; /* Import shared solidtime styles from UI package */
@tailwind components; @import '../js/packages/ui/styles.css';
@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; /* Main app specific styles - Inter font */
--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);
}
/* Inter Variable Font with browser compatibility considerations */
@font-face { @font-face {
font-family: 'Inter'; font-family: 'Inter';
src: url('/fonts/InterVariable.woff2') format('woff2'), src:
url('/fonts/InterVariable.ttf') format('truetype'); url('/fonts/InterVariable.woff2') format('woff2'),
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

@@ -4,12 +4,11 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template> <template>
<TableHeading> <TableHeading>
<div <div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name Name
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary"></div> <div class="px-3 py-1.5 text-left text-text-tertiary"></div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>

View File

@@ -4,11 +4,10 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template> <template>
<TableHeading> <TableHeading>
<div <div class="px-3 py-1.5 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="px-3 py-1.5 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Email Email
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>

View File

@@ -4,14 +4,13 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template> <template>
<TableHeading> <TableHeading>
<div <div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name Name
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Email</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Email</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { XMarkIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { Component } from 'vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
defineProps<{
icon: Component;
label: string;
filterName: string;
}>();
defineEmits<{
remove: [];
}>();
defineSlots<{
default(): void;
}>();
</script>
<template>
<div
class="inline-flex items-center gap-0.5 rounded-md bg-tertiary dark:bg-secondary border border-border-secondary">
<DropdownMenu>
<DropdownMenuTrigger
class="inline-flex items-center gap-1.5 px-2 py-1 text-sm hover:bg-quaternary dark:hover:bg-tertiary rounded-l-md transition-colors whitespace-nowrap">
<component :is="icon" class="h-3.5 w-3.5 text-icon-default" />
<span class="font-medium text-foreground">{{ filterName }}</span>
<span class="text-muted-foreground">is</span>
<span class="text-foreground">{{ label }}</span>
<ChevronDownIcon class="h-3 w-3 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<slot />
</DropdownMenuContent>
</DropdownMenu>
<button
class="px-1.5 py-1 hover:bg-quaternary dark:hover:bg-tertiary h-full rounded-r-md transition-colors group border-l border-border-secondary"
@click="$emit('remove')">
<XMarkIcon class="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground" />
</button>
</div>
</template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue';
import { UserGroupIcon } from '@heroicons/vue/16/solid';
import { DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@/Components/ui/dropdown-menu';
import BaseFilterBadge from './BaseFilterBadge.vue';
import type { Client } from '@/packages/api/src';
import { NO_CLIENT_ID } from './constants';
const props = defineProps<{
value: string[];
clients: Client[];
}>();
const emit = defineEmits<{
remove: [];
'update:value': [value: string[]];
}>();
const hasNoClient = computed(() => props.value.includes(NO_CLIENT_ID));
const label = computed(() => {
const count = props.value.length;
if (count === 0) return 'None';
if (count === 1) {
if (hasNoClient.value) return 'No client';
const client = props.clients.find((c) => c.id === props.value[0]);
return client?.name ?? 'Client';
}
return `${count} selected`;
});
function toggleClient(clientId: string) {
const clientIds = props.value.includes(clientId)
? props.value.filter((id) => id !== clientId)
: [...props.value, clientId];
emit('update:value', clientIds);
}
function toggleNoClient() {
const clientIds = hasNoClient.value
? props.value.filter((id) => id !== NO_CLIENT_ID)
: [...props.value, NO_CLIENT_ID];
emit('update:value', clientIds);
}
</script>
<template>
<BaseFilterBadge
:icon="UserGroupIcon"
:label="label"
filter-name="Client"
@remove="emit('remove')">
<DropdownMenuCheckboxItem :model-value="hasNoClient" @select.prevent="toggleNoClient">
No client
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
v-for="client in clients"
:key="client.id"
:model-value="value.includes(client.id)"
@select.prevent="toggleClient(client.id)">
{{ client.name }}
</DropdownMenuCheckboxItem>
</BaseFilterBadge>
</template>

View File

@@ -130,7 +130,7 @@ function updateValue(project: Project) {
<ComboboxAnchor> <ComboboxAnchor>
<ComboboxInput <ComboboxInput
ref="searchInput" ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full" class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a project..." placeholder="Search for a project..."
@keydown.enter="addProjectIfNoneExists" /> @keydown.enter="addProjectIfNoneExists" />
</ComboboxAnchor> </ComboboxAnchor>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue';
import { CircleStackIcon } from '@heroicons/vue/16/solid';
import { DropdownMenuItem } from '@/Components/ui/dropdown-menu';
import BaseFilterBadge from './BaseFilterBadge.vue';
type StatusValue = 'active' | 'archived' | 'all';
const props = defineProps<{
value: StatusValue;
}>();
const emit = defineEmits<{
remove: [];
'update:value': [value: StatusValue];
}>();
const statusOptions = [
{ id: 'active' as const, name: 'Active' },
{ id: 'archived' as const, name: 'Archived' },
];
const label = computed(() => {
return statusOptions.find((opt) => opt.id === props.value)?.name ?? 'Status';
});
function updateStatus(status: StatusValue) {
emit('update:value', status);
}
</script>
<template>
<BaseFilterBadge
:icon="CircleStackIcon"
:label="label"
filter-name="Status"
@remove="emit('remove')">
<DropdownMenuItem
v-for="option in statusOptions"
:key="option.id"
:class="[value === option.id && 'bg-accent text-accent-foreground']"
@click="updateStatus(option.id)">
{{ option.name }}
</DropdownMenuItem>
</BaseFilterBadge>
</template>

View File

@@ -4,7 +4,10 @@ import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid'; import { PlusIcon } from '@heroicons/vue/16/solid';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue'; import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue'; import ProjectTableHeading, {
type SortColumn,
type SortDirection,
} from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue'; import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import { canCreateProjects } from '@/utils/permissions'; import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src'; import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
@@ -12,13 +15,96 @@ import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import {
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
const props = defineProps<{ const props = defineProps<{
projects: Project[]; projects: Project[];
showBillableRate: boolean; showBillableRate: boolean;
sortColumn: SortColumn;
sortDirection: SortDirection;
}>(); }>();
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
const { clients } = storeToRefs(useClientsStore());
// Create a map of client names for sorting
const clientNameMap = computed(() => {
const map = new Map<string, string>();
clients.value.forEach((client) => {
map.set(client.id, client.name);
});
return map;
});
// Convert our sort state to TanStack Table format
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
]);
// Define column accessors for sorting
const columns = [
{
id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(),
},
{
id: 'client_name',
accessorFn: (row: Project) => {
if (!row.client_id) return '';
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
},
},
{
id: 'spent_time',
accessorFn: (row: Project) => row.spent_time ?? 0,
},
{
id: 'billable_rate',
accessorFn: (row: Project) => row.billable_rate ?? 0,
},
{
id: 'status',
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
},
];
const table = useVueTable({
get data() {
return props.projects;
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedProjects = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
function handleSort(column: SortColumn) {
emit('sort', column);
}
const showCreateProjectModal = ref(false); const showCreateProjectModal = ref(false);
async function createProject(project: CreateProjectBody): Promise<Project | undefined> { async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
return await useProjectsStore().createProject(project); return await useProjectsStore().createProject(project);
} }
@@ -26,11 +112,10 @@ async function createProject(project: CreateProjectBody): Promise<Project | unde
async function createClient(client: CreateClientBody): Promise<Client | undefined> { async function createClient(client: CreateClientBody): Promise<Client | undefined> {
return await useClientsStore().createClient(client); return await useClientsStore().createClient(client);
} }
const { clients } = storeToRefs(useClientsStore());
const gridTemplate = computed(() => { const gridTemplate = computed(() => {
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`; return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;
}); });
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
</script> </script>
<template> <template>
@@ -45,8 +130,11 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
<div class="inline-block min-w-full align-middle"> <div class="inline-block min-w-full align-middle">
<div data-testid="project_table" class="grid min-w-full" :style="gridTemplate"> <div data-testid="project_table" class="grid min-w-full" :style="gridTemplate">
<ProjectTableHeading <ProjectTableHeading
:show-billable-rate="props.showBillableRate"></ProjectTableHeading> :show-billable-rate="props.showBillableRate"
<div v-if="projects.length === 0" class="col-span-5 py-24 text-center"> :sort-column="props.sortColumn"
:sort-direction="props.sortDirection"
@sort="handleSort"></ProjectTableHeading>
<div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon> <FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold"> <h3 class="text-text-primary font-semibold">
{{ {{
@@ -69,7 +157,7 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
>Create your First Project >Create your First Project
</SecondaryButton> </SecondaryButton>
</div> </div>
<template v-for="project in projects" :key="project.id"> <template v-for="project in sortedProjects" :key="project.id">
<ProjectTableRow <ProjectTableRow
:show-billable-rate="props.showBillableRate" :show-billable-rate="props.showBillableRate"
:project="project"></ProjectTableRow> :project="project"></ProjectTableRow>

View File

@@ -1,23 +1,89 @@
<script setup lang="ts"> <script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
defineProps<{ import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
export type SortColumn = 'name' | 'client_name' | 'spent_time' | 'billable_rate' | 'status';
export type SortDirection = 'asc' | 'desc';
const props = defineProps<{
showBillableRate: boolean; showBillableRate: boolean;
sortColumn: SortColumn;
sortDirection: SortDirection;
}>(); }>();
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
</script> </script>
<template> <template>
<TableHeading> <TableHeading>
<div <div
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
Name Name
<ChevronDownIcon v-if="isSorted('name') && sortDirection === 'asc'" class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('name') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Client</div> <div
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div> class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div> @click="handleSort('client_name')">
<div v-if="showBillableRate" class="px-3 py-1.5 text-left font-semibold text-text-primary"> Client
<ChevronDownIcon
v-if="isSorted('client_name') && sortDirection === 'asc'"
class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('client_name') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('spent_time')">
Total Time
<ChevronDownIcon
v-if="isSorted('spent_time') && sortDirection === 'asc'"
class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('spent_time') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
<div
v-if="showBillableRate"
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('billable_rate')">
Billable Rate Billable Rate
<ChevronDownIcon
v-if="isSorted('billable_rate') && sortDirection === 'asc'"
class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('billable_rate') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
Status
<ChevronDownIcon v-if="isSorted('status') && sortDirection === 'asc'" class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('status') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>

View File

@@ -2,7 +2,7 @@
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue'; import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
import type { Project } from '@/packages/api/src'; import type { Project } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue'; import { computed, ref, inject, type ComputedRef } from 'vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid'; import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks'; import { useTasksStore } from '@/utils/useTasks';
@@ -116,9 +116,15 @@ const showEditProjectModal = ref(false);
{{ billableRateInfo }} {{ billableRateInfo }}
</div> </div>
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium"> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<CheckCircleIcon class="w-5"></CheckCircleIcon> <template v-if="project.is_archived">
<span>Active</span> <ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>
</template>
<template v-else>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
</div> </div>
<div <div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { UserGroupIcon, CheckCircleIcon } from '@heroicons/vue/16/solid';
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuCheckboxItem,
DropdownMenuSeparator,
} from '@/Components/ui/dropdown-menu';
import { Button } from '@/packages/ui/src';
import type { Client } from '@/packages/api/src';
import { NO_CLIENT_ID } from './constants';
export interface ProjectFilters {
status: 'active' | 'archived' | 'all';
clientIds: string[];
}
const props = defineProps<{
filters: ProjectFilters;
clients: Client[];
}>();
const emit = defineEmits<{
'update:filters': [filters: ProjectFilters];
}>();
const statusOptions = [
{ id: 'active' as const, name: 'Active' },
{ id: 'archived' as const, name: 'Archived' },
];
const open = ref(false);
function updateStatus(status: 'active' | 'archived' | 'all') {
emit('update:filters', {
...props.filters,
status,
});
open.value = false;
}
function toggleClient(clientId: string) {
const clientIds = props.filters.clientIds.includes(clientId)
? props.filters.clientIds.filter((id) => id !== clientId)
: [...props.filters.clientIds, clientId];
emit('update:filters', {
...props.filters,
clientIds,
});
}
function toggleNoClient() {
const clientIds = props.filters.clientIds.includes(NO_CLIENT_ID)
? props.filters.clientIds.filter((id) => id !== NO_CLIENT_ID)
: [...props.filters.clientIds, NO_CLIENT_ID];
emit('update:filters', {
...props.filters,
clientIds,
});
}
const hasActiveFilters = computed(() => {
return props.filters.status !== 'all' || props.filters.clientIds.length > 0;
});
</script>
<template>
<DropdownMenu v-model:open="open">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="xs" aria-label="Filter projects">
<ListFilterIcon
:class="[hasActiveFilters ? '' : '-ml-0.5', 'h-4 w-4 text-icon-default']" />
<span v-if="!hasActiveFilters" class="text-nowrap">Filter</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-56">
<!-- Status Filter -->
<DropdownMenuSub>
<DropdownMenuSubTrigger class="gap-2">
<CheckCircleIcon class="h-4 w-4 text-icon-default" />
<span>Status</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
v-for="option in statusOptions"
:key="option.id"
:class="[
filters.status === option.id && 'bg-accent text-accent-foreground',
]"
@click="updateStatus(option.id)">
{{ option.name }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<!-- Client Filter -->
<DropdownMenuSub v-if="clients.length > 0">
<DropdownMenuSubTrigger class="gap-2">
<UserGroupIcon class="h-4 w-4 text-icon-default" />
<span>Client</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent class="max-h-[300px] overflow-y-auto">
<DropdownMenuCheckboxItem
:model-value="filters.clientIds.includes(NO_CLIENT_ID)"
@select.prevent="toggleNoClient">
No client
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
v-for="client in clients"
:key="client.id"
:model-value="filters.clientIds.includes(client.id)"
@select.prevent="toggleClient(client.id)">
{{ client.name }}
</DropdownMenuCheckboxItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@@ -0,0 +1 @@
export const NO_CLIENT_ID = '__no_client__';

View File

@@ -4,12 +4,11 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template> <template>
<TableHeading> <TableHeading>
<div <div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name Name
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>

View File

@@ -4,13 +4,12 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template> <template>
<TableHeading> <TableHeading>
<div <div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name Name
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Description</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Description</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Visibility</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Visibility</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Public URL</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Public URL</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>

View File

@@ -113,7 +113,7 @@ const option = computed(() => ({
}, },
axisLabel: { axisLabel: {
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 400,
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 '@/Components/ui/button'; import { Button } from '@/packages/ui/src';
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 '@/packages/ui/src';
import { Button } from '@/Components/ui/button'; import { Button } from '@/packages/ui/src';
import { import {
Select, Select,
SelectContent, SelectContent,

View File

@@ -30,10 +30,7 @@ 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 <div :class="twMerge('pl-6 flex items-center space-x-3', props.indent ? 'pl-16' : '')">
: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

@@ -2,7 +2,7 @@
<template> <template>
<div <div
class="contents [&>*]:border-row-separator text-xs sm:text-sm [&>*]:border-b [&>*]:border-t [&>*]:bg-row-heading-background"> class="contents [&>*]:border-row-separator text-xs [&>*]:border-b [&>*]:border-t [&>*]:bg-row-heading-background">
<slot></slot> <slot></slot>
</div> </div>
</template> </template>

View File

@@ -4,8 +4,7 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template> <template>
<TableHeading> <TableHeading>
<div <div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name Name
</div> </div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">

View File

@@ -4,13 +4,12 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template> <template>
<TableHeading> <TableHeading>
<div <div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Task Name Task Name
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Total Time</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>

View File

@@ -1,19 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import { ref } from '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 { useForm, usePage } from '@inertiajs/vue3';
import type { User } from '@/types/models'; import type { User } from '@/types/models';
import { useSessionStorage } from '@vueuse/core'; import TimezoneMismatchModal from '@/packages/ui/src/TimezoneMismatchModal.vue';
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false }); const saving = ref(false);
const timezone = ref('');
const userTimezone = ref('');
const page = usePage<{ const page = usePage<{
auth: { auth: {
@@ -21,26 +13,11 @@ const page = usePage<{
}; };
}>(); }>();
const hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false); function handleUpdate(timezone: string) {
onMounted(() => {
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
userTimezone.value = getUserTimezone();
if (
getDayJsInstance()().tz(timezone.value).format() !==
getDayJsInstance()().tz(userTimezone.value).format() &&
!hideTimezoneMismatchModal.value
) {
show.value = true;
}
});
function submit() {
saving.value = true; saving.value = true;
const form = useForm({ const form = useForm({
_method: 'PUT', _method: 'PUT',
timezone: timezone.value, timezone: timezone,
name: page.props.auth.user.name, name: page.props.auth.user.name,
email: page.props.auth.user.email, email: page.props.auth.user.email,
week_start: page.props.auth.user.week_start, week_start: page.props.auth.user.week_start,
@@ -54,53 +31,15 @@ function submit() {
show.value = false; show.value = false;
location.reload(); location.reload();
}, },
onError: () => {
saving.value = false;
},
}); });
} }
function cancel() {
show.value = false;
hideTimezoneMismatchModal.value = true;
}
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false"> <TimezoneMismatchModal v-model:show="show" :saving="saving" @update="handleUpdate" />
<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> </template>
<style scoped></style> <style scoped></style>

View File

@@ -1,8 +1,7 @@
<template> <template>
<section class="flex flex-col"> <section class="flex flex-col">
<CardTitle :title="title" :icon="icon"></CardTitle> <CardTitle :title="title" :icon="icon"></CardTitle>
<div <div class="rounded-lg border border-card-border flex-1 flex items-stretch">
class="rounded-lg bg-card-background border border-card-border flex-1 flex items-stretch shadow-card">
<div class="w-full flex flex-col"> <div class="w-full flex flex-col">
<slot></slot> <slot></slot>
</div> </div>

View File

@@ -10,7 +10,8 @@ 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 class="font-semibold text-sm text-text-primary"> <p
class="font-semibold text-sm min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
{{ name }} {{ 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 sm:pb-24 z-[70]"> class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-end sm:p-6 z-[70]">
<div class="flex w-full flex-col items-center space-y-4 sm:items-end"> <div class="flex w-full flex-col items-center space-y-4 sm:items-end">
<Notification <Notification
v-for="notification in notifications" v-for="notification in notifications"

View File

@@ -1,12 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronDownIcon } from '@heroicons/vue/20/solid'; import { ChevronDownIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue'; import { Link, usePage } from '@inertiajs/vue3';
import DropdownLink from '@/Components/DropdownLink.vue'; import {
import { usePage } from '@inertiajs/vue3'; Cog6ToothIcon,
PlusCircleIcon,
CheckCircleIcon,
ArrowRightIcon,
} from '@heroicons/vue/24/solid';
import type { Organization, User } from '@/types/models'; import type { Organization, User } from '@/types/models';
import { isBillingActivated } from '@/utils/billing'; import { isBillingActivated } from '@/utils/billing';
import { canManageBilling } from '@/utils/permissions'; import { canManageBilling } from '@/utils/permissions';
import { switchOrganization } from '@/utils/useOrganization'; import { switchOrganization } from '@/utils/useOrganization';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
} from '@/Components/ui/dropdown-menu';
const page = usePage<{ const page = usePage<{
jetstream: { jetstream: {
@@ -28,84 +39,79 @@ const switchToTeam = (organization: Organization) => {
</script> </script>
<template> <template>
<Dropdown v-if="page.props.jetstream.hasTeamFeatures" align="center" width="60"> <DropdownMenu v-if="page.props.jetstream.hasTeamFeatures">
<template #trigger> <DropdownMenuTrigger
<div class="flex w-full text-left hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-ring cursor-pointer transition pl-2 py-1 rounded w-full items-center justify-between"
data-testid="organization_switcher" as-child>
class="flex hover:bg-white/10 cursor-pointer transition px-2 py-1 rounded-lg w-full items-center justify-between font-medium"> <button data-testid="organization_switcher">
<div class="flex flex-1 space-x-2 items-center w-[calc(100%-30px)]"> <div class="flex flex-1 space-x-2 items-center w-[calc(100%-30px)]">
<div <div
class="rounded sm:rounded-lg bg-blue-900 font-semibold text-xs sm:text-sm flex-shrink-0 text-white w-5 sm:w-6 h-5 sm:h-6 flex items-center justify-center"> class="rounded bg-blue-900 font-medium text-xs flex-shrink-0 text-white w-5 h-5 flex items-center justify-center">
{{ page.props.auth.user.current_team.name.slice(0, 1).toUpperCase() }} {{ page.props.auth.user.current_team.name.slice(0, 1).toUpperCase() }}
</div> </div>
<span class="text-sm flex-1 truncate font-semibold"> <span class="text-xs flex-1 truncate font-medium">
{{ page.props.auth.user.current_team.name }} {{ page.props.auth.user.current_team.name }}
</span> </span>
</div> </div>
<div class="w-[30px]"> <div class="w-[30px]">
<button <div class="p-1 rounded-full flex items-center w-6 h-6">
class="p-1 transition hover:bg-white/10 rounded-full flex items-center w-8 h-8"> <ChevronDownIcon class="w-4 sm:w-full mt-[1px]"></ChevronDownIcon>
<ChevronDownIcon class="w-5 sm:w-full mt-[1px]"></ChevronDownIcon> </div>
</button>
</div> </div>
</div> </button>
</template> </DropdownMenuTrigger>
<template #content> <DropdownMenuContent align="start">
<div class="w-60"> <div class="w-60">
<!-- Organization Management --> <DropdownMenuLabel>Manage Organization</DropdownMenuLabel>
<div class="block px-4 py-2 text-xs text-text-secondary">Manage Organization</div>
<!-- Organization Settings --> <DropdownMenuItem as-child>
<DropdownLink :href="route('teams.show', page.props.auth.user.current_team.id)"> <Link
Organization Settings :href="route('teams.show', page.props.auth.user.current_team.id)"
</DropdownLink> class="inline-flex items-center gap-2.5 w-full">
<Cog6ToothIcon class="w-5 h-5 text-icon-default" />
<span>Organization Settings</span>
</Link>
</DropdownMenuItem>
<DropdownLink v-if="canManageBilling() && isBillingActivated()" href="/billing"> <DropdownMenuItem v-if="canManageBilling() && isBillingActivated()" as-child>
Billing <Link href="/billing" class="inline-flex items-center w-full"> Billing </Link>
</DropdownLink> </DropdownMenuItem>
<DropdownLink <DropdownMenuItem v-if="page.props.jetstream.canCreateTeams" as-child>
v-if="page.props.jetstream.canCreateTeams" <Link
:href="route('teams.create')"> :href="route('teams.create')"
Create new organization class="inline-flex items-center gap-2.5 w-full">
</DropdownLink> <PlusCircleIcon class="w-5 h-5 text-icon-default" />
<span>Create new organization</span>
</Link>
</DropdownMenuItem>
<!-- Organization Switcher -->
<template v-if="page.props.auth.user.all_teams.length > 1"> <template v-if="page.props.auth.user.all_teams.length > 1">
<div class="border-t border-card-background-separator" /> <div class="border-t border-card-background-separator" />
<div class="block px-4 py-2 text-xs text-text-secondary"> <DropdownMenuLabel>Switch Organizations</DropdownMenuLabel>
Switch Organizations
</div>
<template v-for="team in page.props.auth.user.all_teams" :key="team.id"> <template v-for="team in page.props.auth.user.all_teams" :key="team.id">
<form @submit.prevent="switchToTeam(team)"> <form @submit.prevent="switchToTeam(team)">
<DropdownLink as="button"> <DropdownMenuItem
<div class="flex items-center"> as-child
<svg class="inline-flex gap-2.5 items-center w-full">
<button type="submit">
<CheckCircleIcon
v-if="team.id == page.props.auth.user.current_team_id" v-if="team.id == page.props.auth.user.current_team_id"
class="me-2 h-5 w-5 text-green-400" class="h-5 w-5 text-green-400" />
xmlns="http://www.w3.org/2000/svg" <ArrowRightIcon v-else class="h-5 w-5 text-icon-default" />
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div> <div class="w-full truncate text-left">
{{ team.name }} {{ team.name }}
</div> </div>
</div> </button>
</DropdownLink> </DropdownMenuItem>
</form> </form>
</template> </template>
</template> </template>
</div> </div>
</template> </DropdownMenuContent>
</Dropdown> </DropdownMenu>
</template> </template>

View File

@@ -16,12 +16,25 @@ 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 { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src'; import type {
CreateClientBody,
CreateProjectBody,
CreateTimeEntryBody,
Project,
Tag,
} from '@/packages/api/src';
import TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue'; import 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: {
@@ -47,6 +60,8 @@ const emit = defineEmits<{
change: []; change: [];
}>(); }>();
const showManualTimeEntryModal = ref(false);
watch(isActive, () => { watch(isActive, () => {
if (isActive.value) { if (isActive.value) {
startLiveTimer(); startLiveTimer();
@@ -93,14 +108,73 @@ function switchToTimeEntryOrganization() {
switchOrganization(currentTimeEntry.value.organization_id); switchOrganization(currentTimeEntry.value.organization_id);
} }
} }
async function createTag(tag: string) { async function createTag(tag: string): Promise<Tag | undefined> {
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
@@ -109,24 +183,36 @@ const { tags } = storeToRefs(useTagsStore());
switchToTimeEntryOrganization switchToTimeEntryOrganization
"></TimeTrackerRunningInDifferentOrganizationOverlay> "></TimeTrackerRunningInDifferentOrganizationOverlay>
<TimeTrackerControls <div class="flex w-full items-center gap-2">
v-model:current-time-entry="currentTimeEntry" <div class="flex w-full items-center gap-2">
v-model:live-timer="now" <div class="flex-1">
:create-project <TimeTrackerControls
:enable-estimated-time="isAllowedToPerformPremiumAction()" v-model:current-time-entry="currentTimeEntry"
:can-create-project="canCreateProjects()" v-model:live-timer="now"
:create-client :create-project
:clients :enable-estimated-time="isAllowedToPerformPremiumAction()"
:tags :can-create-project="canCreateProjects()"
:tasks :create-client
:projects :clients
:create-tag :tags
:is-active :tasks
:currency="getOrganizationCurrencyString()" :projects
@start-live-timer="startLiveTimer" :time-entries
@stop-live-timer="stopLiveTimer" :create-tag
@start-timer="setActiveState(true)" :is-active
@stop-timer="setActiveState(false)" :currency="getOrganizationCurrencyString()"
@update-time-entry="updateTimeEntry"></TimeTrackerControls> @start-live-timer="startLiveTimer"
@stop-live-timer="stopLiveTimer"
@start-timer="setActiveState(true)"
@stop-timer="setActiveState(false)"
@update-time-entry="updateTimeEntry"
@create-time-entry="createTimeEntryFromCurrentEntry"></TimeTrackerControls>
</div>
<TimeTrackerMoreOptionsDropdown
:has-active-timer="isActive"
@manual-entry="showManualTimeEntryModal = true"
@discard="discardCurrentTimeEntry"></TimeTrackerMoreOptionsDropdown>
</div>
</div>
</div> </div>
</template> </template>

View File

@@ -1,10 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { router, usePage } from '@inertiajs/vue3'; import { Link, router, usePage } from '@inertiajs/vue3';
import type { Organization, User } from '@/types/models'; import type { Organization, User } from '@/types/models';
import DropdownLink from '@/Components/DropdownLink.vue'; import {
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue'; DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
} from '@/Components/ui/dropdown-menu';
import {
UserCircleIcon,
KeyIcon,
ArrowLeftOnRectangleIcon,
ChatBubbleLeftRightIcon,
} from '@heroicons/vue/24/solid';
import { openFeedback } from '@/utils/feedback';
const page = usePage<{ const page = usePage<{
has_services_extension?: boolean;
has_billing_extension?: boolean;
jetstream: { jetstream: {
canCreateTeams: boolean; canCreateTeams: boolean;
hasTeamFeatures: boolean; hasTeamFeatures: boolean;
@@ -23,60 +37,58 @@ const logout = () => {
}; };
</script> </script>
<template> <template>
<div class="ms-3 relative"> <div class="relative">
<Dropdown align="center" width="48"> <DropdownMenu>
<template #trigger> <DropdownMenuTrigger
<button class="flex text-sm border-2 outline-none border-transparent rounded-full focus-visible:ring-2 focus-visible:ring-ring transition"
v-if="page.props.jetstream.managesProfilePhotos" as-child>
data-testid="current_user_button" <button data-testid="current_user_button">
class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
<img <img
class="h-8 w-8 rounded-full object-cover" class="h-7 w-7 rounded-full object-cover"
:src="page.props.auth.user.profile_photo_url" :src="page.props.auth.user.profile_photo_url"
:alt="page.props.auth.user.name" /> :alt="page.props.auth.user.name" />
</button> </button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" class="max-w-48">
<DropdownMenuLabel>Manage Account</DropdownMenuLabel>
<span v-else class="inline-flex rounded-md"> <DropdownMenuItem as-child>
<Link
:href="route('profile.show')"
class="inline-flex items-center gap-2.5 w-full">
<UserCircleIcon class="w-5 h-5 text-icon-default" />
<span>Profile Settings</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem v-if="page.props.jetstream.hasApiFeatures" as-child>
<Link
:href="route('api-tokens.index')"
class="inline-flex items-center gap-2.5 w-full">
<KeyIcon class="w-5 h-5 text-icon-default" />
<span>API Tokens</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem v-if="page.props.has_services_extension" as-child>
<button <button
type="button" type="button"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:bg-gray-50 active:bg-gray-50 transition ease-in-out duration-150"> class="inline-flex items-center gap-2.5 w-full"
{{ page.props.auth.user.name }} @click="openFeedback">
<ChatBubbleLeftRightIcon class="w-5 h-5 text-icon-default" />
<svg <span>Feedback</span>
class="ms-2 -me-0.5 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button> </button>
</span> </DropdownMenuItem>
</template>
<template #content> <form class="w-full" @submit.prevent="logout">
<!-- Account Management --> <DropdownMenuItem as-child class="inline-flex items-center gap-2.5 w-full">
<div class="block px-4 py-2 text-xs text-gray-400">Manage Account</div> <button type="submit" data-testid="logout_button">
<ArrowLeftOnRectangleIcon class="w-5 h-5 text-icon-default" />
<DropdownLink :href="route('profile.show')"> Profile </DropdownLink> <span>Log Out</span>
</button>
<DropdownLink </DropdownMenuItem>
v-if="page.props.jetstream.hasApiFeatures"
:href="route('api-tokens.index')">
API Tokens
</DropdownLink>
<div class="border-t border-card-border" />
<!-- Authentication -->
<form @submit.prevent="logout">
<DropdownLink as="button" data-testid="logout_button"> Log Out </DropdownLink>
</form> </form>
</template> </DropdownMenuContent>
</Dropdown> </DropdownMenu>
</div> </div>
</template> </template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { buttonVariants } from '@/Components/ui/button'; import { buttonVariants } from '@/packages/ui/src';
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 '@/Components/ui/button'; import { buttonVariants } from '@/packages/ui/src';
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,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { cn } from '@/lib/utils'; import { cn, buttonVariants } from '@/packages/ui/src';
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 '@/packages/ui/src';
import { Button } from '@/Components/ui/button'; import { Button } from '@/packages/ui/src';
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,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { cn } from '@/lib/utils'; import { cn, buttonVariants } from '@/packages/ui/src/index';
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,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { cn } from '@/lib/utils'; import { cn, buttonVariants } from '@/packages/ui/src';
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('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)"> :class="cn('block px-2 py-2 text-xs text-gray-400', inset && 'pl-8', props.class)">
<slot /> <slot />
</DropdownMenuLabel> </DropdownMenuLabel>
</template> </template>

View File

@@ -15,9 +15,7 @@ const delegatedProps = computed(() => {
<template> <template>
<TabsList <TabsList
v-bind="delegatedProps" v-bind="delegatedProps"
:class=" :class="cn('inline-flex items-center rounded-lg text-muted-foreground', props.class)">
cn('inline-flex items-center rounded-lg bg-muted text-muted-foreground', props.class)
">
<slot /> <slot />
</TabsList> </TabsList>
</template> </template>

View File

@@ -47,6 +47,8 @@ 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 { twMerge } from 'tailwind-merge';
import { Button } from '@/packages/ui/src';
import { openFeedback } from '@/utils/feedback';
defineProps({ defineProps({
title: String, title: String,
@@ -94,8 +96,8 @@ onMounted(async () => {
}, 100); }, 100);
}; };
}); });
const page = usePage<{ const page = usePage<{
has_services_extension?: boolean;
auth: { auth: {
user: User; user: User;
}; };
@@ -106,7 +108,7 @@ const page = usePage<{
<div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary"> <div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary">
<div <div
:class="{ :class="{
'!flex bg-default-background w-full z-[9999999999]': showSidebarMenu, '!flex bg-default-background w-full z-30': showSidebarMenu,
}" }"
class="flex-shrink-0 h-screen hidden fixed w-[230px] 2xl:w-[250px] px-2.5 2xl:px-3 py-4 lg:flex flex-col justify-between"> class="flex-shrink-0 h-screen hidden fixed w-[230px] 2xl:w-[250px] px-2.5 2xl:px-3 py-4 lg:flex flex-col justify-between">
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
@@ -242,14 +244,23 @@ const page = usePage<{
<div class="justify-self-end"> <div class="justify-self-end">
<UpdateSidebarNotification></UpdateSidebarNotification> <UpdateSidebarNotification></UpdateSidebarNotification>
<ul <ul
class="border-t border-default-background-separator pt-3 flex justify-between pr-4 items-center"> class="border-t border-default-background-separator pt-3 gap-1 pr-2 flex justify-between items-center">
<UserSettingsIcon></UserSettingsIcon>
<NavigationSidebarItem <NavigationSidebarItem
class="flex-1" class="flex-1"
title="Profile Settings" title="Profile Settings"
:icon="Cog6ToothIcon" :icon="Cog6ToothIcon"
:href="route('profile.show')"></NavigationSidebarItem> :href="route('profile.show')"></NavigationSidebarItem>
<UserSettingsIcon></UserSettingsIcon> <Button
v-if="page.props.has_services_extension"
variant="outline"
size="xs"
class="rounded-full ml-2 flex h-6 w-6 items-center text-xs text-icon-default justify-center"
@click="openFeedback">
?
</Button>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -9,7 +9,7 @@ import {
type Project, type Project,
type TimeEntryResponse, type TimeEntryResponse,
} from '@/packages/api/src'; } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { getDayJsInstance } from '@/packages/ui/src/utils/time'; import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import { TimeEntryCalendar } from '@/packages/ui/src'; import { TimeEntryCalendar } from '@/packages/ui/src';
@@ -21,6 +21,8 @@ import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks'; import { useTasksStore } from '@/utils/useTasks';
import { getUserTimezone } from '@/packages/ui/src/utils/settings'; 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 calendarStart = ref<Date | undefined>(undefined);
const calendarEnd = ref<Date | undefined>(undefined); const calendarEnd = ref<Date | undefined>(undefined);
@@ -73,6 +75,7 @@ const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useQuery<Time
queries: { queries: {
start: expandedDateRange.value.start!, start: expandedDateRange.value.start!,
end: expandedDateRange.value.end!, end: expandedDateRange.value.end!,
member_id: getCurrentMembershipId(),
}, },
}), }),
}); });
@@ -128,6 +131,8 @@ function onRefresh() {
:tags="tags" :tags="tags"
:loading="timeEntriesLoading" :loading="timeEntriesLoading"
:enable-estimated-time="isAllowedToPerformPremiumAction()" :enable-estimated-time="isAllowedToPerformPremiumAction()"
:currency="getOrganizationCurrencyString()"
:can-create-project="canCreateProjects()"
:create-time-entry="createTimeEntry" :create-time-entry="createTimeEntry"
:update-time-entry="updateTimeEntry" :update-time-entry="updateTimeEntry"
:delete-time-entry="deleteTimeEntry" :delete-time-entry="deleteTimeEntry"

View File

@@ -35,7 +35,7 @@ const refreshDashboardData = () => {
</MainContainer> </MainContainer>
<MainContainer <MainContainer
class="grid gap-5 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pt-3 sm:pt-5 pb-4 sm:pb-6 border-b border-default-background-separator items-stretch"> class="grid gap-2 sm:gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pt-3 sm:pt-5 pb-4 sm:pb-6 border-b border-default-background-separator items-stretch">
<RecentlyTrackedTasksCard></RecentlyTrackedTasksCard> <RecentlyTrackedTasksCard></RecentlyTrackedTasksCard>
<LastSevenDaysCard></LastSevenDaysCard> <LastSevenDaysCard></LastSevenDaysCard>
<ActivityGraphCard></ActivityGraphCard> <ActivityGraphCard></ActivityGraphCard>

View File

@@ -4,13 +4,16 @@ import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid'; import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue'; import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
import { computed, onMounted, ref } from 'vue'; import type {
SortColumn,
SortDirection,
} from '@/Components/Common/Project/ProjectTableHeading.vue';
import { computed } from 'vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue'; import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateProjects } from '@/utils/permissions'; import { canCreateProjects } from '@/utils/permissions';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import type { CreateClientBody, Client, CreateProjectBody, Project } from '@/packages/api/src'; import type { CreateClientBody, Client, CreateProjectBody, Project } from '@/packages/api/src';
@@ -18,31 +21,95 @@ import { getOrganizationCurrencyString } from '@/utils/money';
import { getCurrentRole } from '@/utils/useUser'; import { getCurrentRole } from '@/utils/useUser';
import { useOrganizationStore } from '@/utils/useOrganization'; import { useOrganizationStore } from '@/utils/useOrganization';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useStorage } from '@vueuse/core';
import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue';
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
// Fetch data using TanStack Query
const { projects } = useProjectsQuery();
onMounted(() => {
useProjectsStore().fetchProjects();
useOrganizationStore().fetchOrganization();
});
const { clients } = storeToRefs(useClientsStore()); const { clients } = storeToRefs(useClientsStore());
const showCreateProjectModal = ref(false);
const { organization } = storeToRefs(useOrganizationStore()); const { organization } = storeToRefs(useOrganizationStore());
const activeTab = ref<'active' | 'archived'>('active'); // Table state persisted in localStorage
interface ProjectTableState {
sortColumn: SortColumn;
sortDirection: SortDirection;
filters: {
clientIds: string[];
status: 'active' | 'archived' | 'all';
};
}
const { projects } = storeToRefs(useProjectsStore()); const tableState = useStorage<ProjectTableState>(
'project-table-state',
{
sortColumn: 'name',
sortDirection: 'asc',
filters: {
clientIds: [],
status: 'all',
},
},
undefined,
{ mergeDefaults: true }
);
const shownProjects = computed(() => { // Handle sorting - toggle direction if same column, otherwise set new column with asc
function handleSort(column: SortColumn) {
if (tableState.value.sortColumn === column) {
tableState.value.sortDirection = tableState.value.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
tableState.value.sortColumn = column;
tableState.value.sortDirection = 'asc';
}
}
// Filter projects based on current filters
const filteredProjects = computed(() => {
return projects.value.filter((project) => { return projects.value.filter((project) => {
if (activeTab.value === 'active') { // Status filter
return !project.is_archived; if (tableState.value.filters.status === 'active' && project.is_archived) {
return false;
} }
return project.is_archived; if (tableState.value.filters.status === 'archived' && !project.is_archived) {
return false;
}
// Client filter
const hasClientFilter = tableState.value.filters.clientIds.length > 0;
if (hasClientFilter) {
const matchesNoClient =
tableState.value.filters.clientIds.includes(NO_CLIENT_ID) && !project.client_id;
const matchesClientId =
project.client_id && tableState.value.filters.clientIds.includes(project.client_id);
if (!matchesNoClient && !matchesClientId) {
return false;
}
}
return true;
}); });
}); });
// Helper functions for active filters
function removeStatusFilter() {
tableState.value.filters.status = 'all';
}
function removeClientFilter() {
tableState.value.filters.clientIds = [];
}
const showCreateProjectModal = useStorage('project-create-modal-open', false);
async function createProject(project: CreateProjectBody): Promise<Project | undefined> { async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
return await useProjectsStore().createProject(project); return await useProjectsStore().createProject(project);
} }
async function createClient(client: CreateClientBody): Promise<Client | undefined> { async function createClient(client: CreateClientBody): Promise<Client | undefined> {
return await useClientsStore().createClient(client); return await useClientsStore().createClient(client);
} }
@@ -57,13 +124,9 @@ const showBillableRate = computed(() => {
<template> <template>
<AppLayout title="Projects" data-testid="projects_view"> <AppLayout title="Projects" data-testid="projects_view">
<MainContainer <MainContainer
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center"> class="py-3 sm:pt-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6"> <div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="FolderIcon" title="Projects"></PageTitle> <PageTitle :icon="FolderIcon" title="Projects"></PageTitle>
<TabBar v-model="activeTab">
<TabBarItem value="active">Active</TabBarItem>
<TabBarItem value="archived">Archived</TabBarItem>
</TabBar>
</div> </div>
<SecondaryButton <SecondaryButton
v-if="canCreateProjects()" v-if="canCreateProjects()"
@@ -80,8 +143,38 @@ const showBillableRate = computed(() => {
:clients="clients" :clients="clients"
@submit="createProject"></ProjectCreateModal> @submit="createProject"></ProjectCreateModal>
</MainContainer> </MainContainer>
<MainContainer>
<div class="flex items-center gap-2 py-1">
<ProjectsFilterDropdown
:filters="tableState.filters"
:clients="clients"
@update:filters="tableState.filters = $event" />
<!-- Active Filters -->
<ProjectStatusFilterBadge
v-if="tableState.filters.status !== 'all'"
data-testid="status-filter-badge"
:value="tableState.filters.status"
@remove="removeStatusFilter"
@update:value="
tableState.filters.status = $event as 'active' | 'archived' | 'all'
" />
<ProjectClientFilterBadge
v-if="tableState.filters.clientIds.length > 0"
data-testid="client-filter-badge"
:value="tableState.filters.clientIds"
:clients="clients"
@remove="removeClientFilter"
@update:value="tableState.filters.clientIds = $event as string[]" />
</div>
</MainContainer>
<ProjectTable <ProjectTable
:show-billable-rate="showBillableRate" :show-billable-rate="showBillableRate"
:projects="shownProjects"></ProjectTable> :projects="filteredProjects"
:sort-column="tableState.sortColumn"
:sort-direction="tableState.sortDirection"
@sort="handleSort"></ProjectTable>
</AppLayout> </AppLayout>
</template> </template>

View File

@@ -383,7 +383,7 @@ async function downloadExport(format: ExportFormat) {
@submit="clearSelectionAndState" @submit="clearSelectionAndState"
@select-all="selectedTimeEntries = [...timeEntries]" @select-all="selectedTimeEntries = [...timeEntries]"
@unselect-all="selectedTimeEntries = []"></TimeEntryMassActionRow> @unselect-all="selectedTimeEntries = []"></TimeEntryMassActionRow>
<div class="w-full relative"> <div class="w-full relative @container">
<div v-for="entry in timeEntries" :key="entry.id"> <div v-for="entry in timeEntries" :key="entry.id">
<TimeEntryRow <TimeEntryRow
:selected="selectedTimeEntries.includes(entry)" :selected="selectedTimeEntries.includes(entry)"
@@ -400,6 +400,7 @@ 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 { fetchOrganization, updateOrganization } = store; const { updateOrganization } = store;
const { organization } = storeToRefs(store); const { organization } = storeToRefs(store);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -47,7 +47,6 @@ 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

@@ -0,0 +1,81 @@
<script setup lang="ts">
import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { Checkbox } from '@/packages/ui/src';
import type { UpdateOrganizationBody } from '@/packages/api/src';
import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
const store = useOrganizationStore();
const { updateOrganization } = store;
const { organization } = storeToRefs(store);
const queryClient = useQueryClient();
const form = ref<{
prevent_overlapping_time_entries: boolean;
employees_can_manage_tasks: boolean;
}>({
prevent_overlapping_time_entries: false,
employees_can_manage_tasks: false,
});
onMounted(async () => {
form.value.prevent_overlapping_time_entries =
organization.value?.prevent_overlapping_time_entries ?? false;
form.value.employees_can_manage_tasks = organization.value?.employees_can_manage_tasks ?? false;
});
const mutation = useMutation({
mutationFn: (values: Partial<UpdateOrganizationBody>) => updateOrganization(values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organization'] });
},
});
async function submit() {
await mutation.mutateAsync({
prevent_overlapping_time_entries: form.value.prevent_overlapping_time_entries,
employees_can_manage_tasks: form.value.employees_can_manage_tasks,
});
}
</script>
<template>
<FormSection>
<template #title>Organization Settings</template>
<template #description>
Configure various settings for your organization, including time entry and task
management permissions.
</template>
<template #form>
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4 space-y-4">
<div class="flex items-center space-x-2">
<Checkbox
id="preventOverlappingTimeEntries"
v-model:checked="form.prevent_overlapping_time_entries" />
<InputLabel
for="preventOverlappingTimeEntries"
value="Prevent overlapping time entries (new entries only)" />
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="employeesCanManageTasks"
v-model:checked="form.employees_can_manage_tasks" />
<InputLabel
for="employeesCanManageTasks"
value="Allow Employees to manage tasks" />
</div>
</div>
</div>
</template>
<template #actions>
<PrimaryButton :disabled="mutation.isPending.value" @click="submit">Save</PrimaryButton>
</template>
</FormSection>
</template>

View File

@@ -8,12 +8,25 @@ 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>
@@ -26,17 +39,25 @@ defineProps<{
<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">
<UpdateTeamNameForm :team="team" :permissions="permissions" /> <div v-if="loading || !organization" class="py-16 text-center text-text-secondary">
Loading organization settings...
</div>
<template v-else>
<UpdateTeamNameForm :team="team" :permissions="permissions" />
<SectionBorder /> <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 />
<template v-if="permissions.canDeleteTeam && !team.personal_team"> <OrganizationTimeEntrySettings v-if="canUpdateOrganization()" />
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" /> <SectionBorder />
<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,8 +15,6 @@ 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';
@@ -24,7 +22,6 @@ 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';
@@ -73,7 +70,6 @@ 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();
@@ -105,33 +101,9 @@ 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">
<div <TimeTracker></TimeTracker>
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"

View File

@@ -1,15 +1,16 @@
{ {
"name": "@solidtime/api", "name": "@solidtime/api",
"version": "0.0.4", "version": "0.0.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@solidtime/api", "name": "@solidtime/api",
"version": "0.0.3", "version": "0.0.6",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",
"axios": "^1.13.2",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
@@ -1094,18 +1095,16 @@
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.7.5", "version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==", "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.4",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
@@ -1127,12 +1126,24 @@
"concat-map": "0.0.1" "concat-map": "0.0.1"
} }
}, },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
}, },
@@ -1198,11 +1209,24 @@
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -1216,6 +1240,51 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1280,7 +1349,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=4.0" "node": ">=4.0"
}, },
@@ -1291,14 +1359,15 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.0", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12" "mime-types": "^2.1.12"
}, },
"engines": { "engines": {
@@ -1339,12 +1408,60 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1362,11 +1479,37 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -1489,12 +1632,20 @@
"@jridgewell/sourcemap-codec": "^1.5.0" "@jridgewell/sourcemap-codec": "^1.5.0"
} }
}, },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -1504,7 +1655,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mime-db": "1.52.0" "mime-db": "1.52.0"
}, },
@@ -1657,8 +1807,7 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@solidtime/api", "name": "@solidtime/api",
"version": "0.0.4", "version": "0.0.6",
"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",
@@ -29,6 +29,7 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",
"axios": "^1.13.2",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },

View File

@@ -36,20 +36,14 @@ 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({ .object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })
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({ .object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) })
email: z.string().email(),
role: z.enum(['admin', 'manager', 'employee']),
})
.passthrough(); .passthrough();
const InvoiceResource = z const InvoiceResource = z
.object({ .object({
@@ -97,6 +91,7 @@ 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(),
@@ -161,6 +156,7 @@ 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(),
@@ -206,6 +202,7 @@ 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,
@@ -320,6 +317,8 @@ 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(),
employees_can_manage_tasks: 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,
@@ -334,6 +333,8 @@ 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(),
employees_can_manage_tasks: 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,
@@ -388,10 +389,7 @@ const ProjectMemberResource = z
}) })
.passthrough(); .passthrough();
const ProjectMemberStoreRequest = z const ProjectMemberStoreRequest = z
.object({ .object({ member_id: z.string(), billable_rate: z.union([z.number(), z.null()]).optional() })
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()]) })
@@ -420,6 +418,7 @@ 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([
@@ -431,6 +430,7 @@ 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),
@@ -453,6 +453,8 @@ 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,6 +481,8 @@ 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(),
@@ -592,12 +596,7 @@ const DetailedWithDataReportResource = z
}) })
.passthrough(); .passthrough();
const TagResource = z const TagResource = z
.object({ .object({ id: z.string(), name: z.string(), created_at: z.string(), updated_at: z.string() })
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();
@@ -629,6 +628,7 @@ 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(),
@@ -749,6 +749,7 @@ export const schemas = {
TimeEntryAggregationType, TimeEntryAggregationType,
TimeEntryAggregationTypeInterval, TimeEntryAggregationTypeInterval,
Weekday, Weekday,
TimeEntryRoundingType,
ReportStoreRequest, ReportStoreRequest,
DetailedReportResource, DetailedReportResource,
ReportUpdateRequest, ReportUpdateRequest,
@@ -761,6 +762,7 @@ export const schemas = {
TaskStoreRequest, TaskStoreRequest,
TaskUpdateRequest, TaskUpdateRequest,
start, start,
rounding_minutes,
TimeEntryResource, TimeEntryResource,
TimeEntryStoreRequest, TimeEntryStoreRequest,
TimeEntryUpdateMultipleRequest, TimeEntryUpdateMultipleRequest,
@@ -790,13 +792,7 @@ const endpoints = makeApi([
alias: 'getCurrencies', alias: 'getCurrencies',
requestFormat: 'json', requestFormat: 'json',
response: z.array( response: z.array(
z z.object({ code: z.string(), name: z.string(), symbol: z.string() }).passthrough()
.object({
code: z.string(),
name: z.string(),
symbol: z.string(),
})
.passthrough()
), ),
}, },
{ {
@@ -868,10 +864,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1166,13 +1159,7 @@ const endpoints = makeApi([
}, },
], ],
response: z.array( response: z.array(
z z.object({ value: z.number().int(), name: z.string(), color: z.string() }).passthrough()
.object({
value: z.number().int(),
name: z.string(),
color: z.string(),
})
.passthrough()
), ),
errors: [ errors: [
{ {
@@ -1235,10 +1222,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1281,10 +1265,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1332,10 +1313,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1363,11 +1341,7 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -1405,11 +1379,7 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -1465,7 +1435,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.string() }).passthrough(), z.object({ message: z.literal('Invalid base64 encoded data') }).passthrough(),
]), ]),
}, },
{ {
@@ -1487,10 +1457,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1511,11 +1478,7 @@ const endpoints = makeApi([
.object({ .object({
data: z.array( data: z.array(
z z
.object({ .object({ key: z.string(), name: z.string(), description: z.string() })
key: z.string(),
name: z.string(),
description: z.string(),
})
.passthrough() .passthrough()
), ),
}) })
@@ -1603,10 +1566,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1634,11 +1594,7 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -1660,10 +1616,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1809,10 +1762,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1855,10 +1805,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1901,10 +1848,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1988,10 +1932,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2056,6 +1997,13 @@ 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`,
@@ -2075,10 +2023,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2102,6 +2047,13 @@ 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`,
@@ -2147,11 +2099,7 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -2173,10 +2121,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2246,10 +2191,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2282,11 +2224,7 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -2308,10 +2246,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2344,11 +2279,7 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -2370,10 +2301,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2401,11 +2329,7 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -2448,11 +2372,7 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -2515,10 +2435,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2634,10 +2551,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2680,10 +2594,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2767,10 +2678,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2798,11 +2706,7 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -2918,11 +2822,7 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -2944,10 +2844,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3053,10 +2950,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3140,10 +3034,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3253,10 +3144,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3304,10 +3192,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3335,11 +3220,7 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -3434,10 +3315,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3480,10 +3358,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3531,10 +3406,7 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3562,11 +3434,7 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -3639,6 +3507,16 @@ 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',
@@ -3696,10 +3574,7 @@ 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({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3727,11 +3602,7 @@ 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({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -3753,10 +3624,7 @@ 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({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3799,10 +3667,7 @@ 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({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3845,10 +3710,7 @@ 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({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3881,11 +3743,7 @@ 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({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -3907,10 +3765,7 @@ 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({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3980,6 +3835,7 @@ 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(),
}, },
@@ -3998,6 +3854,7 @@ 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(),
}, },
@@ -4036,6 +3893,16 @@ 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',
@@ -4120,10 +3987,7 @@ 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({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -4158,6 +4022,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client', 'client',
'billable', 'billable',
'description', 'description',
'tag',
]), ]),
}, },
{ {
@@ -4174,6 +4039,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client', 'client',
'billable', 'billable',
'description', 'description',
'tag',
]), ]),
}, },
{ {
@@ -4221,6 +4087,16 @@ 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',
@@ -4256,11 +4132,7 @@ 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({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -4282,10 +4154,7 @@ 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({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -4346,6 +4215,16 @@ 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',
@@ -4376,11 +4255,7 @@ 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({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -4402,10 +4277,7 @@ 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({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -4487,11 +4359,7 @@ 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({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -4508,10 +4376,7 @@ 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({ .object({ message: z.string(), errors: z.record(z.array(z.string())) })
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -4534,11 +4399,7 @@ 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({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -4576,11 +4437,7 @@ 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({ .object({ error: z.boolean(), key: z.string(), message: z.string() })
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.10", "version": "0.0.15",
"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,16 +21,21 @@
"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"
}, },
"files": [ "files": [
"dist" "dist",
"styles.css",
"tailwind.theme.js"
], ],
"keywords": [ "keywords": [
"solidtime", "solidtime",
@@ -59,8 +64,11 @@
"@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

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '../utils/cn';
import { Primitive, type PrimitiveProps } from 'reka-ui';
import { type ButtonVariants, buttonVariants } from '.';
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant'];
size?: ButtonVariants['size'];
class?: HTMLAttributes['class'];
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
});
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)">
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,36 @@
import { cva, type VariantProps } from 'class-variance-authority';
export { default as Button } from './Button.vue';
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border shadow-xs hover:text-text-primary bg-card-background dark:bg-transparent border-input dark:border-input hover:bg-white/5',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-white/5',
link: 'text-primary underline-offset-4 hover:underline',
input: 'border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent shadow-sm',
},
size: {
default: 'h-9 px-4 py-2',
xs: 'h-7 rounded px-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
input: 'h-[42px] px-3 py-2 text-base',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export type ButtonVariants = VariantProps<typeof buttonVariants>;

View File

@@ -92,7 +92,7 @@ function updateValue(client: { id: string | null; name: string }) {
<ComboboxAnchor> <ComboboxAnchor>
<ComboboxInput <ComboboxInput
ref="searchInput" ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full" class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a client..." /> placeholder="Search for a client..." />
</ComboboxAnchor> </ComboboxAnchor>
<ComboboxContent> <ComboboxContent>

View File

@@ -2,13 +2,14 @@
import { computed, inject, type ComputedRef } from 'vue'; import { computed, inject, type ComputedRef } from 'vue';
import { formatDate, formatHumanReadableDuration } from '../utils/time'; import { formatDate, formatHumanReadableDuration } from '../utils/time';
import type { Organization } from '@/packages/api/src'; import type { Organization } from '@/packages/api/src';
import type { Dayjs } from 'dayjs';
const props = defineProps<{ const props = defineProps<{
date: Date; date: Dayjs;
totalMinutes?: number; totalSeconds?: number;
}>(); }>();
const totalSeconds = computed(() => (props.totalMinutes ?? 0) * 60); const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
// Injected organization for formatting settings // Injected organization for formatting settings
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined; const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
@@ -20,11 +21,11 @@ const dateFormat = computed(() => organization?.value?.date_format);
<template> <template>
<div class="fc-day-header-custom"> <div class="fc-day-header-custom">
<div class="text-xs text-muted-foreground font-medium"> <div class="text-xs text-muted-foreground font-medium">
{{ date.toLocaleDateString('en-US', { weekday: 'short' }) }} {{ date.format('ddd') }}
</div> </div>
<span>{{ formatDate(date.toISOString(), dateFormat) }}</span> <span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
<span class="block text-xs text-muted-foreground font-medium mt-1"> <span class="block text-xs text-muted-foreground font-medium mt-1">
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }} {{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
</span> </span>
</div> </div>
</template> </template>

View File

@@ -40,18 +40,18 @@ const formattedDuration = computed(() =>
</script> </script>
<template> <template>
<div class="text-xs leading-tight"> <div class="text-2xs leading-tight px-0.5 py-1.5">
<div class="font-semibold mb-0.5">{{ title }}</div> <div class="font-semibold">{{ title }}</div>
<div v-if="projectName" class="font-medium text-[0.6875rem] opacity-90"> <div v-if="projectName" class="font-medium opacity-90">
{{ projectName }} {{ projectName }}
</div> </div>
<div v-if="taskName" class="font-medium text-[0.6875rem] opacity-90"> <div v-if="taskName" class="font-medium">
{{ taskName }} {{ taskName }}
</div> </div>
<div v-if="clientName" class="text-[0.625rem] italic opacity-85"> <div v-if="clientName" class="opacity-85">
{{ clientName }} {{ clientName }}
</div> </div>
<div class="text-[0.625rem] font-semibold opacity-90 mt-0.5"> <div class="opacity-90">
{{ formattedDuration }} {{ formattedDuration }}
</div> </div>
</div> </div>

View File

@@ -4,7 +4,17 @@ import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid'; import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction'; import interactionPlugin from '@fullcalendar/interaction';
import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core'; import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';
import { computed, ref, watch, inject, type ComputedRef } from 'vue'; import {
computed,
ref,
watch,
inject,
type ComputedRef,
nextTick,
onMounted,
onActivated,
onUnmounted,
} from 'vue';
import chroma from 'chroma-js'; import chroma from 'chroma-js';
import { useCssVariable } from '@/utils/useCssVariable'; import { useCssVariable } from '@/utils/useCssVariable';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time'; import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
@@ -12,6 +22,10 @@ import { getUserTimezone, getWeekStart } from '../utils/settings';
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..'; import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
import FullCalendarEventContent from './FullCalendarEventContent.vue'; import FullCalendarEventContent from './FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendarDayHeader.vue'; import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
import activityStatusPlugin, {
type ActivityPeriod,
renderActivityStatusBoxes,
} from './idleStatusPlugin';
import type { import type {
TimeEntry, TimeEntry,
Project, Project,
@@ -24,7 +38,10 @@ import type {
} from '@/packages/api/src'; } from '@/packages/api/src';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
type CalendarExtendedProps = { timeEntry: TimeEntry } & Record<string, unknown>; type CalendarExtendedProps = { timeEntry: TimeEntry; isRunning?: boolean } & Record<
string,
unknown
>;
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'dates-change', payload: { start: Date; end: Date }): void; (e: 'dates-change', payload: { start: Date; end: Date }): void;
@@ -37,10 +54,13 @@ const props = defineProps<{
tasks: Task[]; tasks: Task[];
clients: Client[]; clients: Client[];
tags: Tag[]; tags: Tag[];
activityPeriods?: ActivityPeriod[];
loading?: boolean; loading?: boolean;
// Permissions / feature flags // Permissions / feature flags
enableEstimatedTime: boolean; enableEstimatedTime: boolean;
currency: string;
canCreateProject: boolean;
createTimeEntry: ( createTimeEntry: (
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'> entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
@@ -61,6 +81,10 @@ const selectedTimeEntry = ref<TimeEntry | null>(null);
const calendarRef = ref<InstanceType<typeof FullCalendar> | 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 // Inject organization data for settings
const organization = inject<ComputedRef<Organization>>('organization'); const organization = inject<ComputedRef<Organization>>('organization');
@@ -102,67 +126,81 @@ const events = computed(() => {
const themeBackground = (() => { const themeBackground = (() => {
return cssBackground.value?.trim(); return cssBackground.value?.trim();
})(); })();
return props.timeEntries return props.timeEntries?.map((timeEntry) => {
?.filter((timeEntry) => timeEntry.end !== null) const isRunning = timeEntry.end === null;
?.map((timeEntry) => { const project = props.projects.find((p) => p.id === timeEntry.project_id);
const project = props.projects.find((p) => p.id === timeEntry.project_id); const client = props.clients.find((c) => c.id === project?.client_id);
const client = props.clients.find((c) => c.id === project?.client_id); const task = props.tasks.find((t) => t.id === timeEntry.task_id);
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
const duration = getDayJsInstance()(timeEntry.end!).diff(
getDayJsInstance()(timeEntry.start),
'minutes'
);
const title = timeEntry.description || 'No description'; // 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 baseColor = project?.color || '#6B7280'; const title = timeEntry.description || 'No description';
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 baseColor = project?.color || '#6B7280';
const startTime = getLocalizedDayJs(timeEntry.start); const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
const endTime = const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
duration === 0
? startTime.add(1, 'second') // Show as 1 second for minimal visibility
: getLocalizedDayJs(timeEntry.end!);
return { // For 0-duration events, display them with minimum visual duration but preserve actual duration
id: timeEntry.id, const startTime = getLocalizedDayJs(timeEntry.start);
start: startTime.format(), const endTime =
end: endTime.format(), duration === 0
title, ? startTime.add(1, 'second') // Show as 1 second for minimal visibility
backgroundColor, : isRunning
borderColor, ? getLocalizedDayJs(currentTime.value.toISOString())
textColor: 'var(--foreground)', : getLocalizedDayJs(timeEntry.end!);
extendedProps: {
timeEntry, return {
project, id: timeEntry.id,
client, start: startTime.format(),
task, end: endTime.format(),
duration, 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 // Daily totals used in day header
const dailyTotals = computed(() => { const dailyTotals = computed(() => {
const totals: Record<string, number> = {}; const totals: Record<string, number> = {};
props.timeEntries props.timeEntries.forEach((entry) => {
.filter((entry) => entry.end !== null) const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
.forEach((entry) => { let durationSeconds: number;
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
const duration = getDayJsInstance()(entry.end!).diff( if (entry.end !== null) {
// Completed entry
durationSeconds = getDayJsInstance()(entry.end).diff(
getDayJsInstance()(entry.start), getDayJsInstance()(entry.start),
'minutes' 'seconds'
); );
totals[date] = (totals[date] || 0) + duration; } else {
}); // Running entry - use current time
durationSeconds = currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds');
}
totals[date] = (totals[date] || 0) + durationSeconds;
});
return totals; return totals;
}); });
function emitDatesChange(arg: DatesSetArg) { function emitDatesChange(arg: DatesSetArg) {
emit('dates-change', { start: arg.start, end: arg.end }); 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 }) { function handleDateSelect(arg: { start: Date; end: Date }) {
@@ -181,6 +219,10 @@ function handleDateSelect(arg: { start: Date; end: Date }) {
function handleEventClick(arg: EventClickArg) { function handleEventClick(arg: EventClickArg) {
const ext = arg.event.extendedProps as CalendarExtendedProps; const ext = arg.event.extendedProps as CalendarExtendedProps;
// Don't open edit modal for running time entries
if (ext.isRunning) {
return;
}
selectedTimeEntry.value = ext.timeEntry; selectedTimeEntry.value = ext.timeEntry;
showEditTimeEntryModal.value = true; showEditTimeEntryModal.value = true;
} }
@@ -194,11 +236,13 @@ async function handleEventDrop(arg: EventDropArg) {
start: getDayJsInstance()(arg.event.start.toISOString()) start: getDayJsInstance()(arg.event.start.toISOString())
.utc() .utc()
.tz(getUserTimezone(), true) .tz(getUserTimezone(), true)
.second(0)
.utc() .utc()
.format(), .format(),
end: getDayJsInstance()(arg.event.end.toISOString()) end: getDayJsInstance()(arg.event.end.toISOString())
.utc() .utc()
.tz(getUserTimezone(), true) .tz(getUserTimezone(), true)
.second(0)
.utc() .utc()
.format(), .format(),
} as TimeEntry; } as TimeEntry;
@@ -215,20 +259,25 @@ async function handleEventResize(arg: EventChangeArg) {
start: getDayJsInstance()(arg.event.start.toISOString()) start: getDayJsInstance()(arg.event.start.toISOString())
.utc() .utc()
.tz(getUserTimezone(), true) .tz(getUserTimezone(), true)
.second(0)
.utc() .utc()
.format(), .format(),
end: getDayJsInstance()(arg.event.end.toISOString()) // Preserve null end for running entries
.utc() end: ext.isRunning
.tz(getUserTimezone(), true) ? null
.utc() : getDayJsInstance()(arg.event.end.toISOString())
.format(), .utc()
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
} as TimeEntry; } as TimeEntry;
await props.updateTimeEntry(updatedTimeEntry); await props.updateTimeEntry(updatedTimeEntry);
emit('refresh'); emit('refresh');
} }
const calendarOptions = computed(() => ({ const calendarOptions = computed(() => ({
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin], plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
initialView: 'timeGridWeek', initialView: 'timeGridWeek',
headerToolbar: { headerToolbar: {
left: 'prev,next today', left: 'prev,next today',
@@ -241,16 +290,17 @@ const calendarOptions = computed(() => ({
slotDuration: '00:15:00', slotDuration: '00:15:00',
slotLabelInterval: '01:00:00', slotLabelInterval: '01:00:00',
slotLabelFormat: getSlotLabelFormat(), slotLabelFormat: getSlotLabelFormat(),
snapDuration: '00:15:00', snapDuration: '00:01:00',
firstDay: getFirstDay(), firstDay: getFirstDay(),
allDaySlot: false, allDaySlot: false,
nowIndicator: true, nowIndicator: true,
eventMinHeight: 1,
selectable: true, selectable: true,
selectMirror: true, selectMirror: true,
editable: true, editable: true,
eventResizableFromStart: true, eventResizableFromStart: true,
eventDurationEditable: true, eventDurationEditable: true,
timeZone: 'America/Adak', timeZone: getUserTimezone(),
eventStartEditable: true, eventStartEditable: true,
select: handleDateSelect, select: handleDateSelect,
eventClick: handleEventClick, eventClick: handleEventClick,
@@ -259,6 +309,7 @@ const calendarOptions = computed(() => ({
datesSet: emitDatesChange, datesSet: emitDatesChange,
events: events.value, events: events.value,
activityPeriods: props.activityPeriods || [],
})); }));
watch(showCreateTimeEntryModal, (value) => { watch(showCreateTimeEntryModal, (value) => {
@@ -277,6 +328,60 @@ watch(showEditTimeEntryModal, (value) => {
emit('refresh'); 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> </script>
<template> <template>
@@ -295,6 +400,8 @@ watch(showEditTimeEntryModal, (value) => {
:create-client="createClient" :create-client="createClient"
:create-project="createProject" :create-project="createProject"
:create-tag="createTag" :create-tag="createTag"
:currency="currency"
:can-create-project="canCreateProject"
:tags="tags as any" :tags="tags as any"
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
@@ -314,7 +421,9 @@ watch(showEditTimeEntryModal, (value) => {
:tags="tags as any" :tags="tags as any"
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
:clients="clients" /> :clients="clients"
:currency="currency"
:can-create-project="canCreateProject" />
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions"> <FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
<template #eventContent="arg"> <template #eventContent="arg">
<FullCalendarEventContent <FullCalendarEventContent
@@ -332,9 +441,16 @@ watch(showEditTimeEntryModal, (value) => {
</template> </template>
<template #dayHeaderContent="arg"> <template #dayHeaderContent="arg">
<FullCalendarDayHeader <FullCalendarDayHeader
:date="arg.date" :date="
:total-minutes=" getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
dailyTotals[getDayJsInstance()(arg.date).format('YYYY-MM-DD')] || 0 "
:total-seconds="
dailyTotals[
getDayJsInstance()(arg.date)
.utc()
.tz(getUserTimezone(), true)
.format('YYYY-MM-DD')
] || 0
" /> " />
</template> </template>
</FullCalendar> </FullCalendar>
@@ -360,11 +476,11 @@ watch(showEditTimeEntryModal, (value) => {
} }
.fullcalendar :deep(.fc-timegrid-slot-label) { .fullcalendar :deep(.fc-timegrid-slot-label) {
background-color: var(--theme-color-default-background); background-color: var(--background);
} }
.fullcalendar :deep(.fc-toolbar) { .fullcalendar :deep(.fc-toolbar) {
background-color: var(--theme-color-default-background); background-color: var(--background);
padding: 0.5rem; padding: 0.5rem;
margin-bottom: 0; margin-bottom: 0;
} }
@@ -432,7 +548,7 @@ watch(showEditTimeEntryModal, (value) => {
} }
.fullcalendar :deep(.fc-day-today.fc-col-header-cell) { .fullcalendar :deep(.fc-day-today.fc-col-header-cell) {
background-color: var(--color-accent-default); background-color: var(--color-bg-secondary);
} }
.fullcalendar :deep(.fc-day-today) { .fullcalendar :deep(.fc-day-today) {
@@ -445,8 +561,8 @@ watch(showEditTimeEntryModal, (value) => {
} }
.fullcalendar :deep(.fc-event) { .fullcalendar :deep(.fc-event) {
border-radius: var(--radius); border-radius: calc(var(--radius) - 4px);
padding: 0.45rem 0.25rem; padding: 0;
font-size: 0.75rem; font-size: 0.75rem;
cursor: pointer; cursor: pointer;
box-shadow: var(--theme-shadow-card); box-shadow: var(--theme-shadow-card);
@@ -508,7 +624,7 @@ watch(showEditTimeEntryModal, (value) => {
} }
.fullcalendar :deep(.fc-highlight) { .fullcalendar :deep(.fc-highlight) {
background-color: var(--theme-color-default-background); background-color: var(--primary);
} }
.fullcalendar :deep(.fc-select-mirror) { .fullcalendar :deep(.fc-select-mirror) {
@@ -526,7 +642,7 @@ watch(showEditTimeEntryModal, (value) => {
} }
.fullcalendar :deep(.fc-timegrid-body) { .fullcalendar :deep(.fc-timegrid-body) {
background-color: var(--theme-color-default-background); background-color: var(--background);
} }
.fullcalendar :deep(.fc-timegrid-col) { .fullcalendar :deep(.fc-timegrid-col) {
@@ -593,4 +709,57 @@ watch(showEditTimeEntryModal, (value) => {
.fullcalendar :deep(.fc-event-main) { .fullcalendar :deep(.fc-event-main) {
padding: 0.125rem 0.25rem; padding: 0.125rem 0.25rem;
} }
/* Activity status plugin styles */
.fullcalendar :deep(.activity-status-box) {
position: absolute;
width: 10px;
left: 0px;
z-index: 10;
cursor: default;
}
.fullcalendar :deep(.activity-status-box::before) {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 5px;
transition: opacity 0.2s ease;
}
.fullcalendar :deep(.activity-status-box.idle::before) {
background-color: rgba(156, 163, 175, 0.1);
}
.fullcalendar :deep(.activity-status-box.idle):hover::before {
background-color: rgba(156, 163, 175, 0.5);
}
.fullcalendar :deep(.activity-status-box.active::before) {
background-color: rgba(34, 197, 94, 0.3);
}
.fullcalendar :deep(.activity-status-box.active):hover::before {
background-color: rgba(34, 197, 94, 1);
}
/* Add left margin to events only on days with activity status data */
.fullcalendar :deep(.has-activity-status .fc-timegrid-event-harness) {
margin-left: 8px !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> </style>

View File

@@ -0,0 +1,393 @@
import { createPlugin, type PluginDef } from '@fullcalendar/core';
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';
export interface WindowActivityInPeriod {
appName: string;
url: string | null;
count: number;
icon?: string | null;
}
export interface ActivityPeriod {
start: string;
end: string;
isIdle: boolean;
windowActivities?: WindowActivityInPeriod[];
}
export interface ActivityStatusPluginOptions {
activityPeriods?: ActivityPeriod[];
}
// Tooltip state management - single instance per module
let tooltipInstance: HTMLElement | null = null;
let cleanupAutoUpdate: (() => void) | null = null;
/**
* Creates and manages a tooltip element for activity status boxes
*/
function getOrCreateTooltip(): HTMLElement {
if (!tooltipInstance) {
tooltipInstance = document.createElement('div');
tooltipInstance.className =
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground';
tooltipInstance.style.position = 'fixed';
tooltipInstance.style.pointerEvents = 'none';
tooltipInstance.style.opacity = '0';
tooltipInstance.style.whiteSpace = 'nowrap';
tooltipInstance.style.transform = 'scale(0.95)';
tooltipInstance.style.transition = 'opacity 150ms, transform 150ms';
document.body.appendChild(tooltipInstance);
}
return tooltipInstance;
}
/**
* Shows tooltip for an activity status box using Floating UI's autoUpdate
*/
function showTooltip(box: HTMLElement, tooltip: HTMLElement, content: string | HTMLElement) {
// Clear previous content
tooltip.innerHTML = '';
if (typeof content === 'string') {
tooltip.textContent = content;
} else {
tooltip.appendChild(content);
}
tooltip.style.opacity = '1';
tooltip.style.transform = 'scale(1)';
// Clean up previous autoUpdate if it exists
if (cleanupAutoUpdate) {
cleanupAutoUpdate();
}
// Use autoUpdate to automatically update position
cleanupAutoUpdate = autoUpdate(box, tooltip, () => {
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`;
});
});
}
/**
* Hides the tooltip immediately
*/
function hideTooltip(tooltip: HTMLElement) {
tooltip.style.opacity = '0';
tooltip.style.transform = 'scale(0.95)';
// Clean up autoUpdate when tooltip is hidden
if (cleanupAutoUpdate) {
cleanupAutoUpdate();
cleanupAutoUpdate = null;
}
}
/**
* Formats duration in minutes to human readable format
*/
function formatDuration(durationMinutes: number): string {
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
/**
* Creates tooltip content for an activity period
*/
function createTooltipContent(
status: string,
durationText: string,
windowActivities?: WindowActivityInPeriod[]
): string | HTMLElement {
if (!windowActivities || windowActivities.length === 0) {
return `${status} (${durationText})`;
}
const container = document.createElement('div');
container.style.maxWidth = '300px';
// Header with status and duration
const header = document.createElement('div');
header.style.fontWeight = '600';
header.style.marginBottom = '8px';
header.textContent = `${status} (${durationText})`;
container.appendChild(header);
// Window activities list
const totalActivities = windowActivities.reduce((sum, act) => sum + act.count, 0);
// Show top 5 activities
const topActivities = windowActivities.slice(0, 5);
topActivities.forEach((activity) => {
const activityDiv = document.createElement('div');
activityDiv.style.marginTop = '4px';
activityDiv.style.fontSize = '11px';
activityDiv.style.opacity = '0.9';
activityDiv.style.display = 'flex';
activityDiv.style.alignItems = 'center';
activityDiv.style.gap = '6px';
// Add icon if available
if (activity.icon) {
const icon = document.createElement('img');
icon.src = activity.icon;
icon.alt = activity.appName;
icon.style.width = '16px';
icon.style.height = '16px';
icon.style.borderRadius = '2px';
icon.style.flexShrink = '0';
activityDiv.appendChild(icon);
} else {
// Placeholder for no icon
const placeholder = document.createElement('div');
placeholder.style.width = '16px';
placeholder.style.height = '16px';
placeholder.style.borderRadius = '2px';
placeholder.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
placeholder.style.display = 'flex';
placeholder.style.alignItems = 'center';
placeholder.style.justifyContent = 'center';
placeholder.style.fontSize = '8px';
placeholder.style.flexShrink = '0';
placeholder.textContent = activity.appName.charAt(0).toUpperCase();
activityDiv.appendChild(placeholder);
}
const textSpan = document.createElement('span');
textSpan.style.flex = '1';
textSpan.style.overflow = 'hidden';
textSpan.style.textOverflow = 'ellipsis';
textSpan.style.whiteSpace = 'nowrap';
const percentage = ((activity.count / totalActivities) * 100).toFixed(0);
const activityText = activity.url
? `${activity.appName} - ${activity.url}`
: activity.appName;
textSpan.textContent = `${percentage}% ${activityText}`;
activityDiv.appendChild(textSpan);
container.appendChild(activityDiv);
});
// Show "and X more" if there are more activities
if (windowActivities.length > 5) {
const moreDiv = document.createElement('div');
moreDiv.style.marginTop = '4px';
moreDiv.style.fontSize = '11px';
moreDiv.style.opacity = '0.7';
moreDiv.style.fontStyle = 'italic';
moreDiv.textContent = `...and ${windowActivities.length - 5} more`;
container.appendChild(moreDiv);
}
return container;
}
/**
* Renders activity status boxes in the calendar time grid
*/
export function renderActivityStatusBoxes(
calendarEl: HTMLElement,
activityPeriods: ActivityPeriod[]
) {
if (!calendarEl) return;
// Clean up existing activity boxes
const existingBoxes = calendarEl.querySelectorAll('.activity-status-box');
existingBoxes.forEach((box) => box.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) return;
const lanes = timeGrid.querySelectorAll('.fc-timegrid-col');
if (lanes.length === 0) return;
// Get or reuse the single tooltip instance
const tooltip = getOrCreateTooltip();
// Get slot duration from calendar (fallback to 15 minutes)
const slotDurationMinutes = getSlotDuration(calendarEl);
lanes.forEach((lane: Element) => {
// Get the date for this lane from the data attribute
const laneEl = lane as HTMLElement;
const dateStr = laneEl.getAttribute('data-date');
if (!dateStr) 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 actual start and end times for this day
const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart;
const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd;
// Calculate the position and height of the activity box
const { top, height } = calculateBoxPosition(
calendarEl,
actualStart,
actualEnd,
slotDurationMinutes
);
if (height <= 0) return;
hasActivityStatusForThisDay = true;
// Calculate duration in minutes
const durationMs = actualEnd.getTime() - actualStart.getTime();
const durationMinutes = Math.round(durationMs / 60000);
const durationText = formatDuration(durationMinutes);
// Add tooltip text based on status
const status = period.isIdle ? 'Idling' : 'Active';
// Create and append the activity status box
const box = document.createElement('div');
box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`;
box.style.top = `${top}px`;
box.style.height = `${height}px`;
// Store tooltip content generator in data attribute for event delegation
const tooltipContent = createTooltipContent(
status,
durationText,
period.windowActivities
);
// Add hover event listeners for tooltip
box.addEventListener('mouseenter', () => {
showTooltip(box, tooltip, tooltipContent);
});
box.addEventListener('mouseleave', () => {
hideTooltip(tooltip);
});
// Position relative to the lane
const laneFrame = lane.querySelector('.fc-timegrid-col-frame');
if (laneFrame) {
laneFrame.appendChild(box);
}
});
// Mark this lane as having activity status if any periods were rendered
if (hasActivityStatusForThisDay) {
laneEl.classList.add('has-activity-status');
}
});
}
/**
* Gets the slot duration from the calendar configuration
*/
function getSlotDuration(calendarEl: HTMLElement): number {
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
if (slotsEl.length < 2) return 15; // Default to 15 minutes
// Try to calculate from the time difference between slots
const firstSlot = slotsEl[0] as HTMLElement;
const secondSlot = slotsEl[1] as HTMLElement;
const firstTime = firstSlot.getAttribute('data-time');
const secondTime = secondSlot.getAttribute('data-time');
if (firstTime && secondTime) {
const [h1, m1] = firstTime.split(':').map(Number);
const [h2, m2] = secondTime.split(':').map(Number);
const diff = h2 * 60 + m2 - (h1 * 60 + m1);
if (diff > 0) return diff;
}
// Fallback to 15 minutes
return 15;
}
/**
* Calculates the pixel position and height for an activity status box
*/
function calculateBoxPosition(
calendarEl: HTMLElement,
startTime: Date,
endTime: Date,
slotDurationMinutes: number
): { top: number; height: number } {
// Get the slot duration and slot height
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
if (slotsEl.length === 0) {
return { top: 0, height: 0 };
}
// Calculate slot height (assuming all slots are equal height)
const firstSlot = slotsEl[0] as HTMLElement;
const slotHeight = firstSlot.offsetHeight;
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 };
}
/**
* Cleanup function to remove tooltip from DOM
*/
export function cleanupActivityStatusPlugin() {
if (tooltipInstance) {
tooltipInstance.remove();
tooltipInstance = null;
}
if (cleanupAutoUpdate) {
cleanupAutoUpdate();
cleanupAutoUpdate = null;
}
}
/**
* 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

@@ -0,0 +1,20 @@
<script setup lang="ts"></script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path d="M2 5h20" />
<path d="M6 12h12" />
<path d="M9 19h6" />
</svg>
</template>
<style scoped></style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { Button } from '@/Components/ui/button'; import Button from '../Buttons/Button.vue';
import { RangeCalendar } from '@/Components/ui/range-calendar'; import { RangeCalendar } from '../range-calendar';
import { CalendarDate } from '@internationalized/date'; import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from 'lucide-vue-next'; import { CalendarIcon } from 'lucide-vue-next';
import { computed, ref, inject, type ComputedRef, watch } from 'vue'; import { computed, ref, inject, type ComputedRef, watch } from 'vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { watch } from 'vue'; import { watch } from 'vue';
const props = withDefaults( const props = withDefaults(
@@ -52,7 +52,7 @@ watch(open, (value) => {
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
:align="align" :align="align"
class="rounded-lg overflow-hidden relative border border-card-border overflow-none shadow-dropdown bg-card-background" class="rounded-lg overflow-hidden relative border border-card-border overflow-none shadow-dropdown bg-secondary"
@open-auto-focus="handleAutofocus" @open-auto-focus="handleAutofocus"
@click="onContentClick"> @click="onContentClick">
<slot name="content" /> <slot name="content" />

View File

@@ -145,7 +145,7 @@ const highlightedItem = computed(() => {
<input <input
ref="searchInput" ref="searchInput"
:value="searchValue" :value="searchValue"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full" class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
:placeholder="searchPlaceholder" :placeholder="searchPlaceholder"
@input="updateSearchValue" @input="updateSearchValue"
@keydown.up.prevent="moveHighlightUp" @keydown.up.prevent="moveHighlightUp"

View File

@@ -27,11 +27,19 @@ 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))) {
model.value = getLocalizedDayJs(model.value) const currentTime = getLocalizedDayJs(model.value);
.set('hours', Math.min(parseInt(hours), 23)) const newHours = Math.min(parseInt(hours), 23);
.set('minutes', Math.min(parseInt(minutes), 59)) const newMinutes = Math.min(parseInt(minutes), 59);
.format();
emit('changed', model.value); // Only update if hours or minutes are different
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
@@ -42,6 +50,7 @@ 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) {
@@ -50,6 +59,7 @@ 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) {
@@ -57,6 +67,7 @@ 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) {
@@ -64,6 +75,7 @@ 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);
} }
@@ -93,6 +105,7 @@ 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()"

Some files were not shown because too many files have changed in this diff Show More