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:
@@ -1,7 +1,7 @@
|
|||||||
mod preferences;
|
mod preferences;
|
||||||
|
|
||||||
use chrono::{Local, TimeZone};
|
use chrono::{Local, TimeZone};
|
||||||
use preferences::Reader;
|
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;
|
||||||
@@ -9,13 +9,13 @@ use tauri::AppHandle;
|
|||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn load_preferences(app: AppHandle) -> Option<Reader> {
|
fn load_settings(app: AppHandle) -> Settings {
|
||||||
preferences::load(&app)
|
preferences::load(&app)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn save_preferences(app: AppHandle, host: String, port: u16) -> Result<(), String> {
|
fn save_settings(app: AppHandle, settings: Settings) -> Result<(), String> {
|
||||||
preferences::save(&app, Reader { host, port })
|
preferences::save(&app, &settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -46,8 +46,11 @@ async fn connect_with_retries(host: &str, port: u16) -> Result<R701, String> {
|
|||||||
/// chronological (oldest first) order before being returned.
|
/// chronological (oldest first) order before being returned.
|
||||||
#[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 reader = preferences::load(&app).ok_or_else(|| "no reader configured".to_string())?;
|
let settings = preferences::load(&app);
|
||||||
let r701 = connect_with_retries(&reader.host, reader.port).await?;
|
if settings.reader.host.is_empty() {
|
||||||
|
return Err("no reader configured".to_string());
|
||||||
|
}
|
||||||
|
let r701 = connect_with_retries(&settings.reader.host, settings.reader.port).await?;
|
||||||
|
|
||||||
let invalid = || "invalid year/month".to_string();
|
let invalid = || "invalid year/month".to_string();
|
||||||
let start = Local
|
let start = Local
|
||||||
@@ -85,8 +88,11 @@ async fn load_employees(
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
records: Vec<Record>,
|
records: Vec<Record>,
|
||||||
) -> Result<HashMap<u32, String>, String> {
|
) -> Result<HashMap<u32, String>, String> {
|
||||||
let reader = preferences::load(&app).ok_or_else(|| "no reader configured".to_string())?;
|
let settings = preferences::load(&app);
|
||||||
let r701 = connect_with_retries(&reader.host, reader.port).await?;
|
if settings.reader.host.is_empty() {
|
||||||
|
return Err("no reader configured".to_string());
|
||||||
|
}
|
||||||
|
let r701 = connect_with_retries(&settings.reader.host, settings.reader.port).await?;
|
||||||
|
|
||||||
let mut employees = HashMap::new();
|
let mut employees = HashMap::new();
|
||||||
for record in &records {
|
for record in &records {
|
||||||
@@ -145,8 +151,8 @@ fn export_timesheet(payload: ExportPayload) {
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
load_preferences,
|
load_settings,
|
||||||
save_preferences,
|
save_settings,
|
||||||
test_connection,
|
test_connection,
|
||||||
load_records,
|
load_records,
|
||||||
load_employees,
|
load_employees,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -7,25 +8,65 @@ pub struct Reader {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct Preferences {
|
pub struct Shifts {
|
||||||
#[serde(default)]
|
pub in1: String,
|
||||||
reader: Vec<Reader>,
|
pub out1: String,
|
||||||
|
pub in2: String,
|
||||||
|
pub out2: String,
|
||||||
|
pub round_step_min: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(app: &AppHandle) -> Option<Reader> {
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
let path = app.path().app_data_dir().ok()?.join("preferences.toml");
|
pub struct Settings {
|
||||||
let contents = std::fs::read_to_string(path).ok()?;
|
pub reader: Reader,
|
||||||
let preferences: Preferences = toml::from_str(&contents).ok()?;
|
pub shifts: Shifts,
|
||||||
preferences.reader.into_iter().next()
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub lang: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(app: &AppHandle, reader: Reader) -> Result<(), String> {
|
impl Default for Settings {
|
||||||
let dir = app.path().app_data_dir().map_err(|err| err.to_string())?;
|
fn default() -> Self {
|
||||||
std::fs::create_dir_all(&dir).map_err(|err| err.to_string())?;
|
Self {
|
||||||
let preferences = Preferences {
|
reader: Reader {
|
||||||
reader: vec![reader],
|
host: String::new(),
|
||||||
|
port: 5005,
|
||||||
|
},
|
||||||
|
shifts: Shifts {
|
||||||
|
in1: "07:30".to_string(),
|
||||||
|
out1: "12:00".to_string(),
|
||||||
|
in2: "13:30".to_string(),
|
||||||
|
out2: "17:00".to_string(),
|
||||||
|
round_step_min: 10,
|
||||||
|
},
|
||||||
|
lang: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
Ok(app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|err| err.to_string())?
|
||||||
|
.join("preferences.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(app: &AppHandle) -> Settings {
|
||||||
|
let Ok(path) = path(app) else {
|
||||||
|
return Settings::default();
|
||||||
};
|
};
|
||||||
let contents = toml::to_string_pretty(&preferences).map_err(|err| err.to_string())?;
|
std::fs::read_to_string(path)
|
||||||
std::fs::write(dir.join("preferences.toml"), contents).map_err(|err| err.to_string())
|
.ok()
|
||||||
|
.and_then(|contents| toml::from_str(&contents).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(app: &AppHandle, settings: &Settings) -> 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(settings).map_err(|err| err.to_string())?;
|
||||||
|
std::fs::write(dest, contents).map_err(|err| err.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function autofocus(node: HTMLInputElement) {
|
||||||
|
node.focus();
|
||||||
|
node.select();
|
||||||
|
}
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
value = $bindable(),
|
value = $bindable(),
|
||||||
type = 'text',
|
type = 'text',
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
|
maxlength,
|
||||||
|
inputmode,
|
||||||
|
err = false,
|
||||||
|
suffix,
|
||||||
oninput,
|
oninput,
|
||||||
icon
|
icon
|
||||||
}: {
|
}: {
|
||||||
@@ -15,15 +19,30 @@
|
|||||||
value: string | number;
|
value: string | number;
|
||||||
type?: string;
|
type?: string;
|
||||||
placeholder?: 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;
|
icon?: Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field" class:err>
|
||||||
<label for={id}>{label}</label>
|
<label for={id}>{label}</label>
|
||||||
{#if icon}{@render icon()}{/if}
|
{#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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -52,6 +71,20 @@
|
|||||||
box-shadow: 0 0 0 4px var(--ox-accent-ring);
|
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 {
|
.field label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -9px;
|
top: -9px;
|
||||||
@@ -93,4 +126,10 @@
|
|||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.suffix {
|
||||||
|
flex: none;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ox-text-subtle);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
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 Column from './Column.svelte';
|
import Column from './Column.svelte';
|
||||||
import Intro from './Intro.svelte';
|
import Intro from './Intro.svelte';
|
||||||
import Field from './Field.svelte';
|
import Field from './Field.svelte';
|
||||||
@@ -28,7 +29,8 @@
|
|||||||
clearError();
|
clearError();
|
||||||
try {
|
try {
|
||||||
await invoke('test_connection', { host: trimmedHost, port });
|
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 });
|
onconnected({ host: trimmedHost, port });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(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 { untrack } from 'svelte';
|
||||||
import { _, locale } from 'svelte-i18n';
|
import { _, locale } from 'svelte-i18n';
|
||||||
import { monthName } from '$lib/month';
|
import { monthName } from '$lib/month';
|
||||||
|
import type { Shifts } from '$lib/shifts';
|
||||||
import {
|
import {
|
||||||
type Day,
|
type Day,
|
||||||
type Employee,
|
type Employee,
|
||||||
LUNCH,
|
|
||||||
analyze,
|
analyze,
|
||||||
|
assignToShifts,
|
||||||
dayWord,
|
dayWord,
|
||||||
formatTyped,
|
formatTyped,
|
||||||
hm,
|
hm,
|
||||||
lunchSpan,
|
lunchSpan,
|
||||||
normDay,
|
normDay,
|
||||||
parseT,
|
|
||||||
roundT,
|
roundT,
|
||||||
shortSummary,
|
shortSummary,
|
||||||
summary
|
summary
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
employees,
|
employees,
|
||||||
|
shift,
|
||||||
onexport,
|
onexport,
|
||||||
onrename,
|
onrename,
|
||||||
onback
|
onback
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
year: number;
|
year: number;
|
||||||
month: number;
|
month: number;
|
||||||
employees: Employee[];
|
employees: Employee[];
|
||||||
|
shift: Shifts;
|
||||||
onexport?: (p: { scope: 'all' | 'one'; year: number; month: number; employees: Employee[] }) => void;
|
onexport?: (p: { scope: 'all' | 'one'; year: number; month: number; employees: Employee[] }) => void;
|
||||||
onrename?: (id: Employee['id'], name: string) => void;
|
onrename?: (id: Employee['id'], name: string) => void;
|
||||||
onback?: () => void;
|
onback?: () => void;
|
||||||
@@ -62,7 +64,9 @@
|
|||||||
let toast = $state('');
|
let toast = $state('');
|
||||||
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
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 cur = $derived(all[emp]);
|
||||||
const issuesAll = $derived(all.reduce((n, x) => n + x.issues, 0));
|
const issuesAll = $derived(all.reduce((n, x) => n + x.issues, 0));
|
||||||
const infosAll = $derived(all.reduce((n, x) => n + x.infos, 0));
|
const infosAll = $derived(all.reduce((n, x) => n + x.infos, 0));
|
||||||
@@ -77,27 +81,31 @@
|
|||||||
// ---- Azioni sui dati ----
|
// ---- Azioni sui dati ----
|
||||||
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 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 ?? '');
|
const next = formatTyped(input.value, prev, (e as unknown as InputEvent).inputType ?? '');
|
||||||
values[emp][di][ci][ii] = next;
|
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
|
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[]) {
|
function fixDays(dis: number[]) {
|
||||||
for (const di of dis) {
|
for (const di of dis) {
|
||||||
const list = values[emp][di].flat().filter((x) => x !== '');
|
const times = values[emp][di].flat().filter((x) => x !== '');
|
||||||
list.sort((a, b) => (parseT(a) ?? 9999) - (parseT(b) ?? 9999));
|
values[emp][di] = assignToShifts(times, shift);
|
||||||
const day: Day = [[list[0] ?? ''], [list[1] ?? ''], [list[2] ?? ''], [list[3] ?? '']];
|
|
||||||
if (list.length > 4) day[3] = list.slice(3);
|
|
||||||
values[emp][di] = day;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLunch(dis: number[]) {
|
function addLunch(dis: number[]) {
|
||||||
for (const di of dis) {
|
for (const di of dis) {
|
||||||
const span = lunchSpan(values[emp][di]);
|
const span = lunchSpan(values[emp][di], shift);
|
||||||
if (span) values[emp][di] = [[span[0]], [LUNCH.start], [LUNCH.end], [span[1]]];
|
if (span) values[emp][di] = [[span[0]], [shift.out1], [shift.in2], [span[1]]];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +113,7 @@
|
|||||||
for (const di of dis) {
|
for (const di of dis) {
|
||||||
values[ei][di] = values[ei][di].map((c, ci) =>
|
values[ei][di] = values[ei][di].map((c, ci) =>
|
||||||
c.map((x, ii) => {
|
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;
|
if (r !== x) rounded[`${ei}-${di}-${ci}-${ii}`] = r;
|
||||||
return r;
|
return r;
|
||||||
})
|
})
|
||||||
@@ -222,6 +230,7 @@
|
|||||||
title={confirmTitle}
|
title={confirmTitle}
|
||||||
{scopeIssues}
|
{scopeIssues}
|
||||||
{scopeInfos}
|
{scopeInfos}
|
||||||
|
{shift}
|
||||||
items={confirmItems}
|
items={confirmItems}
|
||||||
onclose={() => (confirmScope = null)}
|
onclose={() => (confirmScope = null)}
|
||||||
onroundandexport={() => confirmScope && roundAndExport(confirmScope)}
|
onroundandexport={() => confirmScope && roundAndExport(confirmScope)}
|
||||||
@@ -280,6 +289,7 @@
|
|||||||
stackedCount={cur.stackedDays.length}
|
stackedCount={cur.stackedDays.length}
|
||||||
lunchCount={cur.lunchDays.length}
|
lunchCount={cur.lunchDays.length}
|
||||||
roundCount={cur.roundDays.length}
|
roundCount={cur.roundDays.length}
|
||||||
|
{shift}
|
||||||
onfixall={() => fixDays(cur.stackedDays)}
|
onfixall={() => fixDays(cur.stackedDays)}
|
||||||
onaddlunchall={() => addLunch(cur.lunchDays)}
|
onaddlunchall={() => addLunch(cur.lunchDays)}
|
||||||
onroundall={() => roundDaysOf(emp, cur.roundDays)}
|
onroundall={() => roundDaysOf(emp, cur.roundDays)}
|
||||||
@@ -290,6 +300,7 @@
|
|||||||
rows={visibleRows}
|
rows={visibleRows}
|
||||||
{rounded}
|
{rounded}
|
||||||
{emp}
|
{emp}
|
||||||
|
{shift}
|
||||||
oncellinput={onCellInput}
|
oncellinput={onCellInput}
|
||||||
onfixday={(di) => fixDays([di])}
|
onfixday={(di) => fixDays([di])}
|
||||||
onaddlunch={(di) => addLunch([di])}
|
onaddlunch={(di) => addLunch([di])}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from 'svelte-i18n';
|
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 FixButton from './FixButton.svelte';
|
||||||
import IconButton from './IconButton.svelte';
|
import IconButton from '../IconButton.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
name,
|
name,
|
||||||
@@ -17,6 +19,7 @@
|
|||||||
stackedCount,
|
stackedCount,
|
||||||
lunchCount,
|
lunchCount,
|
||||||
roundCount,
|
roundCount,
|
||||||
|
shift,
|
||||||
onfixall,
|
onfixall,
|
||||||
onaddlunchall,
|
onaddlunchall,
|
||||||
onroundall,
|
onroundall,
|
||||||
@@ -34,6 +37,7 @@
|
|||||||
stackedCount: number;
|
stackedCount: number;
|
||||||
lunchCount: number;
|
lunchCount: number;
|
||||||
roundCount: number;
|
roundCount: number;
|
||||||
|
shift: Shifts;
|
||||||
onfixall: () => void;
|
onfixall: () => void;
|
||||||
onaddlunchall: () => void;
|
onaddlunchall: () => void;
|
||||||
onroundall: () => void;
|
onroundall: () => void;
|
||||||
@@ -51,10 +55,6 @@
|
|||||||
editing = false;
|
editing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function autofocus(node: HTMLInputElement) {
|
|
||||||
node.focus();
|
|
||||||
node.select();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="main-head">
|
<div class="main-head">
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
{#if lunchCount}
|
{#if lunchCount}
|
||||||
<FixButton
|
<FixButton
|
||||||
size="lg"
|
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}
|
onclick={onaddlunchall}
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
|
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
|
||||||
>
|
>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{$_('review.round_all', { values: { step: ROUND_STEP, days: dayWord(roundCount, $_) } })}
|
{$_('review.round_all', { values: { step: shift.round_step_min, days: dayWord(roundCount, $_) } })}
|
||||||
</FixButton>
|
</FixButton>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="seg" role="group" aria-label={$_('review.day_filter_aria')}>
|
<div class="seg" role="group" aria-label={$_('review.day_filter_aria')}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { ROUND_STEP } from './analysis';
|
import type { Shifts } from '$lib/shifts';
|
||||||
import FixButton from './FixButton.svelte';
|
import FixButton from './FixButton.svelte';
|
||||||
import ActionButton from './ActionButton.svelte';
|
import ActionButton from './ActionButton.svelte';
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
title,
|
title,
|
||||||
scopeIssues,
|
scopeIssues,
|
||||||
scopeInfos,
|
scopeInfos,
|
||||||
|
shift,
|
||||||
items,
|
items,
|
||||||
onclose,
|
onclose,
|
||||||
onroundandexport,
|
onroundandexport,
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
title: string;
|
title: string;
|
||||||
scopeIssues: number;
|
scopeIssues: number;
|
||||||
scopeInfos: number;
|
scopeInfos: number;
|
||||||
|
shift: Shifts;
|
||||||
items: { key: string; label: string; detail: string; red: boolean; go: () => void }[];
|
items: { key: string; label: string; detail: string; red: boolean; go: () => void }[];
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
onroundandexport: () => void;
|
onroundandexport: () => void;
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
{#if scopeIssues}
|
{#if scopeIssues}
|
||||||
{$_('review.export_warning_issues')}
|
{$_('review.export_warning_issues')}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('review.export_warning_unrounded', { values: { step: ROUND_STEP } })}
|
{$_('review.export_warning_unrounded', { values: { step: shift.round_step_min } })}
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from 'svelte-i18n';
|
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';
|
import FixButton from './FixButton.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
rows,
|
rows,
|
||||||
rounded,
|
rounded,
|
||||||
emp,
|
emp,
|
||||||
|
shift,
|
||||||
oncellinput,
|
oncellinput,
|
||||||
onfixday,
|
onfixday,
|
||||||
onaddlunch,
|
onaddlunch,
|
||||||
@@ -15,6 +17,7 @@
|
|||||||
rows: Row[];
|
rows: Row[];
|
||||||
rounded: Record<string, string>;
|
rounded: Record<string, string>;
|
||||||
emp: number;
|
emp: number;
|
||||||
|
shift: Shifts;
|
||||||
oncellinput: (di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) => void;
|
oncellinput: (di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) => void;
|
||||||
onfixday: (di: number) => void;
|
onfixday: (di: number) => void;
|
||||||
onaddlunch: (di: number) => void;
|
onaddlunch: (di: number) => void;
|
||||||
@@ -49,7 +52,7 @@
|
|||||||
0
|
0
|
||||||
)}
|
)}
|
||||||
{@const manual = r.rowEdits - rnd}
|
{@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)}
|
{@const label = dayLabel(r)}
|
||||||
<tr class:we={r.weekend} class:anom={!!r.issue}>
|
<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>
|
<td class="first center"><span class="dow">{r.dow}</span><span class="mono">{r.di + 1}</span></td>
|
||||||
@@ -105,7 +108,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if r.lunch}
|
{#if r.lunch}
|
||||||
<FixButton
|
<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)}
|
onclick={() => onaddlunch(r.di)}
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#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
|
><path d="M7 3v8M5 3v4a2 2 0 0 0 4 0V3M7 11v10M17 21V3c-2 1-3 3.5-3 7h3" /></svg
|
||||||
>
|
>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{$_('review.lunch_short')} <span class="mono">{LUNCH.start}–{LUNCH.end}</span>
|
{$_('review.lunch_short')} <span class="mono">{shift.out1}–{shift.in2}</span>
|
||||||
</FixButton>
|
</FixButton>
|
||||||
{/if}
|
{/if}
|
||||||
{#if r.info}
|
{#if r.info}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { _, locale } from 'svelte-i18n';
|
import { _, locale } from 'svelte-i18n';
|
||||||
import { monthName } from '$lib/month';
|
import { monthName } from '$lib/month';
|
||||||
import { dayWord, shortSummary } from './analysis';
|
import { dayWord, shortSummary } from './analysis';
|
||||||
import IconButton from './IconButton.svelte';
|
import IconButton from '../IconButton.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
year,
|
year,
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
// svelte-i18n) e, dove serve la lingua per Intl, `locale` ($locale).
|
// svelte-i18n) e, dove serve la lingua per Intl, `locale` ($locale).
|
||||||
|
|
||||||
import { weekdayName } from '$lib/month';
|
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`
|
// 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;
|
||||||
@@ -50,10 +54,6 @@ export interface Analysis {
|
|||||||
roundDays: number[];
|
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 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 {
|
export function normDay(day: Day): Day {
|
||||||
@@ -63,73 +63,67 @@ export function normDay(day: Day): Day {
|
|||||||
}) as Day;
|
}) as Day;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseT(t: string): number | null {
|
export function isRounded(t: string, roundStep: number): boolean {
|
||||||
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 {
|
|
||||||
const m = parseT(t);
|
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);
|
const m = parseT(t);
|
||||||
if (m === null) return 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 {
|
export function dayWord(n: number, t: Translate): string {
|
||||||
return t('review.day_word', { values: { n } });
|
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. */
|
/** [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 !== '');
|
const all = day.flat().filter((x) => x !== '');
|
||||||
if (all.length !== 2) return null;
|
if (all.length !== 2) return null;
|
||||||
const t = all.map(parseT);
|
const t = all.map(parseT);
|
||||||
if (t.some((x) => x === null)) return null;
|
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]];
|
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) &&
|
return (parseT(a) as number) < (parseT(shift.out1) as number) &&
|
||||||
(parseT(b) as number) > (parseT(LUNCH.end) as number)
|
(parseT(b) as number) > (parseT(shift.in2) as number)
|
||||||
? [a, b]
|
? [a, b]
|
||||||
: null;
|
: 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(
|
export function analyze(
|
||||||
year: number,
|
year: number,
|
||||||
month: number,
|
month: number,
|
||||||
values: Day[],
|
values: Day[],
|
||||||
orig: Day[],
|
orig: Day[],
|
||||||
locale: string | null | undefined,
|
locale: string | null | undefined,
|
||||||
t: Translate
|
t: Translate,
|
||||||
|
shift: Shifts
|
||||||
): Analysis {
|
): Analysis {
|
||||||
const rows: Row[] = [];
|
const rows: Row[] = [];
|
||||||
let minutes = 0;
|
let minutes = 0;
|
||||||
@@ -156,8 +150,8 @@ export function analyze(
|
|||||||
// 1. Turno sbagliato
|
// 1. Turno sbagliato
|
||||||
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 shift = 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');
|
||||||
issue = t('review.issue_stacked', { values: { total, shift } });
|
issue = t('review.issue_stacked', { values: { total, shift: shiftLabel } });
|
||||||
[0, 1, 2, 3].forEach((i) => {
|
[0, 1, 2, 3].forEach((i) => {
|
||||||
if (!filled[i]) miss[i] = true;
|
if (!filled[i]) miss[i] = true;
|
||||||
});
|
});
|
||||||
@@ -165,7 +159,7 @@ export function analyze(
|
|||||||
// 2. Orario non valido
|
// 2. Orario non valido
|
||||||
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. Pausa pranzo non timbrata
|
||||||
const lunch = !stacked && !issue && lunchSpan(v) !== 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');
|
||||||
[0, 1, 2, 3].forEach((i) => {
|
[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 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 rowEdits = changed.reduce((n, c) => n + c.filter(Boolean).length, 0);
|
||||||
const wasStacked = o.some((c) => c.length > 1);
|
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
|
// 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 hasUnr = unr.some((c) => c.some(Boolean));
|
||||||
const info = !issue && hasUnr;
|
const info = !issue && hasUnr;
|
||||||
|
|
||||||
@@ -250,9 +244,15 @@ export function isRnd(
|
|||||||
return val !== '' && rounded[`${emp}-${di}-${ci}-${ii}`] === val;
|
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.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.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.wasLunch) return { text: t('review.note_lunch_added'), kind: 'edit' };
|
||||||
if (r.wasStacked) return { text: t('review.note_shifts_fixed'), 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",
|
"back_to_review": "Back to review",
|
||||||
"round_and_export": "Round and export",
|
"round_and_export": "Round and export",
|
||||||
"export_anyway": "Export anyway"
|
"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",
|
"back_to_review": "Torna alla revisione",
|
||||||
"round_and_export": "Arrotonda ed esporta",
|
"round_and_export": "Arrotonda ed esporta",
|
||||||
"export_anyway": "Esporta comunque"
|
"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,12 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
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 { Snippet } from 'svelte';
|
||||||
|
import type { Settings } from '$lib/settings';
|
||||||
|
|
||||||
let { children }: { children: Snippet } = $props();
|
let { children }: { children: Snippet } = $props();
|
||||||
|
|
||||||
let ready = $state(false);
|
let ready = $state(false);
|
||||||
waitLocale().then(() => {
|
invoke<Settings>('load_settings')
|
||||||
|
.catch(() => null)
|
||||||
|
.then((settings) => {
|
||||||
|
if (settings?.lang) locale.set(settings.lang);
|
||||||
|
return waitLocale();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
ready = true;
|
ready = true;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,24 +9,41 @@
|
|||||||
import ConfirmMonth from '$lib/components/ConfirmMonth.svelte';
|
import ConfirmMonth from '$lib/components/ConfirmMonth.svelte';
|
||||||
import LoadingScreen from '$lib/components/LoadingScreen.svelte';
|
import LoadingScreen from '$lib/components/LoadingScreen.svelte';
|
||||||
import TimesheetReview from '$lib/components/TimesheetReview.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 { 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 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 employeeNames = $state<Record<string, string>>({});
|
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 period = $derived(`${monthName($locale, selectedMonth)} ${selectedYear}`);
|
||||||
const reviewEmployees = $derived(buildEmployees(selectedYear, selectedMonth, records, employeeNames));
|
const reviewEmployees = $derived(buildEmployees(selectedYear, selectedMonth, records, employeeNames));
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const preferences = await invoke<{ host: string; port: number } | null>('load_preferences');
|
const settings = await invoke<SettingsData>('load_settings');
|
||||||
step = preferences ? 'confirm-month' : 'setup';
|
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 }) {
|
async function loadMonth(selection: { year: number; month: number }) {
|
||||||
selectedYear = selection.year;
|
selectedYear = selection.year;
|
||||||
selectedMonth = selection.month;
|
selectedMonth = selection.month;
|
||||||
@@ -49,9 +66,17 @@
|
|||||||
year={selectedYear}
|
year={selectedYear}
|
||||||
month={selectedMonth}
|
month={selectedMonth}
|
||||||
employees={reviewEmployees}
|
employees={reviewEmployees}
|
||||||
|
shift={shifts}
|
||||||
onback={() => (step = 'confirm-month')}
|
onback={() => (step = 'confirm-month')}
|
||||||
onexport={(payload) => invoke('export_timesheet', { payload })}
|
onexport={(payload) => invoke('export_timesheet', { payload })}
|
||||||
/>
|
/>
|
||||||
|
{:else if step === 'settings'}
|
||||||
|
<Settings
|
||||||
|
onback={closeSettings}
|
||||||
|
onsettingschange={(s: SettingsData) => {
|
||||||
|
shifts = s.shifts;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<Screen>
|
<Screen>
|
||||||
{#if step === 'loading'}
|
{#if step === 'loading'}
|
||||||
@@ -59,6 +84,7 @@
|
|||||||
{:else if step === 'setup'}
|
{:else if step === 'setup'}
|
||||||
<Onboarding onconnected={() => (step = 'confirm-month')} />
|
<Onboarding onconnected={() => (step = 'confirm-month')} />
|
||||||
{:else if step === 'confirm-month'}
|
{:else if step === 'confirm-month'}
|
||||||
|
<SettingsButton ariaLabel={$_('settings.title')} onclick={openSettings} />
|
||||||
<ConfirmMonth onstart={loadMonth} />
|
<ConfirmMonth onstart={loadMonth} />
|
||||||
{:else if step === 'loading-records'}
|
{:else if step === 'loading-records'}
|
||||||
<LoadingScreen
|
<LoadingScreen
|
||||||
|
|||||||
Reference in New Issue
Block a user