feat(oxibadge): consolidate review toolbar and add a "shift times" action
Check, format and test / build (push) Successful in 11m56s
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:
@@ -14,12 +14,14 @@
|
|||||||
lunchSpan,
|
lunchSpan,
|
||||||
normDay,
|
normDay,
|
||||||
roundT,
|
roundT,
|
||||||
|
shiftT,
|
||||||
shortSummary,
|
shortSummary,
|
||||||
summary
|
summary
|
||||||
} from './review/analysis';
|
} from './review/analysis';
|
||||||
import TopBar from './review/TopBar.svelte';
|
import TopBar from './review/TopBar.svelte';
|
||||||
import EmployeeSidebar from './review/EmployeeSidebar.svelte';
|
import EmployeeSidebar from './review/EmployeeSidebar.svelte';
|
||||||
import EmployeeHeader from './review/EmployeeHeader.svelte';
|
import EmployeeHeader from './review/EmployeeHeader.svelte';
|
||||||
|
import Toolbar from './review/Toolbar.svelte';
|
||||||
import TimesheetTable from './review/TimesheetTable.svelte';
|
import TimesheetTable from './review/TimesheetTable.svelte';
|
||||||
import ExportModal from './review/ExportModal.svelte';
|
import ExportModal from './review/ExportModal.svelte';
|
||||||
import Toast from './review/Toast.svelte';
|
import Toast from './review/Toast.svelte';
|
||||||
@@ -57,6 +59,7 @@
|
|||||||
/** "employee-day-slot-index" -> time produced by rounding */
|
/** "employee-day-slot-index" -> time produced by rounding */
|
||||||
let rounded = $state<Record<string, string>>({});
|
let rounded = $state<Record<string, string>>({});
|
||||||
let hasUnexportedChanges = $state(false);
|
let hasUnexportedChanges = $state(false);
|
||||||
|
const daysInMonth = orig[0]?.length ?? 0;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
ondirtychange?.(hasUnexportedChanges);
|
ondirtychange?.(hasUnexportedChanges);
|
||||||
@@ -135,6 +138,28 @@
|
|||||||
hasUnexportedChanges = true;
|
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. */
|
/** Reverts one day back to what the time clock originally reported. */
|
||||||
function undoDay(di: number) {
|
function undoDay(di: number) {
|
||||||
values[emp][di] = orig[emp][di].map((c) => [...c]) as Day;
|
values[emp][di] = orig[emp][di].map((c) => [...c]) as Day;
|
||||||
@@ -330,28 +355,40 @@
|
|||||||
issues={cur.issues}
|
issues={cur.issues}
|
||||||
infos={cur.infos}
|
infos={cur.infos}
|
||||||
edits={cur.edits}
|
edits={cur.edits}
|
||||||
stackedCount={cur.stackedDays.length}
|
|
||||||
lunchCount={cur.lunchDays.length}
|
|
||||||
roundCount={cur.roundDays.length}
|
|
||||||
{shift}
|
|
||||||
onfixall={() => fixDays(cur.stackedDays)}
|
|
||||||
onaddlunchall={() => addLunch(cur.lunchDays)}
|
|
||||||
onroundall={() => roundDaysOf(emp, cur.roundDays)}
|
|
||||||
onundoall={requestUndoAll}
|
|
||||||
bind:onlyIssues
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TimesheetTable
|
<div class="table-card">
|
||||||
rows={visibleRows}
|
<Toolbar
|
||||||
{rounded}
|
bind:onlyIssues
|
||||||
{emp}
|
{year}
|
||||||
{shift}
|
{month}
|
||||||
oncellinput={onCellInput}
|
{daysInMonth}
|
||||||
onfixday={(di) => fixDays([di])}
|
currentName={names[emp]}
|
||||||
onaddlunch={(di) => addLunch([di])}
|
stackedCount={cur.stackedDays.length}
|
||||||
onroundday={(di) => roundDaysOf(emp, [di])}
|
lunchCount={cur.lunchDays.length}
|
||||||
onundoday={undoDay}
|
roundCount={cur.roundDays.length}
|
||||||
/>
|
{shift}
|
||||||
|
onfixall={() => fixDays(cur.stackedDays)}
|
||||||
|
onaddlunchall={() => addLunch(cur.lunchDays)}
|
||||||
|
onroundall={() => roundDaysOf(emp, cur.roundDays)}
|
||||||
|
onapplyall={applyAllCorrections}
|
||||||
|
onshift={applyShift}
|
||||||
|
edits={cur.edits}
|
||||||
|
onundoall={requestUndoAll}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TimesheetTable
|
||||||
|
rows={visibleRows}
|
||||||
|
{rounded}
|
||||||
|
{emp}
|
||||||
|
{shift}
|
||||||
|
oncellinput={onCellInput}
|
||||||
|
onfixday={(di) => fixDays([di])}
|
||||||
|
onaddlunch={(di) => addLunch([di])}
|
||||||
|
onroundday={(di) => roundDaysOf(emp, [di])}
|
||||||
|
onundoday={undoDay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -397,6 +434,16 @@
|
|||||||
gap: 16px;
|
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 {
|
.empty-month {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { autofocus } from '$lib/autofocus';
|
import { autofocus } from '$lib/autofocus';
|
||||||
import type { Shifts } from '$lib/shifts';
|
|
||||||
import { dayWord } from './analysis';
|
|
||||||
import FixButton from './FixButton.svelte';
|
|
||||||
import IconButton from '../IconButton.svelte';
|
import IconButton from '../IconButton.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -15,16 +12,7 @@
|
|||||||
totalLabel,
|
totalLabel,
|
||||||
issues,
|
issues,
|
||||||
infos,
|
infos,
|
||||||
edits,
|
edits
|
||||||
stackedCount,
|
|
||||||
lunchCount,
|
|
||||||
roundCount,
|
|
||||||
shift,
|
|
||||||
onfixall,
|
|
||||||
onaddlunchall,
|
|
||||||
onroundall,
|
|
||||||
onundoall,
|
|
||||||
onlyIssues = $bindable(false)
|
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
editing?: boolean;
|
editing?: boolean;
|
||||||
@@ -35,15 +23,6 @@
|
|||||||
issues: number;
|
issues: number;
|
||||||
infos: number;
|
infos: number;
|
||||||
edits: number;
|
edits: number;
|
||||||
stackedCount: number;
|
|
||||||
lunchCount: number;
|
|
||||||
roundCount: number;
|
|
||||||
shift: Shifts;
|
|
||||||
onfixall: () => void;
|
|
||||||
onaddlunchall: () => void;
|
|
||||||
onroundall: () => void;
|
|
||||||
onundoall: () => void;
|
|
||||||
onlyIssues?: boolean;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
function startEdit() {
|
function startEdit() {
|
||||||
@@ -56,7 +35,6 @@
|
|||||||
if (n && n !== name) onsave(n);
|
if (n && n !== name) onsave(n);
|
||||||
editing = false;
|
editing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="main-head">
|
<div class="main-head">
|
||||||
@@ -108,76 +86,11 @@
|
|||||||
<span><b class:orange={edits > 0}>{edits}</b> {$_('review.edited')}</span>
|
<span><b class:orange={edits > 0}>{edits}</b> {$_('review.edited')}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.main-head {
|
.main-head {
|
||||||
flex: none;
|
flex: none;
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.who {
|
.who {
|
||||||
@@ -247,47 +160,4 @@
|
|||||||
.stats b.orange {
|
.stats b.orange {
|
||||||
color: #ff9a66;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -166,9 +166,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid #262626;
|
border-radius: 0 0 14px 14px;
|
||||||
border-radius: 14px;
|
|
||||||
background: #141414;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
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));
|
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 {
|
export function dayWord(n: number, t: Translate): string {
|
||||||
return t('review.day_word', { values: { n } });
|
return t('review.day_word', { values: { n } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,26 @@
|
|||||||
"undo_all_title": "Restore this employee's times to what the time clock originally reported",
|
"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_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_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": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
|
|||||||
@@ -124,7 +124,26 @@
|
|||||||
"undo_all_title": "Ripristina gli orari di questo dipendente a quelli letti dal timbratore",
|
"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_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_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": {
|
"settings": {
|
||||||
"title": "Impostazioni",
|
"title": "Impostazioni",
|
||||||
|
|||||||
Reference in New Issue
Block a user