From b65e85f90877eb128e4413fad5f2629ba5fb1272 Mon Sep 17 00:00:00 2001 From: Nicola Belluti Date: Thu, 8 Oct 2026 18:29:57 +0200 Subject: [PATCH] feat(oxibadge): add settings screen for shifts, rounding and language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a settings screen (gear button on the month-confirmation screen) to configure the expected shift times, the rounding step, the interface language, and the time clock address — replacing the old onboarding-only connection flow. Settings persist to the same preferences.toml as the reader address, under a single load_settings/ save_settings command pair. The review screen's "fix shifts" action now redistributes stacked punches by nearest configured shift time instead of blind position, and a stacked cell manually cleared down to one entry no longer leaves a dangling empty input. Assisted-by: Claude Sonnet 5 --- oxibadge/src-tauri/src/lib.rs | 26 +- oxibadge/src-tauri/src/preferences.rs | 73 +++- oxibadge/src/lib/autofocus.ts | 4 + oxibadge/src/lib/components/Field.svelte | 45 ++- .../components/{review => }/IconButton.svelte | 0 oxibadge/src/lib/components/Onboarding.svelte | 4 +- oxibadge/src/lib/components/Settings.svelte | 266 ++++++++++++++ .../src/lib/components/SettingsButton.svelte | 44 +++ .../src/lib/components/TimesheetReview.svelte | 41 ++- .../components/review/EmployeeHeader.svelte | 16 +- .../lib/components/review/ExportModal.svelte | 6 +- .../components/review/TimesheetTable.svelte | 11 +- .../src/lib/components/review/TopBar.svelte | 2 +- .../src/lib/components/review/analysis.ts | 102 +++--- .../settings/LanguageSection.svelte | 103 ++++++ .../lib/components/settings/ReaderCard.svelte | 332 ++++++++++++++++++ .../lib/components/settings/RoundCard.svelte | 90 +++++ .../lib/components/settings/ShiftsCard.svelte | 126 +++++++ oxibadge/src/lib/components/settings/logic.ts | 42 +++ oxibadge/src/lib/locales/en.json | 42 +++ oxibadge/src/lib/locales/it.json | 42 +++ oxibadge/src/lib/settings.ts | 12 + oxibadge/src/lib/shifts.ts | 7 + oxibadge/src/lib/time.ts | 31 ++ oxibadge/src/routes/+layout.svelte | 16 +- oxibadge/src/routes/+page.svelte | 32 +- 26 files changed, 1397 insertions(+), 118 deletions(-) create mode 100644 oxibadge/src/lib/autofocus.ts rename oxibadge/src/lib/components/{review => }/IconButton.svelte (100%) create mode 100644 oxibadge/src/lib/components/Settings.svelte create mode 100644 oxibadge/src/lib/components/SettingsButton.svelte create mode 100644 oxibadge/src/lib/components/settings/LanguageSection.svelte create mode 100644 oxibadge/src/lib/components/settings/ReaderCard.svelte create mode 100644 oxibadge/src/lib/components/settings/RoundCard.svelte create mode 100644 oxibadge/src/lib/components/settings/ShiftsCard.svelte create mode 100644 oxibadge/src/lib/components/settings/logic.ts create mode 100644 oxibadge/src/lib/settings.ts create mode 100644 oxibadge/src/lib/shifts.ts create mode 100644 oxibadge/src/lib/time.ts diff --git a/oxibadge/src-tauri/src/lib.rs b/oxibadge/src-tauri/src/lib.rs index 732bfeb..083d545 100644 --- a/oxibadge/src-tauri/src/lib.rs +++ b/oxibadge/src-tauri/src/lib.rs @@ -1,7 +1,7 @@ mod preferences; use chrono::{Local, TimeZone}; -use preferences::Reader; +use preferences::Settings; use r701::{Record, R701}; use serde::Deserialize; use std::collections::HashMap; @@ -9,13 +9,13 @@ use tauri::AppHandle; use tokio_stream::StreamExt; #[tauri::command] -fn load_preferences(app: AppHandle) -> Option { +fn load_settings(app: AppHandle) -> Settings { preferences::load(&app) } #[tauri::command] -fn save_preferences(app: AppHandle, host: String, port: u16) -> Result<(), String> { - preferences::save(&app, Reader { host, port }) +fn save_settings(app: AppHandle, settings: Settings) -> Result<(), String> { + preferences::save(&app, &settings) } #[tauri::command] @@ -46,8 +46,11 @@ async fn connect_with_retries(host: &str, port: u16) -> Result { /// chronological (oldest first) order before being returned. #[tauri::command] async fn load_records(app: AppHandle, year: i32, month: u32) -> Result, String> { - let reader = preferences::load(&app).ok_or_else(|| "no reader configured".to_string())?; - let r701 = connect_with_retries(&reader.host, reader.port).await?; + let settings = preferences::load(&app); + if settings.reader.host.is_empty() { + return Err("no reader configured".to_string()); + } + let r701 = connect_with_retries(&settings.reader.host, settings.reader.port).await?; let invalid = || "invalid year/month".to_string(); let start = Local @@ -85,8 +88,11 @@ async fn load_employees( app: AppHandle, records: Vec, ) -> Result, String> { - let reader = preferences::load(&app).ok_or_else(|| "no reader configured".to_string())?; - let r701 = connect_with_retries(&reader.host, reader.port).await?; + let settings = preferences::load(&app); + if settings.reader.host.is_empty() { + return Err("no reader configured".to_string()); + } + let r701 = connect_with_retries(&settings.reader.host, settings.reader.port).await?; let mut employees = HashMap::new(); for record in &records { @@ -145,8 +151,8 @@ fn export_timesheet(payload: ExportPayload) { pub fn run() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![ - load_preferences, - save_preferences, + load_settings, + save_settings, test_connection, load_records, load_employees, diff --git a/oxibadge/src-tauri/src/preferences.rs b/oxibadge/src-tauri/src/preferences.rs index fa7f6f0..f69f0f9 100644 --- a/oxibadge/src-tauri/src/preferences.rs +++ b/oxibadge/src-tauri/src/preferences.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use tauri::{AppHandle, Manager}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -7,25 +8,65 @@ pub struct Reader { pub port: u16, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -struct Preferences { - #[serde(default)] - reader: Vec, +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Shifts { + pub in1: String, + pub out1: String, + pub in2: String, + pub out2: String, + pub round_step_min: u32, } -pub fn load(app: &AppHandle) -> Option { - let path = app.path().app_data_dir().ok()?.join("preferences.toml"); - let contents = std::fs::read_to_string(path).ok()?; - let preferences: Preferences = toml::from_str(&contents).ok()?; - preferences.reader.into_iter().next() +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Settings { + pub reader: Reader, + pub shifts: Shifts, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lang: Option, } -pub fn save(app: &AppHandle, reader: Reader) -> Result<(), String> { - let dir = app.path().app_data_dir().map_err(|err| err.to_string())?; - std::fs::create_dir_all(&dir).map_err(|err| err.to_string())?; - let preferences = Preferences { - reader: vec![reader], +impl Default for Settings { + fn default() -> Self { + Self { + reader: Reader { + host: String::new(), + port: 5005, + }, + shifts: Shifts { + in1: "07:30".to_string(), + out1: "12:00".to_string(), + in2: "13:30".to_string(), + out2: "17:00".to_string(), + round_step_min: 10, + }, + lang: None, + } + } +} + +fn path(app: &AppHandle) -> Result { + Ok(app + .path() + .app_data_dir() + .map_err(|err| err.to_string())? + .join("preferences.toml")) +} + +pub fn load(app: &AppHandle) -> Settings { + let Ok(path) = path(app) else { + return Settings::default(); }; - let contents = toml::to_string_pretty(&preferences).map_err(|err| err.to_string())?; - std::fs::write(dir.join("preferences.toml"), contents).map_err(|err| err.to_string()) + std::fs::read_to_string(path) + .ok() + .and_then(|contents| toml::from_str(&contents).ok()) + .unwrap_or_default() +} + +pub fn save(app: &AppHandle, settings: &Settings) -> Result<(), String> { + let dest = path(app)?; + if let Some(dir) = dest.parent() { + std::fs::create_dir_all(dir).map_err(|err| err.to_string())?; + } + let contents = toml::to_string_pretty(settings).map_err(|err| err.to_string())?; + std::fs::write(dest, contents).map_err(|err| err.to_string()) } diff --git a/oxibadge/src/lib/autofocus.ts b/oxibadge/src/lib/autofocus.ts new file mode 100644 index 0000000..c056fd9 --- /dev/null +++ b/oxibadge/src/lib/autofocus.ts @@ -0,0 +1,4 @@ +export function autofocus(node: HTMLInputElement) { + node.focus(); + node.select(); +} diff --git a/oxibadge/src/lib/components/Field.svelte b/oxibadge/src/lib/components/Field.svelte index a25c0c9..cc0f75d 100644 --- a/oxibadge/src/lib/components/Field.svelte +++ b/oxibadge/src/lib/components/Field.svelte @@ -7,6 +7,10 @@ value = $bindable(), type = 'text', placeholder = '', + maxlength, + inputmode, + err = false, + suffix, oninput, icon }: { @@ -15,15 +19,30 @@ value: string | number; type?: string; placeholder?: string; - oninput?: () => void; + maxlength?: number; + inputmode?: 'text' | 'numeric' | 'decimal' | 'none' | 'tel' | 'search' | 'email' | 'url'; + err?: boolean; + suffix?: string; + oninput?: (e: Event & { currentTarget: HTMLInputElement }) => void; icon?: Snippet; } = $props(); -
+
{#if icon}{@render icon()}{/if} - + + {#if suffix}{suffix}{/if}
diff --git a/oxibadge/src/lib/components/review/IconButton.svelte b/oxibadge/src/lib/components/IconButton.svelte similarity index 100% rename from oxibadge/src/lib/components/review/IconButton.svelte rename to oxibadge/src/lib/components/IconButton.svelte diff --git a/oxibadge/src/lib/components/Onboarding.svelte b/oxibadge/src/lib/components/Onboarding.svelte index 9ae76f4..4d25375 100644 --- a/oxibadge/src/lib/components/Onboarding.svelte +++ b/oxibadge/src/lib/components/Onboarding.svelte @@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core'; import { _ } from 'svelte-i18n'; import { error, setError, clearError } from '$lib/error'; + import type { Settings } from '$lib/settings'; import Column from './Column.svelte'; import Intro from './Intro.svelte'; import Field from './Field.svelte'; @@ -28,7 +29,8 @@ clearError(); try { await invoke('test_connection', { host: trimmedHost, port }); - await invoke('save_preferences', { host: trimmedHost, port }); + const settings = await invoke('load_settings'); + await invoke('save_settings', { settings: { ...settings, reader: { host: trimmedHost, port } } }); onconnected({ host: trimmedHost, port }); } catch (err) { setError(err); diff --git a/oxibadge/src/lib/components/Settings.svelte b/oxibadge/src/lib/components/Settings.svelte new file mode 100644 index 0000000..6c3e049 --- /dev/null +++ b/oxibadge/src/lib/components/Settings.svelte @@ -0,0 +1,266 @@ + + +
+
+
+ {#if onback} + + {#snippet icon()} + + {/snippet} + + {/if} +

{$_('settings.title')}

+
+ + {#if saveState === 'saving'} + + {:else if saveState === 'saved'} + + {:else if saveState === 'invalid' || saveState === 'failed'} + + {/if} + {saveInfo[0]} + +
+ +
+
+ {#if loaded} +
+
+

{$_('settings.device_title')}

+

{$_('settings.device_desc')}

+
+ +
+ +
+
+

{$_('settings.shifts_title')}

+

{$_('settings.shifts_desc')}

+
+ + +
+ + + {/if} +
+
+
+ + diff --git a/oxibadge/src/lib/components/SettingsButton.svelte b/oxibadge/src/lib/components/SettingsButton.svelte new file mode 100644 index 0000000..3a070e0 --- /dev/null +++ b/oxibadge/src/lib/components/SettingsButton.svelte @@ -0,0 +1,44 @@ + + + + + diff --git a/oxibadge/src/lib/components/TimesheetReview.svelte b/oxibadge/src/lib/components/TimesheetReview.svelte index a3ac25f..0619d75 100644 --- a/oxibadge/src/lib/components/TimesheetReview.svelte +++ b/oxibadge/src/lib/components/TimesheetReview.svelte @@ -2,17 +2,17 @@ import { untrack } from 'svelte'; import { _, locale } from 'svelte-i18n'; import { monthName } from '$lib/month'; + import type { Shifts } from '$lib/shifts'; import { type Day, type Employee, - LUNCH, analyze, + assignToShifts, dayWord, formatTyped, hm, lunchSpan, normDay, - parseT, roundT, shortSummary, summary @@ -29,6 +29,7 @@ year, month, employees, + shift, onexport, onrename, onback @@ -36,6 +37,7 @@ year: number; month: number; employees: Employee[]; + shift: Shifts; onexport?: (p: { scope: 'all' | 'one'; year: number; month: number; employees: Employee[] }) => void; onrename?: (id: Employee['id'], name: string) => void; onback?: () => void; @@ -62,7 +64,9 @@ let toast = $state(''); let toastTimer: ReturnType | undefined; - const all = $derived(names.map((_name, ei) => analyze(year, month, values[ei], orig[ei], $locale, $_))); + const all = $derived( + names.map((_name, ei) => analyze(year, month, values[ei], orig[ei], $locale, $_, shift)) + ); 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)); @@ -77,27 +81,31 @@ // ---- 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 cell = values[emp][di][ci]; + const prev = cell[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 + if (next === '' && cell.length > 1) { + // Una delle timbrature impilate è stata svuotata a mano: la casella si + // restringe invece di lasciare un campo vuoto appeso. + cell.splice(ii, 1); + } else { + cell[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. */ + /** Ridistribuisce le timbrature del giorno in E1, U1, E2, U2 in base ai turni previsti. */ 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; + const times = values[emp][di].flat().filter((x) => x !== ''); + values[emp][di] = assignToShifts(times, shift); } } 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]]]; + const span = lunchSpan(values[emp][di], shift); + if (span) values[emp][di] = [[span[0]], [shift.out1], [shift.in2], [span[1]]]; } } @@ -105,7 +113,7 @@ for (const di of dis) { values[ei][di] = values[ei][di].map((c, ci) => c.map((x, ii) => { - const r = roundT(x); + const r = roundT(x, shift.round_step_min); if (r !== x) rounded[`${ei}-${di}-${ci}-${ii}`] = r; return r; }) @@ -222,6 +230,7 @@ title={confirmTitle} {scopeIssues} {scopeInfos} + {shift} items={confirmItems} onclose={() => (confirmScope = null)} onroundandexport={() => confirmScope && roundAndExport(confirmScope)} @@ -280,6 +289,7 @@ stackedCount={cur.stackedDays.length} lunchCount={cur.lunchDays.length} roundCount={cur.roundDays.length} + {shift} onfixall={() => fixDays(cur.stackedDays)} onaddlunchall={() => addLunch(cur.lunchDays)} onroundall={() => roundDaysOf(emp, cur.roundDays)} @@ -290,6 +300,7 @@ rows={visibleRows} {rounded} {emp} + {shift} oncellinput={onCellInput} onfixday={(di) => fixDays([di])} onaddlunch={(di) => addLunch([di])} diff --git a/oxibadge/src/lib/components/review/EmployeeHeader.svelte b/oxibadge/src/lib/components/review/EmployeeHeader.svelte index 12afa15..687274b 100644 --- a/oxibadge/src/lib/components/review/EmployeeHeader.svelte +++ b/oxibadge/src/lib/components/review/EmployeeHeader.svelte @@ -1,8 +1,10 @@
@@ -121,7 +121,7 @@ {#if lunchCount} {#snippet icon()} @@ -139,7 +139,7 @@ > {/snippet} - {$_('review.round_all', { values: { step: ROUND_STEP, days: dayWord(roundCount, $_) } })} + {$_('review.round_all', { values: { step: shift.round_step_min, days: dayWord(roundCount, $_) } })} {/if}
diff --git a/oxibadge/src/lib/components/review/ExportModal.svelte b/oxibadge/src/lib/components/review/ExportModal.svelte index f953b3f..c340011 100644 --- a/oxibadge/src/lib/components/review/ExportModal.svelte +++ b/oxibadge/src/lib/components/review/ExportModal.svelte @@ -1,6 +1,6 @@ + +
+
+

+

{$_('settings.language_desc')}

+
+
+ + +
+
+ + diff --git a/oxibadge/src/lib/components/settings/ReaderCard.svelte b/oxibadge/src/lib/components/settings/ReaderCard.svelte new file mode 100644 index 0000000..a3ea9f2 --- /dev/null +++ b/oxibadge/src/lib/components/settings/ReaderCard.svelte @@ -0,0 +1,332 @@ + + + + +
+ {#if !editing} +
+
+ +
+
+ {$_('settings.device_label')} + {host}:{port} +
+ + {#if status === 'testing'} + + {$_('settings.status_testing')} + {:else if status === 'ok'} + + {$_('settings.status_ok')} + {:else} + + {$_('settings.status_idle')} + {/if} + +
+
+ + +
+ {:else} +
{ + e.preventDefault(); + confirmEdit(); + }} + > +
+
+ (ipError = false)} + /> + {#if ipError}{$_('settings.ip_invalid')}{/if} +
+ +
+
+ {$_('settings.cancel')} + {$_('settings.confirm')} +
+
+ {/if} +
+ +{#if $error} + +{/if} + + diff --git a/oxibadge/src/lib/components/settings/RoundCard.svelte b/oxibadge/src/lib/components/settings/RoundCard.svelte new file mode 100644 index 0000000..54730d0 --- /dev/null +++ b/oxibadge/src/lib/components/settings/RoundCard.svelte @@ -0,0 +1,90 @@ + + +
+
+ {$_('settings.round_title')} + {$_('settings.round_desc')} {example} +
+
+ + {#if !roundValid(roundStep)}{$_('settings.round_range')}{/if} +
+
+ + diff --git a/oxibadge/src/lib/components/settings/ShiftsCard.svelte b/oxibadge/src/lib/components/settings/ShiftsCard.svelte new file mode 100644 index 0000000..3601fd2 --- /dev/null +++ b/oxibadge/src/lib/components/settings/ShiftsCard.svelte @@ -0,0 +1,126 @@ + + +
+ {#each SHIFT_ROWS as row (row.a)} +
+ {$_(row.titleKey)} + onShiftInput(row.a, e)} + /> + onShiftInput(row.b, e)} + /> +
+ {/each} + +
+ {#if check.errorKey} + + + {$_(check.errorKey)} + + {:else if check.minutes} + {$_('settings.hours_per_day_label')} + {hm(check.minutes.out1 - check.minutes.in1 + (check.minutes.out2 - check.minutes.in2))} + {$_('settings.per_day')} + {$_('settings.break_label')} {shifts.out1}–{shifts.in2} + ({check.minutes.in2 - check.minutes.out1} {$_('settings.minutes_short')}) + {/if} +
+
+ + diff --git a/oxibadge/src/lib/components/settings/logic.ts b/oxibadge/src/lib/components/settings/logic.ts new file mode 100644 index 0000000..34b7351 --- /dev/null +++ b/oxibadge/src/lib/components/settings/logic.ts @@ -0,0 +1,42 @@ +import { parseT } from '$lib/time'; +import type { Shifts } from '$lib/shifts'; + +export type { Shifts }; + +export const IPV4 = /^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/; + +/** Le 4 caselle orario di `Shifts` — a parte `round_step_min`, che non è un orario. */ +export type ShiftTimeKey = 'in1' | 'out1' | 'in2' | 'out2'; + +export const SHIFT_ROWS: { titleKey: string; a: ShiftTimeKey; b: ShiftTimeKey }[] = [ + { titleKey: 'settings.shift_1', a: 'in1', b: 'out1' }, + { titleKey: 'settings.shift_2', a: 'in2', b: 'out2' } +]; + +export interface ShiftCheck { + minutes: Record | null; + bad: Partial>; + errorKey: string | null; +} + +export function checkShifts(shifts: Shifts): ShiftCheck { + const t: Record = { + in1: parseT(shifts.in1), + out1: parseT(shifts.out1), + in2: parseT(shifts.in2), + out2: parseT(shifts.out2) + }; + const bad: Partial> = {}; + for (const key of Object.keys(t) as ShiftTimeKey[]) { + if (t[key] === null) bad[key] = true; + } + if (Object.keys(bad).length) return { minutes: null, bad, errorKey: 'settings.shift_error_format' }; + + const n = t as Record; + if (n.out1 <= n.in1) return { minutes: null, bad: { out1: true }, errorKey: 'settings.shift_error_out1' }; + if (n.in2 < n.out1) return { minutes: null, bad: { in2: true }, errorKey: 'settings.shift_error_in2' }; + if (n.out2 <= n.in2) return { minutes: null, bad: { out2: true }, errorKey: 'settings.shift_error_out2' }; + return { minutes: n, bad: {}, errorKey: null }; +} + +export const roundValid = (v: string): boolean => /^\d{1,2}$/.test(v) && Number(v) >= 1 && Number(v) <= 60; diff --git a/oxibadge/src/lib/locales/en.json b/oxibadge/src/lib/locales/en.json index bbdfb27..49ff48d 100644 --- a/oxibadge/src/lib/locales/en.json +++ b/oxibadge/src/lib/locales/en.json @@ -115,5 +115,47 @@ "back_to_review": "Back to review", "round_and_export": "Round and export", "export_anyway": "Export anyway" + }, + "settings": { + "title": "Settings", + "device_title": "Time clock", + "device_desc": "The time clock Oxibadge downloads punches from.", + "device_label": "Address", + "status_testing": "Checking…", + "status_ok": "Reachable", + "status_idle": "Not verified", + "edit_address": "Edit address", + "test_connection": "Test connection", + "ip_label": "IP address", + "port_label": "Port", + "ip_invalid": "Invalid format, e.g. 192.168.1.100", + "cancel": "Cancel", + "confirm": "Confirm", + "shifts_title": "Expected shifts", + "shifts_desc": "The standard work schedule. The break between the two shifts is the one used by \"Add lunch break\" in the review.", + "shift_1": "1st shift", + "shift_2": "2nd shift", + "shift_in": "In", + "shift_out": "Out", + "shift_error_format": "Enter all times in HH:MM format.", + "shift_error_out1": "The 1st shift's clock-out must be after its clock-in.", + "shift_error_in2": "The 2nd shift must start after the 1st one ends.", + "shift_error_out2": "The 2nd shift's clock-out must be after its clock-in.", + "hours_per_day_label": "Expected hours", + "per_day": "per day", + "break_label": "Break", + "round_title": "Time rounding", + "round_desc": "Times are rounded to the nearest multiple.", + "round_example": "E.g. {from} → {to}.", + "round_label": "Minutes", + "minutes_short": "min", + "round_range": "From 1 to 60 minutes", + "language_title": "Language", + "language_desc": "Oxibadge's interface language.", + "save_idle": "Changes are saved automatically", + "save_saving": "Saving…", + "save_saved": "Changes saved", + "save_invalid": "Not saved: fix the error", + "save_failed": "Save failed" } } diff --git a/oxibadge/src/lib/locales/it.json b/oxibadge/src/lib/locales/it.json index ac79933..38de859 100644 --- a/oxibadge/src/lib/locales/it.json +++ b/oxibadge/src/lib/locales/it.json @@ -115,5 +115,47 @@ "back_to_review": "Torna alla revisione", "round_and_export": "Arrotonda ed esporta", "export_anyway": "Esporta comunque" + }, + "settings": { + "title": "Impostazioni", + "device_title": "Timbratore", + "device_desc": "Il timbratore da cui Oxibadge scarica le timbrature.", + "device_label": "Indirizzo", + "status_testing": "Verifica…", + "status_ok": "Raggiungibile", + "status_idle": "Non verificato", + "edit_address": "Modifica indirizzo", + "test_connection": "Testa connessione", + "ip_label": "Indirizzo IP", + "port_label": "Porta", + "ip_invalid": "Formato non valido, es. 192.168.1.100", + "cancel": "Annulla", + "confirm": "Conferma", + "shifts_title": "Turni previsti", + "shifts_desc": "L'orario di lavoro standard. La pausa tra i due turni è quella usata da \"Aggiungi pausa pranzo\" nella revisione.", + "shift_1": "1° turno", + "shift_2": "2° turno", + "shift_in": "Entrata", + "shift_out": "Uscita", + "shift_error_format": "Inserisci tutti gli orari nel formato HH:MM.", + "shift_error_out1": "L'uscita del 1° turno deve essere dopo l'entrata.", + "shift_error_in2": "Il 2° turno deve iniziare dopo la fine del 1°.", + "shift_error_out2": "L'uscita del 2° turno deve essere dopo l'entrata.", + "hours_per_day_label": "Ore previste", + "per_day": "al giorno", + "break_label": "Pausa", + "round_title": "Arrotondamento orari", + "round_desc": "Gli orari vengono arrotondati al multiplo più vicino.", + "round_example": "Es. {from} → {to}.", + "round_label": "Minuti", + "minutes_short": "min", + "round_range": "Da 1 a 60 minuti", + "language_title": "Lingua", + "language_desc": "La lingua dell'interfaccia di Oxibadge.", + "save_idle": "Le modifiche vengono salvate automaticamente", + "save_saving": "Salvataggio…", + "save_saved": "Modifiche salvate", + "save_invalid": "Non salvato: correggi l'errore", + "save_failed": "Salvataggio non riuscito" } } diff --git a/oxibadge/src/lib/settings.ts b/oxibadge/src/lib/settings.ts new file mode 100644 index 0000000..9193ed9 --- /dev/null +++ b/oxibadge/src/lib/settings.ts @@ -0,0 +1,12 @@ +import type { Shifts } from './shifts'; + +export interface Reader { + host: string; + port: number; +} + +export interface Settings { + reader: Reader; + shifts: Shifts; + lang: string | null; +} diff --git a/oxibadge/src/lib/shifts.ts b/oxibadge/src/lib/shifts.ts new file mode 100644 index 0000000..14f4382 --- /dev/null +++ b/oxibadge/src/lib/shifts.ts @@ -0,0 +1,7 @@ +export interface Shifts { + in1: string; + out1: string; + in2: string; + out2: string; + round_step_min: number; +} diff --git a/oxibadge/src/lib/time.ts b/oxibadge/src/lib/time.ts new file mode 100644 index 0000000..abc1555 --- /dev/null +++ b/oxibadge/src/lib/time.ts @@ -0,0 +1,31 @@ +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'); +} + +/** 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; +} diff --git a/oxibadge/src/routes/+layout.svelte b/oxibadge/src/routes/+layout.svelte index ae73a0e..c5eaf4b 100644 --- a/oxibadge/src/routes/+layout.svelte +++ b/oxibadge/src/routes/+layout.svelte @@ -1,14 +1,22 @@ {#if ready} diff --git a/oxibadge/src/routes/+page.svelte b/oxibadge/src/routes/+page.svelte index 63ae3ff..c84b3b3 100644 --- a/oxibadge/src/routes/+page.svelte +++ b/oxibadge/src/routes/+page.svelte @@ -9,24 +9,41 @@ import ConfirmMonth from '$lib/components/ConfirmMonth.svelte'; import LoadingScreen from '$lib/components/LoadingScreen.svelte'; import TimesheetReview from '$lib/components/TimesheetReview.svelte'; + import SettingsButton from '$lib/components/SettingsButton.svelte'; + import Settings from '$lib/components/Settings.svelte'; import { buildEmployees, type AttendanceRecord } from '$lib/components/review/fromRecords'; + import type { Shifts } from '$lib/shifts'; + import type { Settings as SettingsData } from '$lib/settings'; - type Step = 'loading' | 'setup' | 'confirm-month' | 'loading-records' | 'loading-employees' | 'review'; + type Step = 'loading' | 'setup' | 'confirm-month' | 'loading-records' | 'loading-employees' | 'review' | 'settings'; let step = $state('loading'); let selectedYear = $state(0); let selectedMonth = $state(0); let records = $state([]); let employeeNames = $state>({}); + // Placeholder: sovrascritti da load_settings prima che la revisione sia raggiungibile. + let shifts = $state({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 }); 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'); - step = preferences ? 'confirm-month' : 'setup'; + const settings = await invoke('load_settings'); + shifts = settings.shifts; + step = settings.reader.host ? 'confirm-month' : 'setup'; }); + function openSettings() { + clearError(); + step = 'settings'; + } + + function closeSettings() { + clearError(); + step = 'confirm-month'; + } + async function loadMonth(selection: { year: number; month: number }) { selectedYear = selection.year; selectedMonth = selection.month; @@ -49,9 +66,17 @@ year={selectedYear} month={selectedMonth} employees={reviewEmployees} + shift={shifts} onback={() => (step = 'confirm-month')} onexport={(payload) => invoke('export_timesheet', { payload })} /> +{:else if step === 'settings'} + { + shifts = s.shifts; + }} + /> {:else} {#if step === 'loading'} @@ -59,6 +84,7 @@ {:else if step === 'setup'} (step = 'confirm-month')} /> {:else if step === 'confirm-month'} + {:else if step === 'loading-records'}