feat(oxibadge): add timesheet review and export screen
Check, format and test / build (push) Successful in 10m16s

Add the month-review screen shown after records/employees load: an
editable per-employee timesheet table with issue/rounding detection,
bulk fixes (reorder stacked punches, add lunch break, round to step),
and an export flow (single employee or all) wired to a new Rust
export_timesheet command. Split into focused components under
review/ instead of one large file, with shared i18n keys and a new
export_timesheet IPC command.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 15:56:25 +02:00
parent b4cd8e1796
commit acd3efeac6
19 changed files with 2664 additions and 36 deletions
@@ -0,0 +1,395 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { COLS_KEYS, LUNCH, type Row, cellClass, dayLabel, hm, isRnd, noteOf } from './analysis';
import FixButton from './FixButton.svelte';
let {
rows,
rounded,
emp,
oncellinput,
onfixday,
onaddlunch,
onroundday
}: {
rows: Row[];
rounded: Record<string, string>;
emp: number;
oncellinput: (di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) => void;
onfixday: (di: number) => void;
onaddlunch: (di: number) => void;
onroundday: (di: number) => void;
} = $props();
</script>
<div class="table-box scroll">
<table>
<thead>
<tr>
<th style="width: 92px"></th>
<th class="grp" colspan="2"><span>{$_('review.shift_1')}</span></th>
<th class="grp" colspan="2"><span>{$_('review.shift_2')}</span></th>
<th></th>
<th></th>
</tr>
<tr>
<th class="first center">{$_('review.col_day')}</th>
<th class="center">{$_('review.col_in')}</th>
<th class="center">{$_('review.col_out')}</th>
<th class="center">{$_('review.col_in')}</th>
<th class="center">{$_('review.col_out')}</th>
<th class="center">{$_('review.col_hours')}</th>
<th style="width: 100%">{$_('review.col_notes')}</th>
</tr>
</thead>
<tbody>
{#each rows as r (r.di)}
{@const rnd = r.v.reduce(
(n, c, ci) => n + c.filter((x, ii) => isRnd(rounded, emp, r.di, ci, ii, x)).length,
0
)}
{@const manual = r.rowEdits - rnd}
{@const note = noteOf(r, rnd, manual, $_)}
{@const label = dayLabel(r)}
<tr class:we={r.weekend} class:anom={!!r.issue}>
<td class="first center"><span class="dow">{r.dow}</span><span class="mono">{r.di + 1}</span></td>
{#each r.v as list, ci}
{@const many = list.filter((x) => x !== '').length > 1}
<td class="center">
<div class="slot" class:stack={many} class:gone={r.stacked && r.miss[ci]}>
{#each list as val, ii}
<input
class={cellClass(r, ci, ii, isRnd(rounded, emp, r.di, ci, ii, val))}
type="text"
inputmode="numeric"
maxlength="5"
placeholder="--:--"
value={val}
aria-label={list.length > 1
? $_('review.cell_aria_stacked', {
values: { label, col: $_(COLS_KEYS[ci]), index: ii + 1, total: list.length }
})
: $_('review.cell_aria', { values: { label, col: $_(COLS_KEYS[ci]) } })}
oninput={(e) => oncellinput(r.di, ci, ii, e)}
/>
{/each}
</div>
</td>
{/each}
<td class="center mono" class:dim={!r.mins}>{r.mins ? hm(r.mins) : '—'}</td>
<td class="note {note.kind}">
<span class="note-in">
{#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
>
{:else if note.kind === 'info'}
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></svg
>
{/if}
<span>{note.text}</span>
{#if r.stacked}
<FixButton ariaLabel={$_('review.fix_shifts_day_aria', { values: { label } })} onclick={() => onfixday(r.di)}>
{#snippet icon()}
<svg width="14" height="14" 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_short')}
</FixButton>
{/if}
{#if r.lunch}
<FixButton
ariaLabel={$_('review.add_lunch_day_aria', { values: { start: LUNCH.start, end: LUNCH.end, label } })}
onclick={() => onaddlunch(r.di)}
>
{#snippet icon()}
<svg width="14" height="14" 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.lunch_short')} <span class="mono">{LUNCH.start}–{LUNCH.end}</span>
</FixButton>
{/if}
{#if r.info}
<FixButton blue ariaLabel={$_('review.round_day_aria', { values: { label } })} onclick={() => onroundday(r.di)}>
{#snippet icon()}
<svg width="14" height="14" 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_short')}
</FixButton>
{/if}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
{#if rows.length === 0}
<div class="empty">
<svg width="28" height="28" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="12" cy="12" r="9" /><path d="M8 12.5l2.5 2.5L16 9.5" /></svg
>
<span>{$_('review.no_rows')}</span>
</div>
{/if}
</div>
<style>
.mono {
font-family: var(--ox-font-mono);
}
.table-box {
flex: 1;
min-height: 0;
overflow: auto;
border: 1px solid #262626;
border-radius: 14px;
background: #141414;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 14px;
}
th {
position: sticky;
background: #161616;
font-weight: 500;
color: #8a8a8a;
text-align: left;
white-space: nowrap;
z-index: 2;
}
thead tr:first-child th {
top: 0;
height: 30px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #6e6e6e;
padding: 0 8px;
}
thead tr:last-child th {
top: 30px;
height: 34px;
font-size: 12.5px;
padding: 0 8px;
border-bottom: 1px solid #2a2a2a;
}
th.grp {
text-align: center;
}
th.grp span {
display: block;
border-bottom: 1px solid #2e2e2e;
padding-bottom: 6px;
margin: 0 6px;
}
th.first,
td.first {
padding-left: 16px;
}
.center {
text-align: center;
}
td {
height: 44px;
padding: 5px 8px;
border-bottom: 1px solid #1f1f1f;
white-space: nowrap;
}
td.dim {
color: #4a4a4a;
}
tr:hover td {
background: #171717;
}
tr.we td {
color: #5e5e5e;
}
tr.anom td {
background: rgba(229, 72, 77, 0.05);
}
tr.anom:hover td {
background: rgba(229, 72, 77, 0.08);
}
.dow {
display: inline-block;
width: 34px;
color: #6e6e6e;
font-size: 13px;
}
/* Celle orario */
.slot {
display: flex;
flex-direction: column;
gap: 3px;
width: max-content;
margin: 0 auto;
}
.slot.stack {
padding: 3px;
border: 1px dashed #a8383d;
border-radius: 11px;
background: rgba(229, 72, 77, 0.06);
}
.slot.stack .cell {
height: 30px;
color: #ffc2c5;
}
.slot.gone .cell {
border: 1px dashed #a8383d;
background: rgba(229, 72, 77, 0.08);
}
.slot.gone .cell::placeholder {
color: #a8383d;
}
.cell {
all: unset;
box-sizing: border-box;
width: 84px;
height: 34px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid transparent;
font-family: var(--ox-font-mono);
font-size: 14px;
color: #e6e6e6;
caret-color: var(--ox-accent);
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
.cell::placeholder {
color: #3e3e3e;
}
.cell:hover {
border-color: #333333;
}
.cell:focus {
border-color: var(--ox-accent);
box-shadow: 0 0 0 3px var(--ox-accent-ring);
background: #121212;
}
tr.we .cell {
color: #6e6e6e;
}
.cell.edit {
color: #ffb08c;
background: rgba(247, 76, 0, 0.09);
}
.cell.miss {
border: 1px dashed #a8383d;
background: rgba(229, 72, 77, 0.08);
}
.cell.miss::placeholder {
color: #a8383d;
}
.cell.bad {
border-color: #e5484d;
color: #ff8a8f;
}
.cell.unr,
tr.we .cell.unr {
color: #d6e4ff;
border: 1px dashed #3a5f99;
background: transparent;
}
.cell.unr:hover {
border-color: #4d8dff;
}
.cell.unr:focus {
border-style: solid;
border-color: var(--ox-accent);
}
.cell.rnd,
tr.we .cell.rnd {
color: #9cc2ff;
background: rgba(77, 141, 255, 0.12);
}
/* Note */
td.note {
font-size: 13px;
color: #6e6e6e;
}
td.note.issue {
color: #ff9ea3;
}
td.note.info,
td.note.round {
color: #8db8ff;
}
td.note.edit {
color: #ff9a66;
}
.note-in {
display: inline-flex;
align-items: center;
gap: 8px;
}
.empty {
padding: 48px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
color: #8a8a8a;
font-size: 14px;
}
.empty svg {
color: #4cc38a;
}
</style>