Files
solidtime/resources/js/utils/timesheet/useCopyLastWeek.ts
2026-07-28 16:52:36 +02:00

261 lines
9.5 KiB
TypeScript

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[],
type?: 'work' | 'break'
) => string,
breaksEnabled: Ref<boolean>
) {
const dayjs = getDayJsInstance();
const queryClient = useQueryClient();
const { addNotification } = useNotificationsStore();
const isCopyingLastWeek = ref(false);
// The server rejects creating break entries while breaks are disabled,
// so leave last week's breaks out of the copy in that case
function copyableEntries(response: TimeEntryResponse): TimeEntry[] {
return breaksEnabled.value
? response.data
: response.data.filter((entry) => entry.type !== 'break');
}
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, r.type))
);
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,
entry.type
);
if (!existingIdentities.has(identity) && !addedIdentities.has(identity)) {
addedIdentities.add(identity);
addSlot(entry.project_id, entry.task_id, entry.billable, tags, entry.type);
}
}
}
async function copyLastWeekRows(): Promise<void> {
isCopyingLastWeek.value = true;
try {
const prev = await fetchLastWeekEntries();
if (!prev) return;
addMissingRowsFromPreviousWeek(copyableEntries(prev));
} 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();
const prevEntries = copyableEntries(prev);
addMissingRowsFromPreviousWeek(prevEntries);
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 prevEntries) {
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,
type: entry.type,
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,
};
}