feat(oxibadge): add settings screen for shifts, rounding and language
Check, format and test / build (push) Successful in 10m22s
Check, format and test / build (push) Successful in 10m22s
Add a settings screen (gear button on the month-confirmation screen) to configure the expected shift times, the rounding step, the interface language, and the time clock address — replacing the old onboarding-only connection flow. Settings persist to the same preferences.toml as the reader address, under a single load_settings/ save_settings command pair. The review screen's "fix shifts" action now redistributes stacked punches by nearest configured shift time instead of blind position, and a stacked cell manually cleared down to one entry no longer leaves a dangling empty input. Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export function autofocus(node: HTMLInputElement) {
|
||||
node.focus();
|
||||
node.select();
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
value = $bindable(),
|
||||
type = 'text',
|
||||
placeholder = '',
|
||||
maxlength,
|
||||
inputmode,
|
||||
err = false,
|
||||
suffix,
|
||||
oninput,
|
||||
icon
|
||||
}: {
|
||||
@@ -15,15 +19,30 @@
|
||||
value: string | number;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
oninput?: () => void;
|
||||
maxlength?: number;
|
||||
inputmode?: 'text' | 'numeric' | 'decimal' | 'none' | 'tel' | 'search' | 'email' | 'url';
|
||||
err?: boolean;
|
||||
suffix?: string;
|
||||
oninput?: (e: Event & { currentTarget: HTMLInputElement }) => void;
|
||||
icon?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
<div class="field" class:err>
|
||||
<label for={id}>{label}</label>
|
||||
{#if icon}{@render icon()}{/if}
|
||||
<input {id} {type} {placeholder} bind:value {oninput} autocomplete="off" spellcheck="false" />
|
||||
<input
|
||||
{id}
|
||||
{type}
|
||||
{placeholder}
|
||||
{maxlength}
|
||||
{inputmode}
|
||||
bind:value
|
||||
{oninput}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
{#if suffix}<span class="suffix">{suffix}</span>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -52,6 +71,20 @@
|
||||
box-shadow: 0 0 0 4px var(--ox-accent-ring);
|
||||
}
|
||||
|
||||
.field.err,
|
||||
.field.err:hover,
|
||||
.field.err:focus-within {
|
||||
border-color: #e5484d;
|
||||
}
|
||||
|
||||
.field.err:focus-within {
|
||||
box-shadow: 0 0 0 4px rgba(229, 72, 77, 0.18);
|
||||
}
|
||||
|
||||
.field.err label {
|
||||
color: #ff8a8f;
|
||||
}
|
||||
|
||||
.field label {
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
@@ -93,4 +126,10 @@
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
color: var(--ox-text-subtle);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { error, setError, clearError } from '$lib/error';
|
||||
import type { Settings } from '$lib/settings';
|
||||
import Column from './Column.svelte';
|
||||
import Intro from './Intro.svelte';
|
||||
import Field from './Field.svelte';
|
||||
@@ -28,7 +29,8 @@
|
||||
clearError();
|
||||
try {
|
||||
await invoke('test_connection', { host: trimmedHost, port });
|
||||
await invoke('save_preferences', { host: trimmedHost, port });
|
||||
const settings = await invoke<Settings>('load_settings');
|
||||
await invoke('save_settings', { settings: { ...settings, reader: { host: trimmedHost, port } } });
|
||||
onconnected({ host: trimmedHost, port });
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { onMount } from 'svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import IconButton from './IconButton.svelte';
|
||||
import ReaderCard from './settings/ReaderCard.svelte';
|
||||
import ShiftsCard from './settings/ShiftsCard.svelte';
|
||||
import RoundCard from './settings/RoundCard.svelte';
|
||||
import LanguageSection from './settings/LanguageSection.svelte';
|
||||
import { checkShifts, roundValid, type ShiftTimeKey, type Shifts } from './settings/logic';
|
||||
import type { Settings as SettingsData } from '$lib/settings';
|
||||
|
||||
let { onback, onsettingschange }: { onback?: () => void; onsettingschange?: (s: SettingsData) => void } =
|
||||
$props();
|
||||
|
||||
const SAVE_DELAY = 700;
|
||||
|
||||
let loaded = $state(false);
|
||||
let host = $state('');
|
||||
let port = $state(5005);
|
||||
// Placeholder: sovrascritti da load_settings prima che le card siano renderizzate ({#if loaded}).
|
||||
let shifts = $state<Shifts>({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 });
|
||||
let roundStepStr = $state('');
|
||||
let lang = $state<string | null>(null);
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'invalid' | 'failed';
|
||||
let saveState = $state<SaveState>('idle');
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
onMount(async () => {
|
||||
const settings = await invoke<SettingsData>('load_settings');
|
||||
host = settings.reader.host;
|
||||
port = settings.reader.port;
|
||||
shifts = { ...settings.shifts };
|
||||
roundStepStr = String(settings.shifts.round_step_min);
|
||||
lang = settings.lang;
|
||||
loaded = true;
|
||||
});
|
||||
|
||||
function settingsValid() {
|
||||
return !checkShifts(shifts).errorKey && roundValid(roundStepStr);
|
||||
}
|
||||
|
||||
async function persist() {
|
||||
saveState = 'saving';
|
||||
const settings: SettingsData = {
|
||||
reader: { host, port },
|
||||
shifts: { ...shifts, round_step_min: Number(roundStepStr) },
|
||||
lang
|
||||
};
|
||||
try {
|
||||
await invoke('save_settings', { settings });
|
||||
onsettingschange?.(settings);
|
||||
saveState = 'saved';
|
||||
} catch {
|
||||
saveState = 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSave(delay: number) {
|
||||
clearTimeout(saveTimer);
|
||||
if (!settingsValid()) {
|
||||
saveState = 'invalid';
|
||||
return;
|
||||
}
|
||||
saveState = 'saving';
|
||||
saveTimer = setTimeout(persist, delay);
|
||||
}
|
||||
|
||||
$effect(() => () => clearTimeout(saveTimer));
|
||||
|
||||
function onShiftInput(key: ShiftTimeKey, value: string) {
|
||||
shifts[key] = value;
|
||||
scheduleSave(SAVE_DELAY);
|
||||
}
|
||||
|
||||
function onRoundInput(value: string) {
|
||||
roundStepStr = value;
|
||||
scheduleSave(SAVE_DELAY);
|
||||
}
|
||||
|
||||
function onLangChange(next: string) {
|
||||
lang = next;
|
||||
locale.set(next);
|
||||
scheduleSave(0);
|
||||
}
|
||||
|
||||
function onAddressSaved(newHost: string, newPort: number) {
|
||||
host = newHost;
|
||||
port = newPort;
|
||||
}
|
||||
|
||||
const saveInfo = $derived(
|
||||
{
|
||||
idle: [$_('settings.save_idle'), '#6e6e6e'],
|
||||
saving: [$_('settings.save_saving'), '#a3a3a3'],
|
||||
saved: [$_('settings.save_saved'), '#4cc38a'],
|
||||
invalid: [$_('settings.save_invalid'), '#ff9ea3'],
|
||||
failed: [$_('settings.save_failed'), '#ff9ea3']
|
||||
}[saveState]
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
<header class="topbar">
|
||||
<div class="tb-left">
|
||||
{#if onback}
|
||||
<IconButton size="lg" ariaLabel={$_('review.back_to_month')} onclick={onback}>
|
||||
{#snippet icon()}
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><path d="M15 6l-6 6 6 6" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
{/if}
|
||||
<h1>{$_('settings.title')}</h1>
|
||||
</div>
|
||||
<span class="save-state" role="status" aria-live="polite" style="color: {saveInfo[1]}">
|
||||
{#if saveState === 'saving'}
|
||||
<svg class="spin" width="15" height="15" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
|
||||
><path d="M12 3a9 9 0 1 0 9 9" /></svg
|
||||
>
|
||||
{:else if saveState === 'saved'}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.2"
|
||||
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
|
||||
>
|
||||
{:else if saveState === 'invalid' || saveState === 'failed'}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><circle cx="12" cy="12" r="9" /><path d="M12 7.5v5.5M12 16.5h.01" /></svg
|
||||
>
|
||||
{/if}
|
||||
{saveInfo[0]}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="content scroll">
|
||||
<div class="column">
|
||||
{#if loaded}
|
||||
<section aria-labelledby="s-dev">
|
||||
<div class="sec-head">
|
||||
<h2 id="s-dev">{$_('settings.device_title')}</h2>
|
||||
<p>{$_('settings.device_desc')}</p>
|
||||
</div>
|
||||
<ReaderCard {host} {port} onsave={onAddressSaved} />
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="s-shift">
|
||||
<div class="sec-head">
|
||||
<h2 id="s-shift">{$_('settings.shifts_title')}</h2>
|
||||
<p>{$_('settings.shifts_desc')}</p>
|
||||
</div>
|
||||
<ShiftsCard {shifts} oninput={onShiftInput} />
|
||||
<RoundCard roundStep={roundStepStr} oninput={onRoundInput} />
|
||||
</section>
|
||||
|
||||
<LanguageSection lang={(lang ?? $locale ?? 'it').split('-')[0]} onchange={onLangChange} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spin {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.screen {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
min-height: 640px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--ox-bg);
|
||||
color: var(--ox-text);
|
||||
font-family: var(--ox-font-body);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex: none;
|
||||
min-height: 76px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #222222;
|
||||
}
|
||||
|
||||
.tb-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: var(--ox-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 26px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.025em;
|
||||
color: var(--ox-text-strong);
|
||||
}
|
||||
|
||||
.save-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.column {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
padding: 32px 24px 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.sec-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sec-head h2 {
|
||||
margin: 0;
|
||||
font-family: var(--ox-font-display);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.02em;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sec-head p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
let { ariaLabel, onclick }: { ariaLabel: string; onclick: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<button type="button" class="gear" aria-label={ariaLabel} title={ariaLabel} {onclick}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.gear {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 4;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
color: var(--ox-text-subtle);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.gear:hover {
|
||||
background: #1e1e1e;
|
||||
color: var(--ox-text-strong);
|
||||
}
|
||||
|
||||
.gear:focus-visible {
|
||||
outline: 2px solid var(--ox-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,17 +2,17 @@
|
||||
import { untrack } from 'svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { monthName } from '$lib/month';
|
||||
import type { Shifts } from '$lib/shifts';
|
||||
import {
|
||||
type Day,
|
||||
type Employee,
|
||||
LUNCH,
|
||||
analyze,
|
||||
assignToShifts,
|
||||
dayWord,
|
||||
formatTyped,
|
||||
hm,
|
||||
lunchSpan,
|
||||
normDay,
|
||||
parseT,
|
||||
roundT,
|
||||
shortSummary,
|
||||
summary
|
||||
@@ -29,6 +29,7 @@
|
||||
year,
|
||||
month,
|
||||
employees,
|
||||
shift,
|
||||
onexport,
|
||||
onrename,
|
||||
onback
|
||||
@@ -36,6 +37,7 @@
|
||||
year: number;
|
||||
month: number;
|
||||
employees: Employee[];
|
||||
shift: Shifts;
|
||||
onexport?: (p: { scope: 'all' | 'one'; year: number; month: number; employees: Employee[] }) => void;
|
||||
onrename?: (id: Employee['id'], name: string) => void;
|
||||
onback?: () => void;
|
||||
@@ -62,7 +64,9 @@
|
||||
let toast = $state('');
|
||||
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const all = $derived(names.map((_name, ei) => analyze(year, month, values[ei], orig[ei], $locale, $_)));
|
||||
const all = $derived(
|
||||
names.map((_name, ei) => analyze(year, month, values[ei], orig[ei], $locale, $_, shift))
|
||||
);
|
||||
const cur = $derived(all[emp]);
|
||||
const issuesAll = $derived(all.reduce((n, x) => n + x.issues, 0));
|
||||
const infosAll = $derived(all.reduce((n, x) => n + x.infos, 0));
|
||||
@@ -77,27 +81,31 @@
|
||||
// ---- Azioni sui dati ----
|
||||
function onCellInput(di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) {
|
||||
const input = e.currentTarget;
|
||||
const prev = values[emp][di][ci][ii] ?? '';
|
||||
const cell = values[emp][di][ci];
|
||||
const prev = cell[ii] ?? '';
|
||||
const next = formatTyped(input.value, prev, (e as unknown as InputEvent).inputType ?? '');
|
||||
values[emp][di][ci][ii] = next;
|
||||
if (input.value !== next) input.value = next; // allinea il campo anche se lo stato non cambia
|
||||
if (next === '' && cell.length > 1) {
|
||||
// Una delle timbrature impilate è stata svuotata a mano: la casella si
|
||||
// restringe invece di lasciare un campo vuoto appeso.
|
||||
cell.splice(ii, 1);
|
||||
} else {
|
||||
cell[ii] = next;
|
||||
if (input.value !== next) input.value = next; // allinea il campo anche se lo stato non cambia
|
||||
}
|
||||
}
|
||||
|
||||
/** Ordina tutte le timbrature dei giorni e le ridistribuisce in E1, U1, E2, U2. */
|
||||
/** Ridistribuisce le timbrature del giorno in E1, U1, E2, U2 in base ai turni previsti. */
|
||||
function fixDays(dis: number[]) {
|
||||
for (const di of dis) {
|
||||
const list = values[emp][di].flat().filter((x) => x !== '');
|
||||
list.sort((a, b) => (parseT(a) ?? 9999) - (parseT(b) ?? 9999));
|
||||
const day: Day = [[list[0] ?? ''], [list[1] ?? ''], [list[2] ?? ''], [list[3] ?? '']];
|
||||
if (list.length > 4) day[3] = list.slice(3);
|
||||
values[emp][di] = day;
|
||||
const times = values[emp][di].flat().filter((x) => x !== '');
|
||||
values[emp][di] = assignToShifts(times, shift);
|
||||
}
|
||||
}
|
||||
|
||||
function addLunch(dis: number[]) {
|
||||
for (const di of dis) {
|
||||
const span = lunchSpan(values[emp][di]);
|
||||
if (span) values[emp][di] = [[span[0]], [LUNCH.start], [LUNCH.end], [span[1]]];
|
||||
const span = lunchSpan(values[emp][di], shift);
|
||||
if (span) values[emp][di] = [[span[0]], [shift.out1], [shift.in2], [span[1]]];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +113,7 @@
|
||||
for (const di of dis) {
|
||||
values[ei][di] = values[ei][di].map((c, ci) =>
|
||||
c.map((x, ii) => {
|
||||
const r = roundT(x);
|
||||
const r = roundT(x, shift.round_step_min);
|
||||
if (r !== x) rounded[`${ei}-${di}-${ci}-${ii}`] = r;
|
||||
return r;
|
||||
})
|
||||
@@ -222,6 +230,7 @@
|
||||
title={confirmTitle}
|
||||
{scopeIssues}
|
||||
{scopeInfos}
|
||||
{shift}
|
||||
items={confirmItems}
|
||||
onclose={() => (confirmScope = null)}
|
||||
onroundandexport={() => confirmScope && roundAndExport(confirmScope)}
|
||||
@@ -280,6 +289,7 @@
|
||||
stackedCount={cur.stackedDays.length}
|
||||
lunchCount={cur.lunchDays.length}
|
||||
roundCount={cur.roundDays.length}
|
||||
{shift}
|
||||
onfixall={() => fixDays(cur.stackedDays)}
|
||||
onaddlunchall={() => addLunch(cur.lunchDays)}
|
||||
onroundall={() => roundDaysOf(emp, cur.roundDays)}
|
||||
@@ -290,6 +300,7 @@
|
||||
rows={visibleRows}
|
||||
{rounded}
|
||||
{emp}
|
||||
{shift}
|
||||
oncellinput={onCellInput}
|
||||
onfixday={(di) => fixDays([di])}
|
||||
onaddlunch={(di) => addLunch([di])}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { LUNCH, ROUND_STEP, dayWord } from './analysis';
|
||||
import { autofocus } from '$lib/autofocus';
|
||||
import type { Shifts } from '$lib/shifts';
|
||||
import { dayWord } from './analysis';
|
||||
import FixButton from './FixButton.svelte';
|
||||
import IconButton from './IconButton.svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
|
||||
let {
|
||||
name,
|
||||
@@ -17,6 +19,7 @@
|
||||
stackedCount,
|
||||
lunchCount,
|
||||
roundCount,
|
||||
shift,
|
||||
onfixall,
|
||||
onaddlunchall,
|
||||
onroundall,
|
||||
@@ -34,6 +37,7 @@
|
||||
stackedCount: number;
|
||||
lunchCount: number;
|
||||
roundCount: number;
|
||||
shift: Shifts;
|
||||
onfixall: () => void;
|
||||
onaddlunchall: () => void;
|
||||
onroundall: () => void;
|
||||
@@ -51,10 +55,6 @@
|
||||
editing = false;
|
||||
}
|
||||
|
||||
function autofocus(node: HTMLInputElement) {
|
||||
node.focus();
|
||||
node.select();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="main-head">
|
||||
@@ -121,7 +121,7 @@
|
||||
{#if lunchCount}
|
||||
<FixButton
|
||||
size="lg"
|
||||
title={$_('review.add_lunch_title', { values: { start: LUNCH.start, end: LUNCH.end } })}
|
||||
title={$_('review.add_lunch_title', { values: { start: shift.out1, end: shift.in2 } })}
|
||||
onclick={onaddlunchall}
|
||||
>
|
||||
{#snippet icon()}
|
||||
@@ -139,7 +139,7 @@
|
||||
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
{$_('review.round_all', { values: { step: ROUND_STEP, days: dayWord(roundCount, $_) } })}
|
||||
{$_('review.round_all', { values: { step: shift.round_step_min, days: dayWord(roundCount, $_) } })}
|
||||
</FixButton>
|
||||
{/if}
|
||||
<div class="seg" role="group" aria-label={$_('review.day_filter_aria')}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { ROUND_STEP } from './analysis';
|
||||
import type { Shifts } from '$lib/shifts';
|
||||
import FixButton from './FixButton.svelte';
|
||||
import ActionButton from './ActionButton.svelte';
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
title,
|
||||
scopeIssues,
|
||||
scopeInfos,
|
||||
shift,
|
||||
items,
|
||||
onclose,
|
||||
onroundandexport,
|
||||
@@ -16,6 +17,7 @@
|
||||
title: string;
|
||||
scopeIssues: number;
|
||||
scopeInfos: number;
|
||||
shift: Shifts;
|
||||
items: { key: string; label: string; detail: string; red: boolean; go: () => void }[];
|
||||
onclose: () => void;
|
||||
onroundandexport: () => void;
|
||||
@@ -40,7 +42,7 @@
|
||||
{#if scopeIssues}
|
||||
{$_('review.export_warning_issues')}
|
||||
{:else}
|
||||
{$_('review.export_warning_unrounded', { values: { step: ROUND_STEP } })}
|
||||
{$_('review.export_warning_unrounded', { values: { step: shift.round_step_min } })}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { COLS_KEYS, LUNCH, type Row, cellClass, dayLabel, hm, isRnd, noteOf } from './analysis';
|
||||
import type { Shifts } from '$lib/shifts';
|
||||
import { COLS_KEYS, type Row, cellClass, dayLabel, hm, isRnd, noteOf } from './analysis';
|
||||
import FixButton from './FixButton.svelte';
|
||||
|
||||
let {
|
||||
rows,
|
||||
rounded,
|
||||
emp,
|
||||
shift,
|
||||
oncellinput,
|
||||
onfixday,
|
||||
onaddlunch,
|
||||
@@ -15,6 +17,7 @@
|
||||
rows: Row[];
|
||||
rounded: Record<string, string>;
|
||||
emp: number;
|
||||
shift: Shifts;
|
||||
oncellinput: (di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) => void;
|
||||
onfixday: (di: number) => void;
|
||||
onaddlunch: (di: number) => void;
|
||||
@@ -49,7 +52,7 @@
|
||||
0
|
||||
)}
|
||||
{@const manual = r.rowEdits - rnd}
|
||||
{@const note = noteOf(r, rnd, manual, $_)}
|
||||
{@const note = noteOf(r, rnd, manual, $_, shift.round_step_min)}
|
||||
{@const label = dayLabel(r)}
|
||||
<tr class:we={r.weekend} class:anom={!!r.issue}>
|
||||
<td class="first center"><span class="dow">{r.dow}</span><span class="mono">{r.di + 1}</span></td>
|
||||
@@ -105,7 +108,7 @@
|
||||
{/if}
|
||||
{#if r.lunch}
|
||||
<FixButton
|
||||
ariaLabel={$_('review.add_lunch_day_aria', { values: { start: LUNCH.start, end: LUNCH.end, label } })}
|
||||
ariaLabel={$_('review.add_lunch_day_aria', { values: { start: shift.out1, end: shift.in2, label } })}
|
||||
onclick={() => onaddlunch(r.di)}
|
||||
>
|
||||
{#snippet icon()}
|
||||
@@ -113,7 +116,7 @@
|
||||
><path d="M7 3v8M5 3v4a2 2 0 0 0 4 0V3M7 11v10M17 21V3c-2 1-3 3.5-3 7h3" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
{$_('review.lunch_short')} <span class="mono">{LUNCH.start}–{LUNCH.end}</span>
|
||||
{$_('review.lunch_short')} <span class="mono">{shift.out1}–{shift.in2}</span>
|
||||
</FixButton>
|
||||
{/if}
|
||||
{#if r.info}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { monthName } from '$lib/month';
|
||||
import { dayWord, shortSummary } from './analysis';
|
||||
import IconButton from './IconButton.svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
|
||||
let {
|
||||
year,
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
// svelte-i18n) e, dove serve la lingua per Intl, `locale` ($locale).
|
||||
|
||||
import { weekdayName } from '$lib/month';
|
||||
import type { Shifts } from '$lib/shifts';
|
||||
import { fmt, formatTyped, hm, parseT } from '$lib/time';
|
||||
|
||||
export { formatTyped, hm, parseT };
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches svelte-i18n's own loosely-typed `values`
|
||||
export type Translate = (key: string, options?: { values?: Record<string, any> }) => string;
|
||||
@@ -50,10 +54,6 @@ export interface Analysis {
|
||||
roundDays: number[];
|
||||
}
|
||||
|
||||
// ---- Parametri (TODO: renderli configurabili) ----
|
||||
export const LUNCH = { start: '12:30', end: '13:30' };
|
||||
export const ROUND_STEP = 10;
|
||||
|
||||
export const COLS_KEYS = ['review.col_in_1', 'review.col_out_1', 'review.col_in_2', 'review.col_out_2'];
|
||||
|
||||
export function normDay(day: Day): Day {
|
||||
@@ -63,73 +63,67 @@ export function normDay(day: Day): Day {
|
||||
}) as Day;
|
||||
}
|
||||
|
||||
export function parseT(t: string): number | null {
|
||||
const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(t);
|
||||
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
|
||||
}
|
||||
|
||||
export function fmt(m: number): string {
|
||||
return String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0');
|
||||
}
|
||||
|
||||
export function hm(m: number): string {
|
||||
return Math.floor(m / 60) + ':' + String(m % 60).padStart(2, '0');
|
||||
}
|
||||
|
||||
export function isRounded(t: string): boolean {
|
||||
export function isRounded(t: string, roundStep: number): boolean {
|
||||
const m = parseT(t);
|
||||
return m === null || m % ROUND_STEP === 0;
|
||||
return m === null || m % roundStep === 0;
|
||||
}
|
||||
|
||||
export function roundT(t: string): string {
|
||||
export function roundT(t: string, roundStep: number): string {
|
||||
const m = parseT(t);
|
||||
if (m === null) return t;
|
||||
return fmt(Math.min(Math.round(m / ROUND_STEP) * ROUND_STEP, 24 * 60 - ROUND_STEP));
|
||||
return fmt(Math.min(Math.round(m / roundStep) * roundStep, 24 * 60 - roundStep));
|
||||
}
|
||||
|
||||
export function dayWord(n: number, t: Translate): string {
|
||||
return t('review.day_word', { values: { n } });
|
||||
}
|
||||
|
||||
/** Formatta l'orario mentre si scrive, senza dover digitare ":". */
|
||||
export function formatTyped(raw: string, prev: string, inputType: string): string {
|
||||
const deleting = inputType ? inputType.startsWith('delete') : raw.length < prev.length && prev.startsWith(raw);
|
||||
const digits = raw.replace(/\D/g, '').slice(0, 4);
|
||||
if (!digits) return '';
|
||||
let h: string;
|
||||
let m: string;
|
||||
if (digits[0] >= '3' || (digits[0] === '2' && digits.length > 1 && digits[1] > '3')) {
|
||||
h = '0' + digits[0];
|
||||
m = digits.slice(1, 3);
|
||||
} else {
|
||||
h = digits.slice(0, 2);
|
||||
m = digits.slice(2, 4);
|
||||
}
|
||||
if (h.length < 2) return h;
|
||||
if (!m) return deleting ? h : h + ':';
|
||||
return h + ':' + m;
|
||||
}
|
||||
|
||||
/** [entrata, uscita] se il giorno ha solo 2 timbrature che scavalcano la pausa pranzo. */
|
||||
export function lunchSpan(day: Day): [string, string] | null {
|
||||
export function lunchSpan(day: Day, shift: Shifts): [string, string] | null {
|
||||
const all = day.flat().filter((x) => x !== '');
|
||||
if (all.length !== 2) return null;
|
||||
const t = all.map(parseT);
|
||||
if (t.some((x) => x === null)) return null;
|
||||
const [a, b] = (t[0] as number) <= (t[1] as number) ? [all[0], all[1]] : [all[1], all[0]];
|
||||
return (parseT(a) as number) < (parseT(LUNCH.start) as number) &&
|
||||
(parseT(b) as number) > (parseT(LUNCH.end) as number)
|
||||
return (parseT(a) as number) < (parseT(shift.out1) as number) &&
|
||||
(parseT(b) as number) > (parseT(shift.in2) as number)
|
||||
? [a, b]
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ridistribuisce timbrature finite tutte nello stesso turno (es. per un
|
||||
* timbratore configurato male) nelle 4 caselle corrette, scegliendo per
|
||||
* ciascuna l'orario previsto (E1/U1/E2/U2) più vicino. Dato che gli orari
|
||||
* sono ordinati e i turni previsti pure, basta confrontare ogni timbratura
|
||||
* col punto a metà strada fra due turni consecutivi: è esattamente il
|
||||
* confine fra "più vicino al turno prima" e "più vicino al turno dopo".
|
||||
*/
|
||||
export function assignToShifts(times: string[], shift: Shifts): Day {
|
||||
const targets = [shift.in1, shift.out1, shift.in2, shift.out2].map((x) => parseT(x) as number);
|
||||
const bounds = [(targets[0] + targets[1]) / 2, (targets[1] + targets[2]) / 2, (targets[2] + targets[3]) / 2];
|
||||
|
||||
const day: Day = [[], [], [], []];
|
||||
const sorted = [...times].sort((a, b) => (parseT(a) ?? Infinity) - (parseT(b) ?? Infinity));
|
||||
for (const time of sorted) {
|
||||
const m = parseT(time);
|
||||
const slot = m === null ? 3 : m < bounds[0] ? 0 : m < bounds[1] ? 1 : m < bounds[2] ? 2 : 3;
|
||||
day[slot].push(time);
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (day[i].length === 0) day[i] = [''];
|
||||
}
|
||||
return day;
|
||||
}
|
||||
|
||||
export function analyze(
|
||||
year: number,
|
||||
month: number,
|
||||
values: Day[],
|
||||
orig: Day[],
|
||||
locale: string | null | undefined,
|
||||
t: Translate
|
||||
t: Translate,
|
||||
shift: Shifts
|
||||
): Analysis {
|
||||
const rows: Row[] = [];
|
||||
let minutes = 0;
|
||||
@@ -156,8 +150,8 @@ export function analyze(
|
||||
// 1. Turno sbagliato
|
||||
if (stacked) {
|
||||
const total = lists.reduce((n, c) => n + c.length, 0);
|
||||
const shift = filled[0] || filled[1] ? t('review.shift_1') : t('review.shift_2');
|
||||
issue = t('review.issue_stacked', { values: { total, shift } });
|
||||
const shiftLabel = filled[0] || filled[1] ? t('review.shift_1') : t('review.shift_2');
|
||||
issue = t('review.issue_stacked', { values: { total, shift: shiftLabel } });
|
||||
[0, 1, 2, 3].forEach((i) => {
|
||||
if (!filled[i]) miss[i] = true;
|
||||
});
|
||||
@@ -165,7 +159,7 @@ export function analyze(
|
||||
// 2. Orario non valido
|
||||
if (!issue && bad.some((c) => c.some(Boolean))) issue = t('review.issue_invalid_time');
|
||||
// 3. Pausa pranzo non timbrata
|
||||
const lunch = !stacked && !issue && lunchSpan(v) !== null;
|
||||
const lunch = !stacked && !issue && lunchSpan(v, shift) !== null;
|
||||
if (lunch) {
|
||||
issue = t('review.issue_lunch_missing');
|
||||
[0, 1, 2, 3].forEach((i) => {
|
||||
@@ -210,10 +204,10 @@ export function analyze(
|
||||
const changed = v.map((c, ci) => c.map((x, ii) => x !== (o[ci][ii] ?? '')));
|
||||
const rowEdits = changed.reduce((n, c) => n + c.filter(Boolean).length, 0);
|
||||
const wasStacked = o.some((c) => c.length > 1);
|
||||
const wasLunch = !lunch && lunchSpan(o) !== null && v[1][0] === LUNCH.start && v[2][0] === LUNCH.end;
|
||||
const wasLunch = !lunch && lunchSpan(o, shift) !== null && v[1][0] === shift.out1 && v[2][0] === shift.in2;
|
||||
|
||||
// 6. Info: orari non arrotondati
|
||||
const unr = v.map((c) => c.map((x) => x !== '' && !isRounded(x)));
|
||||
const unr = v.map((c) => c.map((x) => x !== '' && !isRounded(x, shift.round_step_min)));
|
||||
const hasUnr = unr.some((c) => c.some(Boolean));
|
||||
const info = !issue && hasUnr;
|
||||
|
||||
@@ -250,9 +244,15 @@ export function isRnd(
|
||||
return val !== '' && rounded[`${emp}-${di}-${ci}-${ii}`] === val;
|
||||
}
|
||||
|
||||
export function noteOf(r: Row, roundedCount: number, manualCount: number, t: Translate): { text: string; kind: string } {
|
||||
export function noteOf(
|
||||
r: Row,
|
||||
roundedCount: number,
|
||||
manualCount: number,
|
||||
t: Translate,
|
||||
roundStep: number
|
||||
): { text: string; kind: string } {
|
||||
if (r.issue) return { text: r.issue, kind: 'issue' };
|
||||
if (r.info) return { text: t('review.info_unrounded', { values: { step: ROUND_STEP } }), kind: 'info' };
|
||||
if (r.info) return { text: t('review.info_unrounded', { values: { step: roundStep } }), kind: 'info' };
|
||||
if (r.empty && !r.weekend) return { text: t('review.note_empty'), kind: 'muted' };
|
||||
if (r.wasLunch) return { text: t('review.note_lunch_added'), kind: 'edit' };
|
||||
if (r.wasStacked) return { text: t('review.note_shifts_fixed'), kind: 'edit' };
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
let { lang, onchange }: { lang: string; onchange: (lang: string) => void } = $props();
|
||||
</script>
|
||||
|
||||
<section class="lang-sec">
|
||||
<div class="sec-head">
|
||||
<h2><label for="ox-lang">{$_('settings.language_title')}</label></h2>
|
||||
<p>{$_('settings.language_desc')}</p>
|
||||
</div>
|
||||
<div class="select-wrap">
|
||||
<select id="ox-lang" class="select" value={lang} onchange={(e) => onchange(e.currentTarget.value)}>
|
||||
<option value="it">Italiano</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" class="select-chev"
|
||||
><path d="M6 9l6 6 6-6" /></svg
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.lang-sec {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px 24px;
|
||||
}
|
||||
|
||||
.sec-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sec-head h2 {
|
||||
margin: 0;
|
||||
font-family: var(--ox-font-display);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.02em;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sec-head p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.select-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
box-sizing: border-box;
|
||||
height: 44px;
|
||||
min-width: 180px;
|
||||
padding: 0 42px 0 14px;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 12px;
|
||||
background: #161616;
|
||||
color: #f2f2f2;
|
||||
font-family: inherit;
|
||||
font-size: 14.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.select:hover {
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
outline: none;
|
||||
border-color: var(--ox-accent);
|
||||
box-shadow: 0 0 0 4px var(--ox-accent-ring);
|
||||
}
|
||||
|
||||
.select option {
|
||||
background: #1a1a1a;
|
||||
color: #f2f2f2;
|
||||
}
|
||||
|
||||
.select-chev {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
pointer-events: none;
|
||||
color: #8a8a8a;
|
||||
stroke-width: 2.2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,332 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { error, setError, clearError } from '$lib/error';
|
||||
import type { Settings } from '$lib/settings';
|
||||
import Field from '../Field.svelte';
|
||||
import ErrorBanner from '../ErrorBanner.svelte';
|
||||
import ActionButton from '../review/ActionButton.svelte';
|
||||
import { IPV4 } from './logic';
|
||||
|
||||
let { host, port, onsave }: { host: string; port: number; onsave: (host: string, port: number) => void } =
|
||||
$props();
|
||||
|
||||
let editing = $state(false);
|
||||
let draftHost = $state('');
|
||||
let draftPort = $state(0);
|
||||
let ipError = $state(false);
|
||||
|
||||
let status = $state<'' | 'testing' | 'ok'>('');
|
||||
|
||||
function startEdit() {
|
||||
draftHost = host;
|
||||
draftPort = port;
|
||||
ipError = false;
|
||||
clearError();
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editing = false;
|
||||
ipError = false;
|
||||
}
|
||||
|
||||
async function confirmEdit() {
|
||||
const v = draftHost.trim();
|
||||
if (!IPV4.test(v)) {
|
||||
ipError = true;
|
||||
return;
|
||||
}
|
||||
const nextPort = draftPort || 5005;
|
||||
editing = false;
|
||||
status = '';
|
||||
clearError();
|
||||
try {
|
||||
const settings = await invoke<Settings>('load_settings');
|
||||
await invoke('save_settings', { settings: { ...settings, reader: { host: v, port: nextPort } } });
|
||||
onsave(v, nextPort);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function test() {
|
||||
status = 'testing';
|
||||
clearError();
|
||||
try {
|
||||
await invoke('test_connection', { host, port });
|
||||
status = 'ok';
|
||||
} catch (err) {
|
||||
status = '';
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
|
||||
function onPortInput(e: Event & { currentTarget: HTMLInputElement }) {
|
||||
const input = e.currentTarget;
|
||||
const next = input.value.replace(/\D/g, '').slice(0, 5);
|
||||
if (input.value !== next) input.value = next;
|
||||
draftPort = Number(next);
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && editing) cancelEdit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<div class="card" class:active={editing}>
|
||||
{#if !editing}
|
||||
<div class="dev-row">
|
||||
<div class="dev-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 1.8"
|
||||
><rect x="5" y="3" width="14" height="18" rx="2.5" /><rect x="8" y="6" width="8" height="5" rx="1" /><path
|
||||
d="M9 15h.01M12 15h.01M15 15h.01M9 18h6"
|
||||
/></svg
|
||||
>
|
||||
</div>
|
||||
<div class="dev-text">
|
||||
<span class="dev-label">{$_('settings.device_label')}</span>
|
||||
<span class="dev-endpoint mono">{host}:{port}</span>
|
||||
</div>
|
||||
<span class="dev-status">
|
||||
{#if status === 'testing'}
|
||||
<svg class="spin" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
|
||||
><path d="M12 3a9 9 0 1 0 9 9" /></svg
|
||||
>
|
||||
{$_('settings.status_testing')}
|
||||
{:else if status === 'ok'}
|
||||
<span class="dot ok" aria-hidden="true"></span>
|
||||
{$_('settings.status_ok')}
|
||||
{:else}
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{$_('settings.status_idle')}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="dev-actions">
|
||||
<button type="button" class="pill" onclick={startEdit}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
|
||||
>
|
||||
<span>{$_('settings.edit_address')}</span>
|
||||
</button>
|
||||
<button type="button" class="pill" onclick={test} disabled={status === 'testing'}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01" /></svg
|
||||
>
|
||||
<span>{$_('settings.test_connection')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<form
|
||||
class="dev-edit"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
confirmEdit();
|
||||
}}
|
||||
>
|
||||
<div class="grid-ip">
|
||||
<div class="stack">
|
||||
<Field
|
||||
id="dev-ip"
|
||||
label={$_('settings.ip_label')}
|
||||
bind:value={draftHost}
|
||||
inputmode="decimal"
|
||||
placeholder="192.168.1.100"
|
||||
err={ipError}
|
||||
oninput={() => (ipError = false)}
|
||||
/>
|
||||
{#if ipError}<span class="field-error">{$_('settings.ip_invalid')}</span>{/if}
|
||||
</div>
|
||||
<Field
|
||||
id="dev-port"
|
||||
label={$_('settings.port_label')}
|
||||
value={draftPort}
|
||||
maxlength={5}
|
||||
inputmode="numeric"
|
||||
placeholder="5005"
|
||||
oninput={onPortInput}
|
||||
/>
|
||||
</div>
|
||||
<div class="row-end">
|
||||
<ActionButton onclick={cancelEdit}>{$_('settings.cancel')}</ActionButton>
|
||||
<ActionButton primary onclick={confirmEdit}>{$_('settings.confirm')}</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $error}
|
||||
<ErrorBanner title={$_('errors.title')} message={$error} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.mono {
|
||||
font-family: var(--ox-font-mono);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #262626;
|
||||
border-radius: 14px;
|
||||
background: #161616;
|
||||
}
|
||||
|
||||
.card.active {
|
||||
border-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.dev-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 16px 16px 18px;
|
||||
}
|
||||
|
||||
.dev-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: none;
|
||||
border-radius: 11px;
|
||||
background: #1f1f1f;
|
||||
color: #bdbdbd;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dev-text {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dev-label {
|
||||
font-size: 12.5px;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.dev-endpoint {
|
||||
font-size: 16px;
|
||||
color: #f2f2f2;
|
||||
}
|
||||
|
||||
.dev-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #6e6e6e;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.dot.ok {
|
||||
background: #4cc38a;
|
||||
}
|
||||
|
||||
.dev-status:has(.ok) {
|
||||
color: #4cc38a;
|
||||
}
|
||||
|
||||
.dev-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 16px 14px 18px;
|
||||
border-top: 1px solid #222222;
|
||||
}
|
||||
|
||||
.pill {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid #333333;
|
||||
color: #c9c9c9;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.pill:hover:not(:disabled) {
|
||||
background: #1e1e1e;
|
||||
color: #ffffff;
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.pill:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.pill:focus-visible {
|
||||
outline: 2px solid var(--ox-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dev-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 22px 16px 16px;
|
||||
}
|
||||
|
||||
.grid-ip {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 120px;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-end {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
padding-left: 4px;
|
||||
font-size: 12.5px;
|
||||
color: #ff8a8f;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.grid-ip {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { fmt } from '$lib/time';
|
||||
import Field from '../Field.svelte';
|
||||
import { roundValid } from './logic';
|
||||
|
||||
let { roundStep, oninput }: { roundStep: string; oninput: (value: string) => void } = $props();
|
||||
|
||||
function onRoundInput(e: Event & { currentTarget: HTMLInputElement }) {
|
||||
const input = e.currentTarget;
|
||||
const next = input.value.replace(/\D/g, '').slice(0, 2);
|
||||
if (input.value !== next) input.value = next;
|
||||
oninput(next);
|
||||
}
|
||||
|
||||
const example = $derived.by(() => {
|
||||
if (!roundValid(roundStep)) return '';
|
||||
const step = Number(roundStep);
|
||||
const rounded = Math.round((8 * 60 + 7) / step) * step;
|
||||
return $_('settings.round_example', { values: { from: '08:07', to: fmt(rounded) } });
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<div class="round-text">
|
||||
<span class="round-title">{$_('settings.round_title')}</span>
|
||||
<span class="round-desc">{$_('settings.round_desc')} {example}</span>
|
||||
</div>
|
||||
<div class="round-box">
|
||||
<Field
|
||||
id="ox-round"
|
||||
label={$_('settings.round_label')}
|
||||
value={roundStep}
|
||||
maxlength={2}
|
||||
inputmode="numeric"
|
||||
placeholder="10"
|
||||
suffix={$_('settings.minutes_short')}
|
||||
err={!roundValid(roundStep)}
|
||||
oninput={onRoundInput}
|
||||
/>
|
||||
{#if !roundValid(roundStep)}<span class="field-error">{$_('settings.round_range')}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
border: 1px solid #262626;
|
||||
border-radius: 14px;
|
||||
background: #161616;
|
||||
padding: 22px 18px 18px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px 24px;
|
||||
}
|
||||
|
||||
.round-text {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.round-title {
|
||||
font-size: 14.5px;
|
||||
font-weight: 500;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.round-desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.round-box {
|
||||
width: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
padding-left: 4px;
|
||||
font-size: 12.5px;
|
||||
color: #ff8a8f;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { formatTyped, hm } from '$lib/time';
|
||||
import Field from '../Field.svelte';
|
||||
import { SHIFT_ROWS, checkShifts, type ShiftTimeKey, type Shifts } from './logic';
|
||||
|
||||
let { shifts, oninput }: { shifts: Shifts; oninput: (key: ShiftTimeKey, value: string) => void } = $props();
|
||||
|
||||
const check = $derived(checkShifts(shifts));
|
||||
|
||||
function onShiftInput(key: ShiftTimeKey, e: Event & { currentTarget: HTMLInputElement }) {
|
||||
const input = e.currentTarget;
|
||||
const next = formatTyped(input.value, shifts[key], (e as unknown as InputEvent).inputType ?? '');
|
||||
if (input.value !== next) input.value = next;
|
||||
oninput(key, next);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
{#each SHIFT_ROWS as row (row.a)}
|
||||
<div class="shift-row">
|
||||
<span class="shift-title">{$_(row.titleKey)}</span>
|
||||
<Field
|
||||
id="sh-{row.a}"
|
||||
label={$_('settings.shift_in')}
|
||||
value={shifts[row.a]}
|
||||
maxlength={5}
|
||||
inputmode="numeric"
|
||||
placeholder="--:--"
|
||||
err={!!check.bad[row.a]}
|
||||
oninput={(e) => onShiftInput(row.a, e)}
|
||||
/>
|
||||
<Field
|
||||
id="sh-{row.b}"
|
||||
label={$_('settings.shift_out')}
|
||||
value={shifts[row.b]}
|
||||
maxlength={5}
|
||||
inputmode="numeric"
|
||||
placeholder="--:--"
|
||||
err={!!check.bad[row.b]}
|
||||
oninput={(e) => onShiftInput(row.b, e)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="summary">
|
||||
{#if check.errorKey}
|
||||
<span class="summary-err">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><circle cx="12" cy="12" r="9" /><path d="M12 7.5v5.5M12 16.5h.01" /></svg
|
||||
>
|
||||
{$_(check.errorKey)}
|
||||
</span>
|
||||
{:else if check.minutes}
|
||||
<span
|
||||
>{$_('settings.hours_per_day_label')}
|
||||
<b>{hm(check.minutes.out1 - check.minutes.in1 + (check.minutes.out2 - check.minutes.in2))}</b>
|
||||
{$_('settings.per_day')}</span
|
||||
>
|
||||
<span
|
||||
>{$_('settings.break_label')} <b>{shifts.out1}–{shifts.in2}</b>
|
||||
({check.minutes.in2 - check.minutes.out1} {$_('settings.minutes_short')})</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
border: 1px solid #262626;
|
||||
border-radius: 14px;
|
||||
background: #161616;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 22px 18px 18px;
|
||||
}
|
||||
|
||||
.shift-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.shift-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #c9c9c9;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin-top: 6px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #242424;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 24px;
|
||||
font-size: 13.5px;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.summary b {
|
||||
font-family: var(--ox-font-mono);
|
||||
font-weight: 400;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.summary-err {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #ff9ea3;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.shift-row {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.shift-title {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { parseT } from '$lib/time';
|
||||
import type { Shifts } from '$lib/shifts';
|
||||
|
||||
export type { Shifts };
|
||||
|
||||
export const IPV4 = /^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
|
||||
|
||||
/** Le 4 caselle orario di `Shifts` — a parte `round_step_min`, che non è un orario. */
|
||||
export type ShiftTimeKey = 'in1' | 'out1' | 'in2' | 'out2';
|
||||
|
||||
export const SHIFT_ROWS: { titleKey: string; a: ShiftTimeKey; b: ShiftTimeKey }[] = [
|
||||
{ titleKey: 'settings.shift_1', a: 'in1', b: 'out1' },
|
||||
{ titleKey: 'settings.shift_2', a: 'in2', b: 'out2' }
|
||||
];
|
||||
|
||||
export interface ShiftCheck {
|
||||
minutes: Record<ShiftTimeKey, number> | null;
|
||||
bad: Partial<Record<ShiftTimeKey, true>>;
|
||||
errorKey: string | null;
|
||||
}
|
||||
|
||||
export function checkShifts(shifts: Shifts): ShiftCheck {
|
||||
const t: Record<ShiftTimeKey, number | null> = {
|
||||
in1: parseT(shifts.in1),
|
||||
out1: parseT(shifts.out1),
|
||||
in2: parseT(shifts.in2),
|
||||
out2: parseT(shifts.out2)
|
||||
};
|
||||
const bad: Partial<Record<ShiftTimeKey, true>> = {};
|
||||
for (const key of Object.keys(t) as ShiftTimeKey[]) {
|
||||
if (t[key] === null) bad[key] = true;
|
||||
}
|
||||
if (Object.keys(bad).length) return { minutes: null, bad, errorKey: 'settings.shift_error_format' };
|
||||
|
||||
const n = t as Record<ShiftTimeKey, number>;
|
||||
if (n.out1 <= n.in1) return { minutes: null, bad: { out1: true }, errorKey: 'settings.shift_error_out1' };
|
||||
if (n.in2 < n.out1) return { minutes: null, bad: { in2: true }, errorKey: 'settings.shift_error_in2' };
|
||||
if (n.out2 <= n.in2) return { minutes: null, bad: { out2: true }, errorKey: 'settings.shift_error_out2' };
|
||||
return { minutes: n, bad: {}, errorKey: null };
|
||||
}
|
||||
|
||||
export const roundValid = (v: string): boolean => /^\d{1,2}$/.test(v) && Number(v) >= 1 && Number(v) <= 60;
|
||||
@@ -115,5 +115,47 @@
|
||||
"back_to_review": "Back to review",
|
||||
"round_and_export": "Round and export",
|
||||
"export_anyway": "Export anyway"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"device_title": "Time clock",
|
||||
"device_desc": "The time clock Oxibadge downloads punches from.",
|
||||
"device_label": "Address",
|
||||
"status_testing": "Checking…",
|
||||
"status_ok": "Reachable",
|
||||
"status_idle": "Not verified",
|
||||
"edit_address": "Edit address",
|
||||
"test_connection": "Test connection",
|
||||
"ip_label": "IP address",
|
||||
"port_label": "Port",
|
||||
"ip_invalid": "Invalid format, e.g. 192.168.1.100",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"shifts_title": "Expected shifts",
|
||||
"shifts_desc": "The standard work schedule. The break between the two shifts is the one used by \"Add lunch break\" in the review.",
|
||||
"shift_1": "1st shift",
|
||||
"shift_2": "2nd shift",
|
||||
"shift_in": "In",
|
||||
"shift_out": "Out",
|
||||
"shift_error_format": "Enter all times in HH:MM format.",
|
||||
"shift_error_out1": "The 1st shift's clock-out must be after its clock-in.",
|
||||
"shift_error_in2": "The 2nd shift must start after the 1st one ends.",
|
||||
"shift_error_out2": "The 2nd shift's clock-out must be after its clock-in.",
|
||||
"hours_per_day_label": "Expected hours",
|
||||
"per_day": "per day",
|
||||
"break_label": "Break",
|
||||
"round_title": "Time rounding",
|
||||
"round_desc": "Times are rounded to the nearest multiple.",
|
||||
"round_example": "E.g. {from} → {to}.",
|
||||
"round_label": "Minutes",
|
||||
"minutes_short": "min",
|
||||
"round_range": "From 1 to 60 minutes",
|
||||
"language_title": "Language",
|
||||
"language_desc": "Oxibadge's interface language.",
|
||||
"save_idle": "Changes are saved automatically",
|
||||
"save_saving": "Saving…",
|
||||
"save_saved": "Changes saved",
|
||||
"save_invalid": "Not saved: fix the error",
|
||||
"save_failed": "Save failed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,5 +115,47 @@
|
||||
"back_to_review": "Torna alla revisione",
|
||||
"round_and_export": "Arrotonda ed esporta",
|
||||
"export_anyway": "Esporta comunque"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"device_title": "Timbratore",
|
||||
"device_desc": "Il timbratore da cui Oxibadge scarica le timbrature.",
|
||||
"device_label": "Indirizzo",
|
||||
"status_testing": "Verifica…",
|
||||
"status_ok": "Raggiungibile",
|
||||
"status_idle": "Non verificato",
|
||||
"edit_address": "Modifica indirizzo",
|
||||
"test_connection": "Testa connessione",
|
||||
"ip_label": "Indirizzo IP",
|
||||
"port_label": "Porta",
|
||||
"ip_invalid": "Formato non valido, es. 192.168.1.100",
|
||||
"cancel": "Annulla",
|
||||
"confirm": "Conferma",
|
||||
"shifts_title": "Turni previsti",
|
||||
"shifts_desc": "L'orario di lavoro standard. La pausa tra i due turni è quella usata da \"Aggiungi pausa pranzo\" nella revisione.",
|
||||
"shift_1": "1° turno",
|
||||
"shift_2": "2° turno",
|
||||
"shift_in": "Entrata",
|
||||
"shift_out": "Uscita",
|
||||
"shift_error_format": "Inserisci tutti gli orari nel formato HH:MM.",
|
||||
"shift_error_out1": "L'uscita del 1° turno deve essere dopo l'entrata.",
|
||||
"shift_error_in2": "Il 2° turno deve iniziare dopo la fine del 1°.",
|
||||
"shift_error_out2": "L'uscita del 2° turno deve essere dopo l'entrata.",
|
||||
"hours_per_day_label": "Ore previste",
|
||||
"per_day": "al giorno",
|
||||
"break_label": "Pausa",
|
||||
"round_title": "Arrotondamento orari",
|
||||
"round_desc": "Gli orari vengono arrotondati al multiplo più vicino.",
|
||||
"round_example": "Es. {from} → {to}.",
|
||||
"round_label": "Minuti",
|
||||
"minutes_short": "min",
|
||||
"round_range": "Da 1 a 60 minuti",
|
||||
"language_title": "Lingua",
|
||||
"language_desc": "La lingua dell'interfaccia di Oxibadge.",
|
||||
"save_idle": "Le modifiche vengono salvate automaticamente",
|
||||
"save_saving": "Salvataggio…",
|
||||
"save_saved": "Modifiche salvate",
|
||||
"save_invalid": "Non salvato: correggi l'errore",
|
||||
"save_failed": "Salvataggio non riuscito"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Shifts } from './shifts';
|
||||
|
||||
export interface Reader {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
reader: Reader;
|
||||
shifts: Shifts;
|
||||
lang: string | null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface Shifts {
|
||||
in1: string;
|
||||
out1: string;
|
||||
in2: string;
|
||||
out2: string;
|
||||
round_step_min: number;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export function parseT(t: string): number | null {
|
||||
const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(t);
|
||||
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
|
||||
}
|
||||
|
||||
export function fmt(m: number): string {
|
||||
return String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0');
|
||||
}
|
||||
|
||||
export function hm(m: number): string {
|
||||
return Math.floor(m / 60) + ':' + String(m % 60).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** Formatta l'orario mentre si scrive, senza dover digitare ":". */
|
||||
export function formatTyped(raw: string, prev: string, inputType: string): string {
|
||||
const deleting = inputType ? inputType.startsWith('delete') : raw.length < prev.length && prev.startsWith(raw);
|
||||
const digits = raw.replace(/\D/g, '').slice(0, 4);
|
||||
if (!digits) return '';
|
||||
let h: string;
|
||||
let m: string;
|
||||
if (digits[0] >= '3' || (digits[0] === '2' && digits.length > 1 && digits[1] > '3')) {
|
||||
h = '0' + digits[0];
|
||||
m = digits.slice(1, 3);
|
||||
} else {
|
||||
h = digits.slice(0, 2);
|
||||
m = digits.slice(2, 4);
|
||||
}
|
||||
if (h.length < 2) return h;
|
||||
if (!m) return deleting ? h : h + ':';
|
||||
return h + ':' + m;
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { waitLocale } from 'svelte-i18n';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { locale, waitLocale } from 'svelte-i18n';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { Settings } from '$lib/settings';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
let ready = $state(false);
|
||||
waitLocale().then(() => {
|
||||
ready = true;
|
||||
});
|
||||
invoke<Settings>('load_settings')
|
||||
.catch(() => null)
|
||||
.then((settings) => {
|
||||
if (settings?.lang) locale.set(settings.lang);
|
||||
return waitLocale();
|
||||
})
|
||||
.then(() => {
|
||||
ready = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if ready}
|
||||
|
||||
@@ -9,24 +9,41 @@
|
||||
import ConfirmMonth from '$lib/components/ConfirmMonth.svelte';
|
||||
import LoadingScreen from '$lib/components/LoadingScreen.svelte';
|
||||
import TimesheetReview from '$lib/components/TimesheetReview.svelte';
|
||||
import SettingsButton from '$lib/components/SettingsButton.svelte';
|
||||
import Settings from '$lib/components/Settings.svelte';
|
||||
import { buildEmployees, type AttendanceRecord } from '$lib/components/review/fromRecords';
|
||||
import type { Shifts } from '$lib/shifts';
|
||||
import type { Settings as SettingsData } from '$lib/settings';
|
||||
|
||||
type Step = 'loading' | 'setup' | 'confirm-month' | 'loading-records' | 'loading-employees' | 'review';
|
||||
type Step = 'loading' | 'setup' | 'confirm-month' | 'loading-records' | 'loading-employees' | 'review' | 'settings';
|
||||
|
||||
let step = $state<Step>('loading');
|
||||
let selectedYear = $state(0);
|
||||
let selectedMonth = $state(0);
|
||||
let records = $state<AttendanceRecord[]>([]);
|
||||
let employeeNames = $state<Record<string, string>>({});
|
||||
// Placeholder: sovrascritti da load_settings prima che la revisione sia raggiungibile.
|
||||
let shifts = $state<Shifts>({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 });
|
||||
|
||||
const period = $derived(`${monthName($locale, selectedMonth)} ${selectedYear}`);
|
||||
const reviewEmployees = $derived(buildEmployees(selectedYear, selectedMonth, records, employeeNames));
|
||||
|
||||
onMount(async () => {
|
||||
const preferences = await invoke<{ host: string; port: number } | null>('load_preferences');
|
||||
step = preferences ? 'confirm-month' : 'setup';
|
||||
const settings = await invoke<SettingsData>('load_settings');
|
||||
shifts = settings.shifts;
|
||||
step = settings.reader.host ? 'confirm-month' : 'setup';
|
||||
});
|
||||
|
||||
function openSettings() {
|
||||
clearError();
|
||||
step = 'settings';
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
clearError();
|
||||
step = 'confirm-month';
|
||||
}
|
||||
|
||||
async function loadMonth(selection: { year: number; month: number }) {
|
||||
selectedYear = selection.year;
|
||||
selectedMonth = selection.month;
|
||||
@@ -49,9 +66,17 @@
|
||||
year={selectedYear}
|
||||
month={selectedMonth}
|
||||
employees={reviewEmployees}
|
||||
shift={shifts}
|
||||
onback={() => (step = 'confirm-month')}
|
||||
onexport={(payload) => invoke('export_timesheet', { payload })}
|
||||
/>
|
||||
{:else if step === 'settings'}
|
||||
<Settings
|
||||
onback={closeSettings}
|
||||
onsettingschange={(s: SettingsData) => {
|
||||
shifts = s.shifts;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<Screen>
|
||||
{#if step === 'loading'}
|
||||
@@ -59,6 +84,7 @@
|
||||
{:else if step === 'setup'}
|
||||
<Onboarding onconnected={() => (step = 'confirm-month')} />
|
||||
{:else if step === 'confirm-month'}
|
||||
<SettingsButton ariaLabel={$_('settings.title')} onclick={openSettings} />
|
||||
<ConfirmMonth onstart={loadMonth} />
|
||||
{:else if step === 'loading-records'}
|
||||
<LoadingScreen
|
||||
|
||||
Reference in New Issue
Block a user