Compare commits

...

35 Commits

Author SHA1 Message Date
Gregor Vostrak
4b5aff20fc bump solidtime ui package version to 0.0.13 2025-11-19 17:00:11 +01:00
Gregor Vostrak
9e5aa77e41 fix display problems caused by minimum height of calendar events 2025-11-19 16:46:58 +01:00
Gregor Vostrak
0791a68283 add support for currently running time entry 2025-11-19 16:08:32 +01:00
Gregor Vostrak
e66679274d improve idle indicator colors, fix typescript issues 2025-11-19 13:37:33 +01:00
Gregor Vostrak
717fd35d76 add tooltips to idlestatus indicators 2025-11-18 13:58:30 +01:00
Gregor Vostrak
5a3a5995cc add activity status plugin to calendar 2025-11-17 14:20:04 +01:00
Gregor Vostrak
a8e6d28eab improve initial mount performance for groupedtimeentrytable by streaming in the rows
mounting the rows mounts lots of nested components which results in a delay on the initial mount.
2025-11-13 15:20:30 +01:00
Gregor Vostrak
9c9aeeab0f use container queries for time entry table 2025-11-13 12:24:28 +01:00
Gregor Vostrak
8a1253e101 make sure that CreateTimeEntry modal always starts with times that have 0 seconds 2025-11-12 18:19:27 +01:00
Gregor Vostrak
661fa25da1 prevent seconds update on timepicker when nothing else changes 2025-11-12 18:15:59 +01:00
Gregor Vostrak
d77048a7dd add tooltip component 2025-11-12 18:01:02 +01:00
Gregor Vostrak
4676af9b40 move css variables and tailwind theme config into ui package 2025-11-12 16:49:41 +01:00
Gregor Vostrak
18c8e62228 make sure that timepicker and calendar set seconds to 0 on update, fixes #968 2025-11-12 14:33:56 +01:00
Gregor Vostrak
e7703aef64 move button component to ui package 2025-11-12 14:24:54 +01:00
Gregor Vostrak
86d0497000 design fixes, improve component encapsulation 2025-11-06 14:20:12 +01:00
Gregor Vostrak
522f7d2bd2 move currency and cancreateproject permission to props to decouple TimeEntryCreateModal from web 2025-11-04 16:08:24 +01:00
Gregor Vostrak
2f807e4808 fix package build error dependencies 2025-11-04 15:48:14 +01:00
Gregor Vostrak
93d9db349b bump api and ui package versions 2025-11-04 15:15:26 +01:00
Gregor Vostrak
3417b60585 only run self-hosting update and telemetry scheduler when app_key is set 2025-11-04 13:35:12 +01:00
Constantin Graf
0f21fabd37 Spread self-hosting update and telemetry requests over the day 2025-11-03 20:24:52 +01:00
Gregor Vostrak
df00200464 load current member time entries in calendar, to be consistent with time view 2025-10-22 14:36:21 +02:00
Gregor Vostrak
3b41de7135 remove project default listener in timeentry edit modal 2025-10-22 13:55:06 +02:00
Gregor Vostrak
9fe0ea5a0f add support for HH:mm:ss format for input time fields 2025-10-22 13:54:14 +02:00
Gregor Vostrak
f8f708a664 add set end time functionality to timetracker component 2025-10-21 17:24:46 +02:00
Gregor Vostrak
c359259e45 fix TimeRangeSelector dropdown behaviour when clicking after other input was focused before 2025-10-21 13:50:30 +02:00
Gregor Vostrak
55d12aaae1 add discard option for running timer 2025-10-21 12:49:49 +02:00
Alexander Groß
9a1dd4861c Extend description to 5000 chars, closes #914 2025-10-21 12:36:32 +02:00
Gregor Vostrak
1e985b71ec move Client visibleByEmployee logic from controller to model 2025-10-21 12:22:17 +02:00
Alexander Groß
93d6a86f74 Show clients that are assigned to the employee, closes #893 2025-10-21 12:20:28 +02:00
Gregor Vostrak
19a206d57c add prevent_overlapping_time_entries setting to organization
when enabled users are blocked from creating or editing new time entries that are overlapping with other time entries
2025-10-13 14:23:41 +02:00
Gregor Vostrak
c0788c270b fix typescript openapi mapping types 2025-10-07 17:42:44 +02:00
Gregor Vostrak
7765056074 add tag grouping 2025-10-07 17:15:20 +02:00
Kaspar Rosin
639f5332e4 feat: add duplicate time entry fields 2025-10-07 17:10:22 +02:00
Gregor Vostrak
4a50145329 fix calendar header timezone issue 2025-10-06 19:30:58 +02:00
Gregor Vostrak
8aabffd1e7 fix race condition in UserTimezoneMismatchModal 2025-10-06 18:33:57 +02:00
96 changed files with 3821 additions and 1673 deletions

View File

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

View File

@@ -20,6 +20,7 @@ enum TimeEntryAggregationType: string
case Client = 'client';
case Billable = 'billable';
case Description = 'description';
case Tag = 'tag';
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
{

View File

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

View File

@@ -38,11 +38,17 @@ class ClientController extends Controller
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
{
$this->checkPermission($organization, 'clients:view');
$canViewAllClients = $this->hasPermission($organization, 'clients:view:all');
$user = $this->user();
$clientsQuery = Client::query()
->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc');
if (! $canViewAllClients) {
$clientsQuery->visibleByEmployee($user);
}
$filterArchived = $request->getFilterArchived();
if ($filterArchived === 'true') {
$clientsQuery->whereNotNull('archived_at');

View File

@@ -61,6 +61,9 @@ class OrganizationController extends Controller
if ($request->getTimeFormat() !== null) {
$organization->time_format = $request->getTimeFormat();
}
if ($request->getPreventOverlappingTimeEntries() !== null) {
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
}
$hasBillableRate = $request->has('billable_rate');
if ($hasBillableRate) {
$oldBillableRate = $organization->billable_rate;

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
@@ -45,6 +46,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\File;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
@@ -56,6 +58,43 @@ use Spatie\TemporaryDirectory\TemporaryDirectory;
class TimeEntryController extends Controller
{
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
{
if (! $organization->prevent_overlapping_time_entries) {
return;
}
$query = TimeEntry::query()
->where('organization_id', $organization->getKey())
->where('user_id', $member->user_id)
->when($exclude !== null, function (Builder $q) use ($exclude): void {
$q->where('id', '!=', $exclude->getKey());
})
->where(function (Builder $q) use ($start, $end): void {
$q->where(function (Builder $q2) use ($start): void {
$q2->where('end', '>', $start)
->where('start', '<', $start);
});
if ($end !== null) {
$q->orWhere(function (Builder $q4) use ($end): void {
$q4->where('start', '<', $end)
->where('end', '>', $end);
});
// Check if the new entry completely surrounds an existing entry
$q->orWhere(function (Builder $q6) use ($start, $end): void {
$q6->where('start', '>=', $start)
->where('end', '<=', $end);
});
}
});
if ($query->exists()) {
throw new OverlappingTimeEntryApiException;
}
}
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
{
parent::checkPermission($organization, $permission);
@@ -549,17 +588,15 @@ class TimeEntryController extends Controller
throw new TimeEntryStillRunningApiException;
}
// Overlap check for create
$start = Carbon::parse($request->input('start'));
$end = $request->input('end') !== null ? Carbon::parse($request->input('end')) : null;
$this->assertNoOverlap($organization, $member, $start, $end);
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
$client = $project?->client;
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
$timeEntry = new TimeEntry;
$timeEntry->fill($request->validated());
$timeEntry->client()->associate($client);
@@ -569,6 +606,13 @@ class TimeEntryController extends Controller
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save();
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
return new TimeEntryResource($timeEntry);
}
@@ -593,6 +637,13 @@ class TimeEntryController extends Controller
throw new TimeEntryCanNotBeRestartedApiException;
}
// Overlap check for update (exclude current)
/** @var Member $effectiveMember */
$effectiveMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : $timeEntry->member;
$effectiveStart = $request->has('start') ? Carbon::parse($request->input('start')) : $timeEntry->start;
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;

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,6 +39,9 @@ class OrganizationUpdateRequest extends BaseFormRequest
'employees_can_see_billable_rates' => [
'boolean',
],
'prevent_overlapping_time_entries' => [
'boolean',
],
'number_format' => [
Rule::enum(NumberFormat::class),
],
@@ -98,4 +101,9 @@ class OrganizationUpdateRequest extends BaseFormRequest
{
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
}
public function getPreventOverlappingTimeEntries(): ?bool
{
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
}
}

View File

@@ -79,7 +79,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'description' => [
'nullable',
'string',
'max:500',
'max:5000',
],
// List of tag IDs
'tags' => [

View File

@@ -79,7 +79,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'changes.description' => [
'nullable',
'string',
'max:500',
'max:5000',
],
// List of tag IDs
'changes.tags' => [

View File

@@ -77,7 +77,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'description' => [
'nullable',
'string',
'max:500',
'max:5000',
],
// List of tag IDs
'tags' => [

View File

@@ -53,6 +53,8 @@ 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 $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency,
/** @var string $currency_symbol Currency symbol */

View File

@@ -7,6 +7,7 @@ namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\ClientFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -62,6 +63,18 @@ class Client extends Model implements AuditableContract
return $this->hasMany(Project::class, 'client_id');
}
/**
* @param Builder<Client> $builder
* @return Builder<Client>
*/
public function scopeVisibleByEmployee(Builder $builder, User $user): Builder
{
return $builder->whereHas('projects', function (Builder $builder) use ($user): Builder {
/** @var Builder<Project> $builder */
return $builder->visibleByEmployee($user);
});
}
/**
* @return Attribute<bool, never>
*/

View File

@@ -70,6 +70,7 @@ class Organization extends JetstreamTeam implements AuditableContract
'personal_team' => 'boolean',
'currency' => 'string',
'employees_can_see_billable_rates' => 'boolean',
'prevent_overlapping_time_entries' => 'boolean',
'number_format' => NumberFormat::class,
'currency_format' => CurrencyFormat::class,
'date_format' => DateFormat::class,

View File

@@ -109,6 +109,7 @@ class JetstreamServiceProvider extends ServiceProvider
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
@@ -172,6 +173,7 @@ class JetstreamServiceProvider extends ServiceProvider
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
@@ -232,6 +234,7 @@ class JetstreamServiceProvider extends ServiceProvider
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
@@ -256,12 +259,13 @@ class JetstreamServiceProvider extends ServiceProvider
'projects:view',
'tags:view',
'tasks:view',
'clients:view',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'organizations:view',
])->description('Employees have the ability to read, create, and update their own time entries and they can see the projects that they are members of.');
])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');

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

View File

