Files
r701/oxibadge/src/lib/components/review/EmployeeHeader.svelte
T
nicolabelluti 0c320c39b0
Check, format and test / build (push) Successful in 10m25s
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 <noreply@anthropic.com>
2026-10-09 13:46:31 +02:00

294 lines
7.6 KiB
Svelte

<script lang="ts">
import { _ } from 'svelte-i18n';
import { autofocus } from '$lib/autofocus';
import type { Shifts } from '$lib/shifts';
import { dayWord } from './analysis';
import FixButton from './FixButton.svelte';
import IconButton from '../IconButton.svelte';
let {
name,
editing = $bindable(false),
draft = $bindable(''),
onsave,
worked,
totalLabel,
issues,
infos,
edits,
stackedCount,
lunchCount,
roundCount,
shift,
onfixall,
onaddlunchall,
onroundall,
onundoall,
onlyIssues = $bindable(false)
}: {
name: string;
editing?: boolean;
draft?: string;
onsave: (name: string) => void;
worked: number;
totalLabel: string;
issues: number;
infos: number;
edits: number;
stackedCount: number;
lunchCount: number;
roundCount: number;
shift: Shifts;
onfixall: () => void;
onaddlunchall: () => void;
onroundall: () => void;
onundoall: () => void;
onlyIssues?: boolean;
} = $props();
function startEdit() {
draft = name;
editing = true;
}
function saveName() {
const n = draft.trim();
if (n && n !== name) onsave(n);
editing = false;
}
</script>
<div class="main-head">
<div class="who">
{#if editing}
<div class="name-row">
<input
class="name-input"
type="text"
aria-label={$_('review.employee_name_aria')}
bind:value={draft}
use:autofocus
onkeydown={(e) => {
if (e.key === 'Enter') saveName();
}}
/>
<IconButton accent ariaLabel={$_('review.save_name')} title={$_('review.save')} onclick={saveName}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
>
{/snippet}
</IconButton>
<IconButton ariaLabel={$_('review.cancel')} title={$_('review.cancel')} onclick={() => (editing = false)}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M6 6l12 12M18 6L6 18" /></svg
>
{/snippet}
</IconButton>
</div>
{:else}
<div class="name-row">
<h2>{name}</h2>
<IconButton ariaLabel={$_('review.edit_name')} title={$_('review.edit_name')} onclick={startEdit}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
>
{/snippet}
</IconButton>
</div>
{/if}
<div class="stats">
<span><b>{worked}</b> {$_('review.days_worked')}</span>
<span><b>{totalLabel}</b> {$_('review.total_hours')}</span>
<span><b class:red={issues > 0}>{issues}</b> {$_('review.to_check')}</span>
<span><b class:blue={infos > 0}>{infos}</b> {$_('review.to_round')}</span>
<span><b class:orange={edits > 0}>{edits}</b> {$_('review.edited')}</span>
</div>
</div>
<div class="tools">
{#if stackedCount}
<FixButton size="lg" title={$_('review.fix_shifts_title')} onclick={onfixall}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M7 4v16M3 8l4-4 4 4M17 20V4M13 16l4 4 4-4" /></svg
>
{/snippet}
{$_('review.fix_shifts', { values: { days: dayWord(stackedCount, $_) } })}
</FixButton>
{/if}
{#if lunchCount}
<FixButton
size="lg"
title={$_('review.add_lunch_title', { values: { start: shift.out1, end: shift.in2 } })}
onclick={onaddlunchall}
>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M7 3v8M5 3v4a2 2 0 0 0 4 0V3M7 11v10M17 21V3c-2 1-3 3.5-3 7h3" /></svg
>
{/snippet}
{$_('review.add_lunch', { values: { days: dayWord(lunchCount, $_) } })}
</FixButton>
{/if}
{#if roundCount}
<FixButton size="lg" blue title={$_('review.round_title')} onclick={onroundall}>
{#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_all', { values: { step: shift.round_step_min, days: dayWord(roundCount, $_) } })}
</FixButton>
{/if}
<div class="seg" role="group" aria-label={$_('review.day_filter_aria')}>
<button type="button" class:on={!onlyIssues} aria-pressed={!onlyIssues} onclick={() => (onlyIssues = false)}
>{$_('review.all_days')}</button
>
<button type="button" class:on={onlyIssues} aria-pressed={onlyIssues} onclick={() => (onlyIssues = true)}
>{$_('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>
<style>
.main-head {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px 24px;
}
.who {
display: flex;
flex-direction: column;
gap: 6px;
}
.name-row {
display: flex;
align-items: center;
gap: 6px;
min-height: 38px;
}
.name-row h2 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 600;
font-size: 22px;
letter-spacing: -0.02em;
color: #ffffff;
}
.name-input {
all: unset;
box-sizing: border-box;
height: 38px;
width: 300px;
max-width: 100%;
padding: 0 10px;
border: 1px solid var(--ox-accent);
box-shadow: 0 0 0 4px var(--ox-accent-ring);
border-radius: 10px;
font-family: var(--ox-font-display);
font-weight: 600;
font-size: 22px;
letter-spacing: -0.02em;
color: #ffffff;
caret-color: var(--ox-accent);
}
.stats {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
font-size: 13px;
color: #8a8a8a;
}
.stats b {
font-family: var(--ox-font-mono);
font-weight: 400;
color: #e6e6e6;
}
/* .stats b's own color above otherwise beats the global .red/.blue/.orange
utility on specificity, since it combines a class with a tag. */
.stats b.red {
color: #ff9ea3;
}
.stats b.blue {
color: #8db8ff;
}
.stats b.orange {
color: #ff9a66;
}
.tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.seg {
display: flex;
gap: 2px;
padding: 3px;
border: 1px solid #2a2a2a;
border-radius: 10px;
}
.seg button {
all: unset;
box-sizing: border-box;
height: 32px;
padding: 0 12px;
display: flex;
align-items: center;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: #a3a3a3;
cursor: pointer;
}
.seg button:hover {
color: #ffffff;
}
.seg button:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: -2px;
}
.seg button.on {
background: #2a2a2a;
color: #ffffff;
}
</style>