Files
data-entry-app/frontend/src/lib/components/MixWorkspace.svelte
T
2026-05-08 09:06:14 +12:00

981 lines
25 KiB
Svelte

<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { api } from '$lib/api';
import { clientSession } from '$lib/session';
import { toast } from '$lib/toast';
import type { Mix, MixIngredient, RawMaterial } from '$lib/types';
type DraftIngredient = {
id: number | null;
raw_material_id: number | null;
quantity_kg: number;
notes: string;
};
let {
rawMaterials,
initialMix = null
}: {
rawMaterials: RawMaterial[];
initialMix?: Mix | null;
} = $props();
const getInitialMix = () => initialMix;
let savedMix = $state<Mix | null>(getInitialMix());
let mixName = $state(getInitialMix()?.name ?? '');
let clientName = $state(getInitialMix()?.client_name ?? '');
let mixStatus = $state(getInitialMix()?.status ?? 'draft');
let mixVersion = $state(getInitialMix()?.version ?? 1);
let mixNotes = $state(getInitialMix()?.notes ?? '');
let draftIngredients = $state<DraftIngredient[]>([]);
let feedback = $state('');
let errorMessage = $state('');
let isSaving = $state(false);
function currency(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
}
return `$${value.toFixed(digits)}`;
}
function findRawMaterial(rawMaterialId: number | null) {
return rawMaterials.find((material) => material.id === rawMaterialId) ?? null;
}
function rowFromIngredient(ingredient: MixIngredient): DraftIngredient {
return {
id: ingredient.id,
raw_material_id: ingredient.raw_material_id,
quantity_kg: ingredient.quantity_kg,
notes: ingredient.notes ?? ''
};
}
function createEmptyIngredient(): DraftIngredient {
return {
id: null,
raw_material_id: rawMaterials[0]?.id ?? null,
quantity_kg: 0,
notes: ''
};
}
function loadDraftFromMix(mix: Mix | null) {
if (!mix) {
savedMix = null;
mixName = '';
clientName = '';
mixStatus = 'draft';
mixVersion = 1;
mixNotes = '';
draftIngredients = [createEmptyIngredient()];
return;
}
savedMix = mix;
mixName = mix.name;
clientName = mix.client_name;
mixStatus = mix.status;
mixVersion = mix.version ?? 1;
mixNotes = mix.notes ?? '';
draftIngredients = mix.ingredients.length ? mix.ingredients.map(rowFromIngredient) : [createEmptyIngredient()];
}
loadDraftFromMix(getInitialMix());
function resetDraft() {
feedback = '';
errorMessage = '';
loadDraftFromMix(savedMix);
}
function updateIngredientField(index: number, field: keyof DraftIngredient, value: string | number | null) {
draftIngredients = draftIngredients.map((row, rowIndex) =>
rowIndex === index
? {
...row,
[field]: value
}
: row
);
}
function addIngredientRow() {
draftIngredients = [...draftIngredients, createEmptyIngredient()];
}
function removeIngredientRow(index: number) {
draftIngredients = draftIngredients.filter((_, rowIndex) => rowIndex !== index);
if (!draftIngredients.length) {
draftIngredients = [createEmptyIngredient()];
}
}
function getCostPerKg(rawMaterialId: number | null) {
return findRawMaterial(rawMaterialId)?.current_price?.cost_per_kg ?? null;
}
function getDraftWarnings() {
const warnings: string[] = [];
const chosen = draftIngredients
.map((row) => row.raw_material_id)
.filter((rawMaterialId): rawMaterialId is number => rawMaterialId !== null);
if (!mixName.trim()) {
warnings.push('Mix name is required.');
}
if (!clientName.trim()) {
warnings.push('Client name is required.');
}
if (!draftIngredients.length || draftIngredients.every((row) => !row.raw_material_id || row.quantity_kg <= 0)) {
warnings.push('Add at least one ingredient row with a positive quantity.');
}
if (new Set(chosen).size !== chosen.length) {
warnings.push('Each raw material can only appear once in a mix.');
}
draftIngredients.forEach((row, index) => {
if (row.raw_material_id === null) {
warnings.push(`Row ${index + 1} is missing a raw material.`);
}
if (row.quantity_kg <= 0) {
warnings.push(`Row ${index + 1} must have a quantity greater than zero.`);
}
if (row.raw_material_id !== null && getCostPerKg(row.raw_material_id) === null) {
warnings.push(`Row ${index + 1} has no active raw material price.`);
}
});
return warnings;
}
function getCleanIngredients() {
return draftIngredients
.filter((row) => row.raw_material_id !== null && row.quantity_kg > 0)
.map((row) => ({
id: row.id,
raw_material_id: row.raw_material_id as number,
quantity_kg: Number(row.quantity_kg),
notes: row.notes.trim() || null
}));
}
async function saveMix() {
const validationWarnings = getDraftWarnings();
if (validationWarnings.length) {
toast.error(validationWarnings[0]);
return;
}
isSaving = true;
const tid = toast.loading(savedMix ? 'Saving mix…' : 'Creating mix…');
try {
const cleanIngredients = getCleanIngredients();
if (!savedMix) {
const created = await api.createMix({
client_name: clientName.trim(),
name: mixName.trim(),
status: mixStatus,
version: mixVersion,
notes: mixNotes.trim() || null,
ingredients: cleanIngredients.map((row) => ({
raw_material_id: row.raw_material_id,
quantity_kg: row.quantity_kg,
notes: row.notes
}))
});
toast.dismiss(tid);
toast.success('Mix created');
await invalidateAll();
await goto(`/mixes/${created.id}`);
return;
}
await api.updateMix(savedMix.id, {
client_name: clientName.trim(),
name: mixName.trim(),
status: mixStatus,
version: mixVersion,
notes: mixNotes.trim() || null
});
const originalById = new Map(savedMix.ingredients.map((ingredient) => [ingredient.id, ingredient]));
const draftIds = new Set(cleanIngredients.filter((row) => row.id !== null).map((row) => row.id as number));
const rowsToDelete = savedMix.ingredients.filter((ingredient) => {
if (!draftIds.has(ingredient.id)) {
return true;
}
const draftRow = cleanIngredients.find((row) => row.id === ingredient.id);
return draftRow ? draftRow.raw_material_id !== ingredient.raw_material_id : false;
});
for (const ingredient of rowsToDelete) {
await api.deleteMixIngredient(savedMix.id, ingredient.id);
}
const rowsToAdd = cleanIngredients.filter((row) => {
if (row.id === null) {
return true;
}
const originalRow = originalById.get(row.id);
return originalRow ? originalRow.raw_material_id !== row.raw_material_id : true;
});
for (const row of rowsToAdd) {
await api.addMixIngredient(savedMix.id, {
raw_material_id: row.raw_material_id,
quantity_kg: row.quantity_kg,
notes: row.notes
});
}
const rowsToPatch = cleanIngredients.filter((row) => {
if (row.id === null) {
return false;
}
const originalRow = originalById.get(row.id);
if (!originalRow || originalRow.raw_material_id !== row.raw_material_id) {
return false;
}
return originalRow.quantity_kg !== row.quantity_kg || (originalRow.notes ?? null) !== row.notes;
});
for (const row of rowsToPatch) {
await api.updateMixIngredient(savedMix.id, row.id as number, {
quantity_kg: row.quantity_kg,
notes: row.notes
});
}
const refreshed = await api.mix(savedMix.id);
await invalidateAll();
loadDraftFromMix(refreshed);
toast.dismiss(tid);
toast.success('Mix saved');
} catch (error) {
toast.dismiss(tid);
toast.error(error instanceof Error ? error.message : 'Unable to save mix');
} finally {
isSaving = false;
}
}
const draftRows = $derived(
draftIngredients.map((row) => {
const material = findRawMaterial(row.raw_material_id);
const costPerKg = material?.current_price?.cost_per_kg ?? null;
const lineCost = costPerKg === null ? null : Number((row.quantity_kg * costPerKg).toFixed(4));
return {
...row,
marketValue: material?.current_price?.market_value ?? null,
wastePercentage: material?.current_price?.waste_percentage ?? null,
costPerKg,
lineCost
};
})
);
const totalMixKg = $derived(draftRows.reduce((sum, row) => sum + Number(row.quantity_kg || 0), 0));
const totalMixCost = $derived(Number(draftRows.reduce((sum, row) => sum + Number(row.lineCost || 0), 0).toFixed(4)));
const mixCostPerKg = $derived(totalMixKg > 0 ? Number((totalMixCost / totalMixKg).toFixed(4)) : null);
const draftWarnings = $derived(getDraftWarnings());
</script>
{#if !$clientSession}
<section class="locked-card">
<p class="eyebrow">Client Access Required</p>
<h2>Sign in on the Hunter Premium Produce home page before editing mixes.</h2>
<p>Mix worksheets use live raw material pricing and save directly into Mix Master.</p>
<a href="/">Return to sign-in</a>
</section>
{:else}
<section class="page-intro">
<div>
<p class="eyebrow">{savedMix ? 'Edit Mix' : 'New Mix'}</p>
<h2>{savedMix ? `Editing ${savedMix.name}` : 'Create a new costing worksheet'}</h2>
<p>Use ingredient rows like a spreadsheet, with live costing based on market value, waste, and unit conversion.</p>
</div>
<div class="intro-actions">
<a class="secondary-button" href="/mixes">Back to table</a>
<button class="primary-button" type="button" onclick={saveMix} disabled={isSaving}>
{isSaving ? 'Saving...' : savedMix ? 'Save Mix' : 'Create Mix'}
</button>
</div>
</section>
{#if feedback}
<p class="feedback success">{feedback}</p>
{/if}
{#if errorMessage}
<p class="feedback error">{errorMessage}</p>
{/if}
<section class="metric-row">
<article class="metric-card">
<span>Live Draft Kg</span>
<strong>{totalMixKg.toFixed(2)}</strong>
<p>Total quantity in the current worksheet</p>
</article>
<article class="metric-card">
<span>Live Draft Cost</span>
<strong>{currency(totalMixCost)}</strong>
<p>Calculated from current row factors</p>
</article>
<article class="metric-card">
<span>Cost / Kg</span>
<strong>{currency(mixCostPerKg, 4)}</strong>
<p>Current worksheet output</p>
</article>
</section>
<section class="editor-grid">
<article class="editor-card">
<div class="section-heading">
<div>
<p class="eyebrow">Worksheet Meta</p>
<h3>Mix details</h3>
</div>
<div class="editor-actions">
<button class="secondary-button" type="button" onclick={resetDraft}>Reset</button>
<button class="primary-button" type="button" onclick={saveMix} disabled={isSaving}>
{isSaving ? 'Saving...' : savedMix ? 'Save Mix' : 'Create Mix'}
</button>
</div>
</div>
<div class="meta-grid">
<label>
Mix Name
<input bind:value={mixName} placeholder="Hunter Orchard Blend" />
</label>
<label>
Client
<input bind:value={clientName} placeholder="Hunter Premium Produce" />
</label>
<label>
Status
<select bind:value={mixStatus}>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</label>
<label>
Version
<input bind:value={mixVersion} type="number" min="1" step="1" />
</label>
</div>
<label class="notes-field">
Notes
<textarea bind:value={mixNotes} rows="3" placeholder="Internal mixing notes, process assumptions, or version comments"></textarea>
</label>
<div class="section-heading spreadsheet-head">
<div>
<p class="eyebrow">Spreadsheet Rows</p>
<h4>Ingredient builder</h4>
</div>
<button class="secondary-button" type="button" onclick={addIngredientRow}>Add Row</button>
</div>
<div class="sheet-wrap">
<table class="sheet-table">
<thead>
<tr>
<th>Raw Material</th>
<th>Market Value</th>
<th>Waste %</th>
<th>Cost / Kg</th>
<th>Qty Kg</th>
<th>Line Cost</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
{#each draftRows as row, index}
<tr>
<td data-label="Raw Material">
<select
value={row.raw_material_id ?? ''}
onchange={(event) =>
updateIngredientField(
index,
'raw_material_id',
Number((event.currentTarget as HTMLSelectElement).value) || null
)}
>
<option value="">Select material</option>
{#each rawMaterials as material}
<option value={material.id}>{material.name}</option>
{/each}
</select>
</td>
<td data-label="Market Value">{currency(row.marketValue)}</td>
<td data-label="Waste %">{row.wastePercentage !== null ? `${(row.wastePercentage * 100).toFixed(1)}%` : 'N/A'}</td>
<td data-label="Cost / Kg">{currency(row.costPerKg, 4)}</td>
<td data-label="Qty Kg">
<input
type="number"
min="0"
step="0.01"
value={row.quantity_kg}
oninput={(event) =>
updateIngredientField(index, 'quantity_kg', Number((event.currentTarget as HTMLInputElement).value))
}
/>
</td>
<td data-label="Line Cost">{currency(row.lineCost)}</td>
<td data-label="Notes">
<input
type="text"
value={row.notes}
oninput={(event) => updateIngredientField(index, 'notes', (event.currentTarget as HTMLInputElement).value)}
placeholder="Optional row note"
/>
</td>
<td data-label="Row Action">
<button class="icon-delete" type="button" onclick={() => removeIngredientRow(index)}>Remove</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</article>
<aside class="sidebar-stack">
<article class="summary-card">
<div class="section-heading">
<div>
<p class="eyebrow">Live Totals</p>
<h3>Worksheet summary</h3>
</div>
</div>
<div class="summary-grid">
<article>
<span>Total Kg</span>
<strong>{totalMixKg.toFixed(2)}</strong>
</article>
<article>
<span>Total Cost</span>
<strong>{currency(totalMixCost)}</strong>
</article>
<article>
<span>Cost / Kg</span>
<strong>{currency(mixCostPerKg, 4)}</strong>
</article>
<article>
<span>Rows</span>
<strong>{draftRows.length}</strong>
</article>
</div>
</article>
<article class="summary-card">
<div class="section-heading">
<div>
<p class="eyebrow">Calculation Factors</p>
<h3>What drives cost</h3>
</div>
</div>
<div class="factor-list">
<article>
<strong>Market value</strong>
<span>Each raw material row uses the active market value from the raw materials module.</span>
</article>
<article>
<strong>Waste percentage</strong>
<span>Material loss is included in the computed cost per kg used by the worksheet.</span>
</article>
<article>
<strong>Kg per unit</strong>
<span>Unit conversion already affects each raw material cost per kg before line costs are calculated.</span>
</article>
<article>
<strong>Quantity used</strong>
<span>Line cost is recalculated instantly from quantity times cost per kg.</span>
</article>
</div>
</article>
<article class="summary-card">
<div class="section-heading">
<div>
<p class="eyebrow">Draft Checks</p>
<h3>Validation</h3>
</div>
</div>
{#if draftWarnings.length}
<div class="warning-list">
{#each draftWarnings as warning}
<article>{warning}</article>
{/each}
</div>
{:else}
<div class="healthy-card">
<strong>Ready to save</strong>
<p>This draft has the required metadata and valid ingredient rows.</p>
</div>
{/if}
</article>
</aside>
</section>
{/if}
<style>
h2,
h3,
h4,
p {
margin: 0;
}
.eyebrow {
color: #7f8e85;
font-size: 0.74rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.locked-card,
.page-intro,
.feedback,
.metric-card,
.editor-card,
.summary-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.16rem;
box-shadow: var(--shadow);
}
.locked-card,
.page-intro,
.feedback,
.metric-row,
.editor-grid {
margin-bottom: 1.12rem;
}
.locked-card,
.page-intro,
.editor-card,
.summary-card {
padding: 1.08rem;
}
.locked-card {
display: grid;
gap: 0.62rem;
max-width: 40rem;
}
.locked-card h2,
.page-intro h2 {
margin: 0.3rem 0 0.4rem;
font-size: clamp(1.56rem, 3vw, 2.02rem);
font-weight: 700;
}
.locked-card p:last-of-type,
.page-intro p:last-child,
.metric-card p,
.summary-card span,
.factor-list span,
.healthy-card p {
color: var(--muted);
}
.locked-card a {
color: var(--green-deep);
font-weight: 600;
}
.page-intro,
.section-heading,
.intro-actions,
.editor-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.68rem;
}
.page-intro {
align-items: end;
}
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.82rem;
padding: 0.74rem 0.9rem;
font-weight: 600;
cursor: pointer;
}
.primary-button {
border: none;
color: #fff;
background: var(--color-brand);
box-shadow: none;
}
.secondary-button {
border: 1px solid var(--line-strong);
color: #304038;
background: #fff;
}
.primary-button:disabled,
.secondary-button:disabled {
opacity: 0.7;
cursor: wait;
}
.feedback {
padding: 0.86rem 0.94rem;
font-weight: 600;
}
.feedback.success {
color: var(--green-deep);
border-color: #d8ecdf;
background: #f6fcf8;
}
.feedback.error {
color: #a03737;
border-color: #f0d9d9;
background: #fff8f8;
}
.metric-row,
.editor-grid,
.meta-grid,
.summary-grid {
display: grid;
gap: 0.9rem;
}
.metric-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metric-card {
padding: 1.04rem 1.08rem;
}
.metric-card span {
display: block;
color: var(--muted);
font-size: 0.84rem;
}
.metric-card strong {
display: block;
margin: 0.48rem 0 0.26rem;
font-size: 1.72rem;
font-weight: 700;
}
.section-heading {
align-items: flex-start;
margin-bottom: 0.9rem;
}
.section-heading h3,
.section-heading h4 {
font-size: 1.02rem;
font-weight: 700;
}
.editor-grid {
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.75fr);
}
.editor-card,
.summary-card {
display: grid;
gap: 0.9rem;
}
.meta-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
label {
display: grid;
gap: 0.32rem;
color: #53645b;
font-size: 0.86rem;
font-weight: 600;
}
input,
textarea,
select {
width: 100%;
padding: 0.82rem 0.88rem;
border: 1px solid var(--line-strong);
border-radius: 0.88rem;
background: var(--panel-soft);
color: var(--text);
}
.sheet-wrap {
overflow-x: auto;
}
.sheet-table {
width: 100%;
min-width: 58rem;
border-collapse: separate;
border-spacing: 0 0.54rem;
}
.sheet-table th,
.sheet-table td {
padding: 0.88rem 0.92rem;
text-align: left;
white-space: nowrap;
}
.sheet-table th {
color: var(--muted);
font-size: 0.74rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.sheet-table tbody td {
background: var(--panel-soft);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.sheet-table tbody td:first-child {
border-left: 1px solid var(--line);
border-radius: 0.92rem 0 0 0.92rem;
}
.sheet-table tbody td:last-child {
border-right: 1px solid var(--line);
border-radius: 0 0.92rem 0.92rem 0;
}
.sheet-table input,
.sheet-table select {
min-width: 8rem;
padding: 0.7rem 0.78rem;
background: #fff;
}
.icon-delete {
border: 1px solid #eed8d8;
border-radius: 0.74rem;
padding: 0.64rem 0.78rem;
color: #a34a4a;
background: #fff7f7;
font-weight: 600;
cursor: pointer;
}
.sidebar-stack,
.factor-list,
.warning-list {
display: grid;
gap: 0.9rem;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-grid article,
.factor-list article {
padding: 0.88rem 0.94rem;
border: 1px solid var(--line);
border-radius: 0.92rem;
background: var(--panel-soft);
}
.summary-grid strong {
display: block;
margin-top: 0.28rem;
font-size: 1.08rem;
font-weight: 700;
}
.factor-list strong,
.healthy-card strong {
display: block;
margin-bottom: 0.22rem;
font-size: 0.94rem;
font-weight: 700;
}
.warning-list article,
.healthy-card {
padding: 0.9rem 0.94rem;
border-radius: 0.92rem;
}
.warning-list article {
border: 1px solid #f1e2c2;
background: #fffaf2;
color: #8d5d21;
font-weight: 500;
}
.healthy-card {
border: 1px solid var(--line);
background: var(--panel-soft);
}
@media (max-width: 1240px) {
.editor-grid {
grid-template-columns: 1fr;
}
.sidebar-stack {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1180px) {
.metric-row {
grid-template-columns: 1fr;
}
.meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.page-intro,
.section-heading,
.intro-actions,
.editor-actions {
flex-direction: column;
align-items: flex-start;
}
.intro-actions,
.editor-actions,
.primary-button,
.secondary-button {
width: 100%;
}
.summary-grid {
grid-template-columns: 1fr;
}
.meta-grid,
.sidebar-stack {
grid-template-columns: 1fr;
}
}
@media (max-width: 880px) {
.sheet-table,
.sheet-table thead,
.sheet-table tbody,
.sheet-table tr,
.sheet-table td {
display: block;
width: 100%;
}
.sheet-table {
min-width: 0;
border-spacing: 0;
}
.sheet-table thead {
display: none;
}
.sheet-table tbody {
display: grid;
gap: 0.9rem;
}
.sheet-table tbody tr {
padding: 0.35rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.sheet-table tbody td {
padding: 0.78rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
.sheet-table tbody td:first-child,
.sheet-table tbody td:last-child {
border: none;
border-radius: 0;
}
.sheet-table tbody td + td {
border-top: 1px solid var(--line);
}
.sheet-table tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.sheet-table input,
.sheet-table select,
.icon-delete {
width: 100%;
min-width: 0;
}
}
</style>