Compare commits

..

1 Commits

Author SHA1 Message Date
Gregor Vostrak
231d22a5ce 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-03 14:59:45 +02:00
45 changed files with 406 additions and 970 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -112,7 +112,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$timeEntry->project_id = $projectId; $timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId; $timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id; $timeEntry->organization_id = $this->organization->id;
if (strlen($record['Description']) > 5000) { if (strlen($record['Description']) > 500) {
throw new ImportException('Time entry description is too long'); throw new ImportException('Time entry description is too long');
} }
$timeEntry->description = $record['Description']; $timeEntry->description = $record['Description'];

View File

@@ -107,7 +107,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
$timeEntry->project_id = $projectId; $timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId; $timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id; $timeEntry->organization_id = $this->organization->id;
if (strlen($record['Notes']) > 5000) { if (strlen($record['Notes']) > 500) {
throw new ImportException('Time entry note is too long'); throw new ImportException('Time entry note is too long');
} }
$timeEntry->description = $record['Notes']; $timeEntry->description = $record['Notes'];

View File

@@ -247,7 +247,7 @@ class SolidtimeImporter extends DefaultImporter
$timeEntry->project_id = $projectId; $timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId; $timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id; $timeEntry->organization_id = $this->organization->id;
if (strlen($timeEntryRow['description']) > 5000) { if (strlen($timeEntryRow['description']) > 500) {
throw new ImportException('Time entry description is too long'); throw new ImportException('Time entry description is too long');
} }
$timeEntry->description = $timeEntryRow['description']; $timeEntry->description = $timeEntryRow['description'];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,14 +36,20 @@ const ClientResource = z
const ClientCollection = z.array(ClientResource); const ClientCollection = z.array(ClientResource);
const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough(); const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
const ClientUpdateRequest = z const ClientUpdateRequest = z
.object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() }) .object({
name: z.string().min(1).max(255),
is_archived: z.boolean().optional(),
})
.passthrough(); .passthrough();
const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough(); const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();
const InvitationResource = z const InvitationResource = z
.object({ id: z.string(), email: z.string(), role: z.string() }) .object({ id: z.string(), email: z.string(), role: z.string() })
.passthrough(); .passthrough();
const InvitationStoreRequest = z const InvitationStoreRequest = z
.object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) }) .object({
email: z.string().email(),
role: z.enum(['admin', 'manager', 'employee']),
})
.passthrough(); .passthrough();
const InvoiceResource = z const InvoiceResource = z
.object({ .object({
@@ -91,7 +97,6 @@ const InvoiceStoreRequest = z
billing_period_end: z.union([z.string(), z.null()]).optional(), billing_period_end: z.union([z.string(), z.null()]).optional(),
reference: z.string(), reference: z.string(),
currency: z.string(), currency: z.string(),
payment_iban: z.union([z.string(), z.null()]).optional(),
tax_rate: z.number().int().gte(0).lte(2147483647).optional(), tax_rate: z.number().int().gte(0).lte(2147483647).optional(),
discount_amount: z.number().int().gte(0).lte(9223372036854776000).optional(), discount_amount: z.number().int().gte(0).lte(9223372036854776000).optional(),
discount_type: InvoiceDiscountType.optional(), discount_type: InvoiceDiscountType.optional(),
@@ -156,7 +161,6 @@ const DetailedInvoiceResource = z
discount_type: z.string(), discount_type: z.string(),
discount_amount: z.number().int(), discount_amount: z.number().int(),
tax_rate: z.number().int(), tax_rate: z.number().int(),
payment_iban: z.string(),
status: z.string(), status: z.string(),
currency: z.string(), currency: z.string(),
date: z.string(), date: z.string(),
@@ -202,7 +206,6 @@ const InvoiceUpdateRequest = z
billing_period_end: z.union([z.string(), z.null()]), billing_period_end: z.union([z.string(), z.null()]),
reference: z.string(), reference: z.string(),
currency: z.string(), currency: z.string(),
payment_iban: z.union([z.string(), z.null()]),
tax_rate: z.number().int().gte(0).lte(2147483647), tax_rate: z.number().int().gte(0).lte(2147483647),
discount_amount: z.number().int().gte(0).lte(9223372036854776000), discount_amount: z.number().int().gte(0).lte(9223372036854776000),
discount_type: InvoiceDiscountType, discount_type: InvoiceDiscountType,
@@ -387,7 +390,10 @@ const ProjectMemberResource = z
}) })
.passthrough(); .passthrough();
const ProjectMemberStoreRequest = z const ProjectMemberStoreRequest = z
.object({ member_id: z.string(), billable_rate: z.union([z.number(), z.null()]).optional() }) .object({
member_id: z.string(),
billable_rate: z.union([z.number(), z.null()]).optional(),
})
.passthrough(); .passthrough();
const ProjectMemberUpdateRequest = z const ProjectMemberUpdateRequest = z
.object({ billable_rate: z.union([z.number(), z.null()]) }) .object({ billable_rate: z.union([z.number(), z.null()]) })
@@ -416,7 +422,6 @@ const TimeEntryAggregationType = z.enum([
'client', 'client',
'billable', 'billable',
'description', 'description',
'tag',
]); ]);
const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']); const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);
const Weekday = z.enum([ const Weekday = z.enum([
@@ -428,7 +433,6 @@ const Weekday = z.enum([
'saturday', 'saturday',
'sunday', 'sunday',
]); ]);
const TimeEntryRoundingType = z.enum(['up', 'down', 'nearest']);
const ReportStoreRequest = z const ReportStoreRequest = z
.object({ .object({
name: z.string().max(255), name: z.string().max(255),
@@ -451,8 +455,6 @@ const ReportStoreRequest = z
history_group: TimeEntryAggregationTypeInterval, history_group: TimeEntryAggregationTypeInterval,
week_start: Weekday.optional(), week_start: Weekday.optional(),
timezone: z.union([z.string(), z.null()]).optional(), timezone: z.union([z.string(), z.null()]).optional(),
rounding_type: TimeEntryRoundingType.optional(),
rounding_minutes: z.union([z.number(), z.null()]).optional(),
}) })
.passthrough(), .passthrough(),
}) })
@@ -479,8 +481,6 @@ const DetailedReportResource = z
project_ids: z.union([z.array(z.string()), z.null()]), project_ids: z.union([z.array(z.string()), z.null()]),
tag_ids: z.union([z.array(z.string()), z.null()]), tag_ids: z.union([z.array(z.string()), z.null()]),
task_ids: z.union([z.array(z.string()), z.null()]), task_ids: z.union([z.array(z.string()), z.null()]),
rounding_type: z.union([z.string(), z.null()]),
rounding_minutes: z.union([z.number(), z.null()]),
}) })
.passthrough(), .passthrough(),
created_at: z.string(), created_at: z.string(),
@@ -594,7 +594,12 @@ const DetailedWithDataReportResource = z
}) })
.passthrough(); .passthrough();
const TagResource = z const TagResource = z
.object({ id: z.string(), name: z.string(), created_at: z.string(), updated_at: z.string() }) .object({
id: z.string(),
name: z.string(),
created_at: z.string(),
updated_at: z.string(),
})
.passthrough(); .passthrough();
const TagCollection = z.array(TagResource); const TagCollection = z.array(TagResource);
const TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough(); const TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
@@ -626,7 +631,6 @@ const TaskUpdateRequest = z
}) })
.passthrough(); .passthrough();
const start = z.union([z.string(), z.null()]).optional(); const start = z.union([z.string(), z.null()]).optional();
const rounding_minutes = z.union([z.number(), z.null()]).optional();
const TimeEntryResource = z const TimeEntryResource = z
.object({ .object({
id: z.string(), id: z.string(),
@@ -747,7 +751,6 @@ export const schemas = {
TimeEntryAggregationType, TimeEntryAggregationType,
TimeEntryAggregationTypeInterval, TimeEntryAggregationTypeInterval,
Weekday, Weekday,
TimeEntryRoundingType,
ReportStoreRequest, ReportStoreRequest,
DetailedReportResource, DetailedReportResource,
ReportUpdateRequest, ReportUpdateRequest,
@@ -760,7 +763,6 @@ export const schemas = {
TaskStoreRequest, TaskStoreRequest,
TaskUpdateRequest, TaskUpdateRequest,
start, start,
rounding_minutes,
TimeEntryResource, TimeEntryResource,
TimeEntryStoreRequest, TimeEntryStoreRequest,
TimeEntryUpdateMultipleRequest, TimeEntryUpdateMultipleRequest,
@@ -790,7 +792,13 @@ const endpoints = makeApi([
alias: 'getCurrencies', alias: 'getCurrencies',
requestFormat: 'json', requestFormat: 'json',
response: z.array( response: z.array(
z.object({ code: z.string(), name: z.string(), symbol: z.string() }).passthrough() z
.object({
code: z.string(),
name: z.string(),
symbol: z.string(),
})
.passthrough()
), ),
}, },
{ {
@@ -862,7 +870,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1157,7 +1168,13 @@ const endpoints = makeApi([
}, },
], ],
response: z.array( response: z.array(
z.object({ value: z.number().int(), name: z.string(), color: z.string() }).passthrough() z
.object({
value: z.number().int(),
name: z.string(),
color: z.string(),
})
.passthrough()
), ),
errors: [ errors: [
{ {
@@ -1220,7 +1237,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1263,7 +1283,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1311,7 +1334,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1339,7 +1365,11 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -1377,7 +1407,11 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -1433,7 +1467,7 @@ const endpoints = makeApi([
status: 400, status: 400,
schema: z.union([ schema: z.union([
z.object({ message: z.string() }).passthrough(), z.object({ message: z.string() }).passthrough(),
z.object({ message: z.literal('Invalid base64 encoded data') }).passthrough(), z.object({ message: z.string() }).passthrough(),
]), ]),
}, },
{ {
@@ -1455,7 +1489,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1476,7 +1513,11 @@ const endpoints = makeApi([
.object({ .object({
data: z.array( data: z.array(
z z
.object({ key: z.string(), name: z.string(), description: z.string() }) .object({
key: z.string(),
name: z.string(),
description: z.string(),
})
.passthrough() .passthrough()
), ),
}) })
@@ -1564,7 +1605,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1592,7 +1636,11 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -1614,7 +1662,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1760,7 +1811,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1803,7 +1857,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1846,7 +1903,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1930,7 +1990,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -1995,13 +2058,6 @@ const endpoints = makeApi([
], ],
response: z.object({ download_link: z.string() }).passthrough(), response: z.object({ download_link: z.string() }).passthrough(),
errors: [ errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{ {
status: 401, status: 401,
description: `Unauthenticated`, description: `Unauthenticated`,
@@ -2021,7 +2077,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2045,13 +2104,6 @@ const endpoints = makeApi([
], ],
response: z.object({ download_link: z.string() }).passthrough(), response: z.object({ download_link: z.string() }).passthrough(),
errors: [ errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{ {
status: 401, status: 401,
description: `Unauthenticated`, description: `Unauthenticated`,
@@ -2097,7 +2149,11 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -2119,7 +2175,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2189,7 +2248,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2222,7 +2284,11 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -2244,7 +2310,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2277,7 +2346,11 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -2299,7 +2372,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2327,7 +2403,11 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -2370,7 +2450,11 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -2433,7 +2517,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2549,7 +2636,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2592,7 +2682,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2676,7 +2769,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2704,7 +2800,11 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -2820,7 +2920,11 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -2842,7 +2946,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -2948,7 +3055,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3032,7 +3142,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3142,7 +3255,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3190,7 +3306,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3218,7 +3337,11 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -3313,7 +3436,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3356,7 +3482,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3404,7 +3533,10 @@ const endpoints = makeApi([
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3432,7 +3564,11 @@ const endpoints = makeApi([
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -3505,16 +3641,6 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
type: 'Query', type: 'Query',
schema: z.enum(['true', 'false']).optional(), schema: z.enum(['true', 'false']).optional(),
}, },
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{ {
name: 'user_id', name: 'user_id',
type: 'Query', type: 'Query',
@@ -3572,7 +3698,10 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3600,7 +3729,11 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -3622,7 +3755,10 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3665,7 +3801,10 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3708,7 +3847,10 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3741,7 +3883,11 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -3763,7 +3909,10 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -3833,7 +3982,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client', 'client',
'billable', 'billable',
'description', 'description',
'tag',
]) ])
.optional(), .optional(),
}, },
@@ -3852,7 +4000,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client', 'client',
'billable', 'billable',
'description', 'description',
'tag',
]) ])
.optional(), .optional(),
}, },
@@ -3891,16 +4038,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query', type: 'Query',
schema: z.enum(['true', 'false']).optional(), schema: z.enum(['true', 'false']).optional(),
}, },
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{ {
name: 'member_ids', name: 'member_ids',
type: 'Query', type: 'Query',
@@ -3985,7 +4122,10 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -4020,7 +4160,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client', 'client',
'billable', 'billable',
'description', 'description',
'tag',
]), ]),
}, },
{ {
@@ -4037,7 +4176,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'client', 'client',
'billable', 'billable',
'description', 'description',
'tag',
]), ]),
}, },
{ {
@@ -4085,16 +4223,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query', type: 'Query',
schema: z.enum(['true', 'false']).optional(), schema: z.enum(['true', 'false']).optional(),
}, },
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{ {
name: 'member_ids', name: 'member_ids',
type: 'Query', type: 'Query',
@@ -4130,7 +4258,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -4152,7 +4284,10 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -4213,16 +4348,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query', type: 'Query',
schema: z.enum(['true', 'false']).optional(), schema: z.enum(['true', 'false']).optional(),
}, },
{
name: 'rounding_type',
type: 'Query',
schema: z.enum(['up', 'down', 'nearest']).optional(),
},
{
name: 'rounding_minutes',
type: 'Query',
schema: rounding_minutes,
},
{ {
name: 'member_ids', name: 'member_ids',
type: 'Query', type: 'Query',
@@ -4253,7 +4378,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -4275,7 +4404,10 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -4357,7 +4489,11 @@ Please note that the access token is only shown in this response and cannot be r
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -4374,7 +4510,10 @@ Please note that the access token is only shown in this response and cannot be r
status: 422, status: 422,
description: `Validation error`, description: `Validation error`,
schema: z schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) }) .object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(), .passthrough(),
}, },
], ],
@@ -4397,7 +4536,11 @@ Please note that the access token is only shown in this response and cannot be r
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {
@@ -4435,7 +4578,11 @@ Please note that the access token is only shown in this response and cannot be r
status: 400, status: 400,
description: `API exception`, description: `API exception`,
schema: z schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() }) .object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(), .passthrough(),
}, },
{ {

View File

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

View File

@@ -250,7 +250,7 @@ const calendarOptions = computed(() => ({
editable: true, editable: true,
eventResizableFromStart: true, eventResizableFromStart: true,
eventDurationEditable: true, eventDurationEditable: true,
timeZone: getUserTimezone(), timeZone: 'America/Adak',
eventStartEditable: true, eventStartEditable: true,
select: handleDateSelect, select: handleDateSelect,
eventClick: handleEventClick, eventClick: handleEventClick,
@@ -332,16 +332,9 @@ watch(showEditTimeEntryModal, (value) => {
</template> </template>
<template #dayHeaderContent="arg"> <template #dayHeaderContent="arg">
<FullCalendarDayHeader <FullCalendarDayHeader
:date=" :date="arg.date"
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
"
:total-minutes=" :total-minutes="
dailyTotals[ dailyTotals[getDayJsInstance()(arg.date).format('YYYY-MM-DD')] || 0
getDayJsInstance()(arg.date)
.utc()
.tz(getUserTimezone(), true)
.format('YYYY-MM-DD')
] || 0
" /> " />
</template> </template>
</FullCalendar> </FullCalendar>

View File

@@ -93,7 +93,6 @@ const inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:m
data-testid="time_picker_input" data-testid="time_picker_input"
type="text" type="text"
@blur="updateTime" @blur="updateTime"
@keydown.enter.prevent="updateTime"
@focus="($event.target as HTMLInputElement).select()" @focus="($event.target as HTMLInputElement).select()"
@mouseup="($event.target as HTMLInputElement).select()" @mouseup="($event.target as HTMLInputElement).select()"
@click="($event.target as HTMLInputElement).select()" @click="($event.target as HTMLInputElement).select()"

View File

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

View File

@@ -33,7 +33,6 @@ const props = defineProps<{
createProject: (project: CreateProjectBody) => Promise<Project | undefined>; createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>; createClient: (client: CreateClientBody) => Promise<Client | undefined>;
onStartStopClick: (timeEntry: TimeEntry) => void; onStartStopClick: (timeEntry: TimeEntry) => void;
duplicateTimeEntry: (timeEntry: TimeEntry) => void;
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void; updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
updateTimeEntry: (timeEntry: TimeEntry) => void; updateTimeEntry: (timeEntry: TimeEntry) => void;
deleteTimeEntries: (timeEntries: TimeEntry[]) => void; deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
@@ -174,7 +173,6 @@ function onSelectChange(checked: boolean) {
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop> @changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown <TimeEntryMoreOptionsDropdown
:show-edit="false" :show-edit="false"
:show-duplicate="false"
@delete=" @delete="
deleteTimeEntries(timeEntry?.timeEntries ?? []) deleteTimeEntries(timeEntry?.timeEntries ?? [])
"></TimeEntryMoreOptionsDropdown> "></TimeEntryMoreOptionsDropdown>
@@ -204,7 +202,6 @@ function onSelectChange(checked: boolean) {
:update-time-entry="(timeEntry: TimeEntry) => updateTimeEntry(timeEntry)" :update-time-entry="(timeEntry: TimeEntry) => updateTimeEntry(timeEntry)"
:on-start-stop-click="() => onStartStopClick(subEntry)" :on-start-stop-click="() => onStartStopClick(subEntry)"
:delete-time-entry="() => deleteTimeEntries([subEntry])" :delete-time-entry="() => deleteTimeEntries([subEntry])"
:duplicate-time-entry="() => duplicateTimeEntry(subEntry)"
:currency="currency" :currency="currency"
:create-tag :create-tag
:time-entry="subEntry" :time-entry="subEntry"

View File

@@ -68,6 +68,19 @@ watch(
{ immediate: true } { 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({ const localStart = computed({
get: () => get: () =>
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '', editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '',

View File

@@ -108,7 +108,6 @@ function startTimeEntryFromExisting(entry: TimeEntry) {
tags: [...entry.tags], tags: [...entry.tags],
}); });
} }
function sumDuration(timeEntries: TimeEntry[]) { function sumDuration(timeEntries: TimeEntry[]) {
return timeEntries.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0); return timeEntries.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
} }
@@ -159,7 +158,6 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
:tags="tags" :tags="tags"
:clients :clients
:on-start-stop-click="startTimeEntryFromExisting" :on-start-stop-click="startTimeEntryFromExisting"
:duplicate-time-entry="createTimeEntry"
:update-time-entries :update-time-entries
:update-time-entry :update-time-entry
:delete-time-entries :delete-time-entries
@@ -200,7 +198,6 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
:update-time-entry :update-time-entry
:on-start-stop-click="() => startTimeEntryFromExisting(entry)" :on-start-stop-click="() => startTimeEntryFromExisting(entry)"
:delete-time-entry="() => deleteTimeEntries([entry])" :delete-time-entry="() => deleteTimeEntries([entry])"
:duplicate-time-entry="() => createTimeEntry(entry)"
:currency="currency" :currency="currency"
:time-entry="entry.timeEntries[0]" :time-entry="entry.timeEntries[0]"
@selected="selectedTimeEntries.push(entry)" @selected="selectedTimeEntries.push(entry)"

View File

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

View File

@@ -36,7 +36,6 @@ const props = defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>; createClient: (client: CreateClientBody) => Promise<Client | undefined>;
onStartStopClick: () => void; onStartStopClick: () => void;
deleteTimeEntry: () => void; deleteTimeEntry: () => void;
duplicateTimeEntry?: () => void;
updateTimeEntry: (timeEntry: TimeEntry) => void; updateTimeEntry: (timeEntry: TimeEntry) => void;
currency: string; currency: string;
showMember?: boolean; showMember?: boolean;
@@ -167,7 +166,6 @@ async function handleDeleteTimeEntry() {
@changed="onStartStopClick"></TimeTrackerStartStop> @changed="onStartStopClick"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown <TimeEntryMoreOptionsDropdown
@edit="handleEdit" @edit="handleEdit"
@duplicate="duplicateTimeEntry"
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown> @delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
</div> </div>
</div> </div>

View File

@@ -29,7 +29,7 @@ const open = ref(false);
function updateTimerAndStartLiveTimerUpdate() { function updateTimerAndStartLiveTimerUpdate() {
const defaultUnit = const defaultUnit =
organizationSettings?.value?.intervalFormat === 'decimal' ? 'hours' : 'minutes'; organizationSettings?.value?.intervalFormat === 'decimal' ? 'hours' : 'minutes';
const seconds = parseTimeInput(temporaryCustomTimerEntry.value, defaultUnit); const { seconds } = parseTimeInput(temporaryCustomTimerEntry.value, defaultUnit);
if (seconds && seconds > 0) { if (seconds && seconds > 0) {
let newEndDate = props.end; let newEndDate = props.end;
let newStartDate = props.start; let newStartDate = props.start;

View File

@@ -49,7 +49,6 @@ const emit = defineEmits<{
updateTimeEntry: []; updateTimeEntry: [];
startLiveTimer: []; startLiveTimer: [];
stopLiveTimer: []; stopLiveTimer: [];
createTimeEntry: [];
}>(); }>();
function updateProject() { function updateProject() {
@@ -281,7 +280,6 @@ useSelectEvents(
@stop-live-timer="emit('stopLiveTimer')" @stop-live-timer="emit('stopLiveTimer')"
@update-timer="emit('updateTimeEntry')" @update-timer="emit('updateTimeEntry')"
@start-timer="emit('startTimer')" @start-timer="emit('startTimer')"
@create-time-entry="emit('createTimeEntry')"
@keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector> @keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,114 +0,0 @@
<?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,7 +34,6 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'clients:view', 'clients:view',
'clients:view:all',
]); ]);
$clients = Client::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4); $clients = Client::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4);
Passport::actingAs($data->user); Passport::actingAs($data->user);
@@ -58,43 +57,11 @@ 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 public function test_index_endpoint_without_filter_archived_returns_only_non_archived_clients(): void
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'clients:view', 'clients:view',
'clients:view:all',
]); ]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2); $archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2); $nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
@@ -114,7 +81,6 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'clients:view', 'clients:view',
'clients:view:all',
]); ]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2); $archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2); $nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
@@ -137,7 +103,6 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'clients:view', 'clients:view',
'clients:view:all',
]); ]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2); $archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2); $nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
@@ -160,7 +125,6 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'clients:view', 'clients:view',
'clients:view:all',
]); ]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2); $archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2); $nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);

View File

@@ -9,7 +9,6 @@ use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Models\Client; use App\Models\Client;
use App\Models\Project; use App\Models\Project;
use App\Models\Tag;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\TimeEntryAggregationService; use App\Service\TimeEntryAggregationService;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -1008,201 +1007,4 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
], ],
], $result); ], $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);
}
} }