Dockerfile updates

This commit is contained in:
2026-06-02 15:41:53 +12:00
parent 84792c0947
commit f5a588d631
18 changed files with 742 additions and 220 deletions
@@ -0,0 +1,338 @@
<script lang="ts">
import type { ThroughputProduct } from '$lib/types';
import { Search, X, Check } from 'lucide-svelte';
let {
products = [],
productId = $bindable(''),
disabled = false,
inputId = 'throughput-product'
}: {
products?: ThroughputProduct[];
productId?: string;
disabled?: boolean;
inputId?: string;
} = $props();
let clientName = $state('');
let query = $state('');
let open = $state(false);
let highlighted = $state(-1);
let focused = $state(false);
let root = $state<HTMLDivElement | null>(null);
function label(product: ThroughputProduct): string {
return product.item_id ? `${product.name} · ${product.item_id}` : product.name;
}
const clients = $derived(
Array.from(
new Set(
products
.map((p) => (p.client_name ?? '').trim())
.filter((name) => name.length > 0)
)
).sort((a, b) => a.localeCompare(b))
);
const selected = $derived(
productId ? products.find((p) => String(p.id) === productId) ?? null : null
);
// Products narrowed by the chosen client, then by the search text. Item id and
// name are both searchable so operators can type either.
const filtered = $derived.by(() => {
const q = query.trim().toLowerCase();
return products.filter((product) => {
if (clientName && (product.client_name ?? '') !== clientName) return false;
if (!q) return true;
return (
product.name.toLowerCase().includes(q) ||
(product.item_id ?? '').toLowerCase().includes(q)
);
});
});
// Keep the text box in sync when the selection is cleared from outside (for
// example after "Save and add another" resets the form).
$effect(() => {
if (!productId && !focused) {
query = '';
}
});
// If the active client no longer contains the selected product, drop it.
$effect(() => {
if (selected && clientName && (selected.client_name ?? '') !== clientName) {
productId = '';
query = '';
}
});
function choose(product: ThroughputProduct) {
productId = String(product.id);
query = label(product);
open = false;
highlighted = -1;
}
function clear() {
productId = '';
query = '';
open = false;
highlighted = -1;
}
function onInput(event: Event) {
query = (event.target as HTMLInputElement).value;
productId = '';
open = true;
highlighted = filtered.length ? 0 : -1;
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
open = true;
highlighted = Math.min(highlighted + 1, filtered.length - 1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
highlighted = Math.max(highlighted - 1, 0);
} else if (event.key === 'Enter') {
if (open && highlighted >= 0 && highlighted < filtered.length) {
event.preventDefault();
choose(filtered[highlighted]);
}
} else if (event.key === 'Escape') {
open = false;
highlighted = -1;
}
}
function onFocusOut(event: FocusEvent) {
if (root && event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) {
return;
}
focused = false;
open = false;
highlighted = -1;
}
</script>
<div class="picker" bind:this={root} onfocusin={() => (focused = true)} onfocusout={onFocusOut}>
<div class="client-row">
<label class="client-label" for={`${inputId}-client`}>Client</label>
<select
id={`${inputId}-client`}
class="client-select"
bind:value={clientName}
{disabled}
>
<option value="">All clients</option>
{#each clients as client (client)}
<option value={client}>{client}</option>
{/each}
</select>
</div>
<div class="combo" role="combobox" aria-expanded={open} aria-haspopup="listbox" aria-controls={`${inputId}-list`}>
<span class="combo-icon" aria-hidden="true"><Search size={16} strokeWidth={2.2} /></span>
<input
id={inputId}
class="combo-input"
type="text"
autocomplete="off"
placeholder="Search product or item id…"
value={query}
{disabled}
aria-autocomplete="list"
oninput={onInput}
onfocus={() => (open = true)}
onkeydown={onKeydown}
/>
{#if productId}
<button type="button" class="combo-clear" onclick={clear} aria-label="Clear product">
<X size={15} strokeWidth={2.4} />
</button>
{/if}
{#if open && !disabled}
<ul class="options" id={`${inputId}-list`} role="listbox">
{#if filtered.length === 0}
<li class="option empty">No products match.</li>
{:else}
{#each filtered.slice(0, 50) as product, i (product.id)}
<li
class="option"
class:highlighted={i === highlighted}
class:selected={String(product.id) === productId}
role="option"
aria-selected={String(product.id) === productId}
onmousedown={(e) => {
e.preventDefault();
choose(product);
}}
onmouseenter={() => (highlighted = i)}
>
<span class="option-name">{product.name}</span>
<span class="option-meta">
{#if product.client_name}<span class="option-client">{product.client_name}</span>{/if}
{#if product.item_id}<span class="option-item">#{product.item_id}</span>{/if}
</span>
{#if String(product.id) === productId}
<span class="option-check" aria-hidden="true"><Check size={15} strokeWidth={2.6} /></span>
{/if}
</li>
{/each}
{#if filtered.length > 50}
<li class="option more">
Showing first 50 of {filtered.length} — keep typing to narrow.
</li>
{/if}
{/if}
</ul>
{/if}
</div>
</div>
<style>
.picker {
display: flex;
flex-direction: column;
gap: 0.4rem;
min-width: 0;
}
.client-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.client-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
white-space: nowrap;
}
.client-select {
flex: 1 1 auto;
min-width: 0;
min-height: 40px;
padding: 0.4rem 0.55rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.5rem;
font: inherit;
background: var(--color-bg-surface, #fff);
color: var(--color-text-primary, #111827);
}
.combo {
position: relative;
display: flex;
align-items: center;
min-width: 0;
}
.combo-icon {
position: absolute;
left: 0.6rem;
display: inline-flex;
color: var(--color-text-muted, #6b7280);
pointer-events: none;
}
.combo-input {
width: 100%;
min-height: 46px;
padding: 0.55rem 2rem 0.55rem 2rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.55rem;
font: inherit;
background: var(--color-bg-surface, #fff);
color: var(--color-text-primary, #111827);
}
.combo-input:focus-visible {
outline: 3px solid var(--color-brand, #157f3a);
outline-offset: 1px;
border-color: var(--color-brand, #157f3a);
}
.combo-clear {
position: absolute;
right: 0.45rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--color-text-muted, #6b7280);
cursor: pointer;
}
.combo-clear:hover {
background: var(--color-bg-app, #f3f4f6);
color: var(--color-text-primary, #111827);
}
.options {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 50;
margin: 0;
padding: 0.25rem;
list-style: none;
max-height: 18rem;
overflow-y: auto;
background: var(--color-bg-surface, #fff);
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.6rem;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16);
}
.option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.6rem;
border-radius: 0.45rem;
font-size: 0.95rem;
color: var(--color-text-primary, #111827);
cursor: pointer;
}
.option.highlighted {
background: var(--color-brand-tint, #e8f5ec);
}
.option.selected {
font-weight: 650;
}
.option.empty,
.option.more {
color: var(--color-text-muted, #6b7280);
cursor: default;
font-size: 0.88rem;
}
.option-name {
flex: 1 1 auto;
min-width: 0;
}
.option-meta {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.option-client {
font-size: 0.78rem;
color: var(--color-text-secondary, #4b5563);
background: var(--color-bg-app, #f3f4f6);
padding: 0.1rem 0.4rem;
border-radius: 999px;
}
.option-item {
font-size: 0.8rem;
color: var(--color-text-muted, #6b7280);
font-variant-numeric: tabular-nums;
}
.option-check {
color: var(--color-brand, #157f3a);
flex-shrink: 0;
}
</style>
+10
View File
@@ -394,6 +394,7 @@ export type ThroughputProduct = {
tenant_id: string;
item_id: string | null;
name: string;
client_name: string | null;
default_bag_size: number | null;
is_bulka_default: boolean;
active: boolean;
@@ -416,6 +417,10 @@ export type ThroughputEntry = {
label_correct: boolean;
bag_sealed: boolean;
pallet_good_condition: boolean;
for_order: boolean;
for_stock: boolean;
job_number: string | null;
stock_quantity: number | null;
sample_box_no: string | null;
test_weight_1: number | null;
test_weight_2: number | null;
@@ -442,6 +447,10 @@ export type ThroughputEntryCreateInput = {
label_correct?: boolean;
bag_sealed?: boolean;
pallet_good_condition?: boolean;
for_order?: boolean;
for_stock?: boolean;
job_number?: string | null;
stock_quantity?: number | null;
sample_box_no?: string | null;
test_weight_1?: number | null;
test_weight_2?: number | null;
@@ -466,6 +475,7 @@ export type ThroughputEntryListParams = {
export type ThroughputProductCreateInput = {
item_id?: string | null;
name: string;
client_name?: string | null;
default_bag_size?: number | null;
is_bulka_default?: boolean;
active?: boolean;
+165 -165
View File
@@ -6,18 +6,37 @@
ThroughputProduct,
ThroughputQuantityType
} from '$lib/types';
import { Plus, ShieldCheck, TriangleAlert, Search, X } from 'lucide-svelte';
import { Plus, TriangleAlert, Search, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte';
let { data } = $props<{ data: { entries: ThroughputEntry[]; products: ThroughputProduct[] } }>();
let entries = $state<ThroughputEntry[]>(data.entries ?? []);
const products = $derived(data.products ?? []);
// A run is "going into stock" when the operator marks it so; everything else
// is a client order. The marker is the word "Stock" in the notes field.
// A run is "going into stock" when the operator ticks the stock checkpoint.
// Older imported rows pre-date that flag, so fall back to the notes marker.
function isStockEntry(entry: ThroughputEntry): boolean {
return /stock/i.test(entry.notes ?? '');
return entry.for_stock || (!entry.for_order && /stock/i.test(entry.notes ?? ''));
}
// The destination shown in the log: an order (with job number), stock, or a
// split across both.
function destinationOf(entry: ThroughputEntry): { label: string; detail: string | null } {
const unit = entry.quantity_type === 'bags' ? 'bags' : 'kg';
if (entry.for_order && entry.for_stock) {
const stock = entry.stock_quantity != null ? `${formatNumber(entry.stock_quantity, 1)} ${unit} to stock` : 'split';
const job = entry.job_number ? `Order ${entry.job_number}` : 'Order';
return { label: 'Split', detail: `${job} · ${stock}` };
}
if (entry.for_order) {
return { label: 'Order', detail: entry.job_number ? `Job ${entry.job_number}` : null };
}
if (isStockEntry(entry)) {
return { label: 'Stock', detail: null };
}
return { label: '—', detail: null };
}
// ── Inline "spreadsheet" add row ──────────────────────────────
@@ -29,17 +48,20 @@
let nBagSize = $state('');
let nStaff = $state('');
let nNotes = $state('');
// QA checks start unchecked on purpose: the operator must confirm each one.
let nScalesChecked = $state(false);
let nLabelCorrect = $state(false);
let nBagSealed = $state(false);
let nPalletGood = $state(false);
// Destination checkpoints: a run can go to a client order, to stock, or both.
let nForOrder = $state(false);
let nForStock = $state(false);
let nJobNumber = $state('');
let nStockQty = $state('');
let showNote = $state(false);
let saving = $state(false);
let addError = $state('');
let highlightId = $state<number | null>(null);
// When both checkpoints are ticked the run is split, so we need to know how
// much goes to stock (the rest belongs to the order).
const isSplit = $derived(nForOrder && nForStock);
const selectedNewProduct = $derived(
nProductId ? products.find((p: ThroughputProduct) => String(p.id) === nProductId) ?? null : null
);
@@ -88,11 +110,30 @@
return;
}
// Fold the "going into stock" marker into the notes, which is where stock
// runs are identified.
let finalNotes = nNotes.trim();
if (nForStock && !/stock/i.test(finalNotes)) {
finalNotes = finalNotes ? `Stock. ${finalNotes}` : 'Stock';
if (!nForOrder && !nForStock) {
addError = 'Mark where this run goes: for an order, for stock, or both.';
return;
}
const job = nJobNumber.trim();
if (nForOrder && !job) {
addError = 'Enter the job number for the order.';
return;
}
// For a split, the operator records how much goes to stock; the remainder
// belongs to the order. Whole runs (stock-only or order-only) don't need it.
let stockQty: number | null = null;
if (isSplit) {
stockQty = toNum(nStockQty);
if (stockQty === null || stockQty <= 0) {
addError = `Enter how much goes to stock (in ${nType === 'bags' ? 'bags' : 'kg'}).`;
return;
}
if (stockQty >= qty) {
addError = 'Stock amount must be less than the total packed for a split.';
return;
}
}
saving = true;
@@ -104,12 +145,12 @@
bag_size: nType === 'bags' ? bag : null,
quantity: qty,
quantity_type: nType,
scales_checked: nScalesChecked,
label_correct: nLabelCorrect,
bag_sealed: nBagSealed,
pallet_good_condition: nPalletGood,
for_order: nForOrder,
for_stock: nForStock,
job_number: nForOrder ? job : null,
stock_quantity: stockQty,
staff_name: nStaff.trim() || null,
notes: finalNotes || null
notes: nNotes.trim() || null
});
entries = [created, ...entries];
highlightId = created.id;
@@ -122,11 +163,10 @@
nType = 'bags';
nBagSize = '';
nNotes = '';
nScalesChecked = false;
nLabelCorrect = false;
nBagSealed = false;
nPalletGood = false;
nForOrder = false;
nForStock = false;
nJobNumber = '';
nStockQty = '';
showNote = false;
} catch (err) {
addError = err instanceof Error ? err.message : 'Could not save. Please try again.';
@@ -185,15 +225,15 @@
const totals = $derived.by(() => {
let totalKg = 0;
let totalBags = 0;
let qaFailures = 0;
let stockCount = 0;
for (const entry of entries) {
totalKg += entry.calculated_kg || 0;
if (entry.quantity_type === 'bags') {
totalBags += entry.quantity || 0;
}
if (!entry.qa_passed) qaFailures += 1;
if (isStockEntry(entry)) stockCount += 1;
}
return { totalKg, totalBags, qaFailures, count: entries.length };
return { totalKg, totalBags, stockCount, count: entries.length };
});
function formatDate(value: string) {
@@ -226,27 +266,17 @@
</script>
<section class="throughput">
<div class="status-band" class:has-issues={totals.qaFailures > 0}>
<div class="status-band">
<div class="qa-status">
{#if totals.qaFailures > 0}
<span class="qa-icon"><TriangleAlert size={26} strokeWidth={2.2} /></span>
<span class="qa-words">
<strong>{formatNumber(totals.qaFailures)} {totals.qaFailures === 1 ? 'entry needs' : 'entries need'} attention</strong>
<small>A quality check did not pass. Look for the amber rows below.</small>
</span>
{:else if totals.count > 0}
<span class="qa-icon"><ShieldCheck size={26} strokeWidth={2.2} /></span>
<span class="qa-words">
<strong>All quality checks passed</strong>
<small>Every entry in this view is good to go.</small>
</span>
{:else}
<span class="qa-icon qa-icon-quiet"><ShieldCheck size={26} strokeWidth={2.2} /></span>
<span class="qa-words">
<span class="qa-words">
{#if totals.count > 0}
<strong>Production log</strong>
<small>{formatNumber(totals.stockCount)} of {formatNumber(totals.count)} {totals.count === 1 ? 'run' : 'runs'} going to stock.</small>
{:else}
<strong>Nothing logged yet</strong>
<small>Fill the green row below to add your first entry.</small>
</span>
{/if}
{/if}
</span>
</div>
<dl class="facts">
@@ -348,7 +378,7 @@
<span class="col-product">Product</span>
<span class="col-packed">Packed</span>
<span class="col-staff">Packed by</span>
<span class="col-qa">Quality</span>
<span class="col-dest">Destination</span>
<span class="col-notes-head">Notes</span>
</div>
@@ -357,14 +387,9 @@
<span class="cell-label">Date</span>
<input type="date" bind:value={nDate} aria-label="Production date" />
</div>
<div class="add-cell">
<div class="add-cell add-product">
<span class="cell-label">Product</span>
<select bind:value={nProductId} aria-label="Product">
<option value="">Choose product…</option>
{#each products as p (p.id)}
<option value={String(p.id)}>{p.name}</option>
{/each}
</select>
<ThroughputProductPicker {products} bind:productId={nProductId} inputId="throughput-add-product" />
</div>
<div class="add-cell">
<span class="cell-label">Packed</span>
@@ -405,14 +430,37 @@
<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-qa">
<span class="cell-label">Quality checks</span>
<div class="qa-checks">
<label class="qa-check"><input type="checkbox" bind:checked={nScalesChecked} /> Scales checked</label>
<label class="qa-check"><input type="checkbox" bind:checked={nLabelCorrect} /> Label correct</label>
<label class="qa-check"><input type="checkbox" bind:checked={nBagSealed} /> Bag sealed</label>
<label class="qa-check"><input type="checkbox" bind:checked={nPalletGood} /> Pallet OK</label>
<div class="add-cell add-dest">
<span class="cell-label">Destination</span>
<div class="dest-options">
<label class="dest-toggle" class:on={nForOrder}>
<input type="checkbox" bind:checked={nForOrder} /> For an order
</label>
<label class="dest-toggle" class:on={nForStock}>
<input type="checkbox" bind:checked={nForStock} /> For stock
</label>
</div>
{#if nForOrder}
<input
class="dest-input"
type="text"
bind:value={nJobNumber}
placeholder="Job number (Order Circle)"
aria-label="Job number"
/>
{/if}
{#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 class="add-cell add-action">
<button type="submit" class="add-entry-button" disabled={saving}>
@@ -422,9 +470,6 @@
</div>
<div class="add-extra">
<label class="stock-toggle" class:on={nForStock}>
<input type="checkbox" bind:checked={nForStock} /> Going into stock
</label>
{#if showNote}
<input
class="note-input"
@@ -439,7 +484,7 @@
{#if addError}
<span class="add-error"><TriangleAlert size={15} strokeWidth={2.4} /> {addError}</span>
{/if}
<a class="detail-link" href="/throughput/add">Full form for QA checks and test weights</a>
<a class="detail-link" href="/throughput/add">Open full form (sample box &amp; test weights)</a>
</div>
</form>
@@ -455,7 +500,8 @@
{/each}
{:else}
{#each entries as entry (entry.id)}
<div class="row" class:needs-attention={!entry.qa_passed} class:just-added={entry.id === highlightId}>
{@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)}
@@ -463,9 +509,6 @@
<span class="col-product">
<span class="cell-label">Product</span>
<span class="product-name">{entry.product_name_snapshot}</span>
{#if isStockEntry(entry)}
<span class="stock-tag">Stock</span>
{/if}
</span>
<span class="col-packed">
<span class="cell-label">Packed</span>
@@ -476,13 +519,15 @@
<span class="cell-label">Packed by</span>
{entry.staff_name ?? '—'}
</span>
<span class="col-qa">
<span class="cell-label">Quality</span>
{#if entry.qa_passed}
<span class="pill pill-pass"><ShieldCheck size={16} strokeWidth={2.4} /> Passed</span>
{:else}
<span class="pill pill-attention"><TriangleAlert size={16} strokeWidth={2.4} /> Needs a look</span>
{/if}
<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>
{#if entry.notes}
<p class="row-notes"><span class="cell-label">Note</span>{entry.notes}</p>
@@ -526,32 +571,11 @@
border: 1px solid #bfe6c8;
border-radius: 0.9rem;
}
.status-band.has-issues {
background: #fdf6e9;
border-color: #ecd9a8;
}
.qa-status {
display: flex;
align-items: center;
gap: 0.9rem;
}
.qa-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
flex-shrink: 0;
border-radius: 50%;
background: #fff;
color: var(--color-success);
}
.status-band.has-issues .qa-icon {
color: var(--color-warning);
}
.qa-icon-quiet {
color: var(--color-text-muted);
}
.qa-words {
display: flex;
flex-direction: column;
@@ -794,12 +818,6 @@
.row:hover {
background: #fafbfc;
}
.row.needs-attention {
background: #fdf6e9;
}
.row.needs-attention:hover {
background: #fbf0db;
}
.row.just-added {
animation: flash-in 1.8s ease-out;
}
@@ -816,24 +834,16 @@
.product-name {
font-weight: 600;
}
.stock-tag {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.55rem;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 650;
line-height: 1.2;
background: #e8f1fc;
color: #0b5cad;
.col-dest {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.stock-tag::before {
content: '';
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: currentColor;
.dest-detail {
font-size: 0.88rem;
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.col-packed {
display: flex;
@@ -868,13 +878,17 @@
font-weight: 650;
white-space: nowrap;
}
.pill-pass {
.pill-stock {
background: #e8f1fc;
color: #0b5cad;
}
.pill-order {
background: var(--color-brand-tint);
color: var(--color-success);
}
.pill-attention {
background: #fbedcf;
color: #8a5a00;
.pill-split {
background: #f3e8fc;
color: #6b21a8;
}
.row-notes {
grid-column: 1 / -1;
@@ -888,7 +902,7 @@
/* ── Inline spreadsheet add row ───────────────────────────── */
.add-row {
display: grid;
grid-template-columns: 9rem minmax(0, 1.4fr) minmax(0, 1.4fr) minmax(0, 1fr) 11rem auto;
grid-template-columns: 9rem minmax(0, 1.6fr) minmax(0, 1.3fr) minmax(0, 0.9fr) minmax(0, 1.6fr) auto;
gap: 0.6rem 1rem;
align-items: start;
padding: 1rem 1.5rem 1.1rem;
@@ -947,35 +961,49 @@
color: var(--color-success);
font-variant-numeric: tabular-nums;
}
.qa-checks {
.add-dest {
gap: 0.4rem;
}
.dest-options {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 0.35rem;
}
.qa-check {
.dest-toggle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
font-weight: 500;
color: var(--color-text-primary);
line-height: 1.15;
gap: 0.4rem;
padding: 0.3rem 0.6rem;
border: 1px solid #aedcbb;
border-radius: 0.5rem;
background: var(--color-bg-surface);
font-size: 0.92rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
}
.qa-check input {
width: 1.3rem;
height: 1.3rem;
.dest-toggle input {
width: 1.2rem;
height: 1.2rem;
min-height: 0;
margin: 0;
flex-shrink: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
.qa-check input:focus-visible {
.dest-toggle input:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.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;
}
@@ -1017,32 +1045,6 @@
gap: 1rem;
flex-wrap: wrap;
}
.stock-toggle {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.7rem;
border: 1px solid #aedcbb;
border-radius: 0.5rem;
background: var(--color-bg-surface);
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
}
.stock-toggle input {
width: 1.2rem;
height: 1.2rem;
margin: 0;
accent-color: #0b5cad;
cursor: pointer;
}
.stock-toggle.on {
border-color: #9ccaf3;
background: #e8f1fc;
color: #0b5cad;
}
.link-button {
padding: 0.25rem 0;
background: none;
@@ -1146,9 +1148,6 @@
border: 1px solid var(--color-border);
border-radius: 0.7rem;
}
.row.needs-attention {
border-color: #ecd9a8;
}
.col-product,
.row-notes {
grid-column: 1 / -1;
@@ -1161,7 +1160,7 @@
.col-product,
.col-packed,
.col-staff,
.col-qa {
.col-dest {
display: flex;
flex-direction: column;
gap: 0.05rem;
@@ -1180,6 +1179,7 @@
display: block;
}
.add-cell:nth-child(2),
.add-dest,
.add-action {
grid-column: 1 / -1;
}
+78 -44
View File
@@ -7,6 +7,7 @@
ThroughputQuantityType
} from '$lib/types';
import { CheckCircle2, AlertTriangle, ArrowLeft } from 'lucide-svelte';
import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte';
let { data } = $props<{ data: { products: ThroughputProduct[] } }>();
const products = $derived(data.products ?? []);
@@ -18,10 +19,10 @@
let bagSize = $state<string>('');
let quantity = $state<string>('');
let quantityType = $state<ThroughputQuantityType>('bags');
let scalesChecked = $state(true);
let labelCorrect = $state(true);
let bagSealed = $state(true);
let palletGood = $state(true);
let forOrder = $state(false);
let forStock = $state(false);
let jobNumber = $state('');
let stockQty = $state('');
let sampleBoxNo = $state('');
let tw1 = $state('');
let tw2 = $state('');
@@ -39,6 +40,8 @@
productId ? products.find((p: ThroughputProduct) => String(p.id) === productId) ?? null : null
);
const isSplit = $derived(forOrder && forStock);
$effect(() => {
if (selectedProduct) {
if (!bagSize && selectedProduct.default_bag_size != null) {
@@ -50,8 +53,6 @@
}
});
const qaWarning = $derived(!(scalesChecked && labelCorrect && bagSealed && palletGood));
function toNum(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
@@ -64,10 +65,10 @@
bagSize = '';
quantity = '';
quantityType = 'bags';
scalesChecked = true;
labelCorrect = true;
bagSealed = true;
palletGood = true;
forOrder = false;
forStock = false;
jobNumber = '';
stockQty = '';
sampleBoxNo = '';
tw1 = '';
tw2 = '';
@@ -97,15 +98,37 @@
return null;
}
if (!forOrder && !forStock) {
errorMessage = 'Mark where this run goes: for an order, for stock, or both.';
return null;
}
const job = jobNumber.trim();
if (forOrder && !job) {
errorMessage = 'Enter the job number for the order.';
return null;
}
let stock: number | null = null;
if (isSplit) {
stock = toNum(stockQty);
if (stock === null || stock <= 0) {
errorMessage = `Enter how much goes to stock (in ${quantityType === 'bags' ? 'bags' : 'kg'}).`;
return null;
}
if (stock >= qty) {
errorMessage = 'Stock amount must be less than the total packed for a split.';
return null;
}
}
return {
production_date: productionDate,
product_id: Number(productId),
product_name_snapshot: selectedProduct?.name ?? '',
bag_size: bag,
scales_checked: scalesChecked,
label_correct: labelCorrect,
bag_sealed: bagSealed,
pallet_good_condition: palletGood,
for_order: forOrder,
for_stock: forStock,
job_number: forOrder ? job : null,
stock_quantity: stock,
sample_box_no: sampleBoxNo.trim() || null,
test_weight_1: toNum(tw1),
test_weight_2: toNum(tw2),
@@ -162,15 +185,10 @@
<input type="date" bind:value={productionDate} required />
</label>
<label>
<span>Product *</span>
<select bind:value={productId} required>
<option value="">Select product…</option>
{#each products as p (p.id)}
<option value={String(p.id)}>{p.name}{p.item_id ? ` · ${p.item_id}` : ''}</option>
{/each}
</select>
</label>
<div class="picker-field">
<span class="picker-label">Product *</span>
<ThroughputProductPicker {products} bind:productId inputId="throughput-full-product" />
</div>
<label>
<span>Bag size (kg)</span>
@@ -196,15 +214,26 @@
</label>
</div>
<fieldset class="qa">
<legend>QA checklist</legend>
<label class="check"><input type="checkbox" bind:checked={scalesChecked} /> Scales checked</label>
<label class="check"><input type="checkbox" bind:checked={labelCorrect} /> Label correct</label>
<label class="check"><input type="checkbox" bind:checked={bagSealed} /> Bag sealed</label>
<label class="check"><input type="checkbox" bind:checked={palletGood} /> Pallet in good condition</label>
{#if qaWarning}
<p class="qa-warning"><AlertTriangle size={14} /> One or more QA checks failed — this entry will be flagged.</p>
{/if}
<fieldset class="destination">
<legend>Where does this run go?</legend>
<div class="dest-checks">
<label class="check"><input type="checkbox" bind:checked={forOrder} /> For an order</label>
<label class="check"><input type="checkbox" bind:checked={forStock} /> For stock</label>
</div>
<div class="dest-fields">
{#if forOrder}
<label>
<span>Job number (Order Circle)</span>
<input type="text" bind:value={jobNumber} placeholder="e.g. job number" />
</label>
{/if}
{#if isSplit}
<label>
<span>Amount going to stock ({quantityType === 'bags' ? 'bags' : 'kg'})</span>
<input type="number" min="0" step="0.01" bind:value={stockQty} placeholder="Remainder goes to the order" />
</label>
{/if}
</div>
</fieldset>
<fieldset class="weights">
@@ -308,25 +337,30 @@
font-weight: 600;
font-size: 0.85rem;
}
.qa {
.picker-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
}
.picker-label {
font-weight: 500;
}
.dest-checks {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1.25rem;
}
.dest-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.4rem 1rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.5rem 1rem;
}
.check {
flex-direction: row;
align-items: center;
gap: 0.45rem;
}
.qa-warning {
grid-column: 1 / -1;
margin: 0;
color: #92400e;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
}
.weight-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));