feat(oxibadge): add settings screen for shifts, rounding and language
Check, format and test / build (push) Successful in 10m22s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:29:57 +02:00
parent acd3efeac6
commit b65e85f908
26 changed files with 1397 additions and 118 deletions
+16 -10
View File
@@ -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<Reader> {
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<R701, String> {
/// chronological (oldest first) order before being returned.
#[tauri::command]
async fn load_records(app: AppHandle, year: i32, month: u32) -> Result<Vec<Record>, 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<Record>,
) -> Result<HashMap<u32, String>, 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,
+57 -16
View File
@@ -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<Reader>,
#[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<Reader> {
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<String>,
}
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<PathBuf, String> {
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())
}
+4
View File
@@ -0,0 +1,4 @@
export function autofocus(node: HTMLInputElement) {
node.focus();
node.select();
}
+42 -3
View File
@@ -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();
</script>
<div class="field">
<div class="field" class:err>
<label for={id}>{label}</label>
{#if icon}{@render icon()}{/if}
<input {id} {type} {placeholder} bind:value {oninput} autocomplete="off" spellcheck="false" />
<input
{id}
{type}
{placeholder}
{maxlength}
{inputmode}
bind:value
{oninput}
autocomplete="off"
spellcheck="false"
/>
{#if suffix}<span class="suffix">{suffix}</span>{/if}
</div>
<style>
@@ -52,6 +71,20 @@
box-shadow: 0 0 0 4px var(--ox-accent-ring);
}
.field.err,
.field.err:hover,
.field.err:focus-within {
border-color: #e5484d;
}
.field.err:focus-within {
box-shadow: 0 0 0 4px rgba(229, 72, 77, 0.18);
}
.field.err label {
color: #ff8a8f;
}
.field label {
position: absolute;
top: -9px;
@@ -93,4 +126,10 @@
-webkit-appearance: none;
margin: 0;
}
.suffix {
flex: none;
font-size: 14px;
color: var(--ox-text-subtle);
}
</style>
@@ -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<Settings>('load_settings');
await invoke('save_settings', { settings: { ...settings, reader: { host: trimmedHost, port } } });
onconnected({ host: trimmedHost, port });
} catch (err) {
setError(err);
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
import { _, locale } from 'svelte-i18n';
import IconButton from './IconButton.svelte';
import ReaderCard from './settings/ReaderCard.svelte';
import ShiftsCard from './settings/ShiftsCard.svelte';
import RoundCard from './settings/RoundCard.svelte';
import LanguageSection from './settings/LanguageSection.svelte';
import { checkShifts, roundValid, type ShiftTimeKey, type Shifts } from './settings/logic';
import type { Settings as SettingsData } from '$lib/settings';
let { onback, onsettingschange }: { onback?: () => void; onsettingschange?: (s: SettingsData) => void } =
$props();
const SAVE_DELAY = 700;
let loaded = $state(false);
let host = $state('');
let port = $state(5005);
// Placeholder: sovrascritti da load_settings prima che le card siano renderizzate ({#if loaded}).
let shifts = $state<Shifts>({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 });
let roundStepStr = $state('');
let lang = $state<string | null>(null);
type SaveState = 'idle' | 'saving' | 'saved' | 'invalid' | 'failed';
let saveState = $state<SaveState>('idle');
let saveTimer: ReturnType<typeof setTimeout> | undefined;
onMount(async () => {
const settings = await invoke<SettingsData>('load_settings');
host = settings.reader.host;
port = settings.reader.port;
shifts = { ...settings.shifts };
roundStepStr = String(settings.shifts.round_step_min);
lang = settings.lang;
loaded = true;
});
function settingsValid() {
return !checkShifts(shifts).errorKey && roundValid(roundStepStr);
}
async function persist() {
saveState = 'saving';
const settings: SettingsData = {
reader: { host, port },
shifts: { ...shifts, round_step_min: Number(roundStepStr) },
lang
};
try {
await invoke('save_settings', { settings });
onsettingschange?.(settings);
saveState = 'saved';
} catch {
saveState = 'failed';
}
}
function scheduleSave(delay: number) {
clearTimeout(saveTimer);
if (!settingsValid()) {
saveState = 'invalid';
return;
}
saveState = 'saving';
saveTimer = setTimeout(persist, delay);
}
$effect(() => () => clearTimeout(saveTimer));
function onShiftInput(key: ShiftTimeKey, value: string) {
shifts[key] = value;
scheduleSave(SAVE_DELAY);
}
function onRoundInput(value: string) {
roundStepStr = value;
scheduleSave(SAVE_DELAY);
}
function onLangChange(next: string) {
lang = next;
locale.set(next);
scheduleSave(0);
}
function onAddressSaved(newHost: string, newPort: number) {
host = newHost;
port = newPort;
}
const saveInfo = $derived(
{
idle: [$_('settings.save_idle'), '#6e6e6e'],
saving: [$_('settings.save_saving'), '#a3a3a3'],
saved: [$_('settings.save_saved'), '#4cc38a'],
invalid: [$_('settings.save_invalid'), '#ff9ea3'],
failed: [$_('settings.save_failed'), '#ff9ea3']
}[saveState]
);
</script>
<div class="screen">
<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}
<h1>{$_('settings.title')}</h1>
</div>
<span class="save-state" role="status" aria-live="polite" style="color: {saveInfo[1]}">
{#if saveState === 'saving'}
<svg class="spin" width="15" height="15" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
><path d="M12 3a9 9 0 1 0 9 9" /></svg
>
{:else if saveState === 'saved'}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.2"
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
>
{:else if saveState === 'invalid' || saveState === 'failed'}
<svg width="16" height="16" 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
>
{/if}
{saveInfo[0]}
</span>
</header>
<div class="content scroll">
<div class="column">
{#if loaded}
<section aria-labelledby="s-dev">
<div class="sec-head">
<h2 id="s-dev">{$_('settings.device_title')}</h2>
<p>{$_('settings.device_desc')}</p>
</div>
<ReaderCard {host} {port} onsave={onAddressSaved} />
</section>
<section aria-labelledby="s-shift">
<div class="sec-head">
<h2 id="s-shift">{$_('settings.shifts_title')}</h2>
<p>{$_('settings.shifts_desc')}</p>
</div>
<ShiftsCard {shifts} oninput={onShiftInput} />
<RoundCard roundStep={roundStepStr} oninput={onRoundInput} />
</section>
<LanguageSection lang={(lang ?? $locale ?? 'it').split('-')[0]} onchange={onLangChange} />
{/if}
</div>
</div>
</div>
<style>
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.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);
}
.topbar {
flex: none;
min-height: 76px;
box-sizing: border-box;
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;
}
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);
}
.save-state {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13.5px;
}
.content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.column {
max-width: 720px;
margin: 0 auto;
box-sizing: border-box;
padding: 32px 24px 64px;
display: flex;
flex-direction: column;
gap: 48px;
}
section {
display: flex;
flex-direction: column;
gap: 20px;
}
.sec-head {
display: flex;
flex-direction: column;
gap: 6px;
}
.sec-head h2 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 600;
font-size: 22px;
letter-spacing: -0.02em;
color: #ffffff;
}
.sec-head p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #8a8a8a;
}
</style>
@@ -0,0 +1,44 @@
<script lang="ts">
let { ariaLabel, onclick }: { ariaLabel: string; onclick: () => void } = $props();
</script>
<button type="button" class="gear" aria-label={ariaLabel} title={ariaLabel} {onclick}>
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"
/>
</svg>
</button>
<style>
.gear {
all: unset;
box-sizing: border-box;
position: absolute;
top: 16px;
right: 16px;
z-index: 4;
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
color: var(--ox-text-subtle);
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.gear:hover {
background: #1e1e1e;
color: var(--ox-text-strong);
}
.gear:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
</style>
@@ -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<typeof setTimeout> | 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 (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])}
@@ -1,8 +1,10 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { LUNCH, ROUND_STEP, dayWord } from './analysis';
import { autofocus } from '$lib/autofocus';
import type { Shifts } from '$lib/shifts';
import { dayWord } from './analysis';
import FixButton from './FixButton.svelte';
import IconButton from './IconButton.svelte';
import IconButton from '../IconButton.svelte';
let {
name,
@@ -17,6 +19,7 @@
stackedCount,
lunchCount,
roundCount,
shift,
onfixall,
onaddlunchall,
onroundall,
@@ -34,6 +37,7 @@
stackedCount: number;
lunchCount: number;
roundCount: number;
shift: Shifts;
onfixall: () => void;
onaddlunchall: () => void;
onroundall: () => void;
@@ -51,10 +55,6 @@
editing = false;
}
function autofocus(node: HTMLInputElement) {
node.focus();
node.select();
}
</script>
<div class="main-head">
@@ -121,7 +121,7 @@
{#if lunchCount}
<FixButton
size="lg"
title={$_('review.add_lunch_title', { values: { start: LUNCH.start, end: LUNCH.end } })}
title={$_('review.add_lunch_title', { values: { start: shift.out1, end: shift.in2 } })}
onclick={onaddlunchall}
>
{#snippet icon()}
@@ -139,7 +139,7 @@
><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, $_) } })}
{$_('review.round_all', { values: { step: shift.round_step_min, days: dayWord(roundCount, $_) } })}
</FixButton>
{/if}
<div class="seg" role="group" aria-label={$_('review.day_filter_aria')}>
@@ -1,6 +1,6 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { ROUND_STEP } from './analysis';
import type { Shifts } from '$lib/shifts';
import FixButton from './FixButton.svelte';
import ActionButton from './ActionButton.svelte';
@@ -8,6 +8,7 @@
title,
scopeIssues,
scopeInfos,
shift,
items,
onclose,
onroundandexport,
@@ -16,6 +17,7 @@
title: string;
scopeIssues: number;
scopeInfos: number;
shift: Shifts;
items: { key: string; label: string; detail: string; red: boolean; go: () => void }[];
onclose: () => void;
onroundandexport: () => void;
@@ -40,7 +42,7 @@
{#if scopeIssues}
{$_('review.export_warning_issues')}
{:else}
{$_('review.export_warning_unrounded', { values: { step: ROUND_STEP } })}
{$_('review.export_warning_unrounded', { values: { step: shift.round_step_min } })}
{/if}
</p>
</div>
@@ -1,12 +1,14 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { COLS_KEYS, LUNCH, type Row, cellClass, dayLabel, hm, isRnd, noteOf } from './analysis';
import type { Shifts } from '$lib/shifts';
import { COLS_KEYS, type Row, cellClass, dayLabel, hm, isRnd, noteOf } from './analysis';
import FixButton from './FixButton.svelte';
let {
rows,
rounded,
emp,
shift,
oncellinput,
onfixday,
onaddlunch,
@@ -15,6 +17,7 @@
rows: Row[];
rounded: Record<string, string>;
emp: number;
shift: Shifts;
oncellinput: (di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) => void;
onfixday: (di: number) => void;
onaddlunch: (di: number) => void;
@@ -49,7 +52,7 @@
0
)}
{@const manual = r.rowEdits - rnd}
{@const note = noteOf(r, rnd, manual, $_)}
{@const note = noteOf(r, rnd, manual, $_, shift.round_step_min)}
{@const label = dayLabel(r)}
<tr class:we={r.weekend} class:anom={!!r.issue}>
<td class="first center"><span class="dow">{r.dow}</span><span class="mono">{r.di + 1}</span></td>
@@ -105,7 +108,7 @@
{/if}
{#if r.lunch}
<FixButton
ariaLabel={$_('review.add_lunch_day_aria', { values: { start: LUNCH.start, end: LUNCH.end, label } })}
ariaLabel={$_('review.add_lunch_day_aria', { values: { start: shift.out1, end: shift.in2, label } })}
onclick={() => onaddlunch(r.di)}
>
{#snippet icon()}
@@ -113,7 +116,7 @@
><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>
{$_('review.lunch_short')} <span class="mono">{shift.out1}–{shift.in2}</span>
</FixButton>
{/if}
{#if r.info}
@@ -2,7 +2,7 @@
import { _, locale } from 'svelte-i18n';
import { monthName } from '$lib/month';
import { dayWord, shortSummary } from './analysis';
import IconButton from './IconButton.svelte';
import IconButton from '../IconButton.svelte';
let {
year,
+51 -51
View File
@@ -4,6 +4,10 @@
// svelte-i18n) e, dove serve la lingua per Intl, `locale` ($locale).
import { weekdayName } from '$lib/month';
import type { Shifts } from '$lib/shifts';
import { fmt, formatTyped, hm, parseT } from '$lib/time';
export { formatTyped, hm, parseT };
// 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;
@@ -50,10 +54,6 @@ export interface Analysis {
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 {
@@ -63,73 +63,67 @@ export function normDay(day: Day): Day {
}) 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 {
export function isRounded(t: string, roundStep: number): boolean {
const m = parseT(t);
return m === null || m % ROUND_STEP === 0;
return m === null || m % roundStep === 0;
}
export function roundT(t: string): string {
export function roundT(t: string, roundStep: number): 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));
return fmt(Math.min(Math.round(m / roundStep) * roundStep, 24 * 60 - roundStep));
}
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 {
export function lunchSpan(day: Day, shift: Shifts): [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)
return (parseT(a) as number) < (parseT(shift.out1) as number) &&
(parseT(b) as number) > (parseT(shift.in2) as number)
? [a, b]
: null;
}
/**
* Ridistribuisce timbrature finite tutte nello stesso turno (es. per un
* timbratore configurato male) nelle 4 caselle corrette, scegliendo per
* ciascuna l'orario previsto (E1/U1/E2/U2) più vicino. Dato che gli orari
* sono ordinati e i turni previsti pure, basta confrontare ogni timbratura
* col punto a metà strada fra due turni consecutivi: è esattamente il
* confine fra "più vicino al turno prima" e "più vicino al turno dopo".
*/
export function assignToShifts(times: string[], shift: Shifts): Day {
const targets = [shift.in1, shift.out1, shift.in2, shift.out2].map((x) => parseT(x) as number);
const bounds = [(targets[0] + targets[1]) / 2, (targets[1] + targets[2]) / 2, (targets[2] + targets[3]) / 2];
const day: Day = [[], [], [], []];
const sorted = [...times].sort((a, b) => (parseT(a) ?? Infinity) - (parseT(b) ?? Infinity));
for (const time of sorted) {
const m = parseT(time);
const slot = m === null ? 3 : m < bounds[0] ? 0 : m < bounds[1] ? 1 : m < bounds[2] ? 2 : 3;
day[slot].push(time);
}
for (let i = 0; i < 4; i++) {
if (day[i].length === 0) day[i] = [''];
}
return day;
}
export function analyze(
year: number,
month: number,
values: Day[],
orig: Day[],
locale: string | null | undefined,
t: Translate
t: Translate,
shift: Shifts
): Analysis {
const rows: Row[] = [];
let minutes = 0;
@@ -156,8 +150,8 @@ export function analyze(
// 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 } });
const shiftLabel = filled[0] || filled[1] ? t('review.shift_1') : t('review.shift_2');
issue = t('review.issue_stacked', { values: { total, shift: shiftLabel } });
[0, 1, 2, 3].forEach((i) => {
if (!filled[i]) miss[i] = true;
});
@@ -165,7 +159,7 @@ export function analyze(
// 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;
const lunch = !stacked && !issue && lunchSpan(v, shift) !== null;
if (lunch) {
issue = t('review.issue_lunch_missing');
[0, 1, 2, 3].forEach((i) => {
@@ -210,10 +204,10 @@ export function analyze(
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;
const wasLunch = !lunch && lunchSpan(o, shift) !== null && v[1][0] === shift.out1 && v[2][0] === shift.in2;
// 6. Info: orari non arrotondati
const unr = v.map((c) => c.map((x) => x !== '' && !isRounded(x)));
const unr = v.map((c) => c.map((x) => x !== '' && !isRounded(x, shift.round_step_min)));
const hasUnr = unr.some((c) => c.some(Boolean));
const info = !issue && hasUnr;
@@ -250,9 +244,15 @@ export function isRnd(
return val !== '' && rounded[`${emp}-${di}-${ci}-${ii}`] === val;
}
export function noteOf(r: Row, roundedCount: number, manualCount: number, t: Translate): { text: string; kind: string } {
export function noteOf(
r: Row,
roundedCount: number,
manualCount: number,
t: Translate,
roundStep: number
): { 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.info) return { text: t('review.info_unrounded', { values: { step: roundStep } }), 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' };
@@ -0,0 +1,103 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
let { lang, onchange }: { lang: string; onchange: (lang: string) => void } = $props();
</script>
<section class="lang-sec">
<div class="sec-head">
<h2><label for="ox-lang">{$_('settings.language_title')}</label></h2>
<p>{$_('settings.language_desc')}</p>
</div>
<div class="select-wrap">
<select id="ox-lang" class="select" value={lang} onchange={(e) => onchange(e.currentTarget.value)}>
<option value="it">Italiano</option>
<option value="en">English</option>
</select>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" class="select-chev"
><path d="M6 9l6 6 6-6" /></svg
>
</div>
</section>
<style>
.lang-sec {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 24px;
}
.sec-head {
display: flex;
flex-direction: column;
gap: 6px;
}
.sec-head h2 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 600;
font-size: 22px;
letter-spacing: -0.02em;
color: #ffffff;
}
.sec-head p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #8a8a8a;
}
.select-wrap {
position: relative;
display: inline-flex;
align-items: center;
}
.select {
appearance: none;
-webkit-appearance: none;
box-sizing: border-box;
height: 44px;
min-width: 180px;
padding: 0 42px 0 14px;
border: 1px solid #333333;
border-radius: 12px;
background: #161616;
color: #f2f2f2;
font-family: inherit;
font-size: 14.5px;
font-weight: 500;
cursor: pointer;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.select:hover {
border-color: #4a4a4a;
}
.select:focus {
outline: none;
border-color: var(--ox-accent);
box-shadow: 0 0 0 4px var(--ox-accent-ring);
}
.select option {
background: #1a1a1a;
color: #f2f2f2;
}
.select-chev {
position: absolute;
right: 14px;
pointer-events: none;
color: #8a8a8a;
stroke-width: 2.2;
}
</style>
@@ -0,0 +1,332 @@
<script lang="ts">
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 Field from '../Field.svelte';
import ErrorBanner from '../ErrorBanner.svelte';
import ActionButton from '../review/ActionButton.svelte';
import { IPV4 } from './logic';
let { host, port, onsave }: { host: string; port: number; onsave: (host: string, port: number) => void } =
$props();
let editing = $state(false);
let draftHost = $state('');
let draftPort = $state(0);
let ipError = $state(false);
let status = $state<'' | 'testing' | 'ok'>('');
function startEdit() {
draftHost = host;
draftPort = port;
ipError = false;
clearError();
editing = true;
}
function cancelEdit() {
editing = false;
ipError = false;
}
async function confirmEdit() {
const v = draftHost.trim();
if (!IPV4.test(v)) {
ipError = true;
return;
}
const nextPort = draftPort || 5005;
editing = false;
status = '';
clearError();
try {
const settings = await invoke<Settings>('load_settings');
await invoke('save_settings', { settings: { ...settings, reader: { host: v, port: nextPort } } });
onsave(v, nextPort);
} catch (err) {
setError(err);
}
}
async function test() {
status = 'testing';
clearError();
try {
await invoke('test_connection', { host, port });
status = 'ok';
} catch (err) {
status = '';
setError(err);
}
}
function onPortInput(e: Event & { currentTarget: HTMLInputElement }) {
const input = e.currentTarget;
const next = input.value.replace(/\D/g, '').slice(0, 5);
if (input.value !== next) input.value = next;
draftPort = Number(next);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && editing) cancelEdit();
}
</script>
<svelte:window onkeydown={onKey} />
<div class="card" class:active={editing}>
{#if !editing}
<div class="dev-row">
<div class="dev-icon">
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 1.8"
><rect x="5" y="3" width="14" height="18" rx="2.5" /><rect x="8" y="6" width="8" height="5" rx="1" /><path
d="M9 15h.01M12 15h.01M15 15h.01M9 18h6"
/></svg
>
</div>
<div class="dev-text">
<span class="dev-label">{$_('settings.device_label')}</span>
<span class="dev-endpoint mono">{host}:{port}</span>
</div>
<span class="dev-status">
{#if status === 'testing'}
<svg class="spin" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
><path d="M12 3a9 9 0 1 0 9 9" /></svg
>
{$_('settings.status_testing')}
{:else if status === 'ok'}
<span class="dot ok" aria-hidden="true"></span>
{$_('settings.status_ok')}
{:else}
<span class="dot" aria-hidden="true"></span>
{$_('settings.status_idle')}
{/if}
</span>
</div>
<div class="dev-actions">
<button type="button" class="pill" onclick={startEdit}>
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
>
<span>{$_('settings.edit_address')}</span>
</button>
<button type="button" class="pill" onclick={test} disabled={status === 'testing'}>
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01" /></svg
>
<span>{$_('settings.test_connection')}</span>
</button>
</div>
{:else}
<form
class="dev-edit"
onsubmit={(e) => {
e.preventDefault();
confirmEdit();
}}
>
<div class="grid-ip">
<div class="stack">
<Field
id="dev-ip"
label={$_('settings.ip_label')}
bind:value={draftHost}
inputmode="decimal"
placeholder="192.168.1.100"
err={ipError}
oninput={() => (ipError = false)}
/>
{#if ipError}<span class="field-error">{$_('settings.ip_invalid')}</span>{/if}
</div>
<Field
id="dev-port"
label={$_('settings.port_label')}
value={draftPort}
maxlength={5}
inputmode="numeric"
placeholder="5005"
oninput={onPortInput}
/>
</div>
<div class="row-end">
<ActionButton onclick={cancelEdit}>{$_('settings.cancel')}</ActionButton>
<ActionButton primary onclick={confirmEdit}>{$_('settings.confirm')}</ActionButton>
</div>
</form>
{/if}
</div>
{#if $error}
<ErrorBanner title={$_('errors.title')} message={$error} />
{/if}
<style>
.mono {
font-family: var(--ox-font-mono);
}
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.card {
border: 1px solid #262626;
border-radius: 14px;
background: #161616;
}
.card.active {
border-color: #3a3a3a;
}
.dev-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
padding: 16px 16px 16px 18px;
}
.dev-icon {
width: 44px;
height: 44px;
flex: none;
border-radius: 11px;
background: #1f1f1f;
color: #bdbdbd;
display: flex;
align-items: center;
justify-content: center;
}
.dev-text {
flex: 1;
min-width: 160px;
display: flex;
flex-direction: column;
gap: 3px;
}
.dev-label {
font-size: 12.5px;
color: #8a8a8a;
}
.dev-endpoint {
font-size: 16px;
color: #f2f2f2;
}
.dev-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #6e6e6e;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.dot.ok {
background: #4cc38a;
}
.dev-status:has(.ok) {
color: #4cc38a;
}
.dev-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px 14px 18px;
border-top: 1px solid #222222;
}
.pill {
all: unset;
box-sizing: border-box;
height: 36px;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 7px;
border-radius: 9px;
border: 1px solid #333333;
color: #c9c9c9;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.pill:hover:not(:disabled) {
background: #1e1e1e;
color: #ffffff;
border-color: #4a4a4a;
}
.pill:disabled {
opacity: 0.5;
cursor: progress;
}
.pill:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
.dev-edit {
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px 16px 16px;
}
.grid-ip {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px;
gap: 12px;
align-items: start;
}
.stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.row-end {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.field-error {
padding-left: 4px;
font-size: 12.5px;
color: #ff8a8f;
}
@media (max-width: 560px) {
.grid-ip {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -0,0 +1,90 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { fmt } from '$lib/time';
import Field from '../Field.svelte';
import { roundValid } from './logic';
let { roundStep, oninput }: { roundStep: string; oninput: (value: string) => void } = $props();
function onRoundInput(e: Event & { currentTarget: HTMLInputElement }) {
const input = e.currentTarget;
const next = input.value.replace(/\D/g, '').slice(0, 2);
if (input.value !== next) input.value = next;
oninput(next);
}
const example = $derived.by(() => {
if (!roundValid(roundStep)) return '';
const step = Number(roundStep);
const rounded = Math.round((8 * 60 + 7) / step) * step;
return $_('settings.round_example', { values: { from: '08:07', to: fmt(rounded) } });
});
</script>
<div class="card">
<div class="round-text">
<span class="round-title">{$_('settings.round_title')}</span>
<span class="round-desc">{$_('settings.round_desc')} {example}</span>
</div>
<div class="round-box">
<Field
id="ox-round"
label={$_('settings.round_label')}
value={roundStep}
maxlength={2}
inputmode="numeric"
placeholder="10"
suffix={$_('settings.minutes_short')}
err={!roundValid(roundStep)}
oninput={onRoundInput}
/>
{#if !roundValid(roundStep)}<span class="field-error">{$_('settings.round_range')}</span>{/if}
</div>
</div>
<style>
.card {
border: 1px solid #262626;
border-radius: 14px;
background: #161616;
padding: 22px 18px 18px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 16px 24px;
}
.round-text {
flex: 1;
min-width: 240px;
display: flex;
flex-direction: column;
gap: 4px;
}
.round-title {
font-size: 14.5px;
font-weight: 500;
color: #e6e6e6;
}
.round-desc {
font-size: 13px;
line-height: 1.5;
color: #8a8a8a;
}
.round-box {
width: 140px;
display: flex;
flex-direction: column;
gap: 8px;
}
.field-error {
padding-left: 4px;
font-size: 12.5px;
color: #ff8a8f;
}
</style>
@@ -0,0 +1,126 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { formatTyped, hm } from '$lib/time';
import Field from '../Field.svelte';
import { SHIFT_ROWS, checkShifts, type ShiftTimeKey, type Shifts } from './logic';
let { shifts, oninput }: { shifts: Shifts; oninput: (key: ShiftTimeKey, value: string) => void } = $props();
const check = $derived(checkShifts(shifts));
function onShiftInput(key: ShiftTimeKey, e: Event & { currentTarget: HTMLInputElement }) {
const input = e.currentTarget;
const next = formatTyped(input.value, shifts[key], (e as unknown as InputEvent).inputType ?? '');
if (input.value !== next) input.value = next;
oninput(key, next);
}
</script>
<div class="card">
{#each SHIFT_ROWS as row (row.a)}
<div class="shift-row">
<span class="shift-title">{$_(row.titleKey)}</span>
<Field
id="sh-{row.a}"
label={$_('settings.shift_in')}
value={shifts[row.a]}
maxlength={5}
inputmode="numeric"
placeholder="--:--"
err={!!check.bad[row.a]}
oninput={(e) => onShiftInput(row.a, e)}
/>
<Field
id="sh-{row.b}"
label={$_('settings.shift_out')}
value={shifts[row.b]}
maxlength={5}
inputmode="numeric"
placeholder="--:--"
err={!!check.bad[row.b]}
oninput={(e) => onShiftInput(row.b, e)}
/>
</div>
{/each}
<div class="summary">
{#if check.errorKey}
<span class="summary-err">
<svg width="16" height="16" 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
>
{$_(check.errorKey)}
</span>
{:else if check.minutes}
<span
>{$_('settings.hours_per_day_label')}
<b>{hm(check.minutes.out1 - check.minutes.in1 + (check.minutes.out2 - check.minutes.in2))}</b>
{$_('settings.per_day')}</span
>
<span
>{$_('settings.break_label')} <b>{shifts.out1}–{shifts.in2}</b>
({check.minutes.in2 - check.minutes.out1} {$_('settings.minutes_short')})</span
>
{/if}
</div>
</div>
<style>
.card {
border: 1px solid #262626;
border-radius: 14px;
background: #161616;
display: flex;
flex-direction: column;
gap: 14px;
padding: 22px 18px 18px;
}
.shift-row {
display: grid;
grid-template-columns: 96px minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
align-items: center;
padding-top: 8px;
}
.shift-title {
font-size: 14px;
font-weight: 500;
color: #c9c9c9;
}
.summary {
margin-top: 6px;
padding-top: 16px;
border-top: 1px solid #242424;
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
font-size: 13.5px;
color: #8a8a8a;
}
.summary b {
font-family: var(--ox-font-mono);
font-weight: 400;
color: #e6e6e6;
}
.summary-err {
display: inline-flex;
align-items: center;
gap: 8px;
color: #ff9ea3;
}
@media (max-width: 560px) {
.shift-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.shift-title {
grid-column: 1 / -1;
}
}
</style>
@@ -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<ShiftTimeKey, number> | null;
bad: Partial<Record<ShiftTimeKey, true>>;
errorKey: string | null;
}
export function checkShifts(shifts: Shifts): ShiftCheck {
const t: Record<ShiftTimeKey, number | null> = {
in1: parseT(shifts.in1),
out1: parseT(shifts.out1),
in2: parseT(shifts.in2),
out2: parseT(shifts.out2)
};
const bad: Partial<Record<ShiftTimeKey, true>> = {};
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<ShiftTimeKey, number>;
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;
+42
View File
@@ -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"
}
}
+42
View File
@@ -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"
}
}
+12
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
export interface Shifts {
in1: string;
out1: string;
in2: string;
out2: string;
round_step_min: number;
}
+31
View File
@@ -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;
}
+10 -2
View File
@@ -1,12 +1,20 @@
<script lang="ts">
import '../app.css';
import { waitLocale } from 'svelte-i18n';
import { invoke } from '@tauri-apps/api/core';
import { locale, waitLocale } from 'svelte-i18n';
import type { Snippet } from 'svelte';
import type { Settings } from '$lib/settings';
let { children }: { children: Snippet } = $props();
let ready = $state(false);
waitLocale().then(() => {
invoke<Settings>('load_settings')
.catch(() => null)
.then((settings) => {
if (settings?.lang) locale.set(settings.lang);
return waitLocale();
})
.then(() => {
ready = true;
});
</script>
+29 -3
View File
@@ -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<Step>('loading');
let selectedYear = $state(0);
let selectedMonth = $state(0);
let records = $state<AttendanceRecord[]>([]);
let employeeNames = $state<Record<string, string>>({});
// Placeholder: sovrascritti da load_settings prima che la revisione sia raggiungibile.
let shifts = $state<Shifts>({ 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<SettingsData>('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'}
<Settings
onback={closeSettings}
onsettingschange={(s: SettingsData) => {
shifts = s.shifts;
}}
/>
{:else}
<Screen>
{#if step === 'loading'}
@@ -59,6 +84,7 @@
{:else if step === 'setup'}
<Onboarding onconnected={() => (step = 'confirm-month')} />
{:else if step === 'confirm-month'}
<SettingsButton ariaLabel={$_('settings.title')} onclick={openSettings} />
<ConfirmMonth onstart={loadMonth} />
{:else if step === 'loading-records'}
<LoadingScreen