diff --git a/oxibadge/src-tauri/src/lib.rs b/oxibadge/src-tauri/src/lib.rs index 1daa1a4..732bfeb 100644 --- a/oxibadge/src-tauri/src/lib.rs +++ b/oxibadge/src-tauri/src/lib.rs @@ -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>>, +} + +#[derive(Debug, Deserialize)] +struct ExportPayload { + scope: String, + year: i32, + month: u32, + employees: Vec, +} + +/// 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"); diff --git a/oxibadge/src-tauri/tauri.conf.json b/oxibadge/src-tauri/tauri.conf.json index 30d86a5..12898bd 100644 --- a/oxibadge/src-tauri/tauri.conf.json +++ b/oxibadge/src-tauri/tauri.conf.json @@ -13,8 +13,8 @@ "windows": [ { "title": "Oxibadge", - "width": 800, - "height": 600 + "width": 1200, + "height": 700 } ], "security": { diff --git a/oxibadge/src/app.css b/oxibadge/src/app.css index 8ca253b..cfb85b6 100644 --- a/oxibadge/src/app.css +++ b/oxibadge/src/app.css @@ -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; +} diff --git a/oxibadge/src/lib/components/TimesheetReview.svelte b/oxibadge/src/lib/components/TimesheetReview.svelte new file mode 100644 index 0000000..a3ac25f --- /dev/null +++ b/oxibadge/src/lib/components/TimesheetReview.svelte @@ -0,0 +1,374 @@ + + + + +
+ {#if menuOpen} + + {/if} + + {#if confirmScope} + (confirmScope = null)} + onroundandexport={() => confirmScope && roundAndExport(confirmScope)} + onexportanyway={() => confirmScope && doExport(confirmScope)} + /> + {/if} + + + + {#if names.length === 0} +
+

{monthName($locale, month)} {year}

+

{$_('review.no_records')}

+ {#if onback} + {$_('review.back_to_month')} + {/if} +
+ {:else} + requestExport('all')} + onexportone={() => requestExport('one')} + /> + +
+ { + emp = ei; + editingName = false; + }} + /> + +
+ fixDays(cur.stackedDays)} + onaddlunchall={() => addLunch(cur.lunchDays)} + onroundall={() => roundDaysOf(emp, cur.roundDays)} + bind:onlyIssues + /> + + fixDays([di])} + onaddlunch={(di) => addLunch([di])} + onroundday={(di) => roundDaysOf(emp, [di])} + /> +
+
+ {/if} +
+ + diff --git a/oxibadge/src/lib/components/review/ActionButton.svelte b/oxibadge/src/lib/components/review/ActionButton.svelte new file mode 100644 index 0000000..1095db9 --- /dev/null +++ b/oxibadge/src/lib/components/review/ActionButton.svelte @@ -0,0 +1,74 @@ + + + + + diff --git a/oxibadge/src/lib/components/review/EmployeeHeader.svelte b/oxibadge/src/lib/components/review/EmployeeHeader.svelte new file mode 100644 index 0000000..12afa15 --- /dev/null +++ b/oxibadge/src/lib/components/review/EmployeeHeader.svelte @@ -0,0 +1,276 @@ + + +
+
+ {#if editing} +
+ { + if (e.key === 'Enter') saveName(); + }} + /> + + {#snippet icon()} + + {/snippet} + + (editing = false)}> + {#snippet icon()} + + {/snippet} + +
+ {:else} +
+

{name}

+ + {#snippet icon()} + + {/snippet} + +
+ {/if} +
+ {worked} {$_('review.days_worked')} + {totalLabel} {$_('review.total_hours')} + 0}>{issues} {$_('review.to_check')} + 0}>{infos} {$_('review.to_round')} + 0}>{edits} {$_('review.edited')} +
+
+ +
+ {#if stackedCount} + + {#snippet icon()} + + {/snippet} + {$_('review.fix_shifts', { values: { days: dayWord(stackedCount, $_) } })} + + {/if} + {#if lunchCount} + + {#snippet icon()} + + {/snippet} + {$_('review.add_lunch', { values: { days: dayWord(lunchCount, $_) } })} + + {/if} + {#if roundCount} + + {#snippet icon()} + + {/snippet} + {$_('review.round_all', { values: { step: ROUND_STEP, days: dayWord(roundCount, $_) } })} + + {/if} +
+ + +
+
+
+ + diff --git a/oxibadge/src/lib/components/review/EmployeeSidebar.svelte b/oxibadge/src/lib/components/review/EmployeeSidebar.svelte new file mode 100644 index 0000000..e4d1de1 --- /dev/null +++ b/oxibadge/src/lib/components/review/EmployeeSidebar.svelte @@ -0,0 +1,204 @@ + + + + + diff --git a/oxibadge/src/lib/components/review/ExportModal.svelte b/oxibadge/src/lib/components/review/ExportModal.svelte new file mode 100644 index 0000000..f953b3f --- /dev/null +++ b/oxibadge/src/lib/components/review/ExportModal.svelte @@ -0,0 +1,215 @@ + + + + + diff --git a/oxibadge/src/lib/components/review/FixButton.svelte b/oxibadge/src/lib/components/review/FixButton.svelte new file mode 100644 index 0000000..1fe33d7 --- /dev/null +++ b/oxibadge/src/lib/components/review/FixButton.svelte @@ -0,0 +1,96 @@ + + + + + diff --git a/oxibadge/src/lib/components/review/IconButton.svelte b/oxibadge/src/lib/components/review/IconButton.svelte new file mode 100644 index 0000000..ec4c733 --- /dev/null +++ b/oxibadge/src/lib/components/review/IconButton.svelte @@ -0,0 +1,68 @@ + + + + + diff --git a/oxibadge/src/lib/components/review/TimesheetTable.svelte b/oxibadge/src/lib/components/review/TimesheetTable.svelte new file mode 100644 index 0000000..a6370f4 --- /dev/null +++ b/oxibadge/src/lib/components/review/TimesheetTable.svelte @@ -0,0 +1,395 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + {#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)} + + + + {#each r.v as list, ci} + {@const many = list.filter((x) => x !== '').length > 1} + + {/each} + + + + + + {/each} + +
{$_('review.shift_1')}{$_('review.shift_2')}
{$_('review.col_day')}{$_('review.col_in')}{$_('review.col_out')}{$_('review.col_in')}{$_('review.col_out')}{$_('review.col_hours')}{$_('review.col_notes')}
{r.dow}{r.di + 1} +
+ {#each list as val, ii} + 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} +
+
{r.mins ? hm(r.mins) : '—'} + + {#if note.kind === 'issue'} + + {:else if note.kind === 'info'} + + {/if} + {note.text} + + {#if r.stacked} + onfixday(r.di)}> + {#snippet icon()} + + {/snippet} + {$_('review.fix_shifts_short')} + + {/if} + {#if r.lunch} + onaddlunch(r.di)} + > + {#snippet icon()} + + {/snippet} + {$_('review.lunch_short')} {LUNCH.start}–{LUNCH.end} + + {/if} + {#if r.info} + onroundday(r.di)}> + {#snippet icon()} + + {/snippet} + {$_('review.round_short')} + + {/if} + +
+ + {#if rows.length === 0} +
+ + {$_('review.no_rows')} +
+ {/if} +
+ + diff --git a/oxibadge/src/lib/components/review/Toast.svelte b/oxibadge/src/lib/components/review/Toast.svelte new file mode 100644 index 0000000..4cdb2b0 --- /dev/null +++ b/oxibadge/src/lib/components/review/Toast.svelte @@ -0,0 +1,33 @@ + + +{#if message} +
+ + {message} +
+{/if} + + diff --git a/oxibadge/src/lib/components/review/TopBar.svelte b/oxibadge/src/lib/components/review/TopBar.svelte new file mode 100644 index 0000000..0270620 --- /dev/null +++ b/oxibadge/src/lib/components/review/TopBar.svelte @@ -0,0 +1,314 @@ + + +
+
+ {#if onback} + + {#snippet icon()} + + {/snippet} + + {/if} +
+ {$_('review.title')} +

{monthName($locale, month)} {year}

+
+
+ +
+ {#if issuesAll} + + + {dayWord(issuesAll, $_)} {$_('review.to_check')} + + {/if} + {#if infosAll} + + + {dayWord(infosAll, $_)} {$_('review.to_round')} + + {/if} + + +
+
+ + + +
+ + {#if menuOpen} + + {/if} +
+
+
+ + diff --git a/oxibadge/src/lib/components/review/analysis.ts b/oxibadge/src/lib/components/review/analysis.ts new file mode 100644 index 0000000..6df1bc3 --- /dev/null +++ b/oxibadge/src/lib/components/review/analysis.ts @@ -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; + +/** [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, + 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(' · '); +} diff --git a/oxibadge/src/lib/components/review/fromRecords.ts b/oxibadge/src/lib/components/review/fromRecords.ts new file mode 100644 index 0000000..0b23c41 --- /dev/null +++ b/oxibadge/src/lib/components/review/fromRecords.ts @@ -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 = { + 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 +): Employee[] { + const daysInMonth = new Date(year, month, 0).getDate(); + const byEmployee = new Map(); + + 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 + })); +} diff --git a/oxibadge/src/lib/locales/en.json b/oxibadge/src/lib/locales/en.json index 05203a9..bbdfb27 100644 --- a/oxibadge/src/lib/locales/en.json +++ b/oxibadge/src/lib/locales/en.json @@ -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" } } diff --git a/oxibadge/src/lib/locales/it.json b/oxibadge/src/lib/locales/it.json index 458498c..ac79933 100644 --- a/oxibadge/src/lib/locales/it.json +++ b/oxibadge/src/lib/locales/it.json @@ -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" } } diff --git a/oxibadge/src/lib/month.ts b/oxibadge/src/lib/month.ts index 88388ca..2dc2369 100644 --- a/oxibadge/src/lib/month.ts +++ b/oxibadge/src/lib/month.ts @@ -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))); +} diff --git a/oxibadge/src/routes/+page.svelte b/oxibadge/src/routes/+page.svelte index 6b761d5..63ae3ff 100644 --- a/oxibadge/src/routes/+page.svelte +++ b/oxibadge/src/routes/+page.svelte @@ -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('loading'); let selectedYear = $state(0); let selectedMonth = $state(0); + let records = $state([]); + let employeeNames = $state>({}); 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,21 +44,29 @@ } - - {#if step === 'loading'} -

{$_('loading')}

- {:else if step === 'setup'} - (step = 'confirm-month')} /> - {:else if step === 'confirm-month'} - - {:else if step === 'loading-records'} - - {:else if step === 'loading-employees'} - - {:else} -

{$_('done.message')}

- {/if} -
+{#if step === 'review'} + (step = 'confirm-month')} + onexport={(payload) => invoke('export_timesheet', { payload })} + /> +{:else} + + {#if step === 'loading'} +

{$_('loading')}

+ {:else if step === 'setup'} + (step = 'confirm-month')} /> + {:else if step === 'confirm-month'} + + {:else if step === 'loading-records'} + + {:else if step === 'loading-employees'} + + {/if} +
+{/if}