Files
r701/oxibadge/src/lib/components/review/TimesheetTable.svelte
T
nicolabelluti b65e85f908
Check, format and test / build (push) Successful in 10m22s
feat(oxibadge): add settings screen for shifts, rounding and language
Add a settings screen (gear button on the month-confirmation screen)
to configure the expected shift times, the rounding step, the
interface language, and the time clock address — replacing the old
onboarding-only connection flow. Settings persist to the same
preferences.toml as the reader address, under a single load_settings/
save_settings command pair.

The review screen's "fix shifts" action now redistributes stacked
punches by nearest configured shift time instead of blind position,
and a stacked cell manually cleared down to one entry no longer
leaves a dangling empty input.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 18:29:57 +02:00

399 lines
9.5 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { Shifts } from '$lib/shifts';
import { COLS_KEYS, type Row, cellClass, dayLabel, hm, isRnd, noteOf } from './analysis';
import FixButton from './FixButton.svelte';
let {
rows,
rounded,
emp,
shift,
oncellinput,
onfixday,
onaddlunch,
onroundday
}: {
rows: Row[];
rounded: Record<string, string>;
emp: number;
shift: Shifts;
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, $_, shift.round_step_min)}
{@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: shift.out1, end: shift.in2, 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">{shift.out1}–{shift.in2}</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>