From 600daf44d9169abd31f7369b39d8ba97316a4488 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 28 Jul 2026 15:21:58 +0200 Subject: [PATCH] add description/project labels to break placement modal for existing time entries --- e2e/timesheet.spec.ts | 6 ++- .../Timesheet/BreakPlacementModal.vue | 49 +++++++++++++------ resources/js/Pages/Timesheet.vue | 12 +++++ 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/e2e/timesheet.spec.ts b/e2e/timesheet.spec.ts index 5d50aef1..37a2729d 100644 --- a/e2e/timesheet.spec.ts +++ b/e2e/timesheet.spec.ts @@ -697,8 +697,12 @@ test('test that adding a timesheet break to a full day splits the work entry via const breakCell = await fillBreakCell(page, '0.5'); await breakCell.press('Enter'); - // The placement modal opens with the split preview + // The placement modal opens with the split preview, naming the entry that + // will be split so the user can recognize it. await expect(page.getByTestId('break_placement_summary')).toBeVisible(); + await expect(page.getByTestId('break_placement_summary')).toContainText( + 'No Project · Split me' + ); await Promise.all([ waitForBreakCreated(page), page.getByRole('button', { name: 'Add break' }).click(), diff --git a/resources/js/Components/Timesheet/BreakPlacementModal.vue b/resources/js/Components/Timesheet/BreakPlacementModal.vue index 345b6988..66fda519 100644 --- a/resources/js/Components/Timesheet/BreakPlacementModal.vue +++ b/resources/js/Components/Timesheet/BreakPlacementModal.vue @@ -13,12 +13,14 @@ import { planMoveInsert, planSplitEntry, type BreakPlacementRequest, + type Interval, } from '@/utils/timesheet/breakPlacementMath'; import { BREAK_GAP_TOLERANCE_MINUTES } from '@/packages/ui/src/utils/breakPlacement'; const props = defineProps<{ request: BreakPlacementRequest | null; apply: (breakStart: string, durationSeconds: number) => Promise; + entryLabel: (id: string) => string; }>(); const emit = defineEmits<{ cancel: [] }>(); @@ -124,30 +126,39 @@ const explanation = computed(() => { : "There's no free gap that fits this break, so the surrounding entries will be shifted to make room."; }); -const changeSummary = computed(() => { +interface PlanLine { + times: string; + label: string; +} + +const changeSummary = computed(() => { + const req = props.request; + if (!req) return []; + const range = (interval: Interval) => `${fmt(interval.start)}–${fmt(interval.end)}`; + const moved = (from: Interval, to: Interval) => `${range(from)} → ${range(to)}`; + if (mode.value === 'split') { const plan = splitPlan.value; if (!plan) return []; + const workLabel = props.entryLabel(req.workEntries[0]!.id); return [ - `${fmt(plan.firstHalf.start)}–${fmt(plan.firstHalf.end)} (work)`, - `${fmt(plan.breakSlot.start)}–${fmt(plan.breakSlot.end)} (break)`, - `${fmt(plan.secondHalf.start)}–${fmt(plan.secondHalf.end)} (work)`, - ...plan.shifted.map((shift) => { - const original = props.request!.otherEntries.find((e) => e.id === shift.id)!; - return `${fmt(original.start)}–${fmt(original.end)} → ${fmt(shift.start)}–${fmt(shift.end)} (break)`; - }), + { times: range(plan.firstHalf), label: workLabel }, + { times: range(plan.breakSlot), label: 'Break' }, + { times: range(plan.secondHalf), label: workLabel }, + ...plan.shifted.map((shift) => ({ + times: moved(req.otherEntries.find((e) => e.id === shift.id)!, shift), + label: props.entryLabel(shift.id), + })), ]; } const plan = movePlan.value; if (!plan) return []; - if (plan.shifted.length === 0) return ['No entries need to move.']; + if (plan.shifted.length === 0) return [{ times: 'No entries need to move.', label: '' }]; return plan.shifted.map((shift) => { - const isBreak = props.request!.otherEntries.some((e) => e.id === shift.id); const original = - props.request!.workEntries.find((e) => e.id === shift.id) ?? - props.request!.otherEntries.find((e) => e.id === shift.id)!; - const label = `${fmt(original.start)}–${fmt(original.end)} → ${fmt(shift.start)}–${fmt(shift.end)}`; - return isBreak ? `${label} (break)` : label; + req.workEntries.find((e) => e.id === shift.id) ?? + req.otherEntries.find((e) => e.id === shift.id)!; + return { times: moved(original, shift), label: props.entryLabel(shift.id) }; }); }); @@ -189,8 +200,14 @@ async function submit() {
{{ mode === 'split' ? 'Result' : 'Entries that move' }}
-
- {{ line }} +
+ {{ line.times }} + + {{ line.label }} +
organization.value?.prevent_overlapping_time_entries ?? false ); +function breakPlanEntryLabel(id: string): string { + const entry = allTimeEntries.value.find((e) => e.id === id); + if (!entry) return ''; + if (entry.type === 'break') return 'Break'; + const project = projects.value.find((p) => p.id === entry.project_id); + const task = tasks.value.find((t) => t.id === entry.task_id); + return [project?.name ?? 'No Project', task?.name, entry.description] + .filter((part): part is string => !!part) + .join(' · '); +} + // Local dates (YYYY-MM-DD) that have a misplaced break. There is only one break // row, so a flat set is enough — its cells show a warning for dates in the set. const misplacedBreakDates = computed>(() => { @@ -257,6 +268,7 @@ async function createTag(name: string): Promise {