add timesheets page

This commit is contained in:
Gregor Vostrak
2026-04-30 02:50:02 +02:00
parent 1cc000a584
commit 09af0f775f
28 changed files with 2424 additions and 147 deletions

View File

@@ -0,0 +1,202 @@
import { type Dayjs } from 'dayjs';
import type { TimeEntry } from '@/packages/api/src';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
// `getDayJsInstance()` reads window-injected settings (week-start), which
// aren't available at module load. Each function calls it lazily at use
// time. The cost is a per-call locale update; cellMath doesn't use any
// week-start-aware APIs so it's a no-op functionally.
/**
* UTC ISO of 09:00 local on `date` — the preferred placement for new
* work when an empty day needs a default start time.
*/
export function workDayStartOn(date: string, tz: string): string {
const dayjs = getDayJsInstance();
return dayjs.tz(`${date} 09:00:00`, tz).utc().format();
}
export interface FreeWindow {
start: string;
end: string;
}
interface Interval {
start: Dayjs;
end: Dayjs;
}
/**
* Collect entries that intersect the day `[dayStart, dayEnd)`, clipped
* to those bounds. Running entries use `nowDayjs` as their end.
*/
function collectDayObstacles(
entries: TimeEntry[],
dayStart: Dayjs,
dayEnd: Dayjs,
nowDayjs: Dayjs
): Interval[] {
const dayjs = getDayJsInstance();
const obstacles: Interval[] = [];
for (const entry of entries) {
const entryStart = dayjs.utc(entry.start);
const entryEnd = entry.end ? dayjs.utc(entry.end) : nowDayjs;
if (entryEnd.isSameOrBefore(dayStart)) continue;
if (entryStart.isSameOrAfter(dayEnd)) continue;
const clippedStart = entryStart.isBefore(dayStart) ? dayStart : entryStart;
const clippedEnd = entryEnd.isAfter(dayEnd) ? dayEnd : entryEnd;
obstacles.push({ start: clippedStart, end: clippedEnd });
}
return obstacles;
}
/**
* First free window on the local calendar day that fits `requiredSeconds`
* without colliding with any existing entry. Returns `null` if nothing fits
* — never crosses midnight.
*
* Obstacles include same-day entries, spillovers from adjacent days, and
* running entries (treated as `end = now`). All are clipped to the day's
* `[00:00, 24:00)` boundaries.
*
* `preferredStart` (UTC ISO) is a hard floor — windows with `start` before
* it are rejected. Use it to place "after some cursor."
*/
export function findFreeWindowOnDay(
entries: TimeEntry[],
date: string,
requiredSeconds: number,
tz: string,
preferredStart?: string | null,
now?: string | Dayjs
): FreeWindow | null {
if (requiredSeconds <= 0) return null;
const dayjs = getDayJsInstance();
const dayStart = dayjs.tz(`${date} 00:00:00`, tz).utc();
const dayEnd = dayStart.add(1, 'day');
if (requiredSeconds > dayEnd.diff(dayStart, 'second')) return null;
const nowDayjs = now ? dayjs.utc(now) : dayjs.utc();
const obstacles = collectDayObstacles(entries, dayStart, dayEnd, nowDayjs);
// Sort + merge so we can walk a clean [gap, obstacle, gap, ...] sequence.
obstacles.sort((a, b) => a.start.diff(b.start));
// merge overlaps
const merged: Interval[] = [];
for (const obs of obstacles) {
const last = merged[merged.length - 1];
if (last && obs.start.isSameOrBefore(last.end)) {
if (obs.end.isAfter(last.end)) {
last.end = obs.end;
}
} else {
merged.push({ start: obs.start, end: obs.end });
}
}
let cursor: Dayjs = dayStart;
if (preferredStart) {
const pref = dayjs.utc(preferredStart);
if (pref.isAfter(cursor)) cursor = pref;
}
if (cursor.isSameOrAfter(dayEnd)) return null;
for (const obs of merged) {
if (obs.end.isSameOrBefore(cursor)) continue;
if (obs.start.isAfter(cursor)) {
const gapSeconds = obs.start.diff(cursor, 'second');
if (gapSeconds >= requiredSeconds) {
return {
start: cursor.format(),
end: cursor.add(requiredSeconds, 'second').format(),
};
}
}
if (obs.end.isAfter(cursor)) cursor = obs.end;
if (cursor.isSameOrAfter(dayEnd)) return null;
}
const trailingSeconds = dayEnd.diff(cursor, 'second');
if (trailingSeconds >= requiredSeconds) {
return {
start: cursor.format(),
end: cursor.add(requiredSeconds, 'second').format(),
};
}
return null;
}
/**
* Seconds of free space starting at `cursor` until the next obstacle
* (or end of day). Returns 0 if the cursor is inside an obstacle or past
* midnight. Used by the extend path: "how far can I push this end forward?"
*/
export function freeGapSecondsAfter(
entries: TimeEntry[],
date: string,
tz: string,
cursor: string,
now?: string | Dayjs
): number {
const dayjs = getDayJsInstance();
const dayStart = dayjs.tz(`${date} 00:00:00`, tz).utc();
const dayEnd = dayStart.add(1, 'day');
const cursorDjs = dayjs.utc(cursor);
if (cursorDjs.isSameOrAfter(dayEnd)) return 0;
if (cursorDjs.isBefore(dayStart)) return 0;
const nowDayjs = now ? dayjs.utc(now) : dayjs.utc();
// Drop obstacles ending at/before the cursor — they're behind us.
const obstacles = collectDayObstacles(entries, dayStart, dayEnd, nowDayjs).filter((obs) =>
obs.end.isAfter(cursorDjs)
);
obstacles.sort((a, b) => a.start.diff(b.start));
// Cursor inside an obstacle → no gap.
for (const obs of obstacles) {
if (obs.start.isSameOrBefore(cursorDjs) && obs.end.isAfter(cursorDjs)) {
return 0;
}
}
// Distance to first obstacle strictly after cursor, or to end of day.
for (const obs of obstacles) {
if (obs.start.isAfter(cursorDjs)) {
return Math.max(0, obs.start.diff(cursorDjs, 'second'));
}
}
return Math.max(0, dayEnd.diff(cursorDjs, 'second'));
}
/**
* Thrown when a required duration cannot fit on the target day without
* introducing an overlap. Callers reformat the message for end users.
*/
export class NoFreeWindowError extends Error {
public readonly code = 'no_free_window' as const;
public readonly date: string;
public readonly requiredSeconds: number;
constructor(date: string, requiredSeconds: number) {
super(
`Cannot fit ${requiredSeconds} seconds on ${date} without overlapping existing time entries.`
);
this.name = 'NoFreeWindowError';
this.date = date;
this.requiredSeconds = requiredSeconds;
}
}

