2026-05-31 20:19:44 +12:00
|
|
|
<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';
|
2026-06-02 15:41:53 +12:00
|
|
|
import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte';
|
2026-05-31 20:19:44 +12:00
|
|
|
|
|
|
|
|
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');
|
2026-06-02 15:41:53 +12:00
|
|
|
let forOrder = $state(false);
|
|
|
|
|
let forStock = $state(false);
|
|
|
|
|
let jobNumber = $state('');
|
|
|
|
|
let stockQty = $state('');
|
2026-05-31 20:19:44 +12:00
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-02 15:41:53 +12:00
|
|
|
const isSplit = $derived(forOrder && forStock);
|
|
|
|
|
|
2026-05-31 20:19:44 +12:00
|
|
|
$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';
|
2026-06-02 15:41:53 +12:00
|
|
|
forOrder = false;
|
|
|
|
|
forStock = false;
|
|
|
|
|
jobNumber = '';
|
|
|
|
|
stockQty = '';
|
2026-05-31 20:19:44 +12:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 15:41:53 +12:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:19:44 +12:00
|
|
|
return {
|
|
|
|
|
production_date: productionDate,
|
|
|
|
|
product_id: Number(productId),
|
|
|
|
|
product_name_snapshot: selectedProduct?.name ?? '',
|
|
|
|
|
bag_size: bag,
|
2026-06-02 15:41:53 +12:00
|
|
|
for_order: forOrder,
|
|
|
|
|
for_stock: forStock,
|
|
|
|
|
job_number: forOrder ? job : null,
|
|
|
|
|
stock_quantity: stock,
|
2026-05-31 20:19:44 +12:00
|
|
|
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>
|
|
|
|
|
|
2026-06-02 15:41:53 +12:00
|
|
|
<div class="picker-field">
|
|
|
|
|
<span class="picker-label">Product *</span>
|
|
|
|
|
<ThroughputProductPicker {products} bind:productId inputId="throughput-full-product" />
|
|
|
|
|
</div>
|
2026-05-31 20:19:44 +12:00
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
2026-06-02 15:41:53 +12:00
|
|
|
<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>
|
2026-05-31 20:19:44 +12:00
|
|
|
</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;
|
|
|
|
|
}
|
2026-06-02 15:41:53 +12:00
|
|
|
.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 {
|
2026-05-31 20:19:44 +12:00
|
|
|
display: grid;
|
2026-06-02 15:41:53 +12:00
|
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
|
|
|
gap: 0.5rem 1rem;
|
2026-05-31 20:19:44 +12:00
|
|
|
}
|
|
|
|
|
.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>
|