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
+5
View File
@@ -838,6 +838,7 @@ def _serialize_ingredient(material: RawMaterial, usage_count: int) -> dict:
"kg_per_unit": material.kg_per_unit, "kg_per_unit": material.kg_per_unit,
"status": material.status, "status": material.status,
"rounding_decimals": material.rounding_decimals, "rounding_decimals": material.rounding_decimals,
"category": material.category,
"notes": material.notes, "notes": material.notes,
"cost_per_kg": cost_per_kg, "cost_per_kg": cost_per_kg,
"usage_count": usage_count, "usage_count": usage_count,
@@ -885,6 +886,7 @@ def create_editor_ingredient(
kg_per_unit=payload.kg_per_unit, kg_per_unit=payload.kg_per_unit,
status=payload.status.strip() or "active", status=payload.status.strip() or "active",
rounding_decimals=payload.rounding_decimals, rounding_decimals=payload.rounding_decimals,
category=(payload.category or "").strip() or None,
notes=payload.notes, notes=payload.notes,
) )
db.add(material) db.add(material)
@@ -928,6 +930,8 @@ def update_editor_ingredient(
updates["supplier"] = (updates["supplier"] or "").strip() or None updates["supplier"] = (updates["supplier"] or "").strip() or None
if "unit_of_measure" in updates and updates["unit_of_measure"] is not None: if "unit_of_measure" in updates and updates["unit_of_measure"] is not None:
updates["unit_of_measure"] = updates["unit_of_measure"].strip() updates["unit_of_measure"] = updates["unit_of_measure"].strip()
if "category" in updates:
updates["category"] = (updates["category"] or "").strip() or None
before = {field: getattr(material, field) for field in updates} before = {field: getattr(material, field) for field in updates}
for field, value in updates.items(): for field, value in updates.items():
setattr(material, field, value) setattr(material, field, value)
@@ -941,6 +945,7 @@ def update_editor_ingredient(
"kg_per_unit": "Kg per unit", "kg_per_unit": "Kg per unit",
"status": "Status", "status": "Status",
"rounding_decimals": "Rounding (dp)", "rounding_decimals": "Rounding (dp)",
"category": "Category",
"notes": "Notes", "notes": "Notes",
}, },
) )
+1
View File
@@ -139,6 +139,7 @@ _LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = (
("production_throughput_entries", "job_number", "VARCHAR(64)"), ("production_throughput_entries", "job_number", "VARCHAR(64)"),
("production_throughput_entries", "stock_quantity", "FLOAT"), ("production_throughput_entries", "stock_quantity", "FLOAT"),
("raw_materials", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"), ("raw_materials", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"),
("raw_materials", "category", "VARCHAR(128)"),
("mix_calculator_session_lines", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"), ("mix_calculator_session_lines", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"),
) )
+3
View File
@@ -18,6 +18,9 @@ class RawMaterial(Base):
unit_of_measure: Mapped[str] = mapped_column(String(64)) unit_of_measure: Mapped[str] = mapped_column(String(64))
kg_per_unit: Mapped[float] = mapped_column(Float) kg_per_unit: Mapped[float] = mapped_column(Float)
status: Mapped[str] = mapped_column(String(32), default="active") status: Mapped[str] = mapped_column(String(32), default="active")
# Manually-assigned grouping used to order ingredients in the Mix Calculator
# output (e.g. "Grains", "Additives"). Optional; uncategorised rows sort last.
category: Mapped[str | None] = mapped_column(String(128), nullable=True)
# Decimal places this ingredient's required-kg is rounded to in the mix # Decimal places this ingredient's required-kg is rounded to in the mix
# calculator output. Set per-ingredient from the Ingredients Editor. # calculator output. Set per-ingredient from the Ingredients Editor.
rounding_decimals: Mapped[int] = mapped_column(Integer, default=2) rounding_decimals: Mapped[int] = mapped_column(Integer, default=2)
+4
View File
@@ -188,6 +188,8 @@ class EditorIngredientRow(BaseModel):
unit_of_measure: str unit_of_measure: str
kg_per_unit: float kg_per_unit: float
status: str status: str
# Manual grouping used to order ingredients in the Mix Calculator output.
category: str | None
# Decimal places this ingredient is rounded to in the mix calculator output. # Decimal places this ingredient is rounded to in the mix calculator output.
rounding_decimals: int rounding_decimals: int
notes: str | None notes: str | None
@@ -206,6 +208,7 @@ class EditorIngredientCreate(BaseModel):
kg_per_unit: float = Field(gt=0) kg_per_unit: float = Field(gt=0)
status: str = Field(default="active", max_length=32) status: str = Field(default="active", max_length=32)
rounding_decimals: int = Field(default=2, ge=0, le=6) rounding_decimals: int = Field(default=2, ge=0, le=6)
category: str | None = Field(default=None, max_length=128)
notes: str | None = Field(default=None, max_length=2000) notes: str | None = Field(default=None, max_length=2000)
@@ -218,6 +221,7 @@ class EditorIngredientUpdate(BaseModel):
kg_per_unit: float | None = Field(default=None, gt=0) kg_per_unit: float | None = Field(default=None, gt=0)
status: str | None = Field(default=None, max_length=32) status: str | None = Field(default=None, max_length=32)
rounding_decimals: int | None = Field(default=None, ge=0, le=6) rounding_decimals: int | None = Field(default=None, ge=0, le=6)
category: str | None = Field(default=None, max_length=128)
notes: str | None = Field(default=None, max_length=2000) notes: str | None = Field(default=None, max_length=2000)
+2
View File
@@ -27,6 +27,8 @@ class MixCalculatorSessionLineRead(BaseModel):
mix_percentage: float mix_percentage: float
unit: str unit: str
rounding_decimals: int = 2 rounding_decimals: int = 2
# Manual ingredient grouping used to order the calculator output.
category: str | None = None
sort_order: int sort_order: int
+182 -23
View File
@@ -35,6 +35,33 @@ def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int)
) )
def _category_sort_key(category: str | None) -> tuple[int, str]:
"""Order ingredients by their manual category; uncategorised rows sort last."""
cleaned = (category or "").strip()
if not cleaned:
return (1, "")
return (0, cleaned.lower())
def _order_formula_rows(rows: list[dict]) -> list[dict]:
"""Sort rows by category (then their original order/name) and renumber.
Category is the primary key so the Mix Calculator groups ingredients by their
manually-assigned category. `sort_order` is reassigned sequentially after the
sort so every downstream consumer (lines, PDF) follows the same order.
"""
rows.sort(
key=lambda row: (
_category_sort_key(row.get("category")),
row.get("sort_order") or 0,
row["raw_material_name"].lower(),
)
)
for index, row in enumerate(rows, start=1):
row["sort_order"] = index
return rows
def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]: def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
if product.ingredients: if product.ingredients:
rows = [ rows = [
@@ -44,6 +71,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
"quantity_kg": ingredient.quantity_kg, "quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure, "unit": ingredient.raw_material.unit_of_measure,
"rounding_decimals": ingredient.raw_material.rounding_decimals, "rounding_decimals": ingredient.raw_material.rounding_decimals,
"category": ingredient.raw_material.category,
"sort_order": ingredient.sort_order, "sort_order": ingredient.sort_order,
} }
for ingredient in product.ingredients for ingredient in product.ingredients
@@ -57,6 +85,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
"quantity_kg": ingredient.quantity_kg, "quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg", "unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
"rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2, "rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2,
"category": ingredient.raw_material.category if ingredient.raw_material is not None else None,
"sort_order": index, "sort_order": index,
} }
for index, ingredient in enumerate(product.mix.ingredients, start=1) for index, ingredient in enumerate(product.mix.ingredients, start=1)
@@ -64,7 +93,29 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
else: else:
rows = [] rows = []
rows.sort(key=lambda row: (row["sort_order"], row["raw_material_name"])) _order_formula_rows(rows)
return rows, round(sum(row["quantity_kg"] for row in rows), 4)
def _mix_formula_rows(mix: Mix) -> tuple[list[dict], float]:
"""Resolve a mix's own (mix-master) formula rows, category-ordered.
Used by the Mix Calculator for mixes that have a formula but no representative
product yet the formula lives directly on the mix.
"""
rows = [
{
"raw_material_id": ingredient.raw_material_id,
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}",
"quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
"rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2,
"category": ingredient.raw_material.category if ingredient.raw_material is not None else None,
"sort_order": index,
}
for index, ingredient in enumerate(mix.ingredients, start=1)
]
_order_formula_rows(rows)
return rows, round(sum(row["quantity_kg"] for row in rows), 4) return rows, round(sum(row["quantity_kg"] for row in rows), 4)
@@ -183,29 +234,32 @@ def resolve_editor_mix_formula(db: Session, *, tenant_id: str, mix: Mix) -> dict
} }
def calculate_mix_calculator_preview( def _scale_preview(
db: Session,
*, *,
tenant_id: str, values: dict,
payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict, formula_rows: list[dict],
): source_total_kg: float,
values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False) client_name: str,
product = _load_product_for_calculation(db, tenant_id, int(values["product_id"])) product_id: int,
if product is None: mix_label: str,
raise ValueError("Product not found") mix_id: int,
if product.client_name != values["client_name"]: unit_of_measure: str,
raise ValueError("Selected product does not belong to the chosen client") ) -> dict:
formula_rows, source_total_kg = _resolved_formula_rows(product) """Scale a resolved formula to the requested batch size and shape the preview.
if source_total_kg <= 0:
raise ValueError("Product has no source kilograms to scale")
Shared by the product-backed path and the formula-only mix path; only the
inputs (where the formula and unit come from) differ.
"""
batch_size_kg = float(values["batch_size_kg"]) batch_size_kg = float(values["batch_size_kg"])
scale_factor = batch_size_kg / source_total_kg scale_factor = batch_size_kg / source_total_kg
unit_size_kg = extract_unit_quantity_kg(product.unit_of_measure) unit_size_kg = extract_unit_quantity_kg(unit_of_measure)
total_bags = round(batch_size_kg / unit_size_kg, 4) if unit_size_kg > 0 else 0.0 total_bags = round(batch_size_kg / unit_size_kg, 4) if unit_size_kg > 0 else 0.0
warnings: list[str] = [] warnings: list[str] = []
bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, product.unit_of_measure) # A bag warning only makes sense when the unit resolves to a bag size; a
# formula-only mix sells in bulk kg, so there's nothing to round to whole bags.
if unit_size_kg > 0:
bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, unit_of_measure)
if bag_warning: if bag_warning:
warnings.append(bag_warning) warnings.append(bag_warning)
@@ -221,24 +275,24 @@ def calculate_mix_calculator_preview(
"mix_percentage": mix_percentage, "mix_percentage": mix_percentage,
"unit": ingredient["unit"], "unit": ingredient["unit"],
"rounding_decimals": ingredient.get("rounding_decimals", 2), "rounding_decimals": ingredient.get("rounding_decimals", 2),
"category": ingredient.get("category"),
"sort_order": ingredient["sort_order"] or index, "sort_order": ingredient["sort_order"] or index,
} }
) )
mix_label = _mix_calculator_label(product)
return { return {
"client_name": product.client_name, "client_name": client_name,
"product_id": product.id, "product_id": product_id,
# The source workbook labels this as Product, but for the calculator # The source workbook labels this as Product, but for the calculator
# it is the mix/formula being produced. # it is the mix/formula being produced.
"product_name": mix_label, "product_name": mix_label,
"mix_id": product.mix_id, "mix_id": mix_id,
"mix_name": mix_label, "mix_name": mix_label,
"mix_date": values["mix_date"], "mix_date": values["mix_date"],
"batch_size_kg": round(batch_size_kg, 4), "batch_size_kg": round(batch_size_kg, 4),
"total_bags": total_bags, "total_bags": total_bags,
"total_kg": round(batch_size_kg, 4), "total_kg": round(batch_size_kg, 4),
"product_unit_of_measure": product.unit_of_measure, "product_unit_of_measure": unit_of_measure,
"product_unit_size_kg": round(unit_size_kg, 4), "product_unit_size_kg": round(unit_size_kg, 4),
"prepared_by_name": values["prepared_by_name"], "prepared_by_name": values["prepared_by_name"],
"status": values.get("status") or "saved", "status": values.get("status") or "saved",
@@ -248,6 +302,70 @@ def calculate_mix_calculator_preview(
} }
def _calculate_mix_only_preview(db: Session, *, tenant_id: str, mix_id: int, values: dict) -> dict:
"""Preview for a mix that has a formula but no representative product.
The Mix Calculator surfaces these via a negative `product_id` sentinel
(`-mix_id`); the formula is read straight off the mix master and there's no
product unit, so output is bulk kg with no bag split.
"""
mix = db.scalar(
select(Mix)
.where(Mix.id == mix_id, Mix.tenant_id == tenant_id)
.options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
)
if mix is None:
raise ValueError("Mix not found")
if mix.client_name != values["client_name"]:
raise ValueError("Selected mix does not belong to the chosen client")
formula_rows, source_total_kg = _mix_formula_rows(mix)
if source_total_kg <= 0:
raise ValueError("Mix has no formula to scale")
return _scale_preview(
values=values,
formula_rows=formula_rows,
source_total_kg=source_total_kg,
client_name=mix.client_name,
product_id=-mix.id,
mix_label=mix.name,
mix_id=mix.id,
unit_of_measure="kg",
)
def calculate_mix_calculator_preview(
db: Session,
*,
tenant_id: str,
payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict,
):
values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False)
product_id = int(values["product_id"])
# Negative ids are the sentinel for a formula-only mix (no product yet).
if product_id < 0:
return _calculate_mix_only_preview(db, tenant_id=tenant_id, mix_id=-product_id, values=values)
product = _load_product_for_calculation(db, tenant_id, product_id)
if product is None:
raise ValueError("Product not found")
if product.client_name != values["client_name"]:
raise ValueError("Selected product does not belong to the chosen client")
formula_rows, source_total_kg = _resolved_formula_rows(product)
if source_total_kg <= 0:
raise ValueError("Product has no source kilograms to scale")
return _scale_preview(
values=values,
formula_rows=formula_rows,
source_total_kg=source_total_kg,
client_name=product.client_name,
product_id=product.id,
mix_label=_mix_calculator_label(product),
mix_id=product.mix_id,
unit_of_measure=product.unit_of_measure,
)
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict: def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
# Prefer product-specific formulas where present; fall back to the shared # Prefer product-specific formulas where present; fall back to the shared
# mix master for legacy rows that have not been migrated yet. # mix master for legacy rows that have not been migrated yet.
@@ -296,7 +414,6 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
key=lambda product: (product.client_name, _mix_calculator_label(product), product.id), key=lambda product: (product.client_name, _mix_calculator_label(product), product.id),
) )
clients = sorted({product.client_name for product in products})
product_rows = [ product_rows = [
{ {
"product_id": product.id, "product_id": product.id,
@@ -311,6 +428,44 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
for product in products for product in products
] ]
# Surface mixes that have a formula but no product at all yet (e.g. a freshly
# created mix). They're selected via a negative `product_id` sentinel (-mix_id)
# and calculated straight off the mix master — bulk kg, no bag split. A mix
# whose only product is hidden is intentionally excluded (it HAS a product),
# so check every product, not just the visible representatives.
covered_mix_ids = set(
db.scalars(
select(Product.mix_id).where(Product.tenant_id == tenant_id).distinct()
).all()
)
formula_only_mix_ids = [
mix_id for mix_id, total in mix_totals.items() if total > 0 and mix_id not in covered_mix_ids
]
if formula_only_mix_ids:
formula_only_mixes = db.scalars(
select(Mix).where(
Mix.tenant_id == tenant_id,
Mix.id.in_(formula_only_mix_ids),
Mix.status == "active",
)
).all()
product_rows.extend(
{
"product_id": -mix.id,
"client_name": mix.client_name,
"product_name": mix.name,
"mix_id": mix.id,
"mix_name": mix.name,
"unit_of_measure": "kg",
"unit_size_kg": 0.0,
"mix_total_kg": mix_totals.get(mix.id, 0.0),
}
for mix in formula_only_mixes
)
product_rows.sort(key=lambda row: (row["client_name"], row["product_name"], row["product_id"]))
clients = sorted({row["client_name"] for row in product_rows})
return {"clients": clients, "products": product_rows} return {"clients": clients, "products": product_rows}
@@ -396,6 +551,10 @@ def _next_session_number(db: Session, *, tenant_id: str, mix_date: date) -> str:
def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, payload: MixCalculatorSessionCreate) -> dict: def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, payload: MixCalculatorSessionCreate) -> dict:
if payload.product_id < 0:
# Sessions reference a real product (FK). A formula-only mix has none yet —
# it can still be previewed and printed, just not saved as a session.
raise ValueError("Add a product to this mix before saving a calculator session.")
preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=payload) preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=payload)
session_record = MixCalculatorSession( session_record = MixCalculatorSession(
tenant_id=auth_session.tenant_id or "default", tenant_id=auth_session.tenant_id or "default",
+7 -1
View File
@@ -482,7 +482,13 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies) options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies)
assert options_response.status_code == 200 assert options_response.status_code == 200
options_payload = options_response.json() options_payload = options_response.json()
assert len(options_payload["products"]) == 84 # 83 product-backed mixes + 1 formula-only mix ("Hi Carb Popcorn", which
# has a mix-master formula but no product yet, surfaced via a negative
# product_id sentinel so a new mix is usable before a product is linked).
assert len(options_payload["products"]) == 84 + 1
formula_only = [product for product in options_payload["products"] if product["product_id"] < 0]
assert len(formula_only) == 1
assert formula_only[0]["unit_size_kg"] == 0
seeded_product = next( seeded_product = next(
product product
for product in options_payload["products"] for product in options_payload["products"]
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.31", "version": "0.1.32",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.31", "version": "0.1.32",
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"lucide-svelte": "^1.0.1" "lucide-svelte": "^1.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.31", "version": "0.1.32",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+8
View File
@@ -17,6 +17,14 @@ export type ChangelogEntry = {
export const APP_VERSION: string = packageInfo.version; export const APP_VERSION: string = packageInfo.version;
export const changelog: ChangelogEntry[] = [ 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', version: '0.1.31',
date: '2026-06-18', date: '2026-06-18',
@@ -46,15 +46,16 @@
} }
function formatWhen(value: string) { function formatWhen(value: string) {
// The server records audit times in its own (Australian) local time. Show // Audit times are stored in UTC on the server (datetime.utcnow), serialized
// those wall-clock values verbatim — do NOT re-interpret them as UTC or // without a timezone suffix. Parse the parts as UTC and let the browser
// shift them into the viewer's timezone, or a 2pm edit reads as 12am. // render them in the viewer's local time, so an edit made at midday in
// Parse the date parts directly so the display never moves with the // Australia reads as midday rather than the raw 02:00 UTC value.
// 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})(?::(\d{2}))?/);
const match = value.match(/(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
if (!match) return value; if (!match) return value;
const [, year, month, day, hour, minute] = match.map(Number); const [, year, month, day, hour, minute, second] = match;
const date = new Date(year, month - 1, day, hour, minute); const date = new Date(
Date.UTC(+year, +month - 1, +day, +hour, +minute, second ? +second : 0)
);
if (Number.isNaN(date.getTime())) return value; if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('en-AU', { return date.toLocaleString('en-AU', {
year: 'numeric', year: 'numeric',
@@ -13,6 +13,7 @@
MixCalculatorSession MixCalculatorSession
} from '$lib/types'; } from '$lib/types';
import MixCalculatorResultsPanel from './MixCalculatorResultsPanel.svelte'; import MixCalculatorResultsPanel from './MixCalculatorResultsPanel.svelte';
import MixCalculatorMixPicker from './MixCalculatorMixPicker.svelte';
let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props(); 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> <span class="composer-icon"><Calculator size={18} strokeWidth={2.2} /></span>
<h2>Mix calculator</h2> <h2>Mix calculator</h2>
</div> </div>
{#if selectedProduct} {#if selectedProduct && selectedProduct.unit_size_kg > 0}
<div class="product-pill"> <div class="product-pill">
<strong>{selectedProduct.unit_size_kg}kg</strong> <strong>{selectedProduct.unit_size_kg}kg</strong>
<span>{selectedProduct.unit_of_measure}</span> <span>{selectedProduct.unit_of_measure}</span>
@@ -344,18 +345,12 @@
<label> <label>
<span>Mix Name</span> <span>Mix Name</span>
<select <MixCalculatorMixPicker
bind:value={productId} products={filteredProducts}
bind:productId
disabled={!canEdit || !clientName || !filteredProducts.length} disabled={!canEdit || !clientName || !filteredProducts.length}
title={!clientName ? 'Select a client first.' : !filteredProducts.length ? 'No mixes are available for the selected client.' : 'Select a mix.'} inputId="mix-calculator-mix"
> />
<option value={0}>Select a mix</option>
{#each filteredProducts as product}
<option value={product.product_id}>
{product.product_name}
</option>
{/each}
</select>
</label> </label>
<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(); } = $props();
// ── Ingredient sorting ────────────────────────────────────────── // ── Ingredient sorting ──────────────────────────────────────────
// Default to heaviest ingredient first; clicking a header toggles direction // Default to the backend's category grouping (ingredients ordered by their
// (or switches column). Required kg starts descending, the name ascending. // manually-assigned category). Clicking a header toggles direction or switches
type LineSortKey = 'raw_material_name' | 'required_kg'; // column. Required kg starts descending; category and name start ascending.
let sortKey = $state<LineSortKey>('required_kg'); type LineSortKey = 'category' | 'raw_material_name' | 'required_kg';
let sortDir = $state<'asc' | 'desc'>('desc'); let sortKey = $state<LineSortKey>('category');
let sortDir = $state<'asc' | 'desc'>('asc');
function toggleSort(key: LineSortKey) { function toggleSort(key: LineSortKey) {
if (sortKey === key) { if (sortKey === key) {
@@ -39,10 +40,16 @@
const sortedLines = $derived.by(() => { const sortedLines = $derived.by(() => {
const dir = sortDir === 'asc' ? 1 : -1; const dir = sortDir === 'asc' ? 1 : -1;
return [...(preview?.lines ?? [])].sort((a, b) => { return [...(preview?.lines ?? [])].sort((a, b) => {
const result = let result: number;
sortKey === 'required_kg' if (sortKey === 'required_kg') {
? (a.required_kg ?? 0) - (b.required_kg ?? 0) result = (a.required_kg ?? 0) - (b.required_kg ?? 0);
: a.raw_material_name.localeCompare(b.raw_material_name, undefined, { sensitivity: 'base' }); } 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; return result * dir;
}); });
}); });
@@ -108,6 +115,17 @@
<table> <table>
<thead> <thead>
<tr> <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')}> <th aria-sort={ariaSort('raw_material_name')}>
<button <button
type="button" type="button"
@@ -135,6 +153,9 @@
<tbody> <tbody>
{#each sortedLines as line} {#each sortedLines as line}
<tr> <tr>
<td data-label="Category">
<span class="category-cell">{line.category || '—'}</span>
</td>
<td data-label="Raw material"> <td data-label="Raw material">
<strong>{line.raw_material_name}</strong> <strong>{line.raw_material_name}</strong>
</td> </td>
@@ -321,6 +342,11 @@
text-transform: uppercase; text-transform: uppercase;
} }
.category-cell {
color: var(--color-text-secondary);
font-size: 0.92rem;
}
/* Clickable header: inherits the th look, adds a sort affordance. */ /* Clickable header: inherits the th look, adds a sort affordance. */
.sort-head { .sort-head {
display: inline-flex; display: inline-flex;
@@ -115,42 +115,6 @@
<span class="cell-label">Packed by</span> <span class="cell-label">Packed by</span>
<input type="text" bind:value={nStaff} placeholder="Name" aria-label="Packed by" /> <input type="text" bind:value={nStaff} placeholder="Name" aria-label="Packed by" />
</div> </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"> <div class="add-cell add-action">
<button type="submit" class="add-entry-button" disabled={saving}> <button type="submit" class="add-entry-button" disabled={saving}>
<Plus size={18} strokeWidth={2.6} /> <Plus size={18} strokeWidth={2.6} />
@@ -218,7 +182,7 @@
.add-row { .add-row {
display: grid; 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; gap: 0.75rem 0.85rem;
align-items: start; align-items: start;
padding: 0 1.45rem 1.25rem; padding: 0 1.45rem 1.25rem;
+3
View File
@@ -100,6 +100,7 @@ export type MixCalculatorLine = {
mix_percentage: number; mix_percentage: number;
unit: string; unit: string;
rounding_decimals?: number; rounding_decimals?: number;
category?: string | null;
sort_order: number; sort_order: number;
}; };
@@ -401,6 +402,7 @@ export type EditorIngredientRow = {
kg_per_unit: number; kg_per_unit: number;
status: string; status: string;
rounding_decimals: number; rounding_decimals: number;
category: string | null;
notes: string | null; notes: string | null;
cost_per_kg: number | null; cost_per_kg: number | null;
usage_count: number; usage_count: number;
@@ -414,6 +416,7 @@ export type EditorIngredientCreateInput = {
kg_per_unit: number; kg_per_unit: number;
status?: string; status?: string;
rounding_decimals?: number; rounding_decimals?: number;
category?: string | null;
notes?: string | null; notes?: string | null;
}; };
+48 -12
View File
@@ -21,6 +21,7 @@
draft_kg_per_unit: number | string; draft_kg_per_unit: number | string;
draft_status: string; draft_status: string;
draft_rounding_decimals: number; draft_rounding_decimals: number;
draft_category: string;
}; };
function toEditable(row: EditorIngredientRow): EditableIngredient { function toEditable(row: EditorIngredientRow): EditableIngredient {
@@ -30,7 +31,8 @@
draft_unit_of_measure: row.unit_of_measure, draft_unit_of_measure: row.unit_of_measure,
draft_kg_per_unit: row.kg_per_unit, draft_kg_per_unit: row.kg_per_unit,
draft_status: row.status, 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 || row.draft_unit_of_measure.trim() !== row.unit_of_measure ||
Number(row.draft_kg_per_unit) !== row.kg_per_unit || Number(row.draft_kg_per_unit) !== row.kg_per_unit ||
row.draft_status !== row.status || 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(), unit_of_measure: row.draft_unit_of_measure.trim(),
kg_per_unit: Number(row.draft_kg_per_unit), kg_per_unit: Number(row.draft_kg_per_unit),
status: row.draft_status, 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'); toast.success('Ingredient saved');
@@ -109,7 +113,8 @@
unit_of_measure: '', unit_of_measure: '',
kg_per_unit: '' as number | string, kg_per_unit: '' as number | string,
status: 'active', status: 'active',
rounding_decimals: 2 rounding_decimals: 2,
category: ''
}; };
} }
let showNew = $state(false); let showNew = $state(false);
@@ -134,7 +139,8 @@
unit_of_measure: newIngredient.unit_of_measure.trim(), unit_of_measure: newIngredient.unit_of_measure.trim(),
kg_per_unit: Number(newIngredient.kg_per_unit), kg_per_unit: Number(newIngredient.kg_per_unit),
status: newIngredient.status, status: newIngredient.status,
rounding_decimals: Number(newIngredient.rounding_decimals) rounding_decimals: Number(newIngredient.rounding_decimals),
category: newIngredient.category.trim() || null
}); });
rows = [toEditable(created), ...rows]; rows = [toEditable(created), ...rows];
toast.success('Ingredient added'); toast.success('Ingredient added');
@@ -163,12 +169,25 @@
(statusFilter === 'archived' && !isActive(row.status)); (statusFilter === 'archived' && !isActive(row.status));
if (!statusMatches) return false; if (!statusMatches) return false;
if (!term) return true; 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, { const table = new TableController<EditableIngredient>(() => visibleRows, {
name: (row) => row.name, name: (row) => row.name,
category: (row) => row.category ?? '',
unit_of_measure: (row) => row.unit_of_measure, unit_of_measure: (row) => row.unit_of_measure,
kg_per_unit: (row) => row.kg_per_unit, kg_per_unit: (row) => row.kg_per_unit,
cost_per_kg: (row) => row.cost_per_kg, cost_per_kg: (row) => row.cost_per_kg,
@@ -276,6 +295,10 @@
<span>Kg per unit</span> <span>Kg per unit</span>
<input bind:value={newIngredient.kg_per_unit} type="number" min="0" step="0.0001" placeholder="0" /> <input bind:value={newIngredient.kg_per_unit} type="number" min="0" step="0.0001" placeholder="0" />
</label> </label>
<label>
<span>Category</span>
<input bind:value={newIngredient.category} list="ingredient-categories" placeholder="e.g. Grains" />
</label>
<label> <label>
<span>Rounding</span> <span>Rounding</span>
<select bind:value={newIngredient.rounding_decimals}> <select bind:value={newIngredient.rounding_decimals}>
@@ -325,6 +348,7 @@
<div class="log"> <div class="log">
<div class="log-head"> <div class="log-head">
<SortHeader label="Ingredient" column="name" controller={table} /> <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="Unit" column="unit_of_measure" controller={table} />
<SortHeader label="Kg / unit" column="kg_per_unit" controller={table} /> <SortHeader label="Kg / unit" column="kg_per_unit" controller={table} />
<SortHeader label="Cost / kg" column="cost_per_kg" 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" /> <input bind:value={row.draft_name} aria-label="Ingredient name" />
</div> </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"> <div class="cell">
<span class="cell-label">Unit</span> <span class="cell-label">Unit</span>
<input bind:value={row.draft_unit_of_measure} aria-label="Unit of measure" /> <input bind:value={row.draft_unit_of_measure} aria-label="Unit of measure" />
@@ -414,6 +443,12 @@
</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"
@@ -832,13 +867,14 @@
.row { .row {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
minmax(180px, 1.45fr) minmax(160px, 1.3fr)
minmax(96px, 0.7fr) minmax(104px, 0.7fr)
minmax(90px, 0.6fr)
minmax(88px, 0.5fr)
minmax(92px, 0.5fr) minmax(92px, 0.5fr)
minmax(96px, 0.55fr) minmax(82px, 0.45fr)
minmax(86px, 0.5fr) minmax(82px, 0.45fr)
minmax(86px, 0.5fr) minmax(104px, 0.55fr)
minmax(110px, 0.6fr)
minmax(150px, auto); minmax(150px, auto);
gap: 0.55rem; gap: 0.55rem;
align-items: center; align-items: center;
+4 -5
View File
@@ -255,11 +255,10 @@
return; return;
} }
if (!nForOrder && !nForStock) { // The order/stock destination split was removed from the composer (operators
addError = 'Mark where this run goes: for an order, for stock, or both.'; // found it hard and it wasn't being used). New runs are saved without a
return; // destination; the guards below only fire when editing legacy entries that
} // still carry order/stock flags.
const job = nJobNumber.trim(); const job = nJobNumber.trim();
if (nForOrder && !job) { if (nForOrder && !job) {
addError = 'Enter the job number for the order.'; addError = 'Enter the job number for the order.';