View File

@@ -0,0 +1,242 @@
import { ref, type Ref } from 'vue';
import type { Dayjs } from 'dayjs';
import axios from 'axios';
import { useQueryClient } from '@tanstack/vue-query';
import {
api,
type CreateTimeEntryBody,
type TimeEntry,
type TimeEntryResponse,
} from '@/packages/api/src';
import {
getDayJsInstance,
getLocalizedDateFromTimestamp,
localDateToUtc,
} from '@/packages/ui/src/utils/time';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { fetchTimesheetEntries } from '@/utils/useTimesheetQuery';
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
import { makeRowKey, type TimesheetRow } from '@/utils/useTimesheetGrid';
import { useNotificationsStore } from '@/utils/notification';
import { findFreeWindowOnDay, workDayStartOn } from './cellMath';
/**
* Implements both variants of "Copy last week":
*
* - `copyLastWeekRows()` — only add rows for each distinct
* (project, task) pair from last week
* that doesn't already exist
* - `copyLastWeekWithTime()` — same, but also duplicates each
* previous-week entry into the same
* day-of-week in the current week,
* stacking copies after any existing
* work on that day
*/
export function useCopyLastWeek(
weekStart: Ref<Dayjs>,
weekDays: Ref<string[]>,
rows: Ref<TimesheetRow[]>,
timeEntries: Ref<TimeEntry[]>,
addSlot: (
projectId: string | null,
taskId: string | null,
billable: boolean,
tags: string[]
) => string
) {
const dayjs = getDayJsInstance();
const queryClient = useQueryClient();
const { addNotification } = useNotificationsStore();
const isCopyingLastWeek = ref(false);
async function fetchLastWeekEntries(): Promise<TimeEntryResponse | null> {
const prevStart = weekStart.value.subtract(7, 'day');
const prevEnd = weekStart.value;
const orgId = getCurrentOrganizationId();
const memberId = getCurrentMembershipId();
if (!orgId) return null;
return await fetchTimesheetEntries(
orgId,
memberId,
localDateToUtc(prevStart),
localDateToUtc(prevEnd)
);
}
/**
* For every entry in `prevEntries`, if the current week doesn't
* already have a row for that (project, task) combination, add one.
* Deduplicates so each combination is added at most once.
*/
function addMissingRowsFromPreviousWeek(prevEntries: TimeEntry[]): void {
const existingIdentities = new Set(
rows.value.map((r) => makeRowKey(r.projectId, r.taskId, r.billable, r.tags))
);
const addedIdentities = new Set<string>();
for (const entry of prevEntries) {
const tags = entry.tags ?? [];
const identity = makeRowKey(entry.project_id, entry.task_id, entry.billable, tags);
if (!existingIdentities.has(identity) && !addedIdentities.has(identity)) {
addedIdentities.add(identity);
addSlot(entry.project_id, entry.task_id, entry.billable, tags);
}
}
}
async function copyLastWeekRows(): Promise<void> {
isCopyingLastWeek.value = true;
try {
const prev = await fetchLastWeekEntries();
if (!prev) return;
addMissingRowsFromPreviousWeek(prev.data);
} finally {
isCopyingLastWeek.value = false;
}
}
async function copyLastWeekWithTime(): Promise<void> {
isCopyingLastWeek.value = true;
try {
const prev = await fetchLastWeekEntries();
if (!prev) return;
const orgId = getCurrentOrganizationId();
const memberId = getCurrentMembershipId();
if (!orgId || !memberId) return;
const tz = getUserTimezone();
addMissingRowsFromPreviousWeek(prev.data);
const prevWeekStart = weekStart.value.subtract(7, 'day');
// Working copy of the current week's entries; placed copies
// are appended so subsequent placement queries see them as
// obstacles (timeEntries.value isn't refreshed until the
// queryClient.invalidate at the end of the loop).
const workingEntries: TimeEntry[] = [...timeEntries.value];
let attempted = 0;
let succeeded = 0;
let overlapFailures = 0;
let otherFailures = 0;
for (const entry of prev.data) {
if (!entry.end || !entry.duration) continue;
// Map previous-week date → same day-of-week in current week.
const entryDate = getLocalizedDateFromTimestamp(entry.start);
const dayOffset = dayjs(entryDate).diff(prevWeekStart, 'day');
const newDate = weekDays.value[dayOffset];
if (!newDate) continue;
// Try the source's wall-clock time on the target day first
// (preserves "Monday 14:00 meeting" → "Monday 14:00 meeting"
// when the slot is free); fall back to 09:00, then to
// anywhere on the day.
const sourceTimeOfDay = dayjs.utc(entry.start).tz(tz).format('HH:mm:ss');
const sourceStartOnTarget = dayjs
.tz(`${newDate} ${sourceTimeOfDay}`, tz)
.utc()
.format();
const window =
findFreeWindowOnDay(
workingEntries,
newDate,
entry.duration,
tz,
sourceStartOnTarget
) ??
findFreeWindowOnDay(
workingEntries,
newDate,
entry.duration,
tz,
workDayStartOn(newDate, tz)
) ??
findFreeWindowOnDay(workingEntries, newDate, entry.duration, tz);
if (!window) {
attempted++;
otherFailures++;
continue;
}
const body: CreateTimeEntryBody = {
member_id: memberId,
project_id: entry.project_id,
task_id: entry.task_id,
start: window.start,
end: window.end,
billable: entry.billable,
description: entry.description ?? null,
tags: entry.tags ?? [],
};
attempted++;
try {
await api.createTimeEntry(body, { params: { organization: orgId } });
succeeded++;
workingEntries.push({
start: window.start,
end: window.end,
} as TimeEntry);
} catch (error) {
if (
axios.isAxiosError(error) &&
error.response?.data?.key === 'overlapping_time_entry'
) {
overlapFailures++;
} else {
otherFailures++;
}
}
}
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
if (attempted === 0) return;
if (succeeded === attempted) {
addNotification(
'success',
`Copied ${succeeded} ${succeeded === 1 ? 'entry' : 'entries'} from last week`
);
} else if (succeeded > 0) {
const skipped = overlapFailures + otherFailures;
const detail =
overlapFailures > 0 && otherFailures === 0
? `${overlapFailures} overlapping`
: otherFailures > 0 && overlapFailures === 0
? `${otherFailures} failed`
: `${skipped} skipped`;
addNotification(
'error',
`Copied ${succeeded} of ${attempted} entries from last week`,
`${detail}.`
);
} else {
addNotification(
'error',
'Failed to copy entries from last week',
overlapFailures > 0 && otherFailures === 0
? 'All entries would overlap with existing time entries.'
: 'Please try again later.'
);
}
} finally {
isCopyingLastWeek.value = false;
}
}
return {
isCopyingLastWeek,
copyLastWeekRows,
copyLastWeekWithTime,
};
}

