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:
@@ -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
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user