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:
@@ -4,6 +4,7 @@
|
|||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default"
|
"core:default",
|
||||||
|
"core:window:allow-destroy"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
size = 'sm',
|
||||||
accent = false,
|
accent = false,
|
||||||
danger = false,
|
danger = false,
|
||||||
|
dangerFilled = false,
|
||||||
title,
|
title,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
onclick,
|
onclick,
|
||||||
@@ -12,7 +13,10 @@
|
|||||||
}: {
|
}: {
|
||||||
size?: 'sm' | 'lg';
|
size?: 'sm' | 'lg';
|
||||||
accent?: boolean;
|
accent?: boolean;
|
||||||
|
/** Subtle red tint on hover only — for a destructive icon among others, e.g. in a list row. */
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
|
/** Permanent red box, like `accent` but red — for a standalone destructive action. */
|
||||||
|
dangerFilled?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
onclick: () => void;
|
onclick: () => void;
|
||||||
@@ -26,6 +30,7 @@
|
|||||||
class:lg={size === 'lg'}
|
class:lg={size === 'lg'}
|
||||||
class:accent
|
class:accent
|
||||||
class:danger
|
class:danger
|
||||||
|
class:danger-filled={dangerFilled}
|
||||||
{title}
|
{title}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{onclick}
|
{onclick}
|
||||||
@@ -81,4 +86,18 @@
|
|||||||
background: rgba(229, 72, 77, 0.14);
|
background: rgba(229, 72, 77, 0.14);
|
||||||
color: #ff8a8f;
|
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>
|
</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 TimesheetTable from './review/TimesheetTable.svelte';
|
||||||
import ExportModal from './review/ExportModal.svelte';
|
import ExportModal from './review/ExportModal.svelte';
|
||||||
import Toast from './review/Toast.svelte';
|
import Toast from './review/Toast.svelte';
|
||||||
import ActionButton from './review/ActionButton.svelte';
|
import ActionButton from './ActionButton.svelte';
|
||||||
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
year,
|
year,
|
||||||
@@ -32,7 +33,8 @@
|
|||||||
shift,
|
shift,
|
||||||
onexport,
|
onexport,
|
||||||
onrename,
|
onrename,
|
||||||
onback
|
onback,
|
||||||
|
ondirtychange
|
||||||
}: {
|
}: {
|
||||||
year: number;
|
year: number;
|
||||||
month: number;
|
month: number;
|
||||||
@@ -41,6 +43,8 @@
|
|||||||
onexport?: (p: { scope: 'all' | 'one'; year: number; month: number; employees: Employee[] }) => void;
|
onexport?: (p: { scope: 'all' | 'one'; year: number; month: number; employees: Employee[] }) => void;
|
||||||
onrename?: (id: Employee['id'], name: string) => void;
|
onrename?: (id: Employee['id'], name: string) => void;
|
||||||
onback?: () => void;
|
onback?: () => void;
|
||||||
|
/** Called whenever there are (or stop being) edits not yet exported. */
|
||||||
|
ondirtychange?: (dirty: boolean) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// Working copies: the component never mutates the props. Loading another
|
// Working copies: the component never mutates the props. Loading another
|
||||||
@@ -52,6 +56,11 @@
|
|||||||
let names = $state<string[]>(untrack(() => employees.map((e) => e.name)));
|
let names = $state<string[]>(untrack(() => employees.map((e) => e.name)));
|
||||||
/** "employee-day-slot-index" -> time produced by rounding */
|
/** "employee-day-slot-index" -> time produced by rounding */
|
||||||
let rounded = $state<Record<string, string>>({});
|
let rounded = $state<Record<string, string>>({});
|
||||||
|
let hasUnexportedChanges = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
ondirtychange?.(hasUnexportedChanges);
|
||||||
|
});
|
||||||
|
|
||||||
// ---- UI state ----
|
// ---- UI state ----
|
||||||
let emp = $state(0);
|
let emp = $state(0);
|
||||||
@@ -61,6 +70,7 @@
|
|||||||
let draft = $state('');
|
let draft = $state('');
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
let confirmScope = $state<'all' | 'one' | null>(null);
|
let confirmScope = $state<'all' | 'one' | null>(null);
|
||||||
|
let confirmingUndoAll = $state(false);
|
||||||
let toast = $state('');
|
let toast = $state('');
|
||||||
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
@@ -92,6 +102,7 @@
|
|||||||
cell[ii] = next;
|
cell[ii] = next;
|
||||||
if (input.value !== next) input.value = next; // keep the field in sync even if the state doesn't change
|
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. */
|
/** 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 !== '');
|
const times = values[emp][di].flat().filter((x) => x !== '');
|
||||||
values[emp][di] = assignToShifts(times, shift);
|
values[emp][di] = assignToShifts(times, shift);
|
||||||
}
|
}
|
||||||
|
hasUnexportedChanges = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLunch(dis: number[]) {
|
function addLunch(dis: number[]) {
|
||||||
@@ -107,6 +119,7 @@
|
|||||||
const span = lunchSpan(values[emp][di], shift);
|
const span = lunchSpan(values[emp][di], shift);
|
||||||
if (span) values[emp][di] = [[span[0]], [shift.out1], [shift.in2], [span[1]]];
|
if (span) values[emp][di] = [[span[0]], [shift.out1], [shift.in2], [span[1]]];
|
||||||
}
|
}
|
||||||
|
hasUnexportedChanges = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function roundDaysOf(ei: number, dis: number[]) {
|
function roundDaysOf(ei: number, dis: number[]) {
|
||||||
@@ -119,6 +132,24 @@
|
|||||||
})
|
})
|
||||||
) as Day;
|
) 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 ----
|
// ---- Name ----
|
||||||
@@ -150,6 +181,7 @@
|
|||||||
});
|
});
|
||||||
confirmScope = null;
|
confirmScope = null;
|
||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
|
hasUnexportedChanges = false;
|
||||||
toast = $_('review.export_done', {
|
toast = $_('review.export_done', {
|
||||||
values: { who: scope === 'all' ? $_('review.everyone') : names[emp] }
|
values: { who: scope === 'all' ? $_('review.everyone') : names[emp] }
|
||||||
});
|
});
|
||||||
@@ -212,6 +244,7 @@
|
|||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (e.key !== 'Escape') return;
|
if (e.key !== 'Escape') return;
|
||||||
if (confirmScope) confirmScope = null;
|
if (confirmScope) confirmScope = null;
|
||||||
|
else if (confirmingUndoAll) confirmingUndoAll = false;
|
||||||
else if (menuOpen) menuOpen = false;
|
else if (menuOpen) menuOpen = false;
|
||||||
else if (editingName) editingName = false;
|
else if (editingName) editingName = false;
|
||||||
}
|
}
|
||||||
@@ -238,6 +271,17 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/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} />
|
<Toast message={toast} />
|
||||||
|
|
||||||
{#if names.length === 0}
|
{#if names.length === 0}
|
||||||
@@ -293,6 +337,7 @@
|
|||||||
onfixall={() => fixDays(cur.stackedDays)}
|
onfixall={() => fixDays(cur.stackedDays)}
|
||||||
onaddlunchall={() => addLunch(cur.lunchDays)}
|
onaddlunchall={() => addLunch(cur.lunchDays)}
|
||||||
onroundall={() => roundDaysOf(emp, cur.roundDays)}
|
onroundall={() => roundDaysOf(emp, cur.roundDays)}
|
||||||
|
onundoall={requestUndoAll}
|
||||||
bind:onlyIssues
|
bind:onlyIssues
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -305,6 +350,7 @@
|
|||||||
onfixday={(di) => fixDays([di])}
|
onfixday={(di) => fixDays([di])}
|
||||||
onaddlunch={(di) => addLunch([di])}
|
onaddlunch={(di) => addLunch([di])}
|
||||||
onroundday={(di) => roundDaysOf(emp, [di])}
|
onroundday={(di) => roundDaysOf(emp, [di])}
|
||||||
|
onundoday={undoDay}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
onfixall,
|
onfixall,
|
||||||
onaddlunchall,
|
onaddlunchall,
|
||||||
onroundall,
|
onroundall,
|
||||||
|
onundoall,
|
||||||
onlyIssues = $bindable(false)
|
onlyIssues = $bindable(false)
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
onfixall: () => void;
|
onfixall: () => void;
|
||||||
onaddlunchall: () => void;
|
onaddlunchall: () => void;
|
||||||
onroundall: () => void;
|
onroundall: () => void;
|
||||||
|
onundoall: () => void;
|
||||||
onlyIssues?: boolean;
|
onlyIssues?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -150,6 +152,21 @@
|
|||||||
>{$_('review.to_check_cap')}</button
|
>{$_('review.to_check_cap')}</button
|
||||||
>
|
>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import type { Shifts } from '$lib/shifts';
|
import type { Shifts } from '$lib/shifts';
|
||||||
|
import ModalFrame from '../ModalFrame.svelte';
|
||||||
import FixButton from './FixButton.svelte';
|
import FixButton from './FixButton.svelte';
|
||||||
import ActionButton from './ActionButton.svelte';
|
import ActionButton from '../ActionButton.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
title,
|
title,
|
||||||
@@ -25,108 +26,64 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="modal-wrap">
|
<ModalFrame
|
||||||
<button type="button" class="modal-backdrop" aria-label={$_('review.cancel_export_aria')} onclick={onclose}
|
titleId="ox-exp-title"
|
||||||
></button>
|
descId="ox-exp-desc"
|
||||||
<div class="modal" role="alertdialog" aria-modal="true" aria-labelledby="ox-exp-title" aria-describedby="ox-exp-desc">
|
closeAriaLabel={$_('review.cancel_export_aria')}
|
||||||
<div class="modal-icon">
|
{onclose}
|
||||||
<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
|
{#snippet icon()}
|
||||||
d="M12 9.5v4M12 17h.01"
|
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
/></svg
|
><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"
|
||||||
</div>
|
/></svg
|
||||||
<div class="modal-text">
|
>
|
||||||
<h2 id="ox-exp-title">{title}</h2>
|
{/snippet}
|
||||||
<p id="ox-exp-desc">
|
<div class="modal-text">
|
||||||
{#if scopeIssues}
|
<h2 id="ox-exp-title">{title}</h2>
|
||||||
{$_('review.export_warning_issues')}
|
<p id="ox-exp-desc">
|
||||||
{:else}
|
{#if scopeIssues}
|
||||||
{$_('review.export_warning_unrounded', { values: { step: shift.round_step_min } })}
|
{$_('review.export_warning_issues')}
|
||||||
{/if}
|
{:else}
|
||||||
</p>
|
{$_('review.export_warning_unrounded', { values: { step: shift.round_step_min } })}
|
||||||
</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>
|
|
||||||
{/if}
|
{/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()}
|
{#snippet icon()}
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||||
><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg
|
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
|
||||||
>
|
>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{$_('review.export_anyway')}
|
{$_('review.round_and_export')}
|
||||||
</ActionButton>
|
</FixButton>
|
||||||
</div>
|
{/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>
|
||||||
</div>
|
</ModalFrame>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.modal-text {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
oncellinput,
|
oncellinput,
|
||||||
onfixday,
|
onfixday,
|
||||||
onaddlunch,
|
onaddlunch,
|
||||||
onroundday
|
onroundday,
|
||||||
|
onundoday
|
||||||
}: {
|
}: {
|
||||||
rows: Row[];
|
rows: Row[];
|
||||||
rounded: Record<string, string>;
|
rounded: Record<string, string>;
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
onfixday: (di: number) => void;
|
onfixday: (di: number) => void;
|
||||||
onaddlunch: (di: number) => void;
|
onaddlunch: (di: number) => void;
|
||||||
onroundday: (di: number) => void;
|
onroundday: (di: number) => void;
|
||||||
|
onundoday: (di: number) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -85,6 +87,19 @@
|
|||||||
|
|
||||||
<td class="note {note.kind}">
|
<td class="note {note.kind}">
|
||||||
<span class="note-in">
|
<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'}
|
{#if note.kind === 'issue'}
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"
|
<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
|
><circle cx="12" cy="12" r="9" /><path d="M12 7.5v5.5M12 16.5h.01" /></svg
|
||||||
@@ -377,6 +392,33 @@
|
|||||||
gap: 8px;
|
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 {
|
.empty {
|
||||||
padding: 48px 24px;
|
padding: 48px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { PRIMARY_READER_INDEX, type EmployeeCache, type EmployeeNames } from '$lib/employees';
|
import { PRIMARY_READER_INDEX, type EmployeeCache, type EmployeeNames } from '$lib/employees';
|
||||||
import Field from '../Field.svelte';
|
import Field from '../Field.svelte';
|
||||||
import ErrorBanner from '../ErrorBanner.svelte';
|
import ErrorBanner from '../ErrorBanner.svelte';
|
||||||
import ActionButton from '../review/ActionButton.svelte';
|
import ActionButton from '../ActionButton.svelte';
|
||||||
import PillButton from '../PillButton.svelte';
|
import PillButton from '../PillButton.svelte';
|
||||||
import EmployeesDrawer from './EmployeesDrawer.svelte';
|
import EmployeesDrawer from './EmployeesDrawer.svelte';
|
||||||
import { IPV4 } from './logic';
|
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.",
|
"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",
|
"back_to_review": "Back to review",
|
||||||
"round_and_export": "Round and export",
|
"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": {
|
"settings": {
|
||||||
"title": "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.",
|
"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",
|
"back_to_review": "Torna alla revisione",
|
||||||
"round_and_export": "Arrotonda ed esporta",
|
"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": {
|
"settings": {
|
||||||
"title": "Impostazioni",
|
"title": "Impostazioni",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { _, locale } from 'svelte-i18n';
|
import { _, locale } from 'svelte-i18n';
|
||||||
import { monthName } from '$lib/month';
|
import { monthName } from '$lib/month';
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
import SettingsButton from '$lib/components/SettingsButton.svelte';
|
import SettingsButton from '$lib/components/SettingsButton.svelte';
|
||||||
import Settings from '$lib/components/Settings.svelte';
|
import Settings from '$lib/components/Settings.svelte';
|
||||||
import NewEmployees from '$lib/components/NewEmployees.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 { buildEmployees, type AttendanceRecord } from '$lib/components/review/fromRecords';
|
||||||
import type { NewEmployee } from '$lib/components/new-employees/logic';
|
import type { NewEmployee } from '$lib/components/new-employees/logic';
|
||||||
import type { Shifts } from '$lib/shifts';
|
import type { Shifts } from '$lib/shifts';
|
||||||
@@ -38,14 +40,45 @@
|
|||||||
let newEmployees = $state<NewEmployee[]>([]);
|
let newEmployees = $state<NewEmployee[]>([]);
|
||||||
// Placeholder: overwritten by load_settings before the review screen is reachable.
|
// Placeholder: overwritten by load_settings before the review screen is reachable.
|
||||||
let shifts = $state<Shifts>({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 });
|
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 period = $derived(`${monthName($locale, selectedMonth)} ${selectedYear}`);
|
||||||
const reviewEmployees = $derived(buildEmployees(selectedYear, selectedMonth, records, employeeNames));
|
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 () => {
|
onMount(async () => {
|
||||||
const settings = await invoke<SettingsData>('load_settings');
|
const settings = await invoke<SettingsData>('load_settings');
|
||||||
shifts = settings.shifts;
|
shifts = settings.shifts;
|
||||||
step = settings.readers[0] ? 'confirm-month' : 'setup';
|
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() {
|
function openSettings() {
|
||||||
@@ -118,9 +151,10 @@
|
|||||||
month={selectedMonth}
|
month={selectedMonth}
|
||||||
employees={reviewEmployees}
|
employees={reviewEmployees}
|
||||||
shift={shifts}
|
shift={shifts}
|
||||||
onback={() => (step = 'confirm-month')}
|
onback={() => requestLeaveReview(() => (step = 'confirm-month'))}
|
||||||
onexport={(payload) => invoke('export_timesheet', { payload })}
|
onexport={(payload) => invoke('export_timesheet', { payload })}
|
||||||
onrename={onEmployeeRenamed}
|
onrename={onEmployeeRenamed}
|
||||||
|
ondirtychange={(dirty) => (reviewDirty = dirty)}
|
||||||
/>
|
/>
|
||||||
{:else if step === 'new-employees'}
|
{:else if step === 'new-employees'}
|
||||||
<NewEmployees employees={newEmployees} oncontinue={onNewEmployeesContinue} />
|
<NewEmployees employees={newEmployees} oncontinue={onNewEmployeesContinue} />
|
||||||
@@ -150,3 +184,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Screen>
|
</Screen>
|
||||||
{/if}
|
{/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