View File

@@ -0,0 +1,322 @@
import type { Ref } from 'vue';
import { useQueryClient } from '@tanstack/vue-query';
import { api, type CreateTimeEntryBody, type TimeEntry } from '@/packages/api/src';
import { formatHumanReadableDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
import {
makeRowKey,
type TimesheetCell,
type TimesheetRow,
type TimesheetRowKey,
} from '@/utils/useTimesheetGrid';
import { useNotificationsStore } from '@/utils/notification';
import {
findFreeWindowOnDay,
freeGapSecondsAfter,
NoFreeWindowError,
workDayStartOn,
type FreeWindow,
} from './cellMath';
/**
* Cell-level edit dispatcher. Picks one of four strategies based on
* the diff between current and requested totals:
*
* - deleteCell — new total is 0
* - createCell — empty cell, place in first free window
* - extendCell — diff > 0, push the latest-ending entry forward,
* splitting the remainder into a new entry if a
* collision blocks the path
* - shrinkFromEnd — diff < 0, shorten / delete entries from most-
* recent backwards
*
* Running entries (end === null) are treated as immutable. Both create
* and extend can throw NoFreeWindowError when the day is too full.
*
* Calls the API directly (not via useTimeEntriesMutations) so a single
* cell edit fanning into multiple mutations produces exactly one toast
* and one cache invalidation.
*/
export function useTimesheetCellMutations(
weekDays: Ref<string[]>,
timeEntries: Ref<TimeEntry[]>,
rows: Ref<TimesheetRow[]>,
removeSlot: (key: TimesheetRowKey) => void
) {
const dayjs = getDayJsInstance();
const queryClient = useQueryClient();
const notifications = useNotificationsStore();
async function handleCellUpdate(
row: TimesheetRow,
dayIndex: number,
newTotalSeconds: number
): Promise<void> {
const cell = row.cells.get(dayIndex);
const existingSeconds = cell?.totalSeconds ?? 0;
if (newTotalSeconds === existingSeconds) return;
// Capture row state before the mutation: a row that was empty
// and shares identity with another slot collapses after the
// first entry lands, so the entry naturally identity-routes to
// the surviving slot.
const wasEmpty = row.totalSeconds === 0;
try {
await dispatchCellUpdate(row, dayIndex, newTotalSeconds);
if (wasEmpty && newTotalSeconds > 0 && hasDuplicateIdentitySlot(row)) {
removeSlot(row.key);
notifications.addNotification(
'success',
'Merged into matching row',
'Another row with the same project, task, billable status and tags already exists.'
);
}
} catch (err) {
if (err instanceof NoFreeWindowError) {
const friendlyDuration = formatHumanReadableDuration(
err.requiredSeconds,
'hours-minutes',
'point'
);
notifications.addNotification(
'error',
"This day can't fit any more work",
`Couldn't fit ${friendlyDuration} on ${err.date} without overlapping existing entries.`
);
return;
}
notifications.addNotification(
'error',
'Failed to update timesheet',
'Please try again later.'
);
throw err;
} finally {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
}
}
function hasDuplicateIdentitySlot(row: TimesheetRow): boolean {
const target = makeRowKey(row.projectId, row.taskId, row.billable, row.tags);
return rows.value.some(
(r) =>
r.key !== row.key &&
makeRowKey(r.projectId, r.taskId, r.billable, r.tags) === target
);
}
async function dispatchCellUpdate(
row: TimesheetRow,
dayIndex: number,
newTotalSeconds: number
): Promise<void> {
const cell = row.cells.get(dayIndex);
const existingSeconds = cell?.totalSeconds ?? 0;
const diff = newTotalSeconds - existingSeconds;
if (newTotalSeconds === 0 && cell) {
await deleteCell(cell);
return;
}
if (!cell || existingSeconds === 0) {
await createCell(row, dayIndex, newTotalSeconds);
return;
}
if (diff > 0) {
await extendCell(row, dayIndex, cell, diff);
return;
}
await shrinkFromEnd(cell, -diff);
}
async function deleteCell(cell: TimesheetCell): Promise<void> {
const orgId = requireOrgId();
await api.deleteTimeEntries(undefined, {
queries: { ids: cell.entries.map((e) => e.id) },
params: { organization: orgId },
});
}
/**
* Place a new entry on the cell's day. Without `afterCursor`, prefers
* 09:00 local with a fall-back to start-of-day. With `afterCursor`,
* places strictly at-or-after that timestamp (used by extendCell to
* skip past a just-written extension that timeEntries.value doesn't
* yet reflect). Throws NoFreeWindowError if nothing fits.
*/
async function createCell(
row: TimesheetRow,
dayIndex: number,
totalSeconds: number,
afterCursor?: string
): Promise<void> {
const date = weekDays.value[dayIndex]!;
const tz = getUserTimezone();
let window: FreeWindow | null;
if (afterCursor) {
window = findFreeWindowOnDay(timeEntries.value, date, totalSeconds, tz, afterCursor);
} else {
window =
findFreeWindowOnDay(
timeEntries.value,
date,
totalSeconds,
tz,
workDayStartOn(date, tz)
) ?? findFreeWindowOnDay(timeEntries.value, date, totalSeconds, tz);
}
if (!window) throw new NoFreeWindowError(date, totalSeconds);
const orgId = requireOrgId();
const memberId = getCurrentMembershipId();
if (!memberId) throw new Error('No member context');
const body: CreateTimeEntryBody = {
member_id: memberId,
project_id: row.projectId,
task_id: row.taskId,
start: window.start,
end: window.end,
billable: row.billable,
description: null,
tags: row.tags,
};
await api.createTimeEntry(body, { params: { organization: orgId } });
}
/**
* Push the latest-ending entry's end forward by `addSeconds`, and if
* a collision blocks the path before that's exhausted, place the
* remainder as a fresh entry in the next free window on the day.
*/
async function extendCell(
row: TimesheetRow,
dayIndex: number,
cell: TimesheetCell,
addSeconds: number
): Promise<void> {
const date = weekDays.value[dayIndex]!;
const tz = getUserTimezone();
// Latest END (not latest start) — extending a nested inner entry
// would leave the outer one as the true tail.
const candidate = pickLatestEndedEntry(cell);
// Running timer (or no ended entry): can't extend, place it all
// as a new entry instead.
if (!candidate || !candidate.end) {
await createCell(row, dayIndex, addSeconds);
return;
}
const gap = freeGapSecondsAfter(timeEntries.value, date, tz, candidate.end);
const extendBy = Math.min(addSeconds, gap);
const remainder = addSeconds - extendBy;
const projectedNewEnd = dayjs.utc(candidate.end).add(extendBy, 'second').format();
// Pre-flight: if there's a remainder, make sure it'll fit in a
// window after `projectedNewEnd` BEFORE we issue the extend PATCH.
// Otherwise a successful extend followed by a no-fit createCell
// would leave the entry persistently lengthened on the server
// while the user sees a "can't fit" error.
if (remainder > 0) {
const fit = findFreeWindowOnDay(
timeEntries.value,
date,
remainder,
tz,
projectedNewEnd
);
if (!fit) throw new NoFreeWindowError(date, addSeconds);
}
if (extendBy > 0) {
await updateEntry({ ...candidate, end: projectedNewEnd });
}
if (remainder <= 0) return;
// timeEntries.value is stale here (still shows candidate's old
// end). Force the placement search past projectedNewEnd so it
// can't propose a window that overlaps the just-extended candidate.
await createCell(row, dayIndex, remainder, projectedNewEnd);
}
async function shrinkFromEnd(cell: TimesheetCell, removeSeconds: number): Promise<void> {
let toRemove = removeSeconds;
// Shrink doesn't introduce overlaps, so latest-START is fine here.
const sortedEntries = [...cell.entries].sort((a, b) => b.start.localeCompare(a.start));
for (const entry of sortedEntries) {
if (toRemove <= 0) break;
if (!entry.end) continue; // running entries are immutable
const entryDuration = entry.duration ?? 0;
if (entryDuration <= toRemove) {
await deleteEntry(entry.id);
toRemove -= entryDuration;
} else {
const newEnd = dayjs
.utc(entry.start)
.add(entryDuration - toRemove, 'second')
.format();
await updateEntry({ ...entry, end: newEnd });
toRemove = 0;
}
}
}
// ── api helpers ───────────────────────────────────────────────
function requireOrgId(): string {
const id = getCurrentOrganizationId();
if (!id) throw new Error('No organization context');
return id;
}
async function updateEntry(entry: TimeEntry) {
const orgId = requireOrgId();
await api.updateTimeEntry(entry, {
params: { organization: orgId, timeEntry: entry.id },
});
}
async function deleteEntry(id: string) {
const orgId = requireOrgId();
await api.deleteTimeEntry(undefined, {
params: { organization: orgId, timeEntry: id },
});
}
function pickLatestEndedEntry(cell: TimesheetCell): TimeEntry | null {
let best: TimeEntry | null = null;
for (const entry of cell.entries) {
if (!best) {
best = entry;
continue;
}
// Running entries are treated as "infinite" — they win.
if (!entry.end) {
best = entry;
continue;
}
if (best.end && entry.end > best.end) {
best = entry;
}
}
return best;
}
return { handleCellUpdate };
}

View File

@@ -0,0 +1,70 @@
import { computed, ref, type Ref } from 'vue';
import type { Project, TimeEntry } from '@/packages/api/src';
import type { TimesheetRow, TimesheetRowKey } from '@/utils/useTimesheetGrid';
import type { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
type Mutations = ReturnType<typeof useTimeEntriesMutations>;
/**
* Holds the state and handlers for the "remove row" confirmation flow.
*
* Empty rows (no entries) are removed immediately without confirmation;
* rows with entries open a confirmation dialog, and on confirm we bulk
* delete every entry in the row before dropping the row from the grid.
*/
export function useTimesheetRowDeletion(
projects: Ref<Project[]>,
mutations: Pick<Mutations, 'deleteTimeEntries'>,
removeSlot: (key: TimesheetRowKey) => void
) {
const showDeleteDialog = ref(false);
const rowToDelete = ref<TimesheetRow | null>(null);
const deleteRowEntryCount = computed(() => {
if (!rowToDelete.value) return 0;
let count = 0;
for (const cell of rowToDelete.value.cells.values()) {
count += cell.entries.length;
}
return count;
});
const deleteRowProjectName = computed(() => {
if (!rowToDelete.value?.projectId) return 'No Project';
return projects.value.find((p) => p.id === rowToDelete.value?.projectId)?.name ?? 'Unknown';
});
function requestRemoveRow(row: TimesheetRow): void {
if (row.totalSeconds === 0) {
removeSlot(row.key);
return;
}
rowToDelete.value = row;
showDeleteDialog.value = true;
}
async function confirmDeleteRow(): Promise<void> {
if (!rowToDelete.value) return;
const allEntries: TimeEntry[] = [];
for (const cell of rowToDelete.value.cells.values()) {
allEntries.push(...cell.entries);
}
if (allEntries.length > 0) {
await mutations.deleteTimeEntries(allEntries);
}
removeSlot(rowToDelete.value.key);
showDeleteDialog.value = false;
rowToDelete.value = null;
}
return {
showDeleteDialog,
rowToDelete,
deleteRowEntryCount,
deleteRowProjectName,
requestRemoveRow,
confirmDeleteRow,
};
}

View File

@@ -0,0 +1,150 @@
import type { Ref } from 'vue';
import type { Project, UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
import {
makeRowKey,
type TimesheetRow,
type TimesheetRowIdentity,
type TimesheetRowKey,
} from '@/utils/useTimesheetGrid';
import type { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { useNotificationsStore } from '@/utils/notification';
function identityPartialToApiChanges(
partial: Partial<TimesheetRowIdentity>
): UpdateMultipleTimeEntriesChangeset {
const changes: UpdateMultipleTimeEntriesChangeset = {};
if ('projectId' in partial) changes.project_id = partial.projectId;
if ('taskId' in partial) changes.task_id = partial.taskId;
if ('billable' in partial) changes.billable = partial.billable;
if ('tags' in partial) changes.tags = partial.tags;
return changes;
}
type Mutations = ReturnType<typeof useTimeEntriesMutations>;
/**
* Row-level mutations that don't involve confirmation.
*
* Rows are keyed by slot id (not identity), so any partial change to
* a row's identity is handled the same way: push the change to the
* server for any entries in the row, then migrate the slot's identity
* in place so the row stays at its existing position.
*/
export function useTimesheetRowMutations(
mutations: Pick<Mutations, 'updateTimeEntries'>,
projects: Ref<Project[]>,
rows: Ref<TimesheetRow[]>,
addSlot: (
projectId: string | null,
taskId: string | null,
billable: boolean,
tags: string[]
) => TimesheetRowKey,
updateSlot: (key: TimesheetRowKey, identity: TimesheetRowIdentity) => void,
removeSlot: (key: TimesheetRowKey) => void
) {
const notifications = useNotificationsStore();
function collectEntryIds(row: TimesheetRow): string[] {
const ids: string[] = [];
for (const cell of row.cells.values()) {
for (const entry of cell.entries) ids.push(entry.id);
}
return ids;
}
function hasDuplicateIdentityRow(
rowKey: TimesheetRowKey,
identity: TimesheetRowIdentity
): boolean {
const target = makeRowKey(
identity.projectId,
identity.taskId,
identity.billable,
identity.tags
);
return rows.value.some(
(candidate) =>
candidate.key !== rowKey &&
makeRowKey(
candidate.projectId,
candidate.taskId,
candidate.billable,
candidate.tags
) === target
);
}
async function handleRowIdentityChange(
row: TimesheetRow,
partial: Partial<TimesheetRowIdentity>
): Promise<void> {
const entryIds = collectEntryIds(row);
const currentIdentity = makeRowKey(row.projectId, row.taskId, row.billable, row.tags);
let merged: TimesheetRowIdentity = {
projectId: row.projectId,
taskId: row.taskId,
billable: row.billable,
tags: row.tags,
...partial,
};
// Auto-default billable on the first project pick for an empty
// row (project provides the default; user can override after).
if (
entryIds.length === 0 &&
partial.projectId !== undefined &&
partial.projectId !== row.projectId &&
partial.projectId &&
partial.billable === undefined
) {
const projectBillable = projects.value.find(
(p) => p.id === partial.projectId
)?.is_billable;
if (projectBillable !== undefined) {
merged = { ...merged, billable: projectBillable };
}
}
const mergedIdentity = makeRowKey(
merged.projectId,
merged.taskId,
merged.billable,
merged.tags
);
const shouldMergeIntoExistingRow =
entryIds.length > 0 &&
currentIdentity !== mergedIdentity &&
hasDuplicateIdentityRow(row.key, merged);
if (entryIds.length > 0) {
await mutations.updateTimeEntries({
ids: entryIds,
changes: identityPartialToApiChanges(partial),
});
}
if (shouldMergeIntoExistingRow) {
removeSlot(row.key);
notifications.addNotification(
'success',
'Merged into matching row',
'Another row with the same project, task, billable status and tags already exists.'
);
return;
}
updateSlot(row.key, merged);
}
function handleAddRow(projectId: string | null = null, taskId: string | null = null): void {
const project = projectId ? projects.value.find((p) => p.id === projectId) : null;
addSlot(projectId, taskId, project?.is_billable ?? false, []);
}
return {
handleRowIdentityChange,
handleAddRow,
};
}

View File

@@ -0,0 +1,82 @@
import { computed, ref, watch } from 'vue';
import type { Dayjs } from 'dayjs';
import { useQueryClient } from '@tanstack/vue-query';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { prefetchTimesheetWeek } from '@/utils/useTimesheetQuery';
import { getInitialWeekRange } from '@/utils/useTimeEntriesCalendarQuery';
/**
* Owns week-navigation state for the timesheet page.
*
* Exposes the current week start/end, the list of day strings, derived
* display helpers (week number, today's date, whether this is the
* current week), and navigation functions.
*
* Also prefetches the adjacent weeks whenever `weekStart` changes so
* that clicking prev/next feels instant.
*/
export function useTimesheetWeek() {
const dayjs = getDayJsInstance();
const queryClient = useQueryClient();
const weekStart = ref<Dayjs>(getInitialWeekRange().start);
const weekEnd = computed(() => weekStart.value.add(7, 'day'));
const weekDays = computed(() => {
const days: string[] = [];
for (let i = 0; i < 7; i++) {
days.push(weekStart.value.add(i, 'day').format('YYYY-MM-DD'));
}
return days;
});
const weekNumber = computed(() => weekStart.value.week());
const isCurrentWeek = computed(() =>
weekStart.value.isSame(getInitialWeekRange().start, 'day')
);
const todayDate = computed(() => {
const tz = getUserTimezone();
return dayjs().tz(tz).format('YYYY-MM-DD');
});
// Prefetch adjacent weeks so prev/next feels instant.
watch(
weekStart,
() => {
const prevStart = weekStart.value.subtract(7, 'day');
const prevEnd = weekStart.value;
const nextStart = weekStart.value.add(7, 'day');
const nextEnd = weekStart.value.add(14, 'day');
prefetchTimesheetWeek(queryClient, prevStart, prevEnd);
prefetchTimesheetWeek(queryClient, nextStart, nextEnd);
},
{ immediate: true }
);
function goToPreviousWeek() {
weekStart.value = weekStart.value.subtract(7, 'day');
}
function goToNextWeek() {
weekStart.value = weekStart.value.add(7, 'day');
}
function goToCurrentWeek() {
weekStart.value = getInitialWeekRange().start;
}
return {
weekStart,
weekEnd,
weekDays,
weekNumber,
isCurrentWeek,
todayDate,
goToPreviousWeek,
goToNextWeek,
goToCurrentWeek,
};
}