From 0c320c39b0d85ec860f0d1049c2759785b285479 Mon Sep 17 00:00:00 2001 From: Nicola Belluti Date: Fri, 9 Oct 2026 13:46:31 +0200 Subject: [PATCH] feat(oxibadge): warn before losing unexported review edits, add undo The review screen now tracks whether there are edits not yet exported and asks for confirmation before discarding them: clicking back, or closing the app window entirely (intercepted via onCloseRequested, which needed a core:window:allow-destroy capability that wasn't granted by core:default). The confirmation dialog (ConfirmDialog.svelte) is generic, built on a shared ModalFrame extracted from ExportModal so the two don't duplicate the backdrop/box styling. Also adds undo: a per-row button (icon-only, left of the note, shown when that day has edits) reverts just that day instantly, and a per-employee "undo all" (icon in a red box, last among the header's tools) reverts every day for the current employee after a confirmation dialog, since that one can't be undone itself. ActionButton moves out of review/ next to IconButton/PillButton, since ConfirmDialog (outside the review feature) now uses it too. Assisted-by: Claude Sonnet 5 --- oxibadge/src-tauri/capabilities/default.json | 3 +- .../{review => }/ActionButton.svelte | 0 .../src/lib/components/ConfirmDialog.svelte | 83 ++++++++++ oxibadge/src/lib/components/IconButton.svelte | 19 +++ oxibadge/src/lib/components/ModalFrame.svelte | 86 ++++++++++ .../src/lib/components/TimesheetReview.svelte | 50 +++++- .../components/review/EmployeeHeader.svelte | 17 ++ .../lib/components/review/ExportModal.svelte | 149 +++++++----------- .../components/review/TimesheetTable.svelte | 44 +++++- .../lib/components/settings/ReaderCard.svelte | 2 +- oxibadge/src/lib/locales/en.json | 12 +- oxibadge/src/lib/locales/it.json | 12 +- oxibadge/src/routes/+page.svelte | 48 +++++- 13 files changed, 421 insertions(+), 104 deletions(-) rename oxibadge/src/lib/components/{review => }/ActionButton.svelte (100%) create mode 100644 oxibadge/src/lib/components/ConfirmDialog.svelte create mode 100644 oxibadge/src/lib/components/ModalFrame.svelte diff --git a/oxibadge/src-tauri/capabilities/default.json b/oxibadge/src-tauri/capabilities/default.json index b9ec4c1..194c1d3 100644 --- a/oxibadge/src-tauri/capabilities/default.json +++ b/oxibadge/src-tauri/capabilities/default.json @@ -4,6 +4,7 @@ "description": "Capability for the main window", "windows": ["main"], "permissions": [ - "core:default" + "core:default", + "core:window:allow-destroy" ] } diff --git a/oxibadge/src/lib/components/review/ActionButton.svelte b/oxibadge/src/lib/components/ActionButton.svelte similarity index 100% rename from oxibadge/src/lib/components/review/ActionButton.svelte rename to oxibadge/src/lib/components/ActionButton.svelte diff --git a/oxibadge/src/lib/components/ConfirmDialog.svelte b/oxibadge/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 0000000..5ef6d49 --- /dev/null +++ b/oxibadge/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,83 @@ + + + + {#snippet icon()} + + {/snippet} + + + + + diff --git a/oxibadge/src/lib/components/IconButton.svelte b/oxibadge/src/lib/components/IconButton.svelte index 33b9f6b..19916ad 100644 --- a/oxibadge/src/lib/components/IconButton.svelte +++ b/oxibadge/src/lib/components/IconButton.svelte @@ -5,6 +5,7 @@ size = 'sm', accent = false, danger = false, + dangerFilled = false, title, ariaLabel, onclick, @@ -12,7 +13,10 @@ }: { size?: 'sm' | 'lg'; accent?: boolean; + /** Subtle red tint on hover only — for a destructive icon among others, e.g. in a list row. */ danger?: boolean; + /** Permanent red box, like `accent` but red — for a standalone destructive action. */ + dangerFilled?: boolean; title?: string; ariaLabel: string; onclick: () => void; @@ -26,6 +30,7 @@ class:lg={size === 'lg'} class:accent class:danger + class:danger-filled={dangerFilled} {title} aria-label={ariaLabel} {onclick} @@ -81,4 +86,18 @@ background: rgba(229, 72, 77, 0.14); color: #ff8a8f; } + + .icon-btn.danger-filled { + background: rgba(229, 72, 77, 0.14); + color: #ff6b74; + } + + .icon-btn.danger-filled:hover { + background: rgba(229, 72, 77, 0.22); + color: #ff8a8f; + } + + .icon-btn.danger-filled:focus-visible { + outline-color: #e5484d; + } diff --git a/oxibadge/src/lib/components/ModalFrame.svelte b/oxibadge/src/lib/components/ModalFrame.svelte new file mode 100644 index 0000000..7066153 --- /dev/null +++ b/oxibadge/src/lib/components/ModalFrame.svelte @@ -0,0 +1,86 @@ + + + + + diff --git a/oxibadge/src/lib/components/TimesheetReview.svelte b/oxibadge/src/lib/components/TimesheetReview.svelte index 158f2a6..6acb283 100644 --- a/oxibadge/src/lib/components/TimesheetReview.svelte +++ b/oxibadge/src/lib/components/TimesheetReview.svelte @@ -23,7 +23,8 @@ import TimesheetTable from './review/TimesheetTable.svelte'; import ExportModal from './review/ExportModal.svelte'; import Toast from './review/Toast.svelte'; - import ActionButton from './review/ActionButton.svelte'; + import ActionButton from './ActionButton.svelte'; + import ConfirmDialog from './ConfirmDialog.svelte'; let { year, @@ -32,7 +33,8 @@ shift, onexport, onrename, - onback + onback, + ondirtychange }: { year: number; month: number; @@ -41,6 +43,8 @@ onexport?: (p: { scope: 'all' | 'one'; year: number; month: number; employees: Employee[] }) => void; onrename?: (id: Employee['id'], name: string) => void; onback?: () => void; + /** Called whenever there are (or stop being) edits not yet exported. */ + ondirtychange?: (dirty: boolean) => void; } = $props(); // Working copies: the component never mutates the props. Loading another @@ -52,6 +56,11 @@ let names = $state(untrack(() => employees.map((e) => e.name))); /** "employee-day-slot-index" -> time produced by rounding */ let rounded = $state>({}); + let hasUnexportedChanges = $state(false); + + $effect(() => { + ondirtychange?.(hasUnexportedChanges); + }); // ---- UI state ---- let emp = $state(0); @@ -61,6 +70,7 @@ let draft = $state(''); let menuOpen = $state(false); let confirmScope = $state<'all' | 'one' | null>(null); + let confirmingUndoAll = $state(false); let toast = $state(''); let toastTimer: ReturnType | undefined; @@ -92,6 +102,7 @@ cell[ii] = next; if (input.value !== next) input.value = next; // keep the field in sync even if the state doesn't change } + hasUnexportedChanges = true; } /** Redistributes the day's punches into E1, U1, E2, U2 based on the expected shifts. */ @@ -100,6 +111,7 @@ const times = values[emp][di].flat().filter((x) => x !== ''); values[emp][di] = assignToShifts(times, shift); } + hasUnexportedChanges = true; } function addLunch(dis: number[]) { @@ -107,6 +119,7 @@ const span = lunchSpan(values[emp][di], shift); if (span) values[emp][di] = [[span[0]], [shift.out1], [shift.in2], [span[1]]]; } + hasUnexportedChanges = true; } function roundDaysOf(ei: number, dis: number[]) { @@ -119,6 +132,24 @@ }) ) as Day; } + hasUnexportedChanges = true; + } + + /** Reverts one day back to what the time clock originally reported. */ + function undoDay(di: number) { + values[emp][di] = orig[emp][di].map((c) => [...c]) as Day; + hasUnexportedChanges = true; + } + + function requestUndoAll() { + confirmingUndoAll = true; + } + + /** Reverts every day of the current employee back to the time clock's original data. */ + function confirmUndoAll() { + confirmingUndoAll = false; + values[emp] = orig[emp].map((day) => day.map((c) => [...c]) as Day); + hasUnexportedChanges = true; } // ---- Name ---- @@ -150,6 +181,7 @@ }); confirmScope = null; menuOpen = false; + hasUnexportedChanges = false; toast = $_('review.export_done', { values: { who: scope === 'all' ? $_('review.everyone') : names[emp] } }); @@ -212,6 +244,7 @@ function onKey(e: KeyboardEvent) { if (e.key !== 'Escape') return; if (confirmScope) confirmScope = null; + else if (confirmingUndoAll) confirmingUndoAll = false; else if (menuOpen) menuOpen = false; else if (editingName) editingName = false; } @@ -238,6 +271,17 @@ /> {/if} + {#if confirmingUndoAll} + (confirmingUndoAll = false)} + /> + {/if} + {#if names.length === 0} @@ -293,6 +337,7 @@ onfixall={() => fixDays(cur.stackedDays)} onaddlunchall={() => addLunch(cur.lunchDays)} onroundall={() => roundDaysOf(emp, cur.roundDays)} + onundoall={requestUndoAll} bind:onlyIssues /> @@ -305,6 +350,7 @@ onfixday={(di) => fixDays([di])} onaddlunch={(di) => addLunch([di])} onroundday={(di) => roundDaysOf(emp, [di])} + onundoday={undoDay} /> diff --git a/oxibadge/src/lib/components/review/EmployeeHeader.svelte b/oxibadge/src/lib/components/review/EmployeeHeader.svelte index 687274b..f411de5 100644 --- a/oxibadge/src/lib/components/review/EmployeeHeader.svelte +++ b/oxibadge/src/lib/components/review/EmployeeHeader.svelte @@ -23,6 +23,7 @@ onfixall, onaddlunchall, onroundall, + onundoall, onlyIssues = $bindable(false) }: { name: string; @@ -41,6 +42,7 @@ onfixall: () => void; onaddlunchall: () => void; onroundall: () => void; + onundoall: () => void; onlyIssues?: boolean; } = $props(); @@ -150,6 +152,21 @@ >{$_('review.to_check_cap')} + {#if edits} + + {#snippet icon()} + + {/snippet} + + {/if} diff --git a/oxibadge/src/lib/components/review/ExportModal.svelte b/oxibadge/src/lib/components/review/ExportModal.svelte index c340011..a321bc6 100644 --- a/oxibadge/src/lib/components/review/ExportModal.svelte +++ b/oxibadge/src/lib/components/review/ExportModal.svelte @@ -1,8 +1,9 @@ -