Files
r701/oxibadge/src/lib/components/review/TimesheetTable.svelte
T
nicolabelluti 5dbb3f51f1
Check, format and test / build (push) Successful in 11m56s
feat(oxibadge): consolidate review toolbar and add a "shift times" action
The per-employee correction buttons (Fix shifts, Add lunch break,
Round) and the day filter used to be scattered across the employee
header. They're now grouped into a single toolbar attached to the top
of the timesheet table: a "Corrections" dropdown (with per-item counts
and an "Apply all corrections" entry) and a "Shift times" panel, plus
the existing day filter and "Undo all changes" button.

"Shift times" adds a fixed offset (in steps of the configured rounding
granularity, ±12h) to every punch in a chosen day range, for either
the current employee or everyone — for when the time clock didn't
follow a daylight-saving change. EmployeeHeader is now just the name
editor and stats; TimesheetTable no longer owns the table's outer
border/background, which moved to the new shared card wrapper.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-09 15:21:47 +02:00

435 lines
10 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,
onundoday
}: {
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;
onundoday: (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 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'}
<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>
.table-box {
flex: 1;
min-height: 0;
overflow: auto;
border-radius: 0 0 14px 14px;
}
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;
}
/* Time cells */
.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);
}
/* Notes */
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;
}
.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 {
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>