tweaks
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user