feat(oxibadge): cache employee names locally and let readers be a list
Check, format and test / build (push) Successful in 10m27s

Employee names are now cached locally (employees.toml, keyed by reader
index then employee id) instead of being re-fetched from the time
clock on every month: load_employees only asks the device for ids not
already in the cache. When a month has employees the cache doesn't
know yet, a new screen (NewEmployees.svelte) lets the user name them
before continuing to the review screen; the chosen names are local to
Oxibadge only, never written back to the device. The same cache can be
managed afterwards from a drawer reachable from the time clock card in
Settings ("Gestisci dipendenti"), reusing the rename-in-row pattern
from the original mockup.

Settings.readers is a list again (was flattened to a single `reader`
object) so multiple time clocks can be supported later; only the first
one is used today.

Also:
- buildEmployees() now derives the employee list from the month's
  records instead of from every cached name ever seen, so the review
  screen no longer shows employees unrelated to the selected month.
- Renaming an employee from the review screen's pencil button now
  actually persists to the cache (the callback existed but was never
  wired up).
- Translated to English a number of Rust/TS/Svelte comments that had
  been written in Italian; only the i18n locale files should contain
  Italian.
- Minor dedup: extracted PillButton.svelte (was duplicated inline in
  ReaderCard), promoted the repeated .mono CSS rule to app.css, moved
  initials() out of the review feature into $lib/text.ts.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 20:04:37 +02:00
