Release v0.1.23
This commit is contained in:
@@ -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 ?? ''));
|
||||
}
|
||||
Reference in New Issue
Block a user