@@ -10,6 +10,7 @@ use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
@@ -17,6 +18,7 @@ use Carbon\CarbonTimeZone;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TimeEntryAggregationService
@@ -45,9 +47,21 @@ class TimeEntryAggregationService
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
{
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
/** @var Builder<TimeEntry> $baseTotalsQuery */
$baseTotalsQuery = $timeEntriesQuery->clone();
$group1Select = null;
$group2Select = null;
$groupBy = null;
// If any grouping is by tag, expand rows per tag and ensure a NULL row for entries without tags
if (($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag)) {
$timeEntriesQuery->crossJoin(DB::raw(
"LATERAL (\n".
" SELECT jsonb_array_elements_text(coalesce(tags, '[]'::jsonb)) AS tag\n".
" UNION ALL\n".
" SELECT ''::text AS tag WHERE coalesce(jsonb_array_length(tags), 0) = 0\n".
') AS tag(tag)'
));
}
if ($group1Type !== null) {
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
$groupBy = ['group_1'];
@@ -84,6 +98,26 @@ class TimeEntryAggregationService
$group1Response = [];
$group1ResponseSum = 0;
$group1ResponseCost = 0;
// If Tag is subgroup, prepare base totals per primary group without tag expansion
$baseTotalsPerGroup1Map = [];
if ($group2Type === TimeEntryAggregationType::Tag) {
$baseTotalsPerGroup1Query = $baseTotalsQuery->clone();
$baseTotalsPerGroup1 = $baseTotalsPerGroup1Query
->selectRaw(
$group1Select.' as group_1,'.
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
)
->groupBy('group_1')
->get();
foreach ($baseTotalsPerGroup1 as $row) {
/** @var object{group_1: mixed, aggregate: int|null, cost: int|null} $row */
$baseTotalsPerGroup1Map[(string) ($row->group_1 ?? '')] = [
'aggregate' => (int) ($row->aggregate ?? 0),
'cost' => (int) ($row->cost ?? 0),
];
}
}
foreach ($groupedAggregates as $group1 => $group1Aggregates) {
/** @var string|int $group1 */
$group2Response = [];
@@ -103,6 +137,14 @@ class TimeEntryAggregationService
$group2ResponseSum += (int) $aggregate->get(0)->aggregate;
$group2ResponseCost += (int) $aggregate->get(0)->cost;
}
// Override primary group totals when Tag is subgroup to avoid double counting
if ($group2Type === TimeEntryAggregationType::Tag) {
$keyForMap = (string) $group1;
if (array_key_exists($keyForMap, $baseTotalsPerGroup1Map)) {
$group2ResponseSum = $baseTotalsPerGroup1Map[$keyForMap]['aggregate'];
$group2ResponseCost = $baseTotalsPerGroup1Map[$keyForMap]['cost'];
}
}
} else {
/** @var Collection<int, object{aggregate: int, cost: int}> $group1Aggregates */
$group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate;
@@ -121,6 +163,23 @@ class TimeEntryAggregationService
$group1ResponseCost += $group2ResponseCost;
}
// If Tag is selected in any grouping, compute overall totals from base (non-tag-expanded) query to avoid double counting
$hasTagGrouping = ($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag);
if ($hasTagGrouping) {
// Reset selects and ordering on the cloned base query
$baseTotals = $baseTotalsQuery
->selectRaw(
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
)
->first();
if ($baseTotals !== null) {
/** @var object{aggregate: int|null, cost: int|null} $baseTotals */
$group1ResponseSum = (int) ($baseTotals->aggregate ?? 0);
$group1ResponseCost = (int) ($baseTotals->cost ?? 0);
}
}
if ($fillGapsInTimeGroupsIsPossible) {
$group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end);
}
@@ -294,6 +353,17 @@ class TimeEntryAggregationService
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Tag) {
$tags = Tag::query()
->whereIn('id', $keys)
->select('id', 'name')
->get();
foreach ($tags as $tag) {
$descriptorMap[$tag->id] = [
'description' => $tag->name,
'color' => null,
];
}
}
return $descriptorMap;
@@ -436,6 +506,8 @@ class TimeEntryAggregationService
return 'billable';
} elseif ($group === TimeEntryAggregationType::Description) {
return 'description';
} elseif ($group === TimeEntryAggregationType::Tag) {
return 'tag';
}
}

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,10 @@ async function goToOrganizationSettings(page) {
async function createTimeEntry(page, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page.getByTestId('time_entry_description').fill('Test time entry');

View File

@@ -26,7 +26,10 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
// Then create the time entry
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page
@@ -52,7 +55,10 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page
@@ -81,7 +87,10 @@ async function createTimeEntryWithBillableStatus(
duration: string
) {
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page

View File

@@ -14,6 +14,7 @@ use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
@@ -47,6 +48,7 @@ return [
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.',
],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
];

View File

@@ -203,6 +203,7 @@ return [
'organization' => 'The :attribute does not exist.',
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
'project_name_already_exists' => 'A project with the same name and client already exists in the organization.',
'overlapping_time_entry' => 'Overlapping time entries are not allowed.',
'tag_name_already_exists' => 'A tag with the same name already exists in the organization.',
'client_name_already_exists' => 'A client with the same name already exists in the organization.',
'task_name_already_exists' => 'A task with the same name already exists in the project.',

View File

@@ -1,240 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root.dark {
--color-bg-primary: #101012;
--color-bg-secondary: #17181B;
--color-bg-tertiary: #2A2C32;
--color-bg-quaternary: #141518;
--color-bg-background: #090909;
--color-text-primary: #ffffff;
--color-text-secondary: #e3e4e6;
--color-text-tertiary: #969799;
--color-text-quaternary: #595a5c;
/* Import shared solidtime styles from UI package */
@import '../js/packages/ui/styles.css';
--color-border-primary: #191b1f;
--color-border-secondary: #23252a;
--color-border-tertiary: #2c2e33;
--color-border-quaternary: #393B42;
--color-input-border-active: rgba(255,255,255,0.3);
--theme-color-chart: var(--color-accent-200);
--theme-color-menu-active: var(--color-bg-secondary);
--theme-color-card-background: var(--color-bg-secondary);
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
--theme-color-card-background-active: var(--color-bg-tertiary);
--theme-color-row-background: var(--color-bg-primary);
--theme-color-row-heading-background: var(--theme-color-card-background);
--theme-color-row-heading-border: var(--theme-color-card-border);
--theme-color-icon-default: var(--color-text-tertiary);
--theme-color-ring: rgba(255,255,255,0.5);
--theme-color-button-primary-background: rgba(var(--color-accent-300), 0.1);
--theme-color-button-primary-background-hover: rgba(var(--color-accent-300), 0.2);
--theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);
--theme-color-button-primary-text: var(--color-text-primary);
--theme-color-input-background: var(--color-bg-secondary);
--theme-color-input-select-active: rgb(var(--color-accent-300));
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
--color-accent-default: rgba(var(--color-accent-300), 0.2);
--color-accent-foreground: rgb(var(--color-accent-100));
--theme-color-default-background: var(--color-bg-primary);
}
:root.light {
--color-bg-primary: #FFFFFF;
--color-bg-secondary: #f7f7f8;
--color-bg-tertiary: #eeeeef;
--color-bg-quaternary: #e1e1e3;
--color-bg-background: #F5F5F5;
--color-text-primary: #18181b;
--color-text-secondary: #3f3f46;
--color-text-tertiary: #57575C;
--color-text-quaternary: #a1a1aa;
--color-border-primary: #e7e7e7;
--color-border-secondary: #e5e5e5;
--color-border-tertiary: #dfdfdf;
--color-border-quaternary: #d1d1d1;
--color-input-border-active: rgba(0,0,0,0.3);
--theme-color-menu-active: var(--color-bg-quaternary);
--theme-color-card-background: var(--color-bg-primary);
--theme-color-card-background-active: var(--color-bg-tertiary);
--theme-color-chart: var(--color-accent-400);
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--theme-color-row-background: var(--theme-color-card-background);
--theme-color-row-heading-background: var(--color-bg-secondary);
--theme-color-row-heading-border: var(--color-border-tertiary);
--theme-color-icon-default: var(--color-text-quaternary);
--theme-color-ring: rgba(0,0,0, 0.7);
--theme-color-button-primary-background: rgba(var(--color-accent-600), 0.9);
--theme-color-button-primary-background-hover: rgba(var(--color-accent-600), 1);
--theme-color-button-primary-border: rgba(var(--color-accent-600), 1);
--theme-color-button-primary-text: #FFFFFF;
--theme-color-input-background: var(--color-bg-primary);
--theme-color-input-select-active: rgb(var(--color-accent-400));
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
--color-accent-default: rgb(var(--color-accent-100));
--color-accent-foreground: rgb(var(--color-accent-800));
--theme-color-default-background: #FCFCFC;
}
:root {
--theme-color-icon-active: rgb(var(--color-text-tertiary));
--theme-color-card-background-separator: var(--color-border-tertiary);
--theme-color-card-border: var(--color-border-secondary);
--theme-color-card-border-active: var(--color-border-tertiary);
--theme-color-default-background-separator: var(--color-border-primary);
--theme-color-primary-text: var(--color-text-primary);
--theme-color-input-border: var(--color-border-quaternary);
--theme-color-tab-background: var(--theme-color-card-background);
--theme-color-tab-background-active: var(--theme-color-card-background-active);
--theme-color-tab-border: var(--theme-color-card-border);
--theme-color-row-separator-background: var(--theme-color-default-background-separator);
--theme-color-row-border: var(--theme-color-card-border);
--color-accent-50: 240, 249, 255; /* sky-50 */
--color-accent-100: 224, 242, 254; /* sky-100 */
--color-accent-200: 186, 230, 253; /* sky-200 */
--color-accent-300: 125, 211, 252; /* sky-300 */
--color-accent-400: 56, 189, 248; /* sky-400 */
--color-accent-500: 14, 165, 233; /* sky-500 */
--color-accent-600: 2, 132, 199; /* sky-600 */
--color-accent-700: 3, 105, 161; /* sky-700 */
--color-accent-800: 7, 89, 133; /* sky-800 */
--color-accent-900: 12, 74, 110; /* sky-900 */
--color-accent-950: 8, 47, 73; /* sky-950 */
--theme-button-secondary-background: var(--theme-color-card-background);
--theme-button-secondary-background-active: var(--theme-color-card-background-active);
--popover-border: var(--color-border-secondary);
}
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* width */
::-webkit-scrollbar {
width: 5px;
}
/* Track */
::-webkit-scrollbar-track, ::-webkit-scrollbar-corner {
background: transparent;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 2px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #555;
}
[x-cloak] {
display: none;
}
body {
background-color: var(--theme-color-default-background);
}
/* Inter Variable Font with browser compatibility considerations */
/* Main app specific styles - Inter font */
@font-face {
font-family: 'Inter';
src: url('/fonts/InterVariable.woff2') format('woff2'),
url('/fonts/InterVariable.ttf') format('truetype');
src:
url('/fonts/InterVariable.woff2') format('woff2'),
url('/fonts/InterVariable.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
@layer base {
:root {
--background: var(--color-bg-background);
--foreground: var(--color-text-primary);
--card: var(--theme-color-card-background);
--card-foreground: var(--color-text-primary);
--popover: var(--theme-color-card-background);
--popover-foreground: var(--color-text-primary);
--primary: var(--theme-color-button-primary-background);
--primary-foreground: var(--theme-color-button-primary-text);
--secondary: var(--color-bg-secondary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-bg-tertiary);
--muted-foreground: var(--color-text-tertiary);
--accent: var(--theme-color-button-primary-background);
--accent-foreground: var(--theme-color-button-primary-text);
--destructive: 0 84.2% 60.2%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
--input: var(--color-border-tertiary);
--ring: var(--theme-color-ring);
--chart-1: var(--color-accent-400);
--chart-2: var(--color-accent-500);
--chart-3: var(--color-accent-600);
--chart-4: var(--color-accent-700);
--chart-5: var(--color-accent-800);
--radius: 0.5rem;
}
.dark {
--background: var(--color-bg-background);
--foreground: var(--color-text-primary);
--card: var(--theme-color-card-background);
--card-foreground: var(--color-text-primary);
--popover: var(--theme-color-card-background);
--popover-foreground: var(--color-text-primary);
--primary: var(--theme-color-button-primary-background);
--primary-foreground: var(--theme-color-button-primary-text);
--secondary: var(--color-bg-secondary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-bg-tertiary);
--muted-foreground: var(--color-text-tertiary);
--accent: var(--theme-color-button-primary-background);
--accent-foreground: var(--theme-color-button-primary-text);
--destructive: 0 62.8% 30.6%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
--input: var(--color-border-tertiary);
--ring: var(--theme-color-ring);
--chart-1: var(--color-accent-200);
--chart-2: var(--color-accent-300);
--chart-3: var(--color-accent-400);
--chart-4: var(--color-accent-500);
--chart-5: var(--color-accent-600);
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -113,7 +113,7 @@ const option = computed(() => ({
},
axisLabel: {
fontSize: 12,
fontWeight: 600,
fontWeight: 400,
color: labelColor.value,
margin: 16,
fontFamily: 'Inter, sans-serif',

View File

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

View File

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

View File

@@ -30,10 +30,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
<template>
<div
class="contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
<div
:class="
twMerge('pl-6 font-medium flex items-center space-x-3', props.indent ? 'pl-16' : '')
">
<div :class="twMerge('pl-6 flex items-center space-x-3', props.indent ? 'pl-16' : '')">
<GroupedItemsCountButton
v-if="entry.grouped_data && entry.grouped_data?.length > 0"
:expanded="expanded"

View File

@@ -27,9 +27,10 @@ onMounted(() => {
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
userTimezone.value = getUserTimezone();
const now = getDayJsInstance()();
if (
getDayJsInstance()().tz(timezone.value).format() !==
getDayJsInstance()().tz(userTimezone.value).format() &&
now.tz(timezone.value).format() !== now.tz(userTimezone.value).format() &&
!hideTimezoneMismatchModal.value
) {
show.value = true;

View File

@@ -10,7 +10,8 @@ defineProps<{
<div class="px-4 py-2 2xl:py-3 border-b border-b-background-separator">
<div class="col-span-2">
<div class="flex justify-between">
<p class="font-semibold text-sm text-text-primary">
<p
class="font-semibold text-sm min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
{{ name }}
</p>
<div v-if="working" class="flex space-x-1.5 items-center justify-end">

View File

@@ -16,12 +16,25 @@ import { useProjectsStore } from '@/utils/useProjects';
import { useTasksStore } from '@/utils/useTasks';
import { useTagsStore } from '@/utils/useTags';
import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
import type {
CreateClientBody,
CreateProjectBody,
CreateTimeEntryBody,
Project,
Tag,
} from '@/packages/api/src';
import TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
import TimeTrackerMoreOptionsDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
import { useClientsStore } from '@/utils/useClients';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
import { ref } from 'vue';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { useNotificationsStore } from '@/utils/notification';
const page = usePage<{
auth: {
@@ -47,6 +60,8 @@ const emit = defineEmits<{
change: [];
}>();
const showManualTimeEntryModal = ref(false);
watch(isActive, () => {
if (isActive.value) {
startLiveTimer();
@@ -93,14 +108,73 @@ function switchToTimeEntryOrganization() {
switchOrganization(currentTimeEntry.value.organization_id);
}
}
async function createTag(tag: string) {
async function createTag(tag: string): Promise<Tag | undefined> {
return await useTagsStore().createTag(tag);
}
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
await useTimeEntriesStore().createTimeEntry(timeEntry);
showManualTimeEntryModal.value = false;
}
async function createTimeEntryFromCurrentEntry() {
const { start, end, description, project_id, task_id, billable, tags } = currentTimeEntry.value;
await createTimeEntry({ start, end, description, project_id, task_id, billable, tags });
currentTimeEntryStore.$reset();
}
const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
const deleteTimeEntryMutation = useMutation({
mutationFn: async (timeEntryId: string) => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) {
throw new Error('No organization selected');
}
return await api.deleteTimeEntry(undefined, {
params: {
organization: organizationId,
timeEntry: timeEntryId,
},
});
},
onSuccess: async () => {
await currentTimeEntryStore.fetchCurrentTimeEntry();
await useTimeEntriesStore().fetchTimeEntries();
queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
},
});
async function discardCurrentTimeEntry() {
if (currentTimeEntry.value.id) {
await handleApiRequestNotifications(
() => deleteTimeEntryMutation.mutateAsync(currentTimeEntry.value.id),
'Time entry discarded successfully',
'Failed to discard time entry'
);
}
}
const { tags } = storeToRefs(useTagsStore());
const { timeEntries } = storeToRefs(useTimeEntriesStore());
</script>
<template>
<TimeEntryCreateModal
v-model:show="showManualTimeEntryModal"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:create-project="createProject"
:create-client="createClient"
:create-tag="createTag"
:create-time-entry="createTimeEntry"
:currency="getOrganizationCurrencyString()"
:can-create-project="canCreateProjects()"
:projects
:tasks
:tags
:clients></TimeEntryCreateModal>
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
<div class="relative">
<TimeTrackerRunningInDifferentOrganizationOverlay
@@ -109,24 +183,36 @@ const { tags } = storeToRefs(useTagsStore());
switchToTimeEntryOrganization
"></TimeTrackerRunningInDifferentOrganizationOverlay>
<TimeTrackerControls
v-model:current-time-entry="currentTimeEntry"
v-model:live-timer="now"
:create-project
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:can-create-project="canCreateProjects()"
:create-client
:clients
:tags
:tasks
:projects
:create-tag
:is-active
:currency="getOrganizationCurrencyString()"
@start-live-timer="startLiveTimer"
@stop-live-timer="stopLiveTimer"
@start-timer="setActiveState(true)"
@stop-timer="setActiveState(false)"
@update-time-entry="updateTimeEntry"></TimeTrackerControls>
<div class="flex w-full items-center gap-2">
<div class="flex w-full items-center gap-2">
<div class="flex-1">
<TimeTrackerControls
v-model:current-time-entry="currentTimeEntry"
v-model:live-timer="now"
:create-project
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:can-create-project="canCreateProjects()"
:create-client
:clients
:tags
:tasks
:projects
:time-entries
:create-tag
:is-active
:currency="getOrganizationCurrencyString()"
@start-live-timer="startLiveTimer"
@stop-live-timer="stopLiveTimer"
@start-timer="setActiveState(true)"
@stop-timer="setActiveState(false)"
@update-time-entry="updateTimeEntry"
@create-time-entry="createTimeEntryFromCurrentEntry"></TimeTrackerControls>
</div>
<TimeTrackerMoreOptionsDropdown
:has-active-timer="isActive"
@manual-entry="showManualTimeEntryModal = true"
@discard="discardCurrentTimeEntry"></TimeTrackerMoreOptionsDropdown>
</div>
</div>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup>
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/Components/ui/button';
import { cn, buttonVariants } from '@/packages/ui/src';
import { ChevronLeft } from 'lucide-vue-next';
import { 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 '@/Components/ui/button/Button.vue';
import { Button } from '@/packages/ui/src';
import { openFeedback } from '@/utils/feedback';
defineProps({

View File

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

View File

@@ -400,6 +400,7 @@ async function downloadExport(format: ExportFormat) {
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
:delete-time-entry="() => deleteTimeEntries([entry])"
:currency="getOrganizationCurrencyString()"
:duplicate-time-entry="() => createTimeEntry(entry)"
:members="members"
show-date
show-member

View File

@@ -27,7 +27,7 @@ interface FormValues {
}
const store = useOrganizationStore();
const { fetchOrganization, updateOrganization } = store;
const { updateOrganization } = store;
const { organization } = storeToRefs(store);
const queryClient = useQueryClient();
@@ -47,7 +47,6 @@ const mutation = useMutation({
});
onMounted(async () => {
await fetchOrganization();
if (organization.value) {
form.value = {
number_format: organization.value.number_format as NumberFormat,

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { Checkbox } from '@/packages/ui/src';
import type { UpdateOrganizationBody } from '@/packages/api/src';
import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
const store = useOrganizationStore();
const { updateOrganization } = store;
const { organization } = storeToRefs(store);
const queryClient = useQueryClient();
const form = ref<{ prevent_overlapping_time_entries: boolean }>({
prevent_overlapping_time_entries: false,
});
onMounted(async () => {
form.value.prevent_overlapping_time_entries =
organization.value?.prevent_overlapping_time_entries ?? false;
});
const mutation = useMutation({
mutationFn: (values: Partial<UpdateOrganizationBody>) => updateOrganization(values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organization'] });
},
});
async function submit() {
await mutation.mutateAsync({
prevent_overlapping_time_entries: form.value.prevent_overlapping_time_entries,
});
}
</script>
<template>
<FormSection>
<template #title>Time Entry Settings</template>
<template #description>
Disallow overlapping time entries for members of this organization. When enabled, users
cannot create new time entries that overlap with their existing ones. This only affects
newly created entries.
</template>
<template #form>
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4">
<div class="flex items-center space-x-2">
<Checkbox
id="preventOverlappingTimeEntries"
v-model:checked="form.prevent_overlapping_time_entries" />
<InputLabel
for="preventOverlappingTimeEntries"
value="Prevent overlapping time entries (new entries only)" />
</div>
</div>
</div>
</template>
<template #actions>
<PrimaryButton :disabled="mutation.isPending.value" @click="submit">Save</PrimaryButton>
</template>
</FormSection>
</template>

View File

@@ -8,12 +8,25 @@ import type { Permissions, Role } from '@/types/jetstream';
import { canUpdateOrganization } from '@/utils/permissions';
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
import { onMounted, ref } from 'vue';
import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia';
defineProps<{
team: Organization;
availableRoles: Role[];
permissions: Permissions;
}>();
const loading = ref(true);
const orgStore = useOrganizationStore();
const { organization } = storeToRefs(orgStore);
onMounted(async () => {
await orgStore.fetchOrganization();
loading.value = false;
});
</script>
<template>
@@ -26,17 +39,25 @@ defineProps<{
<div>
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
<UpdateTeamNameForm :team="team" :permissions="permissions" />
<div v-if="loading || !organization" class="py-16 text-center text-text-secondary">
Loading organization settings...
</div>
<template v-else>
<UpdateTeamNameForm :team="team" :permissions="permissions" />
<SectionBorder />
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
<SectionBorder />
<SectionBorder />
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
<SectionBorder />
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
<SectionBorder />
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
<SectionBorder />
<template v-if="permissions.canDeleteTeam && !team.personal_team">
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
<OrganizationTimeEntrySettings v-if="canUpdateOrganization()" />
<SectionBorder />
<template v-if="permissions.canDeleteTeam && !team.personal_team">
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
</template>
</template>
</div>
</div>

View File

@@ -15,8 +15,6 @@ import type {
} from '@/packages/api/src';
import { useElementVisibility } from '@vueuse/core';
import { ClockIcon } from '@heroicons/vue/20/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { useTasksStore } from '@/utils/useTasks';
@@ -24,7 +22,6 @@ import { useProjectsStore } from '@/utils/useProjects';
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
import { useTagsStore } from '@/utils/useTags';
import { useClientsStore } from '@/utils/useClients';
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
@@ -73,7 +70,6 @@ onMounted(async () => {
await timeEntriesStore.fetchTimeEntries();
});
const showManualTimeEntryModal = ref(false);
const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
@@ -105,33 +101,9 @@ function deleteSelected() {
</script>
<template>
<TimeEntryCreateModal
v-model:show="showManualTimeEntryModal"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:create-project="createProject"
:create-client="createClient"
:create-tag="createTag"
:create-time-entry="createTimeEntry"
:projects
:tasks
:tags
:clients></TimeEntryCreateModal>
<AppLayout title="Dashboard" data-testid="time_view">
<MainContainer class="pt-5 lg:pt-8 pb-4 lg:pb-6">
<div
class="lg:flex items-end lg:divide-x divide-default-background-separator divide-y lg:divide-y-0 space-y-2 lg:space-y-0 lg:space-x-2">
<div class="flex-1">
<TimeTracker></TimeTracker>
</div>
<div class="pb-2 pt-2 lg:pt-0 lg:pl-4 flex justify-center">
<SecondaryButton
class="w-full text-center flex justify-center"
:icon="PlusIcon"
@click="showManualTimeEntryModal = true"
>Manual time entry
</SecondaryButton>
</div>
</div>
<TimeTracker></TimeTracker>
</MainContainer>
<TimeEntryMassActionRow
:selected-time-entries="selectedTimeEntries"

View File

@@ -1,12 +1,12 @@
{
"name": "@solidtime/api",
"version": "0.0.4",
"version": "0.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@solidtime/api",
"version": "0.0.3",
"version": "0.0.5",
"license": "AGPL-3.0",
"dependencies": {
"@zodios/core": "^10.9.6",

View File

@@ -1,6 +1,6 @@
{
"name": "@solidtime/api",
"version": "0.0.4",
"version": "0.0.5",
"description": "Package containing the solidtime api client and type declarations",
"main": "./dist/solidtime-api.umd.cjs",
"module": "./dist/solidtime-api.js",

View File

@@ -36,20 +36,14 @@ const ClientResource = z
const ClientCollection = z.array(ClientResource);
const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
const ClientUpdateRequest = z
.object({
name: z.string().min(1).max(255),
is_archived: z.boolean().optional(),
})
.object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })
.passthrough();
const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();
const InvitationResource = z
.object({ id: z.string(), email: z.string(), role: z.string() })
.passthrough();
const InvitationStoreRequest = z
.object({
email: z.string().email(),
role: z.enum(['admin', 'manager', 'employee']),
})
.object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) })
.passthrough();
const InvoiceResource = z
.object({
@@ -97,6 +91,7 @@ const InvoiceStoreRequest = z
billing_period_end: z.union([z.string(), z.null()]).optional(),
reference: z.string(),
currency: z.string(),
payment_iban: z.union([z.string(), z.null()]).optional(),
tax_rate: z.number().int().gte(0).lte(2147483647).optional(),
discount_amount: z.number().int().gte(0).lte(9223372036854776000).optional(),
discount_type: InvoiceDiscountType.optional(),
@@ -161,6 +156,7 @@ const DetailedInvoiceResource = z
discount_type: z.string(),
discount_amount: z.number().int(),
tax_rate: z.number().int(),
payment_iban: z.string(),
status: z.string(),
currency: z.string(),
date: z.string(),
@@ -206,6 +202,7 @@ const InvoiceUpdateRequest = z
billing_period_end: z.union([z.string(), z.null()]),
reference: z.string(),
currency: z.string(),
payment_iban: z.union([z.string(), z.null()]),
tax_rate: z.number().int().gte(0).lte(2147483647),
discount_amount: z.number().int().gte(0).lte(9223372036854776000),
discount_type: InvoiceDiscountType,
@@ -320,6 +317,7 @@ const OrganizationResource = z
is_personal: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
prevent_overlapping_time_entries: z.boolean(),
currency: z.string(),
currency_symbol: z.string(),
number_format: NumberFormat,
@@ -334,6 +332,7 @@ const OrganizationUpdateRequest = z
name: z.string().max(255),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
prevent_overlapping_time_entries: z.boolean(),
number_format: NumberFormat,
currency_format: CurrencyFormat,
date_format: DateFormat,
@@ -388,10 +387,7 @@ const ProjectMemberResource = z
})
.passthrough();
const ProjectMemberStoreRequest = z
.object({
member_id: z.string(),
billable_rate: z.union([z.number(), z.null()]).optional(),
})
.object({ member_id: z.string(), billable_rate: z.union([z.number(), z.null()]).optional() })
.passthrough();
const ProjectMemberUpdateRequest = z
.object({ billable_rate: z.union([z.number(), z.null()]) })
@@ -420,6 +416,7 @@ const TimeEntryAggregationType = z.enum([
'client',
'billable',
'description',
'tag',
]);
const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);
const Weekday = z.enum([
@@ -431,6 +428,7 @@ const Weekday = z.enum([
'saturday',
'sunday',
]);
const TimeEntryRoundingType = z.enum(['up', 'down', 'nearest']);
const ReportStoreRequest = z
.object({
name: z.string().max(255),
@@ -453,6 +451,8 @@ const ReportStoreRequest = z
history_group: TimeEntryAggregationTypeInterval,
week_start: Weekday.optional(),
timezone: z.union([z.string(), z.null()]).optional(),
rounding_type: TimeEntryRoundingType.optional(),
rounding_minutes: z.union([z.number(), z.null()]).optional(),
})
.passthrough(),
})
@@ -479,6 +479,8 @@ const DetailedReportResource = z
project_ids: z.union([z.array(z.string()), z.null()]),
tag_ids: z.union([z.array(z.string()), z.null()]),
task_ids: z.union([z.array(z.string()), z.null()]),
rounding_type: z.union([z.string(), z.null()]),
rounding_minutes: z.union([z.number(), z.null()]),
})
.passthrough(),
created_at: z.string(),
@@ -592,12 +594,7 @@ const DetailedWithDataReportResource = z
})
.passthrough();
const TagResource = z
.object({
id: z.string(),
name: z.string(),
created_at: z.string(),
updated_at: z.string(),
})
.object({ id: z.string(), name: z.string(), created_at: z.string(), updated_at: z.string() })
.passthrough();
const TagCollection = z.array(TagResource);
const TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
@@ -629,6 +626,7 @@ const TaskUpdateRequest = z
})
.passthrough();
const start = z.union([z.string(), z.null()]).optional();
const rounding_minutes = z.union([z.number(), z.null()]).optional();
const TimeEntryResource = z
.object({
id: z.string(),
@@ -749,6 +747,7 @@ export const schemas = {
TimeEntryAggregationType,
TimeEntryAggregationTypeInterval,
Weekday,
TimeEntryRoundingType,
ReportStoreRequest,
DetailedReportResource,
ReportUpdateRequest,
@@ -761,6 +760,7 @@ export const schemas = {
TaskStoreRequest,
TaskUpdateRequest,
start,
rounding_minutes,
TimeEntryResource,
TimeEntryStoreRequest,
TimeEntryUpdateMultipleRequest,
@@ -790,13 +790,7 @@ const endpoints = makeApi([
alias: 'getCurrencies',
requestFormat: 'json',
response: z.array(
z
.object({
code: z.string(),
name: z.string(),
symbol: z.string(),
})
.passthrough()
z.object({ code: z.string(), name: z.string(), symbol: z.string() }).passthrough()
),
},
{
@@ -868,10 +862,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -1166,13 +1157,7 @@ const endpoints = makeApi([
},
],
response: z.array(
z
.object({
value: z.number().int(),
name: z.string(),
color: z.string(),
})
.passthrough()
z.object({ value: z.number().int(), name: z.string(), color: z.string() }).passthrough()
),
errors: [
{
@@ -1235,10 +1220,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -1281,10 +1263,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -1332,10 +1311,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -1363,11 +1339,7 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -1405,11 +1377,7 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -1465,7 +1433,7 @@ const endpoints = makeApi([
status: 400,
schema: z.union([
z.object({ message: z.string() }).passthrough(),
z.object({ message: z.string() }).passthrough(),
z.object({ message: z.literal('Invalid base64 encoded data') }).passthrough(),
]),
},
{
@@ -1487,10 +1455,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -1511,11 +1476,7 @@ const endpoints = makeApi([
.object({
data: z.array(
z
.object({
key: z.string(),
name: z.string(),
description: z.string(),
})
.object({ key: z.string(), name: z.string(), description: z.string() })
.passthrough()
),
})
@@ -1603,10 +1564,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -1634,11 +1592,7 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -1660,10 +1614,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -1809,10 +1760,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -1855,10 +1803,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -1901,10 +1846,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -1988,10 +1930,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -2056,6 +1995,13 @@ const endpoints = makeApi([
],
response: z.object({ download_link: z.string() }).passthrough(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
@@ -2075,10 +2021,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -2102,6 +2045,13 @@ const endpoints = makeApi([
],
response: z.object({ download_link: z.string() }).passthrough(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
@@ -2147,11 +2097,7 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -2173,10 +2119,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -2246,10 +2189,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -2282,11 +2222,7 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -2308,10 +2244,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -2344,11 +2277,7 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -2370,10 +2299,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -2401,11 +2327,7 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -2448,11 +2370,7 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -2515,10 +2433,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -2634,10 +2549,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -2680,10 +2592,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -2767,10 +2676,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -2798,11 +2704,7 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -2918,11 +2820,7 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -2944,10 +2842,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -3053,10 +2948,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -3140,10 +3032,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -3253,10 +3142,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -3304,10 +3190,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -3335,11 +3218,7 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -3434,10 +3313,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -3480,10 +3356,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -3531,10 +3404,7 @@ const endpoints = makeApi([
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -3562,11 +3432,7 @@ const endpoints = makeApi([
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -3639,6 +3505,16 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{
name: 'user_id',
type: 'Query',
@@ -3696,10 +3572,7 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -3727,11 +3600,7 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -3753,10 +3622,7 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -3799,10 +3665,7 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -3845,10 +3708,7 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -3881,11 +3741,7 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -3907,10 +3763,7 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -3980,6 +3833,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client',
'billable',
'description',
'tag',
])
.optional(),
},
@@ -3998,6 +3852,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client',
'billable',
'description',
'tag',
])
.optional(),
},
@@ -4036,6 +3891,16 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{
name: 'member_ids',
type: 'Query',
@@ -4120,10 +3985,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -4158,6 +4020,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client',
'billable',
'description',
'tag',
]),
},
{
@@ -4174,6 +4037,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client',
'billable',
'description',
'tag',
]),
},
{
@@ -4221,6 +4085,16 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{
name: 'member_ids',
type: 'Query',
@@ -4256,11 +4130,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -4282,10 +4152,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -4346,6 +4213,16 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{
name: 'member_ids',
type: 'Query',
@@ -4376,11 +4253,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -4402,10 +4275,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -4487,11 +4357,7 @@ Please note that the access token is only shown in this response and cannot be r
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -4508,10 +4374,7 @@ Please note that the access token is only shown in this response and cannot be r
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
@@ -4534,11 +4397,7 @@ Please note that the access token is only shown in this response and cannot be r
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
@@ -4576,11 +4435,7 @@ Please note that the access token is only shown in this response and cannot be r
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@solidtime/ui",
"version": "0.0.10",
"version": "0.0.13",
"description": "Package containing the solidtime ui components",
"main": "./dist/solidtime-ui-lib.umd.cjs",
"module": "./dist/solidtime-ui-lib.js",
@@ -21,11 +21,14 @@
"default": "./dist/solidtime-ui-lib.umd.cjs"
}
},
"./style.css": "./dist/style.css"
"./style.css": "./dist/style.css",
"./styles.css": "./styles.css",
"./tailwind.theme.js": "./tailwind.theme.js"
},
"scripts": {
"dev": "vite",
"build": "vite build && vue-tsc --emitDeclarationOnly",
"watch": "vite build --watch",
"types": "vue-tsc ",
"preview": "vite preview"
},
@@ -59,8 +62,11 @@
"@heroicons/vue": "^2.1.5",
"@vueuse/core": "^12.5.0",
"@zodios/core": "^10.9.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"parse-duration": "^2.0.1",
"reka-ui": "^2.2.0",
"tailwind-merge": "^2.5.2",
"tailwindcss": "^3.1.0",
"vue": "^3.5.0",

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,17 @@ import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';
import { computed, ref, watch, inject, type ComputedRef } from 'vue';
import {
computed,
ref,
watch,
inject,
type ComputedRef,
nextTick,
onMounted,
onActivated,
onUnmounted,
} from 'vue';
import chroma from 'chroma-js';
import { useCssVariable } from '@/utils/useCssVariable';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
@@ -12,6 +22,10 @@ import { getUserTimezone, getWeekStart } from '../utils/settings';
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
import FullCalendarEventContent from './FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
import activityStatusPlugin, {
type ActivityPeriod,
renderActivityStatusBoxes,
} from './idleStatusPlugin';
import type {
TimeEntry,
Project,
@@ -24,7 +38,10 @@ import type {
} from '@/packages/api/src';
import type { Dayjs } from 'dayjs';
type CalendarExtendedProps = { timeEntry: TimeEntry } & Record<string, unknown>;
type CalendarExtendedProps = { timeEntry: TimeEntry; isRunning?: boolean } & Record<
string,
unknown
>;
const emit = defineEmits<{
(e: 'dates-change', payload: { start: Date; end: Date }): void;
@@ -37,10 +54,13 @@ const props = defineProps<{
tasks: Task[];
clients: Client[];
tags: Tag[];
activityPeriods?: ActivityPeriod[];
loading?: boolean;
// Permissions / feature flags
enableEstimatedTime: boolean;
currency: string;
canCreateProject: boolean;
createTimeEntry: (
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
@@ -61,6 +81,10 @@ const selectedTimeEntry = ref<TimeEntry | null>(null);
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
// Reactive "now" for running time entry - updates every minute
const currentTime = ref(getDayJsInstance()());
let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
// Inject organization data for settings
const organization = inject<ComputedRef<Organization>>('organization');
@@ -102,47 +126,52 @@ const events = computed(() => {
const themeBackground = (() => {
return cssBackground.value?.trim();
})();
return props.timeEntries
?.filter((timeEntry) => timeEntry.end !== null)
?.map((timeEntry) => {
const project = props.projects.find((p) => p.id === timeEntry.project_id);
const client = props.clients.find((c) => c.id === project?.client_id);
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
const duration = getDayJsInstance()(timeEntry.end!).diff(
getDayJsInstance()(timeEntry.start),
'minutes'
);
return props.timeEntries?.map((timeEntry) => {
const isRunning = timeEntry.end === null;
const project = props.projects.find((p) => p.id === timeEntry.project_id);
const client = props.clients.find((c) => c.id === project?.client_id);
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
const title = timeEntry.description || 'No description';
// For running entries, use current time as end
const effectiveEnd = isRunning ? currentTime.value : getDayJsInstance()(timeEntry.end!);
const duration = effectiveEnd.diff(getDayJsInstance()(timeEntry.start), 'minutes');
const baseColor = project?.color || '#6B7280';
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
const title = timeEntry.description || 'No description';
// For 0-duration events, display them with minimum visual duration but preserve actual duration
const startTime = getLocalizedDayJs(timeEntry.start);
const endTime =
duration === 0
? startTime.add(1, 'second') // Show as 1 second for minimal visibility
: getLocalizedDayJs(timeEntry.end!);
const baseColor = project?.color || '#6B7280';
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
return {
id: timeEntry.id,
start: startTime.format(),
end: endTime.format(),
title,
backgroundColor,
borderColor,
textColor: 'var(--foreground)',
extendedProps: {
timeEntry,
project,
client,
task,
duration,
},
};
});
// For 0-duration events, display them with minimum visual duration but preserve actual duration
const startTime = getLocalizedDayJs(timeEntry.start);
const endTime =
duration === 0
? startTime.add(1, 'second') // Show as 1 second for minimal visibility
: isRunning
? getLocalizedDayJs(currentTime.value.toISOString())
: getLocalizedDayJs(timeEntry.end!);
return {
id: timeEntry.id,
start: startTime.format(),
end: endTime.format(),
title,
backgroundColor,
borderColor,
textColor: 'var(--foreground)',
// For running entries: disable dragging and resizing
startEditable: !isRunning,
classNames: isRunning ? ['running-entry'] : [],
extendedProps: {
timeEntry,
project,
client,
task,
duration,
isRunning,
},
};
});
});
// Daily totals used in day header
@@ -163,6 +192,8 @@ const dailyTotals = computed(() => {
function emitDatesChange(arg: DatesSetArg) {
emit('dates-change', { start: arg.start, end: arg.end });
// Render activity boxes after calendar view has been rendered
renderActivityBoxes();
}
function handleDateSelect(arg: { start: Date; end: Date }) {
@@ -181,6 +212,10 @@ function handleDateSelect(arg: { start: Date; end: Date }) {
function handleEventClick(arg: EventClickArg) {
const ext = arg.event.extendedProps as CalendarExtendedProps;
// Don't open edit modal for running time entries
if (ext.isRunning) {
return;
}
selectedTimeEntry.value = ext.timeEntry;
showEditTimeEntryModal.value = true;
}
@@ -194,11 +229,13 @@ async function handleEventDrop(arg: EventDropArg) {
start: getDayJsInstance()(arg.event.start.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
end: getDayJsInstance()(arg.event.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
} as TimeEntry;
@@ -215,20 +252,25 @@ async function handleEventResize(arg: EventChangeArg) {
start: getDayJsInstance()(arg.event.start.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
end: getDayJsInstance()(arg.event.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
.utc()
.format(),
// Preserve null end for running entries
end: ext.isRunning
? null
: getDayJsInstance()(arg.event.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
} as TimeEntry;
await props.updateTimeEntry(updatedTimeEntry);
emit('refresh');
}
const calendarOptions = computed(() => ({
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
initialView: 'timeGridWeek',
headerToolbar: {
left: 'prev,next today',
@@ -241,16 +283,17 @@ const calendarOptions = computed(() => ({
slotDuration: '00:15:00',
slotLabelInterval: '01:00:00',
slotLabelFormat: getSlotLabelFormat(),
snapDuration: '00:15:00',
snapDuration: '00:01:00',
firstDay: getFirstDay(),
allDaySlot: false,
nowIndicator: true,
eventMinHeight: 1,
selectable: true,
selectMirror: true,
editable: true,
eventResizableFromStart: true,
eventDurationEditable: true,
timeZone: 'America/Adak',
timeZone: getUserTimezone(),
eventStartEditable: true,
select: handleDateSelect,
eventClick: handleEventClick,
@@ -259,6 +302,7 @@ const calendarOptions = computed(() => ({
datesSet: emitDatesChange,
events: events.value,
activityPeriods: props.activityPeriods || [],
}));
watch(showCreateTimeEntryModal, (value) => {
@@ -277,6 +321,60 @@ watch(showEditTimeEntryModal, (value) => {
emit('refresh');
}
});
// Render activity status boxes after FullCalendar has rendered
const renderActivityBoxes = () => {
if (!calendarRef.value || !props.activityPeriods) return;
const calendarEl = calendarRef.value.$el as HTMLElement;
if (calendarEl && props.activityPeriods.length > 0) {
renderActivityStatusBoxes(calendarEl, props.activityPeriods);
}
};
// Watch for activity periods changes - re-render when data changes
watch(
() => props.activityPeriods,
() => {
renderActivityBoxes();
}
);
const scrollToCurrentTime = () => {
nextTick(() => {
if (calendarRef.value) {
const now = getDayJsInstance()();
const oneHourBefore = now.subtract(1, 'hour');
// If subtracting 1 hour keeps us on the same day, scroll to 1 hour before
const scrollTime = now.isSame(oneHourBefore, 'day')
? oneHourBefore.format('HH:mm:ss')
: now.format('HH:mm:ss');
calendarRef.value.getApi().scrollToTime(scrollTime);
}
});
};
onMounted(() => {
scrollToCurrentTime();
// Start interval to update running time entry
currentTimeInterval = setInterval(() => {
currentTime.value = getDayJsInstance()();
}, 60000); // Update every minute
});
onActivated(() => {
scrollToCurrentTime();
});
onUnmounted(() => {
// Clean up interval
if (currentTimeInterval) {
clearInterval(currentTimeInterval);
currentTimeInterval = null;
}
});
</script>
<template>
@@ -295,6 +393,8 @@ watch(showEditTimeEntryModal, (value) => {
:create-client="createClient"
:create-project="createProject"
:create-tag="createTag"
:currency="currency"
:can-create-project="canCreateProject"
:tags="tags as any"
:projects="projects"
:tasks="tasks"
@@ -314,7 +414,9 @@ watch(showEditTimeEntryModal, (value) => {
:tags="tags as any"
:projects="projects"
:tasks="tasks"
:clients="clients" />
:clients="clients"
:currency="currency"
:can-create-project="canCreateProject" />
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
<template #eventContent="arg">
<FullCalendarEventContent
@@ -332,9 +434,16 @@ watch(showEditTimeEntryModal, (value) => {
</template>
<template #dayHeaderContent="arg">
<FullCalendarDayHeader
:date="arg.date"
:date="
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
"
:total-minutes="
dailyTotals[getDayJsInstance()(arg.date).format('YYYY-MM-DD')] || 0
dailyTotals[
getDayJsInstance()(arg.date)
.utc()
.tz(getUserTimezone(), true)
.format('YYYY-MM-DD')
] || 0
" />
</template>
</FullCalendar>
@@ -360,11 +469,11 @@ watch(showEditTimeEntryModal, (value) => {
}
.fullcalendar :deep(.fc-timegrid-slot-label) {
background-color: var(--theme-color-default-background);
background-color: var(--background);
}
.fullcalendar :deep(.fc-toolbar) {
background-color: var(--theme-color-default-background);
background-color: var(--background);
padding: 0.5rem;
margin-bottom: 0;
}
@@ -445,8 +554,8 @@ watch(showEditTimeEntryModal, (value) => {
}
.fullcalendar :deep(.fc-event) {
border-radius: var(--radius);
padding: 0.45rem 0.25rem;
border-radius: calc(var(--radius) - 4px);
padding: 0;
font-size: 0.75rem;
cursor: pointer;
box-shadow: var(--theme-shadow-card);
@@ -508,7 +617,7 @@ watch(showEditTimeEntryModal, (value) => {
}
.fullcalendar :deep(.fc-highlight) {
background-color: var(--theme-color-default-background);
background-color: var(--primary);
}
.fullcalendar :deep(.fc-select-mirror) {
@@ -526,7 +635,7 @@ watch(showEditTimeEntryModal, (value) => {
}
.fullcalendar :deep(.fc-timegrid-body) {
background-color: var(--theme-color-default-background);
background-color: var(--background);
}
.fullcalendar :deep(.fc-timegrid-col) {
@@ -593,4 +702,44 @@ watch(showEditTimeEntryModal, (value) => {
.fullcalendar :deep(.fc-event-main) {
padding: 0.125rem 0.25rem;
}
/* Activity status plugin styles */
.fullcalendar :deep(.activity-status-box) {
transition: opacity 0.2s ease;
}
.fullcalendar :deep(.activity-status-box.idle) {
background-color: rgba(156, 163, 175, 0.1) !important;
}
.fullcalendar :deep(.activity-status-box.idle):hover {
background-color: rgba(156, 163, 175, 0.5) !important;
}
.fullcalendar :deep(.activity-status-box.active) {
background-color: rgba(34, 197, 94, 0.3) !important;
}
.fullcalendar :deep(.activity-status-box.active):hover {
background-color: rgba(34, 197, 94, 1) !important;
}
/* Add left margin to events only on days with activity status data */
.fullcalendar :deep(.has-activity-status .fc-timegrid-event-harness) {
margin-left: 15px !important;
}
.fullcalendar :deep(.fc-timegrid-event) {
margin-left: 0 !important;
}
/* Hide end resizer for running time entries */
.fullcalendar :deep(.running-entry .fc-event-resizer-end) {
display: none;
}
.fullcalendar :deep(.running-entry) {
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}
</style>

View File

@@ -0,0 +1,241 @@
import { createPlugin, type PluginDef } from '@fullcalendar/core';
import { computePosition, flip, shift, offset } from '@floating-ui/dom';
export interface ActivityPeriod {
start: string;
end: string;
isIdle: boolean;
}
export interface ActivityStatusPluginOptions {
activityPeriods?: ActivityPeriod[];
}
/**
* Creates and manages a tooltip element for activity status boxes
*/
function createTooltip(): HTMLElement {
const tooltip = document.createElement('div');
tooltip.className =
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground';
tooltip.style.position = 'fixed';
tooltip.style.pointerEvents = 'none';
tooltip.style.opacity = '0';
tooltip.style.whiteSpace = 'nowrap';
tooltip.style.transform = 'scale(0.95)';
tooltip.style.transition = 'opacity 150ms, transform 150ms';
document.body.appendChild(tooltip);
return tooltip;
}
/**
* Shows tooltip for an activity status box
*/
function showTooltip(box: HTMLElement, tooltip: HTMLElement, text: string) {
tooltip.textContent = text;
tooltip.style.opacity = '1';
tooltip.style.transform = 'scale(1)';
const updatePosition = () => {
computePosition(box, tooltip, {
placement: 'right',
middleware: [offset(8), flip(), shift({ padding: 5 })],
}).then(({ x, y }) => {
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;
});
};
updatePosition();
}
/**
* Hides the tooltip
*/
function hideTooltip(tooltip: HTMLElement) {
tooltip.style.opacity = '0';
tooltip.style.transform = 'scale(0.95)';
}
/**
* Renders activity status boxes in the calendar time grid
*/
export function renderActivityStatusBoxes(
calendarEl: HTMLElement,
activityPeriods: ActivityPeriod[]
) {
if (!calendarEl) return;
// Clean up existing activity boxes and markers first
const existingBoxes = calendarEl.querySelectorAll('.activity-status-box');
existingBoxes.forEach((box) => box.remove());
// Clean up existing tooltips
const existingTooltips = document.querySelectorAll('.activity-status-tooltip');
existingTooltips.forEach((tooltip) => tooltip.remove());
// Remove has-activity-status class from all lanes
const allLanes = calendarEl.querySelectorAll('.fc-timegrid-col');
allLanes.forEach((lane) => lane.classList.remove('has-activity-status'));
const timeGrid = calendarEl.querySelector('.fc-timegrid-body');
if (!timeGrid) {
console.log('No timegrid found');
return;
}
const lanes = timeGrid.querySelectorAll('.fc-timegrid-col');
if (lanes.length === 0) {
console.log('No lanes found');
return;
}
console.log(
'Rendering activity status boxes, lanes:',
lanes.length,
'periods:',
activityPeriods.length
);
// Create a single tooltip instance to be reused
const tooltip = createTooltip();
lanes.forEach((lane: Element, dayIndex: number) => {
// Get the date for this lane from the data attribute
const laneEl = lane as HTMLElement;
const dateStr = laneEl.getAttribute('data-date');
if (!dateStr) {
console.log('No date attribute found for lane', dayIndex);
return;
}
const laneDate = new Date(dateStr);
const laneDateStart = new Date(laneDate);
laneDateStart.setHours(0, 0, 0, 0);
const laneDateEnd = new Date(laneDate);
laneDateEnd.setHours(23, 59, 59, 999);
let hasActivityStatusForThisDay = false;
activityPeriods.forEach((period) => {
const periodStart = new Date(period.start);
const periodEnd = new Date(period.end);
// Check if period overlaps with this day
if (periodEnd < laneDateStart || periodStart > laneDateEnd) {
return;
}
// Calculate the position and height of the idle box
const { top, height } = calculateBoxPosition(
calendarEl,
periodStart > laneDateStart ? periodStart : laneDateStart,
periodEnd < laneDateEnd ? periodEnd : laneDateEnd
);
if (height <= 0) return;
hasActivityStatusForThisDay = true;
// Create and append the activity status box
const box = document.createElement('div');
box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`;
box.style.position = 'absolute';
box.style.top = `${top}px`;
box.style.height = `${height}px`;
box.style.width = '8px';
box.style.left = '4px';
box.style.right = '4px';
box.style.zIndex = '10';
box.style.cursor = 'default';
// Calculate duration in minutes
const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart;
const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd;
const durationMs = actualEnd.getTime() - actualStart.getTime();
const durationMinutes = Math.round(durationMs / 60000);
// Format duration
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
const durationText = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
// Add tooltip text based on status
const status = period.isIdle ? 'Idling' : 'Active';
const tooltipText = `${status} (${durationText})`;
// Add hover event listeners for tooltip
box.addEventListener('mouseenter', () => {
showTooltip(box, tooltip, tooltipText);
});
box.addEventListener('mouseleave', () => {
hideTooltip(tooltip);
});
// Position relative to the lane
const laneFrame = lane.querySelector('.fc-timegrid-col-frame');
if (laneFrame) {
laneFrame.appendChild(box);
} else {
console.log('No lane frame found');
}
});
// Mark this lane as having activity status if any periods were rendered
if (hasActivityStatusForThisDay) {
laneEl.classList.add('has-activity-status');
}
});
}
/**
* Calculates the pixel position and height for an activity status box
*/
function calculateBoxPosition(
calendarEl: HTMLElement,
startTime: Date,
endTime: Date
): { top: number; height: number } {
// Get the slot duration and slot height
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
if (slotsEl.length === 0) {
console.log('No slots found');
return { top: 0, height: 0 };
}
// Calculate slot height (assuming all slots are equal height)
const firstSlot = slotsEl[0] as HTMLElement;
const slotHeight = firstSlot.offsetHeight;
// Each slot is 15 minutes by default (configured in TimeEntryCalendar)
const slotDurationMinutes = 15;
const pixelsPerMinute = slotHeight / slotDurationMinutes;
// Calculate start position (minutes from midnight)
const startMinutes = startTime.getHours() * 60 + startTime.getMinutes();
const endMinutes = endTime.getHours() * 60 + endTime.getMinutes();
// Calculate pixel positions
const top = startMinutes * pixelsPerMinute;
const height = (endMinutes - startMinutes) * pixelsPerMinute;
return { top, height };
}
/**
* FullCalendar plugin to display idle/active status boxes in the time grid
*/
const activityStatusPlugin: PluginDef = createPlugin({
name: '@solidtime/activity-status',
optionRefiners: {
activityPeriods: (rawVal: unknown): ActivityPeriod[] => {
if (!Array.isArray(rawVal)) return [];
return rawVal as ActivityPeriod[];
},
},
});
export default activityStatusPlugin;

View File

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

View File

@@ -27,11 +27,19 @@ function updateTime(event: Event) {
if (newValue.split(':').length === 2) {
const [hours, minutes] = newValue.split(':');
if (!isNaN(parseInt(hours)) && !isNaN(parseInt(minutes))) {
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59))
.format();
emit('changed', model.value);
const currentTime = getLocalizedDayJs(model.value);
const newHours = Math.min(parseInt(hours), 23);
const newMinutes = Math.min(parseInt(minutes), 59);
// Only update if hours or minutes are different
if (currentTime.hour() !== newHours || currentTime.minute() !== newMinutes) {
model.value = currentTime
.set('hours', newHours)
.set('minutes', newMinutes)
.set('seconds', 0)
.format();
emit('changed', model.value);
}
}
}
// check if input is only numbers
@@ -42,6 +50,7 @@ function updateTime(event: Event) {
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59))
.set('seconds', 0)
.format();
emit('changed', model.value);
} else if (newValue.length === 3) {
@@ -50,6 +59,7 @@ function updateTime(event: Event) {
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59))
.set('seconds', 0)
.format();
emit('changed', model.value);
} else if (newValue.length === 2) {
@@ -57,6 +67,7 @@ function updateTime(event: Event) {
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(newValue), 23))
.set('minutes', 0)
.set('seconds', 0)
.format();
emit('changed', model.value);
} else if (newValue.length === 1) {
@@ -64,6 +75,7 @@ function updateTime(event: Event) {
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(newValue), 23))
.set('minutes', 0)
.set('seconds', 0)
.format();
emit('changed', model.value);
}
@@ -93,6 +105,7 @@ const inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:m
data-testid="time_picker_input"
type="text"
@blur="updateTime"
@keydown.enter.prevent="updateTime"
@focus="($event.target as HTMLInputElement).select()"
@mouseup="($event.target as HTMLInputElement).select()"
@click="($event.target as HTMLInputElement).select()"

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,31 +17,42 @@ const emit = defineEmits(['changed', 'close']);
const tempStart = ref(props.start ? getLocalizedDayJs(props.start).format() : dayjs().format());
const tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null);
const showEndTimePicker = ref(false);
watch(props, () => {
tempStart.value = getLocalizedDayJs(props.start).format();
tempEnd.value = props.end ? getLocalizedDayJs(props.end).format() : null;
showEndTimePicker.value = false;
});
function updateTimeEntry() {
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
const tempEndUtc = tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null;
if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
emit(
'changed',
getDayJsInstance()(tempStart.value).utc().format(),
getDayJsInstance()(tempEnd.value).utc().format()
tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null
);
}
}
const dropdownContent = ref();
const { focused } = useFocusWithin(dropdownContent);
function setEndTime() {
showEndTimePicker.value = true;
tempEnd.value = getDayJsInstance()().format();
}
watch(focused, (newValue, oldValue) => {
if (oldValue === true && newValue === false) {
function confirmEndTime() {
// wait for the v-model for the end time to update
nextTick(() => {
updateTimeEntry();
}
});
showEndTimePicker.value = false;
emit('close');
});
}
const dropdownContent = ref();
</script>
<template>
@@ -67,7 +78,7 @@ watch(focused, (newValue, oldValue) => {
</div>
<div class="px-2">
<div class="font-semibold text-text-primary text-sm pb-2">End</div>
<div v-if="tempEnd !== null" class="space-y-2">
<div v-if="end !== null && tempEnd !== null" class="space-y-2">
<TimePickerSimple
v-model="tempEnd"
data-testid="time_entry_range_end"
@@ -77,6 +88,22 @@ watch(focused, (newValue, oldValue) => {
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
@changed="updateTimeEntry"></DatePicker>
</div>
<div v-else-if="end === null && !showEndTimePicker">
<Button variant="outline" size="sm" @click="setEndTime"> Set End Time </Button>
</div>
<div v-else-if="showEndTimePicker && tempEnd !== null" class="space-y-2">
<TimePickerSimple
v-model="tempEnd"
data-testid="time_entry_range_end"
@keydown.enter.prevent.stop="confirmEndTime"></TimePickerSimple>
<DatePicker
v-model="tempEnd"
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
@keydown.enter.prevent="confirmEndTime"></DatePicker>
<Button variant="outline" size="sm" class="w-full" @click="confirmEndTime">
Confirm
</Button>
</div>
<div v-else class="text-text-secondary">-- : --</div>
<div tabindex="0" @focusin="emit('close')"></div>
</div>

View File

@@ -33,6 +33,7 @@ const props = defineProps<{
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
onStartStopClick: (timeEntry: TimeEntry) => void;
duplicateTimeEntry: (timeEntry: TimeEntry) => void;
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
updateTimeEntry: (timeEntry: TimeEntry) => void;
deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
@@ -92,7 +93,7 @@ function onSelectChange(checked: boolean) {
class="border-b border-default-background-separator bg-row-background min-w-0 transition"
data-testid="time_entry_row">
<MainContainer class="min-w-0">
<div class="sm:flex py-2 items-center min-w-0 justify-between group">
<div class="@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="
@@ -124,7 +125,7 @@ function onSelectChange(checked: boolean) {
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
</div>
</div>
<div class="flex items-center font-medium lg:space-x-2">
<div class="flex items-center font-medium space-x-1 @lg:space-x-2">
<TimeEntryRowTagDropdown
:create-tag
:tags="tags"
@@ -141,8 +142,8 @@ function onSelectChange(checked: boolean) {
twMerge(
'text-text-secondary px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary',
organization?.time_format === '12-hours'
? 'w-[170px]'
: 'w-[120px]'
? 'w-[160px]'
: 'w-[100px]'
)
"
@click="expanded = !expanded">
@@ -156,7 +157,7 @@ function onSelectChange(checked: boolean) {
</button>
</div>
<button
class="text-text-primary min-w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
class="text-text-primary !mr-2 min-w-[80px] px-1.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
@click="expanded = !expanded">
{{
formatHumanReadableDuration(
@@ -173,6 +174,7 @@ function onSelectChange(checked: boolean) {
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
:show-edit="false"
:show-duplicate="false"
@delete="
deleteTimeEntries(timeEntry?.timeEntries ?? [])
"></TimeEntryMoreOptionsDropdown>
@@ -202,6 +204,7 @@ function onSelectChange(checked: boolean) {
:update-time-entry="(timeEntry: TimeEntry) => updateTimeEntry(timeEntry)"
:on-start-stop-click="() => onStartStopClick(subEntry)"
:delete-time-entry="() => deleteTimeEntries([subEntry])"
:duplicate-time-entry="() => duplicateTimeEntry(subEntry)"
:currency="currency"
:create-tag
:time-entry="subEntry"

View File

@@ -15,8 +15,6 @@ import type {
Client,
CreateTimeEntryBody,
} from '@/packages/api/src';
import { getOrganizationCurrencyString } from '@/utils/money';
import { canCreateProjects } from '@/utils/permissions';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { Badge } from '@/packages/ui/src';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
@@ -43,6 +41,8 @@ const props = defineProps<{
clients: Client[];
start?: string;
end?: string;
currency: string;
canCreateProject: boolean;
}>();
const description = ref<HTMLInputElement | null>(null);
@@ -61,8 +61,8 @@ const timeEntryDefaultValues = {
task_id: null,
tags: [],
billable: false,
start: getDayJsInstance().utc().subtract(1, 'h').format(),
end: getDayJsInstance().utc().format(),
start: getDayJsInstance().utc().subtract(1, 'h').second(0).format(),
end: getDayJsInstance().utc().second(0).format(),
};
const timeEntry = ref({
@@ -167,8 +167,8 @@ type BillableOption = {
:clients
:create-project
:create-client
:can-create-project="canCreateProjects()"
:currency="getOrganizationCurrencyString()"
:can-create-project
:currency
size="xlarge"
class="bg-input-background"
:projects="projects"

View File

@@ -15,8 +15,6 @@ import type {
Client,
TimeEntry,
} from '@/packages/api/src';
import { getOrganizationCurrencyString } from '@/utils/money';
import { canCreateProjects } from '@/utils/permissions';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { Badge } from '@/packages/ui/src';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
@@ -44,6 +42,8 @@ const props = defineProps<{
projects: Project[];
tasks: Task[];
clients: Client[];
currency: string;
canCreateProject: boolean;
}>();
const description = ref<HTMLInputElement | null>(null);
@@ -68,19 +68,6 @@ watch(
{ immediate: true }
);
watch(
() => editableTimeEntry.value?.project_id,
(value) => {
if (value && editableTimeEntry.value) {
// check if project is billable by default and set billable accordingly
const project = props.projects.find((p) => p.id === value);
if (project) {
editableTimeEntry.value.billable = project.is_billable;
}
}
}
);
const localStart = computed({
get: () =>
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '',
@@ -176,8 +163,8 @@ type BillableOption = {
:clients
:create-project
:create-client
:can-create-project="canCreateProjects()"
:currency="getOrganizationCurrencyString()"
:can-create-project="canCreateProject"
:currency="currency"
size="xlarge"
class="bg-input-background"
:projects="projects"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import type {
CreateClientBody,
CreateProjectBody,
@@ -38,6 +38,8 @@ const props = defineProps<{
canCreateProject: boolean;
}>();
const maxVisibleGroups = ref(7); // Start with 10 day groups, then show all
const groupedTimeEntries = computed(() => {
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
for (const entry of props.timeEntries) {
@@ -94,6 +96,7 @@ const groupedTimeEntries = computed(() => {
groupedEntriesByDayAndType[dailyEntriesKey] = newDailyEntries;
}
return groupedEntriesByDayAndType;
});
@@ -108,6 +111,7 @@ function startTimeEntryFromExisting(entry: TimeEntry) {
tags: [...entry.tags],
});
}
function sumDuration(timeEntries: TimeEntry[]) {
return timeEntries.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
}
@@ -133,80 +137,116 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
);
});
}
const visibleGroupedEntries = computed(() => {
const allGroups = Object.entries(groupedTimeEntries.value);
return Object.fromEntries(allGroups.slice(0, maxVisibleGroups.value));
});
const totalGroups = computed(() => Object.keys(groupedTimeEntries.value).length);
function startProgressiveLoading() {
const loadMoreGroups = () => {
if (maxVisibleGroups.value < totalGroups.value) {
maxVisibleGroups.value = Math.min(maxVisibleGroups.value + 5, totalGroups.value);
if (maxVisibleGroups.value < totalGroups.value) {
requestIdleCallback(loadMoreGroups);
}
}
};
requestIdleCallback(loadMoreGroups);
}
// Watch for changes to totalGroups and adjust maxVisibleGroups accordingly
watch(totalGroups, (newTotal, oldTotal) => {
if (newTotal !== oldTotal) {
maxVisibleGroups.value = newTotal;
}
});
onMounted(() => {
startProgressiveLoading();
});
</script>
<template>
<div v-for="(value, key) in groupedTimeEntries" :key="key">
<TimeEntryRowHeading
:date="key"
:duration="sumDuration(value)"
:checked="
value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))
"
@select-all="selectAllTimeEntries(value)"
@unselect-all="unselectAllTimeEntries(value)"></TimeEntryRowHeading>
<template v-for="entry in value" :key="entry.id">
<TimeEntryAggregateRow
v-if="'timeEntries' in entry && entry.timeEntries.length > 1"
:create-project
:can-create-project
:enable-estimated-time
:selected-time-entries="selectedTimeEntries"
:create-client
:projects="projects"
:tasks="tasks"
:tags="tags"
:clients
:on-start-stop-click="startTimeEntryFromExisting"
:update-time-entries
:update-time-entry
:delete-time-entries
:create-tag
:currency="currency"
:time-entry="entry"
@selected="
(timeEntries: TimeEntry[]) => {
selectedTimeEntries = [...selectedTimeEntries, ...timeEntries];
}
<div class="@container">
<div v-for="(value, key) in visibleGroupedEntries" :key="key">
<TimeEntryRowHeading
:date="String(key)"
:duration="sumDuration(value)"
:checked="
value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))
"
@unselected="
(timeEntriesToUnselect: TimeEntry[]) => {
@select-all="selectAllTimeEntries(value)"
@unselect-all="unselectAllTimeEntries(value)"></TimeEntryRowHeading>
<template v-for="entry in value" :key="entry.id">
<TimeEntryAggregateRow
v-if="'timeEntries' in entry && entry.timeEntries.length > 1"
:create-project
:can-create-project
:enable-estimated-time
:selected-time-entries="selectedTimeEntries"
:create-client
:projects="projects"
:tasks="tasks"
:tags="tags"
:clients
:on-start-stop-click="startTimeEntryFromExisting"
:duplicate-time-entry="createTimeEntry"
:update-time-entries
:update-time-entry
:delete-time-entries
:create-tag
:currency="currency"
:time-entry="entry"
@selected="
(timeEntries: TimeEntry[]) => {
selectedTimeEntries = [...selectedTimeEntries, ...timeEntries];
}
"
@unselected="
(timeEntriesToUnselect: TimeEntry[]) => {
selectedTimeEntries = selectedTimeEntries.filter(
(item: TimeEntry) =>
!timeEntriesToUnselect.find(
(filterEntry: TimeEntry) => filterEntry.id === item.id
)
);
}
"></TimeEntryAggregateRow>
<TimeEntryRow
v-else
:create-client
:enable-estimated-time
:can-create-project
:create-project
:projects="projects"
:selected="
!!selectedTimeEntries.find(
(filterEntry: TimeEntry) => filterEntry.id === entry.id
)
"
:tasks="tasks"
:tags="tags"
:clients
:create-tag
:update-time-entry
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
:delete-time-entry="() => deleteTimeEntries([entry])"
:duplicate-time-entry="() => createTimeEntry(entry)"
:currency="currency"
:time-entry="entry.timeEntries[0]"
@selected="selectedTimeEntries.push(entry)"
@unselected="
selectedTimeEntries = selectedTimeEntries.filter(
(item: TimeEntry) =>
!timeEntriesToUnselect.find(
(filterEntry: TimeEntry) => filterEntry.id === item.id
)
);
}
"></TimeEntryAggregateRow>
<TimeEntryRow
v-else
:create-client
:enable-estimated-time
:can-create-project
:create-project
:projects="projects"
:selected="
!!selectedTimeEntries.find(
(filterEntry: TimeEntry) => filterEntry.id === entry.id
)
"
:tasks="tasks"
:tags="tags"
:clients
:create-tag
:update-time-entry
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
:delete-time-entry="() => deleteTimeEntries([entry])"
:currency="currency"
:time-entry="entry.timeEntries[0]"
@selected="selectedTimeEntries.push(entry)"
@unselected="
selectedTimeEntries = selectedTimeEntries.filter(
(item: TimeEntry) => item.id !== entry.id
)
"></TimeEntryRow>
</template>
(item: TimeEntry) => item.id !== entry.id
)
"></TimeEntryRow>
</template>
</div>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { TrashIcon, PencilIcon } from '@heroicons/vue/20/solid';
import { TrashIcon, PencilIcon, DocumentDuplicateIcon } from '@heroicons/vue/20/solid';
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,8 +10,10 @@ import {
const props = withDefaults(
defineProps<{
showEdit?: boolean;
showDuplicate?: boolean;
}>(),
{
showDuplicate: true,
showEdit: true,
}
);
@@ -19,6 +21,7 @@ const props = withDefaults(
const emit = defineEmits<{
edit: [];
delete: [];
duplicate: [];
}>();
</script>
@@ -51,6 +54,14 @@ const emit = defineEmits<{
<PencilIcon class="w-5" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
v-if="props.showDuplicate"
data-testid="time_entry_duplicate"
class="flex items-center space-x-3 cursor-pointer"
@click="emit('duplicate')">
<DocumentDuplicateIcon class="w-5" />
<span>Duplicate</span>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="time_entry_delete"
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"

View File

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

View File

@@ -36,6 +36,7 @@ const props = defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
onStartStopClick: () => void;
deleteTimeEntry: () => void;
duplicateTimeEntry?: () => void;
updateTimeEntry: (timeEntry: TimeEntry) => void;
currency: string;
showMember?: boolean;
@@ -111,7 +112,7 @@ async function handleDeleteTimeEntry() {
class="border-b border-default-background-separator transition min-w-0 bg-row-background"
data-testid="time_entry_row">
<MainContainer class="min-w-0">
<div class="sm:flex py-2 min-w-0 items-center justify-between group">
<div class="@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>
@@ -133,7 +134,7 @@ async function handleDeleteTimeEntry() {
:task="timeEntry.task_id"
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center font-medium space-x-1 lg:space-x-2">
<div class="flex items-center font-medium space-x-1 @lg:space-x-2">
<div v-if="showMember && members" class="text-sm px-2">
{{ memberName }}
</div>
@@ -166,6 +167,7 @@ async function handleDeleteTimeEntry() {
@changed="onStartStopClick"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
@edit="handleEdit"
@duplicate="duplicateTimeEntry"
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
</div>
</div>
@@ -184,7 +186,9 @@ async function handleDeleteTimeEntry() {
:tags="tags"
:projects="projects"
:tasks="tasks"
:clients="clients" />
:clients="clients"
:currency="currency"
:can-create-project="canCreateProject" />
</template>
<style scoped></style>

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

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

View File

@@ -15,8 +15,6 @@ import type {
} from '@/packages/api/src';
import { computed, nextTick, ref, watch } from 'vue';
import type { Dayjs } from 'dayjs';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { storeToRefs } from 'pinia';
import { useFocus } from '@vueuse/core';
import { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue';
import TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue';
@@ -34,6 +32,7 @@ const props = defineProps<{
tasks: Task[];
tags: Tag[];
clients: Client[];
timeEntries: TimeEntry[];
createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
@@ -49,6 +48,7 @@ const emit = defineEmits<{
updateTimeEntry: [];
startLiveTimer: [];
stopLiveTimer: [];
createTimeEntry: [];
}>();
function updateProject() {
@@ -130,10 +130,9 @@ function updateTimeEntryDescription() {
}
}
const { timeEntries } = storeToRefs(useTimeEntriesStore());
const filteredRecentlyTrackedTimeEntries = computed(() => {
// do not include running time entries
const finishedTimeEntries = timeEntries.value.filter((item) => item.end !== null);
const finishedTimeEntries = props.timeEntries.filter((item) => item.end !== null);
// filter out duplicates based on description, task, project, tags and billable
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
@@ -280,6 +279,7 @@ useSelectEvents(
@stop-live-timer="emit('stopLiveTimer')"
@update-timer="emit('updateTimeEntry')"
@start-timer="emit('startTimer')"
@create-time-entry="emit('createTimeEntry')"
@keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector>
</div>
</div>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { PlusIcon, XMarkIcon } from '@heroicons/vue/20/solid';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
const props = defineProps<{
hasActiveTimer: boolean;
}>();
const emit = defineEmits<{
manualEntry: [];
discard: [];
}>();
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring hover:bg-card-background hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
aria-label="Time entry actions">
<svg
class="h-8 w-8 p-1 rounded-full"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[150px]" align="end">
<DropdownMenuItem
class="flex items-center space-x-3 cursor-pointer"
@click="emit('manualEntry')">
<PlusIcon class="w-5" />
<span>Manual time entry</span>
</DropdownMenuItem>
<DropdownMenuItem
v-if="props.hasActiveTimer"
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
@click="emit('discard')">
<XMarkIcon class="w-5" />
<span>Discard</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<style scoped></style>

View File

@@ -16,6 +16,7 @@ const emit = defineEmits<{
stopLiveTimer: [];
updateTimer: [];
startTimer: [];
createTimeEntry: [];
}>();
const open = ref(false);
@@ -55,7 +56,7 @@ const currentTime = computed({
});
function updateTimerAndStartLiveTimerUpdate() {
const { seconds } = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes');
const seconds = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes');
if (seconds && seconds > 0) {
const newStartDate = dayjs().subtract(seconds, 's');
@@ -73,12 +74,16 @@ function updateTimerAndStartLiveTimerUpdate() {
const temporaryCustomTimerEntry = ref<string>('');
async function updateTimeRange(newStart: string) {
async function updateTimeRange(newStart: string, newEnd: string | null) {
// prohibit updates in the future
if (getDayJsInstance()(newStart).isBefore(getDayJsInstance()())) {
currentTimeEntry.value.start = newStart;
currentTimeEntry.value.end = newEnd;
if (currentTimeEntry.value.id) {
emit('updateTimer');
} else if (newEnd !== null) {
// If there's no ID but we have both start and end, create a new time entry
emit('createTimeEntry');
} else {
emit('startTimer');
}
@@ -91,11 +96,21 @@ const startTime = computed(() => {
}
return dayjs().utc().format();
});
const endTime = computed(() => {
if (currentTimeEntry.value.end && currentTimeEntry.value.end !== '') {
return currentTimeEntry.value.end;
}
return null;
});
const inputField = ref<HTMLInputElement | null>(null);
const timeRangeSelector = ref<HTMLElement | null>(null);
function openModalOnTab(e: FocusEvent) {
pauseLiveTimerUpdate(e);
// check if the source is inside the dropdown
const source = e.relatedTarget as HTMLElement;
if (source && window.document.body.querySelector<HTMLElement>('#app')?.contains(source)) {
@@ -103,6 +118,12 @@ function openModalOnTab(e: FocusEvent) {
}
}
function openModalOnClick(e: MouseEvent) {
pauseLiveTimerUpdate(e);
open.value = true;
}
function focusNextElement(e: KeyboardEvent) {
if (open.value) {
e.preventDefault();
@@ -135,8 +156,8 @@ function closeAndFocusInput() {
data-testid="time_entry_time"
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-muted focus:ring-0 transition"
type="text"
@focus="pauseLiveTimerUpdate"
@focusin="openModalOnTab"
@click="openModalOnClick"
@keydown.exact.tab="focusNextElement"
@keydown.exact.shift.tab="open = false"
@blur="updateTimerAndStartLiveTimerUpdate"
@@ -146,7 +167,7 @@ function closeAndFocusInput() {
<div ref="timeRangeSelector">
<TimeRangeSelector
:start="startTime"
:end="null"
:end="endTime"
@changed="updateTimeRange"
@close="closeAndFocusInput">
</TimeRangeSelector>

View File

@@ -10,8 +10,12 @@ import * as color from './utils/color';
import * as random from './utils/random';
import * as time from './utils/time';
export { cn } from './utils/cn';
export { buttonVariants, type ButtonVariants } from './Buttons/index';
import PrimaryButton from './Buttons/PrimaryButton.vue';
import SecondaryButton from './Buttons/SecondaryButton.vue';
import Button from './Buttons/Button.vue';
import TimeTrackerStartStop from './TimeTrackerStartStop.vue';
import ProjectBadge from './Project/ProjectBadge.vue';
import LoadingSpinner from './LoadingSpinner.vue';
@@ -20,6 +24,7 @@ import TextInput from './Input/TextInput.vue';
import InputLabel from './Input/InputLabel.vue';
import TimeTrackerRunningInDifferentOrganizationOverlay from './TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
import TimeTrackerControls from './TimeTracker/TimeTrackerControls.vue';
import TimeTrackerMoreOptionsDropdown from './TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
import CardTitle from './CardTitle.vue';
import SelectDropdown from './Input/SelectDropdown.vue';
import Badge from './Badge.vue';
@@ -32,12 +37,15 @@ import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
export {
money,
color,
random,
time,
Button,
PrimaryButton,
SecondaryButton,
TimeTrackerStartStop,
@@ -48,6 +56,7 @@ export {
InputLabel,
TimeTrackerRunningInDifferentOrganizationOverlay,
TimeTrackerControls,
TimeTrackerMoreOptionsDropdown,
CardTitle,
SelectDropdown,
Badge,
@@ -60,4 +69,8 @@ export {
FullCalendarEventContent,
FullCalendarDayHeader,
TimeEntryCalendar,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
};

View File

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

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,131 @@
/**
* 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(route('login'));
router.get('/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: null,
end: currentTimeEntry.value.end,
tags: currentTimeEntry.value.tags,
},
{
@@ -175,7 +175,12 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
'Time entry updated!'
);
if (response?.data) {
currentTimeEntry.value = response.data;
if (response.data.end === null) {
currentTimeEntry.value = response.data;
} else {
$reset();
stopLiveTimer();
}
}
} else {
throw new Error(
@@ -215,5 +220,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
stopLiveTimer,
now,
setActiveState,
$reset,
};
});

View File

@@ -12,11 +12,19 @@ import { useProjectsStore } from '@/utils/useProjects';
import { useMembersStore } from '@/utils/useMembers';
import { useTasksStore } from '@/utils/useTasks';
import { useClientsStore } from '@/utils/useClients';
import { useTagsStore } from '@/utils/useTags';
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
export type GroupingOption = 'project' | 'task' | 'user' | 'billable' | 'client' | 'description';
export type GroupingOption =
| 'project'
| 'task'
| 'user'
| 'billable'
| 'client'
| 'description'
| 'tag';
export const useReportingStore = defineStore('reporting', () => {
const reportingGraphResponse = ref<ReportingResponse | null>(null);
@@ -73,6 +81,7 @@ export const useReportingStore = defineStore('reporting', () => {
billable: 'Non-Billable',
client: 'No Client',
description: 'No Description',
tag: 'No Tag',
} as Record<string, string>;
function getNameForReportingRowEntry(key: string | null, type: string | null) {
@@ -106,6 +115,11 @@ export const useReportingStore = defineStore('reporting', () => {
const { clients } = storeToRefs(clientsStore);
return clients.value.find((client) => client.id === key)?.name;
}
if (type === 'tag') {
const tagsStore = useTagsStore();
const { tags } = storeToRefs(tagsStore);
return tags.value.find((tag) => tag.id === key)?.name;
}
if (type === 'billable') {
if (key === '0') {
return 'Non-Billable';
@@ -151,6 +165,11 @@ export const useReportingStore = defineStore('reporting', () => {
value: 'description',
icon: DocumentTextIcon,
},
{
label: 'Tags',
value: 'tag',
icon: DocumentTextIcon,
},
];
return {

View File

@@ -1,225 +1,246 @@
import { defineStore } from 'pinia';
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
import { reactive, ref } from 'vue';
import {
api,
type CreateTimeEntryBody,
type TimeEntriesQueryParams,
type TimeEntry,
import { reactive, ref, type Ref } from 'vue';
import { api } from '@/packages/api/src';
import type {
CreateTimeEntryBody,
TimeEntriesQueryParams,
TimeEntry,
UpdateMultipleTimeEntriesChangeset,
} from '@/packages/api/src';
import dayjs from 'dayjs';
import { useNotificationsStore } from '@/utils/notification';
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
import type {} from '@/packages/api/src';
import { useQueryClient } from '@tanstack/vue-query';
export const useTimeEntriesStore = defineStore('timeEntries', () => {
const timeEntries = ref<TimeEntry[]>(reactive([]));
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([]));
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();
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)
);
timeEntries.value = [...missingTimeEntries, ...timeEntries.value];
async function patchTimeEntries(
queryParams: TimeEntriesQueryParams = {
only_full_dates: 'true',
member_id: getCurrentMembershipId(),
}
}
}
) {
const organizationId = getCurrentOrganizationId();
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,
},
{
if (organizationId) {
const timeEntriesResponse = await handleApiRequestNotifications(
() =>
api.getTimeEntries({
params: {
organization: organizationId,
},
}
),
'Time entries updated successfully',
'Failed to update time entries'
);
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)
);
timeEntries.value = [...missingTimeEntries, ...timeEntries.value];
}
}
}
}
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 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 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 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 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 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 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();
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'] });
}
}
}
return {
timeEntries,
fetchTimeEntries,
updateTimeEntry,
createTimeEntry,
deleteTimeEntry,
fetchMoreTimeEntries,
allTimeEntriesLoaded,
updateTimeEntries,
deleteTimeEntries,
patchTimeEntries,
};
});
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,6 +1,7 @@
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 {
@@ -16,130 +17,10 @@ export default {
],
theme: {
extend: {
boxShadow: {
card: 'var(--theme-shadow-card)',
dropdown: 'var(--theme-shadow-dropdown)',
},
containers: {
'2xs': '16rem',
},
...solidtimeTheme,
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)',
},
},
},

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Console;
use App\Console\Kernel;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCase;
#[CoversClass(Kernel::class)]
class KernelTest extends TestCase
{
public function test_self_host_commands_schedule_time_is_consistent_with_app_key(): void
{
// Arrange
config([
'app.key' => 'base64:cOXN4GLMXYjcdG0fKosnFogofXw1pNoXkLAViRH+a5Y=',
]);
// Act
$schedule1 = app()->make(Kernel::class)->resolveConsoleSchedule();
$firstRunEvents = collect($schedule1->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update') ||
str_contains($event->command, 'self-host:telemetry')
);
$schedule2 = app()->make(Kernel::class)->resolveConsoleSchedule();
$secondRunEvents = collect($schedule2->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update') ||
str_contains($event->command, 'self-host:telemetry')
);
config([
'app.key' => 'base64:eP58hkQ8l3guqf8wvWJR7pB0weVQtnpjMdYpaVwX4Jw=',
]);
$schedule3 = app()->make(Kernel::class)->resolveConsoleSchedule();
$thirdRunEvents = collect($schedule3->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update') ||
str_contains($event->command, 'self-host:telemetry')
);
// Assert
$this->assertCount(2, $firstRunEvents);
$this->assertCount(2, $secondRunEvents);
$this->assertCount(2, $thirdRunEvents);
foreach ($firstRunEvents as $index => $event) {
$this->assertSame('52 9,21 * * *', $firstRunEvents[$index]->expression);
$this->assertSame('52 9,21 * * *', $secondRunEvents[$index]->expression);
$this->assertSame('48 13,1 * * *', $thirdRunEvents[$index]->expression);
}
}
public function test_self_hosting_telemetry_can_be_activated(): void
{
// Arrange
config([
'scheduling.tasks.self_hosting_telemetry' => true,
]);
// Act
$schedule = app()->make(Kernel::class)->resolveConsoleSchedule();
$events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:telemetry')
);
// Assert
$this->assertCount(1, $events);
}
public function test_self_hosting_telemetry_can_be_deactivated(): void
{
// Arrange
config([
'scheduling.tasks.self_hosting_telemetry' => false,
]);
// Act
$schedule = app()->make(Kernel::class)->resolveConsoleSchedule();
$events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:telemetry')
);
// Assert
$this->assertCount(0, $events);
}
public function test_self_hosting_check_for_update_can_be_activated(): void
{
// Arrange
config([
'scheduling.tasks.self_hosting_check_for_update' => true,
]);
// Act
$schedule = app()->make(Kernel::class)->resolveConsoleSchedule();
$events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update')
);
// Assert
$this->assertCount(1, $events);
}
public function test_self_hosting_check_for_update_can_be_deactivated(): void
{
// Arrange
config([
'scheduling.tasks.self_hosting_check_for_update' => false,
]);
// Act
$schedule = app()->make(Kernel::class)->resolveConsoleSchedule();
$events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update')
);
// Assert
$this->assertCount(0, $events);
}
}

View File

@@ -34,6 +34,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
'clients:view:all',
]);
$clients = Client::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4);
Passport::actingAs($data->user);
@@ -57,11 +58,43 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
);
}
public function test_index_endpoint_returns_list_of_clients_assigned_to_employee_user(): void
{
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
]);
$clients = Client::factory()->forOrganization($data->organization)->createMany(2);
$projectWithMembership1 = Project::factory()->forOrganization($data->organization)->forClient($clients->get(0))->addMember($data->member)->isPrivate()->create();
$projectWithMembership2 = Project::factory()->forOrganization($data->organization)->forClient($clients->get(1))->addMember($data->member)->isPrivate()->create();
$otherClients = Client::factory()->forOrganization($data->organization)->createMany(2);
$projectWithoutMembership = Project::factory()->forOrganization($data->organization)->forClient($otherClients->get(0))->isPrivate()->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.clients.index', [$data->organization->getKey()]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(2, 'data');
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('links')
->has('meta')
->count('data', 2)
->where('data.0.id', $clients->get(0)->getKey())
->where('data.1.id', $clients->get(1)->getKey())
);
}
public function test_index_endpoint_without_filter_archived_returns_only_non_archived_clients(): void
{
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
'clients:view:all',
]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
@@ -81,6 +114,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
'clients:view:all',
]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
@@ -103,6 +137,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
'clients:view:all',
]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
@@ -125,6 +160,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
'clients:view:all',
]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);

View File

@@ -3393,6 +3393,241 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_store_endpoint_blocks_overlapping_entries_when_start_overlaps(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
'end' => $baseEnd->copy()->addMinutes(30)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_store_endpoint_blocks_overlapping_entries_when_end_overlaps(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $baseStart->copy()->subMinutes(30)->toIso8601ZuluString(),
'end' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_store_endpoint_blocks_overlapping_entries_when_new_entry_is_within_existing(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $baseStart->copy()->addMinutes(15)->toIso8601ZuluString(),
'end' => $baseStart->copy()->addMinutes(45)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_store_endpoint_blocks_overlapping_entries_when_new_entry_surrounds_existing(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $baseStart->copy()->subMinutes(30)->toIso8601ZuluString(),
'end' => $baseEnd->copy()->addMinutes(30)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_store_endpoint_blocks_starting_active_entry_when_it_overlaps_with_existing(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
'end' => null,
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_store_endpoint_allows_future_time_entries_even_with_running_now(): void
{
// Arrange
$now = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$this->travelTo($now);
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $now->copy()->subHour(),
'end' => null,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $now->copy()->addDay()->toIso8601ZuluString(),
'end' => $now->copy()->addDay()->addHour()->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(201);
}
public function test_update_endpoint_blocks_overlap_and_excludes_current_entry(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 14, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 15, 0, 0, 'UTC');
$base = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
$toUpdate = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseEnd->copy()->addMinutes(30),
'end' => $baseEnd->copy()->addHour(),
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $toUpdate->getKey()]), [
'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_update_multiple_refreshes_billable_rate_on_updates_time_entries(): void
{
// Arrange

View File

@@ -9,6 +9,7 @@ use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Project;
use App\Models\Tag;
use App\Models\TimeEntry;
use App\Service\TimeEntryAggregationService;
use Illuminate\Support\Carbon;
@@ -1007,4 +1008,201 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
],
], $result);
}
public function test_aggregate_time_entries_group_by_tag_includes_no_tag_and_avoids_double_counting_overall(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$start = Carbon::now();
// One entry with two tags (100s)
TimeEntry::factory()->startWithDuration($start, 100)->create([
'tags' => [$tag1->getKey(), $tag2->getKey()],
]);
// One entry with one tag (50s)
TimeEntry::factory()->startWithDuration($start, 50)->create([
'tags' => [$tag1->getKey()],
]);
// One entry with no tags (25s)
TimeEntry::factory()->startWithDuration($start, 25)->create([
'tags' => [],
]);
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Tag,
null,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
null,
null
);
// Assert - overall total should be 175 and groups: null=25, tag1=150, tag2=100
$expected = [
'seconds' => 175,
'cost' => 0,
'grouped_type' => 'tag',
'grouped_data' => [
[
'key' => null,
'seconds' => 25,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $tag1->getKey(),
'seconds' => 150,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $tag2->getKey(),
'seconds' => 100,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
];
$this->assertEqualsCanonicalizing($expected, $result);
}
public function test_aggregate_time_entries_group_by_project_and_subgroup_tag(): void
{
// Arrange
$project = Project::factory()->create();
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$start = Carbon::now();
TimeEntry::factory()->startWithDuration($start, 120)->forProject($project)->create([
'tags' => [$tag1->getKey()],
]);
TimeEntry::factory()->startWithDuration($start, 60)->forProject($project)->create([
'tags' => [$tag2->getKey()],
]);
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
TimeEntryAggregationType::Tag,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
null,
null
);
// Assert
$expected = [
'seconds' => 180,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project->getKey(),
'seconds' => 180,
'cost' => 0,
'grouped_type' => 'tag',
'grouped_data' => [
[
'key' => $tag1->getKey(),
'seconds' => 120,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $tag2->getKey(),
'seconds' => 60,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
],
];
$this->assertEqualsCanonicalizing($expected, $result);
}
public function test_aggregate_time_entries_group_by_project_and_subgroup_tag_avoids_double_counting(): void
{
// Arrange
$project = Project::factory()->create();
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$start = Carbon::now();
// One entry with two tags => subgroup rows show both tags, but project total should equal entry duration
TimeEntry::factory()->startWithDuration($start, 100)->forProject($project)->create([
'tags' => [$tag1->getKey(), $tag2->getKey()],
]);
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
TimeEntryAggregationType::Tag,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
null,
null
);
// Assert
$expected = [
'seconds' => 100,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project->getKey(),
'seconds' => 100,
'cost' => 0,
'grouped_type' => 'tag',
'grouped_data' => [
[
'key' => $tag1->getKey(),
'seconds' => 100,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $tag2->getKey(),
'seconds' => 100,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
],
];
$this->assertEqualsCanonicalizing($expected, $result);
}
}

View File

@@ -6,6 +6,7 @@
"compilerOptions": {
"paths": {
"@/*": ["./resources/js/*"],
"@solidtime/ui": ["./resources/js/packages/ui/src/index.ts"]
}
},
"skipLibCheck": true,