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
+37 -8
View File
@@ -3,6 +3,7 @@ mod preferences;
use chrono::{Local, TimeZone};
use preferences::Reader;
use r701::{Record, R701};
use serde::Deserialize;
use std::collections::HashMap;
use tauri::AppHandle;
use tokio_stream::StreamExt;
@@ -75,14 +76,10 @@ async fn load_records(app: AppHandle, year: i32, month: u32) -> Result<Vec<Recor
records.reverse();
dbg!(&records);
Ok(records)
}
/// Resolves the name of every distinct employee in `records`, printing both
/// the records and the resulting employee map to stdout before returning
/// the map.
/// Resolves the name of every distinct employee in `records`.
#[tauri::command]
async fn load_employees(
app: AppHandle,
@@ -108,11 +105,42 @@ async fn load_employees(
}
}
dbg!(&employees);
Ok(employees)
}
/// One employee's corrected timesheet for a month, as sent by the review
/// screen's export button. `days[d][c]` is the list of times (usually 0
/// or 1, more if stacked) punched in slot `c` (entry/exit of the 1st/2nd
/// shift) on day `d + 1`.
///
/// For now `export_timesheet` only `dbg!`-prints this, so none of the
/// fields are read outside of the derived `Debug` impl (which the
/// dead-code lint deliberately ignores).
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct EmployeeTimesheet {
id: u32,
name: String,
days: Vec<Vec<Vec<String>>>,
}
#[derive(Debug, Deserialize)]
struct ExportPayload {
scope: String,
year: i32,
month: u32,
employees: Vec<EmployeeTimesheet>,
}
/// Placeholder export: just prints each employee's corrected timesheet.
#[tauri::command]
fn export_timesheet(payload: ExportPayload) {
dbg!(&payload.scope, payload.year, payload.month);
for employee in &payload.employees {
dbg!(employee);
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -121,7 +149,8 @@ pub fn run() {
save_preferences,
test_connection,
load_records,
load_employees
load_employees,
export_timesheet
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+2 -2
View File
@@ -13,8 +13,8 @@
"windows": [
{
"title": "Oxibadge",
"width": 800,
"height": 600
"width": 1200,
"height": 700
}
],
"security": {
+27
View File
@@ -59,3 +59,30 @@ h1 {
letter-spacing: -0.03em;
color: var(--ox-text-strong);
}
.red {
color: #ff9ea3;
}
.blue {
color: #8db8ff;
}
.orange {
color: #ff9a66;
}
.scroll {
scrollbar-width: thin;
scrollbar-color: #3a3a3a transparent;
}
.scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scroll::-webkit-scrollbar-thumb {
background: #333333;
border-radius: 4px;
}
@@ -0,0 +1,374 @@
<script lang="ts">
import { untrack } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { monthName } from '$lib/month';
import {
type Day,
type Employee,
LUNCH,
analyze,
dayWord,
formatTyped,
hm,
lunchSpan,
normDay,
parseT,
roundT,
shortSummary,
summary
} from './review/analysis';
import TopBar from './review/TopBar.svelte';
import EmployeeSidebar from './review/EmployeeSidebar.svelte';
import EmployeeHeader from './review/EmployeeHeader.svelte';
import TimesheetTable from './review/TimesheetTable.svelte';
import ExportModal from './review/ExportModal.svelte';
import Toast from './review/Toast.svelte';
import ActionButton from './review/ActionButton.svelte';
let {
year,
month,
employees,
onexport,
onrename,
onback
}: {
year: number;
month: number;
employees: Employee[];
onexport?: (p: { scope: 'all' | 'one'; year: number; month: number; employees: Employee[] }) => void;
onrename?: (id: Employee['id'], name: string) => void;
onback?: () => void;
} = $props();
// Copie di lavoro: il componente non modifica le props. Per caricare un
// altro mese va rimontato (nella nostra app accade già da sé, dato che
// la schermata è dietro un {#if step === 'review'}). `untrack` rende
// esplicito che leggiamo `employees` una sola volta, di proposito.
const orig: Day[][] = untrack(() => employees.map((e) => e.days.map(normDay)));
let values = $state<Day[][]>(untrack(() => employees.map((e) => e.days.map(normDay))));
let names = $state<string[]>(untrack(() => employees.map((e) => e.name)));
/** "dip-giorno-casella-indice" -> orario prodotto dall'arrotondamento */
let rounded = $state<Record<string, string>>({});
// ---- Stato UI ----
let emp = $state(0);
let query = $state('');
let onlyIssues = $state(false);
let editingName = $state(false);
let draft = $state('');
let menuOpen = $state(false);
let confirmScope = $state<'all' | 'one' | null>(null);
let toast = $state('');
let toastTimer: ReturnType<typeof setTimeout> | undefined;
const all = $derived(names.map((_name, ei) => analyze(year, month, values[ei], orig[ei], $locale, $_)));
const cur = $derived(all[emp]);
const issuesAll = $derived(all.reduce((n, x) => n + x.issues, 0));
const infosAll = $derived(all.reduce((n, x) => n + x.infos, 0));
const visibleEmps = $derived(
names
.map((name, ei) => ({ name, ei, issues: all[ei].issues, infos: all[ei].infos }))
.filter((e) => !query.trim() || e.name.toLowerCase().includes(query.trim().toLowerCase()))
);
const visibleRows = $derived(cur.rows.filter((r) => !onlyIssues || r.issue || r.info));
// ---- Azioni sui dati ----
function onCellInput(di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) {
const input = e.currentTarget;
const prev = values[emp][di][ci][ii] ?? '';
const next = formatTyped(input.value, prev, (e as unknown as InputEvent).inputType ?? '');
values[emp][di][ci][ii] = next;
if (input.value !== next) input.value = next; // allinea il campo anche se lo stato non cambia
}
/** Ordina tutte le timbrature dei giorni e le ridistribuisce in E1, U1, E2, U2. */
function fixDays(dis: number[]) {
for (const di of dis) {
const list = values[emp][di].flat().filter((x) => x !== '');
list.sort((a, b) => (parseT(a) ?? 9999) - (parseT(b) ?? 9999));
const day: Day = [[list[0] ?? ''], [list[1] ?? ''], [list[2] ?? ''], [list[3] ?? '']];
if (list.length > 4) day[3] = list.slice(3);
values[emp][di] = day;
}
}
function addLunch(dis: number[]) {
for (const di of dis) {
const span = lunchSpan(values[emp][di]);
if (span) values[emp][di] = [[span[0]], [LUNCH.start], [LUNCH.end], [span[1]]];
}
}
function roundDaysOf(ei: number, dis: number[]) {
for (const di of dis) {
values[ei][di] = values[ei][di].map((c, ci) =>
c.map((x, ii) => {
const r = roundT(x);
if (r !== x) rounded[`${ei}-${di}-${ci}-${ii}`] = r;
return r;
})
) as Day;
}
}
// ---- Nome ----
function renameCurrent(name: string) {
names[emp] = name;
onrename?.(employees[emp].id, name);
}
// ---- Esportazione ----
function requestExport(scope: 'all' | 'one') {
menuOpen = false;
const pending = scope === 'all' ? issuesAll + infosAll : cur.issues + cur.infos;
if (pending > 0) confirmScope = scope;
else doExport(scope);
}
function doExport(scope: 'all' | 'one') {
const pick = scope === 'all' ? names.map((_name, i) => i) : [emp];
const snap = $state.snapshot(values);
onexport?.({
scope,
year,
month,
employees: pick.map((ei) => ({
id: employees[ei].id,
name: names[ei],
days: snap[ei].map((d) => d.map((c) => c.filter((x) => x !== ''))) as Day[]
}))
});
confirmScope = null;
menuOpen = false;
toast = $_('review.export_done', {
values: { who: scope === 'all' ? $_('review.everyone') : names[emp] }
});
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast = ''), 3200);
}
function roundAndExport(scope: 'all' | 'one') {
const eis = scope === 'all' ? names.map((_name, i) => i) : [emp];
for (const ei of eis) roundDaysOf(ei, all[ei].roundDays);
doExport(scope);
}
$effect(() => () => clearTimeout(toastTimer));
// Testi dell'avviso di esportazione
const scopeIssues = $derived(confirmScope === 'all' ? issuesAll : cur.issues);
const scopeInfos = $derived(confirmScope === 'all' ? infosAll : cur.infos);
const confirmSummary = $derived(
scopeIssues
? summary(scopeIssues, scopeInfos, $_)
: $_('review.only_unrounded', { values: { days: dayWord(scopeInfos, $_) } })
);
const confirmTitle = $derived(
confirmScope === 'all'
? $_('review.confirm_title_all', { values: { summary: confirmSummary } })
: $_('review.confirm_title_one', { values: { name: names[emp], summary: confirmSummary } })
);
const confirmItems = $derived(
confirmScope === 'all'
? names
.map((name, ei) => ({ name, ei, iss: all[ei].issues, inf: all[ei].infos }))
.filter((x) => x.iss || x.inf)
.map((x) => ({
key: 'e' + x.ei,
label: x.name,
detail: shortSummary(x.iss, x.inf, $_),
red: x.iss > 0,
go: () => {
confirmScope = null;
emp = x.ei;
onlyIssues = true;
editingName = false;
}
}))
: cur.rows
.filter((r) => r.issue || r.info)
.map((r) => ({
key: 'd' + r.di,
label: r.dow + ' ' + (r.di + 1),
detail: r.issue || $_('review.to_round_cap'),
red: !!r.issue,
go: () => {
confirmScope = null;
onlyIssues = true;
}
}))
);
function onKey(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
if (confirmScope) confirmScope = null;
else if (menuOpen) menuOpen = false;
else if (editingName) editingName = false;
}
</script>
<svelte:window onkeydown={onKey} />
<div class="screen">
{#if menuOpen}
<button type="button" class="backdrop" aria-label={$_('review.close_menu_aria')} onclick={() => (menuOpen = false)}
></button>
{/if}
{#if confirmScope}
<ExportModal
title={confirmTitle}
{scopeIssues}
{scopeInfos}
items={confirmItems}
onclose={() => (confirmScope = null)}
onroundandexport={() => confirmScope && roundAndExport(confirmScope)}
onexportanyway={() => confirmScope && doExport(confirmScope)}
/>
{/if}
<Toast message={toast} />
{#if names.length === 0}
<div class="empty-month">
<h1>{monthName($locale, month)} {year}</h1>
<p>{$_('review.no_records')}</p>
{#if onback}
<ActionButton onclick={onback}>{$_('review.back_to_month')}</ActionButton>
{/if}
</div>
{:else}
<TopBar
{year}
{month}
{issuesAll}
{infosAll}
{onback}
bind:menuOpen
employeeCount={names.length}
currentName={names[emp]}
currentIssues={cur.issues}
currentInfos={cur.infos}
onexportall={() => requestExport('all')}
onexportone={() => requestExport('one')}
/>
<div class="body">
<EmployeeSidebar
employees={visibleEmps}
selected={emp}
bind:query
onselect={(ei) => {
emp = ei;
editingName = false;
}}
/>
<main class="main">
<EmployeeHeader
name={names[emp]}
bind:editing={editingName}
bind:draft
onsave={renameCurrent}
worked={cur.worked}
totalLabel={hm(cur.minutes)}
issues={cur.issues}
infos={cur.infos}
edits={cur.edits}
stackedCount={cur.stackedDays.length}
lunchCount={cur.lunchDays.length}
roundCount={cur.roundDays.length}
onfixall={() => fixDays(cur.stackedDays)}
onaddlunchall={() => addLunch(cur.lunchDays)}
onroundall={() => roundDaysOf(emp, cur.roundDays)}
bind:onlyIssues
/>
<TimesheetTable
rows={visibleRows}
{rounded}
{emp}
oncellinput={onCellInput}
onfixday={(di) => fixDays([di])}
onaddlunch={(di) => addLunch([di])}
onroundday={(di) => roundDaysOf(emp, [di])}
/>
</main>
</div>
{/if}
</div>
<style>
.screen {
position: relative;
height: 100vh;
min-height: 640px;
width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--ox-bg);
color: var(--ox-text);
font-family: var(--ox-font-body);
}
.backdrop {
all: unset;
position: absolute;
inset: 0;
z-index: 20;
cursor: default;
}
.body {
flex: 1;
min-height: 0;
display: flex;
}
.main {
flex: 1;
min-width: 0;
min-height: 0;
box-sizing: border-box;
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.empty-month {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
text-align: center;
padding: 24px;
}
.empty-month h1 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 700;
font-size: 32px;
letter-spacing: -0.025em;
color: var(--ox-text-strong);
}
.empty-month p {
margin: 0;
color: var(--ox-text-muted);
font-size: 15px;
}
@media (max-width: 760px) {
.body {
flex-direction: column;
}
}
</style>
@@ -0,0 +1,74 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
primary = false,
onclick,
icon,
children
}: {
primary?: boolean;
onclick: () => void;
icon?: Snippet;
children: Snippet;
} = $props();
</script>
<button type="button" class="btn" class:primary {onclick}>
{#if icon}{@render icon()}{/if}
<span>{@render children()}</span>
</button>
<style>
.btn {
all: unset;
box-sizing: border-box;
height: 44px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid #333333;
border-radius: 12px;
color: #e6e6e6;
font-size: 14.5px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn:hover {
background: #1e1e1e;
color: #ffffff;
}
.btn:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
.btn.primary {
padding: 0 18px;
border: none;
background: var(--ox-accent);
color: var(--ox-on-accent);
font-weight: 600;
transition:
filter 0.15s,
transform 0.1s;
}
.btn.primary:hover {
background: var(--ox-accent);
filter: brightness(1.08);
}
.btn.primary:active {
transform: translateY(1px);
}
.btn.primary:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 3px;
}
</style>
@@ -0,0 +1,276 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { LUNCH, ROUND_STEP, dayWord } from './analysis';
import FixButton from './FixButton.svelte';
import IconButton from './IconButton.svelte';
let {
name,
editing = $bindable(false),
draft = $bindable(''),
onsave,
worked,
totalLabel,
issues,
infos,
edits,
stackedCount,
lunchCount,
roundCount,
onfixall,
onaddlunchall,
onroundall,
onlyIssues = $bindable(false)
}: {
name: string;
editing?: boolean;
draft?: string;
onsave: (name: string) => void;
worked: number;
totalLabel: string;
issues: number;
infos: number;
edits: number;
stackedCount: number;
lunchCount: number;
roundCount: number;
onfixall: () => void;
onaddlunchall: () => void;
onroundall: () => void;
onlyIssues?: boolean;
} = $props();
function startEdit() {
draft = name;
editing = true;
}
function saveName() {
const n = draft.trim();
if (n && n !== name) onsave(n);
editing = false;
}
function autofocus(node: HTMLInputElement) {
node.focus();
node.select();
}
</script>
<div class="main-head">
<div class="who">
{#if editing}
<div class="name-row">
<input
class="name-input"
type="text"
aria-label={$_('review.employee_name_aria')}
bind:value={draft}
use:autofocus
onkeydown={(e) => {
if (e.key === 'Enter') saveName();
}}
/>
<IconButton accent ariaLabel={$_('review.save_name')} title={$_('review.save')} onclick={saveName}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
>
{/snippet}
</IconButton>
<IconButton ariaLabel={$_('review.cancel')} title={$_('review.cancel')} onclick={() => (editing = false)}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M6 6l12 12M18 6L6 18" /></svg
>
{/snippet}
</IconButton>
</div>
{:else}
<div class="name-row">
<h2>{name}</h2>
<IconButton ariaLabel={$_('review.edit_name')} title={$_('review.edit_name')} onclick={startEdit}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
>
{/snippet}
</IconButton>
</div>
{/if}
<div class="stats">
<span><b>{worked}</b> {$_('review.days_worked')}</span>
<span><b>{totalLabel}</b> {$_('review.total_hours')}</span>
<span><b class:red={issues > 0}>{issues}</b> {$_('review.to_check')}</span>
<span><b class:blue={infos > 0}>{infos}</b> {$_('review.to_round')}</span>
<span><b class:orange={edits > 0}>{edits}</b> {$_('review.edited')}</span>
</div>
</div>
<div class="tools">
{#if stackedCount}
<FixButton size="lg" title={$_('review.fix_shifts_title')} onclick={onfixall}>
{#snippet icon()}
<svg width="16" height="16" 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', { values: { days: dayWord(stackedCount, $_) } })}
</FixButton>
{/if}
{#if lunchCount}
<FixButton
size="lg"
title={$_('review.add_lunch_title', { values: { start: LUNCH.start, end: LUNCH.end } })}
onclick={onaddlunchall}
>
{#snippet icon()}
<svg width="16" height="16" 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.add_lunch', { values: { days: dayWord(lunchCount, $_) } })}
</FixButton>
{/if}
{#if roundCount}
<FixButton size="lg" blue title={$_('review.round_title')} onclick={onroundall}>
{#snippet icon()}
<svg width="16" height="16" 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_all', { values: { step: ROUND_STEP, days: dayWord(roundCount, $_) } })}
</FixButton>
{/if}
<div class="seg" role="group" aria-label={$_('review.day_filter_aria')}>
<button type="button" class:on={!onlyIssues} aria-pressed={!onlyIssues} onclick={() => (onlyIssues = false)}
>{$_('review.all_days')}</button
>
<button type="button" class:on={onlyIssues} aria-pressed={onlyIssues} onclick={() => (onlyIssues = true)}
>{$_('review.to_check_cap')}</button
>
</div>
</div>
</div>
<style>
.main-head {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px 24px;
}
.who {
display: flex;
flex-direction: column;
gap: 6px;
}
.name-row {
display: flex;
align-items: center;
gap: 6px;
min-height: 38px;
}
.name-row h2 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 600;
font-size: 22px;
letter-spacing: -0.02em;
color: #ffffff;
}
.name-input {
all: unset;
box-sizing: border-box;
height: 38px;
width: 300px;
max-width: 100%;
padding: 0 10px;
border: 1px solid var(--ox-accent);
box-shadow: 0 0 0 4px var(--ox-accent-ring);
border-radius: 10px;
font-family: var(--ox-font-display);
font-weight: 600;
font-size: 22px;
letter-spacing: -0.02em;
color: #ffffff;
caret-color: var(--ox-accent);
}
.stats {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
font-size: 13px;
color: #8a8a8a;
}
.stats b {
font-family: var(--ox-font-mono);
font-weight: 400;
color: #e6e6e6;
}
/* .stats b's own color above otherwise beats the global .red/.blue/.orange
utility on specificity, since it combines a class with a tag. */
.stats b.red {
color: #ff9ea3;
}
.stats b.blue {
color: #8db8ff;
}
.stats b.orange {
color: #ff9a66;
}
.tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.seg {
display: flex;
gap: 2px;
padding: 3px;
border: 1px solid #2a2a2a;
border-radius: 10px;
}
.seg button {
all: unset;
box-sizing: border-box;
height: 32px;
padding: 0 12px;
display: flex;
align-items: center;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: #a3a3a3;
cursor: pointer;
}
.seg button:hover {
color: #ffffff;
}
.seg button:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: -2px;
}
.seg button.on {
background: #2a2a2a;
color: #ffffff;
}
</style>
@@ -0,0 +1,204 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { initials } from './analysis';
let {
employees,
selected,
query = $bindable(''),
onselect
}: {
employees: { name: string; ei: number; issues: number; infos: number }[];
selected: number;
query?: string;
onselect: (ei: number) => void;
} = $props();
</script>
<nav class="side scroll" aria-label={$_('review.employees_nav_aria')}>
<div class="search">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="11" cy="11" r="6.5" /><path d="M20 20l-4-4" /></svg
>
<input
type="search"
placeholder={$_('review.search_employee')}
aria-label={$_('review.search_employee')}
bind:value={query}
/>
</div>
<div class="emp-list">
{#each employees as e (e.ei)}
<button
type="button"
class="emp"
class:on={e.ei === selected}
aria-current={e.ei === selected ? 'true' : undefined}
onclick={() => onselect(e.ei)}
>
<span class="avatar">{initials(e.name)}</span>
<span class="emp-name">{e.name}</span>
{#if e.issues}
<span class="badge red" aria-label={$_('review.days_to_check_aria', { values: { n: e.issues } })}
>{e.issues}</span
>
{:else if e.infos}
<span class="badge blue" aria-label={$_('review.days_to_round_aria', { values: { n: e.infos } })}
>{e.infos}</span
>
{:else}
<svg class="ok" width="16" height="16" viewBox="0 0 24 24" aria-label={$_('review.no_problem_aria')}
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
>
{/if}
</button>
{/each}
</div>
</nav>
<style>
.side {
width: 260px;
flex: none;
box-sizing: border-box;
padding: 16px 12px;
border-right: 1px solid #222222;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
}
.search {
height: 40px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 1px solid #333333;
border-radius: 10px;
color: #6e6e6e;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.search:focus-within {
border-color: var(--ox-accent);
box-shadow: 0 0 0 4px var(--ox-accent-ring);
}
.search input {
all: unset;
box-sizing: border-box;
width: 100%;
min-width: 0;
font-size: 14px;
color: #f2f2f2;
caret-color: var(--ox-accent);
}
.search input::placeholder {
color: #5e5e5e;
}
.emp-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.emp {
all: unset;
box-sizing: border-box;
width: 100%;
min-height: 44px;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 10px;
font-size: 14px;
color: #bdbdbd;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.emp:hover {
background: #1c1c1c;
color: #ffffff;
}
.emp:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: -2px;
}
.emp.on {
background: #222222;
color: #ffffff;
font-weight: 500;
}
.avatar {
width: 30px;
height: 30px;
flex: none;
border-radius: 50%;
background: #262626;
color: #c9c9c9;
display: flex;
align-items: center;
justify-content: center;
font-size: 11.5px;
font-weight: 600;
}
.emp-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
min-width: 22px;
height: 22px;
padding: 0 6px;
box-sizing: border-box;
border-radius: 11px;
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.badge.red {
background: rgba(229, 72, 77, 0.16);
color: #ff9ea3;
}
.badge.blue {
background: rgba(77, 141, 255, 0.14);
color: #8db8ff;
}
.ok {
color: #4cc38a;
stroke-width: 2.2;
}
@media (max-width: 760px) {
.side {
width: auto;
max-height: 220px;
border-right: none;
border-bottom: 1px solid #222222;
}
}
</style>
@@ -0,0 +1,215 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { ROUND_STEP } from './analysis';
import FixButton from './FixButton.svelte';
import ActionButton from './ActionButton.svelte';
let {
title,
scopeIssues,
scopeInfos,
items,
onclose,
onroundandexport,
onexportanyway
}: {
title: string;
scopeIssues: number;
scopeInfos: number;
items: { key: string; label: string; detail: string; red: boolean; go: () => void }[];
onclose: () => void;
onroundandexport: () => void;
onexportanyway: () => void;
} = $props();
</script>
<div class="modal-wrap">
<button type="button" class="modal-backdrop" aria-label={$_('review.cancel_export_aria')} onclick={onclose}
></button>
<div class="modal" role="alertdialog" aria-modal="true" aria-labelledby="ox-exp-title" aria-describedby="ox-exp-desc">
<div class="modal-icon">
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
d="M12 9.5v4M12 17h.01"
/></svg
>
</div>
<div class="modal-text">
<h2 id="ox-exp-title">{title}</h2>
<p id="ox-exp-desc">
{#if scopeIssues}
{$_('review.export_warning_issues')}
{:else}
{$_('review.export_warning_unrounded', { values: { step: ROUND_STEP } })}
{/if}
</p>
</div>
<div class="modal-list scroll">
{#each items as it (it.key)}
<button type="button" class="menu-item row-item" onclick={it.go}>
<span class="mi-label">{it.label}</span>
<span class="mi-detail" class:red={it.red} class:blue={!it.red}>{it.detail}</span>
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" class="mi-chev"
><path d="M9 6l6 6-6 6" /></svg
>
</button>
{/each}
</div>
<div class="modal-actions">
<ActionButton onclick={onclose}>{$_('review.back_to_review')}</ActionButton>
{#if scopeIssues === 0 && scopeInfos > 0}
<FixButton size="tall" blue onclick={onroundandexport}>
{#snippet icon()}
<svg width="16" height="16" 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_and_export')}
</FixButton>
{/if}
<ActionButton primary onclick={onexportanyway}>
{#snippet icon()}
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg
>
{/snippet}
{$_('review.export_anyway')}
</ActionButton>
</div>
</div>
</div>
<style>
.modal-wrap {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.modal-backdrop {
all: unset;
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
cursor: default;
}
.modal {
position: relative;
width: 100%;
max-width: 440px;
box-sizing: border-box;
padding: 24px;
background: #1a1a1a;
border: 1px solid #333333;
border-radius: 18px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
gap: 18px;
}
.modal-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(229, 72, 77, 0.14);
color: #ff6b74;
display: flex;
align-items: center;
justify-content: center;
}
.modal-text {
display: flex;
flex-direction: column;
gap: 8px;
}
.modal-text h2 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 700;
font-size: 22px;
line-height: 1.2;
letter-spacing: -0.02em;
color: #ffffff;
}
.modal-text p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #a3a3a3;
}
.modal-list {
max-height: 196px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px;
border: 1px solid #2a2a2a;
border-radius: 12px;
background: #151515;
}
.menu-item {
all: unset;
box-sizing: border-box;
width: 100%;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
font-size: 14px;
color: #bdbdbd;
cursor: pointer;
transition: background 0.15s;
}
.menu-item:hover {
background: #252525;
}
.menu-item:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: -2px;
}
.row-item {
align-items: center;
padding: 8px 10px;
min-height: 40px;
}
.mi-label {
flex: 1;
min-width: 0;
color: #e6e6e6;
font-weight: 500;
}
.mi-detail {
font-size: 12.5px;
white-space: nowrap;
}
.mi-chev {
color: #6e6e6e;
}
.modal-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
</style>
@@ -0,0 +1,96 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
size = 'sm',
blue = false,
title,
ariaLabel,
onclick,
icon,
children
}: {
size?: 'sm' | 'lg' | 'tall';
blue?: boolean;
title?: string;
ariaLabel?: string;
onclick: () => void;
icon: Snippet;
children: Snippet;
} = $props();
</script>
<button
type="button"
class="fix"
class:lg={size === 'lg'}
class:tall={size === 'tall'}
class:blue
{title}
aria-label={ariaLabel}
{onclick}
>
{@render icon()}
<span>{@render children()}</span>
</button>
<style>
.fix {
all: unset;
box-sizing: border-box;
height: 30px;
padding: 0 10px;
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 8px;
border: 1px solid #5c3320;
color: #ff9a66;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s,
color 0.15s;
}
.fix:hover {
background: rgba(247, 76, 0, 0.12);
border-color: var(--ox-accent);
color: #ffb08c;
}
.fix:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
.fix.lg {
height: 40px;
padding: 0 14px;
font-size: 13px;
border-radius: 10px;
}
.fix.tall {
height: 44px;
border-radius: 12px;
font-size: 14.5px;
}
.fix.blue {
border-color: #27406a;
color: #8db8ff;
}
.fix.blue:hover {
background: rgba(77, 141, 255, 0.12);
border-color: #4d8dff;
color: #b3ceff;
}
.fix.blue:focus-visible {
outline-color: #4d8dff;
}
</style>
@@ -0,0 +1,68 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
size = 'sm',
accent = false,
title,
ariaLabel,
onclick,
icon
}: {
size?: 'sm' | 'lg';
accent?: boolean;
title?: string;
ariaLabel: string;
onclick: () => void;
icon: Snippet;
} = $props();
</script>
<button type="button" class="icon-btn" class:lg={size === 'lg'} class:accent {title} aria-label={ariaLabel} {onclick}>
{@render icon()}
</button>
<style>
.icon-btn {
all: unset;
box-sizing: border-box;
width: 32px;
height: 32px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: #8a8a8a;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.icon-btn.lg {
width: 40px;
height: 40px;
border-radius: 10px;
color: #a3a3a3;
}
.icon-btn:hover {
background: #1e1e1e;
color: #ffffff;
}
.icon-btn:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 1px;
}
.icon-btn.accent {
background: var(--ox-accent);
color: var(--ox-on-accent);
}
.icon-btn.accent:hover {
filter: brightness(1.08);
}
</style>
@@ -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>
@@ -0,0 +1,33 @@
<script lang="ts">
let { message }: { message: string } = $props();
</script>
{#if message}
<div class="toast" role="status">
<svg width="18" height="18" 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>{message}</span>
</div>
{/if}
<style>
.toast {
position: absolute;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
z-index: 45;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 12px;
background: #13221a;
border: 1px solid #1f4a31;
color: #8be0ae;
font-size: 14px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
white-space: nowrap;
}
</style>
@@ -0,0 +1,314 @@
<script lang="ts">
import { _, locale } from 'svelte-i18n';
import { monthName } from '$lib/month';
import { dayWord, shortSummary } from './analysis';
import IconButton from './IconButton.svelte';
let {
year,
month,
issuesAll,
infosAll,
onback,
menuOpen = $bindable(false),
employeeCount,
currentName,
currentIssues,
currentInfos,
onexportall,
onexportone
}: {
year: number;
month: number;
issuesAll: number;
infosAll: number;
onback?: () => void;
menuOpen?: boolean;
employeeCount: number;
currentName: string;
currentIssues: number;
currentInfos: number;
onexportall: () => void;
onexportone: () => void;
} = $props();
</script>
<header class="topbar">
<div class="tb-left">
{#if onback}
<IconButton size="lg" ariaLabel={$_('review.back_to_month')} onclick={onback}>
{#snippet icon()}
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M15 6l-6 6 6 6" /></svg
>
{/snippet}
</IconButton>
{/if}
<div class="tb-title">
<span>{$_('review.title')}</span>
<h1>{monthName($locale, month)} {year}</h1>
</div>
</div>
<div class="tb-right">
{#if issuesAll}
<span class="tb-count red">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
d="M12 9.5v4M12 17h.01"
/></svg
>
{dayWord(issuesAll, $_)} {$_('review.to_check')}
</span>
{/if}
{#if infosAll}
<span class="tb-count blue">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></svg
>
{dayWord(infosAll, $_)} {$_('review.to_round')}
</span>
{/if}
<!-- Bottone diviso Esporta -->
<div class="split-wrap">
<div class="split">
<button type="button" class="split-main" onclick={onexportall}>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"
><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg
>
<span>{$_('review.export')}</span>
</button>
<span class="split-sep" aria-hidden="true"></span>
<button
type="button"
class="split-arrow"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label={$_('review.export_options_aria')}
onclick={() => (menuOpen = !menuOpen)}
>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
</button>
</div>
{#if menuOpen}
<div class="dropdown" role="menu" aria-label={$_('review.export')}>
<button type="button" role="menuitem" class="menu-item" onclick={onexportall}>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="9" cy="8" r="3.5" /><path d="M2.5 20c.8-3.3 3.3-5 6.5-5s5.7 1.7 6.5 5" /><path
d="M16 4.5a3.5 3.5 0 0 1 0 7M18 15.2c1.8.7 3 2.3 3.5 4.8"
/></svg
>
<span class="mi-col">
<span class="mi-title">{$_('review.all_employees')}</span>
<span class="mi-sub" class:red={issuesAll > 0} class:blue={!issuesAll && infosAll > 0}>
{$_('review.employee_count', { values: { n: employeeCount } })} ·
{shortSummary(issuesAll, infosAll, $_) || $_('review.all_good')}
</span>
</span>
</button>
<button type="button" role="menuitem" class="menu-item" onclick={onexportone}>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="12" cy="8" r="3.5" /><path d="M5 20c.8-3.3 3.6-5 7-5s6.2 1.7 7 5" /></svg
>
<span class="mi-col">
<span class="mi-title">{$_('review.only_name', { values: { name: currentName } })}</span>
<span class="mi-sub" class:red={currentIssues > 0} class:blue={!currentIssues && currentInfos > 0}>
{shortSummary(currentIssues, currentInfos, $_) || $_('review.all_good_cap')}
</span>
</span>
</button>
</div>
{/if}
</div>
</div>
</header>
<style>
.topbar {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 24px;
border-bottom: 1px solid #222222;
}
.tb-left {
display: flex;
align-items: center;
gap: 14px;
}
.tb-title {
display: flex;
flex-direction: column;
gap: 2px;
}
.tb-title span {
font-size: 12.5px;
color: #8a8a8a;
}
.tb-title h1 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 700;
font-size: 26px;
line-height: 1.1;
letter-spacing: -0.025em;
color: var(--ox-text-strong);
}
.tb-right {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.tb-count {
display: flex;
align-items: center;
gap: 8px;
font-size: 13.5px;
}
/* Bottone diviso */
.split-wrap {
position: relative;
z-index: 21;
}
.split {
display: flex;
align-items: stretch;
height: 44px;
border-radius: 12px;
background: var(--ox-accent);
color: var(--ox-on-accent);
}
.split-main,
.split-arrow {
all: unset;
box-sizing: border-box;
display: flex;
align-items: center;
font-size: 14.5px;
font-weight: 600;
color: var(--ox-on-accent);
cursor: pointer;
transition: background 0.15s;
}
.split-main {
gap: 10px;
padding: 0 16px 0 18px;
border-radius: 12px 0 0 12px;
}
.split-arrow {
width: 40px;
justify-content: center;
border-radius: 0 12px 12px 0;
}
.split-arrow svg {
stroke-width: 2.2;
}
.split-main:hover,
.split-arrow:hover {
background: rgba(255, 255, 255, 0.12);
}
.split-main:active,
.split-arrow:active {
background: rgba(0, 0, 0, 0.08);
}
.split-main:focus-visible,
.split-arrow:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.split-sep {
width: 1px;
margin: 10px 0;
background: rgba(26, 7, 0, 0.28);
}
.dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 300px;
box-sizing: border-box;
padding: 6px;
background: #1a1a1a;
border: 1px solid #333333;
border-radius: 14px;
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55);
display: flex;
flex-direction: column;
gap: 2px;
}
.menu-item {
all: unset;
box-sizing: border-box;
width: 100%;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
font-size: 14px;
color: #bdbdbd;
cursor: pointer;
transition: background 0.15s;
}
.menu-item:hover {
background: #252525;
}
.menu-item:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: -2px;
}
.menu-item > svg {
margin-top: 1px;
}
.mi-col {
display: flex;
flex-direction: column;
gap: 2px;
}
.mi-title {
font-weight: 500;
color: #f2f2f2;
}
.mi-sub {
font-size: 12.5px;
color: #8a8a8a;
}
.mi-sub.red {
color: #ff9ea3;
}
.mi-sub.blue {
color: #8db8ff;
}
</style>
@@ -0,0 +1,294 @@
// Logica pura di analisi delle timbrature di un mese, estratta dal
// componente di revisione per tenerlo leggibile. Le stringhe tradotte
// vengono prodotte passando la funzione `t` (di solito `$_` di
// svelte-i18n) e, dove serve la lingua per Intl, `locale` ($locale).
import { weekdayName } from '$lib/month';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches svelte-i18n's own loosely-typed `values`
export type Translate = (key: string, options?: { values?: Record<string, any> }) => string;
/** [Entrata1[], Uscita1[], Entrata2[], Uscita2[]] — ogni casella è un
* elenco di orari "HH:MM" per rappresentare anche le timbrature impilate. */
export type Day = [string[], string[], string[], string[]];
export interface Employee {
id: number;
name: string;
days: Day[];
}
export interface Row {
di: number;
v: Day;
dow: string;
weekend: boolean;
issue: string;
info: boolean;
miss: [boolean, boolean, boolean, boolean];
bad: boolean[][];
unr: boolean[][];
changed: boolean[][];
mins: number;
empty: boolean;
rowEdits: number;
stacked: boolean;
wasStacked: boolean;
lunch: boolean;
wasLunch: boolean;
}
export interface Analysis {
rows: Row[];
minutes: number;
worked: number;
issues: number;
infos: number;
edits: number;
stackedDays: number[];
lunchDays: number[];
roundDays: number[];
}
// ---- Parametri (TODO: renderli configurabili) ----
export const LUNCH = { start: '12:30', end: '13:30' };
export const ROUND_STEP = 10;
export const COLS_KEYS = ['review.col_in_1', 'review.col_out_1', 'review.col_in_2', 'review.col_out_2'];
export function normDay(day: Day): Day {
return [0, 1, 2, 3].map((i) => {
const c = (day?.[i] ?? []).filter((x) => x !== '');
return c.length ? c.slice() : [''];
}) as Day;
}
export function parseT(t: string): number | null {
const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(t);
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
}
export function fmt(m: number): string {
return String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0');
}
export function hm(m: number): string {
return Math.floor(m / 60) + ':' + String(m % 60).padStart(2, '0');
}
export function isRounded(t: string): boolean {
const m = parseT(t);
return m === null || m % ROUND_STEP === 0;
}
export function roundT(t: string): string {
const m = parseT(t);
if (m === null) return t;
return fmt(Math.min(Math.round(m / ROUND_STEP) * ROUND_STEP, 24 * 60 - ROUND_STEP));
}
export function dayWord(n: number, t: Translate): string {
return t('review.day_word', { values: { n } });
}
/** Formatta l'orario mentre si scrive, senza dover digitare ":". */
export function formatTyped(raw: string, prev: string, inputType: string): string {
const deleting = inputType ? inputType.startsWith('delete') : raw.length < prev.length && prev.startsWith(raw);
const digits = raw.replace(/\D/g, '').slice(0, 4);
if (!digits) return '';
let h: string;
let m: string;
if (digits[0] >= '3' || (digits[0] === '2' && digits.length > 1 && digits[1] > '3')) {
h = '0' + digits[0];
m = digits.slice(1, 3);
} else {
h = digits.slice(0, 2);
m = digits.slice(2, 4);
}
if (h.length < 2) return h;
if (!m) return deleting ? h : h + ':';
return h + ':' + m;
}
/** [entrata, uscita] se il giorno ha solo 2 timbrature che scavalcano la pausa pranzo. */
export function lunchSpan(day: Day): [string, string] | null {
const all = day.flat().filter((x) => x !== '');
if (all.length !== 2) return null;
const t = all.map(parseT);
if (t.some((x) => x === null)) return null;
const [a, b] = (t[0] as number) <= (t[1] as number) ? [all[0], all[1]] : [all[1], all[0]];
return (parseT(a) as number) < (parseT(LUNCH.start) as number) &&
(parseT(b) as number) > (parseT(LUNCH.end) as number)
? [a, b]
: null;
}
export function analyze(
year: number,
month: number,
values: Day[],
orig: Day[],
locale: string | null | undefined,
t: Translate
): Analysis {
const rows: Row[] = [];
let minutes = 0;
let worked = 0;
let issues = 0;
let infos = 0;
let edits = 0;
const stackedDays: number[] = [];
const lunchDays: number[] = [];
const roundDays: number[] = [];
for (let di = 0; di < values.length; di++) {
const v = values[di];
const o = orig[di];
const dow = new Date(year, month - 1, di + 1).getDay();
const weekend = dow === 0 || dow === 6;
const lists = v.map((c) => c.filter((x) => x !== ''));
const filled = lists.map((c) => c.length > 0);
const stacked = lists.some((c) => c.length > 1);
const bad = v.map((c) => c.map((x) => x !== '' && parseT(x) === null));
const miss: [boolean, boolean, boolean, boolean] = [false, false, false, false];
let issue = '';
// 1. Turno sbagliato
if (stacked) {
const total = lists.reduce((n, c) => n + c.length, 0);
const shift = filled[0] || filled[1] ? t('review.shift_1') : t('review.shift_2');
issue = t('review.issue_stacked', { values: { total, shift } });
[0, 1, 2, 3].forEach((i) => {
if (!filled[i]) miss[i] = true;
});
}
// 2. Orario non valido
if (!issue && bad.some((c) => c.some(Boolean))) issue = t('review.issue_invalid_time');
// 3. Pausa pranzo non timbrata
const lunch = !stacked && !issue && lunchSpan(v) !== null;
if (lunch) {
issue = t('review.issue_lunch_missing');
[0, 1, 2, 3].forEach((i) => {
if (!filled[i]) miss[i] = true;
});
}
// 4-5. Coppie incomplete / uscita prima dell'entrata
if (!stacked && !lunch) {
const p = lists.map((c) => (c.length ? parseT(c[0]) : null));
for (const [a, b, shiftKey] of [
[0, 1, 'review.shift_1'],
[2, 3, 'review.shift_2']
] as const) {
if (filled[a] !== filled[b]) {
miss[filled[a] ? b : a] = true;
if (!issue) {
issue = t(filled[a] ? 'review.issue_missing_out' : 'review.issue_missing_in', {
values: { shift: t(shiftKey) }
});
}
} else if (filled[a] && p[a] !== null && p[b] !== null && (p[b] as number) <= (p[a] as number) && !issue) {
issue = t('review.issue_out_before_in', { values: { shift: t(shiftKey) } });
}
}
}
// Ore: coppie per posizione dentro ciascun turno
let mins = 0;
for (const [a, b] of [
[0, 1],
[2, 3]
] as const) {
const n = Math.min(lists[a].length, lists[b].length);
for (let k = 0; k < n; k++) {
const s = parseT(lists[a][k]);
const e = parseT(lists[b][k]);
if (s !== null && e !== null && e > s) mins += e - s;
}
}
const empty = !filled.some(Boolean);
const changed = v.map((c, ci) => c.map((x, ii) => x !== (o[ci][ii] ?? '')));
const rowEdits = changed.reduce((n, c) => n + c.filter(Boolean).length, 0);
const wasStacked = o.some((c) => c.length > 1);
const wasLunch = !lunch && lunchSpan(o) !== null && v[1][0] === LUNCH.start && v[2][0] === LUNCH.end;
// 6. Info: orari non arrotondati
const unr = v.map((c) => c.map((x) => x !== '' && !isRounded(x)));
const hasUnr = unr.some((c) => c.some(Boolean));
const info = !issue && hasUnr;
if (issue) issues++;
if (info) infos++;
if (hasUnr) roundDays.push(di);
if (stacked) stackedDays.push(di);
if (lunch) lunchDays.push(di);
if (!empty) worked++;
minutes += mins;
edits += rowEdits;
rows.push({
di, v, dow: weekdayName(locale, dow), weekend, issue, info, miss, bad, unr, changed,
mins, empty, rowEdits, stacked, wasStacked, lunch, wasLunch
});
}
return { rows, minutes, worked, issues, infos, edits, stackedDays, lunchDays, roundDays };
}
export function dayLabel(r: Row): string {
return `${r.dow} ${r.di + 1}`;
}
/** "dip-giorno-casella-indice" -> è l'orario attuale il risultato di un arrotondamento? */
export function isRnd(
rounded: Record<string, string>,
emp: number,
di: number,
ci: number,
ii: number,
val: string
): boolean {
return val !== '' && rounded[`${emp}-${di}-${ci}-${ii}`] === val;
}
export function noteOf(r: Row, roundedCount: number, manualCount: number, t: Translate): { text: string; kind: string } {
if (r.issue) return { text: r.issue, kind: 'issue' };
if (r.info) return { text: t('review.info_unrounded', { values: { step: ROUND_STEP } }), kind: 'info' };
if (r.empty && !r.weekend) return { text: t('review.note_empty'), kind: 'muted' };
if (r.wasLunch) return { text: t('review.note_lunch_added'), kind: 'edit' };
if (r.wasStacked) return { text: t('review.note_shifts_fixed'), kind: 'edit' };
if (manualCount > 0) return { text: t('review.note_edited', { values: { n: manualCount } }), kind: 'edit' };
if (roundedCount > 0) return { text: t('review.note_rounded', { values: { n: roundedCount } }), kind: 'round' };
return { text: '', kind: 'muted' };
}
export function cellClass(r: Row, ci: number, ii: number, rnd: boolean): string {
let c = 'cell';
if (r.bad[ci][ii]) c += ' bad';
else if (!r.stacked && r.miss[ci]) c += ' miss';
if (r.unr[ci][ii] && !r.bad[ci][ii]) c += ' unr';
else if (rnd) c += ' rnd';
else if (r.changed[ci][ii] && !r.bad[ci][ii]) c += ' edit';
return c;
}
export function initials(n: string): string {
return n
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
export function summary(iss: number, inf: number, t: Translate): string {
return [iss ? `${dayWord(iss, t)} ${t('review.to_check')}` : '', inf ? `${dayWord(inf, t)} ${t('review.to_round')}` : '']
.filter(Boolean)
.join(' · ');
}
export function shortSummary(iss: number, inf: number, t: Translate): string {
return [iss ? `${iss} ${t('review.to_check')}` : '', inf ? `${inf} ${t('review.to_round')}` : '']
.filter(Boolean)
.join(' · ');
}
@@ -0,0 +1,51 @@
import type { Day, Employee } from './analysis';
type Clock = 'FirstIn' | 'FirstOut' | 'SecondIn' | 'SecondOut';
/** Un record come lo restituisce il comando Tauri `load_records`. */
export interface AttendanceRecord {
employee_id: number;
clock: Clock;
datetime: string;
}
const CLOCK_INDEX: Record<Clock, number> = {
FirstIn: 0,
FirstOut: 1,
SecondIn: 2,
SecondOut: 3
};
function timeOf(datetime: string): string {
const d = new Date(datetime);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/** Raggruppa i record per dipendente e giorno nella forma Day attesa da TimesheetReview. */
export function buildEmployees(
year: number,
month: number,
records: AttendanceRecord[],
names: Record<string, string>
): Employee[] {
const daysInMonth = new Date(year, month, 0).getDate();
const byEmployee = new Map<number, Day[]>();
for (const id of Object.keys(names)) {
byEmployee.set(Number(id), Array.from({ length: daysInMonth }, () => [[], [], [], []] as Day));
}
for (const record of records) {
const days = byEmployee.get(record.employee_id);
if (!days) continue;
const date = new Date(record.datetime);
const day = days[date.getDate() - 1];
day[CLOCK_INDEX[record.clock]].push(timeOf(record.datetime));
}
return [...byEmployee.entries()].map(([id, days]) => ({
id,
name: names[String(id)] ?? String(id),
days
}));
}
+81 -2
View File
@@ -34,7 +34,86 @@
"errors": {
"title": "Could not reach the time clock"
},
"done": {
"message": "Nothing to do here."
"review": {
"title": "Clock-in review",
"day_word": "{n, plural, one {1 day} other {{n} days}}",
"to_check": "to check",
"to_round": "to round",
"to_check_cap": "To check",
"to_round_cap": "To round",
"shift_1": "1st shift",
"shift_2": "2nd shift",
"issue_stacked": "{total} punches all on the {shift}",
"issue_invalid_time": "Invalid time (use HH:MM)",
"issue_lunch_missing": "Lunch break not punched",
"issue_missing_out": "Missing clock-out for the {shift}",
"issue_missing_in": "Missing clock-in for the {shift}",
"issue_out_before_in": "Clock-out before clock-in ({shift})",
"info_unrounded": "Times not rounded to {step} min",
"note_empty": "No punches",
"note_lunch_added": "Lunch break added",
"note_shifts_fixed": "Shifts fixed",
"note_edited": "{n, plural, one {1 time edited} other {{n} times edited}}",
"note_rounded": "{n, plural, one {1 time rounded} other {{n} times rounded}}",
"export_done": "Export completed: {who}",
"everyone": "all employees",
"only_unrounded": "{days} with unrounded times",
"confirm_title_all": "There are {summary}",
"confirm_title_one": "{name} has {summary}",
"close_menu_aria": "Close menu",
"no_records": "No punches found for this month.",
"back_to_month": "Back to month selection",
"export": "Export",
"export_options_aria": "More export options",
"all_employees": "All employees",
"employee_count": "{n} employees",
"all_good": "all good",
"all_good_cap": "All good",
"only_name": "Only {name}",
"employees_nav_aria": "Employees",
"search_employee": "Search employee",
"days_to_check_aria": "{n} days to check",
"days_to_round_aria": "{n} days to round",
"no_problem_aria": "No problem",
"employee_name_aria": "Employee name",
"save_name": "Save name",
"save": "Save",
"cancel": "Cancel",
"edit_name": "Edit name",
"days_worked": "days worked",
"total_hours": "total hours",
"edited": "edited",
"fix_shifts_title": "Sorts and redistributes punches that all ended up on the same shift",
"fix_shifts": "Fix shifts ({days})",
"fix_shifts_short": "Fix shifts",
"add_lunch_title": "Adds the {start}–{end} break on days with only a morning clock-in and an evening clock-out",
"add_lunch": "Add lunch break ({days})",
"lunch_short": "Lunch",
"round_title": "Round to the nearest time",
"round_all": "Round to {step} min ({days})",
"round_short": "Round",
"day_filter_aria": "Day filter",
"all_days": "All days",
"col_day": "Day",
"col_in": "In",
"col_out": "Out",
"col_hours": "Hours",
"col_notes": "Notes",
"col_in_1": "1st shift clock-in",
"col_out_1": "1st shift clock-out",
"col_in_2": "2nd shift clock-in",
"col_out_2": "2nd shift clock-out",
"cell_aria": "{label}, {col}",
"cell_aria_stacked": "{label}, {col} ({index} of {total})",
"fix_shifts_day_aria": "Fix the shifts for {label}",
"add_lunch_day_aria": "Add the {start}–{end} lunch break to {label}",
"round_day_aria": "Round the times for {label}",
"no_rows": "No days to check for this employee.",
"cancel_export_aria": "Cancel export",
"export_warning_issues": "If you export now, these days will end up in the file with missing, unverified, or unrounded times. You can go back and fix them, or export anyway.",
"export_warning_unrounded": "The times are complete, but some aren't rounded to {step} minutes. You can round them automatically before exporting, or export them as they are.",
"back_to_review": "Back to review",
"round_and_export": "Round and export",
"export_anyway": "Export anyway"
}
}
+81 -2
View File
@@ -34,7 +34,86 @@
"errors": {
"title": "Impossibile raggiungere il timbratore"
},
"done": {
"message": "Non c'è niente da fare."
"review": {
"title": "Revisione timbrature",
"day_word": "{n, plural, one {1 giorno} other {{n} giorni}}",
"to_check": "da controllare",
"to_round": "da arrotondare",
"to_check_cap": "Da controllare",
"to_round_cap": "Da arrotondare",
"shift_1": "1° turno",
"shift_2": "2° turno",
"issue_stacked": "{total} timbrature tutte sul {shift}",
"issue_invalid_time": "Orario non valido (usa HH:MM)",
"issue_lunch_missing": "Pausa pranzo non timbrata",
"issue_missing_out": "Manca l’uscita del {shift}",
"issue_missing_in": "Manca l’entrata del {shift}",
"issue_out_before_in": "Uscita prima dell’entrata ({shift})",
"info_unrounded": "Orari non arrotondati a {step} min",
"note_empty": "Nessuna timbratura",
"note_lunch_added": "Pausa pranzo aggiunta",
"note_shifts_fixed": "Turni sistemati",
"note_edited": "{n, plural, one {1 orario modificato} other {{n} orari modificati}}",
"note_rounded": "{n, plural, one {1 orario arrotondato} other {{n} orari arrotondati}}",
"export_done": "Esportazione completata: {who}",
"everyone": "tutti i dipendenti",
"only_unrounded": "{days} con orari non arrotondati",
"confirm_title_all": "Ci sono {summary}",
"confirm_title_one": "{name} ha {summary}",
"close_menu_aria": "Chiudi menu",
"no_records": "Nessuna timbratura trovata per questo mese.",
"back_to_month": "Torna alla scelta del mese",
"export": "Esporta",
"export_options_aria": "Altre opzioni di esportazione",
"all_employees": "Tutti i dipendenti",
"employee_count": "{n} dipendenti",
"all_good": "tutto a posto",
"all_good_cap": "Tutto a posto",
"only_name": "Solo {name}",
"employees_nav_aria": "Dipendenti",
"search_employee": "Cerca dipendente",
"days_to_check_aria": "{n} giorni da controllare",
"days_to_round_aria": "{n} giorni da arrotondare",
"no_problem_aria": "Nessun problema",
"employee_name_aria": "Nome del dipendente",
"save_name": "Salva nome",
"save": "Salva",
"cancel": "Annulla",
"edit_name": "Modifica nome",
"days_worked": "giorni lavorati",
"total_hours": "ore totali",
"edited": "modificati",
"fix_shifts_title": "Riordina in ordine di orario le timbrature finite tutte nello stesso turno",
"fix_shifts": "Sistema turni ({days})",
"fix_shifts_short": "Sistema turni",
"add_lunch_title": "Inserisce la pausa {start}–{end} nei giorni con solo entrata del mattino e uscita della sera",
"add_lunch": "Aggiungi pausa pranzo ({days})",
"lunch_short": "Pausa",
"round_title": "Arrotonda all'orario più vicino",
"round_all": "Arrotonda a {step} min ({days})",
"round_short": "Arrotonda",
"day_filter_aria": "Filtro giorni",
"all_days": "Tutti i giorni",
"col_day": "Giorno",
"col_in": "Entrata",
"col_out": "Uscita",
"col_hours": "Ore",
"col_notes": "Note",
"col_in_1": "Entrata 1° turno",
"col_out_1": "Uscita 1° turno",
"col_in_2": "Entrata 2° turno",
"col_out_2": "Uscita 2° turno",
"cell_aria": "{label}, {col}",
"cell_aria_stacked": "{label}, {col} ({index} di {total})",
"fix_shifts_day_aria": "Sistema i turni di {label}",
"add_lunch_day_aria": "Aggiungi pausa pranzo {start}–{end} a {label}",
"round_day_aria": "Arrotonda gli orari di {label}",
"no_rows": "Nessun giorno da controllare per questo dipendente.",
"cancel_export_aria": "Annulla esportazione",
"export_warning_issues": "Se esporti ora, questi giorni finiranno nel file con orari mancanti, da verificare o non arrotondati. Puoi tornare indietro e sistemarli, oppure esportare comunque.",
"export_warning_unrounded": "Gli orari sono completi, ma alcuni non sono arrotondati a {step} minuti. Puoi arrotondarli automaticamente prima di esportare, oppure esportarli così come sono.",
"back_to_review": "Torna alla revisione",
"round_and_export": "Arrotonda ed esporta",
"export_anyway": "Esporta comunque"
}
}
+7
View File
@@ -7,3 +7,10 @@ export function monthName(loc: string | null | undefined, month: number, format:
const formatter = new Intl.DateTimeFormat(loc ?? 'it', { month: format });
return capitalize(formatter.format(new Date(2000, month - 1, 1)));
}
/** `dow` is 0 (Sunday) - 6 (Saturday), matching `Date.getDay()`. */
export function weekdayName(loc: string | null | undefined, dow: number, format: 'short' | 'long' = 'short') {
const formatter = new Intl.DateTimeFormat(loc ?? 'it', { weekday: format });
// 2000-01-02 is a Sunday, so 2000-01-(2+dow) lands on the requested weekday.
return capitalize(formatter.format(new Date(2000, 0, 2 + dow)));
}
+21 -8
View File
@@ -8,14 +8,19 @@
import Onboarding from '$lib/components/Onboarding.svelte';
import ConfirmMonth from '$lib/components/ConfirmMonth.svelte';
import LoadingScreen from '$lib/components/LoadingScreen.svelte';
import TimesheetReview from '$lib/components/TimesheetReview.svelte';
import { buildEmployees, type AttendanceRecord } from '$lib/components/review/fromRecords';
type Step = 'loading' | 'setup' | 'confirm-month' | 'loading-records' | 'loading-employees' | 'done';
type Step = 'loading' | 'setup' | 'confirm-month' | 'loading-records' | 'loading-employees' | 'review';
let step = $state<Step>('loading');
let selectedYear = $state(0);
let selectedMonth = $state(0);
let records = $state<AttendanceRecord[]>([]);
let employeeNames = $state<Record<string, string>>({});
const period = $derived(`${monthName($locale, selectedMonth)} ${selectedYear}`);
const reviewEmployees = $derived(buildEmployees(selectedYear, selectedMonth, records, employeeNames));
onMount(async () => {
const preferences = await invoke<{ host: string; port: number } | null>('load_preferences');
@@ -28,10 +33,10 @@
step = 'loading-records';
clearError();
try {
const records = await invoke('load_records', selection);
records = await invoke('load_records', selection);
step = 'loading-employees';
await invoke('load_employees', { records });
step = 'done';
employeeNames = await invoke('load_employees', { records });
step = 'review';
} catch (err) {
setError(err);
step = 'confirm-month';
@@ -39,7 +44,16 @@
}
</script>
<Screen>
{#if step === 'review'}
<TimesheetReview
year={selectedYear}
month={selectedMonth}
employees={reviewEmployees}
onback={() => (step = 'confirm-month')}
onexport={(payload) => invoke('export_timesheet', { payload })}
/>
{:else}
<Screen>
{#if step === 'loading'}
<p>{$_('loading')}</p>
{:else if step === 'setup'}
@@ -53,7 +67,6 @@
/>
{:else if step === 'loading-employees'}
<LoadingScreen title={$_('loading_employees.title')} description={$_('loading_employees.description')} />
{:else}
<p>{$_('done.message')}</p>
{/if}
</Screen>
</Screen>
{/if}