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;
|
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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}>
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
|
{#snippet icon()}
|
||||||
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
|
<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>
|
{/snippet}
|
||||||
<button type="button" class="pill" onclick={test} disabled={status === 'testing'}>
|
{$_('settings.edit_address')}
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
|
</PillButton>
|
||||||
><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01" /></svg
|
<PillButton onclick={test} disabled={status === 'testing'}>
|
||||||
>
|
{#snippet icon()}
|
||||||
<span>{$_('settings.test_connection')}</span>
|
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
</button>
|
><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01" /></svg
|
||||||
|
>
|
||||||
|
{/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>
|
</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 }[] = [
|
||||||
|
|||||||
@@ -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_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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
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);
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
step = 'review';
|
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) {
|
} 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}
|
||||||
|
|||||||
Reference in New Issue
Block a user