mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
261 lines
9.5 KiB
TypeScript
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,
|
|
};
|
|
}
|