feat(oxibadge): warn before losing unexported review edits, add undo
Check, format and test / build (push) Successful in 10m25s
Check, format and test / build (push) Successful in 10m25s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import ModalFrame from './ModalFrame.svelte';
|
||||
import ActionButton from './ActionButton.svelte';
|
||||
|
||||
let {
|
||||
fixed = false,
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onconfirm,
|
||||
oncancel
|
||||
}: {
|
||||
/** See `ModalFrame`'s `fixed` prop. */
|
||||
fixed?: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
onconfirm: () => void;
|
||||
oncancel: () => void;
|
||||
} = $props();
|
||||
|
||||
// Unique per instance, so two ConfirmDialogs never clash if ever shown
|
||||
// at once (e.g. this one plus the app-wide "quit with unsaved changes" one).
|
||||
const uid = $props.id();
|
||||
</script>
|
||||
|
||||
<ModalFrame
|
||||
{fixed}
|
||||
titleId="{uid}-title"
|
||||
descId="{uid}-desc"
|
||||
closeAriaLabel={cancelLabel}
|
||||
onclose={oncancel}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
|
||||
d="M12 9.5v4M12 17h.01"
|
||||
/></svg
|
||||
>
|
||||
{/snippet}
|
||||
<div class="modal-text">
|
||||
<h2 id="{uid}-title">{title}</h2>
|
||||
<p id="{uid}-desc">{message}</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<ActionButton onclick={oncancel}>{cancelLabel}</ActionButton>
|
||||
<ActionButton primary onclick={onconfirm}>{confirmLabel}</ActionButton>
|
||||
</div>
|
||||
</ModalFrame>
|
||||
|
||||
<style>
|
||||
.modal-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-text h2 {
|
||||
margin: 0;
|
||||
font-family: var(--ox-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.modal-text p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
fixed = false,
|
||||
titleId,
|
||||
descId,
|
||||
closeAriaLabel,
|
||||
onclose,
|
||||
icon,
|
||||
children
|
||||
}: {
|
||||
/** Use for a modal that must sit above the whole app (e.g. shown from
|
||||
* outside the screen it concerns), instead of within the nearest
|
||||
* positioned ancestor. */
|
||||
fixed?: boolean;
|
||||
titleId: string;
|
||||
descId?: string;
|
||||
closeAriaLabel: string;
|
||||
onclose: () => void;
|
||||
icon?: Snippet;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="modal-wrap" class:fixed>
|
||||
<button type="button" class="modal-backdrop" aria-label={closeAriaLabel} onclick={onclose}></button>
|
||||
<div class="modal" role="alertdialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={descId}>
|
||||
{#if icon}
|
||||
<div class="modal-icon">{@render icon()}</div>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-wrap.fixed {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
all: unset;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: rgba(229, 72, 77, 0.14);
|
||||
color: #ff6b74;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -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<string[]>(untrack(() => employees.map((e) => e.name)));
|
||||
/** "employee-day-slot-index" -> time produced by rounding */
|
||||
let rounded = $state<Record<string, string>>({});
|
||||
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<typeof setTimeout> | 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}
|
||||
<ConfirmDialog
|
||||
title={$_('review.undo_all_confirm_title')}
|
||||
message={$_('review.undo_all_confirm_message')}
|
||||
confirmLabel={$_('review.undo_all_confirm')}
|
||||
cancelLabel={$_('review.cancel')}
|
||||
onconfirm={confirmUndoAll}
|
||||
oncancel={() => (confirmingUndoAll = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Toast message={toast} />
|
||||
|
||||
{#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}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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')}</button
|
||||
>
|
||||
</div>
|
||||
{#if edits}
|
||||
<IconButton
|
||||
size="lg"
|
||||
dangerFilled
|
||||
ariaLabel={$_('review.undo_all')}
|
||||
title={$_('review.undo_all_title')}
|
||||
onclick={onundoall}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><path d="M9 14l-5-5 5-5" /><path d="M20 20v-7a4 4 0 0 0-4-4H4" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { Shifts } from '$lib/shifts';
|
||||
import ModalFrame from '../ModalFrame.svelte';
|
||||
import FixButton from './FixButton.svelte';
|
||||
import ActionButton from './ActionButton.svelte';
|
||||
import ActionButton from '../ActionButton.svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
@@ -25,108 +26,64 @@
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="modal-wrap">
|
||||
<button type="button" class="modal-backdrop" aria-label={$_('review.cancel_export_aria')} onclick={onclose}
|
||||
></button>
|
||||
<div class="modal" role="alertdialog" aria-modal="true" aria-labelledby="ox-exp-title" aria-describedby="ox-exp-desc">
|
||||
<div class="modal-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
|
||||
d="M12 9.5v4M12 17h.01"
|
||||
/></svg
|
||||
>
|
||||
</div>
|
||||
<div class="modal-text">
|
||||
<h2 id="ox-exp-title">{title}</h2>
|
||||
<p id="ox-exp-desc">
|
||||
{#if scopeIssues}
|
||||
{$_('review.export_warning_issues')}
|
||||
{:else}
|
||||
{$_('review.export_warning_unrounded', { values: { step: shift.round_step_min } })}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-list scroll">
|
||||
{#each items as it (it.key)}
|
||||
<button type="button" class="menu-item row-item" onclick={it.go}>
|
||||
<span class="mi-label">{it.label}</span>
|
||||
<span class="mi-detail" class:red={it.red} class:blue={!it.red}>{it.detail}</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" class="mi-chev"
|
||||
><path d="M9 6l6 6-6 6" /></svg
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<ActionButton onclick={onclose}>{$_('review.back_to_review')}</ActionButton>
|
||||
{#if scopeIssues === 0 && scopeInfos > 0}
|
||||
<FixButton size="tall" blue onclick={onroundandexport}>
|
||||
{#snippet icon()}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
{$_('review.round_and_export')}
|
||||
</FixButton>
|
||||
<ModalFrame
|
||||
titleId="ox-exp-title"
|
||||
descId="ox-exp-desc"
|
||||
closeAriaLabel={$_('review.cancel_export_aria')}
|
||||
{onclose}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
|
||||
d="M12 9.5v4M12 17h.01"
|
||||
/></svg
|
||||
>
|
||||
{/snippet}
|
||||
<div class="modal-text">
|
||||
<h2 id="ox-exp-title">{title}</h2>
|
||||
<p id="ox-exp-desc">
|
||||
{#if scopeIssues}
|
||||
{$_('review.export_warning_issues')}
|
||||
{:else}
|
||||
{$_('review.export_warning_unrounded', { values: { step: shift.round_step_min } })}
|
||||
{/if}
|
||||
<ActionButton primary onclick={onexportanyway}>
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-list scroll">
|
||||
{#each items as it (it.key)}
|
||||
<button type="button" class="menu-item row-item" onclick={it.go}>
|
||||
<span class="mi-label">{it.label}</span>
|
||||
<span class="mi-detail" class:red={it.red} class:blue={!it.red}>{it.detail}</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" class="mi-chev"
|
||||
><path d="M9 6l6 6-6 6" /></svg
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<ActionButton onclick={onclose}>{$_('review.back_to_review')}</ActionButton>
|
||||
{#if scopeIssues === 0 && scopeInfos > 0}
|
||||
<FixButton size="tall" blue onclick={onroundandexport}>
|
||||
{#snippet icon()}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
{$_('review.export_anyway')}
|
||||
</ActionButton>
|
||||
</div>
|
||||
{$_('review.round_and_export')}
|
||||
</FixButton>
|
||||
{/if}
|
||||
<ActionButton primary onclick={onexportanyway}>
|
||||
{#snippet icon()}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
{$_('review.export_anyway')}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFrame>
|
||||
|
||||
<style>
|
||||
.modal-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
all: unset;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: rgba(229, 72, 77, 0.14);
|
||||
color: #ff6b74;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
oncellinput,
|
||||
onfixday,
|
||||
onaddlunch,
|
||||
onroundday
|
||||
onroundday,
|
||||
onundoday
|
||||
}: {
|
||||
rows: Row[];
|
||||
rounded: Record<string, string>;
|
||||
@@ -22,6 +23,7 @@
|
||||
onfixday: (di: number) => void;
|
||||
onaddlunch: (di: number) => void;
|
||||
onroundday: (di: number) => void;
|
||||
onundoday: (di: number) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
@@ -85,6 +87,19 @@
|
||||
|
||||
<td class="note {note.kind}">
|
||||
<span class="note-in">
|
||||
{#if r.rowEdits > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="undo-btn"
|
||||
aria-label={$_('review.undo_day_aria', { values: { label } })}
|
||||
title={$_('review.undo_short')}
|
||||
onclick={() => onundoday(r.di)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><path d="M9 14l-5-5 5-5" /><path d="M20 20v-7a4 4 0 0 0-4-4H4" /></svg
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
{#if note.kind === 'issue'}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><circle cx="12" cy="12" r="9" /><path d="M12 7.5v5.5M12 16.5h.01" /></svg
|
||||
@@ -377,6 +392,33 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.undo-btn {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: #6e6e6e;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.undo-btn:hover {
|
||||
background: #262626;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.undo-btn:focus-visible {
|
||||
outline: 2px solid var(--ox-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 48px 24px;
|
||||
display: flex;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { PRIMARY_READER_INDEX, type EmployeeCache, type EmployeeNames } from '$lib/employees';
|
||||
import Field from '../Field.svelte';
|
||||
import ErrorBanner from '../ErrorBanner.svelte';
|
||||
import ActionButton from '../review/ActionButton.svelte';
|
||||
import ActionButton from '../ActionButton.svelte';
|
||||
import PillButton from '../PillButton.svelte';
|
||||
import EmployeesDrawer from './EmployeesDrawer.svelte';
|
||||
import { IPV4 } from './logic';
|
||||
|
||||
@@ -114,7 +114,17 @@
|
||||
"export_warning_unrounded": "The times are complete, but some aren't rounded to {step} minutes. You can round them automatically before exporting, or export them as they are.",
|
||||
"back_to_review": "Back to review",
|
||||
"round_and_export": "Round and export",
|
||||
"export_anyway": "Export anyway"
|
||||
"export_anyway": "Export anyway",
|
||||
"leave_confirm_title": "Unexported changes",
|
||||
"leave_confirm_message": "If you leave now, the changes made in this review will be lost.",
|
||||
"leave_anyway": "Leave without exporting",
|
||||
"undo_day_aria": "Undo the changes to {label}",
|
||||
"undo_short": "Undo",
|
||||
"undo_all": "Undo changes",
|
||||
"undo_all_title": "Restore this employee's times to what the time clock originally reported",
|
||||
"undo_all_confirm_title": "Undo all changes?",
|
||||
"undo_all_confirm_message": "All edited times for this employee will revert to what the time clock reported. This can't be undone.",
|
||||
"undo_all_confirm": "Undo all changes"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
|
||||
@@ -114,7 +114,17 @@
|
||||
"export_warning_unrounded": "Gli orari sono completi, ma alcuni non sono arrotondati a {step} minuti. Puoi arrotondarli automaticamente prima di esportare, oppure esportarli così come sono.",
|
||||
"back_to_review": "Torna alla revisione",
|
||||
"round_and_export": "Arrotonda ed esporta",
|
||||
"export_anyway": "Esporta comunque"
|
||||
"export_anyway": "Esporta comunque",
|
||||
"leave_confirm_title": "Modifiche non esportate",
|
||||
"leave_confirm_message": "Se esci ora, le modifiche fatte in questa revisione andranno perse.",
|
||||
"leave_anyway": "Esci senza esportare",
|
||||
"undo_day_aria": "Annulla le modifiche di {label}",
|
||||
"undo_short": "Annulla",
|
||||
"undo_all": "Annulla modifiche",
|
||||
"undo_all_title": "Ripristina gli orari di questo dipendente a quelli letti dal timbratore",
|
||||
"undo_all_confirm_title": "Annullare tutte le modifiche?",
|
||||
"undo_all_confirm_message": "Tutti gli orari modificati per questo dipendente torneranno a quelli letti dal timbratore. L'operazione non si può annullare.",
|
||||
"undo_all_confirm": "Annulla tutte le modifiche"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { onMount } from 'svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { monthName } from '$lib/month';
|
||||
@@ -12,6 +13,7 @@
|
||||
import SettingsButton from '$lib/components/SettingsButton.svelte';
|
||||
import Settings from '$lib/components/Settings.svelte';
|
||||
import NewEmployees from '$lib/components/NewEmployees.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { buildEmployees, type AttendanceRecord } from '$lib/components/review/fromRecords';
|
||||
import type { NewEmployee } from '$lib/components/new-employees/logic';
|
||||
import type { Shifts } from '$lib/shifts';
|
||||
@@ -38,14 +40,45 @@
|
||||
let newEmployees = $state<NewEmployee[]>([]);
|
||||
// Placeholder: overwritten by load_settings before the review screen is reachable.
|
||||
let shifts = $state<Shifts>({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 });
|
||||
let reviewDirty = $state(false);
|
||||
let pendingLeaveAction = $state<null | (() => void)>(null);
|
||||
|
||||
const period = $derived(`${monthName($locale, selectedMonth)} ${selectedYear}`);
|
||||
const reviewEmployees = $derived(buildEmployees(selectedYear, selectedMonth, records, employeeNames));
|
||||
|
||||
/** Runs `action` immediately, unless the review screen has unexported
|
||||
* edits, in which case it asks for confirmation first. */
|
||||
function requestLeaveReview(action: () => void) {
|
||||
if (step === 'review' && reviewDirty) {
|
||||
pendingLeaveAction = action;
|
||||
} else {
|
||||
action();
|
||||
}
|
||||
}
|
||||
|
||||
function confirmLeaveReview() {
|
||||
const action = pendingLeaveAction;
|
||||
pendingLeaveAction = null;
|
||||
action?.();
|
||||
}
|
||||
|
||||
function cancelLeaveReview() {
|
||||
pendingLeaveAction = null;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const settings = await invoke<SettingsData>('load_settings');
|
||||
shifts = settings.shifts;
|
||||
step = settings.readers[0] ? 'confirm-month' : 'setup';
|
||||
|
||||
// Lives for the whole app, so there's no need to ever unlisten.
|
||||
const appWindow = getCurrentWindow();
|
||||
await appWindow.onCloseRequested((event) => {
|
||||
if (step === 'review' && reviewDirty) {
|
||||
event.preventDefault();
|
||||
requestLeaveReview(() => appWindow.destroy());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function openSettings() {
|
||||
@@ -118,9 +151,10 @@
|
||||
month={selectedMonth}
|
||||
employees={reviewEmployees}
|
||||
shift={shifts}
|
||||
onback={() => (step = 'confirm-month')}
|
||||
onback={() => requestLeaveReview(() => (step = 'confirm-month'))}
|
||||
onexport={(payload) => invoke('export_timesheet', { payload })}
|
||||
onrename={onEmployeeRenamed}
|
||||
ondirtychange={(dirty) => (reviewDirty = dirty)}
|
||||
/>
|
||||
{:else if step === 'new-employees'}
|
||||
<NewEmployees employees={newEmployees} oncontinue={onNewEmployeesContinue} />
|
||||
@@ -150,3 +184,15 @@
|
||||
{/if}
|
||||
</Screen>
|
||||
{/if}
|
||||
|
||||
{#if pendingLeaveAction}
|
||||
<ConfirmDialog
|
||||
fixed
|
||||
title={$_('review.leave_confirm_title')}
|
||||
message={$_('review.leave_confirm_message')}
|
||||
confirmLabel={$_('review.leave_anyway')}
|
||||
cancelLabel={$_('review.cancel')}
|
||||
onconfirm={confirmLeaveReview}
|
||||
oncancel={cancelLeaveReview}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user