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:
@@ -17,6 +17,14 @@ export type ChangelogEntry = {
|
||||
export const APP_VERSION: string = packageInfo.version;
|
||||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
{
|
||||
version: '0.1.32',
|
||||
date: '2026-06-21',
|
||||
highlights: [
|
||||
'App: Mix Calculator & Ingredients improvements.',
|
||||
'App: Bug fixes & improvements.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.31',
|
||||
date: '2026-06-18',
|
||||
|
||||
@@ -46,15 +46,16 @@
|
||||
}
|
||||
|
||||
function formatWhen(value: string) {
|
||||
// The server records audit times in its own (Australian) local time. Show
|
||||
// those wall-clock values verbatim — do NOT re-interpret them as UTC or
|
||||
// shift them into the viewer's timezone, or a 2pm edit reads as 12am.
|
||||
// Parse the date parts directly so the display never moves with the
|
||||
// viewer's location (AU, NZ, or anywhere else all see the server time).
|
||||
const match = value.match(/(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
|
||||
// Audit times are stored in UTC on the server (datetime.utcnow), serialized
|
||||
// without a timezone suffix. Parse the parts as UTC and let the browser
|
||||
// render them in the viewer's local time, so an edit made at midday in
|
||||
// Australia reads as midday rather than the raw 02:00 UTC value.
|
||||
const match = value.match(/(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?/);
|
||||
if (!match) return value;
|
||||
const [, year, month, day, hour, minute] = match.map(Number);
|
||||
const date = new Date(year, month - 1, day, hour, minute);
|
||||
const [, year, month, day, hour, minute, second] = match;
|
||||
const date = new Date(
|
||||
Date.UTC(+year, +month - 1, +day, +hour, +minute, second ? +second : 0)
|
||||
);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString('en-AU', {
|
||||
year: 'numeric',
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
MixCalculatorSession
|
||||
} from '$lib/types';
|
||||
import MixCalculatorResultsPanel from './MixCalculatorResultsPanel.svelte';
|
||||
import MixCalculatorMixPicker from './MixCalculatorMixPicker.svelte';
|
||||
|
||||
let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props();
|
||||
|
||||
@@ -304,7 +305,7 @@
|
||||
<span class="composer-icon"><Calculator size={18} strokeWidth={2.2} /></span>
|
||||
<h2>Mix calculator</h2>
|
||||
</div>
|
||||
{#if selectedProduct}
|
||||
{#if selectedProduct && selectedProduct.unit_size_kg > 0}
|
||||
<div class="product-pill">
|
||||
<strong>{selectedProduct.unit_size_kg}kg</strong>
|
||||
<span>{selectedProduct.unit_of_measure}</span>
|
||||
@@ -344,18 +345,12 @@
|
||||
|
||||
<label>
|
||||
<span>Mix Name</span>
|
||||
<select
|
||||
bind:value={productId}
|
||||
<MixCalculatorMixPicker
|
||||
products={filteredProducts}
|
||||
bind:productId
|
||||
disabled={!canEdit || !clientName || !filteredProducts.length}
|
||||
title={!clientName ? 'Select a client first.' : !filteredProducts.length ? 'No mixes are available for the selected client.' : 'Select a mix.'}
|
||||
>
|
||||
<option value={0}>Select a mix</option>
|
||||
{#each filteredProducts as product}
|
||||
<option value={product.product_id}>
|
||||
{product.product_name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
inputId="mix-calculator-mix"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
<script lang="ts">
|
||||
import type { MixCalculatorProductOption } from '$lib/types';
|
||||
import { Search, X, Check } from 'lucide-svelte';
|
||||
|
||||
// Searchable Mix Name picker for the Mix Calculator. Mirrors the throughput
|
||||
// product search (type to filter, arrow/enter to choose) but keys on the
|
||||
// mix's representative product id. The client is chosen separately, so the
|
||||
// `products` passed in are already narrowed to that client.
|
||||
let {
|
||||
products = [],
|
||||
productId = $bindable(0),
|
||||
disabled = false,
|
||||
inputId = 'mix-calculator-mix'
|
||||
}: {
|
||||
products?: MixCalculatorProductOption[];
|
||||
productId?: number;
|
||||
disabled?: boolean;
|
||||
inputId?: string;
|
||||
} = $props();
|
||||
|
||||
let query = $state('');
|
||||
let open = $state(false);
|
||||
let highlighted = $state(-1);
|
||||
let focused = $state(false);
|
||||
let root = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function label(product: MixCalculatorProductOption): string {
|
||||
return product.product_name;
|
||||
}
|
||||
|
||||
const selected = $derived(
|
||||
productId ? products.find((p) => p.product_id === productId) ?? null : null
|
||||
);
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return products;
|
||||
return products.filter((product) => product.product_name.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
// Clear the text box when the selection is cleared from outside (e.g. when the
|
||||
// client changes and the previously chosen mix no longer applies).
|
||||
$effect(() => {
|
||||
if (!productId && !focused) {
|
||||
query = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Reflect a selection set from outside so the box shows the chosen mix.
|
||||
$effect(() => {
|
||||
if (productId && !focused) {
|
||||
const match = products.find((p) => p.product_id === productId);
|
||||
if (match) query = label(match);
|
||||
}
|
||||
});
|
||||
|
||||
function choose(product: MixCalculatorProductOption) {
|
||||
productId = product.product_id;
|
||||
query = label(product);
|
||||
open = false;
|
||||
highlighted = -1;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
productId = 0;
|
||||
query = '';
|
||||
open = false;
|
||||
highlighted = -1;
|
||||
}
|
||||
|
||||
function onInput(event: Event) {
|
||||
query = (event.target as HTMLInputElement).value;
|
||||
productId = 0;
|
||||
open = true;
|
||||
highlighted = filtered.length ? 0 : -1;
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
open = true;
|
||||
highlighted = Math.min(highlighted + 1, filtered.length - 1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
highlighted = Math.max(highlighted - 1, 0);
|
||||
} else if (event.key === 'Enter') {
|
||||
if (open && highlighted >= 0 && highlighted < filtered.length) {
|
||||
event.preventDefault();
|
||||
choose(filtered[highlighted]);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
open = false;
|
||||
highlighted = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
if (root && event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
focused = false;
|
||||
open = false;
|
||||
highlighted = -1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="picker" bind:this={root} onfocusin={() => (focused = true)} onfocusout={onFocusOut}>
|
||||
<div class="combo" role="combobox" aria-expanded={open} aria-haspopup="listbox" aria-controls={`${inputId}-list`}>
|
||||
<span class="combo-icon" aria-hidden="true"><Search size={16} strokeWidth={2.2} /></span>
|
||||
<input
|
||||
id={inputId}
|
||||
class="combo-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Search mix name…"
|
||||
value={query}
|
||||
{disabled}
|
||||
aria-autocomplete="list"
|
||||
oninput={onInput}
|
||||
onfocus={() => (open = true)}
|
||||
onkeydown={onKeydown}
|
||||
/>
|
||||
{#if productId}
|
||||
<button type="button" class="combo-clear" onclick={clear} aria-label="Clear mix">
|
||||
<X size={15} strokeWidth={2.4} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if open && !disabled}
|
||||
<ul class="options" id={`${inputId}-list`} role="listbox">
|
||||
{#if filtered.length === 0}
|
||||
<li class="option empty">No mixes match.</li>
|
||||
{:else}
|
||||
{#each filtered.slice(0, 50) as product, i (product.product_id)}
|
||||
<li
|
||||
class="option"
|
||||
class:highlighted={i === highlighted}
|
||||
class:selected={product.product_id === productId}
|
||||
role="option"
|
||||
aria-selected={product.product_id === productId}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
choose(product);
|
||||
}}
|
||||
onmouseenter={() => (highlighted = i)}
|
||||
>
|
||||
<span class="option-name">{product.product_name}</span>
|
||||
<span class="option-meta">
|
||||
{#if product.unit_size_kg > 0}
|
||||
<span class="option-unit">{product.unit_size_kg}kg {product.unit_of_measure}</span>
|
||||
{:else}
|
||||
<span class="option-tag">Formula only</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if product.product_id === productId}
|
||||
<span class="option-check" aria-hidden="true"><Check size={15} strokeWidth={2.6} /></span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{#if filtered.length > 50}
|
||||
<li class="option more">
|
||||
Showing first 50 of {filtered.length} — keep typing to narrow.
|
||||
</li>
|
||||
{/if}
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
.combo {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.combo-icon {
|
||||
position: absolute;
|
||||
left: 0.6rem;
|
||||
display: inline-flex;
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Self-contained input styling so the picker matches the composer's fields
|
||||
(Svelte scopes the parent's `.composer input` rule to the parent's own
|
||||
markup, so it can't reach this child component's input). */
|
||||
.combo-input {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 0.62rem 2rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
|
||||
border-radius: 0.8rem;
|
||||
font-size: 0.98rem;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
.combo-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
.combo-input:focus-visible {
|
||||
outline: 3px solid var(--color-brand);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
.combo-input:disabled {
|
||||
background: color-mix(in srgb, var(--color-bg-app) 70%, var(--color-bg-surface));
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.combo-clear {
|
||||
position: absolute;
|
||||
right: 0.45rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.combo-clear:hover {
|
||||
background: var(--color-bg-app);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.options {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
list-style: none;
|
||||
max-height: 18rem;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.6rem;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 0.45rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.option.highlighted {
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
.option.selected {
|
||||
font-weight: 650;
|
||||
}
|
||||
.option.empty,
|
||||
.option.more {
|
||||
color: var(--color-text-muted);
|
||||
cursor: default;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.option-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.option-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.option-unit {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.option-tag {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-app);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.option-check {
|
||||
color: var(--color-brand);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -16,11 +16,12 @@
|
||||
} = $props();
|
||||
|
||||
// ── Ingredient sorting ──────────────────────────────────────────
|
||||
// Default to heaviest ingredient first; clicking a header toggles direction
|
||||
// (or switches column). Required kg starts descending, the name ascending.
|
||||
type LineSortKey = 'raw_material_name' | 'required_kg';
|
||||
let sortKey = $state<LineSortKey>('required_kg');
|
||||
let sortDir = $state<'asc' | 'desc'>('desc');
|
||||
// Default to the backend's category grouping (ingredients ordered by their
|
||||
// manually-assigned category). Clicking a header toggles direction or switches
|
||||
// column. Required kg starts descending; category and name start ascending.
|
||||
type LineSortKey = 'category' | 'raw_material_name' | 'required_kg';
|
||||
let sortKey = $state<LineSortKey>('category');
|
||||
let sortDir = $state<'asc' | 'desc'>('asc');
|
||||
|
||||
function toggleSort(key: LineSortKey) {
|
||||
if (sortKey === key) {
|
||||
@@ -39,10 +40,16 @@
|
||||
const sortedLines = $derived.by(() => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
return [...(preview?.lines ?? [])].sort((a, b) => {
|
||||
const result =
|
||||
sortKey === 'required_kg'
|
||||
? (a.required_kg ?? 0) - (b.required_kg ?? 0)
|
||||
: a.raw_material_name.localeCompare(b.raw_material_name, undefined, { sensitivity: 'base' });
|
||||
let result: number;
|
||||
if (sortKey === 'required_kg') {
|
||||
result = (a.required_kg ?? 0) - (b.required_kg ?? 0);
|
||||
} else if (sortKey === 'category') {
|
||||
// The backend orders lines by category and renumbers sort_order to match,
|
||||
// so sorting on it reproduces the category grouping.
|
||||
result = (a.sort_order ?? 0) - (b.sort_order ?? 0);
|
||||
} else {
|
||||
result = a.raw_material_name.localeCompare(b.raw_material_name, undefined, { sensitivity: 'base' });
|
||||
}
|
||||
return result * dir;
|
||||
});
|
||||
});
|
||||
@@ -108,6 +115,17 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th aria-sort={ariaSort('category')}>
|
||||
<button
|
||||
type="button"
|
||||
class="sort-head"
|
||||
class:active={sortKey === 'category'}
|
||||
onclick={() => toggleSort('category')}
|
||||
>
|
||||
<span>Category</span>
|
||||
<ArrowUpDown size={13} strokeWidth={2.1} aria-hidden="true" />
|
||||
</button>
|
||||
</th>
|
||||
<th aria-sort={ariaSort('raw_material_name')}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -135,6 +153,9 @@
|
||||
<tbody>
|
||||
{#each sortedLines as line}
|
||||
<tr>
|
||||
<td data-label="Category">
|
||||
<span class="category-cell">{line.category || '—'}</span>
|
||||
</td>
|
||||
<td data-label="Raw material">
|
||||
<strong>{line.raw_material_name}</strong>
|
||||
</td>
|
||||
@@ -321,6 +342,11 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.category-cell {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
/* Clickable header: inherits the th look, adds a sort affordance. */
|
||||
.sort-head {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -115,42 +115,6 @@
|
||||
<span class="cell-label">Packed by</span>
|
||||
<input type="text" bind:value={nStaff} placeholder="Name" aria-label="Packed by" />
|
||||
</div>
|
||||
<div class="add-cell add-dest">
|
||||
<span class="cell-label">Destination</span>
|
||||
<div class="dest-rows">
|
||||
<div class="dest-line">
|
||||
<label class="dest-toggle" class:on={nForOrder}>
|
||||
<input type="checkbox" bind:checked={nForOrder} /> For an order
|
||||
</label>
|
||||
{#if nForOrder}
|
||||
<input
|
||||
class="dest-input"
|
||||
type="text"
|
||||
bind:value={nJobNumber}
|
||||
placeholder="Job number (Order Circle)"
|
||||
aria-label="Job number"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="dest-line">
|
||||
<label class="dest-toggle" class:on={nForStock}>
|
||||
<input type="checkbox" bind:checked={nForStock} /> For stock
|
||||
</label>
|
||||
{#if isSplit}
|
||||
<input
|
||||
class="dest-input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
inputmode="decimal"
|
||||
bind:value={nStockQty}
|
||||
placeholder={`To stock (${nType === 'bags' ? 'bags' : 'kg'})`}
|
||||
aria-label="Amount going to stock"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="add-cell add-action">
|
||||
<button type="submit" class="add-entry-button" disabled={saving}>
|
||||
<Plus size={18} strokeWidth={2.6} />
|
||||
@@ -218,7 +182,7 @@
|
||||
|
||||
.add-row {
|
||||
display: grid;
|
||||
grid-template-columns: 7.5rem minmax(19rem, 1.7fr) minmax(14rem, 1.15fr) minmax(7rem, 0.65fr) minmax(14rem, 1.2fr) auto;
|
||||
grid-template-columns: 7.5rem minmax(19rem, 1.7fr) minmax(14rem, 1.15fr) minmax(10rem, 0.9fr) auto;
|
||||
gap: 0.75rem 0.85rem;
|
||||
align-items: start;
|
||||
padding: 0 1.45rem 1.25rem;
|
||||
|
||||
@@ -100,6 +100,7 @@ export type MixCalculatorLine = {
|
||||
mix_percentage: number;
|
||||
unit: string;
|
||||
rounding_decimals?: number;
|
||||
category?: string | null;
|
||||
sort_order: number;
|
||||
};
|
||||
|
||||
@@ -401,6 +402,7 @@ export type EditorIngredientRow = {
|
||||
kg_per_unit: number;
|
||||
status: string;
|
||||
rounding_decimals: number;
|
||||
category: string | null;
|
||||
notes: string | null;
|
||||
cost_per_kg: number | null;
|
||||
usage_count: number;
|
||||
@@ -414,6 +416,7 @@ export type EditorIngredientCreateInput = {
|
||||
kg_per_unit: number;
|
||||
status?: string;
|
||||
rounding_decimals?: number;
|
||||
category?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.';
|
||||
|
||||
Reference in New Issue
Block a user