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
This commit is contained in:
2026-06-21 11:57:14 +12:00
parent 696f1e7b09
commit 87878e70fc
6 changed files with 313 additions and 149 deletions
@@ -0,0 +1,301 @@
<script lang="ts">
import { tick } from 'svelte';
import { Check, Plus, Search } from 'lucide-svelte';
// A searchable category picker that also lets you create a new category inline.
// The value is just the category string — picking an existing option keeps
// spelling consistent, while "Create" commits whatever was typed. The menu is
// rendered fixed-position so it escapes the ingredients table's clipped
// (overflow:hidden) scroll container.
let {
value = $bindable(''),
options = [],
placeholder = 'Category',
inputId,
disabled = false,
ariaLabel = 'Category'
}: {
value?: string;
options?: string[];
placeholder?: string;
inputId?: string;
disabled?: boolean;
ariaLabel?: string;
} = $props();
let open = $state(false);
let highlighted = $state(-1);
let root = $state<HTMLDivElement | null>(null);
let inputEl = $state<HTMLInputElement | null>(null);
let menuStyle = $state('');
const query = $derived(value.trim());
const filtered = $derived.by(() => {
const q = query.toLowerCase();
if (!q) return options;
return options.filter((option) => option.toLowerCase().includes(q));
});
// Offer "Create" only when the typed text doesn't already exist (case-insensitive).
const exactExists = $derived(options.some((option) => option.toLowerCase() === query.toLowerCase()));
const showCreate = $derived(query.length > 0 && !exactExists);
// Total selectable rows = filtered options, plus the optional create row last.
const rowCount = $derived(filtered.length + (showCreate ? 1 : 0));
function positionMenu() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
menuStyle = `top: ${rect.bottom + 4}px; left: ${rect.left}px; width: ${Math.max(rect.width, 200)}px;`;
}
async function openMenu() {
if (disabled) return;
open = true;
highlighted = -1;
await tick();
positionMenu();
}
function choose(option: string) {
value = option;
open = false;
highlighted = -1;
}
function commitTyped() {
value = query;
open = false;
highlighted = -1;
}
function onInput(event: Event) {
value = (event.target as HTMLInputElement).value;
open = true;
highlighted = -1;
positionMenu();
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
open = true;
highlighted = Math.min(highlighted + 1, rowCount - 1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
highlighted = Math.max(highlighted - 1, 0);
} else if (event.key === 'Enter') {
if (open && highlighted >= 0) {
event.preventDefault();
if (highlighted < filtered.length) choose(filtered[highlighted]);
else if (showCreate) commitTyped();
} else {
open = false;
}
} else if (event.key === 'Escape') {
open = false;
highlighted = -1;
}
}
function onFocusOut(event: FocusEvent) {
if (root && event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) {
return;
}
open = false;
highlighted = -1;
}
// Keep the fixed menu glued to the input while scrolling/resizing.
$effect(() => {
if (!open) return;
const handler = () => positionMenu();
window.addEventListener('scroll', handler, true);
window.addEventListener('resize', handler);
return () => {
window.removeEventListener('scroll', handler, true);
window.removeEventListener('resize', handler);
};
});
</script>
<div class="combo" bind:this={root} onfocusout={onFocusOut}>
<span class="combo-icon" aria-hidden="true"><Search size={15} strokeWidth={2.2} /></span>
<input
id={inputId}
bind:this={inputEl}
class="combo-input"
type="text"
autocomplete="off"
{placeholder}
aria-label={ariaLabel}
aria-autocomplete="list"
aria-expanded={open}
role="combobox"
aria-controls={inputId ? `${inputId}-list` : undefined}
value={value}
{disabled}
oninput={onInput}
onfocus={openMenu}
onkeydown={onKeydown}
/>
{#if open && !disabled}
<ul class="menu" id={inputId ? `${inputId}-list` : undefined} role="listbox" style={menuStyle}>
{#each filtered as option, i (option)}
<li
class="row"
class:highlighted={i === highlighted}
class:selected={option.toLowerCase() === query.toLowerCase()}
role="option"
aria-selected={option.toLowerCase() === query.toLowerCase()}
onmousedown={(e) => {
e.preventDefault();
choose(option);
}}
onmouseenter={() => (highlighted = i)}
>
<span class="row-label">{option}</span>
{#if option.toLowerCase() === query.toLowerCase()}
<span class="row-check" aria-hidden="true"><Check size={14} strokeWidth={2.6} /></span>
{/if}
</li>
{/each}
{#if showCreate}
<li
class="row create"
class:highlighted={highlighted === filtered.length}
role="option"
aria-selected={false}
onmousedown={(e) => {
e.preventDefault();
commitTyped();
}}
onmouseenter={() => (highlighted = filtered.length)}
>
<span class="create-icon" aria-hidden="true"><Plus size={14} strokeWidth={2.6} /></span>
<span class="row-label">Create “{query}</span>
</li>
{:else if filtered.length === 0}
<li class="row empty">Type to add a category.</li>
{/if}
</ul>
{/if}
</div>
<style>
.combo {
position: relative;
display: flex;
align-items: center;
width: 100%;
min-width: 0;
}
.combo-icon {
position: absolute;
left: 0.5rem;
display: inline-flex;
color: var(--color-text-muted);
pointer-events: none;
}
/* Match the editor's compact inputs (the parent's scoped `input` rule can't
reach this child component). */
.combo-input {
width: 100%;
min-height: 36px;
padding: 0.38rem 0.5rem 0.38rem 1.65rem;
border: 1px solid var(--color-border);
border-radius: 0.42rem;
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-size: 0.88rem;
}
.combo-input::placeholder {
color: var(--color-text-muted);
}
.combo-input:hover {
border-color: var(--color-text-muted);
}
.combo-input:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
border-color: var(--color-brand);
}
.combo-input:disabled {
background: color-mix(in srgb, var(--color-bg-app) 70%, var(--color-bg-surface));
color: var(--color-text-muted);
cursor: not-allowed;
}
.menu {
position: fixed;
z-index: 300;
margin: 0;
padding: 0.25rem;
list-style: none;
max-height: 16rem;
overflow-y: auto;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 0.55rem;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16);
}
.row {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.42rem 0.55rem;
border-radius: 0.4rem;
font-size: 0.88rem;
color: var(--color-text-primary);
cursor: pointer;
}
.row.highlighted {
background: var(--color-brand-tint);
}
.row.selected {
font-weight: 650;
}
.row.empty {
color: var(--color-text-muted);
cursor: default;
}
.row-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-check {
color: var(--color-brand);
flex-shrink: 0;
}
.row.create {
color: var(--color-brand);
font-weight: 650;
border-top: 1px solid var(--color-divider);
margin-top: 0.15rem;
padding-top: 0.5rem;
}
.create-icon {
display: inline-flex;
flex-shrink: 0;
}
</style>
@@ -349,6 +349,7 @@
{@const subActive = subGroupActive(child)} {@const subActive = subGroupActive(child)}
<!-- Third layer: child row links to its own page; chevron <!-- Third layer: child row links to its own page; chevron
reveals the nested submenu (e.g. Integrations → Xero). --> reveals the nested submenu (e.g. Integrations → Xero). -->
{@const ChildIcon = child.icon}
<div class="rail-group-head rail-subgroup-head" class:within-active={subActive && !subOpen}> <div class="rail-group-head rail-subgroup-head" class:within-active={subActive && !subOpen}>
<a <a
class="rail-row rail-group-link" class="rail-row rail-group-link"
@@ -356,6 +357,7 @@
href={child.href} href={child.href}
onclick={() => openSubGroup(key)} onclick={() => openSubGroup(key)}
> >
<span class="rail-icon"><ChildIcon size={18} strokeWidth={1.75} /></span>
<span class="rail-text">{child.label}</span> <span class="rail-text">{child.label}</span>
{#if child.badge}<span class="rail-badge">{child.badge}</span>{/if} {#if child.badge}<span class="rail-badge">{child.badge}</span>{/if}
</a> </a>
@@ -374,12 +376,12 @@
{#if subOpen} {#if subOpen}
<div class="rail-children rail-subchildren"> <div class="rail-children rail-subchildren">
{#each child.children as grandchild} {#each child.children as grandchild}
{@render leafLink(grandchild, false)} {@render leafLink(grandchild, true)}
{/each} {/each}
</div> </div>
{/if} {/if}
{:else} {:else}
{@render leafLink(child, false)} {@render leafLink(child, true)}
{/if} {/if}
{/each} {/each}
</div> </div>
@@ -251,68 +251,6 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.add-dest {
gap: 0.4rem;
}
.dest-rows {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.dest-line {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dest-line .dest-toggle {
flex: 0 0 auto;
min-width: 8.5rem;
}
.dest-line .dest-input {
flex: 1 1 auto;
min-width: 0;
width: auto;
}
.dest-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.68rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
border-radius: 0.72rem;
background: var(--color-bg-surface);
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
}
.dest-toggle input {
width: 1.2rem;
height: 1.2rem;
min-height: 0;
margin: 0;
flex-shrink: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
.dest-toggle.on {
border-color: var(--color-brand);
background: var(--color-brand-tint);
color: var(--color-success);
}
.dest-input {
width: 100%;
}
.add-action { .add-action {
justify-content: center; justify-content: center;
} }
@@ -459,7 +397,6 @@
.add-cell:nth-child(2), .add-cell:nth-child(2),
.add-cell:nth-child(3), .add-cell:nth-child(3),
.add-dest,
.add-action { .add-action {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -490,7 +427,6 @@
} }
.add-cell:nth-child(2), .add-cell:nth-child(2),
.add-dest,
.add-action { .add-action {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -29,7 +29,6 @@
formatNumber, formatNumber,
packedMain, packedMain,
packedDetail, packedDetail,
destinationOf,
onApplyFilters, onApplyFilters,
onClearFilters, onClearFilters,
onToggleSort, onToggleSort,
@@ -59,7 +58,6 @@
formatNumber: (value: number | null | undefined, digits?: number) => string; formatNumber: (value: number | null | undefined, digits?: number) => string;
packedMain: (entry: ThroughputEntry) => string; packedMain: (entry: ThroughputEntry) => string;
packedDetail: (entry: ThroughputEntry) => string; packedDetail: (entry: ThroughputEntry) => string;
destinationOf: (entry: ThroughputEntry) => { label: string; detail: string | null };
onApplyFilters: () => void; onApplyFilters: () => void;
onClearFilters: () => void; onClearFilters: () => void;
onToggleSort: (key: SortKey) => void; onToggleSort: (key: SortKey) => void;
@@ -171,10 +169,6 @@
<span>Packed by</span> <span>Packed by</span>
<ArrowUpDown size={14} strokeWidth={2.1} /> <ArrowUpDown size={14} strokeWidth={2.1} />
</button> </button>
<button type="button" class="sort-head" class:active={sortKey === 'destination'} onclick={() => onToggleSort('destination')}>
<span>Destination</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head col-notes-head" class:active={sortKey === 'notes'} onclick={() => onToggleSort('notes')}> <button type="button" class="sort-head col-notes-head" class:active={sortKey === 'notes'} onclick={() => onToggleSort('notes')}>
<span>Notes</span> <span>Notes</span>
<ArrowUpDown size={14} strokeWidth={2.1} /> <ArrowUpDown size={14} strokeWidth={2.1} />
@@ -194,7 +188,6 @@
{/each} {/each}
{:else} {:else}
{#each paginatedEntries as entry (entry.id)} {#each paginatedEntries as entry (entry.id)}
{@const dest = destinationOf(entry)}
<div class="row" class:just-added={entry.id === highlightId}> <div class="row" class:just-added={entry.id === highlightId}>
<span class="col-date"> <span class="col-date">
<span class="cell-label">Date</span> <span class="cell-label">Date</span>
@@ -217,16 +210,6 @@
<span class="cell-label">Packed by</span> <span class="cell-label">Packed by</span>
{entry.staff_name ?? '—'} {entry.staff_name ?? '—'}
</span> </span>
<span class="col-dest">
<span class="cell-label">Destination</span>
<span
class="pill"
class:pill-stock={dest.label === 'Stock'}
class:pill-order={dest.label === 'Order'}
class:pill-split={dest.label === 'Split'}
>{dest.label}</span>
{#if dest.detail}<span class="dest-detail">{dest.detail}</span>{/if}
</span>
<span class="col-actions"> <span class="col-actions">
<button <button
type="button" type="button"
@@ -442,7 +425,7 @@
.log-head, .log-head,
.row { .row {
display: grid; display: grid;
grid-template-columns: 8rem minmax(0, 1.1fr) minmax(0, 0.95fr) 6rem minmax(0, 0.8fr) 10.5rem 4.8rem; grid-template-columns: 8rem minmax(0, 1.1fr) minmax(0, 0.95fr) 6rem minmax(0, 0.8fr) 4.8rem;
gap: 0.85rem; gap: 0.85rem;
align-items: center; align-items: center;
} }
@@ -494,7 +477,6 @@
} }
.col-product, .col-product,
.col-dest,
.col-packed, .col-packed,
.col-total { .col-total {
display: flex; display: flex;
@@ -509,15 +491,13 @@
font-weight: 650; font-weight: 650;
} }
.dest-detail,
.packed-detail { .packed-detail {
font-size: 0.88rem; font-size: 0.88rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.total-kg, .total-kg,
.packed-main, .packed-main {
.dest-detail {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -591,32 +571,6 @@
display: none; display: none;
} }
.pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.42rem 0.78rem;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 650;
white-space: nowrap;
}
.pill-stock {
background: #e8f1fc;
color: #0b5cad;
}
.pill-order {
background: var(--color-brand-tint);
color: var(--color-success);
}
.pill-split {
background: #f3e8fc;
color: #6b21a8;
}
.row-skeleton { .row-skeleton {
padding: 1.15rem 1.45rem; padding: 1.15rem 1.45rem;
border-bottom: 1px solid var(--color-divider); border-bottom: 1px solid var(--color-divider);
@@ -741,7 +695,7 @@
@media (min-width: 1280px) { @media (min-width: 1280px) {
.log-head, .log-head,
.row { .row {
grid-template-columns: 8rem minmax(0, 1.1fr) minmax(0, 0.95fr) 6rem minmax(0, 0.8fr) 10.5rem minmax(0, 1.2fr) 4.8rem; grid-template-columns: 8rem minmax(0, 1.1fr) minmax(0, 0.95fr) 6rem minmax(0, 0.8fr) minmax(0, 1.2fr) 4.8rem;
} }
.col-notes-head { .col-notes-head {
@@ -749,7 +703,7 @@
} }
.row-notes { .row-notes {
grid-column: 7; grid-column: 6;
align-self: center; align-self: center;
margin: 0; margin: 0;
padding-top: 0; padding-top: 0;
@@ -758,7 +712,7 @@
} }
.col-actions { .col-actions {
grid-column: 8; grid-column: 7;
} }
} }
+3 -8
View File
@@ -3,6 +3,7 @@
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte'; import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import ChangeHistoryModal from '$lib/components/editor/ChangeHistoryModal.svelte'; import ChangeHistoryModal from '$lib/components/editor/ChangeHistoryModal.svelte';
import CategoryCombobox from '$lib/components/editor/CategoryCombobox.svelte';
import SortHeader from '$lib/table/SortHeader.svelte'; import SortHeader from '$lib/table/SortHeader.svelte';
import { TableController } from '$lib/table/table.svelte'; import { TableController } from '$lib/table/table.svelte';
import { formatNumber } from '$lib/format'; import { formatNumber } from '$lib/format';
@@ -297,7 +298,7 @@
</label> </label>
<label> <label>
<span>Category</span> <span>Category</span>
<input bind:value={newIngredient.category} list="ingredient-categories" placeholder="e.g. Grains" /> <CategoryCombobox bind:value={newIngredient.category} options={knownCategories} placeholder="e.g. Grains" inputId="new-ingredient-category" />
</label> </label>
<label> <label>
<span>Rounding</span> <span>Rounding</span>
@@ -367,7 +368,7 @@
<div class="cell"> <div class="cell">
<span class="cell-label">Category</span> <span class="cell-label">Category</span>
<input bind:value={row.draft_category} list="ingredient-categories" placeholder="—" aria-label="Category" /> <CategoryCombobox bind:value={row.draft_category} options={knownCategories} placeholder="—" ariaLabel={`Category for ${row.name}`} />
</div> </div>
<div class="cell"> <div class="cell">
@@ -443,12 +444,6 @@
</div> </div>
</section> </section>
<datalist id="ingredient-categories">
{#each knownCategories as category (category)}
<option value={category}></option>
{/each}
</datalist>
{#if historyIngredient} {#if historyIngredient}
<ChangeHistoryModal <ChangeHistoryModal
entityType="ingredient" entityType="ingredient"
@@ -17,7 +17,6 @@
buildConfetti, buildConfetti,
compareDate, compareDate,
compareText, compareText,
isStockEntry,
startOfWeekMonday, startOfWeekMonday,
toISODate, toISODate,
type SortDirection, type SortDirection,
@@ -70,24 +69,6 @@
statsEntries = statsEntries.filter((e) => e.id !== id); statsEntries = statsEntries.filter((e) => e.id !== id);
} }
// The destination shown in the log: an order (with job number), stock, or a
// split across both.
function destinationOf(entry: ThroughputEntry): { label: string; detail: string | null } {
const unit = entry.quantity_type === 'bags' ? 'bags' : 'kg';
if (entry.for_order && entry.for_stock) {
const stock = entry.stock_quantity != null ? `${formatNumber(entry.stock_quantity, 1)} ${unit} to stock` : 'split';
const job = entry.job_number ? `Order ${entry.job_number}` : 'Order';
return { label: 'Split', detail: `${job} · ${stock}` };
}
if (entry.for_order) {
return { label: 'Order', detail: entry.job_number ? `Job ${entry.job_number}` : null };
}
if (isStockEntry(entry)) {
return { label: 'Stock', detail: null };
}
return { label: '—', detail: null };
}
// ── Inline "spreadsheet" add row ────────────────────────────── // ── Inline "spreadsheet" add row ──────────────────────────────
const today = toISODate(ausToday()); const today = toISODate(ausToday());
let nDate = $state(today); let nDate = $state(today);
@@ -495,10 +476,6 @@
result = (a.calculated_kg ?? 0) - (b.calculated_kg ?? 0); result = (a.calculated_kg ?? 0) - (b.calculated_kg ?? 0);
} else if (sortKey === 'staff') { } else if (sortKey === 'staff') {
result = compareText(a.staff_name, b.staff_name); result = compareText(a.staff_name, b.staff_name);
} else if (sortKey === 'destination') {
const aDest = destinationOf(a);
const bDest = destinationOf(b);
result = compareText(`${aDest.label} ${aDest.detail ?? ''}`, `${bDest.label} ${bDest.detail ?? ''}`);
} else if (sortKey === 'notes') { } else if (sortKey === 'notes') {
result = compareText(a.notes, b.notes); result = compareText(a.notes, b.notes);
} }
@@ -582,7 +559,6 @@
{formatNumber} {formatNumber}
{packedMain} {packedMain}
{packedDetail} {packedDetail}
{destinationOf}
onApplyFilters={applyFilters} onApplyFilters={applyFilters}
onClearFilters={clearFilters} onClearFilters={clearFilters}
onToggleSort={toggleSort} onToggleSort={toggleSort}