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}
+
+
+
{cancelLabel}
+
{confirmLabel}
+
+
+
+
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 @@
+
+
+
+
+
+ {#if icon}
+
{@render icon()}
+ {/if}
+ {@render children()}
+
+
+
+
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 @@
-
-
-
-
-
-
{title}
-
- {#if scopeIssues}
- {$_('review.export_warning_issues')}
- {:else}
- {$_('review.export_warning_unrounded', { values: { step: shift.round_step_min } })}
- {/if}
-
-
-
- {#each items as it (it.key)}
-
- {/each}
-
-
-
{$_('review.back_to_review')}
- {#if scopeIssues === 0 && scopeInfos > 0}
-
- {#snippet icon()}
-
- {/snippet}
- {$_('review.round_and_export')}
-
+
+ {#snippet icon()}
+
+ {/snippet}
+
+
{title}
+
+ {#if scopeIssues}
+ {$_('review.export_warning_issues')}
+ {:else}
+ {$_('review.export_warning_unrounded', { values: { step: shift.round_step_min } })}
{/if}
-
+
+
+
+ {#each items as it (it.key)}
+
+ {/each}
+
+
+
{$_('review.back_to_review')}
+ {#if scopeIssues === 0 && scopeInfos > 0}
+
{#snippet icon()}
-
{/snippet}
- {$_('review.export_anyway')}
-
-
+ {$_('review.round_and_export')}
+
+ {/if}
+
+ {#snippet icon()}
+
+ {/snippet}
+ {$_('review.export_anyway')}
+
-
+