This commit is contained in:
2026-05-31 20:19:44 +12:00
parent 2f2466ecac
commit 84792c0947
59 changed files with 5412 additions and 898 deletions
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canOpenThroughput, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { entries: [], products: [] };
}
const session = getStoredClientSession();
if (!canOpenThroughput(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
// Default view shows the last 30 days; "Find past entries" surfaces older ones.
const recentFrom = new Date();
recentFrom.setDate(recentFrom.getDate() - 30);
const dateFrom = recentFrom.toISOString().slice(0, 10);
try {
const [entries, products] = await Promise.all([
api.throughputEntries({ date_from: dateFrom, limit: 200 }, fetch),
api.throughputProducts(fetch)
]);
return { entries, products };
} catch {
return { entries: [], products: [] };
}
}
@@ -0,0 +1,389 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import type {
ThroughputEntryCreateInput,
ThroughputProduct,
ThroughputQuantityType
} from '$lib/types';
import { CheckCircle2, AlertTriangle, ArrowLeft } from 'lucide-svelte';
let { data } = $props<{ data: { products: ThroughputProduct[] } }>();
const products = $derived(data.products ?? []);
const today = new Date().toISOString().slice(0, 10);
let productionDate = $state(today);
let productId = $state<string>('');
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 sampleBoxNo = $state('');
let tw1 = $state('');
let tw2 = $state('');
let tw3 = $state('');
let tw4 = $state('');
let tw5 = $state('');
let staffName = $state('');
let notes = $state('');
let saving = $state(false);
let successMessage = $state('');
let errorMessage = $state('');
const selectedProduct = $derived(
productId ? products.find((p: ThroughputProduct) => String(p.id) === productId) ?? null : null
);
$effect(() => {
if (selectedProduct) {
if (!bagSize && selectedProduct.default_bag_size != null) {
bagSize = String(selectedProduct.default_bag_size);
}
if (selectedProduct.is_bulka_default) {
quantityType = 'kg';
}
}
});
const qaWarning = $derived(!(scalesChecked && labelCorrect && bagSealed && palletGood));
function toNum(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
function resetExceptDateAndStaff() {
productId = '';
bagSize = '';
quantity = '';
quantityType = 'bags';
scalesChecked = true;
labelCorrect = true;
bagSealed = true;
palletGood = true;
sampleBoxNo = '';
tw1 = '';
tw2 = '';
tw3 = '';
tw4 = '';
tw5 = '';
notes = '';
}
function buildPayload(): ThroughputEntryCreateInput | null {
const qty = toNum(quantity);
if (qty === null || qty < 0) {
errorMessage = 'Quantity is required and must be 0 or greater.';
return null;
}
if (!productionDate) {
errorMessage = 'Date is required.';
return null;
}
if (!productId) {
errorMessage = 'Product is required.';
return null;
}
const bag = toNum(bagSize);
if (quantityType === 'bags' && (bag === null || bag <= 0)) {
errorMessage = 'Bag size is required when quantity type is "bags".';
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,
sample_box_no: sampleBoxNo.trim() || null,
test_weight_1: toNum(tw1),
test_weight_2: toNum(tw2),
test_weight_3: toNum(tw3),
test_weight_4: toNum(tw4),
test_weight_5: toNum(tw5),
quantity: qty,
quantity_type: quantityType,
staff_name: staffName.trim() || null,
notes: notes.trim() || null
};
}
async function submit(mode: 'save' | 'save-and-add') {
errorMessage = '';
successMessage = '';
const payload = buildPayload();
if (!payload) return;
saving = true;
try {
await api.createThroughputEntry(payload);
if (mode === 'save') {
await goto('/throughput');
return;
}
successMessage = 'Entry saved. Ready for the next one.';
resetExceptDateAndStaff();
} catch (err) {
errorMessage = err instanceof Error ? err.message : 'Failed to save entry';
} finally {
saving = false;
}
}
</script>
<section class="add-entry">
<header class="page-header">
<a class="back-link" href="/throughput"><ArrowLeft size={16} /> Back to log</a>
<h1>Add Throughput Entry</h1>
</header>
{#if successMessage}
<div class="banner banner-ok"><CheckCircle2 size={16} /> {successMessage}</div>
{/if}
{#if errorMessage}
<div class="banner banner-error"><AlertTriangle size={16} /> {errorMessage}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); submit('save'); }}>
<div class="grid">
<label>
<span>Date *</span>
<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>
<label>
<span>Bag size (kg)</span>
<input type="number" min="0" step="0.01" bind:value={bagSize} placeholder="e.g. 20" />
</label>
<label>
<span>Quantity *</span>
<input type="number" min="0" step="0.01" bind:value={quantity} required />
</label>
<label>
<span>Quantity type *</span>
<select bind:value={quantityType}>
<option value="bags">Bags</option>
<option value="kg">Kilograms (bulka / bulk)</option>
</select>
</label>
<label>
<span>Sample box no.</span>
<input type="text" bind:value={sampleBoxNo} />
</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>
<fieldset class="weights">
<legend>Test weights (optional, RCP 001)</legend>
<div class="weight-grid">
<label><span>1</span><input type="number" step="0.01" bind:value={tw1} /></label>
<label><span>2</span><input type="number" step="0.01" bind:value={tw2} /></label>
<label><span>3</span><input type="number" step="0.01" bind:value={tw3} /></label>
<label><span>4</span><input type="number" step="0.01" bind:value={tw4} /></label>
<label><span>5</span><input type="number" step="0.01" bind:value={tw5} /></label>
</div>
</fieldset>
<div class="grid">
<label>
<span>Staff name</span>
<input type="text" bind:value={staffName} placeholder="e.g. Jake" />
</label>
<label class="full">
<span>Notes</span>
<textarea rows="2" bind:value={notes}></textarea>
</label>
</div>
<div class="actions">
<button type="submit" class="primary-button" disabled={saving}>Save</button>
<button type="button" class="secondary-button" disabled={saving} onclick={() => submit('save-and-add')}>
Save and add another
</button>
<a href="/throughput" class="ghost-button">Cancel</a>
</div>
</form>
</section>
<style>
.add-entry {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1rem 0;
max-width: 980px;
}
.page-header {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--text-muted, #6b7280);
font-size: 0.85rem;
text-decoration: none;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-radius: 0.65rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
}
label.full {
grid-column: 1 / -1;
}
input,
select,
textarea {
padding: 0.5rem 0.65rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.4rem;
font: inherit;
}
fieldset {
border: 1px solid var(--border, #e5e7eb);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
legend {
padding: 0 0.4rem;
font-weight: 600;
font-size: 0.85rem;
}
.qa {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.4rem 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));
gap: 0.5rem;
}
.weight-grid label {
flex-direction: column;
gap: 0.25rem;
}
.weight-grid label span {
font-size: 0.75rem;
color: var(--text-muted, #6b7280);
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.primary-button {
padding: 0.6rem 1rem;
background: var(--accent, #1f2937);
color: white;
border-radius: 0.5rem;
border: 0;
font-weight: 600;
cursor: pointer;
}
.secondary-button {
padding: 0.6rem 1rem;
background: white;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
}
.ghost-button {
padding: 0.6rem 1rem;
border-radius: 0.5rem;
text-decoration: none;
color: var(--text-muted, #6b7280);
align-self: center;
}
.banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 0.85rem;
border-radius: 0.5rem;
}
.banner-ok {
background: #ecfdf5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.banner-error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
</style>
@@ -0,0 +1,22 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canEditThroughput, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { products: [] };
}
const session = getStoredClientSession();
if (!canEditThroughput(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const products = await api.throughputProducts(fetch);
return { products };
} catch {
return { products: [] };
}
}