feat(oxibadge): add timesheet review and export screen
Check, format and test / build (push) Successful in 10m16s
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:
@@ -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>
|
||||
Reference in New Issue
Block a user