parent b65e85f908
commit 25127bee96
26 changed files with 1109 additions and 143 deletions
+39
View File
@@ -0,0 +1,39 @@
use std::collections::HashMap;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
/// User-chosen employee names, by employee_id (as a string, to be a valid
/// TOML table).
pub type EmployeeNames = HashMap<String, String>;
/// `EmployeeNames` by reader index (as a string, same reason), since an
/// employee_id is only unique within one reader. Local to Oxibadge only:
/// the time clock is never written to.
pub type Cache = HashMap<String, EmployeeNames>;
fn path(app: &AppHandle) -> Result<PathBuf, String> {
Ok(app
.path()
.app_data_dir()
.map_err(|err| err.to_string())?
.join("employees.toml"))
}
pub fn load(app: &AppHandle) -> Cache {
let Ok(path) = path(app) else {
return Cache::new();
};
std::fs::read_to_string(path)
.ok()
.and_then(|contents| toml::from_str(&contents).ok())
.unwrap_or_default()
}
pub fn save(app: &AppHandle, cache: &Cache) -> 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(cache).map_err(|err| err.to_string())?;
std::fs::write(dest, contents).map_err(|err| err.to_string())
}
+27 -9
View File
@@ -1,10 +1,11 @@
mod employees;
mod preferences; mod preferences;
use chrono::{Local, TimeZone}; use chrono::{Local, TimeZone};
use preferences::Settings; use preferences::Settings;
use r701::{Record, R701}; use r701::{Record, R701};
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use tauri::AppHandle; use tauri::AppHandle;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
@@ -18,6 +19,16 @@ fn save_settings(app: AppHandle, settings: Settings) -> Result<(), String> {
preferences::save(&app, &settings) preferences::save(&app, &settings)
} }
#[tauri::command]
fn load_employee_cache(app: AppHandle) -> employees::Cache {
employees::load(&app)
}
#[tauri::command]
fn save_employee_cache(app: AppHandle, cache: employees::Cache) -> Result<(), String> {
employees::save(&app, &cache)
}
#[tauri::command] #[tauri::command]
async fn test_connection(host: String, port: u16) -> Result<(), String> { async fn test_connection(host: String, port: u16) -> Result<(), String> {
connect_with_retries(&host, port).await.map(|_| ()) connect_with_retries(&host, port).await.map(|_| ())
@@ -47,10 +58,10 @@ async fn connect_with_retries(host: &str, port: u16) -> Result<R701, String> {
#[tauri::command] #[tauri::command]
async fn load_records(app: AppHandle, year: i32, month: u32) -> Result<Vec<Record>, String> { async fn load_records(app: AppHandle, year: i32, month: u32) -> Result<Vec<Record>, String> {
let settings = preferences::load(&app); let settings = preferences::load(&app);
if settings.reader.host.is_empty() { let Some(reader) = settings.readers.first() else {
return Err("no reader configured".to_string()); return Err("no reader configured".to_string());
} };
let r701 = connect_with_retries(&settings.reader.host, settings.reader.port).await?; let r701 = connect_with_retries(&reader.host, reader.port).await?;
let invalid = || "invalid year/month".to_string(); let invalid = || "invalid year/month".to_string();
let start = Local let start = Local
@@ -82,21 +93,26 @@ async fn load_records(app: AppHandle, year: i32, month: u32) -> Result<Vec<Recor
Ok(records) Ok(records)
} }
/// Resolves the name of every distinct employee in `records`. /// Resolves the name of every distinct employee in `records`, skipping
/// `known` ones (already in the frontend's local cache) to avoid asking the
/// device for names it has already told us. Returns only the newly fetched
/// names, keyed by employee id.
#[tauri::command] #[tauri::command]
async fn load_employees( async fn load_employees(
app: AppHandle, app: AppHandle,
records: Vec<Record>, records: Vec<Record>,
known: Vec<u32>,
) -> Result<HashMap<u32, String>, String> { ) -> Result<HashMap<u32, String>, String> {
let settings = preferences::load(&app); let settings = preferences::load(&app);
if settings.reader.host.is_empty() { let Some(reader) = settings.readers.first() else {
return Err("no reader configured".to_string()); return Err("no reader configured".to_string());
} };
let r701 = connect_with_retries(&settings.reader.host, settings.reader.port).await?; let r701 = connect_with_retries(&reader.host, reader.port).await?;
let known: HashSet<u32> = known.into_iter().collect();
let mut employees = HashMap::new(); let mut employees = HashMap::new();
for record in &records { for record in &records {
if employees.contains_key(&record.employee_id) { if known.contains(&record.employee_id) || employees.contains_key(&record.employee_id) {
continue; continue;
} }
let name = r701 let name = r701
@@ -153,6 +169,8 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
load_settings, load_settings,
save_settings, save_settings,
load_employee_cache,
save_employee_cache,
test_connection, test_connection,
load_records, load_records,
load_employees, load_employees,
+5 -5
View File
@@ -19,7 +19,10 @@ pub struct Shifts {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings { pub struct Settings {
pub reader: Reader, /// The configured time clock (only the first one is used today, but
/// this stays a list to support multiple readers in the future).
#[serde(default)]
pub readers: Vec<Reader>,
pub shifts: Shifts, pub shifts: Shifts,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub lang: Option<String>, pub lang: Option<String>,
@@ -28,10 +31,7 @@ pub struct Settings {
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
reader: Reader { readers: Vec::new(),
host: String::new(),
port: 5005,
},
shifts: Shifts { shifts: Shifts {
in1: "07:30".to_string(), in1: "07:30".to_string(),
out1: "12:00".to_string(), out1: "12:00".to_string(),
+4
View File
@@ -60,6 +60,10 @@ h1 {
color: var(--ox-text-strong); color: var(--ox-text-strong);
} }
.mono {
font-family: var(--ox-font-mono);
}
.red { .red {
color: #ff9ea3; color: #ff9ea3;
} }
@@ -311,7 +311,6 @@
.opt.mono { .opt.mono {
flex: none; flex: none;
font-family: var(--ox-font-mono);
font-size: 16px; font-size: 16px;
} }
+17 -1
View File
@@ -4,6 +4,7 @@
let { let {
size = 'sm', size = 'sm',
accent = false, accent = false,
danger = false,
title, title,
ariaLabel, ariaLabel,
onclick, onclick,
@@ -11,6 +12,7 @@
}: { }: {
size?: 'sm' | 'lg'; size?: 'sm' | 'lg';
accent?: boolean; accent?: boolean;
danger?: boolean;
title?: string; title?: string;
ariaLabel: string; ariaLabel: string;
onclick: () => void; onclick: () => void;
@@ -18,7 +20,16 @@
} = $props(); } = $props();
</script> </script>
<button type="button" class="icon-btn" class:lg={size === 'lg'} class:accent {title} aria-label={ariaLabel} {onclick}> <button
type="button"
class="icon-btn"
class:lg={size === 'lg'}
class:accent
class:danger
{title}
aria-label={ariaLabel}
{onclick}
>
{@render icon()} {@render icon()}
</button> </button>
@@ -65,4 +76,9 @@
.icon-btn.accent:hover { .icon-btn.accent:hover {
filter: brightness(1.08); filter: brightness(1.08);
} }
.icon-btn.danger:hover {
background: rgba(229, 72, 77, 0.14);
color: #ff8a8f;
}
</style> </style>
@@ -0,0 +1,267 @@
<script lang="ts">
import { untrack } from 'svelte';
import { _ } from 'svelte-i18n';
import { isEdited, isEmpty, type NewEmployee } from './new-employees/logic';
let {
employees,
oncontinue
}: {
employees: NewEmployee[];
oncontinue?: (list: { id: number; name: string; original: string }[]) => void;
} = $props();
// Working copy: the props only serve as initial values. `untrack` makes
// it explicit that we read `employees` only once, on purpose.
let names = $state(untrack(() => employees.map((e) => e.name)));
const count = $derived(employees.length);
const empties = $derived(names.filter((n) => isEmpty(n)).length);
const renamed = $derived(names.filter((n, i) => isEdited(n, employees[i].name)).length);
const inputs: HTMLInputElement[] = [];
function submit() {
if (empties > 0) return;
oncontinue?.(employees.map((e, i) => ({ id: e.id, name: names[i].trim(), original: e.name })));
}
function onKeydown(e: KeyboardEvent, i: number) {
if (e.key !== 'Enter') return;
e.preventDefault();
if (i < inputs.length - 1) inputs[i + 1]?.focus();
else submit();
}
</script>
<main class="screen">
<div class="column">
<header class="intro">
<h1>{$_('new_employees.title')}</h1>
<p>
{$_('new_employees.subtitle', { values: { n: count } })}
{$_('new_employees.hint')}
</p>
</header>
<div class="list scroll">
{#each employees as emp, i (emp.id)}
{@const empty = isEmpty(names[i])}
{@const edited = isEdited(names[i], emp.name)}
<div class="item">
<span class="uid mono">#{emp.id}</span>
<input
bind:this={inputs[i]}
class="name"
class:edit={edited}
class:empty
type="text"
placeholder={$_('new_employees.missing_placeholder')}
aria-label={$_('new_employees.name_aria', { values: { id: emp.id } })}
aria-invalid={empty}
bind:value={names[i]}
onkeydown={(e) => onKeydown(e, i)}
/>
</div>
{/each}
</div>
<div class="footer">
<div class="summary">
<span>{$_('new_employees.renamed_count', { values: { n: renamed } })}</span>
{#if empties > 0}
<span class="red">{$_('new_employees.missing_count', { values: { n: empties } })}</span>
{/if}
</div>
<button type="button" class="btn" disabled={empties > 0} onclick={submit}>
<span>{$_('new_employees.continue')}</span>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</button>
</div>
</div>
</main>
<style>
.screen {
height: 100vh;
min-height: 640px;
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 24px;
background: var(--ox-bg);
color: var(--ox-text);
font-family: var(--ox-font-body);
}
.column {
width: 100%;
max-width: 440px;
max-height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 24px;
}
.intro {
flex: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
h1 {
margin: 0;
font-size: 44px;
}
.intro p {
margin: 0;
max-width: 380px;
font-size: 15px;
line-height: 1.5;
color: var(--ox-text-muted);
}
.list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 8px 0 20px;
display: flex;
flex-direction: column;
gap: 4px;
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 18px, #000 calc(100% - 28px), transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, #000 18px, #000 calc(100% - 28px), transparent 100%);
}
.item {
display: flex;
align-items: center;
gap: 10px;
padding: 0 4px;
}
.uid {
width: 40px;
flex: none;
text-align: right;
font-size: 14px;
font-weight: 300;
color: #6e6e6e;
}
/* The name looks like plain text; it becomes a field on hover/focus. */
.name {
all: unset;
box-sizing: border-box;
flex: 1;
min-width: 0;
height: 44px;
padding: 0 12px;
border: 1px solid transparent;
border-radius: 10px;
font-size: 16.5px;
color: var(--ox-text);
caret-color: var(--ox-accent);
cursor: text;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
.name::placeholder {
color: #b85a60;
}
.name:hover {
border-color: var(--ox-border);
background: #161616;
}
.name:focus {
border-color: var(--ox-accent);
box-shadow: 0 0 0 3px var(--ox-accent-ring);
background: #161616;
}
.name.edit {
color: #ffb08c;
background: rgba(247, 76, 0, 0.09);
}
.name.edit:focus {
background: #161616;
}
.name.empty {
border: 1px dashed #a8383d;
background: rgba(229, 72, 77, 0.08);
}
.name.empty:focus {
border-style: solid;
border-color: #e5484d;
box-shadow: 0 0 0 3px rgba(229, 72, 77, 0.18);
}
.footer {
flex: none;
display: flex;
flex-direction: column;
gap: 12px;
}
.summary {
display: flex;
justify-content: center;
gap: 16px;
font-size: 13px;
color: var(--ox-text-subtle);
}
.btn {
all: unset;
box-sizing: border-box;
height: var(--ox-control-h);
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: var(--ox-radius);
background: var(--ox-accent);
color: var(--ox-on-accent);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition:
filter 0.15s,
transform 0.1s,
opacity 0.2s;
}
.btn:not(:disabled):hover {
filter: brightness(1.08);
}
.btn:not(:disabled):active {
transform: translateY(1px);
}
.btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 3px;
}
</style>
@@ -30,7 +30,7 @@
try { try {
await invoke('test_connection', { host: trimmedHost, port }); await invoke('test_connection', { host: trimmedHost, port });
const settings = await invoke<Settings>('load_settings'); const settings = await invoke<Settings>('load_settings');
await invoke('save_settings', { settings: { ...settings, reader: { host: trimmedHost, port } } }); await invoke('save_settings', { settings: { ...settings, readers: [{ host: trimmedHost, port }] } });
onconnected({ host: trimmedHost, port }); onconnected({ host: trimmedHost, port });
} catch (err) { } catch (err) {
setError(err); setError(err);
@@ -0,0 +1,58 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
disabled = false,
onclick,
icon,
children
}: {
disabled?: boolean;
onclick: () => void;
icon?: Snippet;
children: Snippet;
} = $props();
</script>
<button type="button" class="pill" {disabled} {onclick}>
{#if icon}{@render icon()}{/if}
<span>{@render children()}</span>
</button>
<style>
.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;
}
</style>
+4 -4
View File
@@ -18,7 +18,7 @@
let loaded = $state(false); let loaded = $state(false);
let host = $state(''); let host = $state('');
let port = $state(5005); let port = $state(5005);
// Placeholder: sovrascritti da load_settings prima che le card siano renderizzate ({#if loaded}). // Placeholder: overwritten by load_settings before the cards are rendered ({#if loaded}).
let shifts = $state<Shifts>({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 }); let shifts = $state<Shifts>({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 });
let roundStepStr = $state(''); let roundStepStr = $state('');
let lang = $state<string | null>(null); let lang = $state<string | null>(null);
@@ -29,8 +29,8 @@
onMount(async () => { onMount(async () => {
const settings = await invoke<SettingsData>('load_settings'); const settings = await invoke<SettingsData>('load_settings');
host = settings.reader.host; host = settings.readers[0]?.host ?? '';
port = settings.reader.port; port = settings.readers[0]?.port ?? 5005;
shifts = { ...settings.shifts }; shifts = { ...settings.shifts };
roundStepStr = String(settings.shifts.round_step_min); roundStepStr = String(settings.shifts.round_step_min);
lang = settings.lang; lang = settings.lang;
@@ -44,7 +44,7 @@
async function persist() { async function persist() {
saveState = 'saving'; saveState = 'saving';
const settings: SettingsData = { const settings: SettingsData = {
reader: { host, port }, readers: [{ host, port }],
shifts: { ...shifts, round_step_min: Number(roundStepStr) }, shifts: { ...shifts, round_step_min: Number(roundStepStr) },
lang lang
}; };
@@ -43,17 +43,17 @@
onback?: () => void; onback?: () => void;
} = $props(); } = $props();
// Copie di lavoro: il componente non modifica le props. Per caricare un // Working copies: the component never mutates the props. Loading another
// altro mese va rimontato (nella nostra app accade già da sé, dato che // month remounts it (already the case in this app, since this screen
// la schermata è dietro un {#if step === 'review'}). `untrack` rende // sits behind a {#if step === 'review'}). `untrack` makes it explicit
// esplicito che leggiamo `employees` una sola volta, di proposito. // that we read `employees` only once, on purpose.
const orig: Day[][] = untrack(() => employees.map((e) => e.days.map(normDay))); const orig: Day[][] = untrack(() => employees.map((e) => e.days.map(normDay)));
let values = $state<Day[][]>(untrack(() => employees.map((e) => e.days.map(normDay)))); let values = $state<Day[][]>(untrack(() => employees.map((e) => e.days.map(normDay))));
let names = $state<string[]>(untrack(() => employees.map((e) => e.name))); let names = $state<string[]>(untrack(() => employees.map((e) => e.name)));
/** "dip-giorno-casella-indice" -> orario prodotto dall'arrotondamento */ /** "employee-day-slot-index" -> time produced by rounding */
let rounded = $state<Record<string, string>>({}); let rounded = $state<Record<string, string>>({});
// ---- Stato UI ---- // ---- UI state ----
let emp = $state(0); let emp = $state(0);
let query = $state(''); let query = $state('');
let onlyIssues = $state(false); let onlyIssues = $state(false);
@@ -78,23 +78,23 @@
); );
const visibleRows = $derived(cur.rows.filter((r) => !onlyIssues || r.issue || r.info)); const visibleRows = $derived(cur.rows.filter((r) => !onlyIssues || r.issue || r.info));
// ---- Azioni sui dati ---- // ---- Data actions ----
function onCellInput(di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) { function onCellInput(di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) {
const input = e.currentTarget; const input = e.currentTarget;
const cell = values[emp][di][ci]; const cell = values[emp][di][ci];
const prev = cell[ii] ?? ''; const prev = cell[ii] ?? '';
const next = formatTyped(input.value, prev, (e as unknown as InputEvent).inputType ?? ''); const next = formatTyped(input.value, prev, (e as unknown as InputEvent).inputType ?? '');
if (next === '' && cell.length > 1) { if (next === '' && cell.length > 1) {
// Una delle timbrature impilate è stata svuotata a mano: la casella si // One of the stacked punches was cleared by hand: the cell shrinks
// restringe invece di lasciare un campo vuoto appeso. // instead of leaving a dangling empty field.
cell.splice(ii, 1); cell.splice(ii, 1);
} else { } else {
cell[ii] = next; cell[ii] = next;
if (input.value !== next) input.value = next; // allinea il campo anche se lo stato non cambia if (input.value !== next) input.value = next; // keep the field in sync even if the state doesn't change
} }
} }
/** Ridistribuisce le timbrature del giorno in E1, U1, E2, U2 in base ai turni previsti. */ /** Redistributes the day's punches into E1, U1, E2, U2 based on the expected shifts. */
function fixDays(dis: number[]) { function fixDays(dis: number[]) {
for (const di of dis) { for (const di of dis) {
const times = values[emp][di].flat().filter((x) => x !== ''); const times = values[emp][di].flat().filter((x) => x !== '');
@@ -121,13 +121,13 @@
} }
} }
// ---- Nome ---- // ---- Name ----
function renameCurrent(name: string) { function renameCurrent(name: string) {
names[emp] = name; names[emp] = name;
onrename?.(employees[emp].id, name); onrename?.(employees[emp].id, name);
} }
// ---- Esportazione ---- // ---- Export ----
function requestExport(scope: 'all' | 'one') { function requestExport(scope: 'all' | 'one') {
menuOpen = false; menuOpen = false;
const pending = scope === 'all' ? issuesAll + infosAll : cur.issues + cur.infos; const pending = scope === 'all' ? issuesAll + infosAll : cur.issues + cur.infos;
@@ -165,7 +165,7 @@
$effect(() => () => clearTimeout(toastTimer)); $effect(() => () => clearTimeout(toastTimer));
// Testi dell'avviso di esportazione // Export warning dialog texts
const scopeIssues = $derived(confirmScope === 'all' ? issuesAll : cur.issues); const scopeIssues = $derived(confirmScope === 'all' ? issuesAll : cur.issues);
const scopeInfos = $derived(confirmScope === 'all' ? infosAll : cur.infos); const scopeInfos = $derived(confirmScope === 'all' ? infosAll : cur.infos);
const confirmSummary = $derived( const confirmSummary = $derived(
@@ -0,0 +1,12 @@
export interface NewEmployee {
id: number;
name: string;
}
export function isEmpty(name: string): boolean {
return !name.trim();
}
export function isEdited(name: string, original: string): boolean {
return !isEmpty(name) && name.trim() !== original;
}
@@ -147,10 +147,6 @@
</div> </div>
<style> <style>
.mono {
font-family: var(--ox-font-mono);
}
.table-box { .table-box {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -249,7 +245,7 @@
font-size: 13px; font-size: 13px;
} }
/* Celle orario */ /* Time cells */
.slot { .slot {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -356,7 +352,7 @@
background: rgba(77, 141, 255, 0.12); background: rgba(77, 141, 255, 0.12);
} }
/* Note */ /* Notes */
td.note { td.note {
font-size: 13px; font-size: 13px;
color: #6e6e6e; color: #6e6e6e;
@@ -70,7 +70,7 @@
</span> </span>
{/if} {/if}
<!-- Bottone diviso Esporta --> <!-- Split export button -->
<div class="split-wrap"> <div class="split-wrap">
<div class="split"> <div class="split">
<button type="button" class="split-main" onclick={onexportall}> <button type="button" class="split-main" onclick={onexportall}>
@@ -178,7 +178,7 @@
font-size: 13.5px; font-size: 13.5px;
} }
/* Bottone diviso */ /* Split button */
.split-wrap { .split-wrap {
position: relative; position: relative;
z-index: 21; z-index: 21;
+22 -31
View File
@@ -1,19 +1,20 @@
// Logica pura di analisi delle timbrature di un mese, estratta dal // Pure logic for analyzing a month's attendance records, extracted from
// componente di revisione per tenerlo leggibile. Le stringhe tradotte // the review component to keep it readable. Translated strings are
// vengono prodotte passando la funzione `t` (di solito `$_` di // produced by passing the `t` function (usually svelte-i18n's `$_`) and,
// svelte-i18n) e, dove serve la lingua per Intl, `locale` ($locale). // where Intl needs the language, `locale` ($locale).
import { weekdayName } from '$lib/month'; import { weekdayName } from '$lib/month';
import type { Shifts } from '$lib/shifts'; import type { Shifts } from '$lib/shifts';
import { initials } from '$lib/text';
import { fmt, formatTyped, hm, parseT } from '$lib/time'; import { fmt, formatTyped, hm, parseT } from '$lib/time';
export { formatTyped, hm, parseT }; export { formatTyped, hm, initials, parseT };
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches svelte-i18n's own loosely-typed `values` // 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; export type Translate = (key: string, options?: { values?: Record<string, any> }) => string;
/** [Entrata1[], Uscita1[], Entrata2[], Uscita2[]] — ogni casella è un /** [In1[], Out1[], In2[], Out2[]] — each slot is a list of "HH:MM" times,
* elenco di orari "HH:MM" per rappresentare anche le timbrature impilate. */ * to also represent stacked punches. */
export type Day = [string[], string[], string[], string[]]; export type Day = [string[], string[], string[], string[]];
export interface Employee { export interface Employee {
@@ -78,7 +79,7 @@ export function dayWord(n: number, t: Translate): string {
return t('review.day_word', { values: { n } }); return t('review.day_word', { values: { n } });
} }
/** [entrata, uscita] se il giorno ha solo 2 timbrature che scavalcano la pausa pranzo. */ /** [in, out] if the day has exactly 2 punches spanning the lunch break. */
export function lunchSpan(day: Day, shift: Shifts): [string, string] | null { export function lunchSpan(day: Day, shift: Shifts): [string, string] | null {
const all = day.flat().filter((x) => x !== ''); const all = day.flat().filter((x) => x !== '');
if (all.length !== 2) return null; if (all.length !== 2) return null;
@@ -92,12 +93,12 @@ export function lunchSpan(day: Day, shift: Shifts): [string, string] | null {
} }
/** /**
* Ridistribuisce timbrature finite tutte nello stesso turno (es. per un * Redistributes punches that all ended up in the same shift (e.g. from a
* timbratore configurato male) nelle 4 caselle corrette, scegliendo per * misconfigured time clock) into the correct 4 slots, picking for each one
* ciascuna l'orario previsto (E1/U1/E2/U2) più vicino. Dato che gli orari * the closest expected shift time (E1/U1/E2/U2). Since both the punches and
* sono ordinati e i turni previsti pure, basta confrontare ogni timbratura * the expected shifts are sorted, it's enough to compare each punch against
* col punto a metà strada fra due turni consecutivi: è esattamente il * the midpoint between two consecutive shifts: that's exactly the boundary
* confine fra "più vicino al turno prima" e "più vicino al turno dopo". * between "closer to the shift before" and "closer to the shift after".
*/ */
export function assignToShifts(times: string[], shift: Shifts): Day { 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 targets = [shift.in1, shift.out1, shift.in2, shift.out2].map((x) => parseT(x) as number);
@@ -147,7 +148,7 @@ export function analyze(
const miss: [boolean, boolean, boolean, boolean] = [false, false, false, false]; const miss: [boolean, boolean, boolean, boolean] = [false, false, false, false];
let issue = ''; let issue = '';
// 1. Turno sbagliato // 1. Wrong shift
if (stacked) { if (stacked) {
const total = lists.reduce((n, c) => n + c.length, 0); const total = lists.reduce((n, c) => n + c.length, 0);
const shiftLabel = filled[0] || filled[1] ? t('review.shift_1') : t('review.shift_2'); const shiftLabel = filled[0] || filled[1] ? t('review.shift_1') : t('review.shift_2');
@@ -156,9 +157,9 @@ export function analyze(
if (!filled[i]) miss[i] = true; if (!filled[i]) miss[i] = true;
}); });
} }
// 2. Orario non valido // 2. Invalid time
if (!issue && bad.some((c) => c.some(Boolean))) issue = t('review.issue_invalid_time'); if (!issue && bad.some((c) => c.some(Boolean))) issue = t('review.issue_invalid_time');
// 3. Pausa pranzo non timbrata // 3. Unpunched lunch break
const lunch = !stacked && !issue && lunchSpan(v, shift) !== null; const lunch = !stacked && !issue && lunchSpan(v, shift) !== null;
if (lunch) { if (lunch) {
issue = t('review.issue_lunch_missing'); issue = t('review.issue_lunch_missing');
@@ -166,7 +167,7 @@ export function analyze(
if (!filled[i]) miss[i] = true; if (!filled[i]) miss[i] = true;
}); });
} }
// 4-5. Coppie incomplete / uscita prima dell'entrata // 4-5. Incomplete pairs / clock-out before clock-in
if (!stacked && !lunch) { if (!stacked && !lunch) {
const p = lists.map((c) => (c.length ? parseT(c[0]) : null)); const p = lists.map((c) => (c.length ? parseT(c[0]) : null));
for (const [a, b, shiftKey] of [ for (const [a, b, shiftKey] of [
@@ -186,7 +187,7 @@ export function analyze(
} }
} }
// Ore: coppie per posizione dentro ciascun turno // Hours: pairs by position within each shift
let mins = 0; let mins = 0;
for (const [a, b] of [ for (const [a, b] of [
[0, 1], [0, 1],
@@ -206,7 +207,7 @@ export function analyze(
const wasStacked = o.some((c) => c.length > 1); const wasStacked = o.some((c) => c.length > 1);
const wasLunch = !lunch && lunchSpan(o, shift) !== null && v[1][0] === shift.out1 && v[2][0] === shift.in2; const wasLunch = !lunch && lunchSpan(o, shift) !== null && v[1][0] === shift.out1 && v[2][0] === shift.in2;
// 6. Info: orari non arrotondati // 6. Info: unrounded times
const unr = v.map((c) => c.map((x) => x !== '' && !isRounded(x, shift.round_step_min))); const unr = v.map((c) => c.map((x) => x !== '' && !isRounded(x, shift.round_step_min)));
const hasUnr = unr.some((c) => c.some(Boolean)); const hasUnr = unr.some((c) => c.some(Boolean));
const info = !issue && hasUnr; const info = !issue && hasUnr;
@@ -232,7 +233,7 @@ export function dayLabel(r: Row): string {
return `${r.dow} ${r.di + 1}`; return `${r.dow} ${r.di + 1}`;
} }
/** "dip-giorno-casella-indice" -> è l'orario attuale il risultato di un arrotondamento? */ /** "employee-day-slot-index" -> is the current time the result of a rounding? */
export function isRnd( export function isRnd(
rounded: Record<string, string>, rounded: Record<string, string>,
emp: number, emp: number,
@@ -271,16 +272,6 @@ export function cellClass(r: Row, ci: number, ii: number, rnd: boolean): string
return c; 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 { 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')}` : ''] return [iss ? `${dayWord(iss, t)} ${t('review.to_check')}` : '', inf ? `${dayWord(inf, t)} ${t('review.to_round')}` : '']
.filter(Boolean) .filter(Boolean)
@@ -2,7 +2,7 @@ import type { Day, Employee } from './analysis';
type Clock = 'FirstIn' | 'FirstOut' | 'SecondIn' | 'SecondOut'; type Clock = 'FirstIn' | 'FirstOut' | 'SecondIn' | 'SecondOut';
/** Un record come lo restituisce il comando Tauri `load_records`. */ /** A record as returned by the `load_records` Tauri command. */
export interface AttendanceRecord { export interface AttendanceRecord {
employee_id: number; employee_id: number;
clock: Clock; clock: Clock;
@@ -21,7 +21,12 @@ function timeOf(datetime: string): string {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; 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. */ /**
* Groups records by employee and day into the Day shape expected by
* TimesheetReview. Only employees with at least one record this month are
* included — `names` is just used to resolve a display name, not to decide
* who shows up (it may hold every employee ever cached, across all months).
*/
export function buildEmployees( export function buildEmployees(
year: number, year: number,
month: number, month: number,
@@ -31,13 +36,12 @@ export function buildEmployees(
const daysInMonth = new Date(year, month, 0).getDate(); const daysInMonth = new Date(year, month, 0).getDate();
const byEmployee = new Map<number, Day[]>(); const byEmployee = new Map<number, Day[]>();
for (const id of Object.keys(names)) {
byEmployee.set(Number(id), Array.from({ length: daysInMonth }, () => [[], [], [], []] as Day));
}
for (const record of records) { for (const record of records) {
const days = byEmployee.get(record.employee_id); let days = byEmployee.get(record.employee_id);
if (!days) continue; if (!days) {
days = Array.from({ length: daysInMonth }, () => [[], [], [], []] as Day);
byEmployee.set(record.employee_id, days);
}
const date = new Date(record.datetime); const date = new Date(record.datetime);
const day = days[date.getDate() - 1]; const day = days[date.getDate() - 1];
day[CLOCK_INDEX[record.clock]].push(timeOf(record.datetime)); day[CLOCK_INDEX[record.clock]].push(timeOf(record.datetime));
@@ -0,0 +1,407 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { autofocus } from '$lib/autofocus';
import { initials } from '$lib/text';
import IconButton from '../IconButton.svelte';
let {
employees,
total,
query = $bindable(''),
onclose,
onrename,
ondelete
}: {
employees: { id: string; name: string }[];
total: number;
query?: string;
onclose: () => void;
onrename: (id: string, name: string) => void;
ondelete: (id: string) => void;
} = $props();
let editId = $state<string | null>(null);
let draft = $state('');
let delId = $state<string | null>(null);
function startEdit(id: string, name: string) {
editId = id;
draft = name;
delId = null;
}
function saveEdit() {
const id = editId;
const name = draft.trim();
editId = null;
if (!id || !name) return;
onrename(id, name);
}
function onKey(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
if (editId) editId = null;
else if (delId) delId = null;
else onclose();
}
</script>
<svelte:window onkeydown={onKey} />
<div class="drawer-wrap">
<button type="button" class="backdrop" aria-label={$_('settings.close_aria')} onclick={onclose}></button>
<div class="drawer" role="dialog" aria-modal="true" aria-labelledby="employees-title">
<div class="drawer-head">
<div>
<h2 id="employees-title">{$_('settings.employees_title')}</h2>
<span class="drawer-sub">{$_('settings.employees_count', { values: { n: total } })}</span>
</div>
<IconButton ariaLabel={$_('settings.close_aria')} onclick={onclose}>
{#snippet icon()}
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
{/snippet}
</IconButton>
</div>
<div class="drawer-tools">
<div class="search">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="11" cy="11" r="6.5" /><path d="M20 20l-4-4" /></svg
>
<input
type="search"
placeholder={$_('review.search_employee')}
aria-label={$_('review.search_employee')}
bind:value={query}
/>
</div>
</div>
<div class="list scroll">
{#each employees as emp (emp.id)}
<div class="row" class:del={delId === emp.id}>
<span class="avatar">{initials(emp.name)}</span>
{#if editId === emp.id}
<input
class="name-in"
type="text"
aria-label={$_('settings.employee_name_aria', { values: { id: emp.id } })}
bind:value={draft}
use:autofocus
onkeydown={(e) => {
if (e.key === 'Enter') saveEdit();
}}
/>
<IconButton accent ariaLabel={$_('review.save_name')} onclick={saveEdit}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
>
{/snippet}
</IconButton>
<IconButton ariaLabel={$_('settings.cancel')} onclick={() => (editId = null)}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><path d="M6 6l12 12M18 6L6 18" /></svg
>
{/snippet}
</IconButton>
{:else if delId === emp.id}
<span class="del-text"
>{$_('settings.employee_remove_confirm', { values: { name: emp.name } })}</span
>
<button type="button" class="ghost" onclick={() => (delId = null)}>{$_('settings.cancel')}</button>
<button type="button" class="danger" onclick={() => ondelete(emp.id)}>{$_('settings.remove')}</button>
{:else}
<div class="info">
<span class="name">{emp.name}</span>
<span class="uid mono">#{emp.id}</span>
</div>
<IconButton
ariaLabel={$_('settings.edit_name_aria', { values: { name: emp.name } })}
onclick={() => startEdit(emp.id, emp.name)}
>
{#snippet icon()}
<svg width="16" height="16" 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
>
{/snippet}
</IconButton>
<IconButton
danger
ariaLabel={$_('settings.remove_name_aria', { values: { name: emp.name } })}
onclick={() => {
delId = emp.id;
editId = null;
}}
>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><path
d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3"
/></svg
>
{/snippet}
</IconButton>
{/if}
</div>
{:else}
<div class="empty">{$_('settings.no_employees_found')}</div>
{/each}
</div>
<div class="drawer-foot">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></svg
>
<span>{$_('settings.employees_local_note')}</span>
</div>
</div>
</div>
<style>
.drawer-wrap {
position: absolute;
inset: 0;
z-index: 30;
display: flex;
justify-content: flex-end;
}
.backdrop {
all: unset;
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
cursor: default;
}
.drawer {
position: relative;
width: 100%;
max-width: 460px;
height: 100%;
box-sizing: border-box;
background: #171717;
border-left: 1px solid #2e2e2e;
box-shadow: -24px 0 60px rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
}
.drawer-head {
flex: none;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 20px 16px;
}
.drawer-head h2 {
margin: 0 0 4px;
font-family: var(--ox-font-display);
font-weight: 700;
font-size: 22px;
letter-spacing: -0.02em;
color: #ffffff;
}
.drawer-sub {
font-size: 13px;
color: #8a8a8a;
}
.drawer-tools {
flex: none;
padding: 0 20px 12px;
}
.search {
height: 44px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 1px solid #333333;
border-radius: 12px;
color: #6e6e6e;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.search:focus-within {
border-color: var(--ox-accent);
box-shadow: 0 0 0 4px var(--ox-accent-ring);
}
.search input {
all: unset;
box-sizing: border-box;
width: 100%;
min-width: 0;
font-size: 14px;
color: #f2f2f2;
caret-color: var(--ox-accent);
}
.search input::placeholder {
color: #5e5e5e;
}
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 12px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
padding: 8px 8px 8px 12px;
border-radius: 12px;
transition: background 0.15s;
}
.row:hover {
background: #1c1c1c;
}
.row.del {
background: rgba(229, 72, 77, 0.08);
}
.avatar {
width: 34px;
height: 34px;
flex: none;
border-radius: 50%;
background: #262626;
color: #c9c9c9;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
}
.info {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
gap: 8px;
}
.name {
min-width: 0;
font-size: 14.5px;
color: #f2f2f2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.uid {
flex: none;
font-size: 13px;
font-weight: 300;
color: #6e6e6e;
}
.del-text {
flex: 1;
min-width: 0;
font-size: 13.5px;
color: #ffb3b8;
}
.name-in {
all: unset;
box-sizing: border-box;
flex: 1;
min-width: 0;
height: 36px;
padding: 0 10px;
border: 1px solid var(--ox-accent);
box-shadow: 0 0 0 3px var(--ox-accent-ring);
border-radius: 9px;
font-size: 14.5px;
color: #ffffff;
caret-color: var(--ox-accent);
}
.ghost,
.danger {
all: unset;
box-sizing: border-box;
height: 32px;
padding: 0 12px;
display: inline-flex;
align-items: center;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.ghost {
border: 1px solid #333333;
color: #e6e6e6;
}
.ghost:hover {
background: #1e1e1e;
color: #ffffff;
}
.danger {
background: #e5484d;
color: #ffffff;
font-weight: 600;
transition: filter 0.15s;
}
.danger:hover {
filter: brightness(1.08);
}
.ghost:focus-visible,
.danger:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
.empty {
padding: 40px 16px;
text-align: center;
font-size: 14px;
color: #8a8a8a;
}
.drawer-foot {
flex: none;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px 20px 18px;
border-top: 1px solid #262626;
font-size: 12.5px;
line-height: 1.5;
color: #8a8a8a;
}
.drawer-foot svg {
margin-top: 1px;
}
</style>
@@ -1,11 +1,15 @@
<script lang="ts"> <script lang="ts">
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { error, setError, clearError } from '$lib/error'; import { error, setError, clearError } from '$lib/error';
import type { Settings } from '$lib/settings'; import type { Settings } from '$lib/settings';
import { PRIMARY_READER_INDEX, type EmployeeCache, type EmployeeNames } from '$lib/employees';
import Field from '../Field.svelte'; import Field from '../Field.svelte';
import ErrorBanner from '../ErrorBanner.svelte'; import ErrorBanner from '../ErrorBanner.svelte';
import ActionButton from '../review/ActionButton.svelte'; import ActionButton from '../review/ActionButton.svelte';
import PillButton from '../PillButton.svelte';
import EmployeesDrawer from './EmployeesDrawer.svelte';
import { IPV4 } from './logic'; import { IPV4 } from './logic';
let { host, port, onsave }: { host: string; port: number; onsave: (host: string, port: number) => void } = let { host, port, onsave }: { host: string; port: number; onsave: (host: string, port: number) => void } =
@@ -18,6 +22,48 @@
let status = $state<'' | 'testing' | 'ok'>(''); let status = $state<'' | 'testing' | 'ok'>('');
let fullEmployeeCache = $state<EmployeeCache>({});
let employeeCache = $state<EmployeeNames>({});
let employeesOpen = $state(false);
let employeeQuery = $state('');
onMount(async () => {
fullEmployeeCache = await invoke<EmployeeCache>('load_employee_cache');
employeeCache = fullEmployeeCache[PRIMARY_READER_INDEX] ?? {};
});
const employeesList = $derived(
Object.entries(employeeCache)
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name))
);
const employeeCount = $derived(employeesList.length);
const visibleEmployees = $derived(
employeesList.filter(
(e) =>
!employeeQuery.trim() ||
e.name.toLowerCase().includes(employeeQuery.trim().toLowerCase()) ||
e.id.includes(employeeQuery.trim())
)
);
async function persistEmployees(next: EmployeeNames) {
employeeCache = next;
const nextFullCache = { ...fullEmployeeCache, [PRIMARY_READER_INDEX]: next };
fullEmployeeCache = nextFullCache;
await invoke('save_employee_cache', { cache: nextFullCache });
}
function renameEmployee(id: string, name: string) {
persistEmployees({ ...employeeCache, [id]: name });
}
function removeEmployee(id: string) {
const next = { ...employeeCache };
delete next[id];
persistEmployees(next);
}
function startEdit() { function startEdit() {
draftHost = host; draftHost = host;
draftPort = port; draftPort = port;
@@ -43,7 +89,7 @@
clearError(); clearError();
try { try {
const settings = await invoke<Settings>('load_settings'); const settings = await invoke<Settings>('load_settings');
await invoke('save_settings', { settings: { ...settings, reader: { host: v, port: nextPort } } }); await invoke('save_settings', { settings: { ...settings, readers: [{ host: v, port: nextPort }] } });
onsave(v, nextPort); onsave(v, nextPort);
} catch (err) { } catch (err) {
setError(err); setError(err);
@@ -106,18 +152,33 @@
</span> </span>
</div> </div>
<div class="dev-actions"> <div class="dev-actions">
<button type="button" class="pill" onclick={startEdit}> <PillButton onclick={startEdit}>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true" <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 ><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> {/snippet}
</button> {$_('settings.edit_address')}
<button type="button" class="pill" onclick={test} disabled={status === 'testing'}> </PillButton>
<PillButton onclick={test} disabled={status === 'testing'}>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true" <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 ><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> {/snippet}
</button> {$_('settings.test_connection')}
</PillButton>
<PillButton onclick={() => (employeesOpen = true)}>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="9" cy="8" r="3.5" /><path d="M2.5 20c.8-3.3 3.3-5 6.5-5s5.7 1.7 6.5 5" /><path
d="M16 4.5a3.5 3.5 0 0 1 0 7M18 15.2c1.8.7 3 2.3 3.5 4.8"
/></svg
>
{/snippet}
{$_('settings.manage_employees')}
<span class="count mono">{employeeCount}</span>
</PillButton>
</div> </div>
{:else} {:else}
<form <form
@@ -162,11 +223,18 @@
<ErrorBanner title={$_('errors.title')} message={$error} /> <ErrorBanner title={$_('errors.title')} message={$error} />
{/if} {/if}
<style> {#if employeesOpen}
.mono { <EmployeesDrawer
font-family: var(--ox-font-mono); employees={visibleEmployees}
} total={employeeCount}
bind:query={employeeQuery}
onclose={() => (employeesOpen = false)}
onrename={renameEmployee}
ondelete={removeEmployee}
/>
{/if}
<style>
.spin { .spin {
animation: spin 0.9s linear infinite; animation: spin 0.9s linear infinite;
} }
@@ -256,40 +324,10 @@
border-top: 1px solid #222222; border-top: 1px solid #222222;
} }
.pill { .count {
all: unset; margin-left: 6px;
box-sizing: border-box; font-size: 12.5px;
height: 36px; color: #6e6e6e;
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 { .dev-edit {
@@ -5,7 +5,7 @@ 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)$/; 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. */ /** The 4 time fields of `Shifts` — everything but `round_step_min`, which isn't a time. */
export type ShiftTimeKey = 'in1' | 'out1' | 'in2' | 'out2'; export type ShiftTimeKey = 'in1' | 'out1' | 'in2' | 'out2';
export const SHIFT_ROWS: { titleKey: string; a: ShiftTimeKey; b: ShiftTimeKey }[] = [ export const SHIFT_ROWS: { titleKey: string; a: ShiftTimeKey; b: ShiftTimeKey }[] = [
+10
View File
@@ -0,0 +1,10 @@
/** Employee names chosen by the user, by employee_id. */
export type EmployeeNames = Record<string, string>;
/** `EmployeeNames` by reader index, since an employee_id is only unique
* within one reader. */
export type EmployeeCache = Record<string, EmployeeNames>;
/** Only the first reader is used today (see `Settings.readers`), so this is
* the only bucket of the cache ever read or written. */
export const PRIMARY_READER_INDEX = '0';
+22 -1
View File
@@ -156,6 +156,27 @@
"save_saving": "Saving…", "save_saving": "Saving…",
"save_saved": "Changes saved", "save_saved": "Changes saved",
"save_invalid": "Not saved: fix the error", "save_invalid": "Not saved: fix the error",
"save_failed": "Save failed" "save_failed": "Save failed",
"employees_title": "Employees",
"employees_count": "{n, plural, one {1 employee} other {{n} employees}}",
"manage_employees": "Manage employees",
"close_aria": "Close",
"employee_name_aria": "Name of employee #{id}",
"edit_name_aria": "Edit {name}'s name",
"remove_name_aria": "Remove {name} from the cache",
"employee_remove_confirm": "Remove {name}? You'll be asked for their name again next time.",
"remove": "Remove",
"no_employees_found": "No employees found.",
"employees_local_note": "Edited names only apply within Oxibadge: the time clock is never written to."
},
"new_employees": {
"title": "New employees",
"subtitle": "{n, plural, one {The time clock found 1 new employee.} other {The time clock found {n} new employees.}}",
"hint": "Fix the names if needed: they only apply within Oxibadge.",
"missing_placeholder": "missing name",
"name_aria": "Name of employee #{id}",
"renamed_count": "{n, plural, one {1 name edited} other {{n} names edited}}",
"missing_count": "{n, plural, one {1 name missing} other {{n} names missing}}",
"continue": "Continue"
} }
} }
+22 -1
View File
@@ -156,6 +156,27 @@
"save_saving": "Salvataggio…", "save_saving": "Salvataggio…",
"save_saved": "Modifiche salvate", "save_saved": "Modifiche salvate",
"save_invalid": "Non salvato: correggi l'errore", "save_invalid": "Non salvato: correggi l'errore",
"save_failed": "Salvataggio non riuscito" "save_failed": "Salvataggio non riuscito",
"employees_title": "Dipendenti",
"employees_count": "{n, plural, one {1 dipendente} other {{n} dipendenti}}",
"manage_employees": "Gestisci dipendenti",
"close_aria": "Chiudi",
"employee_name_aria": "Nome del dipendente #{id}",
"edit_name_aria": "Modifica nome di {name}",
"remove_name_aria": "Rimuovi {name} dalla cache",
"employee_remove_confirm": "Rimuovere {name}? Al prossimo controllo verrà chiesto di nuovo il nome.",
"remove": "Rimuovi",
"no_employees_found": "Nessun dipendente trovato.",
"employees_local_note": "I nomi modificati valgono solo in Oxibadge: il timbratore non viene mai scritto."
},
"new_employees": {
"title": "Nuovi dipendenti",
"subtitle": "{n, plural, one {Sul timbratore c'è 1 dipendente nuovo.} other {Sul timbratore ci sono {n} dipendenti nuovi.}}",
"hint": "Correggi i nomi se serve: valgono solo in Oxibadge.",
"missing_placeholder": "nome mancante",
"name_aria": "Nome del dipendente #{id}",
"renamed_count": "{n, plural, one {1 nome modificato} other {{n} nomi modificati}}",
"missing_count": "{n, plural, one {1 nome mancante} other {{n} nomi mancanti}}",
"continue": "Continua"
} }
} }
+3 -1
View File
@@ -6,7 +6,9 @@ export interface Reader {
} }
export interface Settings { export interface Settings {
reader: Reader; /** The configured time clock (only the first one is used today, but this
* stays a list to support multiple readers in the future). */
readers: Reader[];
shifts: Shifts; shifts: Shifts;
lang: string | null; lang: string | null;
} }
+9
View File
@@ -0,0 +1,9 @@
export function initials(n: string): string {
return n
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
+1 -1
View File
@@ -11,7 +11,7 @@ export function hm(m: number): string {
return Math.floor(m / 60) + ':' + String(m % 60).padStart(2, '0'); return Math.floor(m / 60) + ':' + String(m % 60).padStart(2, '0');
} }
/** Formatta l'orario mentre si scrive, senza dover digitare ":". */ /** Formats a time as it's typed, without needing to type ":". */
export function formatTyped(raw: string, prev: string, inputType: string): string { export function formatTyped(raw: string, prev: string, inputType: string): string {
const deleting = inputType ? inputType.startsWith('delete') : raw.length < prev.length && prev.startsWith(raw); const deleting = inputType ? inputType.startsWith('delete') : raw.length < prev.length && prev.startsWith(raw);
const digits = raw.replace(/\D/g, '').slice(0, 4); const digits = raw.replace(/\D/g, '').slice(0, 4);
+58 -4
View File
@@ -11,18 +11,32 @@
import TimesheetReview from '$lib/components/TimesheetReview.svelte'; import TimesheetReview from '$lib/components/TimesheetReview.svelte';
import SettingsButton from '$lib/components/SettingsButton.svelte'; import SettingsButton from '$lib/components/SettingsButton.svelte';
import Settings from '$lib/components/Settings.svelte'; import Settings from '$lib/components/Settings.svelte';
import NewEmployees from '$lib/components/NewEmployees.svelte';
import { buildEmployees, type AttendanceRecord } from '$lib/components/review/fromRecords'; import { buildEmployees, type AttendanceRecord } from '$lib/components/review/fromRecords';
import type { NewEmployee } from '$lib/components/new-employees/logic';
import type { Shifts } from '$lib/shifts'; import type { Shifts } from '$lib/shifts';
import type { Settings as SettingsData } from '$lib/settings'; import type { Settings as SettingsData } from '$lib/settings';
import { PRIMARY_READER_INDEX, type EmployeeCache, type EmployeeNames } from '$lib/employees';
type Step = 'loading' | 'setup' | 'confirm-month' | 'loading-records' | 'loading-employees' | 'review' | 'settings'; type Step =
| 'loading'
| 'setup'
| 'confirm-month'
| 'loading-records'
| 'loading-employees'
| 'new-employees'
| 'review'
| 'settings';
let step = $state<Step>('loading'); let step = $state<Step>('loading');
let selectedYear = $state(0); let selectedYear = $state(0);
let selectedMonth = $state(0); let selectedMonth = $state(0);
let records = $state<AttendanceRecord[]>([]); let records = $state<AttendanceRecord[]>([]);
let fullEmployeeCache = $state<EmployeeCache>({});
let employeeCache = $state<EmployeeNames>({});
let employeeNames = $state<Record<string, string>>({}); let employeeNames = $state<Record<string, string>>({});
// Placeholder: sovrascritti da load_settings prima che la revisione sia raggiungibile. let newEmployees = $state<NewEmployee[]>([]);
// Placeholder: overwritten by load_settings before the review screen is reachable.
let shifts = $state<Shifts>({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 }); let shifts = $state<Shifts>({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 });
const period = $derived(`${monthName($locale, selectedMonth)} ${selectedYear}`); const period = $derived(`${monthName($locale, selectedMonth)} ${selectedYear}`);
@@ -31,7 +45,7 @@
onMount(async () => { onMount(async () => {
const settings = await invoke<SettingsData>('load_settings'); const settings = await invoke<SettingsData>('load_settings');
shifts = settings.shifts; shifts = settings.shifts;
step = settings.reader.host ? 'confirm-month' : 'setup'; step = settings.readers[0] ? 'confirm-month' : 'setup';
}); });
function openSettings() { function openSettings() {
@@ -52,13 +66,50 @@
try { try {
records = await invoke('load_records', selection); records = await invoke('load_records', selection);
step = 'loading-employees'; step = 'loading-employees';
employeeNames = await invoke('load_employees', { records });
fullEmployeeCache = await invoke<EmployeeCache>('load_employee_cache');
employeeCache = fullEmployeeCache[PRIMARY_READER_INDEX] ?? {};
const distinctIds = [...new Set(records.map((r: AttendanceRecord) => r.employee_id))];
const knownIds = distinctIds.filter((id) => String(id) in employeeCache);
const newIds = distinctIds.filter((id) => !(String(id) in employeeCache));
if (newIds.length > 0) {
const fetched = await invoke<Record<string, string>>('load_employees', {
records,
known: knownIds
});
newEmployees = newIds.map((id) => ({ id, name: fetched[String(id)] ?? '' }));
step = 'new-employees';
} else {
employeeNames = employeeCache;
step = 'review'; step = 'review';
}
} catch (err) { } catch (err) {
setError(err); setError(err);
step = 'confirm-month'; step = 'confirm-month';
} }
} }
/** Merges `updates` into the employee cache (local state + on disk) and returns the merged names. */
async function mergeEmployeeNames(updates: Record<string, string>): Promise<Record<string, string>> {
const merged = { ...employeeCache, ...updates };
employeeCache = merged;
employeeNames = merged;
const nextFullCache = { ...fullEmployeeCache, [PRIMARY_READER_INDEX]: merged };
fullEmployeeCache = nextFullCache;
await invoke('save_employee_cache', { cache: nextFullCache });
return merged;
}
async function onNewEmployeesContinue(list: { id: number; name: string }[]) {
const updates = Object.fromEntries(list.map((e) => [String(e.id), e.name]));
step = 'review';
await mergeEmployeeNames(updates);
}
async function onEmployeeRenamed(id: number, name: string) {
await mergeEmployeeNames({ [String(id)]: name });
}
</script> </script>
{#if step === 'review'} {#if step === 'review'}
@@ -69,7 +120,10 @@
shift={shifts} shift={shifts}
onback={() => (step = 'confirm-month')} onback={() => (step = 'confirm-month')}
onexport={(payload) => invoke('export_timesheet', { payload })} onexport={(payload) => invoke('export_timesheet', { payload })}
onrename={onEmployeeRenamed}
/> />
{:else if step === 'new-employees'}
<NewEmployees employees={newEmployees} oncontinue={onNewEmployeesContinue} />
{:else if step === 'settings'} {:else if step === 'settings'}
<Settings <Settings
onback={closeSettings} onback={closeSettings}