Compare commits

..

1 Commits

Author SHA1 Message Date
Gregor Vostrak
6f364d3fd0 add discard option for running timer 2025-10-16 14:33:01 +02:00
106 changed files with 1912 additions and 3938 deletions

View File

@@ -22,27 +22,13 @@ class Kernel extends ConsoleKernel
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
->everyTenMinutes();
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:check-for-update')
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
->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:telemetry')
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
->twiceDaily();
$schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))

View File

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

View File

@@ -46,9 +46,6 @@ 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();
}

View File

@@ -11,7 +11,6 @@ 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;
@@ -28,26 +27,6 @@ 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
*
@@ -96,15 +75,7 @@ class TaskController extends Controller
*/
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
{
/** @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');
}
$this->checkPermission($organization, 'tasks:create');
$task = new Task;
$task->name = $request->input('name');
$task->project_id = $request->input('project_id');
@@ -126,17 +97,7 @@ class TaskController extends Controller
*/
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
{
// 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');
}
$this->checkPermission($organization, 'tasks:update', $task);
$task->name = $request->input('name');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$task->estimated_time = $request->getEstimatedTime();
@@ -158,16 +119,7 @@ class TaskController extends Controller
*/
public function destroy(Organization $organization, Task $task): JsonResponse
{
// 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');
}
$this->checkPermission($organization, 'tasks:delete', $task);
if ($task->timeEntries()->exists()) {
throw new EntityStillInUseApiException('task', 'time_entry');

View File

@@ -42,7 +42,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);

View File

@@ -39,9 +39,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
'employees_can_see_billable_rates' => [
'boolean',
],
'employees_can_manage_tasks' => [
'boolean',
],
'prevent_overlapping_time_entries' => [
'boolean',
],
@@ -105,11 +102,6 @@ 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;

View File

@@ -10,10 +10,8 @@ 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;
/**
@@ -44,16 +42,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$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;
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// ID of the task that the time entry should belong to
@@ -90,7 +79,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'description' => [
'nullable',
'string',
'max:5000',
'max:500',
],
// List of tag IDs
'tags' => [

View File

@@ -10,10 +10,8 @@ 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;
/**
@@ -56,16 +54,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$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;
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// ID of the task that the time entry should belong to
@@ -90,7 +79,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'changes.description' => [
'nullable',
'string',
'max:5000',
'max:500',
],
// List of tag IDs
'changes.tags' => [

View File

@@ -10,10 +10,8 @@ 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;
/**
@@ -44,16 +42,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$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;
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// ID of the task that the time entry should belong to
@@ -88,7 +77,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'description' => [
'nullable',
'string',
'max:5000',
'max:500',
],
// List of tag IDs
'tags' => [

View File

@@ -53,8 +53,6 @@ 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) */

View File

@@ -7,7 +7,6 @@ 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;
@@ -63,18 +62,6 @@ 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>
*/

View File

@@ -35,7 +35,6 @@ 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
@@ -71,7 +70,6 @@ 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,

View File

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

View File

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

View File

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

View File

@@ -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']) > 5000) {
if (strlen($record['Description']) > 500) {
throw new ImportException('Time entry description is too long');
}
$timeEntry->description = $record['Description'];

View File

@@ -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']) > 5000) {
if (strlen($record['Notes']) > 500) {
throw new ImportException('Time entry note is too long');
}
$timeEntry->description = $record['Notes'];

View File

@@ -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']) > 5000) {
if (strlen($timeEntryRow['description']) > 500) {
throw new ImportException('Time entry description is too long');
}
$timeEntry->description = $timeEntryRow['description'];

View File

@@ -71,19 +71,7 @@ class PermissionStore
/** @var Role|null $roleObj */
$roleObj = Jetstream::findRole($role);
$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;
return $roleObj->permissions ?? [];
}
/**

View File

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

View File

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

View File

@@ -435,7 +435,7 @@ CREATE TABLE public.tasks (
CREATE TABLE public.time_entries (
id uuid NOT NULL,
description character varying(5000) NOT NULL,
description character varying(500) NOT NULL,
start timestamp(0) without time zone NOT NULL,
"end" timestamp(0) without time zone,
billable_rate integer,

View File

@@ -1,14 +1,240 @@
/* Import shared solidtime styles from UI package */
@import '../js/packages/ui/styles.css';
@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;
/* Main app specific styles - Inter font */
--color-border-primary: #191b1f;
--color-border-secondary: #23252a;
--color-border-tertiary: #2c2e33;
--color-border-quaternary: #393B42;
--color-input-border-active: rgba(255,255,255,0.3);
--theme-color-chart: var(--color-accent-200);
--theme-color-menu-active: var(--color-bg-secondary);
--theme-color-card-background: var(--color-bg-secondary);
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
--theme-color-card-background-active: var(--color-bg-tertiary);
--theme-color-row-background: var(--color-bg-primary);
--theme-color-row-heading-background: var(--theme-color-card-background);
--theme-color-row-heading-border: var(--theme-color-card-border);
--theme-color-icon-default: var(--color-text-tertiary);
--theme-color-ring: rgba(255,255,255,0.5);
--theme-color-button-primary-background: rgba(var(--color-accent-300), 0.1);
--theme-color-button-primary-background-hover: rgba(var(--color-accent-300), 0.2);
--theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);
--theme-color-button-primary-text: var(--color-text-primary);
--theme-color-input-background: var(--color-bg-secondary);
--theme-color-input-select-active: rgb(var(--color-accent-300));
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
--color-accent-default: rgba(var(--color-accent-300), 0.2);
--color-accent-foreground: rgb(var(--color-accent-100));
--theme-color-default-background: var(--color-bg-primary);
}
:root.light {
--color-bg-primary: #FFFFFF;
--color-bg-secondary: #f7f7f8;
--color-bg-tertiary: #eeeeef;
--color-bg-quaternary: #e1e1e3;
--color-bg-background: #F5F5F5;
--color-text-primary: #18181b;
--color-text-secondary: #3f3f46;
--color-text-tertiary: #57575C;
--color-text-quaternary: #a1a1aa;
--color-border-primary: #e7e7e7;
--color-border-secondary: #e5e5e5;
--color-border-tertiary: #dfdfdf;
--color-border-quaternary: #d1d1d1;
--color-input-border-active: rgba(0,0,0,0.3);
--theme-color-menu-active: var(--color-bg-quaternary);
--theme-color-card-background: var(--color-bg-primary);
--theme-color-card-background-active: var(--color-bg-tertiary);
--theme-color-chart: var(--color-accent-400);
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--theme-color-row-background: var(--theme-color-card-background);
--theme-color-row-heading-background: var(--color-bg-secondary);
--theme-color-row-heading-border: var(--color-border-tertiary);
--theme-color-icon-default: var(--color-text-quaternary);
--theme-color-ring: rgba(0,0,0, 0.7);
--theme-color-button-primary-background: rgba(var(--color-accent-600), 0.9);
--theme-color-button-primary-background-hover: rgba(var(--color-accent-600), 1);
--theme-color-button-primary-border: rgba(var(--color-accent-600), 1);
--theme-color-button-primary-text: #FFFFFF;
--theme-color-input-background: var(--color-bg-primary);
--theme-color-input-select-active: rgb(var(--color-accent-400));
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
--color-accent-default: rgb(var(--color-accent-100));
--color-accent-foreground: rgb(var(--color-accent-800));
--theme-color-default-background: #FCFCFC;
}
:root {
--theme-color-icon-active: rgb(var(--color-text-tertiary));
--theme-color-card-background-separator: var(--color-border-tertiary);
--theme-color-card-border: var(--color-border-secondary);
--theme-color-card-border-active: var(--color-border-tertiary);
--theme-color-default-background-separator: var(--color-border-primary);
--theme-color-primary-text: var(--color-text-primary);
--theme-color-input-border: var(--color-border-quaternary);
--theme-color-tab-background: var(--theme-color-card-background);
--theme-color-tab-background-active: var(--theme-color-card-background-active);
--theme-color-tab-border: var(--theme-color-card-border);
--theme-color-row-separator-background: var(--theme-color-default-background-separator);
--theme-color-row-border: var(--theme-color-card-border);
--color-accent-50: 240, 249, 255; /* sky-50 */
--color-accent-100: 224, 242, 254; /* sky-100 */
--color-accent-200: 186, 230, 253; /* sky-200 */
--color-accent-300: 125, 211, 252; /* sky-300 */
--color-accent-400: 56, 189, 248; /* sky-400 */
--color-accent-500: 14, 165, 233; /* sky-500 */
--color-accent-600: 2, 132, 199; /* sky-600 */
--color-accent-700: 3, 105, 161; /* sky-700 */
--color-accent-800: 7, 89, 133; /* sky-800 */
--color-accent-900: 12, 74, 110; /* sky-900 */
--color-accent-950: 8, 47, 73; /* sky-950 */
--theme-button-secondary-background: var(--theme-color-card-background);
--theme-button-secondary-background-active: var(--theme-color-card-background-active);
--popover-border: var(--color-border-secondary);
}
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* width */
::-webkit-scrollbar {
width: 5px;
}
/* Track */
::-webkit-scrollbar-track, ::-webkit-scrollbar-corner {
background: transparent;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 2px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #555;
}
[x-cloak] {
display: none;
}
body {
background-color: var(--theme-color-default-background);
}
/* Inter Variable Font with browser compatibility considerations */
@font-face {
font-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;
}
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Button } from '@/packages/ui/src';
import { Button } from '@/Components/ui/button';
const props = defineProps<{
icon: Component;

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Switch } from '@/Components/ui/switch';
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
import { Button } from '@/packages/ui/src';
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Button } from '@/Components/ui/button';
import {
Select,
SelectContent,

View File

@@ -1,11 +1,19 @@
<script setup lang="ts">
import { ref } from 'vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import { useForm, usePage } from '@inertiajs/vue3';
import type { User } from '@/types/models';
import TimezoneMismatchModal from '@/packages/ui/src/TimezoneMismatchModal.vue';
import { useSessionStorage } from '@vueuse/core';
const show = defineModel('show', { default: false });
const saving = ref(false);
const saving = defineModel('saving', { default: false });
const timezone = ref('');
const userTimezone = ref('');
const page = usePage<{
auth: {
@@ -13,11 +21,27 @@ const page = usePage<{
};
}>();
function handleUpdate(timezone: string) {
const hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false);
onMounted(() => {
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
userTimezone.value = getUserTimezone();
const now = getDayJsInstance()();
if (
now.tz(timezone.value).format() !== now.tz(userTimezone.value).format() &&
!hideTimezoneMismatchModal.value
) {
show.value = true;
}
});
function submit() {
saving.value = true;
const form = useForm({
_method: 'PUT',
timezone: timezone,
timezone: timezone.value,
name: page.props.auth.user.name,
email: page.props.auth.user.email,
week_start: page.props.auth.user.week_start,
@@ -31,15 +55,53 @@ function handleUpdate(timezone: string) {
show.value = false;
location.reload();
},
onError: () => {
saving.value = false;
},
});
}
function cancel() {
show.value = false;
hideTimezoneMismatchModal.value = true;
}
</script>
<template>
<TimezoneMismatchModal v-model:show="show" :saving="saving" @update="handleUpdate" />
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex justify-center">
<span> Timezone mismatch detected </span>
</div>
</template>
<template #content>
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1 space-y-2">
<p>
The timezone of your device does not match the timezone in your user
settings. <br />
<strong
>We highly recommend that you update your timezone settings to your
current timezone.</strong
>
</p>
<p>
Want to change your timezone setting from
<strong>{{ userTimezone }}</strong> to <strong>{{ timezone }}</strong
>.
</p>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="cancel"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit()">
Update timezone
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -10,8 +10,7 @@ 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 min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
<p class="font-semibold text-sm text-text-primary">
{{ name }}
</p>
<div v-if="working" class="flex space-x-1.5 items-center justify-end">

View File

@@ -117,12 +117,6 @@ async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>
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();
@@ -158,7 +152,6 @@ async function discardCurrentTimeEntry() {
}
const { tags } = storeToRefs(useTagsStore());
const { timeEntries } = storeToRefs(useTimeEntriesStore());
</script>
<template>
@@ -169,8 +162,6 @@ const { timeEntries } = storeToRefs(useTimeEntriesStore());
:create-client="createClient"
:create-tag="createTag"
:create-time-entry="createTimeEntry"
:currency="getOrganizationCurrencyString()"
:can-create-project="canCreateProjects()"
:projects
:tasks
:tags
@@ -197,7 +188,6 @@ const { timeEntries } = storeToRefs(useTimeEntriesStore());
:tags
:tasks
:projects
:time-entries
:create-tag
:is-active
:currency="getOrganizationCurrencyString()"
@@ -205,8 +195,7 @@ const { timeEntries } = storeToRefs(useTimeEntriesStore());
@stop-live-timer="stopLiveTimer"
@start-timer="setActiveState(true)"
@stop-timer="setActiveState(false)"
@update-time-entry="updateTimeEntry"
@create-time-entry="createTimeEntryFromCurrentEntry"></TimeTrackerControls>
@update-time-entry="updateTimeEntry"></TimeTrackerControls>
</div>
<TimeTrackerMoreOptionsDropdown
:has-active-timer="isActive"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { cn } from '@/lib/utils';
import { AccordionContent, type AccordionContentProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { cn } from '@/lib/utils';
import { AccordionItem, type AccordionItemProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { cn } from '@/lib/utils';
import { ChevronDown } from 'lucide-vue-next';
import { AccordionHeader, AccordionTrigger, type AccordionTriggerProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { buttonVariants } from '@/packages/ui/src';
import { buttonVariants } from '@/Components/ui/button';
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import { twMerge } from 'tailwind-merge';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { buttonVariants } from '@/packages/ui/src';
import { buttonVariants } from '@/Components/ui/button';
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import { twMerge } from 'tailwind-merge';

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/Components/ui/button';
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
import { Button } from '@/packages/ui/src';
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Button } from '@/Components/ui/button';
import { Calendar } from '@/Components/ui/calendar';
import { CalendarIcon, XIcon } from 'lucide-vue-next';
import { formatDate } from '@/packages/ui/src/utils/time';

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src/index';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/Components/ui/button';
import { ChevronRight } from 'lucide-vue-next';
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/Components/ui/button';
import { ChevronLeft } from 'lucide-vue-next';
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/Components/ui/button';
import {
RangeCalendarCellTrigger,
type RangeCalendarCellTriggerProps,

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/Components/ui/button';
import { ChevronRight } from 'lucide-vue-next';
import { RangeCalendarNext, type RangeCalendarNextProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/Components/ui/button';
import { ChevronLeft } from 'lucide-vue-next';
import { RangeCalendarPrev, type RangeCalendarPrevProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -47,7 +47,7 @@ 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 Button from '@/Components/ui/button/Button.vue';
import { openFeedback } from '@/utils/feedback';
defineProps({

View File

@@ -9,7 +9,7 @@ import {
type Project,
type TimeEntryResponse,
} from '@/packages/api/src';
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { computed, ref } from 'vue';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import { TimeEntryCalendar } from '@/packages/ui/src';
@@ -21,8 +21,6 @@ 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);
@@ -75,7 +73,6 @@ const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useQuery<Time
queries: {
start: expandedDateRange.value.start!,
end: expandedDateRange.value.end!,
member_id: getCurrentMembershipId(),
},
}),
});
@@ -131,8 +128,6 @@ 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"

View File

@@ -383,7 +383,7 @@ async function downloadExport(format: ExportFormat) {
@submit="clearSelectionAndState"
@select-all="selectedTimeEntries = [...timeEntries]"
@unselect-all="selectedTimeEntries = []"></TimeEntryMassActionRow>
<div class="w-full relative @container">
<div class="w-full relative">
<div v-for="entry in timeEntries" :key="entry.id">
<TimeEntryRow
:selected="selectedTimeEntries.includes(entry)"

View File

@@ -14,18 +14,13 @@ const { updateOrganization } = store;
const { organization } = storeToRefs(store);
const queryClient = useQueryClient();
const form = ref<{
prevent_overlapping_time_entries: boolean;
employees_can_manage_tasks: boolean;
}>({
const form = ref<{ prevent_overlapping_time_entries: 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({
@@ -38,22 +33,22 @@ const mutation = useMutation({
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 #title>Time Entry Settings</template>
<template #description>
Configure various settings for your organization, including time entry and task
management permissions.
Disallow overlapping time entries for members of this organization. When enabled, users
cannot create new time entries that overlap with their existing ones. This only affects
newly created entries.
</template>
<template #form>
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4 space-y-4">
<div class="col-span-6 sm:col-span-4">
<div class="flex items-center space-x-2">
<Checkbox
id="preventOverlappingTimeEntries"
@@ -62,14 +57,6 @@ async function submit() {
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>

View File

@@ -1,16 +1,15 @@
{
"name": "@solidtime/api",
"version": "0.0.6",
"version": "0.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@solidtime/api",
"version": "0.0.6",
"version": "0.0.3",
"license": "AGPL-3.0",
"dependencies": {
"@zodios/core": "^10.9.6",
"axios": "^1.13.2",
"typescript": "^5.5.4",
"zod": "^3.23.8"
},
@@ -1095,16 +1094,18 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz",
"integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
@@ -1126,24 +1127,12 @@
"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"
},
@@ -1209,24 +1198,11 @@
"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",
@@ -1240,51 +1216,6 @@
"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",
@@ -1349,6 +1280,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=4.0"
},
@@ -1359,15 +1291,14 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"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": {
@@ -1408,60 +1339,12 @@
"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",
@@ -1479,37 +1362,11 @@
"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"
@@ -1632,20 +1489,12 @@
"@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"
}
@@ -1655,6 +1504,7 @@
"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"
},
@@ -1807,7 +1657,8 @@
"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"
"license": "MIT",
"peer": true
},
"node_modules/punycode": {
"version": "2.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@solidtime/api",
"version": "0.0.6",
"version": "0.0.4",
"description": "Package containing the solidtime api client and type declarations",
"main": "./dist/solidtime-api.umd.cjs",
"module": "./dist/solidtime-api.js",
@@ -29,7 +29,6 @@
"license": "AGPL-3.0",
"dependencies": {
"@zodios/core": "^10.9.6",
"axios": "^1.13.2",
"typescript": "^5.5.4",
"zod": "^3.23.8"
},

View File

@@ -317,7 +317,6 @@ 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(),
@@ -333,7 +332,6 @@ 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,

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@solidtime/ui",
"version": "0.0.15",
"version": "0.0.10",
"description": "Package containing the solidtime ui components",
"main": "./dist/solidtime-ui-lib.umd.cjs",
"module": "./dist/solidtime-ui-lib.js",
@@ -21,21 +21,16 @@
"default": "./dist/solidtime-ui-lib.umd.cjs"
}
},
"./style.css": "./dist/style.css",
"./styles.css": "./styles.css",
"./tailwind.theme.js": "./tailwind.theme.js"
"./style.css": "./dist/style.css"
},
"scripts": {
"dev": "vite",
"build": "vite build && vue-tsc --emitDeclarationOnly",
"watch": "vite build --watch",
"types": "vue-tsc ",
"preview": "vite preview"
},
"files": [
"dist",
"styles.css",
"tailwind.theme.js"
"dist"
],
"keywords": [
"solidtime",
@@ -64,11 +59,8 @@
"@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",

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ const dateFormat = computed(() => organization?.value?.date_format);
<div class="text-xs text-muted-foreground font-medium">
{{ date.format('ddd') }}
</div>
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
<span>{{ formatDate(date.toISOString(), dateFormat) }}</span>
<span class="block text-xs text-muted-foreground font-medium mt-1">
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
</span>

View File

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

View File

@@ -4,17 +4,7 @@ import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';
import {
computed,
ref,
watch,
inject,
type ComputedRef,
nextTick,
onMounted,
onActivated,
onUnmounted,
} from 'vue';
import { computed, ref, watch, inject, type ComputedRef } from 'vue';
import chroma from 'chroma-js';
import { useCssVariable } from '@/utils/useCssVariable';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
@@ -22,10 +12,6 @@ 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,
@@ -38,10 +24,7 @@ import type {
} from '@/packages/api/src';
import type { Dayjs } from 'dayjs';
type CalendarExtendedProps = { timeEntry: TimeEntry; isRunning?: boolean } & Record<
string,
unknown
>;
type CalendarExtendedProps = { timeEntry: TimeEntry } & Record<string, unknown>;
const emit = defineEmits<{
(e: 'dates-change', payload: { start: Date; end: Date }): void;
@@ -54,13 +37,10 @@ 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'>
@@ -81,10 +61,6 @@ 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');
@@ -126,81 +102,67 @@ const events = computed(() => {
const themeBackground = (() => {
return cssBackground.value?.trim();
})();
return props.timeEntries?.map((timeEntry) => {
const isRunning = timeEntry.end === null;
const project = props.projects.find((p) => p.id === timeEntry.project_id);
const client = props.clients.find((c) => c.id === project?.client_id);
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
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'
);
// For running entries, use current time as end
const effectiveEnd = isRunning ? currentTime.value : getDayJsInstance()(timeEntry.end!);
const duration = effectiveEnd.diff(getDayJsInstance()(timeEntry.start), 'minutes');
const title = timeEntry.description || 'No description';
const title = timeEntry.description || 'No description';
const baseColor = project?.color || '#6B7280';
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
const baseColor = project?.color || '#6B7280';
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
// For 0-duration events, display them with minimum visual duration but preserve actual duration
const startTime = getLocalizedDayJs(timeEntry.start);
const endTime =
duration === 0
? startTime.add(1, 'second') // Show as 1 second for minimal visibility
: getLocalizedDayJs(timeEntry.end!);
// 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,
},
};
});
return {
id: timeEntry.id,
start: startTime.format(),
end: endTime.format(),
title,
backgroundColor,
borderColor,
textColor: 'var(--foreground)',
extendedProps: {
timeEntry,
project,
client,
task,
duration,
},
};
});
});
// Daily totals used in day header
const dailyTotals = computed(() => {
const totals: Record<string, number> = {};
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(
props.timeEntries
.filter((entry) => entry.end !== null)
.forEach((entry) => {
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
const duration = getDayJsInstance()(entry.end!).diff(
getDayJsInstance()(entry.start),
'minutes'
);
} else {
// Running entry - use current time
duration = currentTime.value.diff(getDayJsInstance()(entry.start), 'minutes');
}
totals[date] = (totals[date] || 0) + duration;
});
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 }) {
@@ -219,10 +181,6 @@ 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;
}
@@ -236,13 +194,11 @@ 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;
@@ -259,25 +215,20 @@ async function handleEventResize(arg: EventChangeArg) {
start: getDayJsInstance()(arg.event.start.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
// Preserve null end for running entries
end: ext.isRunning
? null
: getDayJsInstance()(arg.event.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
end: getDayJsInstance()(arg.event.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
.utc()
.format(),
} as TimeEntry;
await props.updateTimeEntry(updatedTimeEntry);
emit('refresh');
}
const calendarOptions = computed(() => ({
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
initialView: 'timeGridWeek',
headerToolbar: {
left: 'prev,next today',
@@ -290,11 +241,10 @@ const calendarOptions = computed(() => ({
slotDuration: '00:15:00',
slotLabelInterval: '01:00:00',
slotLabelFormat: getSlotLabelFormat(),
snapDuration: '00:01:00',
snapDuration: '00:15:00',
firstDay: getFirstDay(),
allDaySlot: false,
nowIndicator: true,
eventMinHeight: 1,
selectable: true,
selectMirror: true,
editable: true,
@@ -309,7 +259,6 @@ const calendarOptions = computed(() => ({
datesSet: emitDatesChange,
events: events.value,
activityPeriods: props.activityPeriods || [],
}));
watch(showCreateTimeEntryModal, (value) => {
@@ -328,60 +277,6 @@ 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>
@@ -400,8 +295,6 @@ onUnmounted(() => {
:create-client="createClient"
:create-project="createProject"
:create-tag="createTag"
:currency="currency"
:can-create-project="canCreateProject"
:tags="tags as any"
:projects="projects"
:tasks="tasks"
@@ -421,9 +314,7 @@ onUnmounted(() => {
:tags="tags as any"
:projects="projects"
:tasks="tasks"
:clients="clients"
:currency="currency"
:can-create-project="canCreateProject" />
:clients="clients" />
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
<template #eventContent="arg">
<FullCalendarEventContent
@@ -476,11 +367,11 @@ onUnmounted(() => {
}
.fullcalendar :deep(.fc-timegrid-slot-label) {
background-color: var(--background);
background-color: var(--theme-color-default-background);
}
.fullcalendar :deep(.fc-toolbar) {
background-color: var(--background);
background-color: var(--theme-color-default-background);
padding: 0.5rem;
margin-bottom: 0;
}
@@ -561,8 +452,8 @@ onUnmounted(() => {
}
.fullcalendar :deep(.fc-event) {
border-radius: calc(var(--radius) - 4px);
padding: 0;
border-radius: var(--radius);
padding: 0.45rem 0.25rem;
font-size: 0.75rem;
cursor: pointer;
box-shadow: var(--theme-shadow-card);
@@ -624,7 +515,7 @@ onUnmounted(() => {
}
.fullcalendar :deep(.fc-highlight) {
background-color: var(--primary);
background-color: var(--theme-color-default-background);
}
.fullcalendar :deep(.fc-select-mirror) {
@@ -642,7 +533,7 @@ onUnmounted(() => {
}
.fullcalendar :deep(.fc-timegrid-body) {
background-color: var(--background);
background-color: var(--theme-color-default-background);
}
.fullcalendar :deep(.fc-timegrid-col) {
@@ -709,57 +600,4 @@ onUnmounted(() => {
.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>

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { watch } from 'vue';
const props = withDefaults(

View File

@@ -27,19 +27,11 @@ function updateTime(event: Event) {
if (newValue.split(':').length === 2) {
const [hours, minutes] = newValue.split(':');
if (!isNaN(parseInt(hours)) && !isNaN(parseInt(minutes))) {
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);
}
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59))
.format();
emit('changed', model.value);
}
}
// check if input is only numbers
@@ -50,7 +42,6 @@ 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) {
@@ -59,7 +50,6 @@ 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) {
@@ -67,7 +57,6 @@ 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) {
@@ -75,7 +64,6 @@ 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);
}
@@ -105,7 +93,6 @@ 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()"

View File

@@ -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,42 +17,31 @@ 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(),
tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null
getDayJsInstance()(tempEnd.value).utc().format()
);
}
}
function setEndTime() {
showEndTimePicker.value = true;
tempEnd.value = getDayJsInstance()().format();
}
function confirmEndTime() {
// wait for the v-model for the end time to update
nextTick(() => {
updateTimeEntry();
showEndTimePicker.value = false;
emit('close');
});
}
const dropdownContent = ref();
const { focused } = useFocusWithin(dropdownContent);
watch(focused, (newValue, oldValue) => {
if (oldValue === true && newValue === false) {
updateTimeEntry();
}
});
</script>
<template>
@@ -78,7 +67,7 @@ const dropdownContent = ref();
</div>
<div class="px-2">
<div class="font-semibold text-text-primary text-sm pb-2">End</div>
<div v-if="end !== null && tempEnd !== null" class="space-y-2">
<div v-if="tempEnd !== null" class="space-y-2">
<TimePickerSimple
v-model="tempEnd"
data-testid="time_entry_range_end"
@@ -88,22 +77,6 @@ const dropdownContent = ref();
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
@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>

View File

@@ -93,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="@xl:flex py-2 items-center min-w-0 justify-between group">
<div class="sm:flex py-2 items-center min-w-0 justify-between group">
<div class="flex space-x-3 items-center min-w-0">
<Checkbox
:checked="
@@ -125,7 +125,7 @@ function onSelectChange(checked: boolean) {
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
</div>
</div>
<div class="flex items-center font-medium space-x-1 @lg:space-x-2">
<div class="flex items-center font-medium lg:space-x-2">
<TimeEntryRowTagDropdown
:create-tag
:tags="tags"
@@ -142,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-[160px]'
: 'w-[100px]'
? 'w-[170px]'
: 'w-[120px]'
)
"
@click="expanded = !expanded">
@@ -157,7 +157,7 @@ function onSelectChange(checked: boolean) {
</button>
</div>
<button
class="text-text-primary !mr-2 min-w-[80px] px-1.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
class="text-text-primary min-w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
@click="expanded = !expanded">
{{
formatHumanReadableDuration(

View File

@@ -15,6 +15,8 @@ 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';
@@ -41,8 +43,6 @@ 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').second(0).format(),
end: getDayJsInstance().utc().second(0).format(),
start: getDayJsInstance().utc().subtract(1, 'h').format(),
end: getDayJsInstance().utc().format(),
};
const timeEntry = ref({
@@ -167,8 +167,8 @@ type BillableOption = {
:clients
:create-project
:create-client
:can-create-project
:currency
:can-create-project="canCreateProjects()"
:currency="getOrganizationCurrencyString()"
size="xlarge"
class="bg-input-background"
:projects="projects"

View File

@@ -15,6 +15,8 @@ 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';
@@ -42,8 +44,6 @@ const props = defineProps<{
projects: Project[];
tasks: Task[];
clients: Client[];
currency: string;
canCreateProject: boolean;
}>();
const description = ref<HTMLInputElement | null>(null);
@@ -68,6 +68,19 @@ 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() : '',
@@ -163,8 +176,8 @@ type BillableOption = {
:clients
:create-project
:create-client
:can-create-project="canCreateProject"
:currency="currency"
:can-create-project="canCreateProjects()"
:currency="getOrganizationCurrencyString()"
size="xlarge"
class="bg-input-background"
:projects="projects"

View File

@@ -94,7 +94,6 @@ const groupedTimeEntries = computed(() => {
groupedEntriesByDayAndType[dailyEntriesKey] = newDailyEntries;
}
return groupedEntriesByDayAndType;
});
@@ -138,81 +137,79 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
</script>
<template>
<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))
<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"
: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];
}
"
@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="
@unselected="
(timeEntriesToUnselect: TimeEntry[]) => {
selectedTimeEntries = selectedTimeEntries.filter(
(item: TimeEntry) => item.id !== entry.id
)
"></TimeEntryRow>
</template>
</div>
(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) => item.id !== entry.id
)
"></TimeEntryRow>
</template>
</div>
</template>

View File

@@ -35,11 +35,11 @@ const organization = inject<ComputedRef<Organization>>('organization');
data-testid="time_entry_range_selector"
:class="
twMerge(
'text-text-secondary px-1 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
'text-text-secondary px-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
showDate
? 'text-xs py-1.5 font-semibold'
: 'text-sm py-1.5 font-medium',
organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[100px]',
organization?.time_format === '12-hours' ? 'w-[170px]' : 'w-[120px]',
open && 'border-card-border bg-card-background'
)
">

View File

@@ -112,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="@xl:flex py-2 min-w-0 items-center justify-between group">
<div class="sm:flex py-2 min-w-0 items-center justify-between group">
<div class="flex items-center min-w-0">
<Checkbox :checked="selected" @update:checked="onSelectChange" />
<div v-if="indent === true" class="w-10 h-7"></div>
@@ -134,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>
@@ -186,9 +186,7 @@ async function handleDeleteTimeEntry() {
:tags="tags"
:projects="projects"
:tasks="tasks"
:clients="clients"
:currency="currency"
:can-create-project="canCreateProject" />
:clients="clients" />
</template>
<style scoped></style>

View File

@@ -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-[80px] !mr-2 px-1.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
class="text-text-primary w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
@focus="selectInput"
@keydown.tab="open = false"
@blur="updateTimerAndStartLiveTimerUpdate"

View File

@@ -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-[87px] @lg:pr-[92px]">
<div class="text-text-secondary pr-[90px] lg:pr-[92px]">
<span class="font-medium">
{{
formatHumanReadableDuration(

View File

@@ -15,6 +15,8 @@ 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';
@@ -32,7 +34,6 @@ 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>;
@@ -48,7 +49,6 @@ const emit = defineEmits<{
updateTimeEntry: [];
startLiveTimer: [];
stopLiveTimer: [];
createTimeEntry: [];
}>();
function updateProject() {
@@ -130,9 +130,10 @@ function updateTimeEntryDescription() {
}
}
const { timeEntries } = storeToRefs(useTimeEntriesStore());
const filteredRecentlyTrackedTimeEntries = computed(() => {
// do not include running time entries
const finishedTimeEntries = props.timeEntries.filter((item) => item.end !== null);
const finishedTimeEntries = timeEntries.value.filter((item) => item.end !== null);
// filter out duplicates based on description, task, project, tags and billable
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
@@ -279,7 +280,6 @@ 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>

View File

@@ -16,7 +16,6 @@ const emit = defineEmits<{
stopLiveTimer: [];
updateTimer: [];
startTimer: [];
createTimeEntry: [];
}>();
const open = ref(false);
@@ -56,7 +55,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');
@@ -74,16 +73,12 @@ function updateTimerAndStartLiveTimerUpdate() {
const temporaryCustomTimerEntry = ref<string>('');
async function updateTimeRange(newStart: string, newEnd: string | null) {
async function updateTimeRange(newStart: string) {
// 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');
}
@@ -96,21 +91,11 @@ 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)) {
@@ -118,12 +103,6 @@ function openModalOnTab(e: FocusEvent) {
}
}
function openModalOnClick(e: MouseEvent) {
pauseLiveTimerUpdate(e);
open.value = true;
}
function focusNextElement(e: KeyboardEvent) {
if (open.value) {
e.preventDefault();
@@ -156,8 +135,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"
@@ -167,7 +146,7 @@ function closeAndFocusInput() {
<div ref="timeRangeSelector">
<TimeRangeSelector
:start="startTime"
:end="endTime"
:end="null"
@changed="updateTimeRange"
@close="closeAndFocusInput">
</TimeRangeSelector>

View File

@@ -1,109 +0,0 @@
<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>

View File

@@ -10,12 +10,8 @@ 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';
@@ -24,7 +20,6 @@ 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';
@@ -37,20 +32,12 @@ 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,
@@ -61,7 +48,6 @@ export {
InputLabel,
TimeTrackerRunningInDifferentOrganizationOverlay,
TimeTrackerControls,
TimeTrackerMoreOptionsDropdown,
CardTitle,
SelectDropdown,
Badge,
@@ -74,19 +60,4 @@ export {
FullCalendarEventContent,
FullCalendarDayHeader,
TimeEntryCalendar,
DateRangePicker,
TimezoneMismatchModal,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Popover,
PopoverContent,
PopoverTrigger,
PopoverAnchor,
RangeCalendar,
};

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui';
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui';
const props = defineProps<TooltipRootProps>();
const emits = defineEmits<TooltipRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<TooltipRoot v-bind="forwarded">
<slot />
</TooltipRoot>
</template>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui';
import { cn } from '@/lib/utils';
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(
defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(),
{
sideOffset: 4,
}
);
const emits = defineEmits<TooltipContentEmits>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<TooltipPortal>
<TooltipContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
">
<slot />
</TooltipContent>
</TooltipPortal>
</template>

View File

@@ -1,12 +0,0 @@
<script setup lang="ts">
import type { TooltipProviderProps } from 'reka-ui';
import { TooltipProvider } from 'reka-ui';
const props = defineProps<TooltipProviderProps>();
</script>
<template>
<TooltipProvider v-bind="props">
<slot />
</TooltipProvider>
</template>

View File

@@ -1,12 +0,0 @@
<script setup lang="ts">
import type { TooltipTriggerProps } from 'reka-ui';
import { TooltipTrigger } from 'reka-ui';
const props = defineProps<TooltipTriggerProps>();
</script>
<template>
<TooltipTrigger v-bind="props">
<slot />
</TooltipTrigger>
</template>

View File

@@ -1,4 +0,0 @@
export { default as Tooltip } from './Tooltip.vue';
export { default as TooltipContent } from './TooltipContent.vue';
export { default as TooltipProvider } from './TooltipProvider.vue';
export { default as TooltipTrigger } from './TooltipTrigger.vue';

View File

@@ -1,6 +0,0 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs.filter(Boolean)));
}

View File

@@ -208,30 +208,22 @@ export function formatStartEnd(
export function parseTimeInput(
input: string,
defaultUnit: TimeInputUnit = 'minutes'
): number | null {
): {
seconds: number | null;
isHHMM: boolean;
} {
// Check if input is a decimal number (hours)
const decimalRegex = /^-?\d+[.,]\d+$/;
if (decimalRegex.test(input)) {
const hours = parseFloat(input.replace(',', '.'));
return Math.round(hours * 3600);
return { seconds: Math.round(hours * 3600), isHHMM: false };
}
// Check if input is just a number (minutes or hours based on defaultUnit)
if (/^-?\d+$/.test(input)) {
const value = parseInt(input);
return defaultUnit === 'minutes' ? value * 60 : value * 3600;
}
// Check if input is in HH:MM:SS format
const HHMMSStimeRegex = /^([0-9]{1,2}):([0-5]?[0-9]):([0-5]?[0-9])$/;
if (HHMMSStimeRegex.test(input)) {
const match = input.match(HHMMSStimeRegex);
if (match) {
const hours = parseInt(match[1]);
const minutes = parseInt(match[2]);
const seconds = parseInt(match[3]);
return hours * 3600 + minutes * 60 + seconds;
}
const seconds = defaultUnit === 'minutes' ? value * 60 : value * 3600;
return { seconds, isHHMM: false };
}
// Check if input is in HH:MM format
@@ -241,15 +233,15 @@ export function parseTimeInput(
if (match) {
const hours = parseInt(match[1]);
const minutes = parseInt(match[2]);
return (hours * 60 + minutes) * 60;
return { seconds: (hours * 60 + minutes) * 60, isHHMM: true };
}
}
// Try to parse natural language like "1h 30m"
const parsedDuration = parse(input, 's');
if (parsedDuration && parsedDuration > 0) {
return parsedDuration;
return { seconds: parsedDuration, isHHMM: false };
}
return null;
return { seconds: null, isHHMM: false };
}

View File

@@ -1,240 +0,0 @@
/**
* Shared styles for solidtime
* This CSS file contains all the shared theme variables and base styles
* used by both the main solidtime app and the desktop app.
*
* Font-face declarations are intentionally omitted here as they differ between apps:
* - Main app uses 'Inter'
* - Desktop app uses 'Outfit'
* Each app should include their own font-face declarations.
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
:root.dark {
--color-bg-primary: #101012;
--color-bg-secondary: #17181b;
--color-bg-tertiary: #2a2c32;
--color-bg-quaternary: #141518;
--color-bg-background: #090909;
--color-text-primary: #ffffff;
--color-text-secondary: #e3e4e6;
--color-text-tertiary: #969799;
--color-text-quaternary: #595a5c;
--color-border-primary: #191b1f;
--color-border-secondary: #23252a;
--color-border-tertiary: #2c2e33;
--color-border-quaternary: #393b42;
--color-input-border-active: rgba(255, 255, 255, 0.3);
--theme-color-chart: var(--color-accent-200);
--theme-color-menu-active: var(--color-bg-secondary);
--theme-color-card-background: var(--color-bg-secondary);
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
--theme-color-card-background-active: var(--color-bg-tertiary);
--theme-color-row-background: var(--color-bg-primary);
--theme-color-row-heading-background: var(--theme-color-card-background);
--theme-color-row-heading-border: var(--theme-color-card-border);
--theme-color-icon-default: var(--color-text-tertiary);
--theme-color-ring: rgba(255, 255, 255, 0.5);
--theme-color-button-primary-background: rgba(var(--color-accent-300), 0.1);
--theme-color-button-primary-background-hover: rgba(var(--color-accent-300), 0.2);
--theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);
--theme-color-button-primary-text: var(--color-text-primary);
--theme-color-input-background: var(--color-bg-secondary);
--theme-color-input-select-active: rgb(var(--color-accent-300));
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
--color-accent-default: rgba(var(--color-accent-300), 0.2);
--color-accent-foreground: rgb(var(--color-accent-100));
--theme-color-default-background: var(--color-bg-primary);
}
:root.light {
--color-bg-primary: #ffffff;
--color-bg-secondary: #f7f7f8;
--color-bg-tertiary: #eeeeef;
--color-bg-quaternary: #e1e1e3;
--color-bg-background: #f5f5f5;
--color-text-primary: #18181b;
--color-text-secondary: #3f3f46;
--color-text-tertiary: #57575c;
--color-text-quaternary: #a1a1aa;
--color-border-primary: #e7e7e7;
--color-border-secondary: #e5e5e5;
--color-border-tertiary: #dfdfdf;
--color-border-quaternary: #d1d1d1;
--color-input-border-active: rgba(0, 0, 0, 0.3);
--theme-color-menu-active: var(--color-bg-quaternary);
--theme-color-card-background: var(--color-bg-primary);
--theme-color-card-background-active: var(--color-bg-tertiary);
--theme-color-chart: var(--color-accent-400);
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--theme-color-row-background: var(--theme-color-card-background);
--theme-color-row-heading-background: var(--color-bg-secondary);
--theme-color-row-heading-border: var(--color-border-tertiary);
--theme-color-icon-default: var(--color-text-quaternary);
--theme-color-ring: rgba(0, 0, 0, 0.7);
--theme-color-button-primary-background: rgba(var(--color-accent-600), 0.9);
--theme-color-button-primary-background-hover: rgba(var(--color-accent-600), 1);
--theme-color-button-primary-border: rgba(var(--color-accent-600), 1);
--theme-color-button-primary-text: #ffffff;
--theme-color-input-background: var(--color-bg-primary);
--theme-color-input-select-active: rgb(var(--color-accent-400));
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
--color-accent-default: rgb(var(--color-accent-100));
--color-accent-foreground: rgb(var(--color-accent-800));
--theme-color-default-background: #fcfcfc;
}
:root {
--theme-color-icon-active: rgb(var(--color-text-tertiary));
--theme-color-card-background-separator: var(--color-border-tertiary);
--theme-color-card-border: var(--color-border-secondary);
--theme-color-card-border-active: var(--color-border-tertiary);
--theme-color-default-background-separator: var(--color-border-primary);
--theme-color-primary-text: var(--color-text-primary);
--theme-color-input-border: var(--color-border-quaternary);
--theme-color-tab-background: var(--theme-color-card-background);
--theme-color-tab-background-active: var(--theme-color-card-background-active);
--theme-color-tab-border: var(--theme-color-card-border);
--theme-color-row-separator-background: var(--theme-color-default-background-separator);
--theme-color-row-border: var(--theme-color-card-border);
--color-accent-50: 240, 249, 255; /* sky-50 */
--color-accent-100: 224, 242, 254; /* sky-100 */
--color-accent-200: 186, 230, 253; /* sky-200 */
--color-accent-300: 125, 211, 252; /* sky-300 */
--color-accent-400: 56, 189, 248; /* sky-400 */
--color-accent-500: 14, 165, 233; /* sky-500 */
--color-accent-600: 2, 132, 199; /* sky-600 */
--color-accent-700: 3, 105, 161; /* sky-700 */
--color-accent-800: 7, 89, 133; /* sky-800 */
--color-accent-900: 12, 74, 110; /* sky-900 */
--color-accent-950: 8, 47, 73; /* sky-950 */
--theme-button-secondary-background: var(--theme-color-card-background);
--theme-button-secondary-background-active: var(--theme-color-card-background-active);
--popover-border: var(--color-border-secondary);
}
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* width */
::-webkit-scrollbar {
width: 5px;
}
/* Track */
::-webkit-scrollbar-track,
::-webkit-scrollbar-corner {
background: transparent;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 2px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #555;
}
[x-cloak] {
display: none;
}
body {
background-color: var(--theme-color-default-background);
}
@layer base {
:root {
--background: var(--color-bg-background);
--foreground: var(--color-text-primary);
--card: var(--theme-color-card-background);
--card-foreground: var(--color-text-primary);
--popover: var(--theme-color-card-background);
--popover-foreground: var(--color-text-primary);
--primary: var(--color-bg-primary);
--primary-foreground: var(--color-text-primary);
--secondary: var(--color-bg-secondary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-bg-tertiary);
--muted-foreground: var(--color-text-tertiary);
--accent: var(--theme-color-button-primary-background);
--accent-foreground: var(--theme-color-button-primary-text);
--destructive: 0 84.2% 60.2%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
--input: var(--color-border-tertiary);
--ring: var(--theme-color-ring);
--chart-1: var(--color-accent-400);
--chart-2: var(--color-accent-500);
--chart-3: var(--color-accent-600);
--chart-4: var(--color-accent-700);
--chart-5: var(--color-accent-800);
--radius: 0.5rem;
}
.dark {
--background: var(--color-bg-background);
--foreground: var(--color-text-primary);
--card: var(--theme-color-card-background);
--card-foreground: var(--color-text-primary);
--popover: var(--theme-color-card-background);
--popover-foreground: var(--color-text-primary);
--primary: var(--color-bg-primary);
--primary-foreground: var(--color-text-primary);
--secondary: var(--color-bg-secondary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-bg-tertiary);
--muted-foreground: var(--color-text-tertiary);
--accent: var(--theme-color-button-primary-background);
--accent-foreground: var(--theme-color-button-primary-text);
--destructive: 0 62.8% 30.6%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
--input: var(--color-border-tertiary);
--ring: var(--theme-color-ring);
--chart-1: var(--color-accent-200);
--chart-2: var(--color-accent-300);
--chart-3: var(--color-accent-400);
--chart-4: var(--color-accent-500);
--chart-5: var(--color-accent-600);
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,131 +0,0 @@
/**
* Shared Tailwind theme configuration for solidtime
* This configuration is used by both the main solidtime app and the desktop app
*
* Note: fontFamily is intentionally omitted here as it differs between apps:
* - Main app uses 'Inter'
* - Desktop app uses 'Outfit'
* Each app should override the fontFamily in their own config.
*/
export const solidtimeTheme = {
boxShadow: {
card: 'var(--theme-shadow-card)',
dropdown: 'var(--theme-shadow-dropdown)',
},
containers: {
'2xs': '16rem',
},
fontSize: {
'2xs': ['0.625rem', { lineHeight: '0.75rem' }],
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.8125rem', { lineHeight: '1.125rem' }],
base: ['0.875rem', { lineHeight: '1.25rem' }],
lg: ['1rem', { lineHeight: '1.5rem' }],
xl: ['1.125rem', { lineHeight: '1.75rem' }],
'2xl': ['1.25rem', { lineHeight: '1.75rem' }],
'3xl': ['1.5rem', { lineHeight: '2rem' }],
'4xl': ['1.75rem', { lineHeight: '2.25rem' }],
'5xl': ['2rem', { lineHeight: '1' }],
'6xl': ['2.25rem', { lineHeight: '1' }],
'7xl': ['2.5rem', { lineHeight: '1' }],
'8xl': ['3rem', { lineHeight: '1' }],
'9xl': ['3.5rem', { lineHeight: '1' }],
},
colors: {
ring: 'var(--ring)',
primary: {
DEFAULT: 'var(--primary)',
foreground: 'var(--primary-foreground)',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
tertiary: 'var(--color-bg-tertiary)',
quaternary: 'var(--color-bg-quaternary)',
background: 'var(--background)',
'text-primary': 'var(--color-text-primary)',
'text-secondary': 'var(--color-text-secondary)',
'text-tertiary': 'var(--color-text-tertiary)',
'text-quaternary': 'var(--color-text-quaternary)',
'border-primary': 'var(--color-border-primary)',
'border-secondary': 'var(--color-border-secondary)',
'border-tertiary': 'var(--color-border-tertiary)',
'default-background': 'var(--theme-color-default-background)',
'default-background-separator': 'var(--theme-color-default-background-separator)',
'row-background': 'var(--theme-color-row-background)',
'card-background': 'var(--theme-color-card-background)',
'card-background-active': 'var(--theme-color-card-background-active)',
'card-background-separator': 'var(--theme-color-card-background-separator)',
'card-border': 'var(--theme-color-card-border)',
'card-border-active': 'var(--theme-color-card-border-active)',
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
'tab-background': 'var(--theme-color-tab-background)',
'tab-background-active': 'var(--theme-color-tab-background-active)',
'tab-border': 'var(--theme-color-tab-border)',
'icon-default': 'var(--theme-color-icon-default)',
'icon-active': 'var(--theme-color-icon-active)',
'menu-active': 'var(--theme-color-menu-active)',
'input-border': 'var(--theme-color-input-border)',
'input-border-active': 'var(--color-input-border-active)',
'input-background': 'var(--theme-color-input-background)',
'button-secondary-background': 'var(--theme-button-secondary-background)',
'button-secondary-background-hover': 'var(--theme-button-secondary-background-active)',
'button-secondary-border': 'var(--theme-color-card-border)',
'row-separator': 'var(--theme-color-row-separator-background)',
'row-heading-background': 'var(--theme-color-row-heading-background)',
'row-heading-border': 'var(--theme-color-row-heading-border)',
accent: {
'50': 'rgba(var(--color-accent-50), <alpha-value>)',
'100': 'rgba(var(--color-accent-100), <alpha-value>)',
'200': 'rgba(var(--color-accent-200), <alpha-value>)',
'300': 'rgba(var(--color-accent-300), <alpha-value>)',
'400': 'rgba(var(--color-accent-400), <alpha-value>)',
'500': 'rgba(var(--color-accent-500), <alpha-value>)',
'600': 'rgba(var(--color-accent-600), <alpha-value>)',
'700': 'rgba(var(--color-accent-700), <alpha-value>)',
'800': 'rgba(var(--color-accent-800), <alpha-value>)',
'900': 'rgba(var(--color-accent-900), <alpha-value>)',
'950': 'rgba(var(--color-accent-950), <alpha-value>)',
DEFAULT: 'var(--color-accent-default)',
foreground: 'var(--color-accent-foreground)',
},
'button-primary-background': 'var(--theme-color-button-primary-background)',
'button-primary-background-hover': 'var(--theme-color-button-primary-background-hover)',
'button-primary-border': 'var(--theme-color-button-primary-border)',
'button-primary-text': 'var(--theme-color-button-primary-text)',
'input-select-active': 'var(--theme-color-input-select-active)',
'input-select-active-hover': 'var(--theme-color-input-select-active-hover)',
foreground: 'var(--foreground)',
card: {
DEFAULT: 'var(--card))',
foreground: 'var(--card-foreground))',
},
popover: {
DEFAULT: 'var(--popover)',
foreground: 'var(--popover-foreground)',
border: 'var(--popover-border)',
},
destructive: {
DEFAULT: 'var(--destructive)',
foreground: 'var(--destructive-foreground)',
},
border: 'var(--border)',
input: 'var(--input)',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
};

View File

@@ -81,7 +81,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
}
return response;
} catch {
router.get('/login');
router.get(route('login'));
}
} else {
addNotification('error', 'The action failed. Please try again later.');

View File

@@ -162,7 +162,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
task_id: currentTimeEntry.value.task_id,
start: currentTimeEntry.value.start,
billable: currentTimeEntry.value.billable,
end: currentTimeEntry.value.end,
end: null,
tags: currentTimeEntry.value.tags,
},
{
@@ -175,12 +175,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
'Time entry updated!'
);
if (response?.data) {
if (response.data.end === null) {
currentTimeEntry.value = response.data;
} else {
$reset();
stopLiveTimer();
}
currentTimeEntry.value = response.data;
}
} else {
throw new Error(
@@ -220,6 +215,5 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
stopLiveTimer,
now,
setActiveState,
$reset,
};
});

View File

@@ -1,246 +1,225 @@
import { defineStore } from 'pinia';
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
import { reactive, ref, type Ref } from 'vue';
import { api } from '@/packages/api/src';
import type {
CreateTimeEntryBody,
TimeEntriesQueryParams,
TimeEntry,
UpdateMultipleTimeEntriesChangeset,
import { reactive, ref } from 'vue';
import {
api,
type CreateTimeEntryBody,
type TimeEntriesQueryParams,
type TimeEntry,
} from '@/packages/api/src';
import dayjs from 'dayjs';
import { useNotificationsStore } from '@/utils/notification';
import type {} from '@/packages/api/src';
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
import { useQueryClient } from '@tanstack/vue-query';
export const useTimeEntriesStore = defineStore(
'timeEntries',
(): {
timeEntries: Ref<TimeEntry[]>;
fetchTimeEntries: (queryParams?: TimeEntriesQueryParams) => Promise<void>;
updateTimeEntry: (timeEntry: TimeEntry) => Promise<void>;
createTimeEntry: (timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) => Promise<void>;
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
fetchMoreTimeEntries: () => Promise<void>;
allTimeEntriesLoaded: Ref<boolean>;
updateTimeEntries: (
ids: string[],
changes: UpdateMultipleTimeEntriesChangeset
) => Promise<void>;
deleteTimeEntries: (timeEntries: TimeEntry[]) => Promise<void>;
patchTimeEntries: (queryParams?: TimeEntriesQueryParams) => Promise<void>;
} => {
const timeEntries = ref<TimeEntry[]>(reactive([]));
export const useTimeEntriesStore = defineStore('timeEntries', () => {
const timeEntries = ref<TimeEntry[]>(reactive([]));
const allTimeEntriesLoaded = ref(false);
const { handleApiRequestNotifications } = useNotificationsStore();
const allTimeEntriesLoaded = ref(false);
const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
const queryClient = useQueryClient();
async function patchTimeEntries(
queryParams: TimeEntriesQueryParams = {
only_full_dates: 'true',
member_id: getCurrentMembershipId(),
}
) {
const organizationId = getCurrentOrganizationId();
async function patchTimeEntries(
queryParams: TimeEntriesQueryParams = {
only_full_dates: 'true',
member_id: getCurrentMembershipId(),
}
) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const timeEntriesResponse = await handleApiRequestNotifications(
() =>
api.getTimeEntries({
params: {
organization: organizationId,
},
queries: queryParams,
}),
undefined,
'Failed to fetch time entries'
if (organizationId) {
const timeEntriesResponse = await handleApiRequestNotifications(
() =>
api.getTimeEntries({
params: {
organization: organizationId,
},
queries: queryParams,
}),
undefined,
'Failed to fetch time entries'
);
if (timeEntriesResponse?.data) {
// insert missing time entries
const missingTimeEntries = timeEntriesResponse.data.filter(
(entry) => !timeEntries.value.find((e) => e.id === entry.id)
);
if (timeEntriesResponse?.data) {
// insert missing time entries
const missingTimeEntries = timeEntriesResponse.data.filter(
(entry) => !timeEntries.value.find((e) => e.id === entry.id)
);
timeEntries.value = [...missingTimeEntries, ...timeEntries.value];
}
timeEntries.value = [...missingTimeEntries, ...timeEntries.value];
}
}
async function fetchTimeEntries(
queryParams: TimeEntriesQueryParams = {
only_full_dates: 'true',
member_id: getCurrentMembershipId(),
}
) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const timeEntriesResponse = await handleApiRequestNotifications(
() =>
api.getTimeEntries({
params: {
organization: organizationId,
},
queries: queryParams,
}),
undefined,
'Failed to fetch time entries'
);
if (timeEntriesResponse?.data) {
timeEntries.value = timeEntriesResponse.data;
}
}
}
async function fetchMoreTimeEntries() {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const latestTimeEntry = timeEntries.value[timeEntries.value.length - 1];
dayjs(latestTimeEntry.start).utc().format('YYYY-MM-DD');
const timeEntriesResponse = await handleApiRequestNotifications(
() =>
api.getTimeEntries({
params: {
organization: organizationId,
},
queries: {
only_full_dates: 'true',
member_id: getCurrentMembershipId(),
end: dayjs(latestTimeEntry.start).utc().format(),
},
}),
undefined,
'Failed to fetch time entries'
);
if (timeEntriesResponse?.data && timeEntriesResponse.data.length > 0) {
timeEntries.value = timeEntries.value.concat(timeEntriesResponse.data);
} else {
allTimeEntriesLoaded.value = true;
}
}
}
async function updateTimeEntries(
ids: string[],
changes: UpdateMultipleTimeEntriesChangeset
) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.updateMultipleTimeEntries(
{
ids: ids,
changes: changes,
},
{
params: {
organization: organizationId,
},
}
),
'Time entries updated successfully',
'Failed to update time entries'
);
}
}
async function updateTimeEntry(timeEntry: TimeEntry) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const response = await handleApiRequestNotifications(
() =>
api.updateTimeEntry(timeEntry, {
params: {
organization: organizationId,
timeEntry: timeEntry.id,
},
}),
'Time entry updated successfully',
'Failed to update time entry'
);
timeEntries.value = timeEntries.value.map((entry) =>
entry.id === timeEntry.id ? response.data : entry
);
queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
}
}
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
const organizationId = getCurrentOrganizationId();
const memberId = getCurrentMembershipId();
if (organizationId && memberId !== undefined) {
const newTimeEntry = {
...timeEntry,
member_id: memberId,
} as CreateTimeEntryBody;
await handleApiRequestNotifications(
() =>
api.createTimeEntry(newTimeEntry, {
params: {
organization: organizationId,
},
}),
'Time entry created successfully',
'Failed to create time entry'
);
await fetchTimeEntries();
}
}
async function deleteTimeEntry(timeEntryId: string) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.deleteTimeEntry(undefined, {
params: {
organization: organizationId,
timeEntry: timeEntryId,
},
}),
'Time entry deleted successfully',
'Failed to delete time entry'
);
await fetchTimeEntries();
}
}
async function deleteTimeEntries(timeEntries: TimeEntry[]) {
const organizationId = getCurrentOrganizationId();
const timeEntryIds = timeEntries.map((entry) => entry.id);
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.deleteTimeEntries(undefined, {
queries: {
ids: timeEntryIds,
},
params: {
organization: organizationId,
},
}),
'Time entries deleted successfully',
'Failed to delete time entries'
);
await fetchTimeEntries();
}
}
return {
timeEntries,
fetchTimeEntries,
updateTimeEntry,
createTimeEntry,
deleteTimeEntry,
fetchMoreTimeEntries,
allTimeEntriesLoaded,
updateTimeEntries,
deleteTimeEntries,
patchTimeEntries,
};
}
);
async function fetchTimeEntries(
queryParams: TimeEntriesQueryParams = {
only_full_dates: 'true',
member_id: getCurrentMembershipId(),
}
) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const timeEntriesResponse = await handleApiRequestNotifications(
() =>
api.getTimeEntries({
params: {
organization: organizationId,
},
queries: queryParams,
}),
undefined,
'Failed to fetch time entries'
);
if (timeEntriesResponse?.data) {
timeEntries.value = timeEntriesResponse.data;
}
}
}
async function fetchMoreTimeEntries() {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const latestTimeEntry = timeEntries.value[timeEntries.value.length - 1];
dayjs(latestTimeEntry.start).utc().format('YYYY-MM-DD');
const timeEntriesResponse = await handleApiRequestNotifications(
() =>
api.getTimeEntries({
params: {
organization: organizationId,
},
queries: {
only_full_dates: 'true',
member_id: getCurrentMembershipId(),
end: dayjs(latestTimeEntry.start).utc().format(),
},
}),
undefined,
'Failed to fetch time entries'
);
if (timeEntriesResponse?.data && timeEntriesResponse.data.length > 0) {
timeEntries.value = timeEntries.value.concat(timeEntriesResponse.data);
} else {
allTimeEntriesLoaded.value = true;
}
}
}
async function updateTimeEntries(ids: string[], changes: UpdateMultipleTimeEntriesChangeset) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.updateMultipleTimeEntries(
{
ids: ids,
changes: changes,
},
{
params: {
organization: organizationId,
},
}
),
'Time entries updated successfully',
'Failed to update time entries'
);
}
}
async function updateTimeEntry(timeEntry: TimeEntry) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const response = await handleApiRequestNotifications(
() =>
api.updateTimeEntry(timeEntry, {
params: {
organization: organizationId,
timeEntry: timeEntry.id,
},
}),
'Time entry updated successfully',
'Failed to update time entry'
);
timeEntries.value = timeEntries.value.map((entry) =>
entry.id === timeEntry.id ? response.data : entry
);
queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
}
}
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
const organizationId = getCurrentOrganizationId();
const memberId = getCurrentMembershipId();
if (organizationId && memberId !== undefined) {
const newTimeEntry = {
...timeEntry,
member_id: memberId,
} as CreateTimeEntryBody;
await handleApiRequestNotifications(
() =>
api.createTimeEntry(newTimeEntry, {
params: {
organization: organizationId,
},
}),
'Time entry created successfully',
'Failed to create time entry'
);
await fetchTimeEntries();
}
}
async function deleteTimeEntry(timeEntryId: string) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.deleteTimeEntry(undefined, {
params: {
organization: organizationId,
timeEntry: timeEntryId,
},
}),
'Time entry deleted successfully',
'Failed to delete time entry'
);
await fetchTimeEntries();
}
}
async function deleteTimeEntries(timeEntries: TimeEntry[]) {
const organizationId = getCurrentOrganizationId();
const timeEntryIds = timeEntries.map((entry) => entry.id);
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.deleteTimeEntries(undefined, {
queries: {
ids: timeEntryIds,
},
params: {
organization: organizationId,
},
}),
'Time entries deleted successfully',
'Failed to delete time entries'
);
await fetchTimeEntries();
}
}
return {
timeEntries,
fetchTimeEntries,
updateTimeEntry,
createTimeEntry,
deleteTimeEntry,
fetchMoreTimeEntries,
allTimeEntriesLoaded,
updateTimeEntries,
deleteTimeEntries,
patchTimeEntries,
};
});

View File

@@ -1,7 +1,6 @@
import defaultTheme from 'tailwindcss/defaultTheme';
import forms from '@tailwindcss/forms';
import typography from '@tailwindcss/typography';
import { solidtimeTheme } from './resources/js/packages/ui/tailwind.theme.js';
/** @type {import("tailwindcss").Config} */
export default {
@@ -17,10 +16,130 @@ export default {
],
theme: {
extend: {
...solidtimeTheme,
boxShadow: {
card: 'var(--theme-shadow-card)',
dropdown: 'var(--theme-shadow-dropdown)',
},
containers: {
'2xs': '16rem',
},
fontFamily: {
sans: ['Inter', ...defaultTheme.fontFamily.sans],
},
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.8125rem', { lineHeight: '1.125rem' }],
base: ['0.875rem', { lineHeight: '1.25rem' }],
lg: ['1rem', { lineHeight: '1.5rem' }],
xl: ['1.125rem', { lineHeight: '1.75rem' }],
'2xl': ['1.25rem', { lineHeight: '1.75rem' }],
'3xl': ['1.5rem', { lineHeight: '2rem' }],
'4xl': ['1.75rem', { lineHeight: '2.25rem' }],
'5xl': ['2rem', { lineHeight: '1' }],
'6xl': ['2.25rem', { lineHeight: '1' }],
'7xl': ['2.5rem', { lineHeight: '1' }],
'8xl': ['3rem', { lineHeight: '1' }],
'9xl': ['3.5rem', { lineHeight: '1' }],
},
colors: {
ring: 'var(--ring)',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
tertiary: 'var(--color-bg-tertiary)',
quaternary: 'var(--color-bg-quaternary)',
background: 'var(--background)',
'text-primary': 'var(--color-text-primary)',
'text-secondary': 'var(--color-text-secondary)',
'text-tertiary': 'var(--color-text-tertiary)',
'text-quaternary': 'var(--color-text-quaternary)',
'border-primary': 'var(--color-border-primary)',
'border-secondary': 'var(--color-border-secondary)',
'border-tertiary': 'var(--color-border-tertiary)',
'default-background': 'var(--theme-color-default-background)',
'default-background-separator': 'var(--theme-color-default-background-separator)',
'row-background': 'var(--theme-color-row-background)',
'card-background': 'var(--theme-color-card-background)',
'card-background-active': 'var(--theme-color-card-background-active)',
'card-background-separator': 'var(--theme-color-card-background-separator)',
'card-border': 'var(--theme-color-card-border)',
'card-border-active': 'var(--theme-color-card-border-active)',
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
'tab-background': 'var(--theme-color-tab-background)',
'tab-background-active': 'var(--theme-color-tab-background-active)',
'tab-border': 'var(--theme-color-tab-border)',
'icon-default': 'var(--theme-color-icon-default)',
'icon-active': 'var(--theme-color-icon-active)',
'menu-active': 'var(--theme-color-menu-active)',
'input-border': 'var(--theme-color-input-border)',
'input-border-active': 'var(--color-input-border-active)',
'input-background': 'var(--theme-color-input-background)',
'button-secondary-background': 'var(--theme-button-secondary-background)',
'button-secondary-background-hover':
'var(--theme-button-secondary-background-active)',
'button-secondary-border': 'var(--theme-color-card-border)',
'row-separator': 'var(--theme-color-row-separator-background)',
'row-heading-background': 'var(--theme-color-row-heading-background)',
'row-heading-border': 'var(--theme-color-row-heading-border)',
accent: {
'50': 'rgba(var(--color-accent-50), <alpha-value>)',
'100': 'rgba(var(--color-accent-100), <alpha-value>)',
'200': 'rgba(var(--color-accent-200), <alpha-value>)',
'300': 'rgba(var(--color-accent-300), <alpha-value>)',
'400': 'rgba(var(--color-accent-400), <alpha-value>)',
'500': 'rgba(var(--color-accent-500), <alpha-value>)',
'600': 'rgba(var(--color-accent-600), <alpha-value>)',
'700': 'rgba(var(--color-accent-700), <alpha-value>)',
'800': 'rgba(var(--color-accent-800), <alpha-value>)',
'900': 'rgba(var(--color-accent-900), <alpha-value>)',
'950': 'rgba(var(--color-accent-950), <alpha-value>)',
DEFAULT: 'var(--color-accent-default)',
foreground: 'var(--color-accent-foreground)',
},
'button-primary-background': 'var(--theme-color-button-primary-background)',
'button-primary-background-hover':
'var(--theme-color-button-primary-background-hover)',
'button-primary-border': 'var(--theme-color-button-primary-border)',
'button-primary-text': 'var(--theme-color-button-primary-text)',
'input-select-active': 'var(--theme-color-input-select-active)',
'input-select-active-hover': 'var(--theme-color-input-select-active-hover)',
foreground: 'var(--foreground)',
card: {
DEFAULT: 'var(--card))',
foreground: 'var(--card-foreground))',
},
popover: {
DEFAULT: 'var(--popover)',
foreground: 'var(--popover-foreground)',
border: 'var(--popover-border)',
},
destructive: {
DEFAULT: 'var(--destructive)',
foreground: 'var(--destructive-foreground)',
},
border: 'var(--border)',
input: 'var(--input)',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},

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