feat(oxibadge): implement the review screen's export to ODS
Check, format and test / build (push) Successful in 11m23s

Replaces the export button's placeholder (which only dbg!-printed the
payload) with a real implementation: one employee exports a single
.ods file, more than one bundles a .zip of one .ods per employee,
written wherever the user picks via the native "save" dialog
(tauri-plugin-dialog).

The "hours worked" column is a live ODF formula with a cached fallback
value, not just a static number, so the file stays correct if edited
afterward in LibreOffice; the formula and the cached value both treat
a day's own missing punches as 0 in the subtraction, matching how a
day with only an entry and an exit (no lunch break) still nets out to
the right span. Column headers are sent by the frontend (translated
via its own i18n), not hardcoded in Rust. A stacked slot (more than
one punch the device recorded in the same shift) exports every punch,
one per line in the cell, instead of only the first.

Also closes a real gap surfaced while building this: the new-employees
screen already blocked "Continue" for a blank name, but not for a name
the device sent as invalid UTF-8 (lossily converted to text containing
the Unicode replacement character on the Rust side) — such a name is
now flagged and blocks continuing too, so every employee reaching the
review/export flow is guaranteed to have a real, displayable name.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 08:48:05 +02:00
parent 5dbb3f51f1
commit d661680539
13 changed files with 1183 additions and 62 deletions
@@ -1,7 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { _ } from 'svelte-i18n';
import { isEdited, isEmpty, type NewEmployee } from './new-employees/logic';
import { isEdited, isEmpty, isInvalid, type NewEmployee } from './new-employees/logic';
let {
employees,
@@ -17,12 +17,13 @@
const count = $derived(employees.length);
const empties = $derived(names.filter((n) => isEmpty(n)).length);
const invalids = $derived(names.filter((n) => isInvalid(n)).length);
const renamed = $derived(names.filter((n, i) => isEdited(n, employees[i].name)).length);
const inputs: HTMLInputElement[] = [];
function submit() {
if (empties > 0) return;
if (empties > 0 || invalids > 0) return;
oncontinue?.(employees.map((e, i) => ({ id: e.id, name: names[i].trim(), original: e.name })));
}
@@ -47,6 +48,7 @@
<div class="list scroll">
{#each employees as emp, i (emp.id)}
{@const empty = isEmpty(names[i])}
{@const invalid = isInvalid(names[i])}
{@const edited = isEdited(names[i], emp.name)}
<div class="item">
<span class="uid mono">#{emp.id}</span>
@@ -55,10 +57,12 @@
class="name"
class:edit={edited}
class:empty
class:invalid
type="text"
placeholder={$_('new_employees.missing_placeholder')}
title={invalid ? $_('new_employees.invalid_name_title') : undefined}
aria-label={$_('new_employees.name_aria', { values: { id: emp.id } })}
aria-invalid={empty}
aria-invalid={empty || invalid}
bind:value={names[i]}
onkeydown={(e) => onKeydown(e, i)}
/>
@@ -72,8 +76,11 @@
{#if empties > 0}
<span class="red">{$_('new_employees.missing_count', { values: { n: empties } })}</span>
{/if}
{#if invalids > 0}
<span class="red">{$_('new_employees.invalid_count', { values: { n: invalids } })}</span>
{/if}
</div>
<button type="button" class="btn" disabled={empties > 0} onclick={submit}>
<button type="button" class="btn" disabled={empties > 0 || invalids > 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>
@@ -200,12 +207,14 @@
background: #161616;
}
.name.empty {
.name.empty,
.name.invalid {
border: 1px dashed #a8383d;
background: rgba(229, 72, 77, 0.08);
}
.name.empty:focus {
.name.empty:focus,
.name.invalid:focus {
border-style: solid;
border-color: #e5484d;
box-shadow: 0 0 0 3px rgba(229, 72, 77, 0.18);
@@ -27,6 +27,8 @@
import Toast from './review/Toast.svelte';
import ActionButton from './ActionButton.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import ErrorBanner from './ErrorBanner.svelte';
import { error, setError, clearError } from '$lib/error';
let {
year,
@@ -42,7 +44,14 @@
month: number;
employees: Employee[];
shift: Shifts;
onexport?: (p: { scope: 'all' | 'one'; year: number; month: number; employees: Employee[] }) => void;
/** Returns `false` if the user cancelled (e.g. the save dialog), in which case nothing changes. */
onexport?: (p: {
scope: 'all' | 'one';
year: number;
month: number;
employees: Employee[];
headers: { date: string; in1: string; out1: string; in2: string; out2: string; total: string };
}) => Promise<boolean>;
onrename?: (id: Employee['id'], name: string) => void;
onback?: () => void;
/** Called whenever there are (or stop being) edits not yet exported. */
@@ -74,6 +83,7 @@
let menuOpen = $state(false);
let confirmScope = $state<'all' | 'one' | null>(null);
let confirmingUndoAll = $state(false);
let exporting = $state(false);
let toast = $state('');
let toastTimer: ReturnType<typeof setTimeout> | undefined;
@@ -191,10 +201,11 @@
else doExport(scope);
}
function doExport(scope: 'all' | 'one') {
async function doExport(scope: 'all' | 'one') {
if (exporting) return;
const pick = scope === 'all' ? names.map((_name, i) => i) : [emp];
const snap = $state.snapshot(values);
onexport?.({
const payload = {
scope,
year,
month,
@@ -202,22 +213,41 @@
id: employees[ei].id,
name: names[ei],
days: snap[ei].map((d) => d.map((c) => c.filter((x) => x !== ''))) as Day[]
}))
});
confirmScope = null;
menuOpen = false;
hasUnexportedChanges = false;
toast = $_('review.export_done', {
values: { who: scope === 'all' ? $_('review.everyone') : names[emp] }
});
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast = ''), 3200);
})),
headers: {
date: $_('export.col_date'),
in1: $_('export.col_in1'),
out1: $_('export.col_out1'),
in2: $_('export.col_in2'),
out2: $_('export.col_out2'),
total: $_('export.col_total')
}
};
exporting = true;
clearError();
try {
const exported = await onexport?.(payload);
if (!exported) return; // the user cancelled (e.g. closed the save dialog)
confirmScope = null;
menuOpen = false;
hasUnexportedChanges = false;
toast = $_('review.export_done', {
values: { who: scope === 'all' ? $_('review.everyone') : names[emp] }
});
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast = ''), 3200);
} catch (err) {
setError(err);
} finally {
exporting = false;
}
}
function roundAndExport(scope: 'all' | 'one') {
async function roundAndExport(scope: 'all' | 'one') {
const eis = scope === 'all' ? names.map((_name, i) => i) : [emp];
for (const ei of eis) roundDaysOf(ei, all[ei].roundDays);
doExport(scope);
await doExport(scope);
}
$effect(() => () => clearTimeout(toastTimer));
@@ -357,6 +387,10 @@
edits={cur.edits}
/>
{#if $error}
<ErrorBanner title={$_('errors.export_title')} message={$error} />
{/if}
<div class="table-card">
<Toolbar
bind:onlyIssues
@@ -7,6 +7,13 @@ export function isEmpty(name: string): boolean {
return !name.trim();
}
/** True if `name` contains the Unicode replacement character — the device
* sent bytes that aren't valid UTF-8, which the backend lossily converts
* rather than fail the whole fetch over one bad name. */
export function isInvalid(name: string): boolean {
return name.includes('�');
}
export function isEdited(name: string, original: string): boolean {
return !isEmpty(name) && name.trim() !== original;
}
+12 -1
View File
@@ -32,7 +32,8 @@
"description": "Reading the employee names registered on the time clock. Just a moment."
},
"errors": {
"title": "Could not reach the time clock"
"title": "Could not reach the time clock",
"export_title": "Export failed"
},
"review": {
"title": "Clock-in review",
@@ -206,6 +207,16 @@
"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}}",
"invalid_count": "{n, plural, one {1 invalid name} other {{n} invalid names}}",
"invalid_name_title": "The time clock sent unreadable characters: fix the name",
"continue": "Continue"
},
"export": {
"col_date": "Date",
"col_in1": "Entry 1",
"col_out1": "Exit 1",
"col_in2": "Entry 2",
"col_out2": "Exit 2",
"col_total": "Hours worked"
}
}
+12 -1
View File
@@ -32,7 +32,8 @@
"description": "Sto leggendo i nomi dei dipendenti registrati sul timbratore. Ci vuole solo un attimo."
},
"errors": {
"title": "Impossibile raggiungere il timbratore"
"title": "Impossibile raggiungere il timbratore",
"export_title": "Esportazione non riuscita"
},
"review": {
"title": "Revisione timbrature",
@@ -206,6 +207,16 @@
"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}}",
"invalid_count": "{n, plural, one {1 nome non valido} other {{n} nomi non validi}}",
"invalid_name_title": "Il timbratore ha inviato caratteri non leggibili: correggi il nome",
"continue": "Continua"
},
"export": {
"col_date": "Data",
"col_in1": "Ingresso 1",
"col_out1": "Uscita 1",
"col_in2": "Ingresso 2",
"col_out2": "Uscita 2",
"col_total": "Ore lavorate"
}
}
+25 -1
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { save } from '@tauri-apps/plugin-dialog';
import { onMount } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { monthName } from '$lib/month';
import { setError, clearError } from '$lib/error';
import type { Employee } from '$lib/components/review/analysis';
import Screen from '$lib/components/Screen.svelte';
import Onboarding from '$lib/components/Onboarding.svelte';
import ConfirmMonth from '$lib/components/ConfirmMonth.svelte';
@@ -143,6 +145,28 @@
async function onEmployeeRenamed(id: number, name: string) {
await mergeEmployeeNames({ [String(id)]: name });
}
/** Lets the user pick a destination (native "save" dialog) and writes the export there.
* One employee exports a single `.ods`; more than one bundles a `.zip` of `.ods` files.
* Returns `false` without writing anything if the user cancels the dialog. */
async function onExport(payload: {
scope: 'all' | 'one';
year: number;
month: number;
employees: Employee[];
headers: { date: string; in1: string; out1: string; in2: string; out2: string; total: string };
}): Promise<boolean> {
const multi = payload.employees.length > 1;
const extension = multi ? 'zip' : 'ods';
const who = multi ? $_('review.all_employees') : payload.employees[0].name;
const path = await save({
defaultPath: `${monthName($locale, payload.month)} ${payload.year} - ${who}.${extension}`,
filters: [{ name: extension.toUpperCase(), extensions: [extension] }]
});
if (!path) return false;
await invoke('export_timesheet', { payload, path });
return true;
}
</script>
{#if step === 'review'}
@@ -152,7 +176,7 @@
employees={reviewEmployees}
shift={shifts}
onback={() => requestLeaveReview(() => (step = 'confirm-month'))}
onexport={(payload) => invoke('export_timesheet', { payload })}
onexport={onExport}
onrename={onEmployeeRenamed}
ondirtychange={(dirty) => (reviewDirty = dirty)}
/>