v0.1.32 - Mix Calculator search, ingredient categories, throughput tidy-up

- Mix Calculator: searchable Mix Name picker (mirrors Throughput search)
- Ingredients Editor: add manual Category column; used to order Mix Calculator output
- Mix Calculator: surface formula-only mixes (no product yet) via -mix_id sentinel
- Throughput: remove unused For order / For stock destination controls from composer
- Editor change history: show timestamps in local time (stored UTC) instead of raw UTC

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 11:09:43 +12:00
co-authored by Claude Opus 4.8
parent 10722a65a6
commit 696f1e7b09
18 changed files with 628 additions and 112 deletions
+48 -12
View File
@@ -21,6 +21,7 @@
draft_kg_per_unit: number | string;
draft_status: string;
draft_rounding_decimals: number;
draft_category: string;
};
function toEditable(row: EditorIngredientRow): EditableIngredient {
@@ -30,7 +31,8 @@
draft_unit_of_measure: row.unit_of_measure,
draft_kg_per_unit: row.kg_per_unit,
draft_status: row.status,
draft_rounding_decimals: row.rounding_decimals
draft_rounding_decimals: row.rounding_decimals,
draft_category: row.category ?? ''
};
}
@@ -57,7 +59,8 @@
row.draft_unit_of_measure.trim() !== row.unit_of_measure ||
Number(row.draft_kg_per_unit) !== row.kg_per_unit ||
row.draft_status !== row.status ||
Number(row.draft_rounding_decimals) !== row.rounding_decimals
Number(row.draft_rounding_decimals) !== row.rounding_decimals ||
row.draft_category.trim() !== (row.category ?? '')
);
}
@@ -91,7 +94,8 @@
unit_of_measure: row.draft_unit_of_measure.trim(),
kg_per_unit: Number(row.draft_kg_per_unit),
status: row.draft_status,
rounding_decimals: Number(row.draft_rounding_decimals)
rounding_decimals: Number(row.draft_rounding_decimals),
category: row.draft_category.trim() || null
})
);
toast.success('Ingredient saved');
@@ -109,7 +113,8 @@
unit_of_measure: '',
kg_per_unit: '' as number | string,
status: 'active',
rounding_decimals: 2
rounding_decimals: 2,
category: ''
};
}
let showNew = $state(false);
@@ -134,7 +139,8 @@
unit_of_measure: newIngredient.unit_of_measure.trim(),
kg_per_unit: Number(newIngredient.kg_per_unit),
status: newIngredient.status,
rounding_decimals: Number(newIngredient.rounding_decimals)
rounding_decimals: Number(newIngredient.rounding_decimals),
category: newIngredient.category.trim() || null
});
rows = [toEditable(created), ...rows];
toast.success('Ingredient added');
@@ -163,12 +169,25 @@
(statusFilter === 'archived' && !isActive(row.status));
if (!statusMatches) return false;
if (!term) return true;
return [row.name, row.unit_of_measure].join(' ').toLowerCase().includes(term);
return [row.name, row.unit_of_measure, row.category ?? ''].join(' ').toLowerCase().includes(term);
})
);
// Existing categories, offered as autocomplete suggestions so spelling stays
// consistent across ingredients.
const knownCategories = $derived(
Array.from(
new Set(
rows
.map((row) => (row.draft_category || row.category || '').trim())
.filter((value) => value.length > 0)
)
).sort((a, b) => a.localeCompare(b))
);
const table = new TableController<EditableIngredient>(() => visibleRows, {
name: (row) => row.name,
category: (row) => row.category ?? '',
unit_of_measure: (row) => row.unit_of_measure,
kg_per_unit: (row) => row.kg_per_unit,
cost_per_kg: (row) => row.cost_per_kg,
@@ -276,6 +295,10 @@
<span>Kg per unit</span>
<input bind:value={newIngredient.kg_per_unit} type="number" min="0" step="0.0001" placeholder="0" />
</label>
<label>
<span>Category</span>
<input bind:value={newIngredient.category} list="ingredient-categories" placeholder="e.g. Grains" />
</label>
<label>
<span>Rounding</span>
<select bind:value={newIngredient.rounding_decimals}>
@@ -325,6 +348,7 @@
<div class="log">
<div class="log-head">
<SortHeader label="Ingredient" column="name" controller={table} />
<SortHeader label="Category" column="category" controller={table} />
<SortHeader label="Unit" column="unit_of_measure" controller={table} />
<SortHeader label="Kg / unit" column="kg_per_unit" controller={table} />
<SortHeader label="Cost / kg" column="cost_per_kg" controller={table} />
@@ -341,6 +365,11 @@
<input bind:value={row.draft_name} aria-label="Ingredient name" />
</div>
<div class="cell">
<span class="cell-label">Category</span>
<input bind:value={row.draft_category} list="ingredient-categories" placeholder="—" aria-label="Category" />
</div>
<div class="cell">
<span class="cell-label">Unit</span>
<input bind:value={row.draft_unit_of_measure} aria-label="Unit of measure" />
@@ -414,6 +443,12 @@
</div>
</section>
<datalist id="ingredient-categories">
{#each knownCategories as category (category)}
<option value={category}></option>
{/each}
</datalist>
{#if historyIngredient}
<ChangeHistoryModal
entityType="ingredient"
@@ -832,13 +867,14 @@
.row {
display: grid;
grid-template-columns:
minmax(180px, 1.45fr)
minmax(96px, 0.7fr)
minmax(160px, 1.3fr)
minmax(104px, 0.7fr)
minmax(90px, 0.6fr)
minmax(88px, 0.5fr)
minmax(92px, 0.5fr)
minmax(96px, 0.55fr)
minmax(86px, 0.5fr)
minmax(86px, 0.5fr)
minmax(110px, 0.6fr)
minmax(82px, 0.45fr)
minmax(82px, 0.45fr)
minmax(104px, 0.55fr)
minmax(150px, auto);
gap: 0.55rem;
align-items: center;
+4 -5
View File
@@ -255,11 +255,10 @@
return;
}
if (!nForOrder && !nForStock) {
addError = 'Mark where this run goes: for an order, for stock, or both.';
return;
}
// The order/stock destination split was removed from the composer (operators
// found it hard and it wasn't being used). New runs are saved without a
// destination; the guards below only fire when editing legacy entries that
// still carry order/stock flags.
const job = nJobNumber.trim();
if (nForOrder && !job) {
addError = 'Enter the job number for the order.';