Check, format and test / build (push) Successful in 10m22s
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>
399 lines
9.5 KiB
Svelte
399 lines
9.5 KiB
Svelte
<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>
|