mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
54 Commits
feature/si
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a7b67e6e6 | ||
|
|
de97d15925 | ||
|
|
0691fe10ef | ||
|
|
513b2048ee | ||
|
|
3acf9b8b07 | ||
|
|
814d539fb0 | ||
|
|
7a51fca2f9 | ||
|
|
280032ee02 | ||
|
|
b1bb7245b0 | ||
|
|
6f37ad500a | ||
|
|
500ccd5719 | ||
|
|
bacd6f4222 | ||
|
|
022caf59ee | ||
|
|
f955ab3135 | ||
|
|
5b491b0da2 | ||
|
|
249ab67ac8 | ||
|
|
1bd2c28b37 | ||
|
|
33ac994cc0 | ||
|
|
8d3ee58bed | ||
|
|
8a2c260533 | ||
|
|
95ab1699c4 | ||
|
|
306a081a3d | ||
|
|
878ac4ab81 | ||
|
|
947550d639 | ||
|
|
09fb5aa48e | ||
|
|
9b9371e5a5 | ||
|
|
0648437478 | ||
|
|
8ba04eca0c | ||
|
|
8a2f35de0c | ||
|
|
b7dafb0892 | ||
|
|
6eca0c2c76 | ||
|
|
3417b60585 | ||
|
|
0f21fabd37 | ||
|
|
df00200464 | ||
|
|
3b41de7135 | ||
|
|
9fe0ea5a0f | ||
|
|
f8f708a664 | ||
|
|
c359259e45 | ||
|
|
55d12aaae1 | ||
|
|
9a1dd4861c | ||
|
|
1e985b71ec | ||
|
|
93d6a86f74 | ||
|
|
19a206d57c | ||
|
|
c0788c270b | ||
|
|
7765056074 | ||
|
|
639f5332e4 | ||
|
|
4a50145329 | ||
|
|
8aabffd1e7 | ||
|
|
b373427dc7 | ||
|
|
d2a4d60441 | ||
|
|
c3305b3df6 | ||
|
|
7584e59d0b | ||
|
|
d2f75cca6e | ||
|
|
250379d4bd |
@@ -22,13 +22,27 @@ class Kernel extends ConsoleKernel
|
||||
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
|
||||
->everyTenMinutes();
|
||||
|
||||
$schedule->command('self-host:check-for-update')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
||||
->twiceDaily();
|
||||
if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) {
|
||||
// Convert string to a stable integer for seeding
|
||||
/** @var int $seed Take the first 8 hex chars → 32-bit int */
|
||||
$seed = hexdec(substr(hash('md5', config('app.key')), 0, 8));
|
||||
$seed = abs($seed); // Ensure it's positive
|
||||
mt_srand($seed);
|
||||
$firstHour = mt_rand(0, 23);
|
||||
$secondHour = ($firstHour + 12) % 24;
|
||||
$minuteOffset = mt_rand(0, 59);
|
||||
mt_srand(null); // Reset the random number generator
|
||||
|
||||
$schedule->command('self-host:telemetry')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
|
||||
->twiceDaily();
|
||||
if (config('scheduling.tasks.self_hosting_check_for_update')) {
|
||||
$schedule->command('self-host:check-for-update')
|
||||
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
|
||||
}
|
||||
|
||||
if (config('scheduling.tasks.self_hosting_telemetry')) {
|
||||
$schedule->command('self-host:telemetry')
|
||||
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
|
||||
}
|
||||
}
|
||||
|
||||
$schedule->command('self-host:database-consistency')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
|
||||
|
||||
@@ -20,6 +20,7 @@ enum TimeEntryAggregationType: string
|
||||
case Client = 'client';
|
||||
case Billable = 'billable';
|
||||
case Description = 'description';
|
||||
case Tag = 'tag';
|
||||
|
||||
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
||||
{
|
||||
|
||||
10
app/Exceptions/Api/OverlappingTimeEntryApiException.php
Normal file
10
app/Exceptions/Api/OverlappingTimeEntryApiException.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class OverlappingTimeEntryApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'overlapping_time_entry';
|
||||
}
|
||||
@@ -38,11 +38,17 @@ class ClientController extends Controller
|
||||
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'clients:view');
|
||||
$canViewAllClients = $this->hasPermission($organization, 'clients:view:all');
|
||||
$user = $this->user();
|
||||
|
||||
$clientsQuery = Client::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if (! $canViewAllClients) {
|
||||
$clientsQuery->visibleByEmployee($user);
|
||||
}
|
||||
|
||||
$filterArchived = $request->getFilterArchived();
|
||||
if ($filterArchived === 'true') {
|
||||
$clientsQuery->whereNotNull('archived_at');
|
||||
|
||||
@@ -46,6 +46,9 @@ class OrganizationController extends Controller
|
||||
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
||||
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
||||
}
|
||||
if ($request->getEmployeesCanManageTasks() !== null) {
|
||||
$organization->employees_can_manage_tasks = $request->getEmployeesCanManageTasks();
|
||||
}
|
||||
if ($request->getNumberFormat() !== null) {
|
||||
$organization->number_format = $request->getNumberFormat();
|
||||
}
|
||||
@@ -61,6 +64,9 @@ class OrganizationController extends Controller
|
||||
if ($request->getTimeFormat() !== null) {
|
||||
$organization->time_format = $request->getTimeFormat();
|
||||
}
|
||||
if ($request->getPreventOverlappingTimeEntries() !== null) {
|
||||
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
|
||||
}
|
||||
$hasBillableRate = $request->has('billable_rate');
|
||||
if ($hasBillableRate) {
|
||||
$oldBillableRate = $organization->billable_rate;
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Http\Requests\V1\Task\TaskUpdateRequest;
|
||||
use App\Http\Resources\V1\Task\TaskCollection;
|
||||
use App\Http\Resources\V1\Task\TaskResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -27,6 +28,26 @@ class TaskController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check scoped permission and verify user has access to the project
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
private function checkScopedPermissionForProject(Organization $organization, Project $project, string $permission): void
|
||||
{
|
||||
$this->checkPermission($organization, $permission);
|
||||
|
||||
$user = $this->user();
|
||||
$hasAccess = Project::query()
|
||||
->where('id', $project->id)
|
||||
->visibleByEmployee($user)
|
||||
->exists();
|
||||
|
||||
if (! $hasAccess) {
|
||||
throw new AuthorizationException('You do not have permission to '.$permission.' in this project.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks
|
||||
*
|
||||
@@ -75,7 +96,15 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:create');
|
||||
/** @var Project $project */
|
||||
$project = Project::query()->findOrFail($request->input('project_id'));
|
||||
|
||||
if ($this->hasPermission($organization, 'tasks:create:all')) {
|
||||
$this->checkPermission($organization, 'tasks:create:all');
|
||||
} else {
|
||||
$this->checkScopedPermissionForProject($organization, $project, 'tasks:create');
|
||||
}
|
||||
|
||||
$task = new Task;
|
||||
$task->name = $request->input('name');
|
||||
$task->project_id = $request->input('project_id');
|
||||
@@ -97,7 +126,17 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:update', $task);
|
||||
// Check task belongs to organization
|
||||
if ($task->organization_id !== $organization->id) {
|
||||
throw new AuthorizationException('Task does not belong to organization');
|
||||
}
|
||||
|
||||
if ($this->hasPermission($organization, 'tasks:update:all')) {
|
||||
$this->checkPermission($organization, 'tasks:update:all');
|
||||
} else {
|
||||
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:update');
|
||||
}
|
||||
|
||||
$task->name = $request->input('name');
|
||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||
$task->estimated_time = $request->getEstimatedTime();
|
||||
@@ -119,7 +158,16 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function destroy(Organization $organization, Task $task): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:delete', $task);
|
||||
// Check task belongs to organization
|
||||
if ($task->organization_id !== $organization->id) {
|
||||
throw new AuthorizationException('Task does not belong to organization');
|
||||
}
|
||||
|
||||
if ($this->hasPermission($organization, 'tasks:delete:all')) {
|
||||
$this->checkPermission($organization, 'tasks:delete:all');
|
||||
} else {
|
||||
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:delete');
|
||||
}
|
||||
|
||||
if ($task->timeEntries()->exists()) {
|
||||
throw new EntityStillInUseApiException('task', 'time_entry');
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api\V1;
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||
@@ -45,6 +46,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\File;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
@@ -56,6 +58,43 @@ use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
|
||||
class TimeEntryController extends Controller
|
||||
{
|
||||
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
|
||||
{
|
||||
if (! $organization->prevent_overlapping_time_entries) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query = TimeEntry::query()
|
||||
->where('organization_id', $organization->getKey())
|
||||
->where('user_id', $member->user_id)
|
||||
->when($exclude !== null, function (Builder $q) use ($exclude): void {
|
||||
$q->where('id', '!=', $exclude->getKey());
|
||||
})
|
||||
->where(function (Builder $q) use ($start, $end): void {
|
||||
$q->where(function (Builder $q2) use ($start): void {
|
||||
$q2->where('end', '>', $start)
|
||||
->where('start', '<', $start);
|
||||
});
|
||||
|
||||
if ($end !== null) {
|
||||
$q->orWhere(function (Builder $q4) use ($end): void {
|
||||
$q4->where('start', '<', $end)
|
||||
->where('end', '>', $end);
|
||||
});
|
||||
// Check if the new entry completely surrounds an existing entry
|
||||
$q->orWhere(function (Builder $q6) use ($start, $end): void {
|
||||
$q6->where('start', '>=', $start)
|
||||
->where('end', '<=', $end);
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
if ($query->exists()) {
|
||||
throw new OverlappingTimeEntryApiException;
|
||||
}
|
||||
}
|
||||
|
||||
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
|
||||
{
|
||||
parent::checkPermission($organization, $permission);
|
||||
@@ -549,17 +588,15 @@ class TimeEntryController extends Controller
|
||||
throw new TimeEntryStillRunningApiException;
|
||||
}
|
||||
|
||||
// Overlap check for create
|
||||
$start = Carbon::parse($request->input('start'));
|
||||
$end = $request->input('end') !== null ? Carbon::parse($request->input('end')) : null;
|
||||
$this->assertNoOverlap($organization, $member, $start, $end);
|
||||
|
||||
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
|
||||
$client = $project?->client;
|
||||
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
|
||||
|
||||
if ($project !== null) {
|
||||
RecalculateSpentTimeForProject::dispatch($project);
|
||||
}
|
||||
if ($task !== null) {
|
||||
RecalculateSpentTimeForTask::dispatch($task);
|
||||
}
|
||||
|
||||
$timeEntry = new TimeEntry;
|
||||
$timeEntry->fill($request->validated());
|
||||
$timeEntry->client()->associate($client);
|
||||
@@ -569,6 +606,13 @@ class TimeEntryController extends Controller
|
||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||
$timeEntry->save();
|
||||
|
||||
if ($project !== null) {
|
||||
RecalculateSpentTimeForProject::dispatch($project);
|
||||
}
|
||||
if ($task !== null) {
|
||||
RecalculateSpentTimeForTask::dispatch($task);
|
||||
}
|
||||
|
||||
return new TimeEntryResource($timeEntry);
|
||||
}
|
||||
|
||||
@@ -593,6 +637,13 @@ class TimeEntryController extends Controller
|
||||
throw new TimeEntryCanNotBeRestartedApiException;
|
||||
}
|
||||
|
||||
// Overlap check for update (exclude current)
|
||||
/** @var Member $effectiveMember */
|
||||
$effectiveMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : $timeEntry->member;
|
||||
$effectiveStart = $request->has('start') ? Carbon::parse($request->input('start')) : $timeEntry->start;
|
||||
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
|
||||
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
|
||||
|
||||
$oldProject = $timeEntry->project;
|
||||
$oldTask = $timeEntry->task;
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
||||
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
||||
|
||||
/** @var BillingContract $billing */
|
||||
$billing = app(BillingContract::class);
|
||||
@@ -50,6 +51,7 @@ class HandleInertiaRequests extends Middleware
|
||||
return array_merge(parent::share($request), [
|
||||
'has_billing_extension' => $hasBilling,
|
||||
'has_invoicing_extension' => $hasInvoicing,
|
||||
'has_services_extension' => $hasServices,
|
||||
'billing' => $currentOrganization !== null ? [
|
||||
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||
|
||||
@@ -39,6 +39,12 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
||||
'employees_can_see_billable_rates' => [
|
||||
'boolean',
|
||||
],
|
||||
'employees_can_manage_tasks' => [
|
||||
'boolean',
|
||||
],
|
||||
'prevent_overlapping_time_entries' => [
|
||||
'boolean',
|
||||
],
|
||||
'number_format' => [
|
||||
Rule::enum(NumberFormat::class),
|
||||
],
|
||||
@@ -98,4 +104,14 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
||||
{
|
||||
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
|
||||
}
|
||||
|
||||
public function getEmployeesCanManageTasks(): ?bool
|
||||
{
|
||||
return $this->has('employees_can_manage_tasks') ? $this->boolean('employees_can_manage_tasks') : null;
|
||||
}
|
||||
|
||||
public function getPreventOverlappingTimeEntries(): ?bool
|
||||
{
|
||||
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
@@ -42,7 +44,16 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
||||
'required_with:task_id',
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||
|
||||
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
||||
$permissionStore = app(PermissionStore::class);
|
||||
if (! $permissionStore->has($this->organization, 'time-entries:create:all')
|
||||
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
||||
$builder = $builder->visibleByEmployee(Auth::user());
|
||||
}
|
||||
|
||||
return $builder;
|
||||
})->uuid(),
|
||||
],
|
||||
// ID of the task that the time entry should belong to
|
||||
@@ -79,7 +90,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
||||
'description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:500',
|
||||
'max:5000',
|
||||
],
|
||||
// List of tag IDs
|
||||
'tags' => [
|
||||
|
||||
@@ -10,8 +10,10 @@ use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
@@ -54,7 +56,16 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
|
||||
'required_with:task_id',
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||
|
||||
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
||||
$permissionStore = app(PermissionStore::class);
|
||||
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
|
||||
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
||||
$builder = $builder->visibleByEmployee(Auth::user());
|
||||
}
|
||||
|
||||
return $builder;
|
||||
})->uuid(),
|
||||
],
|
||||
// ID of the task that the time entry should belong to
|
||||
@@ -79,7 +90,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
|
||||
'changes.description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:500',
|
||||
'max:5000',
|
||||
],
|
||||
// List of tag IDs
|
||||
'changes.tags' => [
|
||||
|
||||
@@ -10,8 +10,10 @@ use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
@@ -42,7 +44,16 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
||||
'required_with:task_id',
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||
|
||||
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
||||
$permissionStore = app(PermissionStore::class);
|
||||
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
|
||||
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
||||
$builder = $builder->visibleByEmployee(Auth::user());
|
||||
}
|
||||
|
||||
return $builder;
|
||||
})->uuid(),
|
||||
],
|
||||
// ID of the task that the time entry should belong to
|
||||
@@ -77,7 +88,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
||||
'description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:500',
|
||||
'max:5000',
|
||||
],
|
||||
// List of tag IDs
|
||||
'tags' => [
|
||||
|
||||
@@ -53,6 +53,10 @@ class OrganizationResource extends BaseResource
|
||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
|
||||
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
||||
/** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
||||
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
|
||||
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
|
||||
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
|
||||
/** @var string $currency Currency code (ISO 4217) */
|
||||
'currency' => $this->resource->currency,
|
||||
/** @var string $currency_symbol Currency symbol */
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\ClientFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -62,6 +63,18 @@ class Client extends Model implements AuditableContract
|
||||
return $this->hasMany(Project::class, 'client_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Client> $builder
|
||||
* @return Builder<Client>
|
||||
*/
|
||||
public function scopeVisibleByEmployee(Builder $builder, User $user): Builder
|
||||
{
|
||||
return $builder->whereHas('projects', function (Builder $builder) use ($user): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->visibleByEmployee($user);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Attribute<bool, never>
|
||||
*/
|
||||
|
||||
@@ -35,6 +35,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property int|null $billable_rate
|
||||
* @property string $user_id
|
||||
* @property bool $employees_can_see_billable_rates
|
||||
* @property bool $employees_can_manage_tasks
|
||||
* @property User $owner
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
@@ -70,6 +71,8 @@ class Organization extends JetstreamTeam implements AuditableContract
|
||||
'personal_team' => 'boolean',
|
||||
'currency' => 'string',
|
||||
'employees_can_see_billable_rates' => 'boolean',
|
||||
'employees_can_manage_tasks' => 'boolean',
|
||||
'prevent_overlapping_time_entries' => 'boolean',
|
||||
'number_format' => NumberFormat::class,
|
||||
'currency_format' => CurrencyFormat::class,
|
||||
'date_format' => DateFormat::class,
|
||||
|
||||
@@ -94,8 +94,11 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tasks:view',
|
||||
'tasks:view:all',
|
||||
'tasks:create',
|
||||
'tasks:create:all',
|
||||
'tasks:update',
|
||||
'tasks:update:all',
|
||||
'tasks:delete',
|
||||
'tasks:delete:all',
|
||||
'time-entries:view:all',
|
||||
'time-entries:create:all',
|
||||
'time-entries:update:all',
|
||||
@@ -109,6 +112,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
@@ -157,8 +161,11 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tasks:view',
|
||||
'tasks:view:all',
|
||||
'tasks:create',
|
||||
'tasks:create:all',
|
||||
'tasks:update',
|
||||
'tasks:update:all',
|
||||
'tasks:delete',
|
||||
'tasks:delete:all',
|
||||
'time-entries:view:all',
|
||||
'time-entries:create:all',
|
||||
'time-entries:update:all',
|
||||
@@ -172,6 +179,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
@@ -217,8 +225,11 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tasks:view',
|
||||
'tasks:view:all',
|
||||
'tasks:create',
|
||||
'tasks:create:all',
|
||||
'tasks:update',
|
||||
'tasks:update:all',
|
||||
'tasks:delete',
|
||||
'tasks:delete:all',
|
||||
'time-entries:view:all',
|
||||
'time-entries:create:all',
|
||||
'time-entries:update:all',
|
||||
@@ -232,6 +243,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
@@ -256,12 +268,13 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'projects:view',
|
||||
'tags:view',
|
||||
'tasks:view',
|
||||
'clients:view',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'organizations:view',
|
||||
])->description('Employees have the ability to read, create, and update their own time entries and they can see the projects that they are members of.');
|
||||
])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
|
||||
|
||||
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
|
||||
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
||||
|
||||
@@ -266,7 +266,8 @@ class DashboardService
|
||||
) as aggregate'))
|
||||
->where('billable', '=', true)
|
||||
->whereNotNull('billable_rate')
|
||||
->where('user_id', '=', $user->id);
|
||||
->where('user_id', '=', $user->getKey())
|
||||
->where('organization_id', '=', $organization->getKey());
|
||||
|
||||
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
||||
/** @var Collection<int, object{aggregate: int}> $resultDb */
|
||||
|
||||
@@ -167,7 +167,7 @@ class ExportService
|
||||
$client->id,
|
||||
$client->name,
|
||||
$client->organization_id,
|
||||
$client->archived_at ?? '',
|
||||
$client->archived_at?->toIso8601ZuluString() ?? '',
|
||||
$client->created_at?->toIso8601ZuluString() ?? '',
|
||||
$client->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
|
||||
@@ -112,7 +112,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
if (strlen($record['Description']) > 500) {
|
||||
if (strlen($record['Description']) > 5000) {
|
||||
throw new ImportException('Time entry description is too long');
|
||||
}
|
||||
$timeEntry->description = $record['Description'];
|
||||
|
||||
@@ -107,7 +107,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
if (strlen($record['Notes']) > 500) {
|
||||
if (strlen($record['Notes']) > 5000) {
|
||||
throw new ImportException('Time entry note is too long');
|
||||
}
|
||||
$timeEntry->description = $record['Notes'];
|
||||
|
||||
@@ -247,7 +247,7 @@ class SolidtimeImporter extends DefaultImporter
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
if (strlen($timeEntryRow['description']) > 500) {
|
||||
if (strlen($timeEntryRow['description']) > 5000) {
|
||||
throw new ImportException('Time entry description is too long');
|
||||
}
|
||||
$timeEntry->description = $timeEntryRow['description'];
|
||||
|
||||
@@ -71,7 +71,19 @@ class PermissionStore
|
||||
/** @var Role|null $roleObj */
|
||||
$roleObj = Jetstream::findRole($role);
|
||||
|
||||
return $roleObj->permissions ?? [];
|
||||
$permissions = $roleObj->permissions ?? [];
|
||||
|
||||
// If the organization allows employees to manage tasks and the user is an employee,
|
||||
// add the task management permissions for accessible projects
|
||||
if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) {
|
||||
$permissions = array_merge($permissions, [
|
||||
'tasks:create',
|
||||
'tasks:update',
|
||||
'tasks:delete',
|
||||
]);
|
||||
}
|
||||
|
||||
return $permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Enums\TimeEntryRoundingType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Client;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
@@ -17,6 +18,7 @@ use Carbon\CarbonTimeZone;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TimeEntryAggregationService
|
||||
@@ -45,9 +47,21 @@ class TimeEntryAggregationService
|
||||
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
|
||||
{
|
||||
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
||||
/** @var Builder<TimeEntry> $baseTotalsQuery */
|
||||
$baseTotalsQuery = $timeEntriesQuery->clone();
|
||||
$group1Select = null;
|
||||
$group2Select = null;
|
||||
$groupBy = null;
|
||||
// If any grouping is by tag, expand rows per tag and ensure a NULL row for entries without tags
|
||||
if (($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag)) {
|
||||
$timeEntriesQuery->crossJoin(DB::raw(
|
||||
"LATERAL (\n".
|
||||
" SELECT jsonb_array_elements_text(coalesce(tags, '[]'::jsonb)) AS tag\n".
|
||||
" UNION ALL\n".
|
||||
" SELECT ''::text AS tag WHERE coalesce(jsonb_array_length(tags), 0) = 0\n".
|
||||
') AS tag(tag)'
|
||||
));
|
||||
}
|
||||
if ($group1Type !== null) {
|
||||
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
|
||||
$groupBy = ['group_1'];
|
||||
@@ -84,6 +98,26 @@ class TimeEntryAggregationService
|
||||
$group1Response = [];
|
||||
$group1ResponseSum = 0;
|
||||
$group1ResponseCost = 0;
|
||||
// If Tag is subgroup, prepare base totals per primary group without tag expansion
|
||||
$baseTotalsPerGroup1Map = [];
|
||||
if ($group2Type === TimeEntryAggregationType::Tag) {
|
||||
$baseTotalsPerGroup1Query = $baseTotalsQuery->clone();
|
||||
$baseTotalsPerGroup1 = $baseTotalsPerGroup1Query
|
||||
->selectRaw(
|
||||
$group1Select.' as group_1,'.
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
||||
)
|
||||
->groupBy('group_1')
|
||||
->get();
|
||||
foreach ($baseTotalsPerGroup1 as $row) {
|
||||
/** @var object{group_1: mixed, aggregate: int|null, cost: int|null} $row */
|
||||
$baseTotalsPerGroup1Map[(string) ($row->group_1 ?? '')] = [
|
||||
'aggregate' => (int) ($row->aggregate ?? 0),
|
||||
'cost' => (int) ($row->cost ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
foreach ($groupedAggregates as $group1 => $group1Aggregates) {
|
||||
/** @var string|int $group1 */
|
||||
$group2Response = [];
|
||||
@@ -103,6 +137,14 @@ class TimeEntryAggregationService
|
||||
$group2ResponseSum += (int) $aggregate->get(0)->aggregate;
|
||||
$group2ResponseCost += (int) $aggregate->get(0)->cost;
|
||||
}
|
||||
// Override primary group totals when Tag is subgroup to avoid double counting
|
||||
if ($group2Type === TimeEntryAggregationType::Tag) {
|
||||
$keyForMap = (string) $group1;
|
||||
if (array_key_exists($keyForMap, $baseTotalsPerGroup1Map)) {
|
||||
$group2ResponseSum = $baseTotalsPerGroup1Map[$keyForMap]['aggregate'];
|
||||
$group2ResponseCost = $baseTotalsPerGroup1Map[$keyForMap]['cost'];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/** @var Collection<int, object{aggregate: int, cost: int}> $group1Aggregates */
|
||||
$group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate;
|
||||
@@ -121,6 +163,23 @@ class TimeEntryAggregationService
|
||||
$group1ResponseCost += $group2ResponseCost;
|
||||
}
|
||||
|
||||
// If Tag is selected in any grouping, compute overall totals from base (non-tag-expanded) query to avoid double counting
|
||||
$hasTagGrouping = ($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag);
|
||||
if ($hasTagGrouping) {
|
||||
// Reset selects and ordering on the cloned base query
|
||||
$baseTotals = $baseTotalsQuery
|
||||
->selectRaw(
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
||||
)
|
||||
->first();
|
||||
if ($baseTotals !== null) {
|
||||
/** @var object{aggregate: int|null, cost: int|null} $baseTotals */
|
||||
$group1ResponseSum = (int) ($baseTotals->aggregate ?? 0);
|
||||
$group1ResponseCost = (int) ($baseTotals->cost ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
if ($fillGapsInTimeGroupsIsPossible) {
|
||||
$group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end);
|
||||
}
|
||||
@@ -294,6 +353,17 @@ class TimeEntryAggregationService
|
||||
'color' => null,
|
||||
];
|
||||
}
|
||||
} elseif ($type === TimeEntryAggregationType::Tag) {
|
||||
$tags = Tag::query()
|
||||
->whereIn('id', $keys)
|
||||
->select('id', 'name')
|
||||
->get();
|
||||
foreach ($tags as $tag) {
|
||||
$descriptorMap[$tag->id] = [
|
||||
'description' => $tag->name,
|
||||
'color' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $descriptorMap;
|
||||
@@ -436,6 +506,8 @@ class TimeEntryAggregationService
|
||||
return 'billable';
|
||||
} elseif ($group === TimeEntryAggregationType::Description) {
|
||||
return 'description';
|
||||
} elseif ($group === TimeEntryAggregationType::Tag) {
|
||||
return 'tag';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('organizations', function (Blueprint $table): void {
|
||||
$table->boolean('prevent_overlapping_time_entries')->default(false)->after('employees_can_see_billable_rates');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('organizations', function (Blueprint $table): void {
|
||||
$table->dropColumn('prevent_overlapping_time_entries');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('time_entries', function (Blueprint $table): void {
|
||||
$table->string('description', 5000)->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('time_entries', function (Blueprint $table): void {
|
||||
$table->string('description', 500)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('organizations', function (Blueprint $table): void {
|
||||
$table->boolean('employees_can_manage_tasks')->default(false)->after('employees_can_see_billable_rates');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('organizations', function (Blueprint $table): void {
|
||||
$table->dropColumn('employees_can_manage_tasks');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -435,7 +435,7 @@ CREATE TABLE public.tasks (
|
||||
|
||||
CREATE TABLE public.time_entries (
|
||||
id uuid NOT NULL,
|
||||
description character varying(500) NOT NULL,
|
||||
description character varying(5000) NOT NULL,
|
||||
start timestamp(0) without time zone NOT NULL,
|
||||
"end" timestamp(0) without time zone,
|
||||
billable_rate integer,
|
||||
|
||||
@@ -9,7 +9,10 @@ async function goToOrganizationSettings(page) {
|
||||
|
||||
async function createTimeEntry(page, duration: string) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Open the dropdown menu and click "Manual time entry"
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Fill in the time entry details
|
||||
await page.getByTestId('time_entry_description').fill('Test time entry');
|
||||
|
||||
@@ -26,7 +26,10 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
|
||||
|
||||
// Then create the time entry
|
||||
await goToTimeOverview(page);
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Open the dropdown menu and click "Manual time entry"
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Fill in the time entry details
|
||||
await page
|
||||
@@ -52,7 +55,10 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
|
||||
|
||||
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
|
||||
await goToTimeOverview(page);
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Open the dropdown menu and click "Manual time entry"
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Fill in the time entry details
|
||||
await page
|
||||
@@ -81,7 +87,10 @@ async function createTimeEntryWithBillableStatus(
|
||||
duration: string
|
||||
) {
|
||||
await goToTimeOverview(page);
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Open the dropdown menu and click "Manual time entry"
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Fill in the time entry details
|
||||
await page
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
||||
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||
@@ -47,6 +48,7 @@ return [
|
||||
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
|
||||
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
|
||||
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
|
||||
OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.',
|
||||
],
|
||||
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||
];
|
||||
|
||||
@@ -203,6 +203,7 @@ return [
|
||||
'organization' => 'The :attribute does not exist.',
|
||||
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
|
||||
'project_name_already_exists' => 'A project with the same name and client already exists in the organization.',
|
||||
'overlapping_time_entry' => 'Overlapping time entries are not allowed.',
|
||||
'tag_name_already_exists' => 'A tag with the same name already exists in the organization.',
|
||||
'client_name_already_exists' => 'A client with the same name already exists in the organization.',
|
||||
'task_name_already_exists' => 'A task with the same name already exists in the project.',
|
||||
|
||||
@@ -1,240 +1,14 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
:root.dark {
|
||||
--color-bg-primary: #101012;
|
||||
--color-bg-secondary: #17181B;
|
||||
--color-bg-tertiary: #2A2C32;
|
||||
--color-bg-quaternary: #141518;
|
||||
--color-bg-background: #090909;
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #e3e4e6;
|
||||
--color-text-tertiary: #969799;
|
||||
--color-text-quaternary: #595a5c;
|
||||
/* Import shared solidtime styles from UI package */
|
||||
@import '../js/packages/ui/styles.css';
|
||||
|
||||
--color-border-primary: #191b1f;
|
||||
--color-border-secondary: #23252a;
|
||||
--color-border-tertiary: #2c2e33;
|
||||
--color-border-quaternary: #393B42;
|
||||
--color-input-border-active: rgba(255,255,255,0.3);
|
||||
|
||||
--theme-color-chart: var(--color-accent-200);
|
||||
|
||||
--theme-color-menu-active: var(--color-bg-secondary);
|
||||
--theme-color-card-background: var(--color-bg-secondary);
|
||||
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
|
||||
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
|
||||
|
||||
--theme-color-card-background-active: var(--color-bg-tertiary);
|
||||
|
||||
--theme-color-row-background: var(--color-bg-primary);
|
||||
--theme-color-row-heading-background: var(--theme-color-card-background);
|
||||
--theme-color-row-heading-border: var(--theme-color-card-border);
|
||||
--theme-color-icon-default: var(--color-text-tertiary);
|
||||
|
||||
--theme-color-ring: rgba(255,255,255,0.5);
|
||||
|
||||
--theme-color-button-primary-background: rgba(var(--color-accent-300), 0.1);
|
||||
--theme-color-button-primary-background-hover: rgba(var(--color-accent-300), 0.2);
|
||||
--theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);
|
||||
--theme-color-button-primary-text: var(--color-text-primary);
|
||||
|
||||
--theme-color-input-background: var(--color-bg-secondary);
|
||||
|
||||
--theme-color-input-select-active: rgb(var(--color-accent-300));
|
||||
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
|
||||
|
||||
--color-accent-default: rgba(var(--color-accent-300), 0.2);
|
||||
--color-accent-foreground: rgb(var(--color-accent-100));
|
||||
--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 */
|
||||
/* Main app specific styles - Inter font */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/InterVariable.woff2') format('woff2'),
|
||||
url('/fonts/InterVariable.ttf') format('truetype');
|
||||
src:
|
||||
url('/fonts/InterVariable.woff2') format('woff2'),
|
||||
url('/fonts/InterVariable.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: var(--color-bg-background);
|
||||
--foreground: var(--color-text-primary);
|
||||
--card: var(--theme-color-card-background);
|
||||
--card-foreground: var(--color-text-primary);
|
||||
--popover: var(--theme-color-card-background);
|
||||
--popover-foreground: var(--color-text-primary);
|
||||
--primary: var(--theme-color-button-primary-background);
|
||||
--primary-foreground: var(--theme-color-button-primary-text);
|
||||
--secondary: var(--color-bg-secondary);
|
||||
--secondary-foreground: var(--color-text-primary);
|
||||
--muted: var(--color-bg-tertiary);
|
||||
--muted-foreground: var(--color-text-tertiary);
|
||||
--accent: var(--theme-color-button-primary-background);
|
||||
--accent-foreground: var(--theme-color-button-primary-text);
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: var(--color-text-primary);
|
||||
--border: var(--color-border-primary);
|
||||
--input: var(--color-border-tertiary);
|
||||
--ring: var(--theme-color-ring);
|
||||
--chart-1: var(--color-accent-400);
|
||||
--chart-2: var(--color-accent-500);
|
||||
--chart-3: var(--color-accent-600);
|
||||
--chart-4: var(--color-accent-700);
|
||||
--chart-5: var(--color-accent-800);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: var(--color-bg-background);
|
||||
--foreground: var(--color-text-primary);
|
||||
--card: var(--theme-color-card-background);
|
||||
--card-foreground: var(--color-text-primary);
|
||||
--popover: var(--theme-color-card-background);
|
||||
--popover-foreground: var(--color-text-primary);
|
||||
--primary: var(--theme-color-button-primary-background);
|
||||
--primary-foreground: var(--theme-color-button-primary-text);
|
||||
--secondary: var(--color-bg-secondary);
|
||||
--secondary-foreground: var(--color-text-primary);
|
||||
--muted: var(--color-bg-tertiary);
|
||||
--muted-foreground: var(--color-text-tertiary);
|
||||
--accent: var(--theme-color-button-primary-background);
|
||||
--accent-foreground: var(--theme-color-button-primary-text);
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: var(--color-text-primary);
|
||||
--border: var(--color-border-primary);
|
||||
--input: var(--color-border-tertiary);
|
||||
--ring: var(--theme-color-ring);
|
||||
--chart-1: var(--color-accent-200);
|
||||
--chart-2: var(--color-accent-300);
|
||||
--chart-3: var(--color-accent-400);
|
||||
--chart-4: var(--color-accent-500);
|
||||
--chart-5: var(--color-accent-600);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ const option = computed(() => ({
|
||||
},
|
||||
axisLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fontWeight: 400,
|
||||
color: labelColor.value,
|
||||
margin: 16,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Button } from '@/packages/ui/src';
|
||||
|
||||
const props = defineProps<{
|
||||
icon: Component;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Switch } from '@/Components/ui/switch';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
|
||||
import { Button } from '@/packages/ui/src';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
||||
@@ -30,10 +30,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
||||
<template>
|
||||
<div
|
||||
class="contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
|
||||
<div
|
||||
:class="
|
||||
twMerge('pl-6 font-medium flex items-center space-x-3', props.indent ? 'pl-16' : '')
|
||||
">
|
||||
<div :class="twMerge('pl-6 flex items-center space-x-3', props.indent ? 'pl-16' : '')">
|
||||
<GroupedItemsCountButton
|
||||
v-if="entry.grouped_data && entry.grouped_data?.length > 0"
|
||||
:expanded="expanded"
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
import { ref } from 'vue';
|
||||
import { useForm, usePage } from '@inertiajs/vue3';
|
||||
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 saving = defineModel('saving', { default: false });
|
||||
|
||||
const timezone = ref('');
|
||||
const userTimezone = ref('');
|
||||
const saving = ref(false);
|
||||
|
||||
const page = usePage<{
|
||||
auth: {
|
||||
@@ -21,26 +13,11 @@ const page = usePage<{
|
||||
};
|
||||
}>();
|
||||
|
||||
const hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false);
|
||||
|
||||
onMounted(() => {
|
||||
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
userTimezone.value = getUserTimezone();
|
||||
|
||||
if (
|
||||
getDayJsInstance()().tz(timezone.value).format() !==
|
||||
getDayJsInstance()().tz(userTimezone.value).format() &&
|
||||
!hideTimezoneMismatchModal.value
|
||||
) {
|
||||
show.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
function submit() {
|
||||
function handleUpdate(timezone: string) {
|
||||
saving.value = true;
|
||||
const form = useForm({
|
||||
_method: 'PUT',
|
||||
timezone: timezone.value,
|
||||
timezone: timezone,
|
||||
name: page.props.auth.user.name,
|
||||
email: page.props.auth.user.email,
|
||||
week_start: page.props.auth.user.week_start,
|
||||
@@ -54,53 +31,15 @@ function submit() {
|
||||
show.value = false;
|
||||
location.reload();
|
||||
},
|
||||
onError: () => {
|
||||
saving.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
show.value = false;
|
||||
hideTimezoneMismatchModal.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex justify-center">
|
||||
<span> Timezone mismatch detected </span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="col-span-6 sm:col-span-4 flex-1 space-y-2">
|
||||
<p>
|
||||
The timezone of your device does not match the timezone in your user
|
||||
settings. <br />
|
||||
<strong
|
||||
>We highly recommend that you update your timezone settings to your
|
||||
current timezone.</strong
|
||||
>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Want to change your timezone setting from
|
||||
<strong>{{ userTimezone }}</strong> to <strong>{{ timezone }}</strong
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="cancel"> Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving"
|
||||
@click="submit()">
|
||||
Update timezone
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
<TimezoneMismatchModal v-model:show="show" :saving="saving" @update="handleUpdate" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -10,7 +10,8 @@ defineProps<{
|
||||
<div class="px-4 py-2 2xl:py-3 border-b border-b-background-separator">
|
||||
<div class="col-span-2">
|
||||
<div class="flex justify-between">
|
||||
<p class="font-semibold text-sm text-text-primary">
|
||||
<p
|
||||
class="font-semibold text-sm min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
|
||||
{{ name }}
|
||||
</p>
|
||||
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
aria-live="assertive"
|
||||
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-end sm:p-6 sm:pb-24 z-[70]">
|
||||
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-end sm:p-6 z-[70]">
|
||||
<div class="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||
<Notification
|
||||
v-for="notification in notifications"
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import DropdownLink from '@/Components/DropdownLink.vue';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import {
|
||||
Cog6ToothIcon,
|
||||
PlusCircleIcon,
|
||||
CheckCircleIcon,
|
||||
ArrowRightIcon,
|
||||
} from '@heroicons/vue/24/solid';
|
||||
import type { Organization, User } from '@/types/models';
|
||||
import { isBillingActivated } from '@/utils/billing';
|
||||
import { canManageBilling } from '@/utils/permissions';
|
||||
import { switchOrganization } from '@/utils/useOrganization';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
} from '@/Components/ui/dropdown-menu';
|
||||
|
||||
const page = usePage<{
|
||||
jetstream: {
|
||||
@@ -28,84 +39,79 @@ const switchToTeam = (organization: Organization) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown v-if="page.props.jetstream.hasTeamFeatures" align="center" width="60">
|
||||
<template #trigger>
|
||||
<div
|
||||
data-testid="organization_switcher"
|
||||
class="flex hover:bg-white/10 cursor-pointer transition px-2 py-1 rounded-lg w-full items-center justify-between font-medium">
|
||||
<DropdownMenu v-if="page.props.jetstream.hasTeamFeatures">
|
||||
<DropdownMenuTrigger
|
||||
class="flex w-full text-left hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-ring cursor-pointer transition pl-2 py-1 rounded w-full items-center justify-between"
|
||||
as-child>
|
||||
<button data-testid="organization_switcher">
|
||||
<div class="flex flex-1 space-x-2 items-center w-[calc(100%-30px)]">
|
||||
<div
|
||||
class="rounded sm:rounded-lg bg-blue-900 font-semibold text-xs sm:text-sm flex-shrink-0 text-white w-5 sm:w-6 h-5 sm:h-6 flex items-center justify-center">
|
||||
class="rounded bg-blue-900 font-medium text-xs flex-shrink-0 text-white w-5 h-5 flex items-center justify-center">
|
||||
{{ page.props.auth.user.current_team.name.slice(0, 1).toUpperCase() }}
|
||||
</div>
|
||||
<span class="text-sm flex-1 truncate font-semibold">
|
||||
<span class="text-xs flex-1 truncate font-medium">
|
||||
{{ page.props.auth.user.current_team.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-[30px]">
|
||||
<button
|
||||
class="p-1 transition hover:bg-white/10 rounded-full flex items-center w-8 h-8">
|
||||
<ChevronDownIcon class="w-5 sm:w-full mt-[1px]"></ChevronDownIcon>
|
||||
</button>
|
||||
<div class="p-1 rounded-full flex items-center w-6 h-6">
|
||||
<ChevronDownIcon class="w-4 sm:w-full mt-[1px]"></ChevronDownIcon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<template #content>
|
||||
<DropdownMenuContent align="start">
|
||||
<div class="w-60">
|
||||
<!-- Organization Management -->
|
||||
<div class="block px-4 py-2 text-xs text-text-secondary">Manage Organization</div>
|
||||
<DropdownMenuLabel>Manage Organization</DropdownMenuLabel>
|
||||
|
||||
<!-- Organization Settings -->
|
||||
<DropdownLink :href="route('teams.show', page.props.auth.user.current_team.id)">
|
||||
Organization Settings
|
||||
</DropdownLink>
|
||||
<DropdownMenuItem as-child>
|
||||
<Link
|
||||
:href="route('teams.show', page.props.auth.user.current_team.id)"
|
||||
class="inline-flex items-center gap-2.5 w-full">
|
||||
<Cog6ToothIcon class="w-5 h-5 text-icon-default" />
|
||||
<span>Organization Settings</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownLink v-if="canManageBilling() && isBillingActivated()" href="/billing">
|
||||
Billing
|
||||
</DropdownLink>
|
||||
<DropdownMenuItem v-if="canManageBilling() && isBillingActivated()" as-child>
|
||||
<Link href="/billing" class="inline-flex items-center w-full"> Billing </Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownLink
|
||||
v-if="page.props.jetstream.canCreateTeams"
|
||||
:href="route('teams.create')">
|
||||
Create new organization
|
||||
</DropdownLink>
|
||||
<DropdownMenuItem v-if="page.props.jetstream.canCreateTeams" as-child>
|
||||
<Link
|
||||
:href="route('teams.create')"
|
||||
class="inline-flex items-center gap-2.5 w-full">
|
||||
<PlusCircleIcon class="w-5 h-5 text-icon-default" />
|
||||
<span>Create new organization</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Organization Switcher -->
|
||||
<template v-if="page.props.auth.user.all_teams.length > 1">
|
||||
<div class="border-t border-card-background-separator" />
|
||||
|
||||
<div class="block px-4 py-2 text-xs text-text-secondary">
|
||||
Switch Organizations
|
||||
</div>
|
||||
<DropdownMenuLabel>Switch Organizations</DropdownMenuLabel>
|
||||
|
||||
<template v-for="team in page.props.auth.user.all_teams" :key="team.id">
|
||||
<form @submit.prevent="switchToTeam(team)">
|
||||
<DropdownLink as="button">
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
<DropdownMenuItem
|
||||
as-child
|
||||
class="inline-flex gap-2.5 items-center w-full">
|
||||
<button type="submit">
|
||||
<CheckCircleIcon
|
||||
v-if="team.id == page.props.auth.user.current_team_id"
|
||||
class="me-2 h-5 w-5 text-green-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
class="h-5 w-5 text-green-400" />
|
||||
<ArrowRightIcon v-else class="h-5 w-5 text-icon-default" />
|
||||
|
||||
<div>
|
||||
<div class="w-full truncate text-left">
|
||||
{{ team.name }}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownLink>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</form>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
@@ -16,12 +16,25 @@ import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';
|
||||
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
|
||||
import type {
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
CreateTimeEntryBody,
|
||||
Project,
|
||||
Tag,
|
||||
} from '@/packages/api/src';
|
||||
import TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
|
||||
import TimeTrackerMoreOptionsDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
|
||||
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import { ref } from 'vue';
|
||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
||||
import { api } from '@/packages/api/src';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
|
||||
const page = usePage<{
|
||||
auth: {
|
||||
@@ -47,6 +60,8 @@ const emit = defineEmits<{
|
||||
change: [];
|
||||
}>();
|
||||
|
||||
const showManualTimeEntryModal = ref(false);
|
||||
|
||||
watch(isActive, () => {
|
||||
if (isActive.value) {
|
||||
startLiveTimer();
|
||||
@@ -93,14 +108,73 @@ function switchToTimeEntryOrganization() {
|
||||
switchOrganization(currentTimeEntry.value.organization_id);
|
||||
}
|
||||
}
|
||||
async function createTag(tag: string) {
|
||||
async function createTag(tag: string): Promise<Tag | undefined> {
|
||||
return await useTagsStore().createTag(tag);
|
||||
}
|
||||
|
||||
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
|
||||
await useTimeEntriesStore().createTimeEntry(timeEntry);
|
||||
showManualTimeEntryModal.value = false;
|
||||
}
|
||||
|
||||
async function createTimeEntryFromCurrentEntry() {
|
||||
const { start, end, description, project_id, task_id, billable, tags } = currentTimeEntry.value;
|
||||
await createTimeEntry({ start, end, description, project_id, task_id, billable, tags });
|
||||
currentTimeEntryStore.$reset();
|
||||
}
|
||||
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const deleteTimeEntryMutation = useMutation({
|
||||
mutationFn: async (timeEntryId: string) => {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (!organizationId) {
|
||||
throw new Error('No organization selected');
|
||||
}
|
||||
return await api.deleteTimeEntry(undefined, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
timeEntry: timeEntryId,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await currentTimeEntryStore.fetchCurrentTimeEntry();
|
||||
await useTimeEntriesStore().fetchTimeEntries();
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
},
|
||||
});
|
||||
|
||||
async function discardCurrentTimeEntry() {
|
||||
if (currentTimeEntry.value.id) {
|
||||
await handleApiRequestNotifications(
|
||||
() => deleteTimeEntryMutation.mutateAsync(currentTimeEntry.value.id),
|
||||
'Time entry discarded successfully',
|
||||
'Failed to discard time entry'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { tags } = storeToRefs(useTagsStore());
|
||||
const { timeEntries } = storeToRefs(useTimeEntriesStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TimeEntryCreateModal
|
||||
v-model:show="showManualTimeEntryModal"
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
:create-project="createProject"
|
||||
:create-client="createClient"
|
||||
:create-tag="createTag"
|
||||
:create-time-entry="createTimeEntry"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:can-create-project="canCreateProjects()"
|
||||
:projects
|
||||
:tasks
|
||||
:tags
|
||||
:clients></TimeEntryCreateModal>
|
||||
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
|
||||
<div class="relative">
|
||||
<TimeTrackerRunningInDifferentOrganizationOverlay
|
||||
@@ -109,24 +183,36 @@ const { tags } = storeToRefs(useTagsStore());
|
||||
switchToTimeEntryOrganization
|
||||
"></TimeTrackerRunningInDifferentOrganizationOverlay>
|
||||
|
||||
<TimeTrackerControls
|
||||
v-model:current-time-entry="currentTimeEntry"
|
||||
v-model:live-timer="now"
|
||||
:create-project
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
:can-create-project="canCreateProjects()"
|
||||
:create-client
|
||||
:clients
|
||||
:tags
|
||||
:tasks
|
||||
:projects
|
||||
:create-tag
|
||||
:is-active
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
@start-live-timer="startLiveTimer"
|
||||
@stop-live-timer="stopLiveTimer"
|
||||
@start-timer="setActiveState(true)"
|
||||
@stop-timer="setActiveState(false)"
|
||||
@update-time-entry="updateTimeEntry"></TimeTrackerControls>
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<div class="flex-1">
|
||||
<TimeTrackerControls
|
||||
v-model:current-time-entry="currentTimeEntry"
|
||||
v-model:live-timer="now"
|
||||
:create-project
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
:can-create-project="canCreateProjects()"
|
||||
:create-client
|
||||
:clients
|
||||
:tags
|
||||
:tasks
|
||||
:projects
|
||||
:time-entries
|
||||
:create-tag
|
||||
:is-active
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
@start-live-timer="startLiveTimer"
|
||||
@stop-live-timer="stopLiveTimer"
|
||||
@start-timer="setActiveState(true)"
|
||||
@stop-timer="setActiveState(false)"
|
||||
@update-time-entry="updateTimeEntry"
|
||||
@create-time-entry="createTimeEntryFromCurrentEntry"></TimeTrackerControls>
|
||||
</div>
|
||||
<TimeTrackerMoreOptionsDropdown
|
||||
:has-active-timer="isActive"
|
||||
@manual-entry="showManualTimeEntryModal = true"
|
||||
@discard="discardCurrentTimeEntry"></TimeTrackerMoreOptionsDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { router, usePage } from '@inertiajs/vue3';
|
||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||
import type { Organization, User } from '@/types/models';
|
||||
import DropdownLink from '@/Components/DropdownLink.vue';
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
} from '@/Components/ui/dropdown-menu';
|
||||
import {
|
||||
UserCircleIcon,
|
||||
KeyIcon,
|
||||
ArrowLeftOnRectangleIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
} from '@heroicons/vue/24/solid';
|
||||
import { openFeedback } from '@/utils/feedback';
|
||||
|
||||
const page = usePage<{
|
||||
has_services_extension?: boolean;
|
||||
has_billing_extension?: boolean;
|
||||
jetstream: {
|
||||
canCreateTeams: boolean;
|
||||
hasTeamFeatures: boolean;
|
||||
@@ -23,60 +37,58 @@ const logout = () => {
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div class="ms-3 relative">
|
||||
<Dropdown align="center" width="48">
|
||||
<template #trigger>
|
||||
<button
|
||||
v-if="page.props.jetstream.managesProfilePhotos"
|
||||
data-testid="current_user_button"
|
||||
class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
|
||||
<div class="relative">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
class="flex text-sm border-2 outline-none border-transparent rounded-full focus-visible:ring-2 focus-visible:ring-ring transition"
|
||||
as-child>
|
||||
<button data-testid="current_user_button">
|
||||
<img
|
||||
class="h-8 w-8 rounded-full object-cover"
|
||||
class="h-7 w-7 rounded-full object-cover"
|
||||
:src="page.props.auth.user.profile_photo_url"
|
||||
:alt="page.props.auth.user.name" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" class="max-w-48">
|
||||
<DropdownMenuLabel>Manage Account</DropdownMenuLabel>
|
||||
|
||||
<span v-else class="inline-flex rounded-md">
|
||||
<DropdownMenuItem as-child>
|
||||
<Link
|
||||
:href="route('profile.show')"
|
||||
class="inline-flex items-center gap-2.5 w-full">
|
||||
<UserCircleIcon class="w-5 h-5 text-icon-default" />
|
||||
<span>Profile Settings</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem v-if="page.props.jetstream.hasApiFeatures" as-child>
|
||||
<Link
|
||||
:href="route('api-tokens.index')"
|
||||
class="inline-flex items-center gap-2.5 w-full">
|
||||
<KeyIcon class="w-5 h-5 text-icon-default" />
|
||||
<span>API Tokens</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem v-if="page.props.has_services_extension" as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:bg-gray-50 active:bg-gray-50 transition ease-in-out duration-150">
|
||||
{{ page.props.auth.user.name }}
|
||||
|
||||
<svg
|
||||
class="ms-2 -me-0.5 h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
class="inline-flex items-center gap-2.5 w-full"
|
||||
@click="openFeedback">
|
||||
<ChatBubbleLeftRightIcon class="w-5 h-5 text-icon-default" />
|
||||
<span>Feedback</span>
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<template #content>
|
||||
<!-- Account Management -->
|
||||
<div class="block px-4 py-2 text-xs text-gray-400">Manage Account</div>
|
||||
|
||||
<DropdownLink :href="route('profile.show')"> Profile </DropdownLink>
|
||||
|
||||
<DropdownLink
|
||||
v-if="page.props.jetstream.hasApiFeatures"
|
||||
:href="route('api-tokens.index')">
|
||||
API Tokens
|
||||
</DropdownLink>
|
||||
|
||||
<div class="border-t border-card-border" />
|
||||
|
||||
<!-- Authentication -->
|
||||
<form @submit.prevent="logout">
|
||||
<DropdownLink as="button" data-testid="logout_button"> Log Out </DropdownLink>
|
||||
<form class="w-full" @submit.prevent="logout">
|
||||
<DropdownMenuItem as-child class="inline-flex items-center gap-2.5 w-full">
|
||||
<button type="submit" data-testid="logout_button">
|
||||
<ArrowLeftOnRectangleIcon class="w-5 h-5 text-icon-default" />
|
||||
<span>Log Out</span>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</form>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { buttonVariants } from '@/packages/ui/src';
|
||||
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { buttonVariants } from '@/packages/ui/src';
|
||||
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
||||
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
|
||||
import { Button } from '@/packages/ui/src';
|
||||
import { Calendar } from '@/Components/ui/calendar';
|
||||
import { CalendarIcon, XIcon } from 'lucide-vue-next';
|
||||
import { formatDate } from '@/packages/ui/src/utils/time';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { cn, buttonVariants } from '@/packages/ui/src/index';
|
||||
import { ChevronRight } from 'lucide-vue-next';
|
||||
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
||||
import { ChevronLeft } from 'lucide-vue-next';
|
||||
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
@@ -19,7 +19,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
||||
<template>
|
||||
<DropdownMenuLabel
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)">
|
||||
:class="cn('block px-2 py-2 text-xs text-gray-400', inset && 'pl-8', props.class)">
|
||||
<slot />
|
||||
</DropdownMenuLabel>
|
||||
</template>
|
||||
|
||||
@@ -47,6 +47,8 @@ import { api } from '@/packages/api/src';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { Button } from '@/packages/ui/src';
|
||||
import { openFeedback } from '@/utils/feedback';
|
||||
|
||||
defineProps({
|
||||
title: String,
|
||||
@@ -94,8 +96,8 @@ onMounted(async () => {
|
||||
}, 100);
|
||||
};
|
||||
});
|
||||
|
||||
const page = usePage<{
|
||||
has_services_extension?: boolean;
|
||||
auth: {
|
||||
user: User;
|
||||
};
|
||||
@@ -106,7 +108,7 @@ const page = usePage<{
|
||||
<div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary">
|
||||
<div
|
||||
:class="{
|
||||
'!flex bg-default-background w-full z-[9999999999]': showSidebarMenu,
|
||||
'!flex bg-default-background w-full z-30': showSidebarMenu,
|
||||
}"
|
||||
class="flex-shrink-0 h-screen hidden fixed w-[230px] 2xl:w-[250px] px-2.5 2xl:px-3 py-4 lg:flex flex-col justify-between">
|
||||
<div class="flex flex-col h-full">
|
||||
@@ -242,14 +244,23 @@ const page = usePage<{
|
||||
<div class="justify-self-end">
|
||||
<UpdateSidebarNotification></UpdateSidebarNotification>
|
||||
<ul
|
||||
class="border-t border-default-background-separator pt-3 flex justify-between pr-4 items-center">
|
||||
class="border-t border-default-background-separator pt-3 gap-1 pr-2 flex justify-between items-center">
|
||||
<UserSettingsIcon></UserSettingsIcon>
|
||||
|
||||
<NavigationSidebarItem
|
||||
class="flex-1"
|
||||
title="Profile Settings"
|
||||
:icon="Cog6ToothIcon"
|
||||
:href="route('profile.show')"></NavigationSidebarItem>
|
||||
|
||||
<UserSettingsIcon></UserSettingsIcon>
|
||||
<Button
|
||||
v-if="page.props.has_services_extension"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
class="rounded-full ml-2 flex h-6 w-6 items-center text-xs text-icon-default justify-center"
|
||||
@click="openFeedback">
|
||||
?
|
||||
</Button>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type Project,
|
||||
type TimeEntryResponse,
|
||||
} from '@/packages/api/src';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
||||
import { computed, ref } from 'vue';
|
||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
import { TimeEntryCalendar } from '@/packages/ui/src';
|
||||
@@ -21,6 +21,8 @@ import { useClientsStore } from '@/utils/useClients';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
|
||||
const calendarStart = ref<Date | undefined>(undefined);
|
||||
const calendarEnd = ref<Date | undefined>(undefined);
|
||||
@@ -73,6 +75,7 @@ const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useQuery<Time
|
||||
queries: {
|
||||
start: expandedDateRange.value.start!,
|
||||
end: expandedDateRange.value.end!,
|
||||
member_id: getCurrentMembershipId(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -128,6 +131,8 @@ function onRefresh() {
|
||||
:tags="tags"
|
||||
:loading="timeEntriesLoading"
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:can-create-project="canCreateProjects()"
|
||||
:create-time-entry="createTimeEntry"
|
||||
:update-time-entry="updateTimeEntry"
|
||||
:delete-time-entry="deleteTimeEntry"
|
||||
|
||||
@@ -383,7 +383,7 @@ async function downloadExport(format: ExportFormat) {
|
||||
@submit="clearSelectionAndState"
|
||||
@select-all="selectedTimeEntries = [...timeEntries]"
|
||||
@unselect-all="selectedTimeEntries = []"></TimeEntryMassActionRow>
|
||||
<div class="w-full relative">
|
||||
<div class="w-full relative @container">
|
||||
<div v-for="entry in timeEntries" :key="entry.id">
|
||||
<TimeEntryRow
|
||||
:selected="selectedTimeEntries.includes(entry)"
|
||||
@@ -400,6 +400,7 @@ async function downloadExport(format: ExportFormat) {
|
||||
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
||||
:delete-time-entry="() => deleteTimeEntries([entry])"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:duplicate-time-entry="() => createTimeEntry(entry)"
|
||||
:members="members"
|
||||
show-date
|
||||
show-member
|
||||
|
||||
@@ -27,7 +27,7 @@ interface FormValues {
|
||||
}
|
||||
|
||||
const store = useOrganizationStore();
|
||||
const { fetchOrganization, updateOrganization } = store;
|
||||
const { updateOrganization } = store;
|
||||
const { organization } = storeToRefs(store);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -47,7 +47,6 @@ const mutation = useMutation({
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchOrganization();
|
||||
if (organization.value) {
|
||||
form.value = {
|
||||
number_format: organization.value.number_format as NumberFormat,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import { Checkbox } from '@/packages/ui/src';
|
||||
import type { UpdateOrganizationBody } from '@/packages/api/src';
|
||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
||||
|
||||
const store = useOrganizationStore();
|
||||
const { updateOrganization } = store;
|
||||
const { organization } = storeToRefs(store);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = ref<{
|
||||
prevent_overlapping_time_entries: boolean;
|
||||
employees_can_manage_tasks: boolean;
|
||||
}>({
|
||||
prevent_overlapping_time_entries: false,
|
||||
employees_can_manage_tasks: false,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
form.value.prevent_overlapping_time_entries =
|
||||
organization.value?.prevent_overlapping_time_entries ?? false;
|
||||
form.value.employees_can_manage_tasks = organization.value?.employees_can_manage_tasks ?? false;
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: Partial<UpdateOrganizationBody>) => updateOrganization(values),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['organization'] });
|
||||
},
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
await mutation.mutateAsync({
|
||||
prevent_overlapping_time_entries: form.value.prevent_overlapping_time_entries,
|
||||
employees_can_manage_tasks: form.value.employees_can_manage_tasks,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormSection>
|
||||
<template #title>Organization Settings</template>
|
||||
<template #description>
|
||||
Configure various settings for your organization, including time entry and task
|
||||
management permissions.
|
||||
</template>
|
||||
|
||||
<template #form>
|
||||
<div class="col-span-6">
|
||||
<div class="col-span-6 sm:col-span-4 space-y-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="preventOverlappingTimeEntries"
|
||||
v-model:checked="form.prevent_overlapping_time_entries" />
|
||||
<InputLabel
|
||||
for="preventOverlappingTimeEntries"
|
||||
value="Prevent overlapping time entries (new entries only)" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="employeesCanManageTasks"
|
||||
v-model:checked="form.employees_can_manage_tasks" />
|
||||
<InputLabel
|
||||
for="employeesCanManageTasks"
|
||||
value="Allow Employees to manage tasks" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<PrimaryButton :disabled="mutation.isPending.value" @click="submit">Save</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
</template>
|
||||
@@ -8,12 +8,25 @@ import type { Permissions, Role } from '@/types/jetstream';
|
||||
import { canUpdateOrganization } from '@/utils/permissions';
|
||||
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
|
||||
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
|
||||
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
defineProps<{
|
||||
team: Organization;
|
||||
availableRoles: Role[];
|
||||
permissions: Permissions;
|
||||
}>();
|
||||
|
||||
const loading = ref(true);
|
||||
const orgStore = useOrganizationStore();
|
||||
const { organization } = storeToRefs(orgStore);
|
||||
|
||||
onMounted(async () => {
|
||||
await orgStore.fetchOrganization();
|
||||
loading.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,17 +39,25 @@ defineProps<{
|
||||
|
||||
<div>
|
||||
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
|
||||
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
||||
<div v-if="loading || !organization" class="py-16 text-center text-text-secondary">
|
||||
Loading organization settings...
|
||||
</div>
|
||||
<template v-else>
|
||||
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
||||
|
||||
<SectionBorder />
|
||||
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
|
||||
<SectionBorder />
|
||||
<SectionBorder />
|
||||
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
|
||||
<SectionBorder />
|
||||
|
||||
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
|
||||
<SectionBorder />
|
||||
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
|
||||
<SectionBorder />
|
||||
|
||||
<template v-if="permissions.canDeleteTeam && !team.personal_team">
|
||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||
<OrganizationTimeEntrySettings v-if="canUpdateOrganization()" />
|
||||
<SectionBorder />
|
||||
|
||||
<template v-if="permissions.canDeleteTeam && !team.personal_team">
|
||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,8 +15,6 @@ import type {
|
||||
} from '@/packages/api/src';
|
||||
import { useElementVisibility } from '@vueuse/core';
|
||||
import { ClockIcon } from '@heroicons/vue/20/solid';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
@@ -24,7 +22,6 @@ import { useProjectsStore } from '@/utils/useProjects';
|
||||
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
||||
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
|
||||
@@ -73,7 +70,6 @@ onMounted(async () => {
|
||||
await timeEntriesStore.fetchTimeEntries();
|
||||
});
|
||||
|
||||
const showManualTimeEntryModal = ref(false);
|
||||
const projectStore = useProjectsStore();
|
||||
const { projects } = storeToRefs(projectStore);
|
||||
const taskStore = useTasksStore();
|
||||
@@ -105,33 +101,9 @@ function deleteSelected() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TimeEntryCreateModal
|
||||
v-model:show="showManualTimeEntryModal"
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
:create-project="createProject"
|
||||
:create-client="createClient"
|
||||
:create-tag="createTag"
|
||||
:create-time-entry="createTimeEntry"
|
||||
:projects
|
||||
:tasks
|
||||
:tags
|
||||
:clients></TimeEntryCreateModal>
|
||||
<AppLayout title="Dashboard" data-testid="time_view">
|
||||
<MainContainer class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
||||
<div
|
||||
class="lg:flex items-end lg:divide-x divide-default-background-separator divide-y lg:divide-y-0 space-y-2 lg:space-y-0 lg:space-x-2">
|
||||
<div class="flex-1">
|
||||
<TimeTracker></TimeTracker>
|
||||
</div>
|
||||
<div class="pb-2 pt-2 lg:pt-0 lg:pl-4 flex justify-center">
|
||||
<SecondaryButton
|
||||
class="w-full text-center flex justify-center"
|
||||
:icon="PlusIcon"
|
||||
@click="showManualTimeEntryModal = true"
|
||||
>Manual time entry
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
<TimeTracker></TimeTracker>
|
||||
</MainContainer>
|
||||
<TimeEntryMassActionRow
|
||||
:selected-time-entries="selectedTimeEntries"
|
||||
|
||||
193
resources/js/packages/api/package-lock.json
generated
193
resources/js/packages/api/package-lock.json
generated
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "@solidtime/api",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@solidtime/api",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.6",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@zodios/core": "^10.9.6",
|
||||
"axios": "^1.13.2",
|
||||
"typescript": "^5.5.4",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -1094,18 +1095,16 @@
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz",
|
||||
"integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==",
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -1127,12 +1126,24 @@
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
@@ -1198,11 +1209,24 @@
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
@@ -1216,6 +1240,51 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -1280,7 +1349,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
@@ -1291,14 +1359,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1339,12 +1408,60 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -1362,11 +1479,37 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -1489,12 +1632,20 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -1504,7 +1655,6 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
@@ -1657,8 +1807,7 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@solidtime/api",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"description": "Package containing the solidtime api client and type declarations",
|
||||
"main": "./dist/solidtime-api.umd.cjs",
|
||||
"module": "./dist/solidtime-api.js",
|
||||
@@ -29,6 +29,7 @@
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@zodios/core": "^10.9.6",
|
||||
"axios": "^1.13.2",
|
||||
"typescript": "^5.5.4",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
||||
@@ -36,20 +36,14 @@ const ClientResource = z
|
||||
const ClientCollection = z.array(ClientResource);
|
||||
const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
||||
const ClientUpdateRequest = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
is_archived: z.boolean().optional(),
|
||||
})
|
||||
.object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })
|
||||
.passthrough();
|
||||
const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();
|
||||
const InvitationResource = z
|
||||
.object({ id: z.string(), email: z.string(), role: z.string() })
|
||||
.passthrough();
|
||||
const InvitationStoreRequest = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(['admin', 'manager', 'employee']),
|
||||
})
|
||||
.object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) })
|
||||
.passthrough();
|
||||
const InvoiceResource = z
|
||||
.object({
|
||||
@@ -97,6 +91,7 @@ const InvoiceStoreRequest = z
|
||||
billing_period_end: z.union([z.string(), z.null()]).optional(),
|
||||
reference: z.string(),
|
||||
currency: z.string(),
|
||||
payment_iban: z.union([z.string(), z.null()]).optional(),
|
||||
tax_rate: z.number().int().gte(0).lte(2147483647).optional(),
|
||||
discount_amount: z.number().int().gte(0).lte(9223372036854776000).optional(),
|
||||
discount_type: InvoiceDiscountType.optional(),
|
||||
@@ -161,6 +156,7 @@ const DetailedInvoiceResource = z
|
||||
discount_type: z.string(),
|
||||
discount_amount: z.number().int(),
|
||||
tax_rate: z.number().int(),
|
||||
payment_iban: z.string(),
|
||||
status: z.string(),
|
||||
currency: z.string(),
|
||||
date: z.string(),
|
||||
@@ -206,6 +202,7 @@ const InvoiceUpdateRequest = z
|
||||
billing_period_end: z.union([z.string(), z.null()]),
|
||||
reference: z.string(),
|
||||
currency: z.string(),
|
||||
payment_iban: z.union([z.string(), z.null()]),
|
||||
tax_rate: z.number().int().gte(0).lte(2147483647),
|
||||
discount_amount: z.number().int().gte(0).lte(9223372036854776000),
|
||||
discount_type: InvoiceDiscountType,
|
||||
@@ -320,6 +317,8 @@ const OrganizationResource = z
|
||||
is_personal: z.boolean(),
|
||||
billable_rate: z.union([z.number(), z.null()]),
|
||||
employees_can_see_billable_rates: z.boolean(),
|
||||
employees_can_manage_tasks: z.boolean(),
|
||||
prevent_overlapping_time_entries: z.boolean(),
|
||||
currency: z.string(),
|
||||
currency_symbol: z.string(),
|
||||
number_format: NumberFormat,
|
||||
@@ -334,6 +333,8 @@ const OrganizationUpdateRequest = z
|
||||
name: z.string().max(255),
|
||||
billable_rate: z.union([z.number(), z.null()]),
|
||||
employees_can_see_billable_rates: z.boolean(),
|
||||
employees_can_manage_tasks: z.boolean(),
|
||||
prevent_overlapping_time_entries: z.boolean(),
|
||||
number_format: NumberFormat,
|
||||
currency_format: CurrencyFormat,
|
||||
date_format: DateFormat,
|
||||
@@ -388,10 +389,7 @@ const ProjectMemberResource = z
|
||||
})
|
||||
.passthrough();
|
||||
const ProjectMemberStoreRequest = z
|
||||
.object({
|
||||
member_id: z.string(),
|
||||
billable_rate: z.union([z.number(), z.null()]).optional(),
|
||||
})
|
||||
.object({ member_id: z.string(), billable_rate: z.union([z.number(), z.null()]).optional() })
|
||||
.passthrough();
|
||||
const ProjectMemberUpdateRequest = z
|
||||
.object({ billable_rate: z.union([z.number(), z.null()]) })
|
||||
@@ -420,6 +418,7 @@ const TimeEntryAggregationType = z.enum([
|
||||
'client',
|
||||
'billable',
|
||||
'description',
|
||||
'tag',
|
||||
]);
|
||||
const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);
|
||||
const Weekday = z.enum([
|
||||
@@ -431,6 +430,7 @@ const Weekday = z.enum([
|
||||
'saturday',
|
||||
'sunday',
|
||||
]);
|
||||
const TimeEntryRoundingType = z.enum(['up', 'down', 'nearest']);
|
||||
const ReportStoreRequest = z
|
||||
.object({
|
||||
name: z.string().max(255),
|
||||
@@ -453,6 +453,8 @@ const ReportStoreRequest = z
|
||||
history_group: TimeEntryAggregationTypeInterval,
|
||||
week_start: Weekday.optional(),
|
||||
timezone: z.union([z.string(), z.null()]).optional(),
|
||||
rounding_type: TimeEntryRoundingType.optional(),
|
||||
rounding_minutes: z.union([z.number(), z.null()]).optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
@@ -479,6 +481,8 @@ const DetailedReportResource = z
|
||||
project_ids: z.union([z.array(z.string()), z.null()]),
|
||||
tag_ids: z.union([z.array(z.string()), z.null()]),
|
||||
task_ids: z.union([z.array(z.string()), z.null()]),
|
||||
rounding_type: z.union([z.string(), z.null()]),
|
||||
rounding_minutes: z.union([z.number(), z.null()]),
|
||||
})
|
||||
.passthrough(),
|
||||
created_at: z.string(),
|
||||
@@ -592,12 +596,7 @@ const DetailedWithDataReportResource = z
|
||||
})
|
||||
.passthrough();
|
||||
const TagResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
.object({ id: z.string(), name: z.string(), created_at: z.string(), updated_at: z.string() })
|
||||
.passthrough();
|
||||
const TagCollection = z.array(TagResource);
|
||||
const TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
||||
@@ -629,6 +628,7 @@ const TaskUpdateRequest = z
|
||||
})
|
||||
.passthrough();
|
||||
const start = z.union([z.string(), z.null()]).optional();
|
||||
const rounding_minutes = z.union([z.number(), z.null()]).optional();
|
||||
const TimeEntryResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -749,6 +749,7 @@ export const schemas = {
|
||||
TimeEntryAggregationType,
|
||||
TimeEntryAggregationTypeInterval,
|
||||
Weekday,
|
||||
TimeEntryRoundingType,
|
||||
ReportStoreRequest,
|
||||
DetailedReportResource,
|
||||
ReportUpdateRequest,
|
||||
@@ -761,6 +762,7 @@ export const schemas = {
|
||||
TaskStoreRequest,
|
||||
TaskUpdateRequest,
|
||||
start,
|
||||
rounding_minutes,
|
||||
TimeEntryResource,
|
||||
TimeEntryStoreRequest,
|
||||
TimeEntryUpdateMultipleRequest,
|
||||
@@ -790,13 +792,7 @@ const endpoints = makeApi([
|
||||
alias: 'getCurrencies',
|
||||
requestFormat: 'json',
|
||||
response: z.array(
|
||||
z
|
||||
.object({
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
symbol: z.string(),
|
||||
})
|
||||
.passthrough()
|
||||
z.object({ code: z.string(), name: z.string(), symbol: z.string() }).passthrough()
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -868,10 +864,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1166,13 +1159,7 @@ const endpoints = makeApi([
|
||||
},
|
||||
],
|
||||
response: z.array(
|
||||
z
|
||||
.object({
|
||||
value: z.number().int(),
|
||||
name: z.string(),
|
||||
color: z.string(),
|
||||
})
|
||||
.passthrough()
|
||||
z.object({ value: z.number().int(), name: z.string(), color: z.string() }).passthrough()
|
||||
),
|
||||
errors: [
|
||||
{
|
||||
@@ -1235,10 +1222,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1281,10 +1265,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1332,10 +1313,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1363,11 +1341,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -1405,11 +1379,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -1465,7 +1435,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
schema: z.union([
|
||||
z.object({ message: z.string() }).passthrough(),
|
||||
z.object({ message: z.string() }).passthrough(),
|
||||
z.object({ message: z.literal('Invalid base64 encoded data') }).passthrough(),
|
||||
]),
|
||||
},
|
||||
{
|
||||
@@ -1487,10 +1457,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1511,11 +1478,7 @@ const endpoints = makeApi([
|
||||
.object({
|
||||
data: z.array(
|
||||
z
|
||||
.object({
|
||||
key: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
})
|
||||
.object({ key: z.string(), name: z.string(), description: z.string() })
|
||||
.passthrough()
|
||||
),
|
||||
})
|
||||
@@ -1603,10 +1566,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1634,11 +1594,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -1660,10 +1616,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1809,10 +1762,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1855,10 +1805,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1901,10 +1848,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -1988,10 +1932,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2056,6 +1997,13 @@ const endpoints = makeApi([
|
||||
],
|
||||
response: z.object({ download_link: z.string() }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
@@ -2075,10 +2023,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2102,6 +2047,13 @@ const endpoints = makeApi([
|
||||
],
|
||||
response: z.object({ download_link: z.string() }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
@@ -2147,11 +2099,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -2173,10 +2121,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2246,10 +2191,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2282,11 +2224,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -2308,10 +2246,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2344,11 +2279,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -2370,10 +2301,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2401,11 +2329,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -2448,11 +2372,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -2515,10 +2435,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2634,10 +2551,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2680,10 +2594,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2767,10 +2678,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -2798,11 +2706,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -2918,11 +2822,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -2944,10 +2844,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3053,10 +2950,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3140,10 +3034,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3253,10 +3144,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3304,10 +3192,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3335,11 +3220,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -3434,10 +3315,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3480,10 +3358,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3531,10 +3406,7 @@ const endpoints = makeApi([
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3562,11 +3434,7 @@ const endpoints = makeApi([
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -3639,6 +3507,16 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
type: 'Query',
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['up', 'down', 'nearest']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_minutes',
|
||||
type: 'Query',
|
||||
schema: rounding_minutes,
|
||||
},
|
||||
{
|
||||
name: 'user_id',
|
||||
type: 'Query',
|
||||
@@ -3696,10 +3574,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3727,11 +3602,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -3753,10 +3624,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3799,10 +3667,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3845,10 +3710,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3881,11 +3743,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -3907,10 +3765,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -3980,6 +3835,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
'client',
|
||||
'billable',
|
||||
'description',
|
||||
'tag',
|
||||
])
|
||||
.optional(),
|
||||
},
|
||||
@@ -3998,6 +3854,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
'client',
|
||||
'billable',
|
||||
'description',
|
||||
'tag',
|
||||
])
|
||||
.optional(),
|
||||
},
|
||||
@@ -4036,6 +3893,16 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
type: 'Query',
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['up', 'down', 'nearest']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_minutes',
|
||||
type: 'Query',
|
||||
schema: rounding_minutes,
|
||||
},
|
||||
{
|
||||
name: 'member_ids',
|
||||
type: 'Query',
|
||||
@@ -4120,10 +3987,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -4158,6 +4022,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
'client',
|
||||
'billable',
|
||||
'description',
|
||||
'tag',
|
||||
]),
|
||||
},
|
||||
{
|
||||
@@ -4174,6 +4039,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
'client',
|
||||
'billable',
|
||||
'description',
|
||||
'tag',
|
||||
]),
|
||||
},
|
||||
{
|
||||
@@ -4221,6 +4087,16 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
type: 'Query',
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['up', 'down', 'nearest']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_minutes',
|
||||
type: 'Query',
|
||||
schema: rounding_minutes,
|
||||
},
|
||||
{
|
||||
name: 'member_ids',
|
||||
type: 'Query',
|
||||
@@ -4256,11 +4132,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -4282,10 +4154,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -4346,6 +4215,16 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
type: 'Query',
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['up', 'down', 'nearest']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'rounding_minutes',
|
||||
type: 'Query',
|
||||
schema: rounding_minutes,
|
||||
},
|
||||
{
|
||||
name: 'member_ids',
|
||||
type: 'Query',
|
||||
@@ -4376,11 +4255,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -4402,10 +4277,7 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -4487,11 +4359,7 @@ Please note that the access token is only shown in this response and cannot be r
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -4508,10 +4376,7 @@ Please note that the access token is only shown in this response and cannot be r
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
@@ -4534,11 +4399,7 @@ Please note that the access token is only shown in this response and cannot be r
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
@@ -4576,11 +4437,7 @@ Please note that the access token is only shown in this response and cannot be r
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
|
||||
2014
resources/js/packages/ui/package-lock.json
generated
2014
resources/js/packages/ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@solidtime/ui",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.15",
|
||||
"description": "Package containing the solidtime ui components",
|
||||
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
||||
"module": "./dist/solidtime-ui-lib.js",
|
||||
@@ -21,16 +21,21 @@
|
||||
"default": "./dist/solidtime-ui-lib.umd.cjs"
|
||||
}
|
||||
},
|
||||
"./style.css": "./dist/style.css"
|
||||
"./style.css": "./dist/style.css",
|
||||
"./styles.css": "./styles.css",
|
||||
"./tailwind.theme.js": "./tailwind.theme.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && vue-tsc --emitDeclarationOnly",
|
||||
"watch": "vite build --watch",
|
||||
"types": "vue-tsc ",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"styles.css",
|
||||
"tailwind.theme.js"
|
||||
],
|
||||
"keywords": [
|
||||
"solidtime",
|
||||
@@ -59,8 +64,11 @@
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@zodios/core": "^10.9.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"parse-duration": "^2.0.1",
|
||||
"reka-ui": "^2.2.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss": "^3.1.0",
|
||||
"vue": "^3.5.0",
|
||||
|
||||
25
resources/js/packages/ui/src/Buttons/Button.vue
Normal file
25
resources/js/packages/ui/src/Buttons/Button.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
import { cn } from '../utils/cn';
|
||||
import { Primitive, type PrimitiveProps } from 'reka-ui';
|
||||
import { type ButtonVariants, buttonVariants } from '.';
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants['variant'];
|
||||
size?: ButtonVariants['size'];
|
||||
class?: HTMLAttributes['class'];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
36
resources/js/packages/ui/src/Buttons/index.ts
Normal file
36
resources/js/packages/ui/src/Buttons/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export { default as Button } from './Button.vue';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border shadow-xs hover:text-text-primary bg-card-background dark:bg-transparent border-input dark:border-input hover:bg-white/5',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-white/5',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
input: 'border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent shadow-sm',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
xs: 'h-7 rounded px-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
input: 'h-[42px] px-3 py-2 text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
@@ -2,9 +2,10 @@
|
||||
import { computed, inject, type ComputedRef } from 'vue';
|
||||
import { formatDate, formatHumanReadableDuration } from '../utils/time';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
const props = defineProps<{
|
||||
date: Date;
|
||||
date: Dayjs;
|
||||
totalMinutes?: number;
|
||||
}>();
|
||||
|
||||
@@ -20,9 +21,9 @@ const dateFormat = computed(() => organization?.value?.date_format);
|
||||
<template>
|
||||
<div class="fc-day-header-custom">
|
||||
<div class="text-xs text-muted-foreground font-medium">
|
||||
{{ date.toLocaleDateString('en-US', { weekday: 'short' }) }}
|
||||
{{ date.format('ddd') }}
|
||||
</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">
|
||||
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
|
||||
</span>
|
||||
|
||||
@@ -40,18 +40,18 @@ const formattedDuration = computed(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-xs leading-tight">
|
||||
<div class="font-semibold mb-0.5">{{ title }}</div>
|
||||
<div v-if="projectName" class="font-medium text-[0.6875rem] opacity-90">
|
||||
<div class="text-2xs leading-tight px-0.5 py-1.5">
|
||||
<div class="font-semibold">{{ title }}</div>
|
||||
<div v-if="projectName" class="font-medium opacity-90">
|
||||
{{ projectName }}
|
||||
</div>
|
||||
<div v-if="taskName" class="font-medium text-[0.6875rem] opacity-90">
|
||||
<div v-if="taskName" class="font-medium">
|
||||
{{ taskName }}
|
||||
</div>
|
||||
<div v-if="clientName" class="text-[0.625rem] italic opacity-85">
|
||||
<div v-if="clientName" class="opacity-85">
|
||||
{{ clientName }}
|
||||
</div>
|
||||
<div class="text-[0.625rem] font-semibold opacity-90 mt-0.5">
|
||||
<div class="opacity-90">
|
||||
{{ formattedDuration }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,17 @@ import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';
|
||||
import { computed, ref, watch, inject, type ComputedRef } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
ref,
|
||||
watch,
|
||||
inject,
|
||||
type ComputedRef,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onActivated,
|
||||
onUnmounted,
|
||||
} from 'vue';
|
||||
import chroma from 'chroma-js';
|
||||
import { useCssVariable } from '@/utils/useCssVariable';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
||||
@@ -12,6 +22,10 @@ import { getUserTimezone, getWeekStart } from '../utils/settings';
|
||||
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
||||
import FullCalendarEventContent from './FullCalendarEventContent.vue';
|
||||
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
||||
import activityStatusPlugin, {
|
||||
type ActivityPeriod,
|
||||
renderActivityStatusBoxes,
|
||||
} from './idleStatusPlugin';
|
||||
import type {
|
||||
TimeEntry,
|
||||
Project,
|
||||
@@ -24,7 +38,10 @@ import type {
|
||||
} from '@/packages/api/src';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
type CalendarExtendedProps = { timeEntry: TimeEntry } & Record<string, unknown>;
|
||||
type CalendarExtendedProps = { timeEntry: TimeEntry; isRunning?: boolean } & Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'dates-change', payload: { start: Date; end: Date }): void;
|
||||
@@ -37,10 +54,13 @@ const props = defineProps<{
|
||||
tasks: Task[];
|
||||
clients: Client[];
|
||||
tags: Tag[];
|
||||
activityPeriods?: ActivityPeriod[];
|
||||
loading?: boolean;
|
||||
|
||||
// Permissions / feature flags
|
||||
enableEstimatedTime: boolean;
|
||||
currency: string;
|
||||
canCreateProject: boolean;
|
||||
|
||||
createTimeEntry: (
|
||||
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
|
||||
@@ -61,6 +81,10 @@ const selectedTimeEntry = ref<TimeEntry | null>(null);
|
||||
|
||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||
|
||||
// Reactive "now" for running time entry - updates every minute
|
||||
const currentTime = ref(getDayJsInstance()());
|
||||
let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Inject organization data for settings
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
@@ -102,67 +126,81 @@ const events = computed(() => {
|
||||
const themeBackground = (() => {
|
||||
return cssBackground.value?.trim();
|
||||
})();
|
||||
return props.timeEntries
|
||||
?.filter((timeEntry) => timeEntry.end !== null)
|
||||
?.map((timeEntry) => {
|
||||
const project = props.projects.find((p) => p.id === timeEntry.project_id);
|
||||
const client = props.clients.find((c) => c.id === project?.client_id);
|
||||
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
|
||||
const duration = getDayJsInstance()(timeEntry.end!).diff(
|
||||
getDayJsInstance()(timeEntry.start),
|
||||
'minutes'
|
||||
);
|
||||
return props.timeEntries?.map((timeEntry) => {
|
||||
const isRunning = timeEntry.end === null;
|
||||
const project = props.projects.find((p) => p.id === timeEntry.project_id);
|
||||
const client = props.clients.find((c) => c.id === project?.client_id);
|
||||
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
|
||||
|
||||
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 backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
|
||||
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
|
||||
const title = timeEntry.description || 'No description';
|
||||
|
||||
// For 0-duration events, display them with minimum visual duration but preserve actual duration
|
||||
const startTime = getLocalizedDayJs(timeEntry.start);
|
||||
const endTime =
|
||||
duration === 0
|
||||
? startTime.add(1, 'second') // Show as 1 second for minimal visibility
|
||||
: getLocalizedDayJs(timeEntry.end!);
|
||||
const baseColor = project?.color || '#6B7280';
|
||||
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
|
||||
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
|
||||
|
||||
return {
|
||||
id: timeEntry.id,
|
||||
start: startTime.format(),
|
||||
end: endTime.format(),
|
||||
title,
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
textColor: 'var(--foreground)',
|
||||
extendedProps: {
|
||||
timeEntry,
|
||||
project,
|
||||
client,
|
||||
task,
|
||||
duration,
|
||||
},
|
||||
};
|
||||
});
|
||||
// For 0-duration events, display them with minimum visual duration but preserve actual duration
|
||||
const startTime = getLocalizedDayJs(timeEntry.start);
|
||||
const endTime =
|
||||
duration === 0
|
||||
? startTime.add(1, 'second') // Show as 1 second for minimal visibility
|
||||
: isRunning
|
||||
? getLocalizedDayJs(currentTime.value.toISOString())
|
||||
: getLocalizedDayJs(timeEntry.end!);
|
||||
|
||||
return {
|
||||
id: timeEntry.id,
|
||||
start: startTime.format(),
|
||||
end: endTime.format(),
|
||||
title,
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
textColor: 'var(--foreground)',
|
||||
// For running entries: disable dragging and resizing
|
||||
startEditable: !isRunning,
|
||||
classNames: isRunning ? ['running-entry'] : [],
|
||||
extendedProps: {
|
||||
timeEntry,
|
||||
project,
|
||||
client,
|
||||
task,
|
||||
duration,
|
||||
isRunning,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Daily totals used in day header
|
||||
const dailyTotals = computed(() => {
|
||||
const totals: Record<string, number> = {};
|
||||
props.timeEntries
|
||||
.filter((entry) => entry.end !== null)
|
||||
.forEach((entry) => {
|
||||
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
|
||||
const duration = getDayJsInstance()(entry.end!).diff(
|
||||
props.timeEntries.forEach((entry) => {
|
||||
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
|
||||
let duration: number;
|
||||
|
||||
if (entry.end !== null) {
|
||||
// Completed entry
|
||||
duration = getDayJsInstance()(entry.end).diff(
|
||||
getDayJsInstance()(entry.start),
|
||||
'minutes'
|
||||
);
|
||||
totals[date] = (totals[date] || 0) + duration;
|
||||
});
|
||||
} else {
|
||||
// Running entry - use current time
|
||||
duration = currentTime.value.diff(getDayJsInstance()(entry.start), 'minutes');
|
||||
}
|
||||
|
||||
totals[date] = (totals[date] || 0) + duration;
|
||||
});
|
||||
return totals;
|
||||
});
|
||||
|
||||
function emitDatesChange(arg: DatesSetArg) {
|
||||
emit('dates-change', { start: arg.start, end: arg.end });
|
||||
// Render activity boxes after calendar view has been rendered
|
||||
renderActivityBoxes();
|
||||
}
|
||||
|
||||
function handleDateSelect(arg: { start: Date; end: Date }) {
|
||||
@@ -181,6 +219,10 @@ function handleDateSelect(arg: { start: Date; end: Date }) {
|
||||
|
||||
function handleEventClick(arg: EventClickArg) {
|
||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||
// Don't open edit modal for running time entries
|
||||
if (ext.isRunning) {
|
||||
return;
|
||||
}
|
||||
selectedTimeEntry.value = ext.timeEntry;
|
||||
showEditTimeEntryModal.value = true;
|
||||
}
|
||||
@@ -194,11 +236,13 @@ async function handleEventDrop(arg: EventDropArg) {
|
||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0)
|
||||
.utc()
|
||||
.format(),
|
||||
end: getDayJsInstance()(arg.event.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0)
|
||||
.utc()
|
||||
.format(),
|
||||
} as TimeEntry;
|
||||
@@ -215,20 +259,25 @@ async function handleEventResize(arg: EventChangeArg) {
|
||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0)
|
||||
.utc()
|
||||
.format(),
|
||||
end: getDayJsInstance()(arg.event.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc()
|
||||
.format(),
|
||||
// Preserve null end for running entries
|
||||
end: ext.isRunning
|
||||
? null
|
||||
: getDayJsInstance()(arg.event.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0)
|
||||
.utc()
|
||||
.format(),
|
||||
} as TimeEntry;
|
||||
await props.updateTimeEntry(updatedTimeEntry);
|
||||
emit('refresh');
|
||||
}
|
||||
|
||||
const calendarOptions = computed(() => ({
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
|
||||
initialView: 'timeGridWeek',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
@@ -241,16 +290,17 @@ const calendarOptions = computed(() => ({
|
||||
slotDuration: '00:15:00',
|
||||
slotLabelInterval: '01:00:00',
|
||||
slotLabelFormat: getSlotLabelFormat(),
|
||||
snapDuration: '00:15:00',
|
||||
snapDuration: '00:01:00',
|
||||
firstDay: getFirstDay(),
|
||||
allDaySlot: false,
|
||||
nowIndicator: true,
|
||||
eventMinHeight: 1,
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
editable: true,
|
||||
eventResizableFromStart: true,
|
||||
eventDurationEditable: true,
|
||||
timeZone: 'America/Adak',
|
||||
timeZone: getUserTimezone(),
|
||||
eventStartEditable: true,
|
||||
select: handleDateSelect,
|
||||
eventClick: handleEventClick,
|
||||
@@ -259,6 +309,7 @@ const calendarOptions = computed(() => ({
|
||||
datesSet: emitDatesChange,
|
||||
|
||||
events: events.value,
|
||||
activityPeriods: props.activityPeriods || [],
|
||||
}));
|
||||
|
||||
watch(showCreateTimeEntryModal, (value) => {
|
||||
@@ -277,6 +328,60 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
emit('refresh');
|
||||
}
|
||||
});
|
||||
|
||||
// Render activity status boxes after FullCalendar has rendered
|
||||
const renderActivityBoxes = () => {
|
||||
if (!calendarRef.value || !props.activityPeriods) return;
|
||||
|
||||
const calendarEl = calendarRef.value.$el as HTMLElement;
|
||||
if (calendarEl && props.activityPeriods.length > 0) {
|
||||
renderActivityStatusBoxes(calendarEl, props.activityPeriods);
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for activity periods changes - re-render when data changes
|
||||
watch(
|
||||
() => props.activityPeriods,
|
||||
() => {
|
||||
renderActivityBoxes();
|
||||
}
|
||||
);
|
||||
|
||||
const scrollToCurrentTime = () => {
|
||||
nextTick(() => {
|
||||
if (calendarRef.value) {
|
||||
const now = getDayJsInstance()();
|
||||
const oneHourBefore = now.subtract(1, 'hour');
|
||||
|
||||
// If subtracting 1 hour keeps us on the same day, scroll to 1 hour before
|
||||
const scrollTime = now.isSame(oneHourBefore, 'day')
|
||||
? oneHourBefore.format('HH:mm:ss')
|
||||
: now.format('HH:mm:ss');
|
||||
|
||||
calendarRef.value.getApi().scrollToTime(scrollTime);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
scrollToCurrentTime();
|
||||
// Start interval to update running time entry
|
||||
currentTimeInterval = setInterval(() => {
|
||||
currentTime.value = getDayJsInstance()();
|
||||
}, 60000); // Update every minute
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
scrollToCurrentTime();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up interval
|
||||
if (currentTimeInterval) {
|
||||
clearInterval(currentTimeInterval);
|
||||
currentTimeInterval = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -295,6 +400,8 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
:create-client="createClient"
|
||||
:create-project="createProject"
|
||||
:create-tag="createTag"
|
||||
:currency="currency"
|
||||
:can-create-project="canCreateProject"
|
||||
:tags="tags as any"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
@@ -314,7 +421,9 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
:tags="tags as any"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:clients="clients" />
|
||||
:clients="clients"
|
||||
:currency="currency"
|
||||
:can-create-project="canCreateProject" />
|
||||
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
|
||||
<template #eventContent="arg">
|
||||
<FullCalendarEventContent
|
||||
@@ -332,9 +441,16 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
</template>
|
||||
<template #dayHeaderContent="arg">
|
||||
<FullCalendarDayHeader
|
||||
:date="arg.date"
|
||||
:date="
|
||||
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
|
||||
"
|
||||
:total-minutes="
|
||||
dailyTotals[getDayJsInstance()(arg.date).format('YYYY-MM-DD')] || 0
|
||||
dailyTotals[
|
||||
getDayJsInstance()(arg.date)
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.format('YYYY-MM-DD')
|
||||
] || 0
|
||||
" />
|
||||
</template>
|
||||
</FullCalendar>
|
||||
@@ -360,11 +476,11 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-slot-label) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-toolbar) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
background-color: var(--background);
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -445,8 +561,8 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event) {
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem 0.25rem;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
padding: 0;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--theme-shadow-card);
|
||||
@@ -508,7 +624,7 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-highlight) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-select-mirror) {
|
||||
@@ -526,7 +642,7 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-body) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-col) {
|
||||
@@ -593,4 +709,57 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
.fullcalendar :deep(.fc-event-main) {
|
||||
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>
|
||||
|
||||
393
resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts
Normal file
393
resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts
Normal 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;
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { RangeCalendar } from '@/Components/ui/range-calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
|
||||
import Button from '../Buttons/Button.vue';
|
||||
import { RangeCalendar } from '../range-calendar';
|
||||
import { CalendarDate } from '@internationalized/date';
|
||||
import { CalendarIcon } from 'lucide-vue-next';
|
||||
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
|
||||
import { watch } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
@@ -27,11 +27,19 @@ function updateTime(event: Event) {
|
||||
if (newValue.split(':').length === 2) {
|
||||
const [hours, minutes] = newValue.split(':');
|
||||
if (!isNaN(parseInt(hours)) && !isNaN(parseInt(minutes))) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
const currentTime = getLocalizedDayJs(model.value);
|
||||
const newHours = Math.min(parseInt(hours), 23);
|
||||
const newMinutes = Math.min(parseInt(minutes), 59);
|
||||
|
||||
// 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
|
||||
@@ -42,6 +50,7 @@ function updateTime(event: Event) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
.set('seconds', 0)
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
} else if (newValue.length === 3) {
|
||||
@@ -50,6 +59,7 @@ function updateTime(event: Event) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
.set('seconds', 0)
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
} else if (newValue.length === 2) {
|
||||
@@ -57,6 +67,7 @@ function updateTime(event: Event) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(newValue), 23))
|
||||
.set('minutes', 0)
|
||||
.set('seconds', 0)
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
} else if (newValue.length === 1) {
|
||||
@@ -64,6 +75,7 @@ function updateTime(event: Event) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(newValue), 23))
|
||||
.set('minutes', 0)
|
||||
.set('seconds', 0)
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
}
|
||||
@@ -93,6 +105,7 @@ const inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:m
|
||||
data-testid="time_picker_input"
|
||||
type="text"
|
||||
@blur="updateTime"
|
||||
@keydown.enter.prevent="updateTime"
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
@mouseup="($event.target as HTMLInputElement).select()"
|
||||
@click="($event.target as HTMLInputElement).select()"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, nextTick, ref, watch } from 'vue';
|
||||
import { useFocusWithin } from '@vueuse/core';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import dayjs from 'dayjs';
|
||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||
import Button from '../Buttons/Button.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
start: string;
|
||||
@@ -17,31 +17,42 @@ const emit = defineEmits(['changed', 'close']);
|
||||
|
||||
const tempStart = ref(props.start ? getLocalizedDayJs(props.start).format() : dayjs().format());
|
||||
const tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null);
|
||||
const showEndTimePicker = ref(false);
|
||||
|
||||
watch(props, () => {
|
||||
tempStart.value = getLocalizedDayJs(props.start).format();
|
||||
tempEnd.value = props.end ? getLocalizedDayJs(props.end).format() : null;
|
||||
showEndTimePicker.value = false;
|
||||
});
|
||||
|
||||
function updateTimeEntry() {
|
||||
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
|
||||
const tempEndUtc = tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null;
|
||||
|
||||
if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
|
||||
emit(
|
||||
'changed',
|
||||
getDayJsInstance()(tempStart.value).utc().format(),
|
||||
getDayJsInstance()(tempEnd.value).utc().format()
|
||||
tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const dropdownContent = ref();
|
||||
const { focused } = useFocusWithin(dropdownContent);
|
||||
function setEndTime() {
|
||||
showEndTimePicker.value = true;
|
||||
tempEnd.value = getDayJsInstance()().format();
|
||||
}
|
||||
|
||||
watch(focused, (newValue, oldValue) => {
|
||||
if (oldValue === true && newValue === false) {
|
||||
function confirmEndTime() {
|
||||
// wait for the v-model for the end time to update
|
||||
nextTick(() => {
|
||||
updateTimeEntry();
|
||||
}
|
||||
});
|
||||
showEndTimePicker.value = false;
|
||||
emit('close');
|
||||
});
|
||||
}
|
||||
|
||||
const dropdownContent = ref();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -67,7 +78,7 @@ watch(focused, (newValue, oldValue) => {
|
||||
</div>
|
||||
<div class="px-2">
|
||||
<div class="font-semibold text-text-primary text-sm pb-2">End</div>
|
||||
<div v-if="tempEnd !== null" class="space-y-2">
|
||||
<div v-if="end !== null && tempEnd !== null" class="space-y-2">
|
||||
<TimePickerSimple
|
||||
v-model="tempEnd"
|
||||
data-testid="time_entry_range_end"
|
||||
@@ -77,6 +88,22 @@ watch(focused, (newValue, oldValue) => {
|
||||
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
|
||||
@changed="updateTimeEntry"></DatePicker>
|
||||
</div>
|
||||
<div v-else-if="end === null && !showEndTimePicker">
|
||||
<Button variant="outline" size="sm" @click="setEndTime"> Set End Time </Button>
|
||||
</div>
|
||||
<div v-else-if="showEndTimePicker && tempEnd !== null" class="space-y-2">
|
||||
<TimePickerSimple
|
||||
v-model="tempEnd"
|
||||
data-testid="time_entry_range_end"
|
||||
@keydown.enter.prevent.stop="confirmEndTime"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="tempEnd"
|
||||
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
|
||||
@keydown.enter.prevent="confirmEndTime"></DatePicker>
|
||||
<Button variant="outline" size="sm" class="w-full" @click="confirmEndTime">
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="text-text-secondary">-- : --</div>
|
||||
<div tabindex="0" @focusin="emit('close')"></div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ const props = defineProps<{
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
onStartStopClick: (timeEntry: TimeEntry) => void;
|
||||
duplicateTimeEntry: (timeEntry: TimeEntry) => void;
|
||||
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
|
||||
updateTimeEntry: (timeEntry: TimeEntry) => void;
|
||||
deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
|
||||
@@ -92,7 +93,7 @@ function onSelectChange(checked: boolean) {
|
||||
class="border-b border-default-background-separator bg-row-background min-w-0 transition"
|
||||
data-testid="time_entry_row">
|
||||
<MainContainer class="min-w-0">
|
||||
<div class="sm:flex py-2 items-center min-w-0 justify-between group">
|
||||
<div class="@xl:flex py-2 items-center min-w-0 justify-between group">
|
||||
<div class="flex space-x-3 items-center min-w-0">
|
||||
<Checkbox
|
||||
:checked="
|
||||
@@ -124,7 +125,7 @@ function onSelectChange(checked: boolean) {
|
||||
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center font-medium lg:space-x-2">
|
||||
<div class="flex items-center font-medium space-x-1 @lg:space-x-2">
|
||||
<TimeEntryRowTagDropdown
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
@@ -141,8 +142,8 @@ function onSelectChange(checked: boolean) {
|
||||
twMerge(
|
||||
'text-text-secondary px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary',
|
||||
organization?.time_format === '12-hours'
|
||||
? 'w-[170px]'
|
||||
: 'w-[120px]'
|
||||
? 'w-[160px]'
|
||||
: 'w-[100px]'
|
||||
)
|
||||
"
|
||||
@click="expanded = !expanded">
|
||||
@@ -156,7 +157,7 @@ function onSelectChange(checked: boolean) {
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="text-text-primary min-w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
|
||||
class="text-text-primary !mr-2 min-w-[80px] px-1.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
|
||||
@click="expanded = !expanded">
|
||||
{{
|
||||
formatHumanReadableDuration(
|
||||
@@ -173,6 +174,7 @@ function onSelectChange(checked: boolean) {
|
||||
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
:show-edit="false"
|
||||
:show-duplicate="false"
|
||||
@delete="
|
||||
deleteTimeEntries(timeEntry?.timeEntries ?? [])
|
||||
"></TimeEntryMoreOptionsDropdown>
|
||||
@@ -202,6 +204,7 @@ function onSelectChange(checked: boolean) {
|
||||
:update-time-entry="(timeEntry: TimeEntry) => updateTimeEntry(timeEntry)"
|
||||
:on-start-stop-click="() => onStartStopClick(subEntry)"
|
||||
:delete-time-entry="() => deleteTimeEntries([subEntry])"
|
||||
:duplicate-time-entry="() => duplicateTimeEntry(subEntry)"
|
||||
:currency="currency"
|
||||
:create-tag
|
||||
:time-entry="subEntry"
|
||||
|
||||
@@ -15,8 +15,6 @@ import type {
|
||||
Client,
|
||||
CreateTimeEntryBody,
|
||||
} from '@/packages/api/src';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { Badge } from '@/packages/ui/src';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
@@ -43,6 +41,8 @@ const props = defineProps<{
|
||||
clients: Client[];
|
||||
start?: string;
|
||||
end?: string;
|
||||
currency: string;
|
||||
canCreateProject: boolean;
|
||||
}>();
|
||||
|
||||
const description = ref<HTMLInputElement | null>(null);
|
||||
@@ -61,8 +61,8 @@ const timeEntryDefaultValues = {
|
||||
task_id: null,
|
||||
tags: [],
|
||||
billable: false,
|
||||
start: getDayJsInstance().utc().subtract(1, 'h').format(),
|
||||
end: getDayJsInstance().utc().format(),
|
||||
start: getDayJsInstance().utc().subtract(1, 'h').second(0).format(),
|
||||
end: getDayJsInstance().utc().second(0).format(),
|
||||
};
|
||||
|
||||
const timeEntry = ref({
|
||||
@@ -167,8 +167,8 @@ type BillableOption = {
|
||||
:clients
|
||||
:create-project
|
||||
:create-client
|
||||
:can-create-project="canCreateProjects()"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:can-create-project
|
||||
:currency
|
||||
size="xlarge"
|
||||
class="bg-input-background"
|
||||
:projects="projects"
|
||||
|
||||
@@ -15,8 +15,6 @@ import type {
|
||||
Client,
|
||||
TimeEntry,
|
||||
} from '@/packages/api/src';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { Badge } from '@/packages/ui/src';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
@@ -44,6 +42,8 @@ const props = defineProps<{
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
clients: Client[];
|
||||
currency: string;
|
||||
canCreateProject: boolean;
|
||||
}>();
|
||||
|
||||
const description = ref<HTMLInputElement | null>(null);
|
||||
@@ -68,19 +68,6 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => editableTimeEntry.value?.project_id,
|
||||
(value) => {
|
||||
if (value && editableTimeEntry.value) {
|
||||
// check if project is billable by default and set billable accordingly
|
||||
const project = props.projects.find((p) => p.id === value);
|
||||
if (project) {
|
||||
editableTimeEntry.value.billable = project.is_billable;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const localStart = computed({
|
||||
get: () =>
|
||||
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '',
|
||||
@@ -176,8 +163,8 @@ type BillableOption = {
|
||||
:clients
|
||||
:create-project
|
||||
:create-client
|
||||
:can-create-project="canCreateProjects()"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:can-create-project="canCreateProject"
|
||||
:currency="currency"
|
||||
size="xlarge"
|
||||
class="bg-input-background"
|
||||
:projects="projects"
|
||||
|
||||
@@ -94,6 +94,7 @@ const groupedTimeEntries = computed(() => {
|
||||
|
||||
groupedEntriesByDayAndType[dailyEntriesKey] = newDailyEntries;
|
||||
}
|
||||
|
||||
return groupedEntriesByDayAndType;
|
||||
});
|
||||
|
||||
@@ -108,6 +109,7 @@ function startTimeEntryFromExisting(entry: TimeEntry) {
|
||||
tags: [...entry.tags],
|
||||
});
|
||||
}
|
||||
|
||||
function sumDuration(timeEntries: TimeEntry[]) {
|
||||
return timeEntries.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
|
||||
}
|
||||
@@ -136,77 +138,81 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="(value, key) in groupedTimeEntries" :key="key">
|
||||
<TimeEntryRowHeading
|
||||
:date="key"
|
||||
:duration="sumDuration(value)"
|
||||
:checked="
|
||||
value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))
|
||||
"
|
||||
@select-all="selectAllTimeEntries(value)"
|
||||
@unselect-all="unselectAllTimeEntries(value)"></TimeEntryRowHeading>
|
||||
<template v-for="entry in value" :key="entry.id">
|
||||
<TimeEntryAggregateRow
|
||||
v-if="'timeEntries' in entry && entry.timeEntries.length > 1"
|
||||
:create-project
|
||||
:can-create-project
|
||||
:enable-estimated-time
|
||||
:selected-time-entries="selectedTimeEntries"
|
||||
:create-client
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:tags="tags"
|
||||
:clients
|
||||
:on-start-stop-click="startTimeEntryFromExisting"
|
||||
:update-time-entries
|
||||
:update-time-entry
|
||||
:delete-time-entries
|
||||
:create-tag
|
||||
:currency="currency"
|
||||
:time-entry="entry"
|
||||
@selected="
|
||||
(timeEntries: TimeEntry[]) => {
|
||||
selectedTimeEntries = [...selectedTimeEntries, ...timeEntries];
|
||||
}
|
||||
<div class="@container">
|
||||
<div v-for="(value, key) in groupedTimeEntries" :key="key">
|
||||
<TimeEntryRowHeading
|
||||
:date="String(key)"
|
||||
:duration="sumDuration(value)"
|
||||
:checked="
|
||||
value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))
|
||||
"
|
||||
@unselected="
|
||||
(timeEntriesToUnselect: TimeEntry[]) => {
|
||||
@select-all="selectAllTimeEntries(value)"
|
||||
@unselect-all="unselectAllTimeEntries(value)"></TimeEntryRowHeading>
|
||||
<template v-for="entry in value" :key="entry.id">
|
||||
<TimeEntryAggregateRow
|
||||
v-if="'timeEntries' in entry && entry.timeEntries.length > 1"
|
||||
:create-project
|
||||
:can-create-project
|
||||
:enable-estimated-time
|
||||
:selected-time-entries="selectedTimeEntries"
|
||||
:create-client
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:tags="tags"
|
||||
:clients
|
||||
:on-start-stop-click="startTimeEntryFromExisting"
|
||||
:duplicate-time-entry="createTimeEntry"
|
||||
:update-time-entries
|
||||
:update-time-entry
|
||||
:delete-time-entries
|
||||
:create-tag
|
||||
:currency="currency"
|
||||
:time-entry="entry"
|
||||
@selected="
|
||||
(timeEntries: TimeEntry[]) => {
|
||||
selectedTimeEntries = [...selectedTimeEntries, ...timeEntries];
|
||||
}
|
||||
"
|
||||
@unselected="
|
||||
(timeEntriesToUnselect: TimeEntry[]) => {
|
||||
selectedTimeEntries = selectedTimeEntries.filter(
|
||||
(item: TimeEntry) =>
|
||||
!timeEntriesToUnselect.find(
|
||||
(filterEntry: TimeEntry) => filterEntry.id === item.id
|
||||
)
|
||||
);
|
||||
}
|
||||
"></TimeEntryAggregateRow>
|
||||
<TimeEntryRow
|
||||
v-else
|
||||
:create-client
|
||||
:enable-estimated-time
|
||||
:can-create-project
|
||||
:create-project
|
||||
:projects="projects"
|
||||
:selected="
|
||||
!!selectedTimeEntries.find(
|
||||
(filterEntry: TimeEntry) => filterEntry.id === entry.id
|
||||
)
|
||||
"
|
||||
:tasks="tasks"
|
||||
:tags="tags"
|
||||
:clients
|
||||
:create-tag
|
||||
:update-time-entry
|
||||
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
||||
:delete-time-entry="() => deleteTimeEntries([entry])"
|
||||
:duplicate-time-entry="() => createTimeEntry(entry)"
|
||||
:currency="currency"
|
||||
:time-entry="entry.timeEntries[0]"
|
||||
@selected="selectedTimeEntries.push(entry)"
|
||||
@unselected="
|
||||
selectedTimeEntries = selectedTimeEntries.filter(
|
||||
(item: TimeEntry) =>
|
||||
!timeEntriesToUnselect.find(
|
||||
(filterEntry: TimeEntry) => filterEntry.id === item.id
|
||||
)
|
||||
);
|
||||
}
|
||||
"></TimeEntryAggregateRow>
|
||||
<TimeEntryRow
|
||||
v-else
|
||||
:create-client
|
||||
:enable-estimated-time
|
||||
:can-create-project
|
||||
:create-project
|
||||
:projects="projects"
|
||||
:selected="
|
||||
!!selectedTimeEntries.find(
|
||||
(filterEntry: TimeEntry) => filterEntry.id === entry.id
|
||||
)
|
||||
"
|
||||
:tasks="tasks"
|
||||
:tags="tags"
|
||||
:clients
|
||||
:create-tag
|
||||
:update-time-entry
|
||||
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
||||
:delete-time-entry="() => deleteTimeEntries([entry])"
|
||||
:currency="currency"
|
||||
:time-entry="entry.timeEntries[0]"
|
||||
@selected="selectedTimeEntries.push(entry)"
|
||||
@unselected="
|
||||
selectedTimeEntries = selectedTimeEntries.filter(
|
||||
(item: TimeEntry) => item.id !== entry.id
|
||||
)
|
||||
"></TimeEntryRow>
|
||||
</template>
|
||||
(item: TimeEntry) => item.id !== entry.id
|
||||
)
|
||||
"></TimeEntryRow>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, PencilIcon } from '@heroicons/vue/20/solid';
|
||||
import { TrashIcon, PencilIcon, DocumentDuplicateIcon } from '@heroicons/vue/20/solid';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showEdit?: boolean;
|
||||
showDuplicate?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showDuplicate: true,
|
||||
showEdit: true,
|
||||
}
|
||||
);
|
||||
@@ -19,6 +21,7 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
edit: [];
|
||||
delete: [];
|
||||
duplicate: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -51,6 +54,14 @@ const emit = defineEmits<{
|
||||
<PencilIcon class="w-5" />
|
||||
<span>Edit</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="props.showDuplicate"
|
||||
data-testid="time_entry_duplicate"
|
||||
class="flex items-center space-x-3 cursor-pointer"
|
||||
@click="emit('duplicate')">
|
||||
<DocumentDuplicateIcon class="w-5" />
|
||||
<span>Duplicate</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="time_entry_delete"
|
||||
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
|
||||
|
||||
@@ -35,11 +35,11 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
||||
data-testid="time_entry_range_selector"
|
||||
:class="
|
||||
twMerge(
|
||||
'text-text-secondary px-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
|
||||
'text-text-secondary px-1 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
|
||||
showDate
|
||||
? 'text-xs py-1.5 font-semibold'
|
||||
: 'text-sm py-1.5 font-medium',
|
||||
organization?.time_format === '12-hours' ? 'w-[170px]' : 'w-[120px]',
|
||||
organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[100px]',
|
||||
open && 'border-card-border bg-card-background'
|
||||
)
|
||||
">
|
||||
|
||||
@@ -36,6 +36,7 @@ const props = defineProps<{
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
onStartStopClick: () => void;
|
||||
deleteTimeEntry: () => void;
|
||||
duplicateTimeEntry?: () => void;
|
||||
updateTimeEntry: (timeEntry: TimeEntry) => void;
|
||||
currency: string;
|
||||
showMember?: boolean;
|
||||
@@ -111,7 +112,7 @@ async function handleDeleteTimeEntry() {
|
||||
class="border-b border-default-background-separator transition min-w-0 bg-row-background"
|
||||
data-testid="time_entry_row">
|
||||
<MainContainer class="min-w-0">
|
||||
<div class="sm:flex py-2 min-w-0 items-center justify-between group">
|
||||
<div class="@xl:flex py-2 min-w-0 items-center justify-between group">
|
||||
<div class="flex items-center min-w-0">
|
||||
<Checkbox :checked="selected" @update:checked="onSelectChange" />
|
||||
<div v-if="indent === true" class="w-10 h-7"></div>
|
||||
@@ -133,7 +134,7 @@ async function handleDeleteTimeEntry() {
|
||||
:task="timeEntry.task_id"
|
||||
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<div class="flex items-center font-medium space-x-1 lg:space-x-2">
|
||||
<div class="flex items-center font-medium space-x-1 @lg:space-x-2">
|
||||
<div v-if="showMember && members" class="text-sm px-2">
|
||||
{{ memberName }}
|
||||
</div>
|
||||
@@ -166,6 +167,7 @@ async function handleDeleteTimeEntry() {
|
||||
@changed="onStartStopClick"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
@edit="handleEdit"
|
||||
@duplicate="duplicateTimeEntry"
|
||||
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +186,9 @@ async function handleDeleteTimeEntry() {
|
||||
:tags="tags"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:clients="clients" />
|
||||
:clients="clients"
|
||||
:currency="currency"
|
||||
:can-create-project="canCreateProject" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -29,7 +29,7 @@ const open = ref(false);
|
||||
function updateTimerAndStartLiveTimerUpdate() {
|
||||
const defaultUnit =
|
||||
organizationSettings?.value?.intervalFormat === 'decimal' ? 'hours' : 'minutes';
|
||||
const { seconds } = parseTimeInput(temporaryCustomTimerEntry.value, defaultUnit);
|
||||
const seconds = parseTimeInput(temporaryCustomTimerEntry.value, defaultUnit);
|
||||
if (seconds && seconds > 0) {
|
||||
let newEndDate = props.end;
|
||||
let newStartDate = props.start;
|
||||
@@ -77,7 +77,7 @@ function selectInput(event: Event) {
|
||||
v-model="currentTime"
|
||||
data-testid="time_entry_duration_input"
|
||||
name="Duration"
|
||||
class="text-text-primary w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="text-text-primary w-[80px] !mr-2 px-1.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@focus="selectInput"
|
||||
@keydown.tab="open = false"
|
||||
@blur="updateTimerAndStartLiveTimerUpdate"
|
||||
|
||||
@@ -32,13 +32,13 @@ function selectUnselectAll(value: boolean) {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-row-heading-background border-t border-b border-row-heading-border py-1 text-xs sm:text-sm">
|
||||
class="bg-row-heading-background border-t border-b border-row-heading-border py-1 text-xs @sm:text-sm">
|
||||
<MainContainer>
|
||||
<div class="flex group justify-between items-center">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-5">
|
||||
<svg
|
||||
class="w-3 sm:w-4 text-icon-default group-hover:hidden block"
|
||||
class="w-3 @sm:w-4 text-icon-default group-hover:hidden block"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none">
|
||||
@@ -61,7 +61,7 @@ function selectUnselectAll(value: boolean) {
|
||||
{{ formatDate(date, organization?.date_format) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-text-secondary pr-[90px] lg:pr-[92px]">
|
||||
<div class="text-text-secondary pr-[87px] @lg:pr-[92px]">
|
||||
<span class="font-medium">
|
||||
{{
|
||||
formatHumanReadableDuration(
|
||||
|
||||
@@ -15,8 +15,6 @@ import type {
|
||||
} from '@/packages/api/src';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue';
|
||||
import TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue';
|
||||
@@ -34,6 +32,7 @@ const props = defineProps<{
|
||||
tasks: Task[];
|
||||
tags: Tag[];
|
||||
clients: Client[];
|
||||
timeEntries: TimeEntry[];
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
@@ -49,6 +48,7 @@ const emit = defineEmits<{
|
||||
updateTimeEntry: [];
|
||||
startLiveTimer: [];
|
||||
stopLiveTimer: [];
|
||||
createTimeEntry: [];
|
||||
}>();
|
||||
|
||||
function updateProject() {
|
||||
@@ -130,10 +130,9 @@ function updateTimeEntryDescription() {
|
||||
}
|
||||
}
|
||||
|
||||
const { timeEntries } = storeToRefs(useTimeEntriesStore());
|
||||
const filteredRecentlyTrackedTimeEntries = computed(() => {
|
||||
// do not include running time entries
|
||||
const finishedTimeEntries = timeEntries.value.filter((item) => item.end !== null);
|
||||
const finishedTimeEntries = props.timeEntries.filter((item) => item.end !== null);
|
||||
|
||||
// filter out duplicates based on description, task, project, tags and billable
|
||||
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
|
||||
@@ -280,6 +279,7 @@ useSelectEvents(
|
||||
@stop-live-timer="emit('stopLiveTimer')"
|
||||
@update-timer="emit('updateTimeEntry')"
|
||||
@start-timer="emit('startTimer')"
|
||||
@create-time-entry="emit('createTimeEntry')"
|
||||
@keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, XMarkIcon } from '@heroicons/vue/20/solid';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/Components/ui/dropdown-menu';
|
||||
|
||||
const props = defineProps<{
|
||||
hasActiveTimer: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
manualEntry: [];
|
||||
discard: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring hover:bg-card-background hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
|
||||
aria-label="Time entry actions">
|
||||
<svg
|
||||
class="h-8 w-8 p-1 rounded-full"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
|
||||
</svg>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="min-w-[150px]" align="end">
|
||||
<DropdownMenuItem
|
||||
class="flex items-center space-x-3 cursor-pointer"
|
||||
@click="emit('manualEntry')">
|
||||
<PlusIcon class="w-5" />
|
||||
<span>Manual time entry</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="props.hasActiveTimer"
|
||||
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
|
||||
@click="emit('discard')">
|
||||
<XMarkIcon class="w-5" />
|
||||
<span>Discard</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -16,6 +16,7 @@ const emit = defineEmits<{
|
||||
stopLiveTimer: [];
|
||||
updateTimer: [];
|
||||
startTimer: [];
|
||||
createTimeEntry: [];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
@@ -55,7 +56,7 @@ const currentTime = computed({
|
||||
});
|
||||
|
||||
function updateTimerAndStartLiveTimerUpdate() {
|
||||
const { seconds } = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes');
|
||||
const seconds = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes');
|
||||
|
||||
if (seconds && seconds > 0) {
|
||||
const newStartDate = dayjs().subtract(seconds, 's');
|
||||
@@ -73,12 +74,16 @@ function updateTimerAndStartLiveTimerUpdate() {
|
||||
|
||||
const temporaryCustomTimerEntry = ref<string>('');
|
||||
|
||||
async function updateTimeRange(newStart: string) {
|
||||
async function updateTimeRange(newStart: string, newEnd: string | null) {
|
||||
// prohibit updates in the future
|
||||
if (getDayJsInstance()(newStart).isBefore(getDayJsInstance()())) {
|
||||
currentTimeEntry.value.start = newStart;
|
||||
currentTimeEntry.value.end = newEnd;
|
||||
if (currentTimeEntry.value.id) {
|
||||
emit('updateTimer');
|
||||
} else if (newEnd !== null) {
|
||||
// If there's no ID but we have both start and end, create a new time entry
|
||||
emit('createTimeEntry');
|
||||
} else {
|
||||
emit('startTimer');
|
||||
}
|
||||
@@ -91,11 +96,21 @@ const startTime = computed(() => {
|
||||
}
|
||||
return dayjs().utc().format();
|
||||
});
|
||||
|
||||
const endTime = computed(() => {
|
||||
if (currentTimeEntry.value.end && currentTimeEntry.value.end !== '') {
|
||||
return currentTimeEntry.value.end;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const inputField = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const timeRangeSelector = ref<HTMLElement | null>(null);
|
||||
|
||||
function openModalOnTab(e: FocusEvent) {
|
||||
pauseLiveTimerUpdate(e);
|
||||
|
||||
// check if the source is inside the dropdown
|
||||
const source = e.relatedTarget as HTMLElement;
|
||||
if (source && window.document.body.querySelector<HTMLElement>('#app')?.contains(source)) {
|
||||
@@ -103,6 +118,12 @@ function openModalOnTab(e: FocusEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function openModalOnClick(e: MouseEvent) {
|
||||
pauseLiveTimerUpdate(e);
|
||||
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
function focusNextElement(e: KeyboardEvent) {
|
||||
if (open.value) {
|
||||
e.preventDefault();
|
||||
@@ -135,8 +156,8 @@ function closeAndFocusInput() {
|
||||
data-testid="time_entry_time"
|
||||
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-muted focus:ring-0 transition"
|
||||
type="text"
|
||||
@focus="pauseLiveTimerUpdate"
|
||||
@focusin="openModalOnTab"
|
||||
@click="openModalOnClick"
|
||||
@keydown.exact.tab="focusNextElement"
|
||||
@keydown.exact.shift.tab="open = false"
|
||||
@blur="updateTimerAndStartLiveTimerUpdate"
|
||||
@@ -146,7 +167,7 @@ function closeAndFocusInput() {
|
||||
<div ref="timeRangeSelector">
|
||||
<TimeRangeSelector
|
||||
:start="startTime"
|
||||
:end="null"
|
||||
:end="endTime"
|
||||
@changed="updateTimeRange"
|
||||
@close="closeAndFocusInput">
|
||||
</TimeRangeSelector>
|
||||
|
||||
109
resources/js/packages/ui/src/TimezoneMismatchModal.vue
Normal file
109
resources/js/packages/ui/src/TimezoneMismatchModal.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import SecondaryButton from './Buttons/SecondaryButton.vue';
|
||||
import DialogModal from './DialogModal.vue';
|
||||
import PrimaryButton from './Buttons/PrimaryButton.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { getUserTimezone } from './utils/settings';
|
||||
import { getDayJsInstance } from './utils/time';
|
||||
import { useSessionStorage } from '@vueuse/core';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [timezone: string];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
defineProps<{
|
||||
saving?: boolean;
|
||||
}>();
|
||||
|
||||
const timezone = ref('');
|
||||
const userTimezone = ref('');
|
||||
const shouldShow = ref(false);
|
||||
|
||||
const hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false);
|
||||
|
||||
/**
|
||||
* Check if timezone mismatch exists and should be shown
|
||||
*/
|
||||
function checkTimezoneMismatch(): boolean {
|
||||
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
userTimezone.value = getUserTimezone();
|
||||
|
||||
const now = getDayJsInstance()();
|
||||
|
||||
const hasMismatch =
|
||||
now.tz(timezone.value).format() !== now.tz(userTimezone.value).format() &&
|
||||
!hideTimezoneMismatchModal.value;
|
||||
|
||||
shouldShow.value = hasMismatch;
|
||||
return hasMismatch;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkTimezoneMismatch();
|
||||
if (shouldShow.value) {
|
||||
show.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
function submit() {
|
||||
emit('update', timezone.value);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
show.value = false;
|
||||
hideTimezoneMismatchModal.value = true;
|
||||
emit('cancel');
|
||||
}
|
||||
|
||||
// Expose methods for parent component
|
||||
defineExpose({
|
||||
checkTimezoneMismatch,
|
||||
currentTimezone: timezone,
|
||||
userTimezone,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show && shouldShow" @close="cancel">
|
||||
<template #title>
|
||||
<div class="flex justify-center">
|
||||
<span> Timezone mismatch detected </span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="col-span-6 sm:col-span-4 flex-1 space-y-2">
|
||||
<p>
|
||||
The timezone of your device does not match the timezone in your user
|
||||
settings. <br />
|
||||
<strong
|
||||
>We highly recommend that you update your timezone settings to your
|
||||
current timezone.</strong
|
||||
>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Want to change your timezone setting from
|
||||
<strong>{{ userTimezone }}</strong> to <strong>{{ timezone }}</strong
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="cancel"> Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving"
|
||||
@click="submit()">
|
||||
Update timezone
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '../utils/cn';
|
||||
import { AccordionContent, type AccordionContentProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '../utils/cn';
|
||||
import { AccordionItem, type AccordionItemProps, useForwardProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '../utils/cn';
|
||||
import { ChevronDown } from 'lucide-vue-next';
|
||||
import { AccordionHeader, AccordionTrigger, type AccordionTriggerProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
@@ -10,8 +10,12 @@ import * as color from './utils/color';
|
||||
import * as random from './utils/random';
|
||||
import * as time from './utils/time';
|
||||
|
||||
export { cn } from './utils/cn';
|
||||
export { buttonVariants, type ButtonVariants } from './Buttons/index';
|
||||
|
||||
import PrimaryButton from './Buttons/PrimaryButton.vue';
|
||||
import SecondaryButton from './Buttons/SecondaryButton.vue';
|
||||
import Button from './Buttons/Button.vue';
|
||||
import TimeTrackerStartStop from './TimeTrackerStartStop.vue';
|
||||
import ProjectBadge from './Project/ProjectBadge.vue';
|
||||
import LoadingSpinner from './LoadingSpinner.vue';
|
||||
@@ -20,6 +24,7 @@ import TextInput from './Input/TextInput.vue';
|
||||
import InputLabel from './Input/InputLabel.vue';
|
||||
import TimeTrackerRunningInDifferentOrganizationOverlay from './TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
|
||||
import TimeTrackerControls from './TimeTracker/TimeTrackerControls.vue';
|
||||
import TimeTrackerMoreOptionsDropdown from './TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
|
||||
import CardTitle from './CardTitle.vue';
|
||||
import SelectDropdown from './Input/SelectDropdown.vue';
|
||||
import Badge from './Badge.vue';
|
||||
@@ -32,12 +37,20 @@ import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
|
||||
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
||||
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
|
||||
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
||||
import DateRangePicker from './Input/DateRangePicker.vue';
|
||||
import TimezoneMismatchModal from './TimezoneMismatchModal.vue';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './accordion/index';
|
||||
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './popover/index';
|
||||
import { RangeCalendar } from './range-calendar/index';
|
||||
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
|
||||
|
||||
export {
|
||||
money,
|
||||
color,
|
||||
random,
|
||||
time,
|
||||
Button,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
TimeTrackerStartStop,
|
||||
@@ -48,6 +61,7 @@ export {
|
||||
InputLabel,
|
||||
TimeTrackerRunningInDifferentOrganizationOverlay,
|
||||
TimeTrackerControls,
|
||||
TimeTrackerMoreOptionsDropdown,
|
||||
CardTitle,
|
||||
SelectDropdown,
|
||||
Badge,
|
||||
@@ -60,4 +74,19 @@ export {
|
||||
FullCalendarEventContent,
|
||||
FullCalendarDayHeader,
|
||||
TimeEntryCalendar,
|
||||
DateRangePicker,
|
||||
TimezoneMismatchModal,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
PopoverAnchor,
|
||||
RangeCalendar,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
||||
import {
|
||||
RangeCalendarCellTrigger,
|
||||
type RangeCalendarCellTriggerProps,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user