feat(oxibadge): add timesheet review and export screen
Check, format and test / build (push) Successful in 10m16s
Check, format and test / build (push) Successful in 10m16s
Add the month-review screen shown after records/employees load: an editable per-employee timesheet table with issue/rounding detection, bulk fixes (reorder stacked punches, add lunch break, round to step), and an export flow (single employee or all) wired to a new Rust export_timesheet command. Split into focused components under review/ instead of one large file, with shared i18n keys and a new export_timesheet IPC command. Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ mod preferences;
|
||||
use chrono::{Local, TimeZone};
|
||||
use preferences::Reader;
|
||||
use r701::{Record, R701};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use tauri::AppHandle;
|
||||
use tokio_stream::StreamExt;
|
||||
@@ -75,14 +76,10 @@ async fn load_records(app: AppHandle, year: i32, month: u32) -> Result<Vec<Recor
|
||||
|
||||
records.reverse();
|
||||
|
||||
dbg!(&records);
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// Resolves the name of every distinct employee in `records`, printing both
|
||||
/// the records and the resulting employee map to stdout before returning
|
||||
/// the map.
|
||||
/// Resolves the name of every distinct employee in `records`.
|
||||
#[tauri::command]
|
||||
async fn load_employees(
|
||||
app: AppHandle,
|
||||
@@ -108,11 +105,42 @@ async fn load_employees(
|
||||
}
|
||||
}
|
||||
|
||||
dbg!(&employees);
|
||||
|
||||
Ok(employees)
|
||||
}
|
||||
|
||||
/// One employee's corrected timesheet for a month, as sent by the review
|
||||
/// screen's export button. `days[d][c]` is the list of times (usually 0
|
||||
/// or 1, more if stacked) punched in slot `c` (entry/exit of the 1st/2nd
|
||||
/// shift) on day `d + 1`.
|
||||
///
|
||||
/// For now `export_timesheet` only `dbg!`-prints this, so none of the
|
||||
/// fields are read outside of the derived `Debug` impl (which the
|
||||
/// dead-code lint deliberately ignores).
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct EmployeeTimesheet {
|
||||
id: u32,
|
||||
name: String,
|
||||
days: Vec<Vec<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ExportPayload {
|
||||
scope: String,
|
||||
year: i32,
|
||||
month: u32,
|
||||
employees: Vec<EmployeeTimesheet>,
|
||||
}
|
||||
|
||||
/// Placeholder export: just prints each employee's corrected timesheet.
|
||||
#[tauri::command]
|
||||
fn export_timesheet(payload: ExportPayload) {
|
||||
dbg!(&payload.scope, payload.year, payload.month);
|
||||
for employee in &payload.employees {
|
||||
dbg!(employee);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
@@ -121,7 +149,8 @@ pub fn run() {
|
||||
save_preferences,
|
||||
test_connection,
|
||||
load_records,
|
||||
load_employees
|
||||
load_employees,
|
||||
export_timesheet
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "Oxibadge",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
"width": 1200,
|
||||
"height": 700
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -59,3 +59,30 @@ h1 {
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--ox-text-strong);
|
||||
}
|
||||
|
||||
.red {
|
||||
color: #ff9ea3;
|
||||
}
|
||||
|
||||
.blue {
|
||||
color: #8db8ff;
|
||||
}
|
||||
|
||||
.orange {
|
||||
color: #ff9a66;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3a3a3a transparent;
|
||||
}
|
||||
|
||||
.scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scroll::-webkit-scrollbar-thumb {
|
||||
background: #333333;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { monthName } from '$lib/month';
|
||||
import {
|
||||
type Day,
|
||||
type Employee,
|
||||
LUNCH,
|
||||
analyze,
|
||||
dayWord,
|
||||
formatTyped,
|
||||
hm,
|
||||
lunchSpan,
|
||||
normDay,
|
||||
parseT,
|
||||
roundT,
|
||||
shortSummary,
|
||||
summary
|
||||
} from './review/analysis';
|
||||
import TopBar from './review/TopBar.svelte';
|
||||
import EmployeeSidebar from './review/EmployeeSidebar.svelte';
|
||||
import EmployeeHeader from './review/EmployeeHeader.svelte';
|
||||
import TimesheetTable from './review/TimesheetTable.svelte';
|
||||
import ExportModal from './review/ExportModal.svelte';
|
||||
import Toast from './review/Toast.svelte';
|
||||
import ActionButton from './review/ActionButton.svelte';
|
||||
|
||||
let {
|
||||
year,
|
||||
month,
|
||||
employees,
|
||||
onexport,
|
||||
onrename,
|
||||
onback
|
||||
}: {
|
||||
year: number;
|
||||
month: number;
|
||||
employees: Employee[];
|
||||
onexport?: (p: { scope: 'all' | 'one'; year: number; month: number; employees: Employee[] }) => void;
|
||||
onrename?: (id: Employee['id'], name: string) => void;
|
||||
onback?: () => void;
|
||||
} = $props();
|
||||
|
||||
// Copie di lavoro: il componente non modifica le props. Per caricare un
|
||||
// altro mese va rimontato (nella nostra app accade già da sé, dato che
|
||||
// la schermata è dietro un {#if step === 'review'}). `untrack` rende
|
||||
// esplicito che leggiamo `employees` una sola volta, di proposito.
|
||||
const orig: Day[][] = untrack(() => employees.map((e) => e.days.map(normDay)));
|
||||
let values = $state<Day[][]>(untrack(() => employees.map((e) => e.days.map(normDay))));
|
||||
let names = $state<string[]>(untrack(() => employees.map((e) => e.name)));
|
||||
/** "dip-giorno-casella-indice" -> orario prodotto dall'arrotondamento */
|
||||
let rounded = $state<Record<string, string>>({});
|
||||
|
||||
// ---- Stato UI ----
|
||||
let emp = $state(0);
|
||||
let query = $state('');
|
||||
let onlyIssues = $state(false);
|
||||
let editingName = $state(false);
|
||||
let draft = $state('');
|
||||
let menuOpen = $state(false);
|
||||
let confirmScope = $state<'all' | 'one' | null>(null);
|
||||
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 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));
|
||||
|
||||
const visibleEmps = $derived(
|
||||
names
|
||||
.map((name, ei) => ({ name, ei, issues: all[ei].issues, infos: all[ei].infos }))
|
||||
.filter((e) => !query.trim() || e.name.toLowerCase().includes(query.trim().toLowerCase()))
|
||||
);
|
||||
const visibleRows = $derived(cur.rows.filter((r) => !onlyIssues || r.issue || r.info));
|
||||
|
||||
// ---- 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 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
|
||||
}
|
||||
|
||||
/** Ordina tutte le timbrature dei giorni e le ridistribuisce in E1, U1, E2, U2. */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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]]];
|
||||
}
|
||||
}
|
||||
|
||||
function roundDaysOf(ei: number, dis: number[]) {
|
||||
for (const di of dis) {
|
||||
values[ei][di] = values[ei][di].map((c, ci) =>
|
||||
c.map((x, ii) => {
|
||||
const r = roundT(x);
|
||||
if (r !== x) rounded[`${ei}-${di}-${ci}-${ii}`] = r;
|
||||
return r;
|
||||
})
|
||||
) as Day;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Nome ----
|
||||
function renameCurrent(name: string) {
|
||||
names[emp] = name;
|
||||
onrename?.(employees[emp].id, name);
|
||||
}
|
||||
|
||||
// ---- Esportazione ----
|
||||
function requestExport(scope: 'all' | 'one') {
|
||||
menuOpen = false;
|
||||
const pending = scope === 'all' ? issuesAll + infosAll : cur.issues + cur.infos;
|
||||
if (pending > 0) confirmScope = scope;
|
||||
else doExport(scope);
|
||||
}
|
||||
|
||||
function doExport(scope: 'all' | 'one') {
|
||||
const pick = scope === 'all' ? names.map((_name, i) => i) : [emp];
|
||||
const snap = $state.snapshot(values);
|
||||
onexport?.({
|
||||
scope,
|
||||
year,
|
||||
month,
|
||||
employees: pick.map((ei) => ({
|
||||
id: employees[ei].id,
|
||||
name: names[ei],
|
||||
days: snap[ei].map((d) => d.map((c) => c.filter((x) => x !== ''))) as Day[]
|
||||
}))
|
||||
});
|
||||
confirmScope = null;
|
||||
menuOpen = false;
|
||||
toast = $_('review.export_done', {
|
||||
values: { who: scope === 'all' ? $_('review.everyone') : names[emp] }
|
||||
});
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => (toast = ''), 3200);
|
||||
}
|
||||
|
||||
function roundAndExport(scope: 'all' | 'one') {
|
||||
const eis = scope === 'all' ? names.map((_name, i) => i) : [emp];
|
||||
for (const ei of eis) roundDaysOf(ei, all[ei].roundDays);
|
||||
doExport(scope);
|
||||
}
|
||||
|
||||
$effect(() => () => clearTimeout(toastTimer));
|
||||
|
||||
// Testi dell'avviso di esportazione
|
||||
const scopeIssues = $derived(confirmScope === 'all' ? issuesAll : cur.issues);
|
||||
const scopeInfos = $derived(confirmScope === 'all' ? infosAll : cur.infos);
|
||||
const confirmSummary = $derived(
|
||||
scopeIssues
|
||||
? summary(scopeIssues, scopeInfos, $_)
|
||||
: $_('review.only_unrounded', { values: { days: dayWord(scopeInfos, $_) } })
|
||||
);
|
||||
const confirmTitle = $derived(
|
||||
confirmScope === 'all'
|
||||
? $_('review.confirm_title_all', { values: { summary: confirmSummary } })
|
||||
: $_('review.confirm_title_one', { values: { name: names[emp], summary: confirmSummary } })
|
||||
);
|
||||
const confirmItems = $derived(
|
||||
confirmScope === 'all'
|
||||
? names
|
||||
.map((name, ei) => ({ name, ei, iss: all[ei].issues, inf: all[ei].infos }))
|
||||
.filter((x) => x.iss || x.inf)
|
||||
.map((x) => ({
|
||||
key: 'e' + x.ei,
|
||||
label: x.name,
|
||||
detail: shortSummary(x.iss, x.inf, $_),
|
||||
red: x.iss > 0,
|
||||
go: () => {
|
||||
confirmScope = null;
|
||||
emp = x.ei;
|
||||
onlyIssues = true;
|
||||
editingName = false;
|
||||
}
|
||||
}))
|
||||
: cur.rows
|
||||
.filter((r) => r.issue || r.info)
|
||||
.map((r) => ({
|
||||
key: 'd' + r.di,
|
||||
label: r.dow + ' ' + (r.di + 1),
|
||||
detail: r.issue || $_('review.to_round_cap'),
|
||||
red: !!r.issue,
|
||||
go: () => {
|
||||
confirmScope = null;
|
||||
onlyIssues = true;
|
||||
}
|
||||
}))
|
||||
);
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (confirmScope) confirmScope = null;
|
||||
else if (menuOpen) menuOpen = false;
|
||||
else if (editingName) editingName = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<div class="screen">
|
||||
{#if menuOpen}
|
||||
<button type="button" class="backdrop" aria-label={$_('review.close_menu_aria')} onclick={() => (menuOpen = false)}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
{#if confirmScope}
|
||||
<ExportModal
|
||||
title={confirmTitle}
|
||||
{scopeIssues}
|
||||
{scopeInfos}
|
||||
items={confirmItems}
|
||||
onclose={() => (confirmScope = null)}
|
||||
onroundandexport={() => confirmScope && roundAndExport(confirmScope)}
|
||||
onexportanyway={() => confirmScope && doExport(confirmScope)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Toast message={toast} />
|
||||
|
||||
{#if names.length === 0}
|
||||
<div class="empty-month">
|
||||
<h1>{monthName($locale, month)} {year}</h1>
|
||||
<p>{$_('review.no_records')}</p>
|
||||
{#if onback}
|
||||
<ActionButton onclick={onback}>{$_('review.back_to_month')}</ActionButton>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<TopBar
|
||||
{year}
|
||||
{month}
|
||||
{issuesAll}
|
||||
{infosAll}
|
||||
{onback}
|
||||
bind:menuOpen
|
||||
employeeCount={names.length}
|
||||
currentName={names[emp]}
|
||||
currentIssues={cur.issues}
|
||||
currentInfos={cur.infos}
|
||||
onexportall={() => requestExport('all')}
|
||||
onexportone={() => requestExport('one')}
|
||||
/>
|
||||
|
||||
<div class="body">
|
||||
<EmployeeSidebar
|
||||
employees={visibleEmps}
|
||||
selected={emp}
|
||||
bind:query
|
||||
onselect={(ei) => {
|
||||
emp = ei;
|
||||
editingName = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
<main class="main">
|
||||
<EmployeeHeader
|
||||
name={names[emp]}
|
||||
bind:editing={editingName}
|
||||
bind:draft
|
||||
onsave={renameCurrent}
|
||||
worked={cur.worked}
|
||||
totalLabel={hm(cur.minutes)}
|
||||
issues={cur.issues}
|
||||
infos={cur.infos}
|
||||
edits={cur.edits}
|
||||
stackedCount={cur.stackedDays.length}
|
||||
lunchCount={cur.lunchDays.length}
|
||||
roundCount={cur.roundDays.length}
|
||||
onfixall={() => fixDays(cur.stackedDays)}
|
||||
onaddlunchall={() => addLunch(cur.lunchDays)}
|
||||
onroundall={() => roundDaysOf(emp, cur.roundDays)}
|
||||
bind:onlyIssues
|
||||
/>
|
||||
|
||||
<TimesheetTable
|
||||
rows={visibleRows}
|
||||
{rounded}
|
||||
{emp}
|
||||
oncellinput={onCellInput}
|
||||
onfixday={(di) => fixDays([di])}
|
||||
onaddlunch={(di) => addLunch([di])}
|
||||
onroundday={(di) => roundDaysOf(emp, [di])}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.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);
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
all: unset;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 20px 24px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty-month {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty-month h1 {
|
||||
margin: 0;
|
||||
font-family: var(--ox-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.025em;
|
||||
color: var(--ox-text-strong);
|
||||
}
|
||||
|
||||
.empty-month p {
|
||||
margin: 0;
|
||||
color: var(--ox-text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.body {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
primary = false,
|
||||
onclick,
|
||||
icon,
|
||||
children
|
||||
}: {
|
||||
primary?: boolean;
|
||||
onclick: () => void;
|
||||
icon?: Snippet;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button type="button" class="btn" class:primary {onclick}>
|
||||
{#if icon}{@render icon()}{/if}
|
||||
<span>{@render children()}</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 12px;
|
||||
color: #e6e6e6;
|
||||
font-size: 14.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #1e1e1e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--ox-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
padding: 0 18px;
|
||||
border: none;
|
||||
background: var(--ox-accent);
|
||||
color: var(--ox-on-accent);
|
||||
font-weight: 600;
|
||||
transition:
|
||||
filter 0.15s,
|
||||
transform 0.1s;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background: var(--ox-accent);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.btn.primary:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn.primary:focus-visible {
|
||||
outline: 2px solid #ffffff;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,276 @@
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { LUNCH, ROUND_STEP, dayWord } from './analysis';
|
||||
import FixButton from './FixButton.svelte';
|
||||
import IconButton from './IconButton.svelte';
|
||||
|
||||
let {
|
||||
name,
|
||||
editing = $bindable(false),
|
||||
draft = $bindable(''),
|
||||
onsave,
|
||||
worked,
|
||||
totalLabel,
|
||||
issues,
|
||||
infos,
|
||||
edits,
|
||||
stackedCount,
|
||||
lunchCount,
|
||||
roundCount,
|
||||
onfixall,
|
||||
onaddlunchall,
|
||||
onroundall,
|
||||
onlyIssues = $bindable(false)
|
||||
}: {
|
||||
name: string;
|
||||
editing?: boolean;
|
||||
draft?: string;
|
||||
onsave: (name: string) => void;
|
||||
worked: number;
|
||||
totalLabel: string;
|
||||
issues: number;
|
||||
infos: number;
|
||||
edits: number;
|
||||
stackedCount: number;
|
||||
lunchCount: number;
|
||||
roundCount: number;
|
||||
onfixall: () => void;
|
||||
onaddlunchall: () => void;
|
||||
onroundall: () => void;
|
||||
onlyIssues?: boolean;
|
||||
} = $props();
|
||||
|
||||
function startEdit() {
|
||||
draft = name;
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function saveName() {
|
||||
const n = draft.trim();
|
||||
if (n && n !== name) onsave(n);
|
||||
editing = false;
|
||||
}
|
||||
|
||||
function autofocus(node: HTMLInputElement) {
|
||||
node.focus();
|
||||
node.select();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="main-head">
|
||||
<div class="who">
|
||||
{#if editing}
|
||||
<div class="name-row">
|
||||
<input
|
||||
class="name-input"
|
||||
type="text"
|
||||
aria-label={$_('review.employee_name_aria')}
|
||||
bind:value={draft}
|
||||
use:autofocus
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') saveName();
|
||||
}}
|
||||
/>
|
||||
<IconButton accent ariaLabel={$_('review.save_name')} title={$_('review.save')} onclick={saveName}>
|
||||
{#snippet icon()}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
|
||||
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
<IconButton ariaLabel={$_('review.cancel')} title={$_('review.cancel')} onclick={() => (editing = false)}>
|
||||
{#snippet icon()}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><path d="M6 6l12 12M18 6L6 18" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="name-row">
|
||||
<h2>{name}</h2>
|
||||
<IconButton ariaLabel={$_('review.edit_name')} title={$_('review.edit_name')} onclick={startEdit}>
|
||||
{#snippet icon()}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="stats">
|
||||
<span><b>{worked}</b> {$_('review.days_worked')}</span>
|
||||
<span><b>{totalLabel}</b> {$_('review.total_hours')}</span>
|
||||
<span><b class:red={issues > 0}>{issues}</b> {$_('review.to_check')}</span>
|
||||
<span><b class:blue={infos > 0}>{infos}</b> {$_('review.to_round')}</span>
|
||||
<span><b class:orange={edits > 0}>{edits}</b> {$_('review.edited')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tools">
|
||||
{#if stackedCount}
|
||||
<FixButton size="lg" title={$_('review.fix_shifts_title')} onclick={onfixall}>
|
||||
{#snippet icon()}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><path d="M7 4v16M3 8l4-4 4 4M17 20V4M13 16l4 4 4-4" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
{$_('review.fix_shifts', { values: { days: dayWord(stackedCount, $_) } })}
|
||||
</FixButton>
|
||||
{/if}
|
||||
{#if lunchCount}
|
||||
<FixButton
|
||||
size="lg"
|
||||
title={$_('review.add_lunch_title', { values: { start: LUNCH.start, end: LUNCH.end } })}
|
||||
onclick={onaddlunchall}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><path d="M7 3v8M5 3v4a2 2 0 0 0 4 0V3M7 11v10M17 21V3c-2 1-3 3.5-3 7h3" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
{$_('review.add_lunch', { values: { days: dayWord(lunchCount, $_) } })}
|
||||
</FixButton>
|
||||
{/if}
|
||||
{#if roundCount}
|
||||
<FixButton size="lg" blue title={$_('review.round_title')} onclick={onroundall}>
|
||||
{#snippet icon()}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><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, $_) } })}
|
||||
</FixButton>
|
||||
{/if}
|
||||
<div class="seg" role="group" aria-label={$_('review.day_filter_aria')}>
|
||||
<button type="button" class:on={!onlyIssues} aria-pressed={!onlyIssues} onclick={() => (onlyIssues = false)}
|
||||
>{$_('review.all_days')}</button
|
||||
>
|
||||
<button type="button" class:on={onlyIssues} aria-pressed={onlyIssues} onclick={() => (onlyIssues = true)}
|
||||
>{$_('review.to_check_cap')}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.main-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px 24px;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.name-row h2 {
|
||||
margin: 0;
|
||||
font-family: var(--ox-font-display);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.02em;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.name-input {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
height: 38px;
|
||||
width: 300px;
|
||||
max-width: 100%;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--ox-accent);
|
||||
box-shadow: 0 0 0 4px var(--ox-accent-ring);
|
||||
border-radius: 10px;
|
||||
font-family: var(--ox-font-display);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.02em;
|
||||
color: #ffffff;
|
||||
caret-color: var(--ox-accent);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 18px;
|
||||
font-size: 13px;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.stats b {
|
||||
font-family: var(--ox-font-mono);
|
||||
font-weight: 400;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
/* .stats b's own color above otherwise beats the global .red/.blue/.orange
|
||||
utility on specificity, since it combines a class with a tag. */
|
||||
.stats b.red {
|
||||
color: #ff9ea3;
|
||||
}
|
||||
|
||||
.stats b.blue {
|
||||
color: #8db8ff;
|
||||
}
|
||||
|
||||
.stats b.orange {
|
||||
color: #ff9a66;
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.seg button {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #a3a3a3;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seg button:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.seg button:focus-visible {
|
||||
outline: 2px solid var(--ox-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.seg button.on {
|
||||
background: #2a2a2a;
|
||||
color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { initials } from './analysis';
|
||||
|
||||
let {
|
||||
employees,
|
||||
selected,
|
||||
query = $bindable(''),
|
||||
onselect
|
||||
}: {
|
||||
employees: { name: string; ei: number; issues: number; infos: number }[];
|
||||
selected: number;
|
||||
query?: string;
|
||||
onselect: (ei: number) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<nav class="side scroll" aria-label={$_('review.employees_nav_aria')}>
|
||||
<div class="search">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><circle cx="11" cy="11" r="6.5" /><path d="M20 20l-4-4" /></svg
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
placeholder={$_('review.search_employee')}
|
||||
aria-label={$_('review.search_employee')}
|
||||
bind:value={query}
|
||||
/>
|
||||
</div>
|
||||
<div class="emp-list">
|
||||
{#each employees as e (e.ei)}
|
||||
<button
|
||||
type="button"
|
||||
class="emp"
|
||||
class:on={e.ei === selected}
|
||||
aria-current={e.ei === selected ? 'true' : undefined}
|
||||
onclick={() => onselect(e.ei)}
|
||||
>
|
||||
<span class="avatar">{initials(e.name)}</span>
|
||||
<span class="emp-name">{e.name}</span>
|
||||
{#if e.issues}
|
||||
<span class="badge red" aria-label={$_('review.days_to_check_aria', { values: { n: e.issues } })}
|
||||
>{e.issues}</span
|
||||
>
|
||||
{:else if e.infos}
|
||||
<span class="badge blue" aria-label={$_('review.days_to_round_aria', { values: { n: e.infos } })}
|
||||
>{e.infos}</span
|
||||
>
|
||||
{:else}
|
||||
<svg class="ok" width="16" height="16" viewBox="0 0 24 24" aria-label={$_('review.no_problem_aria')}
|
||||
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.side {
|
||||
width: 260px;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
padding: 16px 12px;
|
||||
border-right: 1px solid #222222;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.search {
|
||||
height: 40px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 10px;
|
||||
color: #6e6e6e;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.search:focus-within {
|
||||
border-color: var(--ox-accent);
|
||||
box-shadow: 0 0 0 4px var(--ox-accent-ring);
|
||||
}
|
||||
|
||||
.search input {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
color: #f2f2f2;
|
||||
caret-color: var(--ox-accent);
|
||||
}
|
||||
|
||||
.search input::placeholder {
|
||||
color: #5e5e5e;
|
||||
}
|
||||
|
||||
.emp-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.emp {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: #bdbdbd;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.emp:hover {
|
||||
background: #1c1c1c;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.emp:focus-visible {
|
||||
outline: 2px solid var(--ox-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.emp.on {
|
||||
background: #222222;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: #262626;
|
||||
color: #c9c9c9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.emp-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.badge.red {
|
||||
background: rgba(229, 72, 77, 0.16);
|
||||
color: #ff9ea3;
|
||||
}
|
||||
|
||||
.badge.blue {
|
||||
background: rgba(77, 141, 255, 0.14);
|
||||
color: #8db8ff;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #4cc38a;
|
||||
stroke-width: 2.2;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.side {
|
||||
width: auto;
|
||||
max-height: 220px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #222222;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { ROUND_STEP } from './analysis';
|
||||
import FixButton from './FixButton.svelte';
|
||||
import ActionButton from './ActionButton.svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
scopeIssues,
|
||||
scopeInfos,
|
||||
items,
|
||||
onclose,
|
||||
onroundandexport,
|
||||
onexportanyway
|
||||
}: {
|
||||
title: string;
|
||||
scopeIssues: number;
|
||||
scopeInfos: number;
|
||||
items: { key: string; label: string; detail: string; red: boolean; go: () => void }[];
|
||||
onclose: () => void;
|
||||
onroundandexport: () => void;
|
||||
onexportanyway: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="modal-wrap">
|
||||
<button type="button" class="modal-backdrop" aria-label={$_('review.cancel_export_aria')} onclick={onclose}
|
||||
></button>
|
||||
<div class="modal" role="alertdialog" aria-modal="true" aria-labelledby="ox-exp-title" aria-describedby="ox-exp-desc">
|
||||
<div class="modal-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
|
||||
d="M12 9.5v4M12 17h.01"
|
||||
/></svg
|
||||
>
|
||||
</div>
|
||||
<div class="modal-text">
|
||||
<h2 id="ox-exp-title">{title}</h2>
|
||||
<p id="ox-exp-desc">
|
||||
{#if scopeIssues}
|
||||
{$_('review.export_warning_issues')}
|
||||
{:else}
|
||||
{$_('review.export_warning_unrounded', { values: { step: ROUND_STEP } })}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-list scroll">
|
||||
{#each items as it (it.key)}
|
||||
<button type="button" class="menu-item row-item" onclick={it.go}>
|
||||
<span class="mi-label">{it.label}</span>
|
||||
<span class="mi-detail" class:red={it.red} class:blue={!it.red}>{it.detail}</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" class="mi-chev"
|
||||
><path d="M9 6l6 6-6 6" /></svg
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<ActionButton onclick={onclose}>{$_('review.back_to_review')}</ActionButton>
|
||||
{#if scopeIssues === 0 && scopeInfos > 0}
|
||||
<FixButton size="tall" blue onclick={onroundandexport}>
|
||||
{#snippet icon()}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
{$_('review.round_and_export')}
|
||||
</FixButton>
|
||||
{/if}
|
||||
<ActionButton primary onclick={onexportanyway}>
|
||||
{#snippet icon()}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
{$_('review.export_anyway')}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
all: unset;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: rgba(229, 72, 77, 0.14);
|
||||
color: #ff6b74;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-text h2 {
|
||||
margin: 0;
|
||||
font-family: var(--ox-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.modal-text p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.modal-list {
|
||||
max-height: 196px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
background: #151515;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: #bdbdbd;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.menu-item:focus-visible {
|
||||
outline: 2px solid var(--ox-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.row-item {
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.mi-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: #e6e6e6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mi-detail {
|
||||
font-size: 12.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mi-chev {
|
||||
color: #6e6e6e;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
size = 'sm',
|
||||
blue = false,
|
||||
title,
|
||||
ariaLabel,
|
||||
onclick,
|
||||
icon,
|
||||
children
|
||||
}: {
|
||||
size?: 'sm' | 'lg' | 'tall';
|
||||
blue?: boolean;
|
||||
title?: string;
|
||||
ariaLabel?: string;
|
||||
onclick: () => void;
|
||||
icon: Snippet;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="fix"
|
||||
class:lg={size === 'lg'}
|
||||
class:tall={size === 'tall'}
|
||||
class:blue
|
||||
{title}
|
||||
aria-label={ariaLabel}
|
||||
{onclick}
|
||||
>
|
||||
{@render icon()}
|
||||
<span>{@render children()}</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.fix {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #5c3320;
|
||||
color: #ff9a66;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.fix:hover {
|
||||
background: rgba(247, 76, 0, 0.12);
|
||||
border-color: var(--ox-accent);
|
||||
color: #ffb08c;
|
||||
}
|
||||
|
||||
.fix:focus-visible {
|
||||
outline: 2px solid var(--ox-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.fix.lg {
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.fix.tall {
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
.fix.blue {
|
||||
border-color: #27406a;
|
||||
color: #8db8ff;
|
||||
}
|
||||
|
||||
.fix.blue:hover {
|
||||
background: rgba(77, 141, 255, 0.12);
|
||||
border-color: #4d8dff;
|
||||
color: #b3ceff;
|
||||
}
|
||||
|
||||
.fix.blue:focus-visible {
|
||||
outline-color: #4d8dff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
size = 'sm',
|
||||
accent = false,
|
||||
title,
|
||||
ariaLabel,
|
||||
onclick,
|
||||
icon
|
||||
}: {
|
||||
size?: 'sm' | 'lg';
|
||||
accent?: boolean;
|
||||
title?: string;
|
||||
ariaLabel: string;
|
||||
onclick: () => void;
|
||||
icon: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button type="button" class="icon-btn" class:lg={size === 'lg'} class:accent {title} aria-label={ariaLabel} {onclick}>
|
||||
{@render icon()}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.icon-btn {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: #8a8a8a;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.icon-btn.lg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: #1e1e1e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.icon-btn:focus-visible {
|
||||
outline: 2px solid var(--ox-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.icon-btn.accent {
|
||||
background: var(--ox-accent);
|
||||
color: var(--ox-on-accent);
|
||||
}
|
||||
|
||||
.icon-btn.accent:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,395 @@
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { COLS_KEYS, LUNCH, type Row, cellClass, dayLabel, hm, isRnd, noteOf } from './analysis';
|
||||
import FixButton from './FixButton.svelte';
|
||||
|
||||
let {
|
||||
rows,
|
||||
rounded,
|
||||
emp,
|
||||
oncellinput,
|
||||
onfixday,
|
||||
onaddlunch,
|
||||
onroundday
|
||||
}: {
|
||||
rows: Row[];
|
||||
rounded: Record<string, string>;
|
||||
emp: number;
|
||||
oncellinput: (di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) => void;
|
||||
onfixday: (di: number) => void;
|
||||
onaddlunch: (di: number) => void;
|
||||
onroundday: (di: number) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="table-box scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 92px"></th>
|
||||
<th class="grp" colspan="2"><span>{$_('review.shift_1')}</span></th>
|
||||
<th class="grp" colspan="2"><span>{$_('review.shift_2')}</span></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="first center">{$_('review.col_day')}</th>
|
||||
<th class="center">{$_('review.col_in')}</th>
|
||||
<th class="center">{$_('review.col_out')}</th>
|
||||
<th class="center">{$_('review.col_in')}</th>
|
||||
<th class="center">{$_('review.col_out')}</th>
|
||||
<th class="center">{$_('review.col_hours')}</th>
|
||||
<th style="width: 100%">{$_('review.col_notes')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as r (r.di)}
|
||||
{@const rnd = r.v.reduce(
|
||||
(n, c, ci) => n + c.filter((x, ii) => isRnd(rounded, emp, r.di, ci, ii, x)).length,
|
||||
0
|
||||
)}
|
||||
{@const manual = r.rowEdits - rnd}
|
||||
{@const note = noteOf(r, rnd, manual, $_)}
|
||||
{@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>
|
||||
|
||||
{#each r.v as list, ci}
|
||||
{@const many = list.filter((x) => x !== '').length > 1}
|
||||
<td class="center">
|
||||
<div class="slot" class:stack={many} class:gone={r.stacked && r.miss[ci]}>
|
||||
{#each list as val, ii}
|
||||
<input
|
||||
class={cellClass(r, ci, ii, isRnd(rounded, emp, r.di, ci, ii, val))}
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="5"
|
||||
placeholder="--:--"
|
||||
value={val}
|
||||
aria-label={list.length > 1
|
||||
? $_('review.cell_aria_stacked', {
|
||||
values: { label, col: $_(COLS_KEYS[ci]), index: ii + 1, total: list.length }
|
||||
})
|
||||
: $_('review.cell_aria', { values: { label, col: $_(COLS_KEYS[ci]) } })}
|
||||
oninput={(e) => oncellinput(r.di, ci, ii, e)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
{/each}
|
||||
|
||||
<td class="center mono" class:dim={!r.mins}>{r.mins ? hm(r.mins) : '—'}</td>
|
||||
|
||||
<td class="note {note.kind}">
|
||||
<span class="note-in">
|
||||
{#if note.kind === 'issue'}
|
||||
<svg width="14" height="14" 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
|
||||
>
|
||||
{:else if note.kind === 'info'}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></svg
|
||||
>
|
||||
{/if}
|
||||
<span>{note.text}</span>
|
||||
|
||||
{#if r.stacked}
|
||||
<FixButton ariaLabel={$_('review.fix_shifts_day_aria', { values: { label } })} onclick={() => onfixday(r.di)}>
|
||||
{#snippet icon()}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><path d="M7 4v16M3 8l4-4 4 4M17 20V4M13 16l4 4 4-4" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
{$_('review.fix_shifts_short')}
|
||||
</FixButton>
|
||||
{/if}
|
||||
{#if r.lunch}
|
||||
<FixButton
|
||||
ariaLabel={$_('review.add_lunch_day_aria', { values: { start: LUNCH.start, end: LUNCH.end, label } })}
|
||||
onclick={() => onaddlunch(r.di)}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><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>
|
||||
</FixButton>
|
||||
{/if}
|
||||
{#if r.info}
|
||||
<FixButton blue ariaLabel={$_('review.round_day_aria', { values: { label } })} onclick={() => onroundday(r.di)}>
|
||||
{#snippet icon()}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
|
||||
>
|
||||
{/snippet}
|
||||
{$_('review.round_short')}
|
||||
</FixButton>
|
||||
{/if}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{#if rows.length === 0}
|
||||
<div class="empty">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><circle cx="12" cy="12" r="9" /><path d="M8 12.5l2.5 2.5L16 9.5" /></svg
|
||||
>
|
||||
<span>{$_('review.no_rows')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mono {
|
||||
font-family: var(--ox-font-mono);
|
||||
}
|
||||
|
||||
.table-box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 14px;
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
background: #161616;
|
||||
font-weight: 500;
|
||||
color: #8a8a8a;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
thead tr:first-child th {
|
||||
top: 0;
|
||||
height: 30px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #6e6e6e;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
thead tr:last-child th {
|
||||
top: 30px;
|
||||
height: 34px;
|
||||
font-size: 12.5px;
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
th.grp {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
th.grp span {
|
||||
display: block;
|
||||
border-bottom: 1px solid #2e2e2e;
|
||||
padding-bottom: 6px;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
th.first,
|
||||
td.first {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
td {
|
||||
height: 44px;
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid #1f1f1f;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td.dim {
|
||||
color: #4a4a4a;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: #171717;
|
||||
}
|
||||
|
||||
tr.we td {
|
||||
color: #5e5e5e;
|
||||
}
|
||||
|
||||
tr.anom td {
|
||||
background: rgba(229, 72, 77, 0.05);
|
||||
}
|
||||
|
||||
tr.anom:hover td {
|
||||
background: rgba(229, 72, 77, 0.08);
|
||||
}
|
||||
|
||||
.dow {
|
||||
display: inline-block;
|
||||
width: 34px;
|
||||
color: #6e6e6e;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Celle orario */
|
||||
.slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
width: max-content;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.slot.stack {
|
||||
padding: 3px;
|
||||
border: 1px dashed #a8383d;
|
||||
border-radius: 11px;
|
||||
background: rgba(229, 72, 77, 0.06);
|
||||
}
|
||||
|
||||
.slot.stack .cell {
|
||||
height: 30px;
|
||||
color: #ffc2c5;
|
||||
}
|
||||
|
||||
.slot.gone .cell {
|
||||
border: 1px dashed #a8383d;
|
||||
background: rgba(229, 72, 77, 0.08);
|
||||
}
|
||||
|
||||
.slot.gone .cell::placeholder {
|
||||
color: #a8383d;
|
||||
}
|
||||
|
||||
.cell {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
width: 84px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
font-family: var(--ox-font-mono);
|
||||
font-size: 14px;
|
||||
color: #e6e6e6;
|
||||
caret-color: var(--ox-accent);
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.cell::placeholder {
|
||||
color: #3e3e3e;
|
||||
}
|
||||
|
||||
.cell:hover {
|
||||
border-color: #333333;
|
||||
}
|
||||
|
||||
.cell:focus {
|
||||
border-color: var(--ox-accent);
|
||||
box-shadow: 0 0 0 3px var(--ox-accent-ring);
|
||||
background: #121212;
|
||||
}
|
||||
|
||||
tr.we .cell {
|
||||
color: #6e6e6e;
|
||||
}
|
||||
|
||||
.cell.edit {
|
||||
color: #ffb08c;
|
||||
background: rgba(247, 76, 0, 0.09);
|
||||
}
|
||||
|
||||
.cell.miss {
|
||||
border: 1px dashed #a8383d;
|
||||
background: rgba(229, 72, 77, 0.08);
|
||||
}
|
||||
|
||||
.cell.miss::placeholder {
|
||||
color: #a8383d;
|
||||
}
|
||||
|
||||
.cell.bad {
|
||||
border-color: #e5484d;
|
||||
color: #ff8a8f;
|
||||
}
|
||||
|
||||
.cell.unr,
|
||||
tr.we .cell.unr {
|
||||
color: #d6e4ff;
|
||||
border: 1px dashed #3a5f99;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cell.unr:hover {
|
||||
border-color: #4d8dff;
|
||||
}
|
||||
|
||||
.cell.unr:focus {
|
||||
border-style: solid;
|
||||
border-color: var(--ox-accent);
|
||||
}
|
||||
|
||||
.cell.rnd,
|
||||
tr.we .cell.rnd {
|
||||
color: #9cc2ff;
|
||||
background: rgba(77, 141, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Note */
|
||||
td.note {
|
||||
font-size: 13px;
|
||||
color: #6e6e6e;
|
||||
}
|
||||
|
||||
td.note.issue {
|
||||
color: #ff9ea3;
|
||||
}
|
||||
|
||||
td.note.info,
|
||||
td.note.round {
|
||||
color: #8db8ff;
|
||||
}
|
||||
|
||||
td.note.edit {
|
||||
color: #ff9a66;
|
||||
}
|
||||
|
||||
.note-in {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 48px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
color: #8a8a8a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty svg {
|
||||
color: #4cc38a;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
let { message }: { message: string } = $props();
|
||||
</script>
|
||||
|
||||
{#if message}
|
||||
<div class="toast" role="status">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><circle cx="12" cy="12" r="9" /><path d="M8 12.5l2.5 2.5L16 9.5" /></svg
|
||||
>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.toast {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 45;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: #13221a;
|
||||
border: 1px solid #1f4a31;
|
||||
color: #8be0ae;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,314 @@
|
||||
<script lang="ts">
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { monthName } from '$lib/month';
|
||||
import { dayWord, shortSummary } from './analysis';
|
||||
import IconButton from './IconButton.svelte';
|
||||
|
||||
let {
|
||||
year,
|
||||
month,
|
||||
issuesAll,
|
||||
infosAll,
|
||||
onback,
|
||||
menuOpen = $bindable(false),
|
||||
employeeCount,
|
||||
currentName,
|
||||
currentIssues,
|
||||
currentInfos,
|
||||
onexportall,
|
||||
onexportone
|
||||
}: {
|
||||
year: number;
|
||||
month: number;
|
||||
issuesAll: number;
|
||||
infosAll: number;
|
||||
onback?: () => void;
|
||||
menuOpen?: boolean;
|
||||
employeeCount: number;
|
||||
currentName: string;
|
||||
currentIssues: number;
|
||||
currentInfos: number;
|
||||
onexportall: () => void;
|
||||
onexportone: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<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}
|
||||
<div class="tb-title">
|
||||
<span>{$_('review.title')}</span>
|
||||
<h1>{monthName($locale, month)} {year}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tb-right">
|
||||
{#if issuesAll}
|
||||
<span class="tb-count red">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
|
||||
d="M12 9.5v4M12 17h.01"
|
||||
/></svg
|
||||
>
|
||||
{dayWord(issuesAll, $_)} {$_('review.to_check')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if infosAll}
|
||||
<span class="tb-count blue">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></svg
|
||||
>
|
||||
{dayWord(infosAll, $_)} {$_('review.to_round')}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Bottone diviso Esporta -->
|
||||
<div class="split-wrap">
|
||||
<div class="split">
|
||||
<button type="button" class="split-main" onclick={onexportall}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg
|
||||
>
|
||||
<span>{$_('review.export')}</span>
|
||||
</button>
|
||||
<span class="split-sep" aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="split-arrow"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
aria-label={$_('review.export_options_aria')}
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if menuOpen}
|
||||
<div class="dropdown" role="menu" aria-label={$_('review.export')}>
|
||||
<button type="button" role="menuitem" class="menu-item" onclick={onexportall}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><circle cx="9" cy="8" r="3.5" /><path d="M2.5 20c.8-3.3 3.3-5 6.5-5s5.7 1.7 6.5 5" /><path
|
||||
d="M16 4.5a3.5 3.5 0 0 1 0 7M18 15.2c1.8.7 3 2.3 3.5 4.8"
|
||||
/></svg
|
||||
>
|
||||
<span class="mi-col">
|
||||
<span class="mi-title">{$_('review.all_employees')}</span>
|
||||
<span class="mi-sub" class:red={issuesAll > 0} class:blue={!issuesAll && infosAll > 0}>
|
||||
{$_('review.employee_count', { values: { n: employeeCount } })} ·
|
||||
{shortSummary(issuesAll, infosAll, $_) || $_('review.all_good')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" class="menu-item" onclick={onexportone}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"
|
||||
><circle cx="12" cy="8" r="3.5" /><path d="M5 20c.8-3.3 3.6-5 7-5s6.2 1.7 7 5" /></svg
|
||||
>
|
||||
<span class="mi-col">
|
||||
<span class="mi-title">{$_('review.only_name', { values: { name: currentName } })}</span>
|
||||
<span class="mi-sub" class:red={currentIssues > 0} class:blue={!currentIssues && currentInfos > 0}>
|
||||
{shortSummary(currentIssues, currentInfos, $_) || $_('review.all_good_cap')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.topbar {
|
||||
flex: none;
|
||||
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;
|
||||
}
|
||||
|
||||
.tb-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tb-title span {
|
||||
font-size: 12.5px;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.tb-title 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);
|
||||
}
|
||||
|
||||
.tb-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tb-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
/* Bottone diviso */
|
||||
.split-wrap {
|
||||
position: relative;
|
||||
z-index: 21;
|
||||
}
|
||||
|
||||
.split {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: var(--ox-accent);
|
||||
color: var(--ox-on-accent);
|
||||
}
|
||||
|
||||
.split-main,
|
||||
.split-arrow {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ox-on-accent);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.split-main {
|
||||
gap: 10px;
|
||||
padding: 0 16px 0 18px;
|
||||
border-radius: 12px 0 0 12px;
|
||||
}
|
||||
|
||||
.split-arrow {
|
||||
width: 40px;
|
||||
justify-content: center;
|
||||
border-radius: 0 12px 12px 0;
|
||||
}
|
||||
|
||||
.split-arrow svg {
|
||||
stroke-width: 2.2;
|
||||
}
|
||||
|
||||
.split-main:hover,
|
||||
.split-arrow:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.split-main:active,
|
||||
.split-arrow:active {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.split-main:focus-visible,
|
||||
.split-arrow:focus-visible {
|
||||
outline: 2px solid #ffffff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.split-sep {
|
||||
width: 1px;
|
||||
margin: 10px 0;
|
||||
background: rgba(26, 7, 0, 0.28);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 300px;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: #bdbdbd;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.menu-item:focus-visible {
|
||||
outline: 2px solid var(--ox-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.menu-item > svg {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.mi-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mi-title {
|
||||
font-weight: 500;
|
||||
color: #f2f2f2;
|
||||
}
|
||||
|
||||
.mi-sub {
|
||||
font-size: 12.5px;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.mi-sub.red {
|
||||
color: #ff9ea3;
|
||||
}
|
||||
|
||||
.mi-sub.blue {
|
||||
color: #8db8ff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,294 @@
|
||||
// Logica pura di analisi delle timbrature di un mese, estratta dal
|
||||
// componente di revisione per tenerlo leggibile. Le stringhe tradotte
|
||||
// vengono prodotte passando la funzione `t` (di solito `$_` di
|
||||
// svelte-i18n) e, dove serve la lingua per Intl, `locale` ($locale).
|
||||
|
||||
import { weekdayName } from '$lib/month';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches svelte-i18n's own loosely-typed `values`
|
||||
export type Translate = (key: string, options?: { values?: Record<string, any> }) => string;
|
||||
|
||||
/** [Entrata1[], Uscita1[], Entrata2[], Uscita2[]] — ogni casella è un
|
||||
* elenco di orari "HH:MM" per rappresentare anche le timbrature impilate. */
|
||||
export type Day = [string[], string[], string[], string[]];
|
||||
|
||||
export interface Employee {
|
||||
id: number;
|
||||
name: string;
|
||||
days: Day[];
|
||||
}
|
||||
|
||||
export interface Row {
|
||||
di: number;
|
||||
v: Day;
|
||||
dow: string;
|
||||
weekend: boolean;
|
||||
issue: string;
|
||||
info: boolean;
|
||||
miss: [boolean, boolean, boolean, boolean];
|
||||
bad: boolean[][];
|
||||
unr: boolean[][];
|
||||
changed: boolean[][];
|
||||
mins: number;
|
||||
empty: boolean;
|
||||
rowEdits: number;
|
||||
stacked: boolean;
|
||||
wasStacked: boolean;
|
||||
lunch: boolean;
|
||||
wasLunch: boolean;
|
||||
}
|
||||
|
||||
export interface Analysis {
|
||||
rows: Row[];
|
||||
minutes: number;
|
||||
worked: number;
|
||||
issues: number;
|
||||
infos: number;
|
||||
edits: number;
|
||||
stackedDays: number[];
|
||||
lunchDays: 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 function normDay(day: Day): Day {
|
||||
return [0, 1, 2, 3].map((i) => {
|
||||
const c = (day?.[i] ?? []).filter((x) => x !== '');
|
||||
return c.length ? c.slice() : [''];
|
||||
}) 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 {
|
||||
const m = parseT(t);
|
||||
return m === null || m % ROUND_STEP === 0;
|
||||
}
|
||||
|
||||
export function roundT(t: string): 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));
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
? [a, b]
|
||||
: null;
|
||||
}
|
||||
|
||||
export function analyze(
|
||||
year: number,
|
||||
month: number,
|
||||
values: Day[],
|
||||
orig: Day[],
|
||||
locale: string | null | undefined,
|
||||
t: Translate
|
||||
): Analysis {
|
||||
const rows: Row[] = [];
|
||||
let minutes = 0;
|
||||
let worked = 0;
|
||||
let issues = 0;
|
||||
let infos = 0;
|
||||
let edits = 0;
|
||||
const stackedDays: number[] = [];
|
||||
const lunchDays: number[] = [];
|
||||
const roundDays: number[] = [];
|
||||
|
||||
for (let di = 0; di < values.length; di++) {
|
||||
const v = values[di];
|
||||
const o = orig[di];
|
||||
const dow = new Date(year, month - 1, di + 1).getDay();
|
||||
const weekend = dow === 0 || dow === 6;
|
||||
const lists = v.map((c) => c.filter((x) => x !== ''));
|
||||
const filled = lists.map((c) => c.length > 0);
|
||||
const stacked = lists.some((c) => c.length > 1);
|
||||
const bad = v.map((c) => c.map((x) => x !== '' && parseT(x) === null));
|
||||
const miss: [boolean, boolean, boolean, boolean] = [false, false, false, false];
|
||||
let issue = '';
|
||||
|
||||
// 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 } });
|
||||
[0, 1, 2, 3].forEach((i) => {
|
||||
if (!filled[i]) miss[i] = true;
|
||||
});
|
||||
}
|
||||
// 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;
|
||||
if (lunch) {
|
||||
issue = t('review.issue_lunch_missing');
|
||||
[0, 1, 2, 3].forEach((i) => {
|
||||
if (!filled[i]) miss[i] = true;
|
||||
});
|
||||
}
|
||||
// 4-5. Coppie incomplete / uscita prima dell'entrata
|
||||
if (!stacked && !lunch) {
|
||||
const p = lists.map((c) => (c.length ? parseT(c[0]) : null));
|
||||
for (const [a, b, shiftKey] of [
|
||||
[0, 1, 'review.shift_1'],
|
||||
[2, 3, 'review.shift_2']
|
||||
] as const) {
|
||||
if (filled[a] !== filled[b]) {
|
||||
miss[filled[a] ? b : a] = true;
|
||||
if (!issue) {
|
||||
issue = t(filled[a] ? 'review.issue_missing_out' : 'review.issue_missing_in', {
|
||||
values: { shift: t(shiftKey) }
|
||||
});
|
||||
}
|
||||
} else if (filled[a] && p[a] !== null && p[b] !== null && (p[b] as number) <= (p[a] as number) && !issue) {
|
||||
issue = t('review.issue_out_before_in', { values: { shift: t(shiftKey) } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ore: coppie per posizione dentro ciascun turno
|
||||
let mins = 0;
|
||||
for (const [a, b] of [
|
||||
[0, 1],
|
||||
[2, 3]
|
||||
] as const) {
|
||||
const n = Math.min(lists[a].length, lists[b].length);
|
||||
for (let k = 0; k < n; k++) {
|
||||
const s = parseT(lists[a][k]);
|
||||
const e = parseT(lists[b][k]);
|
||||
if (s !== null && e !== null && e > s) mins += e - s;
|
||||
}
|
||||
}
|
||||
|
||||
const empty = !filled.some(Boolean);
|
||||
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;
|
||||
|
||||
// 6. Info: orari non arrotondati
|
||||
const unr = v.map((c) => c.map((x) => x !== '' && !isRounded(x)));
|
||||
const hasUnr = unr.some((c) => c.some(Boolean));
|
||||
const info = !issue && hasUnr;
|
||||
|
||||
if (issue) issues++;
|
||||
if (info) infos++;
|
||||
if (hasUnr) roundDays.push(di);
|
||||
if (stacked) stackedDays.push(di);
|
||||
if (lunch) lunchDays.push(di);
|
||||
if (!empty) worked++;
|
||||
minutes += mins;
|
||||
edits += rowEdits;
|
||||
|
||||
rows.push({
|
||||
di, v, dow: weekdayName(locale, dow), weekend, issue, info, miss, bad, unr, changed,
|
||||
mins, empty, rowEdits, stacked, wasStacked, lunch, wasLunch
|
||||
});
|
||||
}
|
||||
return { rows, minutes, worked, issues, infos, edits, stackedDays, lunchDays, roundDays };
|
||||
}
|
||||
|
||||
export function dayLabel(r: Row): string {
|
||||
return `${r.dow} ${r.di + 1}`;
|
||||
}
|
||||
|
||||
/** "dip-giorno-casella-indice" -> è l'orario attuale il risultato di un arrotondamento? */
|
||||
export function isRnd(
|
||||
rounded: Record<string, string>,
|
||||
emp: number,
|
||||
di: number,
|
||||
ci: number,
|
||||
ii: number,
|
||||
val: string
|
||||
): boolean {
|
||||
return val !== '' && rounded[`${emp}-${di}-${ci}-${ii}`] === val;
|
||||
}
|
||||
|
||||
export function noteOf(r: Row, roundedCount: number, manualCount: number, t: Translate): { 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.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' };
|
||||
if (manualCount > 0) return { text: t('review.note_edited', { values: { n: manualCount } }), kind: 'edit' };
|
||||
if (roundedCount > 0) return { text: t('review.note_rounded', { values: { n: roundedCount } }), kind: 'round' };
|
||||
return { text: '', kind: 'muted' };
|
||||
}
|
||||
|
||||
export function cellClass(r: Row, ci: number, ii: number, rnd: boolean): string {
|
||||
let c = 'cell';
|
||||
if (r.bad[ci][ii]) c += ' bad';
|
||||
else if (!r.stacked && r.miss[ci]) c += ' miss';
|
||||
if (r.unr[ci][ii] && !r.bad[ci][ii]) c += ' unr';
|
||||
else if (rnd) c += ' rnd';
|
||||
else if (r.changed[ci][ii] && !r.bad[ci][ii]) c += ' edit';
|
||||
return c;
|
||||
}
|
||||
|
||||
export function initials(n: string): string {
|
||||
return n
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
export function summary(iss: number, inf: number, t: Translate): string {
|
||||
return [iss ? `${dayWord(iss, t)} ${t('review.to_check')}` : '', inf ? `${dayWord(inf, t)} ${t('review.to_round')}` : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
export function shortSummary(iss: number, inf: number, t: Translate): string {
|
||||
return [iss ? `${iss} ${t('review.to_check')}` : '', inf ? `${inf} ${t('review.to_round')}` : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Day, Employee } from './analysis';
|
||||
|
||||
type Clock = 'FirstIn' | 'FirstOut' | 'SecondIn' | 'SecondOut';
|
||||
|
||||
/** Un record come lo restituisce il comando Tauri `load_records`. */
|
||||
export interface AttendanceRecord {
|
||||
employee_id: number;
|
||||
clock: Clock;
|
||||
datetime: string;
|
||||
}
|
||||
|
||||
const CLOCK_INDEX: Record<Clock, number> = {
|
||||
FirstIn: 0,
|
||||
FirstOut: 1,
|
||||
SecondIn: 2,
|
||||
SecondOut: 3
|
||||
};
|
||||
|
||||
function timeOf(datetime: string): string {
|
||||
const d = new Date(datetime);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Raggruppa i record per dipendente e giorno nella forma Day attesa da TimesheetReview. */
|
||||
export function buildEmployees(
|
||||
year: number,
|
||||
month: number,
|
||||
records: AttendanceRecord[],
|
||||
names: Record<string, string>
|
||||
): Employee[] {
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
const byEmployee = new Map<number, Day[]>();
|
||||
|
||||
for (const id of Object.keys(names)) {
|
||||
byEmployee.set(Number(id), Array.from({ length: daysInMonth }, () => [[], [], [], []] as Day));
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
const days = byEmployee.get(record.employee_id);
|
||||
if (!days) continue;
|
||||
const date = new Date(record.datetime);
|
||||
const day = days[date.getDate() - 1];
|
||||
day[CLOCK_INDEX[record.clock]].push(timeOf(record.datetime));
|
||||
}
|
||||
|
||||
return [...byEmployee.entries()].map(([id, days]) => ({
|
||||
id,
|
||||
name: names[String(id)] ?? String(id),
|
||||
days
|
||||
}));
|
||||
}
|
||||
@@ -34,7 +34,86 @@
|
||||
"errors": {
|
||||
"title": "Could not reach the time clock"
|
||||
},
|
||||
"done": {
|
||||
"message": "Nothing to do here."
|
||||
"review": {
|
||||
"title": "Clock-in review",
|
||||
"day_word": "{n, plural, one {1 day} other {{n} days}}",
|
||||
"to_check": "to check",
|
||||
"to_round": "to round",
|
||||
"to_check_cap": "To check",
|
||||
"to_round_cap": "To round",
|
||||
"shift_1": "1st shift",
|
||||
"shift_2": "2nd shift",
|
||||
"issue_stacked": "{total} punches all on the {shift}",
|
||||
"issue_invalid_time": "Invalid time (use HH:MM)",
|
||||
"issue_lunch_missing": "Lunch break not punched",
|
||||
"issue_missing_out": "Missing clock-out for the {shift}",
|
||||
"issue_missing_in": "Missing clock-in for the {shift}",
|
||||
"issue_out_before_in": "Clock-out before clock-in ({shift})",
|
||||
"info_unrounded": "Times not rounded to {step} min",
|
||||
"note_empty": "No punches",
|
||||
"note_lunch_added": "Lunch break added",
|
||||
"note_shifts_fixed": "Shifts fixed",
|
||||
"note_edited": "{n, plural, one {1 time edited} other {{n} times edited}}",
|
||||
"note_rounded": "{n, plural, one {1 time rounded} other {{n} times rounded}}",
|
||||
"export_done": "Export completed: {who}",
|
||||
"everyone": "all employees",
|
||||
"only_unrounded": "{days} with unrounded times",
|
||||
"confirm_title_all": "There are {summary}",
|
||||
"confirm_title_one": "{name} has {summary}",
|
||||
"close_menu_aria": "Close menu",
|
||||
"no_records": "No punches found for this month.",
|
||||
"back_to_month": "Back to month selection",
|
||||
"export": "Export",
|
||||
"export_options_aria": "More export options",
|
||||
"all_employees": "All employees",
|
||||
"employee_count": "{n} employees",
|
||||
"all_good": "all good",
|
||||
"all_good_cap": "All good",
|
||||
"only_name": "Only {name}",
|
||||
"employees_nav_aria": "Employees",
|
||||
"search_employee": "Search employee",
|
||||
"days_to_check_aria": "{n} days to check",
|
||||
"days_to_round_aria": "{n} days to round",
|
||||
"no_problem_aria": "No problem",
|
||||
"employee_name_aria": "Employee name",
|
||||
"save_name": "Save name",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"edit_name": "Edit name",
|
||||
"days_worked": "days worked",
|
||||
"total_hours": "total hours",
|
||||
"edited": "edited",
|
||||
"fix_shifts_title": "Sorts and redistributes punches that all ended up on the same shift",
|
||||
"fix_shifts": "Fix shifts ({days})",
|
||||
"fix_shifts_short": "Fix shifts",
|
||||
"add_lunch_title": "Adds the {start}–{end} break on days with only a morning clock-in and an evening clock-out",
|
||||
"add_lunch": "Add lunch break ({days})",
|
||||
"lunch_short": "Lunch",
|
||||
"round_title": "Round to the nearest time",
|
||||
"round_all": "Round to {step} min ({days})",
|
||||
"round_short": "Round",
|
||||
"day_filter_aria": "Day filter",
|
||||
"all_days": "All days",
|
||||
"col_day": "Day",
|
||||
"col_in": "In",
|
||||
"col_out": "Out",
|
||||
"col_hours": "Hours",
|
||||
"col_notes": "Notes",
|
||||
"col_in_1": "1st shift clock-in",
|
||||
"col_out_1": "1st shift clock-out",
|
||||
"col_in_2": "2nd shift clock-in",
|
||||
"col_out_2": "2nd shift clock-out",
|
||||
"cell_aria": "{label}, {col}",
|
||||
"cell_aria_stacked": "{label}, {col} ({index} of {total})",
|
||||
"fix_shifts_day_aria": "Fix the shifts for {label}",
|
||||
"add_lunch_day_aria": "Add the {start}–{end} lunch break to {label}",
|
||||
"round_day_aria": "Round the times for {label}",
|
||||
"no_rows": "No days to check for this employee.",
|
||||
"cancel_export_aria": "Cancel export",
|
||||
"export_warning_issues": "If you export now, these days will end up in the file with missing, unverified, or unrounded times. You can go back and fix them, or export anyway.",
|
||||
"export_warning_unrounded": "The times are complete, but some aren't rounded to {step} minutes. You can round them automatically before exporting, or export them as they are.",
|
||||
"back_to_review": "Back to review",
|
||||
"round_and_export": "Round and export",
|
||||
"export_anyway": "Export anyway"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,86 @@
|
||||
"errors": {
|
||||
"title": "Impossibile raggiungere il timbratore"
|
||||
},
|
||||
"done": {
|
||||
"message": "Non c'è niente da fare."
|
||||
"review": {
|
||||
"title": "Revisione timbrature",
|
||||
"day_word": "{n, plural, one {1 giorno} other {{n} giorni}}",
|
||||
"to_check": "da controllare",
|
||||
"to_round": "da arrotondare",
|
||||
"to_check_cap": "Da controllare",
|
||||
"to_round_cap": "Da arrotondare",
|
||||
"shift_1": "1° turno",
|
||||
"shift_2": "2° turno",
|
||||
"issue_stacked": "{total} timbrature tutte sul {shift}",
|
||||
"issue_invalid_time": "Orario non valido (usa HH:MM)",
|
||||
"issue_lunch_missing": "Pausa pranzo non timbrata",
|
||||
"issue_missing_out": "Manca l’uscita del {shift}",
|
||||
"issue_missing_in": "Manca l’entrata del {shift}",
|
||||
"issue_out_before_in": "Uscita prima dell’entrata ({shift})",
|
||||
"info_unrounded": "Orari non arrotondati a {step} min",
|
||||
"note_empty": "Nessuna timbratura",
|
||||
"note_lunch_added": "Pausa pranzo aggiunta",
|
||||
"note_shifts_fixed": "Turni sistemati",
|
||||
"note_edited": "{n, plural, one {1 orario modificato} other {{n} orari modificati}}",
|
||||
"note_rounded": "{n, plural, one {1 orario arrotondato} other {{n} orari arrotondati}}",
|
||||
"export_done": "Esportazione completata: {who}",
|
||||
"everyone": "tutti i dipendenti",
|
||||
"only_unrounded": "{days} con orari non arrotondati",
|
||||
"confirm_title_all": "Ci sono {summary}",
|
||||
"confirm_title_one": "{name} ha {summary}",
|
||||
"close_menu_aria": "Chiudi menu",
|
||||
"no_records": "Nessuna timbratura trovata per questo mese.",
|
||||
"back_to_month": "Torna alla scelta del mese",
|
||||
"export": "Esporta",
|
||||
"export_options_aria": "Altre opzioni di esportazione",
|
||||
"all_employees": "Tutti i dipendenti",
|
||||
"employee_count": "{n} dipendenti",
|
||||
"all_good": "tutto a posto",
|
||||
"all_good_cap": "Tutto a posto",
|
||||
"only_name": "Solo {name}",
|
||||
"employees_nav_aria": "Dipendenti",
|
||||
"search_employee": "Cerca dipendente",
|
||||
"days_to_check_aria": "{n} giorni da controllare",
|
||||
"days_to_round_aria": "{n} giorni da arrotondare",
|
||||
"no_problem_aria": "Nessun problema",
|
||||
"employee_name_aria": "Nome del dipendente",
|
||||
"save_name": "Salva nome",
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
"edit_name": "Modifica nome",
|
||||
"days_worked": "giorni lavorati",
|
||||
"total_hours": "ore totali",
|
||||
"edited": "modificati",
|
||||
"fix_shifts_title": "Riordina in ordine di orario le timbrature finite tutte nello stesso turno",
|
||||
"fix_shifts": "Sistema turni ({days})",
|
||||
"fix_shifts_short": "Sistema turni",
|
||||
"add_lunch_title": "Inserisce la pausa {start}–{end} nei giorni con solo entrata del mattino e uscita della sera",
|
||||
"add_lunch": "Aggiungi pausa pranzo ({days})",
|
||||
"lunch_short": "Pausa",
|
||||
"round_title": "Arrotonda all'orario più vicino",
|
||||
"round_all": "Arrotonda a {step} min ({days})",
|
||||
"round_short": "Arrotonda",
|
||||
"day_filter_aria": "Filtro giorni",
|
||||
"all_days": "Tutti i giorni",
|
||||
"col_day": "Giorno",
|
||||
"col_in": "Entrata",
|
||||
"col_out": "Uscita",
|
||||
"col_hours": "Ore",
|
||||
"col_notes": "Note",
|
||||
"col_in_1": "Entrata 1° turno",
|
||||
"col_out_1": "Uscita 1° turno",
|
||||
"col_in_2": "Entrata 2° turno",
|
||||
"col_out_2": "Uscita 2° turno",
|
||||
"cell_aria": "{label}, {col}",
|
||||
"cell_aria_stacked": "{label}, {col} ({index} di {total})",
|
||||
"fix_shifts_day_aria": "Sistema i turni di {label}",
|
||||
"add_lunch_day_aria": "Aggiungi pausa pranzo {start}–{end} a {label}",
|
||||
"round_day_aria": "Arrotonda gli orari di {label}",
|
||||
"no_rows": "Nessun giorno da controllare per questo dipendente.",
|
||||
"cancel_export_aria": "Annulla esportazione",
|
||||
"export_warning_issues": "Se esporti ora, questi giorni finiranno nel file con orari mancanti, da verificare o non arrotondati. Puoi tornare indietro e sistemarli, oppure esportare comunque.",
|
||||
"export_warning_unrounded": "Gli orari sono completi, ma alcuni non sono arrotondati a {step} minuti. Puoi arrotondarli automaticamente prima di esportare, oppure esportarli così come sono.",
|
||||
"back_to_review": "Torna alla revisione",
|
||||
"round_and_export": "Arrotonda ed esporta",
|
||||
"export_anyway": "Esporta comunque"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,3 +7,10 @@ export function monthName(loc: string | null | undefined, month: number, format:
|
||||
const formatter = new Intl.DateTimeFormat(loc ?? 'it', { month: format });
|
||||
return capitalize(formatter.format(new Date(2000, month - 1, 1)));
|
||||
}
|
||||
|
||||
/** `dow` is 0 (Sunday) - 6 (Saturday), matching `Date.getDay()`. */
|
||||
export function weekdayName(loc: string | null | undefined, dow: number, format: 'short' | 'long' = 'short') {
|
||||
const formatter = new Intl.DateTimeFormat(loc ?? 'it', { weekday: format });
|
||||
// 2000-01-02 is a Sunday, so 2000-01-(2+dow) lands on the requested weekday.
|
||||
return capitalize(formatter.format(new Date(2000, 0, 2 + dow)));
|
||||
}
|
||||
|
||||
@@ -8,14 +8,19 @@
|
||||
import Onboarding from '$lib/components/Onboarding.svelte';
|
||||
import ConfirmMonth from '$lib/components/ConfirmMonth.svelte';
|
||||
import LoadingScreen from '$lib/components/LoadingScreen.svelte';
|
||||
import TimesheetReview from '$lib/components/TimesheetReview.svelte';
|
||||
import { buildEmployees, type AttendanceRecord } from '$lib/components/review/fromRecords';
|
||||
|
||||
type Step = 'loading' | 'setup' | 'confirm-month' | 'loading-records' | 'loading-employees' | 'done';
|
||||
type Step = 'loading' | 'setup' | 'confirm-month' | 'loading-records' | 'loading-employees' | 'review';
|
||||
|
||||
let step = $state<Step>('loading');
|
||||
let selectedYear = $state(0);
|
||||
let selectedMonth = $state(0);
|
||||
let records = $state<AttendanceRecord[]>([]);
|
||||
let employeeNames = $state<Record<string, string>>({});
|
||||
|
||||
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');
|
||||
@@ -28,10 +33,10 @@
|
||||
step = 'loading-records';
|
||||
clearError();
|
||||
try {
|
||||
const records = await invoke('load_records', selection);
|
||||
records = await invoke('load_records', selection);
|
||||
step = 'loading-employees';
|
||||
await invoke('load_employees', { records });
|
||||
step = 'done';
|
||||
employeeNames = await invoke('load_employees', { records });
|
||||
step = 'review';
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
step = 'confirm-month';
|
||||
@@ -39,21 +44,29 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Screen>
|
||||
{#if step === 'loading'}
|
||||
<p>{$_('loading')}</p>
|
||||
{:else if step === 'setup'}
|
||||
<Onboarding onconnected={() => (step = 'confirm-month')} />
|
||||
{:else if step === 'confirm-month'}
|
||||
<ConfirmMonth onstart={loadMonth} />
|
||||
{:else if step === 'loading-records'}
|
||||
<LoadingScreen
|
||||
title={$_('loading_records.title')}
|
||||
description={$_('loading_records.description', { values: { period } })}
|
||||
/>
|
||||
{:else if step === 'loading-employees'}
|
||||
<LoadingScreen title={$_('loading_employees.title')} description={$_('loading_employees.description')} />
|
||||
{:else}
|
||||
<p>{$_('done.message')}</p>
|
||||
{/if}
|
||||
</Screen>
|
||||
{#if step === 'review'}
|
||||
<TimesheetReview
|
||||
year={selectedYear}
|
||||
month={selectedMonth}
|
||||
employees={reviewEmployees}
|
||||
onback={() => (step = 'confirm-month')}
|
||||
onexport={(payload) => invoke('export_timesheet', { payload })}
|
||||
/>
|
||||
{:else}
|
||||
<Screen>
|
||||
{#if step === 'loading'}
|
||||
<p>{$_('loading')}</p>
|
||||
{:else if step === 'setup'}
|
||||
<Onboarding onconnected={() => (step = 'confirm-month')} />
|
||||
{:else if step === 'confirm-month'}
|
||||
<ConfirmMonth onstart={loadMonth} />
|
||||
{:else if step === 'loading-records'}
|
||||
<LoadingScreen
|
||||
title={$_('loading_records.title')}
|
||||
description={$_('loading_records.description', { values: { period } })}
|
||||
/>
|
||||
{:else if step === 'loading-employees'}
|
||||
<LoadingScreen title={$_('loading_employees.title')} description={$_('loading_employees.description')} />
|
||||
{/if}
|
||||
</Screen>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user