feat(oxibadge): cache employee names locally and let readers be a list
Check, format and test / build (push) Successful in 10m27s
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:
@@ -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())
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
mod employees;
|
||||
mod preferences;
|
||||
|
||||
use chrono::{Local, TimeZone};
|
||||
use preferences::Settings;
|
||||
use r701::{Record, R701};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tauri::AppHandle;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
@@ -18,6 +19,16 @@ fn save_settings(app: AppHandle, settings: Settings) -> Result<(), String> {
|
||||
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]
|
||||
async fn test_connection(host: String, port: u16) -> Result<(), String> {
|
||||
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]
|
||||
async fn load_records(app: AppHandle, year: i32, month: u32) -> Result<Vec<Record>, String> {
|
||||
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());
|
||||
}
|
||||
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 start = Local
|
||||
@@ -82,21 +93,26 @@ async fn load_records(app: AppHandle, year: i32, month: u32) -> Result<Vec<Recor
|
||||
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]
|
||||
async fn load_employees(
|
||||
app: AppHandle,
|
||||
records: Vec<Record>,
|
||||
known: Vec<u32>,
|
||||
) -> Result<HashMap<u32, String>, String> {
|
||||
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());
|
||||
}
|
||||
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();
|
||||
for record in &records {
|
||||
if employees.contains_key(&record.employee_id) {
|
||||
if known.contains(&record.employee_id) || employees.contains_key(&record.employee_id) {
|
||||
continue;
|
||||
}
|
||||
let name = r701
|
||||
@@ -153,6 +169,8 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
load_settings,
|
||||
save_settings,
|
||||
load_employee_cache,
|
||||
save_employee_cache,
|
||||
test_connection,
|
||||
load_records,
|
||||
load_employees,
|
||||
|
||||
@@ -19,7 +19,10 @@ pub struct Shifts {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
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,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub lang: Option<String>,
|
||||
@@ -28,10 +31,7 @@ pub struct Settings {
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
reader: Reader {
|
||||
host: String::new(),
|
||||
port: 5005,
|
||||
},
|
||||
readers: Vec::new(),
|
||||
shifts: Shifts {
|
||||
in1: "07:30".to_string(),
|
||||
out1: "12:00".to_string(),
|
||||
|
||||
@@ -60,6 +60,10 @@ h1 {
|
||||
color: var(--ox-text-strong);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--ox-font-mono);
|
||||
}
|
||||
|
||||
.red {
|
||||
color: #ff9ea3;
|
||||
}
|
||||
|
||||
@@ -311,7 +311,6 @@
|
||||
|
||||
.opt.mono {
|
||||
flex: none;
|
||||
font-family: var(--ox-font-mono);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
let {
|
||||
size = 'sm',
|
||||
accent = false,
|
||||
danger = false,
|
||||
title,
|
||||
ariaLabel,
|
||||
onclick,
|
||||
@@ -11,6 +12,7 @@
|
||||
}: {
|
||||
size?: 'sm' | 'lg';
|
||||
accent?: boolean;
|
||||
danger?: boolean;
|
||||
title?: string;
|
||||
ariaLabel: string;
|
||||
onclick: () => void;
|
||||
@@ -18,7 +20,16 @@
|
||||
} = $props();
|
||||
</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()}
|
||||
</button>
|
||||
|
||||
@@ -65,4 +76,9 @@
|
||||
.icon-btn.accent:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
background: rgba(229, 72, 77, 0.14);
|
||||
color: #ff8a8f;
|
||||
}
|
||||
</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 {
|
||||
await invoke('test_connection', { host: trimmedHost, port });
|
||||
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 });
|
||||
} catch (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>
|
||||
@@ -18,7 +18,7 @@
|
||||
let loaded = $state(false);
|
||||
let host = $state('');
|
||||
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 roundStepStr = $state('');
|
||||
let lang = $state<string | null>(null);
|
||||
@@ -29,8 +29,8 @@
|
||||
|
||||
onMount(async () => {
|
||||
const settings = await invoke<SettingsData>('load_settings');
|
||||
host = settings.reader.host;
|
||||
port = settings.reader.port;
|
||||
host = settings.readers[0]?.host ?? '';
|
||||
port = settings.readers[0]?.port ?? 5005;
|
||||
shifts = { ...settings.shifts };
|
||||
roundStepStr = String(settings.shifts.round_step_min);
|
||||
lang = settings.lang;
|
||||
@@ -44,7 +44,7 @@
|
||||
async function persist() {
|
||||
saveState = 'saving';
|
||||
const settings: SettingsData = {
|
||||
reader: { host, port },
|
||||
readers: [{ host, port }],
|
||||
shifts: { ...shifts, round_step_min: Number(roundStepStr) },
|
||||
lang
|
||||
};
|
||||
|
||||
@@ -43,17 +43,17 @@
|
||||
onback?: () => void;
|
||||
} = $props();
|
||||
|
||||
// Copie di lavoro: il componente non modifica le props. Per caricare un
|
||||
// altro mese va rimontato (nella nostra app accade già da sé, dato che
|
||||
// la schermata è dietro un {#if step === 'review'}). `untrack` rende
|
||||
// esplicito che leggiamo `employees` una sola volta, di proposito.
|
||||
// Working copies: the component never mutates the props. Loading another
|
||||
// month remounts it (already the case in this app, since this screen
|
||||
// sits behind a {#if step === 'review'}). `untrack` makes it explicit
|
||||
// that we read `employees` only once, on purpose.
|
||||
const orig: Day[][] = untrack(() => employees.map((e) => e.days.map(normDay)));
|
||||
let values = $state<Day[][]>(untrack(() => employees.map((e) => e.days.map(normDay))));
|
||||
let names = $state<string[]>(untrack(() => employees.map((e) => e.name)));
|
||||
/** "dip-giorno-casella-indice" -> orario prodotto dall'arrotondamento */
|
||||
/** "employee-day-slot-index" -> time produced by rounding */
|
||||
let rounded = $state<Record<string, string>>({});
|
||||
|
||||
// ---- Stato UI ----
|
||||
// ---- UI state ----
|
||||
let emp = $state(0);
|
||||
let query = $state('');
|
||||
let onlyIssues = $state(false);
|
||||
@@ -78,23 +78,23 @@
|
||||
);
|
||||
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 }) {
|
||||
const input = e.currentTarget;
|
||||
const cell = values[emp][di][ci];
|
||||
const prev = cell[ii] ?? '';
|
||||
const next = formatTyped(input.value, prev, (e as unknown as InputEvent).inputType ?? '');
|
||||
if (next === '' && cell.length > 1) {
|
||||
// Una delle timbrature impilate è stata svuotata a mano: la casella si
|
||||
// restringe invece di lasciare un campo vuoto appeso.
|
||||
// One of the stacked punches was cleared by hand: the cell shrinks
|
||||
// instead of leaving a dangling empty field.
|
||||
cell.splice(ii, 1);
|
||||
} else {
|
||||
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[]) {
|
||||
for (const di of dis) {
|
||||
const times = values[emp][di].flat().filter((x) => x !== '');
|
||||
@@ -121,13 +121,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Nome ----
|
||||
// ---- Name ----
|
||||
function renameCurrent(name: string) {
|
||||
names[emp] = name;
|
||||
onrename?.(employees[emp].id, name);
|
||||
}
|
||||
|
||||
// ---- Esportazione ----
|
||||
// ---- Export ----
|
||||
function requestExport(scope: 'all' | 'one') {
|
||||
menuOpen = false;
|
||||
const pending = scope === 'all' ? issuesAll + infosAll : cur.issues + cur.infos;
|
||||
@@ -165,7 +165,7 @@
|
||||
|
||||
$effect(() => () => clearTimeout(toastTimer));
|
||||
|
||||
// Testi dell'avviso di esportazione
|
||||
// Export warning dialog texts
|
||||
const scopeIssues = $derived(confirmScope === 'all' ? issuesAll : cur.issues);
|
||||
const scopeInfos = $derived(confirmScope === 'all' ? infosAll : cur.infos);
|
||||
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>
|
||||
|
||||
<style>
|
||||
.mono {
|
||||
font-family: var(--ox-font-mono);
|
||||
}
|
||||
|
||||
.table-box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -249,7 +245,7 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Celle orario */
|
||||
/* Time cells */
|
||||
.slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -356,7 +352,7 @@
|
||||
background: rgba(77, 141, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Note */
|
||||
/* Notes */
|
||||
td.note {
|
||||
font-size: 13px;
|
||||
color: #6e6e6e;
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Bottone diviso Esporta -->
|
||||
<!-- Split export button -->
|
||||
<div class="split-wrap">
|
||||
<div class="split">
|
||||
<button type="button" class="split-main" onclick={onexportall}>
|
||||
@@ -178,7 +178,7 @@
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
/* Bottone diviso */
|
||||
/* Split button */
|
||||
.split-wrap {
|
||||
position: relative;
|
||||
z-index: 21;
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
// Logica pura di analisi delle timbrature di un mese, estratta dal
|
||||
// componente di revisione per tenerlo leggibile. Le stringhe tradotte
|
||||
// vengono prodotte passando la funzione `t` (di solito `$_` di
|
||||
// svelte-i18n) e, dove serve la lingua per Intl, `locale` ($locale).
|
||||
// Pure logic for analyzing a month's attendance records, extracted from
|
||||
// the review component to keep it readable. Translated strings are
|
||||
// produced by passing the `t` function (usually svelte-i18n's `$_`) and,
|
||||
// where Intl needs the language, `locale` ($locale).
|
||||
|
||||
import { weekdayName } from '$lib/month';
|
||||
import type { Shifts } from '$lib/shifts';
|
||||
import { initials } from '$lib/text';
|
||||
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`
|
||||
export type Translate = (key: string, options?: { values?: Record<string, any> }) => string;
|
||||
|
||||
/** [Entrata1[], Uscita1[], Entrata2[], Uscita2[]] — ogni casella è un
|
||||
* elenco di orari "HH:MM" per rappresentare anche le timbrature impilate. */
|
||||
/** [In1[], Out1[], In2[], Out2[]] — each slot is a list of "HH:MM" times,
|
||||
* to also represent stacked punches. */
|
||||
export type Day = [string[], string[], string[], string[]];
|
||||
|
||||
export interface Employee {
|
||||
@@ -78,7 +79,7 @@ export function dayWord(n: number, t: Translate): string {
|
||||
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 {
|
||||
const all = day.flat().filter((x) => x !== '');
|
||||
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
|
||||
* 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".
|
||||
* Redistributes punches that all ended up in the same shift (e.g. from a
|
||||
* misconfigured time clock) into the correct 4 slots, picking for each one
|
||||
* the closest expected shift time (E1/U1/E2/U2). Since both the punches and
|
||||
* the expected shifts are sorted, it's enough to compare each punch against
|
||||
* the midpoint between two consecutive shifts: that's exactly the boundary
|
||||
* between "closer to the shift before" and "closer to the shift after".
|
||||
*/
|
||||
export function assignToShifts(times: string[], shift: Shifts): Day {
|
||||
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];
|
||||
let issue = '';
|
||||
|
||||
// 1. Turno sbagliato
|
||||
// 1. Wrong shift
|
||||
if (stacked) {
|
||||
const total = lists.reduce((n, c) => n + c.length, 0);
|
||||
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;
|
||||
});
|
||||
}
|
||||
// 2. Orario non valido
|
||||
// 2. 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;
|
||||
if (lunch) {
|
||||
issue = t('review.issue_lunch_missing');
|
||||
@@ -166,7 +167,7 @@ export function analyze(
|
||||
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) {
|
||||
const p = lists.map((c) => (c.length ? parseT(c[0]) : null));
|
||||
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;
|
||||
for (const [a, b] of [
|
||||
[0, 1],
|
||||
@@ -206,7 +207,7 @@ export function analyze(
|
||||
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;
|
||||
|
||||
// 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 hasUnr = unr.some((c) => c.some(Boolean));
|
||||
const info = !issue && hasUnr;
|
||||
@@ -232,7 +233,7 @@ export function dayLabel(r: Row): string {
|
||||
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(
|
||||
rounded: Record<string, string>,
|
||||
emp: number,
|
||||
@@ -271,16 +272,6 @@ export function cellClass(r: Row, ci: number, ii: number, rnd: boolean): string
|
||||
return c;
|
||||
}
|
||||
|
||||
export function initials(n: string): string {
|
||||
return n
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
export function summary(iss: number, inf: number, t: Translate): string {
|
||||
return [iss ? `${dayWord(iss, t)} ${t('review.to_check')}` : '', inf ? `${dayWord(inf, t)} ${t('review.to_round')}` : '']
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Day, Employee } from './analysis';
|
||||
|
||||
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 {
|
||||
employee_id: number;
|
||||
clock: Clock;
|
||||
@@ -21,7 +21,12 @@ function timeOf(datetime: string): string {
|
||||
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(
|
||||
year: number,
|
||||
month: number,
|
||||
@@ -31,13 +36,12 @@ export function buildEmployees(
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
const byEmployee = new Map<number, Day[]>();
|
||||
|
||||
for (const id of Object.keys(names)) {
|
||||
byEmployee.set(Number(id), Array.from({ length: daysInMonth }, () => [[], [], [], []] as Day));
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
const days = byEmployee.get(record.employee_id);
|
||||
if (!days) continue;
|
||||
let days = byEmployee.get(record.employee_id);
|
||||
if (!days) {
|
||||
days = Array.from({ length: daysInMonth }, () => [[], [], [], []] as Day);
|
||||
byEmployee.set(record.employee_id, days);
|
||||
}
|
||||
const date = new Date(record.datetime);
|
||||
const day = days[date.getDate() - 1];
|
||||
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">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { error, setError, clearError } from '$lib/error';
|
||||
import type { Settings } from '$lib/settings';
|
||||
import { PRIMARY_READER_INDEX, type EmployeeCache, type EmployeeNames } from '$lib/employees';
|
||||
import Field from '../Field.svelte';
|
||||
import ErrorBanner from '../ErrorBanner.svelte';
|
||||
import ActionButton from '../review/ActionButton.svelte';
|
||||
import PillButton from '../PillButton.svelte';
|
||||
import EmployeesDrawer from './EmployeesDrawer.svelte';
|
||||
import { IPV4 } from './logic';
|
||||
|
||||
let { host, port, onsave }: { host: string; port: number; onsave: (host: string, port: number) => void } =
|
||||
@@ -18,6 +22,48 @@
|
||||
|
||||
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() {
|
||||
draftHost = host;
|
||||
draftPort = port;
|
||||
@@ -43,7 +89,7 @@
|
||||
clearError();
|
||||
try {
|
||||
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);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
@@ -106,18 +152,33 @@
|
||||
</span>
|
||||
</div>
|
||||
<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"
|
||||
><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'}>
|
||||
{/snippet}
|
||||
{$_('settings.edit_address')}
|
||||
</PillButton>
|
||||
<PillButton onclick={test} disabled={status === 'testing'}>
|
||||
{#snippet icon()}
|
||||
<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>
|
||||
{/snippet}
|
||||
{$_('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>
|
||||
{:else}
|
||||
<form
|
||||
@@ -162,11 +223,18 @@
|
||||
<ErrorBanner title={$_('errors.title')} message={$error} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.mono {
|
||||
font-family: var(--ox-font-mono);
|
||||
}
|
||||
{#if employeesOpen}
|
||||
<EmployeesDrawer
|
||||
employees={visibleEmployees}
|
||||
total={employeeCount}
|
||||
bind:query={employeeQuery}
|
||||
onclose={() => (employeesOpen = false)}
|
||||
onrename={renameEmployee}
|
||||
ondelete={removeEmployee}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.spin {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@@ -256,40 +324,10 @@
|
||||
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;
|
||||
.count {
|
||||
margin-left: 6px;
|
||||
font-size: 12.5px;
|
||||
color: #6e6e6e;
|
||||
}
|
||||
|
||||
.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)$/;
|
||||
|
||||
/** 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 const SHIFT_ROWS: { titleKey: string; a: ShiftTimeKey; b: ShiftTimeKey }[] = [
|
||||
|
||||
@@ -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';
|
||||
@@ -156,6 +156,27 @@
|
||||
"save_saving": "Saving…",
|
||||
"save_saved": "Changes saved",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,27 @@
|
||||
"save_saving": "Salvataggio…",
|
||||
"save_saved": "Modifiche salvate",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ export interface Reader {
|
||||
}
|
||||
|
||||
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;
|
||||
lang: string | null;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -11,7 +11,7 @@ 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 ":". */
|
||||
/** Formats a time as it's typed, without needing to type ":". */
|
||||
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);
|
||||
|
||||
@@ -11,18 +11,32 @@
|
||||
import TimesheetReview from '$lib/components/TimesheetReview.svelte';
|
||||
import SettingsButton from '$lib/components/SettingsButton.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 type { NewEmployee } from '$lib/components/new-employees/logic';
|
||||
import type { Shifts } from '$lib/shifts';
|
||||
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 selectedYear = $state(0);
|
||||
let selectedMonth = $state(0);
|
||||
let records = $state<AttendanceRecord[]>([]);
|
||||
let fullEmployeeCache = $state<EmployeeCache>({});
|
||||
let employeeCache = $state<EmployeeNames>({});
|
||||
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 });
|
||||
|
||||
const period = $derived(`${monthName($locale, selectedMonth)} ${selectedYear}`);
|
||||
@@ -31,7 +45,7 @@
|
||||
onMount(async () => {
|
||||
const settings = await invoke<SettingsData>('load_settings');
|
||||
shifts = settings.shifts;
|
||||
step = settings.reader.host ? 'confirm-month' : 'setup';
|
||||
step = settings.readers[0] ? 'confirm-month' : 'setup';
|
||||
});
|
||||
|
||||
function openSettings() {
|
||||
@@ -52,13 +66,50 @@
|
||||
try {
|
||||
records = await invoke('load_records', selection);
|
||||
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';
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
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>
|
||||
|
||||
{#if step === 'review'}
|
||||
@@ -69,7 +120,10 @@
|
||||
shift={shifts}
|
||||
onback={() => (step = 'confirm-month')}
|
||||
onexport={(payload) => invoke('export_timesheet', { payload })}
|
||||
onrename={onEmployeeRenamed}
|
||||
/>
|
||||
{:else if step === 'new-employees'}
|
||||
<NewEmployees employees={newEmployees} oncontinue={onNewEmployeesContinue} />
|
||||
{:else if step === 'settings'}
|
||||
<Settings
|
||||
onback={closeSettings}
|
||||
|
||||
Reference in New Issue
Block a user