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,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>
|
||||
Reference in New Issue
Block a user