Files
data-entry-app/frontend/src/routes/throughput/add/+page.svelte
T
2026-06-02 15:41:53 +12:00

424 lines
11 KiB
Svelte

<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';
import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.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 forOrder = $state(false);
let forStock = $state(false);
let jobNumber = $state('');
let stockQty = $state('');
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
);
const isSplit = $derived(forOrder && forStock);
$effect(() => {
if (selectedProduct) {
if (!bagSize && selectedProduct.default_bag_size != null) {
bagSize = String(selectedProduct.default_bag_size);
}
if (selectedProduct.is_bulka_default) {
quantityType = 'kg';
}
}
});
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';
forOrder = false;
forStock = false;
jobNumber = '';
stockQty = '';
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;
}
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,
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),
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>
<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>
<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="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">
<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;
}
.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(220px, 1fr));
gap: 0.5rem 1rem;
}
.check {
flex-direction: row;
align-items: center;
gap: 0.45rem;
}
.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>