feat(oxibadge): consolidate review toolbar and add a "shift times" action
Check, format and test / build (push) Successful in 11m56s

The per-employee correction buttons (Fix shifts, Add lunch break,
Round) and the day filter used to be scattered across the employee
header. They're now grouped into a single toolbar attached to the top
of the timesheet table: a "Corrections" dropdown (with per-item counts
and an "Apply all corrections" entry) and a "Shift times" panel, plus
the existing day filter and "Undo all changes" button.

"Shift times" adds a fixed offset (in steps of the configured rounding
granularity, ±12h) to every punch in a chosen day range, for either
the current employee or everyone — for when the time clock didn't
follow a daylight-saving change. EmployeeHeader is now just the name
editor and stats; TimesheetTable no longer owns the table's outer
border/background, which moved to the new shared card wrapper.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 15:21:47 +02:00
parent da5daf5bca
commit 5dbb3f51f1
7 changed files with 901 additions and 156 deletions
@@ -14,12 +14,14 @@
lunchSpan,
normDay,
roundT,
shiftT,
shortSummary,
summary
} from './review/analysis';
import TopBar from './review/TopBar.svelte';
import EmployeeSidebar from './review/EmployeeSidebar.svelte';
import EmployeeHeader from './review/EmployeeHeader.svelte';
import Toolbar from './review/Toolbar.svelte';
import TimesheetTable from './review/TimesheetTable.svelte';
import ExportModal from './review/ExportModal.svelte';
import Toast from './review/Toast.svelte';
@@ -57,6 +59,7 @@
/** "employee-day-slot-index" -> time produced by rounding */
let rounded = $state<Record<string, string>>({});
let hasUnexportedChanges = $state(false);
const daysInMonth = orig[0]?.length ?? 0;
$effect(() => {
ondirtychange?.(hasUnexportedChanges);
@@ -135,6 +138,28 @@
hasUnexportedChanges = true;
}
/** Applies all three automatic corrections to the current employee in order: shifts, then lunch, then rounding. */
function applyAllCorrections() {
const allDays = values[emp].map((_d, di) => di);
fixDays(cur.stackedDays);
addLunch(allDays);
roundDaysOf(emp, allDays);
}
/** Adds a fixed offset to every time of the chosen days, for the given employee(s) — e.g. to fix a
* daylight-saving change the time clock didn't follow. */
function applyShift(opts: { minutes: number; fromDay: number; toDay: number; scope: 'all' | 'one' }) {
const eis = opts.scope === 'all' ? names.map((_name, i) => i) : [emp];
const from = opts.fromDay - 1;
const to = opts.toDay - 1;
for (const ei of eis) {
for (let di = from; di <= to && di < values[ei].length; di++) {
values[ei][di] = values[ei][di].map((c) => c.map((x) => shiftT(x, opts.minutes))) as Day;
}
}
hasUnexportedChanges = true;
}
/** Reverts one day back to what the time clock originally reported. */
function undoDay(di: number) {
values[emp][di] = orig[emp][di].map((c) => [...c]) as Day;
@@ -330,6 +355,15 @@
issues={cur.issues}
infos={cur.infos}
edits={cur.edits}
/>
<div class="table-card">
<Toolbar
bind:onlyIssues
{year}
{month}
{daysInMonth}
currentName={names[emp]}
stackedCount={cur.stackedDays.length}
lunchCount={cur.lunchDays.length}
roundCount={cur.roundDays.length}
@@ -337,8 +371,10 @@
onfixall={() => fixDays(cur.stackedDays)}
onaddlunchall={() => addLunch(cur.lunchDays)}
onroundall={() => roundDaysOf(emp, cur.roundDays)}
onapplyall={applyAllCorrections}
onshift={applyShift}
edits={cur.edits}
onundoall={requestUndoAll}
bind:onlyIssues
/>
<TimesheetTable
@@ -352,6 +388,7 @@
onroundday={(di) => roundDaysOf(emp, [di])}
onundoday={undoDay}
/>
</div>
</main>
</div>
{/if}
@@ -397,6 +434,16 @@
gap: 16px;
}
.table-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid #262626;
border-radius: 14px;
background: #141414;
}
.empty-month {
flex: 1;
display: flex;
@@ -1,9 +1,6 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { autofocus } from '$lib/autofocus';
import type { Shifts } from '$lib/shifts';
import { dayWord } from './analysis';
import FixButton from './FixButton.svelte';
import IconButton from '../IconButton.svelte';
let {
@@ -15,16 +12,7 @@
totalLabel,
issues,
infos,
edits,
stackedCount,
lunchCount,
roundCount,
shift,
onfixall,
onaddlunchall,
onroundall,
onundoall,
onlyIssues = $bindable(false)
edits
}: {
name: string;
editing?: boolean;
@@ -35,15 +23,6 @@
issues: number;
infos: number;
edits: number;
stackedCount: number;
lunchCount: number;
roundCount: number;
shift: Shifts;
onfixall: () => void;
onaddlunchall: () => void;
onroundall: () => void;
onundoall: () => void;
onlyIssues?: boolean;
} = $props();
function startEdit() {
@@ -56,7 +35,6 @@
if (n && n !== name) onsave(n);
editing = false;
}
</script>
<div class="main-head">
@@ -108,76 +86,11 @@
<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: shift.out1, end: shift.in2 } })}
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: shift.round_step_min, 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>
{#if edits}
<IconButton
size="lg"
dangerFilled
ariaLabel={$_('review.undo_all')}
title={$_('review.undo_all_title')}
onclick={onundoall}
>
{#snippet icon()}
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M9 14l-5-5 5-5" /><path d="M20 20v-7a4 4 0 0 0-4-4H4" /></svg
>
{/snippet}
</IconButton>
{/if}
</div>
</div>
<style>
.main-head {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px 24px;
}
.who {
@@ -247,47 +160,4 @@
.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>
@@ -166,9 +166,7 @@
flex: 1;
min-height: 0;
overflow: auto;
border: 1px solid #262626;
border-radius: 14px;
background: #141414;
border-radius: 0 0 14px 14px;
}
table {
@@ -0,0 +1,778 @@
<script lang="ts">
import { untrack } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { weekdayName } from '$lib/month';
import type { Shifts } from '$lib/shifts';
import { shiftT, signedHm } from './analysis';
import ActionButton from '../ActionButton.svelte';
import IconButton from '../IconButton.svelte';
let {
onlyIssues = $bindable(false),
year,
month,
daysInMonth,
currentName,
stackedCount,
lunchCount,
roundCount,
shift,
onfixall,
onaddlunchall,
onroundall,
onapplyall,
onshift,
edits,
onundoall
}: {
onlyIssues?: boolean;
year: number;
month: number;
daysInMonth: number;
currentName: string;
stackedCount: number;
lunchCount: number;
roundCount: number;
shift: Shifts;
onfixall: () => void;
onaddlunchall: () => void;
onroundall: () => void;
onapplyall: () => void;
onshift: (opts: { minutes: number; fromDay: number; toDay: number; scope: 'all' | 'one' }) => void;
edits: number;
onundoall: () => void;
} = $props();
// Unique per instance, mainly so the shift panel's aria-labelledby never
// risks clashing with anything else on the page.
const uid = $props.id();
let fixMenu = $state(false);
let shiftOpen = $state(false);
const toolPopOpen = $derived(fixMenu || shiftOpen);
function closeToolPops() {
fixMenu = false;
shiftOpen = false;
}
const fixTotal = $derived(stackedCount + lunchCount + roundCount);
const firstName = $derived(currentName.split(' ')[0]);
// ---- "Shift times" panel state ----
let shiftMin = $state(-60);
let shiftFrom = $state(1);
let shiftTo = $state(untrack(() => daysInMonth));
let shiftScope = $state<'all' | 'one'>('all');
const dayOptions = $derived(
Array.from({ length: daysInMonth }, (_v, i) => ({
v: i + 1,
label: `${weekdayName($locale, new Date(year, month - 1, i + 1).getDay())} ${i + 1}`
}))
);
function decShiftMin() {
shiftMin = Math.max(-720, shiftMin - shift.round_step_min);
}
function incShiftMin() {
shiftMin = Math.min(720, shiftMin + shift.round_step_min);
}
function applyShift() {
if (!shiftMin) return;
onshift({
minutes: shiftMin,
fromDay: Math.min(shiftFrom, shiftTo),
toDay: Math.max(shiftFrom, shiftTo),
scope: shiftScope
});
shiftOpen = false;
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && toolPopOpen) closeToolPops();
}
</script>
<svelte:window onkeydown={onKey} />
{#if toolPopOpen}
<button type="button" class="pop-backdrop" aria-label={$_('review.close_menu_aria')} onclick={closeToolPops}
></button>
{/if}
<div class="toolbar">
<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 class="tb-actions">
<div class="anchor">
<button
type="button"
class="tool"
class:open={shiftOpen}
aria-haspopup="dialog"
aria-expanded={shiftOpen}
onclick={() => {
shiftOpen = !shiftOpen;
fixMenu = false;
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><circle cx="12" cy="12" r="8.5" /><path d="M12 7.5V12l3 2" /></svg
>
<span>{$_('review.shift_times')}</span>
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"
><path d="M6 9l6 6 6-6" /></svg
>
</button>
{#if shiftOpen}
<div class="pop shift-pop" role="dialog" aria-labelledby="{uid}-shift-title">
<div class="pop-head">
<span id="{uid}-shift-title" class="pop-title">{$_('review.shift_panel_title')}</span>
<span class="pop-desc">{$_('review.shift_panel_desc')}</span>
</div>
<div class="pop-group">
<span class="pop-label">{$_('review.shift_amount_label')}</span>
<div class="stepper">
<button type="button" class="step-btn" aria-label={$_('review.shift_minus_aria')} onclick={decShiftMin}>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.2"
><path d="M5 12h14" /></svg
>
</button>
<div class="step-value mono" class:zero={shiftMin === 0}>{signedHm(shiftMin)}</div>
<button type="button" class="step-btn" aria-label={$_('review.shift_plus_aria')} onclick={incShiftMin}>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.2"
><path d="M12 5v14M5 12h14" /></svg
>
</button>
</div>
<div class="chips">
<button type="button" class="chip" class:on={shiftMin === -60} onclick={() => (shiftMin = -60)}
>{$_('review.shift_minus_hour')}</button
>
<button type="button" class="chip" class:on={shiftMin === 60} onclick={() => (shiftMin = 60)}
>{$_('review.shift_plus_hour')}</button
>
</div>
</div>
<div class="pop-group">
<span class="pop-label">{$_('review.shift_days_label')}</span>
<div class="range">
<label for="{uid}-shift-from">{$_('review.shift_from')}</label>
<div class="mini-select">
<select id="{uid}-shift-from" bind:value={shiftFrom}>
{#each dayOptions as o (o.v)}<option value={o.v}>{o.label}</option>{/each}
</select>
<svg class="chev" width="12" height="12" viewBox="0 0 24 24" aria-hidden="true"
><path d="M6 9l6 6 6-6" /></svg
>
</div>
<label for="{uid}-shift-to">{$_('review.shift_to')}</label>
<div class="mini-select">
<select id="{uid}-shift-to" bind:value={shiftTo}>
{#each dayOptions as o (o.v)}<option value={o.v}>{o.label}</option>{/each}
</select>
<svg class="chev" width="12" height="12" viewBox="0 0 24 24" aria-hidden="true"
><path d="M6 9l6 6 6-6" /></svg
>
</div>
</div>
</div>
<div class="pop-group">
<span class="pop-label">{$_('review.shift_who_label')}</span>
<div class="seg full" role="group" aria-label={$_('review.shift_who_label')}>
<button type="button" class:on={shiftScope === 'all'} aria-pressed={shiftScope === 'all'}
onclick={() => (shiftScope = 'all')}>{$_('review.shift_scope_all')}</button
>
<button type="button" class:on={shiftScope === 'one'} aria-pressed={shiftScope === 'one'}
onclick={() => (shiftScope = 'one')}
>{$_('review.only_name', { values: { name: firstName } })}</button
>
</div>
</div>
<div class="preview">
{@html $_('review.shift_preview', { values: { from: '08:02', to: shiftT('08:02', shiftMin) } })}
</div>
<div class="pop-actions">
<ActionButton onclick={() => (shiftOpen = false)}>{$_('review.cancel')}</ActionButton>
<ActionButton primary disabled={shiftMin === 0} onclick={applyShift}>{$_('review.apply')}</ActionButton>
</div>
</div>
{/if}
</div>
<div class="anchor">
<button
type="button"
class="tool"
class:open={fixMenu}
aria-haspopup="menu"
aria-expanded={fixMenu}
onclick={() => {
fixMenu = !fixMenu;
shiftOpen = false;
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path
d="M15 4V2M15 16v-2M8 9h2M20 9h2M17.8 11.8l1.4 1.4M17.8 6.2l1.4-1.4M12.2 6.2l-1.4-1.4M3 21l9-9"
/></svg
>
<span>{$_('review.corrections')}</span>
{#if fixTotal}<span class="tool-badge">{fixTotal}</span>{/if}
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"
><path d="M6 9l6 6 6-6" /></svg
>
</button>
{#if fixMenu}
<div class="pop fix-pop" role="menu" aria-label={$_('review.corrections')}>
<button
type="button"
role="menuitem"
class="menu-item"
disabled={!stackedCount}
onclick={() => {
onfixall();
fixMenu = false;
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" class="mi-icon orange"
><path d="M7 4v16M3 8l4-4 4 4M17 20V4M13 16l4 4 4-4" /></svg
>
<span class="mi-col grow">
<span class="mi-title">{$_('review.fix_shifts_short')}</span>
<span class="mi-sub">{$_('review.fix_shifts_title')}</span>
</span>
<span class="count">{stackedCount}</span>
</button>
<button
type="button"
role="menuitem"
class="menu-item"
disabled={!lunchCount}
onclick={() => {
onaddlunchall();
fixMenu = false;
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" class="mi-icon orange"
><path d="M7 3v8M5 3v4a2 2 0 0 0 4 0V3M7 11v10M17 21V3c-2 1-3 3.5-3 7h3" /></svg
>
<span class="mi-col grow">
<span class="mi-title">{$_('review.add_lunch_action')}</span>
<span class="mi-sub"
>{$_('review.add_lunch_title', { values: { start: shift.out1, end: shift.in2 } })}</span
>
</span>
<span class="count">{lunchCount}</span>
</button>
<button
type="button"
role="menuitem"
class="menu-item"
disabled={!roundCount}
onclick={() => {
onroundall();
fixMenu = false;
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" class="mi-icon blue"
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
>
<span class="mi-col grow">
<span class="mi-title">{$_('review.round_action', { values: { step: shift.round_step_min } })}</span>
<span class="mi-sub">{$_('review.round_title')}</span>
</span>
<span class="count">{roundCount}</span>
</button>
<div class="pop-sep"></div>
<button
type="button"
role="menuitem"
class="menu-item center"
disabled={!fixTotal}
onclick={() => {
onapplyall();
fixMenu = false;
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" class="mi-icon accent" style="stroke-width: 2.2"
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
>
<span class="grow apply-all">{$_('review.apply_all_corrections')}</span>
</button>
</div>
{/if}
</div>
{#if edits}
<span class="tb-sep" aria-hidden="true"></span>
<IconButton
size="lg"
dangerFilled
ariaLabel={$_('review.undo_all')}
title={$_('review.undo_all_title')}
onclick={onundoall}
>
{#snippet icon()}
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M9 14l-5-5 5-5" /><path d="M20 20v-7a4 4 0 0 0-4-4H4" /></svg
>
{/snippet}
</IconButton>
{/if}
</div>
</div>
<style>
.pop-backdrop {
all: unset;
position: fixed;
inset: 0;
z-index: 25;
cursor: default;
}
.toolbar {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #262626;
}
.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;
}
.seg.full {
width: 100%;
}
.seg.full button {
flex: 1;
justify-content: center;
}
.tb-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.anchor {
position: relative;
}
.tool {
all: unset;
box-sizing: border-box;
height: 36px;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 7px;
border-radius: 9px;
border: 1px solid #333333;
color: #c9c9c9;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.tool:hover,
.tool.open {
background: #1e1e1e;
color: #ffffff;
border-color: #4a4a4a;
}
.tool:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
.tool .chev {
color: #6e6e6e;
}
.tool-badge {
min-width: 18px;
height: 18px;
padding: 0 5px;
box-sizing: border-box;
border-radius: 9px;
background: rgba(247, 76, 0, 0.18);
color: #ffb08c;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.tb-sep {
width: 1px;
height: 24px;
background: #2a2a2a;
}
/* Popovers */
.pop {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 30;
box-sizing: border-box;
padding: 14px;
background: #1a1a1a;
border: 1px solid #333333;
border-radius: 16px;
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55);
display: flex;
flex-direction: column;
gap: 14px;
}
.shift-pop {
width: 320px;
}
.fix-pop {
width: 320px;
padding: 6px;
gap: 2px;
}
.pop-head {
display: flex;
flex-direction: column;
gap: 4px;
}
.pop-title {
font-size: 14px;
font-weight: 600;
color: #f2f2f2;
}
.pop-desc {
font-size: 12.5px;
line-height: 1.4;
color: #8a8a8a;
}
.pop-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.pop-label {
font-size: 12px;
font-weight: 500;
color: #8a8a8a;
}
.stepper {
display: flex;
align-items: stretch;
gap: 8px;
}
.step-btn {
all: unset;
box-sizing: border-box;
width: 44px;
height: 44px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #333333;
border-radius: 12px;
color: #c9c9c9;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.step-btn:hover {
background: #1e1e1e;
border-color: #4a4a4a;
color: #ffffff;
}
.step-btn:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
.step-value {
flex: 1;
min-width: 0;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #2a2a2a;
border-radius: 12px;
background: #161616;
font-size: 16px;
font-weight: 600;
color: #f2f2f2;
}
.step-value.zero {
color: #6e6e6e;
}
.chips {
display: flex;
gap: 6px;
}
.chip {
all: unset;
box-sizing: border-box;
height: 28px;
padding: 0 10px;
display: flex;
align-items: center;
border-radius: 8px;
border: 1px solid #2a2a2a;
font-size: 12.5px;
color: #a3a3a3;
cursor: pointer;
}
.chip:hover {
border-color: #4a4a4a;
color: #ffffff;
}
.chip.on {
background: rgba(247, 76, 0, 0.14);
border-color: var(--ox-accent);
color: #ffb08c;
}
.range {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.range label {
font-size: 12.5px;
color: #a3a3a3;
}
.mini-select {
position: relative;
display: inline-flex;
}
.mini-select select {
all: unset;
-webkit-appearance: none;
appearance: none;
box-sizing: border-box;
display: flex;
align-items: center;
height: 36px;
padding: 0 26px 0 12px;
border: 1px solid #2a2a2a;
border-radius: 9px;
font-size: 13px;
font-weight: 500;
color: #e6e6e6;
background: #1b1b1b;
cursor: pointer;
}
.mini-select select:hover {
border-color: #4a4a4a;
}
.mini-select select:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 1px;
}
.mini-select .chev {
position: absolute;
top: 50%;
right: 9px;
transform: translateY(-50%);
color: #6e6e6e;
pointer-events: none;
}
.preview {
padding: 10px;
border-radius: 10px;
background: #151515;
font-size: 13px;
color: #c9c9c9;
text-align: center;
}
.preview :global(b) {
color: #ffb08c;
font-weight: 600;
}
.pop-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Corrections menu */
.menu-item {
all: unset;
box-sizing: border-box;
width: 100%;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 10px;
border-radius: 10px;
font-size: 13.5px;
color: #bdbdbd;
cursor: pointer;
transition: background 0.15s;
}
.menu-item:hover:not(:disabled) {
background: #252525;
}
.menu-item:disabled {
opacity: 0.4;
cursor: default;
}
.menu-item:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: -2px;
}
.menu-item.center {
align-items: center;
justify-content: center;
text-align: center;
}
.mi-icon {
margin-top: 1px;
flex: none;
}
.mi-icon.orange {
color: #ff9a66;
}
.mi-icon.blue {
color: #8db8ff;
}
.mi-icon.accent {
color: var(--ox-accent);
}
.mi-col {
display: flex;
flex-direction: column;
gap: 2px;
}
.grow {
flex: 1;
min-width: 0;
}
.mi-title {
font-weight: 500;
color: #e6e6e6;
}
.mi-sub {
font-size: 11.5px;
color: #7a7a7a;
}
.count {
flex: none;
font-family: var(--ox-font-mono);
font-size: 12.5px;
color: #6e6e6e;
}
.pop-sep {
height: 1px;
margin: 4px 6px;
background: #2a2a2a;
}
.apply-all {
font-weight: 600;
color: var(--ox-accent);
}
</style>
@@ -75,6 +75,20 @@ export function roundT(t: string, roundStep: number): string {
return fmt(Math.min(Math.round(m / roundStep) * roundStep, 24 * 60 - roundStep));
}
/** Adds `delta` minutes to `t`, clamped to the same day (00:00-23:59). */
export function shiftT(t: string, delta: number): string {
const m = parseT(t);
if (m === null) return t;
return fmt(Math.max(0, Math.min(24 * 60 - 1, m + delta)));
}
/** Formats a signed minute offset as e.g. "+1:30", "−0:45", "0:00". */
export function signedHm(m: number): string {
const sign = m > 0 ? '+' : m < 0 ? '−' : '';
const abs = Math.abs(m);
return sign + Math.floor(abs / 60) + ':' + String(abs % 60).padStart(2, '0');
}
export function dayWord(n: number, t: Translate): string {
return t('review.day_word', { values: { n } });
}
+20 -1
View File
@@ -124,7 +124,26 @@
"undo_all_title": "Restore this employee's times to what the time clock originally reported",
"undo_all_confirm_title": "Undo all changes?",
"undo_all_confirm_message": "All edited times for this employee will revert to what the time clock reported. This can't be undone.",
"undo_all_confirm": "Undo all changes"
"undo_all_confirm": "Undo all changes",
"corrections": "Corrections",
"add_lunch_action": "Add lunch break",
"round_action": "Round to {step} min",
"apply_all_corrections": "Apply all corrections",
"shift_times": "Shift times",
"shift_panel_title": "Shift all the times",
"shift_panel_desc": "For when the time clock didn't follow a daylight-saving time change.",
"shift_amount_label": "By how much",
"shift_minus_aria": "Minus 15 minutes",
"shift_plus_aria": "Plus 15 minutes",
"shift_minus_hour": "−1 hour",
"shift_plus_hour": "+1 hour",
"shift_days_label": "Which days",
"shift_from": "From",
"shift_to": "to",
"shift_who_label": "For whom",
"shift_scope_all": "Everyone",
"shift_preview": "E.g. {from} → <b>{to}</b>",
"apply": "Apply"
},
"settings": {
"title": "Settings",
+20 -1
View File
@@ -124,7 +124,26 @@
"undo_all_title": "Ripristina gli orari di questo dipendente a quelli letti dal timbratore",
"undo_all_confirm_title": "Annullare tutte le modifiche?",
"undo_all_confirm_message": "Tutti gli orari modificati per questo dipendente torneranno a quelli letti dal timbratore. L'operazione non si può annullare.",
"undo_all_confirm": "Annulla tutte le modifiche"
"undo_all_confirm": "Annulla tutte le modifiche",
"corrections": "Correzioni",
"add_lunch_action": "Aggiungi pausa pranzo",
"round_action": "Arrotonda a {step} min",
"apply_all_corrections": "Applica tutte le correzioni",
"shift_times": "Sposta orari",
"shift_panel_title": "Sposta tutti gli orari",
"shift_panel_desc": "Per quando il timbratore non ha seguito il cambio dell'ora legale.",
"shift_amount_label": "Di quanto",
"shift_minus_aria": "Meno 15 minuti",
"shift_plus_aria": "Più 15 minuti",
"shift_minus_hour": "−1 ora",
"shift_plus_hour": "+1 ora",
"shift_days_label": "Quali giorni",
"shift_from": "Dal",
"shift_to": "al",
"shift_who_label": "Per chi",
"shift_scope_all": "Tutti",
"shift_preview": "Es. {from} → <b>{to}</b>",
"apply": "Applica"
},
"settings": {
"title": "Impostazioni",