Release v0.1.23

This commit is contained in:
2026-06-15 11:59:04 +12:00
parent 250d6ab6a9
commit 8f9a7b8193
18 changed files with 3725 additions and 3298 deletions
@@ -0,0 +1,538 @@
<script lang="ts">
import { Plus, TriangleAlert, X } from 'lucide-svelte';
import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte';
import type { ThroughputProduct, ThroughputQuantityType } from '$lib/types';
let {
products,
editingId,
saving,
nDate = $bindable(''),
nProductId = $bindable(''),
nQuantity = $bindable(''),
nType = $bindable<ThroughputQuantityType>('bags'),
nBagSize = $bindable(''),
nStaff = $bindable(''),
nNotes = $bindable(''),
nForOrder = $bindable(false),
nForStock = $bindable(false),
nJobNumber = $bindable(''),
nStockQty = $bindable(''),
showNote = $bindable(false),
addError,
isSplit,
addTotalKg,
formatNumber,
onSubmit,
onDismissNote,
onCancelEdit,
composerRef = $bindable<HTMLElement | null>(null)
}: {
products: ThroughputProduct[];
editingId: number | null;
saving: boolean;
nDate?: string;
nProductId?: string;
nQuantity?: string;
nType?: ThroughputQuantityType;
nBagSize?: string;
nStaff?: string;
nNotes?: string;
nForOrder?: boolean;
nForStock?: boolean;
nJobNumber?: string;
nStockQty?: string;
showNote?: boolean;
addError: string;
isSplit: boolean;
addTotalKg: number | null;
formatNumber: (value: number | null | undefined, digits?: number) => string;
onSubmit: () => void;
onDismissNote: () => void;
onCancelEdit: () => void;
composerRef?: HTMLElement | null;
} = $props();
</script>
<div class="composer" class:editing={editingId != null} bind:this={composerRef}>
<div class="composer-head">
<div class="composer-title">
<h2>{editingId != null ? 'Edit packing run' : 'Add a packing run'}</h2>
</div>
{#if editingId != null}
<button type="button" class="cancel-edit" onclick={onCancelEdit}>
<X size={16} strokeWidth={2.4} /> Cancel edit
</button>
{/if}
</div>
<form class="add-row" onsubmit={(e) => { e.preventDefault(); onSubmit(); }}>
<div class="add-cell">
<span class="cell-label">Date</span>
<input type="date" bind:value={nDate} aria-label="Production date" />
</div>
<div class="add-cell add-product">
<span class="cell-label">Product</span>
<ThroughputProductPicker {products} bind:productId={nProductId} inputId="throughput-add-product" />
</div>
<div class="add-cell">
<span class="cell-label">Packed</span>
<div class="packed-inputs">
<input
class="qty"
type="number"
min="0"
step="0.01"
inputmode="decimal"
bind:value={nQuantity}
placeholder={nType === 'bags' ? 'Bags' : 'Total kg'}
aria-label={nType === 'bags' ? 'Number of bags' : 'Total kilograms'}
/>
<select class="unit" bind:value={nType} aria-label="Bags or kilograms">
<option value="bags">bags</option>
<option value="kg">kg (bulka)</option>
</select>
{#if nType === 'bags'}
<span class="times" aria-hidden="true">×</span>
<input
class="bag"
type="number"
min="0"
step="0.01"
inputmode="decimal"
bind:value={nBagSize}
placeholder="kg/bag"
aria-label="Kilograms per bag"
/>
{/if}
</div>
{#if nType === 'bags' && addTotalKg !== null}
<span class="packed-total">= {formatNumber(addTotalKg)} kg total</span>
{/if}
</div>
<div class="add-cell">
<span class="cell-label">Packed by</span>
<input type="text" bind:value={nStaff} placeholder="Name" aria-label="Packed by" />
</div>
<div class="add-cell add-dest">
<span class="cell-label">Destination</span>
<div class="dest-rows">
<div class="dest-line">
<label class="dest-toggle" class:on={nForOrder}>
<input type="checkbox" bind:checked={nForOrder} /> For an order
</label>
{#if nForOrder}
<input
class="dest-input"
type="text"
bind:value={nJobNumber}
placeholder="Job number (Order Circle)"
aria-label="Job number"
/>
{/if}
</div>
<div class="dest-line">
<label class="dest-toggle" class:on={nForStock}>
<input type="checkbox" bind:checked={nForStock} /> For stock
</label>
{#if isSplit}
<input
class="dest-input"
type="number"
min="0"
step="0.01"
inputmode="decimal"
bind:value={nStockQty}
placeholder={`To stock (${nType === 'bags' ? 'bags' : 'kg'})`}
aria-label="Amount going to stock"
/>
{/if}
</div>
</div>
</div>
<div class="add-cell add-action">
<button type="submit" class="add-entry-button" disabled={saving}>
<Plus size={18} strokeWidth={2.6} />
<span>{saving ? 'Saving…' : editingId != null ? 'Save' : 'Add'}</span>
</button>
</div>
<div class="add-extra">
{#if showNote}
<div class="note-field">
<input
class="note-input"
type="text"
bind:value={nNotes}
placeholder="Note (optional)"
aria-label="Note"
/>
<button
type="button"
class="note-dismiss"
title="Remove note"
aria-label="Remove note"
onclick={onDismissNote}
>
<X size={16} strokeWidth={2.4} />
</button>
</div>
{:else}
<button type="button" class="link-button" onclick={() => (showNote = true)}>+ Add a note</button>
{/if}
{#if addError}
<span class="add-error"><TriangleAlert size={15} strokeWidth={2.4} /> {addError}</span>
{/if}
</div>
</form>
</div>
<style>
.composer {
display: grid;
gap: 0.85rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 16%, var(--color-border));
border-radius: 1rem;
background: var(--color-bg-surface);
box-shadow: 0 16px 34px -28px rgba(15, 23, 42, 0.28);
/* The product picker menu is absolutely positioned and needs to escape the
card bounds without being cut off. */
overflow: visible;
}
.composer-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.45rem 0;
}
.composer-title h2 {
margin: 0;
font-size: 1.22rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.add-row {
display: grid;
grid-template-columns: 7.5rem minmax(19rem, 1.7fr) minmax(14rem, 1.15fr) minmax(7rem, 0.65fr) minmax(14rem, 1.2fr) auto;
gap: 0.75rem 0.85rem;
align-items: start;
padding: 0 1.45rem 1.25rem;
}
.add-cell {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.add-row input:not([type='checkbox']),
.add-row select {
width: 100%;
min-height: 48px;
padding: 0.62rem 0.78rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
border-radius: 0.8rem;
font-size: 0.98rem;
color: var(--color-text-primary);
background: var(--color-bg-surface);
}
.add-row input:not([type='checkbox']):focus-visible,
.add-row select:focus-visible,
.cancel-edit:focus-visible,
.add-entry-button:focus-visible,
.note-dismiss:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
}
.packed-inputs {
display: flex;
align-items: center;
gap: 0.4rem;
}
.packed-inputs .qty {
flex: 1 1 4.5rem;
min-width: 0;
}
.packed-inputs .unit {
flex: 0 0 4.7rem;
width: 4.7rem;
}
.packed-inputs .bag {
flex: 0 0 6rem;
width: 6rem;
}
.packed-inputs .times {
flex: 0 0 auto;
color: var(--color-text-secondary);
font-weight: 700;
}
.packed-total {
margin-top: 0.3rem;
font-size: 0.84rem;
font-weight: 650;
color: var(--color-success);
font-variant-numeric: tabular-nums;
}
.add-dest {
gap: 0.4rem;
}
.dest-rows {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.dest-line {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dest-line .dest-toggle {
flex: 0 0 auto;
min-width: 8.5rem;
}
.dest-line .dest-input {
flex: 1 1 auto;
min-width: 0;
width: auto;
}
.dest-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.68rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
border-radius: 0.72rem;
background: var(--color-bg-surface);
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
}
.dest-toggle input {
width: 1.2rem;
height: 1.2rem;
min-height: 0;
margin: 0;
flex-shrink: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
.dest-toggle.on {
border-color: var(--color-brand);
background: var(--color-brand-tint);
color: var(--color-success);
}
.dest-input {
width: 100%;
}
.add-action {
justify-content: center;
}
.add-entry-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 48px;
padding: 0.62rem 1.28rem;
background: var(--color-brand);
color: #fff;
border: 1px solid var(--color-brand);
border-radius: 0.8rem;
font-size: 0.98rem;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
transition: background-color 160ms ease;
}
.add-entry-button:hover:not(:disabled) {
background: #126a33;
}
.add-entry-button:disabled {
opacity: 0.65;
cursor: progress;
}
.add-extra {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
padding-top: 0.15rem;
}
.link-button {
padding: 0.25rem 0;
background: none;
border: 0;
color: var(--color-success);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
}
.link-button:hover {
text-decoration: underline;
}
.note-field {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 1 1 16rem;
max-width: 28rem;
}
.note-input {
flex: 1 1 auto;
min-width: 0;
}
.note-dismiss,
.cancel-edit {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border);
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
}
.note-dismiss {
flex-shrink: 0;
width: 2.1rem;
height: 2.1rem;
padding: 0;
border-radius: 0.55rem;
transition: border-color 140ms ease, color 140ms ease, background-color 140ms ease;
}
.cancel-edit {
gap: 0.35rem;
padding: 0.5rem 0.85rem;
border-radius: 0.6rem;
font-size: 0.92rem;
font-weight: 600;
}
.note-dismiss:hover,
.cancel-edit:hover {
color: var(--color-text-primary);
border-color: var(--color-text-muted);
background: #fafbfc;
}
.add-error {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: #8a1622;
font-weight: 650;
font-size: 0.95rem;
}
.cell-label {
display: none;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-muted);
}
.composer.editing {
border-color: var(--color-brand);
box-shadow: 0 0 0 1px var(--color-brand) inset;
}
@media (max-width: 1440px) {
.add-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem 1rem;
}
.add-action {
justify-content: flex-end;
}
.add-entry-button {
width: 100%;
}
}
@media (max-width: 1040px) {
.add-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem 1rem;
}
.add-cell:nth-child(2),
.add-cell:nth-child(3),
.add-dest,
.add-action {
grid-column: 1 / -1;
}
.add-entry-button {
width: 100%;
}
}
@media (max-width: 760px) {
.composer-head {
padding-left: 1.15rem;
padding-right: 1.15rem;
}
.composer-title h2 {
font-size: 1.28rem;
}
.add-row {
grid-template-columns: 1fr 1fr;
gap: 0.85rem 1rem;
padding: 0 1.15rem 1.15rem;
}
.add-cell .cell-label {
display: block;
}
.add-cell:nth-child(2),
.add-dest,
.add-action {
grid-column: 1 / -1;
}
.add-entry-button {
width: 100%;
}
}
</style>
@@ -0,0 +1,172 @@
<script lang="ts">
import { Trash2 } from 'lucide-svelte';
import type { ThroughputEntry } from '$lib/types';
let {
pendingDelete,
deletingId,
formatDate,
onCancel,
onConfirm,
dialogRef = $bindable<HTMLElement | null>(null)
}: {
pendingDelete: ThroughputEntry;
deletingId: number | null;
formatDate: (value: string) => string;
onCancel: () => void;
onConfirm: () => void;
dialogRef?: HTMLElement | null;
} = $props();
</script>
<div class="modal-backdrop" role="presentation" onclick={onCancel}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="delete-title"
tabindex="-1"
bind:this={dialogRef}
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') onCancel(); }}
>
<div class="modal-icon"><Trash2 size={22} strokeWidth={2.2} /></div>
<h2 id="delete-title" class="modal-title">Delete this run?</h2>
<p class="modal-text">
The <strong>{pendingDelete.product_name_snapshot}</strong> run from
{formatDate(pendingDelete.production_date)} will be permanently removed.
This cannot be undone.
</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={onCancel}>Cancel</button>
<button
type="button"
class="modal-confirm"
disabled={deletingId === pendingDelete.id}
onclick={onConfirm}
>
{deletingId === pendingDelete.id ? 'Deleting…' : 'Delete run'}
</button>
</div>
</div>
</div>
<style>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--color-text-primary) 32%, transparent);
backdrop-filter: blur(6px);
}
.modal-card {
width: min(28rem, 100%);
display: grid;
gap: 0.7rem;
padding: 1.6rem;
border: 1px solid var(--color-border);
border-radius: 1rem;
background: var(--color-bg-surface);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
animation: modal-pop 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.modal-card:focus {
outline: none;
}
.modal-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.8rem;
height: 2.8rem;
border-radius: 0.8rem;
background: #fdecee;
color: #b3261e;
}
.modal-title,
.modal-text {
margin: 0;
}
.modal-title {
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text-primary);
}
.modal-text {
font-size: 0.98rem;
line-height: 1.5;
color: var(--color-text-secondary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 0.55rem;
}
.modal-cancel,
.modal-confirm {
min-height: 44px;
padding: 0.6rem 1.15rem;
border-radius: 0.7rem;
font-size: 0.98rem;
font-weight: 650;
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease;
}
.modal-cancel {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
}
.modal-cancel:hover {
color: var(--color-text-primary);
border-color: var(--color-text-muted);
}
.modal-confirm {
background: #b3261e;
border: 1px solid #b3261e;
color: #fff;
}
.modal-confirm:hover:not(:disabled) {
background: #95201a;
}
.modal-confirm:disabled {
opacity: 0.65;
cursor: progress;
}
.modal-cancel:focus-visible,
.modal-confirm:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
}
@keyframes modal-pop {
from { opacity: 0; transform: translateY(6px) scale(0.985); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.modal-card {
animation: none;
}
}
</style>
@@ -0,0 +1,771 @@
<script lang="ts">
import { ArrowUpDown, ChevronLeft, ChevronRight, History, Pencil, Search, Trash2, TriangleAlert, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
import type { SortKey } from '$lib/components/throughput/utils';
import type { ThroughputEntry, ThroughputProduct, ThroughputQuantityType } from '$lib/types';
let {
products,
filtersActive,
showFilters = $bindable(false),
dateFrom = $bindable(''),
dateTo = $bindable(''),
productFilter = $bindable(''),
staffFilter = $bindable(''),
typeFilter = $bindable<'' | ThroughputQuantityType>(''),
isLoading,
errorMessage,
sortKey,
highlightId,
deletingId,
sortedEntries,
paginatedEntries,
page,
totalPages,
pageStart,
pageEnd,
formatDate,
formatNumber,
packedMain,
packedDetail,
destinationOf,
onApplyFilters,
onClearFilters,
onToggleSort,
onStartEdit,
onRequestDelete
}: {
products: ThroughputProduct[];
filtersActive: boolean;
showFilters?: boolean;
dateFrom?: string;
dateTo?: string;
productFilter?: string;
staffFilter?: string;
typeFilter?: '' | ThroughputQuantityType;
isLoading: boolean;
errorMessage: string;
sortKey: SortKey;
highlightId: number | null;
deletingId: number | null;
sortedEntries: ThroughputEntry[];
paginatedEntries: ThroughputEntry[];
page: number;
totalPages: number;
pageStart: number;
pageEnd: number;
formatDate: (value: string) => string;
formatNumber: (value: number | null | undefined, digits?: number) => string;
packedMain: (entry: ThroughputEntry) => string;
packedDetail: (entry: ThroughputEntry) => string;
destinationOf: (entry: ThroughputEntry) => { label: string; detail: string | null };
onApplyFilters: () => void;
onClearFilters: () => void;
onToggleSort: (key: SortKey) => void;
onStartEdit: (entry: ThroughputEntry) => void;
onRequestDelete: (entry: ThroughputEntry) => void;
} = $props();
</script>
<div class="history-shell">
<div class="log-controls">
<div class="log-title">
<h2 class="history-title">
{#if !filtersActive}<History size={18} strokeWidth={2.1} />{/if}
<span>{filtersActive ? 'Filtered entries' : 'Recent entries'}</span>
</h2>
<span class="log-subtitle">{filtersActive ? 'Matching runs' : 'Last 30 days'}</span>
</div>
<button
type="button"
class="find-button"
class:active={showFilters}
aria-expanded={showFilters}
onclick={() => (showFilters = !showFilters)}
>
<Search size={18} strokeWidth={2.2} />
<span>Find past entries</span>
{#if filtersActive}<span class="find-dot" aria-label="filters applied"></span>{/if}
</button>
</div>
{#if showFilters}
<form
class="filters"
transition:fade={{ duration: 120 }}
onsubmit={(e) => {
e.preventDefault();
onApplyFilters();
}}
>
<label>
<span>From date</span>
<input type="date" bind:value={dateFrom} />
</label>
<label>
<span>To date</span>
<input type="date" bind:value={dateTo} />
</label>
<label>
<span>Product</span>
<select bind:value={productFilter}>
<option value="">All products</option>
{#each products as product (product.id)}
<option value={String(product.id)}>{product.name}</option>
{/each}
</select>
</label>
<label>
<span>Staff</span>
<input type="text" placeholder="Name" bind:value={staffFilter} />
</label>
<label>
<span>Packed as</span>
<select bind:value={typeFilter}>
<option value="">Bags or kg</option>
<option value="bags">Bags</option>
<option value="kg">Kilograms</option>
</select>
</label>
<div class="filter-actions">
<button type="submit" class="apply-button" disabled={isLoading}>
{isLoading ? 'Searching…' : 'Search'}
</button>
{#if filtersActive}
<button type="button" class="clear-button" onclick={onClearFilters}>
<X size={16} strokeWidth={2.4} /> Clear
</button>
{/if}
</div>
</form>
{/if}
{#if errorMessage}
<div class="error" role="alert">
<TriangleAlert size={20} strokeWidth={2.2} />
<span>{errorMessage}</span>
<button type="button" class="retry-button" onclick={onApplyFilters}>Try again</button>
</div>
{/if}
<div class="log">
<div class="log-head">
<button type="button" class="sort-head" class:active={sortKey === 'date'} onclick={() => onToggleSort('date')}>
<span>Date</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'product'} onclick={() => onToggleSort('product')}>
<span>Product</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'packed'} onclick={() => onToggleSort('packed')}>
<span>Packed</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'total'} onclick={() => onToggleSort('total')}>
<span>Total kg</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'staff'} onclick={() => onToggleSort('staff')}>
<span>Packed by</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'destination'} onclick={() => onToggleSort('destination')}>
<span>Destination</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head col-notes-head" class:active={sortKey === 'notes'} onclick={() => onToggleSort('notes')}>
<span>Notes</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<span class="col-actions-head">Edit</span>
</div>
{#if isLoading}
{#each Array(5) as _, i (i)}
<div class="row row-skeleton" aria-hidden="true">
<span class="sk sk-date"></span>
<span class="sk sk-product"></span>
<span class="sk sk-packed"></span>
<span class="sk sk-staff"></span>
<span class="sk sk-qa"></span>
</div>
{/each}
{:else}
{#each paginatedEntries as entry (entry.id)}
{@const dest = destinationOf(entry)}
<div class="row" class:just-added={entry.id === highlightId}>
<span class="col-date">
<span class="cell-label">Date</span>
{formatDate(entry.production_date)}
</span>
<span class="col-product">
<span class="cell-label">Product</span>
<span class="product-name">{entry.product_name_snapshot}</span>
</span>
<span class="col-packed">
<span class="cell-label">Packed</span>
<span class="packed-main">{packedMain(entry)}</span>
<span class="packed-detail">{packedDetail(entry)}</span>
</span>
<span class="col-total">
<span class="cell-label">Total kg</span>
<span class="total-kg">{formatNumber(entry.calculated_kg)} kg</span>
</span>
<span class="col-staff">
<span class="cell-label">Packed by</span>
{entry.staff_name ?? '—'}
</span>
<span class="col-dest">
<span class="cell-label">Destination</span>
<span
class="pill"
class:pill-stock={dest.label === 'Stock'}
class:pill-order={dest.label === 'Order'}
class:pill-split={dest.label === 'Split'}
>{dest.label}</span>
{#if dest.detail}<span class="dest-detail">{dest.detail}</span>{/if}
</span>
<span class="col-actions">
<button
type="button"
class="row-action"
title="Edit this run"
aria-label="Edit this run"
onclick={() => onStartEdit(entry)}
>
<Pencil size={16} strokeWidth={2.2} />
</button>
<button
type="button"
class="row-action row-action-danger"
title="Delete this run"
aria-label="Delete this run"
disabled={deletingId === entry.id}
onclick={() => onRequestDelete(entry)}
>
<Trash2 size={16} strokeWidth={2.2} />
</button>
</span>
{#if entry.notes}
<p class="row-notes"><span class="cell-label">Note</span>{entry.notes}</p>
{/if}
</div>
{:else}
<div class="empty">
{#if filtersActive}
<p class="empty-title">No entries match your search</p>
<p class="empty-help">Try a wider date range, or clear the filters to see everything.</p>
<button type="button" class="clear-button" onclick={onClearFilters}>
<X size={16} strokeWidth={2.4} /> Clear filters
</button>
{:else}
<p class="empty-title">No packing logged yet</p>
<p class="empty-help">Use the inline entry area above to add your first run. It appears here immediately, newest first.</p>
{/if}
</div>
{/each}
{/if}
</div>
{#if sortedEntries.length > 0}
<div class="pagination">
<p class="pagination-summary">{pageStart}-{pageEnd} of {sortedEntries.length}</p>
<div class="pagination-actions">
<button type="button" class="page-button" disabled={page === 1} onclick={() => (page = Math.max(1, page - 1))}>
<ChevronLeft size={16} strokeWidth={2.2} />
<span>Previous</span>
</button>
<span class="page-indicator">Page {page} of {totalPages}</span>
<button type="button" class="page-button" disabled={page === totalPages} onclick={() => (page = Math.min(totalPages, page + 1))}>
<span>Next</span>
<ChevronRight size={16} strokeWidth={2.2} />
</button>
</div>
</div>
{/if}
</div>
<style>
.history-shell {
display: grid;
gap: 0.95rem;
}
.log-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.log-title {
display: grid;
gap: 0.2rem;
}
.history-title {
display: inline-flex;
align-items: center;
gap: 0.55rem;
margin: 0;
font-size: 1.16rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.log-subtitle {
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.find-button,
.apply-button,
.clear-button,
.retry-button,
.sort-head,
.page-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
cursor: pointer;
}
.find-button {
position: relative;
min-height: 44px;
padding: 0.6rem 0.95rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 18%, var(--color-border));
border-radius: 0.78rem;
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-size: 0.95rem;
font-weight: 650;
}
.find-button.active {
border-color: var(--color-brand);
background: var(--color-brand-tint);
color: var(--color-success);
}
.find-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--color-brand);
}
.filters {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)) auto;
gap: 0.8rem;
align-items: end;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 0.95rem;
background: color-mix(in srgb, var(--color-bg-surface) 82%, white);
}
.filters label {
display: grid;
gap: 0.32rem;
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text-secondary);
}
.filters input,
.filters select {
min-height: 44px;
padding: 0.58rem 0.72rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 12%, var(--color-border));
border-radius: 0.78rem;
font-size: 0.97rem;
color: var(--color-text-primary);
background: var(--color-bg-surface);
}
.filter-actions {
display: inline-flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.apply-button,
.clear-button,
.retry-button,
.page-button {
min-height: 42px;
padding: 0.55rem 0.82rem;
border: 1px solid var(--color-border);
border-radius: 0.72rem;
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-size: 0.92rem;
font-weight: 650;
}
.apply-button {
border-color: var(--color-brand);
background: var(--color-brand);
color: #fff;
}
.clear-button {
color: var(--color-text-secondary);
}
.error {
display: flex;
align-items: center;
gap: 0.7rem;
flex-wrap: wrap;
padding: 0.95rem 1rem;
border: 1px solid #efc6c2;
border-radius: 0.9rem;
background: #fff4f2;
color: #8a1622;
}
.log {
border: 1px solid var(--color-border);
border-radius: 1rem;
background: var(--color-bg-surface);
overflow: clip;
}
.log-head,
.row {
display: grid;
grid-template-columns: 8rem minmax(0, 1.1fr) minmax(0, 0.95fr) 6rem minmax(0, 0.8fr) 10.5rem 4.8rem;
gap: 0.85rem;
align-items: center;
}
.log-head {
padding: 0.9rem 1.45rem;
border-bottom: 1px solid var(--color-divider);
background: color-mix(in srgb, var(--color-bg-app) 55%, var(--color-bg-surface));
}
.sort-head {
justify-content: flex-start;
padding: 0;
border: 0;
background: none;
color: var(--color-text-secondary);
font-size: 0.8rem;
font-weight: 750;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sort-head.active,
.sort-head:hover {
color: var(--color-text-primary);
}
.row {
padding: 1rem 1.45rem;
border-bottom: 1px solid var(--color-divider);
transition: background-color 160ms ease;
}
.row:last-child {
border-bottom: 0;
}
.row:hover {
background: #fafbfc;
}
.row.just-added {
animation: flash-in 1.8s ease-out;
}
@keyframes flash-in {
0% { background: var(--color-brand-tint); }
100% { background: transparent; }
}
.col-product,
.col-dest,
.col-packed,
.col-total {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
}
.product-name,
.packed-main,
.total-kg {
font-weight: 650;
}
.dest-detail,
.packed-detail {
font-size: 0.88rem;
color: var(--color-text-secondary);
}
.total-kg,
.packed-main,
.dest-detail {
font-variant-numeric: tabular-nums;
}
.col-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0.3rem;
}
.col-actions-head {
justify-self: end;
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.row-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 0.55rem;
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: border-color 140ms ease, color 140ms ease, background-color 140ms ease;
}
.row-action:hover {
border-color: var(--color-text-muted);
color: var(--color-text-primary);
background: #fafbfc;
}
.row-action-danger:hover {
border-color: #e2a8af;
color: #b3261e;
background: #fdecee;
}
.row-action:disabled,
.page-button:disabled,
.apply-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.row-notes {
grid-column: 1 / -1;
margin: 0.35rem 0 0;
padding-top: 0.65rem;
border-top: 1px dashed var(--color-divider);
font-size: 0.94rem;
color: var(--color-text-secondary);
}
.cell-label {
display: none;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-muted);
}
.col-notes-head {
display: none;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.42rem 0.78rem;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 650;
white-space: nowrap;
}
.pill-stock {
background: #e8f1fc;
color: #0b5cad;
}
.pill-order {
background: var(--color-brand-tint);
color: var(--color-success);
}
.pill-split {
background: #f3e8fc;
color: #6b21a8;
}
.row-skeleton {
padding: 1.15rem 1.45rem;
border-bottom: 1px solid var(--color-divider);
}
.sk {
height: 1.1rem;
border-radius: 0.4rem;
background: linear-gradient(90deg, #eef1f4 25%, #f6f8fa 50%, #eef1f4 75%);
background-size: 200% 100%;
animation: shimmer 1.3s ease-in-out infinite;
}
.sk-staff { width: 70%; }
.sk-qa { width: 6rem; height: 1.7rem; border-radius: 999px; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
padding: 3rem 1.5rem;
text-align: center;
}
.empty-title {
margin: 0;
font-size: 1.3rem;
font-weight: 650;
color: var(--color-text-primary);
}
.empty-help {
margin: 0;
font-size: 1.02rem;
color: var(--color-text-secondary);
max-width: 42ch;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 1rem 1.45rem 1.15rem;
border-top: 1px solid var(--color-divider);
background: color-mix(in srgb, var(--color-bg-app) 48%, var(--color-bg-surface));
}
.pagination-summary,
.page-indicator {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.94rem;
font-weight: 600;
}
.pagination-actions {
display: inline-flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
@media (max-width: 760px) {
.log-controls,
.filters,
.error {
padding-left: 1.15rem;
padding-right: 1.15rem;
}
.history-title {
font-size: 1.28rem;
}
.log-head {
display: none;
}
.row {
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1rem;
padding: 1rem 1.15rem;
}
.col-product,
.row-notes {
grid-column: 1 / -1;
}
.cell-label {
display: block;
margin-bottom: 0.1rem;
}
.col-actions {
grid-column: 1 / -1;
justify-content: flex-start;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--color-divider);
}
.row-action {
width: 2.5rem;
height: 2.5rem;
}
.pagination {
padding-left: 1.15rem;
padding-right: 1.15rem;
}
}
@media (min-width: 1280px) {
.log-head,
.row {
grid-template-columns: 8rem minmax(0, 1.1fr) minmax(0, 0.95fr) 6rem minmax(0, 0.8fr) 10.5rem minmax(0, 1.2fr) 4.8rem;
}
.col-notes-head {
display: block;
}
.row-notes {
grid-column: 7;
align-self: center;
margin: 0;
padding-top: 0;
border-top: 0;
font-size: 0.95rem;
}
.col-actions {
grid-column: 8;
}
}
@media (prefers-reduced-motion: reduce) {
.row.just-added,
.sk {
animation: none;
}
}
</style>
@@ -0,0 +1,120 @@
<script lang="ts">
import { CheckCircle2 } from 'lucide-svelte';
import { fade } from 'svelte/transition';
import type { ConfettiPiece } from '$lib/components/throughput/utils';
let { confetti }: { confetti: ConfettiPiece[] } = $props();
</script>
<div class="success-overlay" role="presentation" transition:fade={{ duration: 160 }}>
<div class="success-card" role="status" aria-live="polite">
<div class="confetti" aria-hidden="true">
{#each confetti as piece, i (i)}
<span
class="confetti-piece"
style="left:{piece.left}%; width:{piece.size}px; height:{piece.size}px; background:{piece.color}; animation-delay:{piece.delay}s; animation-duration:{piece.duration}s; --rot:{piece.rotate}deg; --drift:{piece.drift}px;"
></span>
{/each}
</div>
<span class="success-icon"><CheckCircle2 size={44} strokeWidth={2.4} /></span>
<p class="success-title">Added</p>
<p class="success-text">Your packing run has been added to the log.</p>
</div>
</div>
<style>
.success-overlay {
position: fixed;
inset: 0;
z-index: 90;
display: grid;
place-items: center;
pointer-events: none;
overflow: hidden;
}
.success-card {
position: relative;
overflow: hidden;
width: min(30rem, 100%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.7rem;
padding: 2.6rem 2.8rem 2.8rem;
border: 1px solid var(--color-border);
border-radius: 1.25rem;
background: var(--color-bg-surface);
box-shadow: 0 28px 70px -18px rgba(0, 0, 0, 0.45);
text-align: center;
animation: success-pop 240ms cubic-bezier(0.18, 0.89, 0.32, 1.28);
}
.success-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 4.2rem;
height: 4.2rem;
border-radius: 50%;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.success-title,
.success-text {
margin: 0;
}
.success-title {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text-primary);
}
.success-text {
font-size: 1rem;
line-height: 1.45;
color: var(--color-text-secondary);
}
.confetti {
position: absolute;
inset: 0;
pointer-events: none;
}
.confetti-piece {
position: absolute;
top: -12%;
display: block;
border-radius: 2px;
opacity: 0;
animation-name: confetti-fall;
animation-timing-function: ease-in;
animation-iteration-count: infinite;
}
@keyframes success-pop {
from { opacity: 0; transform: scale(0.82); }
to { opacity: 1; transform: scale(1); }
}
@keyframes confetti-fall {
0% { transform: translate(0, 0) rotate(0deg); opacity: 0; }
12% { opacity: 1; }
100% { transform: translate(var(--drift), 340px) rotate(var(--rot)); opacity: 0.9; }
}
@media (prefers-reduced-motion: reduce) {
.success-card {
animation: none;
}
.confetti {
display: none;
}
}
</style>
@@ -0,0 +1,256 @@
<script lang="ts">
import { CalendarDays, CalendarRange, Carrot, Gauge, TrendingUp, Wheat } from 'lucide-svelte';
import { MIX_RANGES } from '$lib/components/throughput/utils';
let {
today,
weekRangeLabel,
heroStats,
mixTotals,
mixRangeKey = $bindable<(typeof MIX_RANGES)[number]['key']>('4w'),
formatDate,
formatNumber
}: {
today: string;
weekRangeLabel: string;
heroStats: { today: number; thisWeek: number; avgFourWeek: number };
mixTotals: { horse: number; grain: number };
mixRangeKey?: (typeof MIX_RANGES)[number]['key'];
formatDate: (value: string) => string;
formatNumber: (value: number | null | undefined, digits?: number) => string;
} = $props();
</script>
<header class="throughput-summary" aria-label="Throughput summary">
<div class="summary-heading">
<span class="summary-icon"><Gauge size={17} strokeWidth={2.2} /></span>
<h2>Throughput Overview</h2>
<div class="range-select" role="group" aria-label="Customer mix date range">
{#each MIX_RANGES as range (range.key)}
<button
type="button"
class="range-option"
class:active={mixRangeKey === range.key}
aria-pressed={mixRangeKey === range.key}
onclick={() => (mixRangeKey = range.key)}
>{range.label}</button>
{/each}
</div>
</div>
<dl class="facts">
<div class="fact">
<dt><span class="fact-icon"><CalendarDays size={16} strokeWidth={2.2} /></span>Today</dt>
<dd>{formatNumber(heroStats.today)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">{formatDate(today)}</p>
</div>
<div class="fact">
<dt><span class="fact-icon"><CalendarRange size={16} strokeWidth={2.2} /></span>This week</dt>
<dd>{formatNumber(heroStats.thisWeek)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">{weekRangeLabel}</p>
</div>
<div class="fact">
<dt><span class="fact-icon"><TrendingUp size={16} strokeWidth={2.2} /></span>4-week average</dt>
<dd>{formatNumber(heroStats.avgFourWeek)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">Per week, last 4 weeks</p>
</div>
</dl>
<dl class="mix-facts" aria-label="Throughput by customer">
<div class="fact">
<dt><span class="fact-icon"><Carrot size={16} strokeWidth={2.2} /></span>Horse Mix</dt>
<dd>{formatNumber(mixTotals.horse)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">PHF Horsemix · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}</p>
</div>
<div class="fact">
<dt><span class="fact-icon"><Wheat size={16} strokeWidth={2.2} /></span>Grain Mix</dt>
<dd>{formatNumber(mixTotals.grain)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">All other customers · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}</p>
</div>
</dl>
</header>
<style>
.throughput-summary {
display: grid;
padding: 0;
border: 1px solid color-mix(in srgb, var(--color-brand) 18%, var(--color-border));
border-radius: 0.95rem;
background:
radial-gradient(circle at top right, color-mix(in srgb, var(--color-brand) 12%, transparent), transparent 45%),
linear-gradient(180deg, color-mix(in srgb, var(--color-brand) 5%, var(--color-bg-surface)), var(--color-bg-surface));
}
.summary-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem 0.75rem;
padding: 1rem 1rem 0;
}
.summary-heading h2 {
margin: 0;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.summary-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
border-radius: 0.65rem;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.range-select {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-left: auto;
padding: 0.28rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
border-radius: 0.8rem;
background: color-mix(in srgb, var(--color-bg-surface) 55%, transparent);
}
.range-option {
padding: 0.5rem 0.95rem;
border: 0;
border-radius: 0.6rem;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.95rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
}
.range-option:hover {
color: var(--color-text-primary);
}
.range-option.active {
background: var(--color-brand);
color: #fff;
box-shadow: 0 8px 18px -14px color-mix(in srgb, var(--color-brand) 85%, transparent);
}
.facts,
.mix-facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
padding: 1rem;
}
.mix-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding-top: 0;
}
.fact {
container-type: inline-size;
position: relative;
display: grid;
gap: 0.28rem;
min-height: 7rem;
padding: 1rem 1rem 0.9rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 12%, var(--color-border));
border-radius: 0.95rem;
background: color-mix(in srgb, var(--color-bg-surface) 84%, white);
box-shadow: 0 12px 36px -28px rgba(15, 23, 42, 0.28);
}
.fact dt {
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin: 0;
font-size: 0.83rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.fact-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.7rem;
height: 1.7rem;
border-radius: 0.6rem;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.fact dd {
margin: 0;
font-size: clamp(1.85rem, 4.5cqi, 2.3rem);
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.03em;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
}
.fact-unit {
font-size: 0.45em;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.fact-sub {
margin: 0.05rem 0 0;
color: var(--color-text-secondary);
font-size: 0.88rem;
line-height: 1.35;
}
@media (min-width: 1024px) {
.throughput-summary {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.75rem;
padding: 1rem;
}
.facts,
.mix-facts {
display: contents;
}
.summary-heading {
grid-column: 1 / -1;
padding: 0;
}
.fact {
min-height: 7rem;
padding: 0.9rem 0.85rem;
}
.fact-sub {
font-size: 0.78rem;
}
}
@media (max-width: 760px) {
.facts {
grid-template-columns: 1fr;
padding: 0.9rem;
}
.mix-facts {
grid-template-columns: 1fr;
padding: 0 0.9rem 0.9rem;
}
}
</style>
@@ -0,0 +1,77 @@
import type { ThroughputEntry } from '$lib/types';
export type SortKey = 'date' | 'product' | 'packed' | 'total' | 'staff' | 'destination' | 'notes';
export type SortDirection = 'asc' | 'desc';
export type ConfettiPiece = {
left: number;
delay: number;
duration: number;
color: string;
rotate: number;
drift: number;
size: number;
};
export const CONFETTI_COLORS = ['#16a34a', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444'];
export const MIX_RANGES = [
{ key: '7d', label: '7 days', sub: 'last 7 days', days: 7 },
{ key: '4w', label: '4 weeks', sub: 'last 4 weeks', days: 28 },
{ key: '6w', label: '6 weeks', sub: 'last 6 weeks', days: 42 },
{ key: '12w', label: '12 weeks', sub: 'last 12 weeks', days: 84 }
] as const;
export function compareText(a: string | null | undefined, b: string | null | undefined) {
return (a ?? '').localeCompare(b ?? '', undefined, { sensitivity: 'base' });
}
export function compareDate(a: string | null | undefined, b: string | null | undefined) {
const aTime = a ? Date.parse(a) : Number.NEGATIVE_INFINITY;
const bTime = b ? Date.parse(b) : Number.NEGATIVE_INFINITY;
return aTime - bTime;
}
export function toISODate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
export function ausToday(): Date {
const ymd = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Australia/Sydney',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(new Date());
const [y, m, d] = ymd.split('-').map(Number);
return new Date(y, m - 1, d);
}
export function startOfWeekMonday(d: Date): Date {
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const dow = (start.getDay() + 6) % 7;
start.setDate(start.getDate() - dow);
return start;
}
export function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
export function buildConfetti(colors = CONFETTI_COLORS): ConfettiPiece[] {
return Array.from({ length: 42 }, (_, i) => ({
left: Math.random() * 100,
delay: Math.random() * 1.2,
duration: 1 + Math.random() * 0.8,
color: colors[i % colors.length],
rotate: 220 + Math.random() * 360,
drift: (Math.random() - 0.5) * 60,
size: 6 + Math.random() * 6
}));
}
export function isStockEntry(entry: ThroughputEntry): boolean {
return entry.for_stock || (!entry.for_order && /stock/i.test(entry.notes ?? ''));
}