Compare commits

..
8 Commits
Author SHA1 Message Date
adminandClaude Opus 4.8 5a4d9d77e5 v0.1.36 - What's new dialog: historical changelog accordion
Add a "Read previous changes" accordion to the What's new dialog and
backfill the changelog history from git.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:36:17 +12:00
adminandClaude Opus 4.8 dc50e0538e v0.1.35 - Ingredient categories: created categories now available across all rows
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:28:23 +12:00
admin c9f233dc0e v0.1.34 2026-06-21 12:16:41 +12:00
admin 87878e70fc 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
2026-06-21 11:57:14 +12:00
adminandClaude Opus 4.8 696f1e7b09 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>
2026-06-21 11:09:43 +12:00
admin 10722a65a6 v0.1.31 - Mix Editor multi edit 2026-06-18 15:15:46 +12:00
adminandClaude Opus 4.8 1062c038e8 v0.1.30 - Throughput overview today-only mix cards; mix formula save 500 fix
Throughput Overview: Horse Mix and Grain Mix are now the first two cards and
show TODAY's output only. Removed the 7d/4w/6w/12w range selector; the cards
are fixed to Horse mix today, Grain mix today, Today, This week, 4-week average.

Mix Editor formula save: fix HTTP 500 on PUT /editor/mixes/{id}/formula. The
audit-diff path read the resolved formula by attribute, but the resolver returns
dicts -> AttributeError. Read by key and expire stale ORM state so the response
reflects the just-saved rows. Adds regression tests for both save branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:04:49 +12:00
adminandClaude Opus 4.8 e7a7b11589 v0.1.29 - Mix Editor % edits no longer rebalance other ingredients
Editing one ingredient's % now converts only that row to kg against the
Total mix anchor; other ingredients are left untouched (no proportional
redistribution). Removing a row likewise leaves the rest as-is. The hard
"percentages must total 100%" save guard is relaxed (kg is canonical and
the backend does not require 100%); the % chip remains as live feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:49:15 +12:00
28 changed files with 1981 additions and 407 deletions
+57 -7
View File
@@ -27,7 +27,6 @@ from app.schemas.editor import (
EditorProductRow, EditorProductRow,
EditorProductUpdate, EditorProductUpdate,
EditorResolvedMixFormula, EditorResolvedMixFormula,
EditorResolvedMixIngredient,
) )
from app.services.change_log import ( from app.services.change_log import (
ENTITY_INGREDIENT, ENTITY_INGREDIENT,
@@ -162,12 +161,16 @@ def _format_kg(value: float) -> str:
def _formula_deltas( def _formula_deltas(
before: list[EditorResolvedMixIngredient] | list, before: list[dict],
after: list[EditorResolvedMixIngredient] | list, after: list[dict],
) -> list[dict]: ) -> list[dict]:
"""Per-ingredient before/after deltas between two resolved formulas.""" """Per-ingredient before/after deltas between two resolved formulas.
before_map = {row.raw_material_name: row.quantity_kg for row in before}
after_map = {row.raw_material_name: row.quantity_kg for row in after} `resolve_editor_mix_formula` returns plain dicts (ingredients are dicts too),
so read the rows by key, not attribute.
"""
before_map = {row["raw_material_name"]: row["quantity_kg"] for row in before}
after_map = {row["raw_material_name"]: row["quantity_kg"] for row in after}
deltas: list[dict] = [] deltas: list[dict] = []
for name in sorted(set(before_map) | set(after_map)): for name in sorted(set(before_map) | set(after_map)):
old = before_map.get(name) old = before_map.get(name)
@@ -420,6 +423,45 @@ def update_editor_mix(
return _serialize_mix_row(mix, visible_count=visible_count, product_count=total) return _serialize_mix_row(mix, visible_count=visible_count, product_count=total)
@router.delete("/mixes/{mix_id}", status_code=204)
def delete_editor_mix(
mix_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
"""Delete a mix that no product depends on.
A product must reference a mix (`products.mix_id` is NOT NULL), so a mix that
still drives products can't be removed without orphaning them — those should
be marked inactive instead. The mix's own ingredient rows cascade away with
it via the `delete-orphan` relationship.
"""
mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id))
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
product_total = (
db.scalar(
select(func.count())
.select_from(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
)
or 0
)
if product_total > 0:
raise HTTPException(
status_code=409,
detail=(
f"This mix has {product_total} linked product"
f"{'s' if product_total != 1 else ''}. Mark it inactive or remove its products first."
),
)
db.delete(mix)
db.commit()
return None
@router.get("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead) @router.get("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead)
def get_editor_mix_ingredients( def get_editor_mix_ingredients(
mix_id: int, mix_id: int,
@@ -640,9 +682,12 @@ def replace_editor_mix_formula(
) )
db.flush() db.flush()
# Drop now-stale ORM state so the re-resolve reads the rows we just wrote
# rather than the formerly-loaded ingredient collections from the identity map.
db.expire_all()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id) mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
after_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix) after_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
deltas = _formula_deltas(before_formula.ingredients, after_formula.ingredients) deltas = _formula_deltas(before_formula["ingredients"], after_formula["ingredients"])
if deltas: if deltas:
record_change( record_change(
db, db,
@@ -793,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,
@@ -840,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)
@@ -883,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)
@@ -896,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
+184 -25
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,31 +234,34 @@ 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
if bag_warning: # formula-only mix sells in bulk kg, so there's nothing to round to whole bags.
warnings.append(bag_warning) if unit_size_kg > 0:
bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, unit_of_measure)
if bag_warning:
warnings.append(bag_warning)
lines = [] lines = []
for index, ingredient in enumerate(formula_rows, start=1): for index, ingredient in enumerate(formula_rows, start=1):
@@ -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",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hunter-backend" name = "hunter-backend"
version = "0.1.28" version = "0.1.36"
description = "Costing platform MVP backend (API for Hunter)" description = "Costing platform MVP backend (API for Hunter)"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+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"]
+139 -1
View File
@@ -7,13 +7,19 @@ product is chosen the way the calculator chooses it.
""" """
from __future__ import annotations from __future__ import annotations
from sqlalchemy import create_engine from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
import pytest
from fastapi import HTTPException
from app.api.deps import AuthSession
from app.api.editor import delete_editor_mix, replace_editor_mix_formula
from app.db.session import Base from app.db.session import Base
from app.models.mix import Mix, MixIngredient from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial from app.models.raw_material import RawMaterial
from app.schemas.editor import EditorMixFormulaReplace, EditorMixFormulaRowInput
from app.services.mix_calculator_service import ( from app.services.mix_calculator_service import (
resolve_editor_mix_formula, resolve_editor_mix_formula,
resolve_representative_product, resolve_representative_product,
@@ -22,6 +28,17 @@ from app.services.mix_calculator_service import (
TENANT = "hunter-premium-produce" TENANT = "hunter-premium-produce"
def _editor_session() -> AuthSession:
return AuthSession(
role="internal",
email="editor@hunter.test",
name="Editor",
tenant_id=TENANT,
client_role="admin",
user_id=1,
)
def _session() -> Session: def _session() -> Session:
engine = create_engine("sqlite:///:memory:") engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -82,6 +99,127 @@ def test_falls_back_to_mix_master_when_no_product_formula():
assert formula["ingredients"][0]["mix_percentage"] == 100.0 assert formula["ingredients"][0]["mix_percentage"] == 100.0
def test_replace_mix_master_formula_returns_fresh_rows():
"""PUT formula on a mix without a product writes the mix master and the
response reflects the just-saved rows (not the stale pre-save collection).
Regression: the diff path read the resolved formula by attribute, but the
resolver returns dicts, which raised AttributeError -> HTTP 500 on save.
"""
db = _session()
maize = _raw(db, "Maize")
barley = _raw(db, "Barley")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
db.add(mix)
db.flush()
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=100))
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=barley.id, quantity_kg=100))
db.commit()
# Percentages need not total 100% — kg is canonical.
payload = EditorMixFormulaReplace(
rows=[
EditorMixFormulaRowInput(raw_material_id=maize.id, quantity_kg=330.0, notes=None),
EditorMixFormulaRowInput(raw_material_id=barley.id, quantity_kg=140.0, notes="confirmed"),
]
)
result = replace_editor_mix_formula(mix.id, payload, session=_editor_session(), db=db)
assert result["source"] == "mix"
assert result["total_kg"] == 470.0
by_name = {row["raw_material_name"]: row for row in result["ingredients"]}
assert by_name["Maize"]["quantity_kg"] == 330.0
assert by_name["Barley"]["quantity_kg"] == 140.0
persisted = db.scalars(select(MixIngredient).where(MixIngredient.mix_id == mix.id)).all()
assert sorted((row.raw_material_id, row.quantity_kg) for row in persisted) == [
(maize.id, 330.0),
(barley.id, 140.0),
]
def test_replace_product_formula_writes_product_ingredients():
"""When a representative product owns the formula, PUT replaces the product's
ingredients (the source the calculator reads) and returns the fresh rows."""
db = _session()
bayley = _raw(db, "Bayley")
filler = _raw(db, "Filler")
canola = _raw(db, "Canola")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Layer Mix")
db.add(mix)
db.flush()
product = Product(
tenant_id=TENANT, client_name="Hunter", name="Layer 20kg", mix_id=mix.id,
unit_of_measure="20kg bag", visible=True,
)
db.add(product)
db.flush()
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=10, sort_order=1))
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=10, sort_order=2))
db.commit()
payload = EditorMixFormulaReplace(
rows=[
EditorMixFormulaRowInput(raw_material_id=bayley.id, quantity_kg=600.0, notes=None),
EditorMixFormulaRowInput(raw_material_id=canola.id, quantity_kg=200.0, notes=None),
]
)
result = replace_editor_mix_formula(mix.id, payload, session=_editor_session(), db=db)
assert result["source"] == "product"
assert result["product_id"] == product.id
assert result["total_kg"] == 800.0
persisted = db.scalars(select(ProductIngredient).where(ProductIngredient.product_id == product.id)).all()
# Filler dropped, Canola added; mix master is untouched.
assert sorted((row.raw_material_id, row.quantity_kg) for row in persisted) == [
(bayley.id, 600.0),
(canola.id, 200.0),
]
def test_delete_mix_without_products_removes_mix_and_ingredients():
"""A product-less mix can be deleted; its ingredient rows cascade away."""
db = _session()
maize = _raw(db, "Maize")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
db.add(mix)
db.flush()
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50))
db.commit()
mix_id = mix.id
delete_editor_mix(mix_id, session=_editor_session(), db=db)
assert db.scalar(select(Mix).where(Mix.id == mix_id)) is None
assert db.scalars(select(MixIngredient).where(MixIngredient.mix_id == mix_id)).first() is None
def test_delete_mix_with_products_is_refused():
"""A mix that still drives products can't be deleted (409) — products must
keep a mix, so the user marks it inactive instead."""
db = _session()
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Layer Mix")
db.add(mix)
db.flush()
db.add(
Product(
tenant_id=TENANT, client_name="Hunter", name="Layer 20kg", mix_id=mix.id,
unit_of_measure="20kg bag", visible=True,
)
)
db.commit()
mix_id = mix.id
with pytest.raises(HTTPException) as excinfo:
delete_editor_mix(mix_id, session=_editor_session(), db=db)
assert excinfo.value.status_code == 409
assert "linked product" in excinfo.value.detail
# The mix is left intact.
assert db.scalar(select(Mix).where(Mix.id == mix_id)) is not None
def test_representative_product_prefers_20kg_bag(): def test_representative_product_prefers_20kg_bag():
db = _session() db = _session()
maize = _raw(db, "Maize") maize = _raw(db, "Maize")
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.28", "version": "0.1.36",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.28", "version": "0.1.36",
"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.28", "version": "0.1.36",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+4
View File
@@ -410,6 +410,10 @@ export const api = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'client'), }, 'client'),
deleteEditorMix: (mixId: number) =>
request<void>(`/api/editor/mixes/${mixId}`, {
method: 'DELETE'
}, 'client'),
editorMixFormula: (mixId: number) => editorMixFormula: (mixId: number) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'), request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'),
// The resolved formula matching the Mix Calculator (product-first), used by // The resolved formula matching the Mix Calculator (product-first), used by
+111 -1
View File
@@ -17,6 +17,94 @@ 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.36',
date: '2026-06-21',
highlights: [
'App: Bug fixes & improvements.',
'App: General improvements.'
]
},
{
version: '0.1.35',
date: '2026-06-21',
highlights: [
'App: Bug fixes & improvements.',
'App: General improvements.'
]
},
{
version: '0.1.34',
date: '2026-06-21',
highlights: [
'App: Bug fixes & improvements.',
'App: General improvements.'
]
},
{
version: '0.1.33',
date: '2026-06-21',
highlights: [
'App: Mix Calculator & Ingredients improvements.',
'App: Bug fixes & improvements.'
]
},
{
version: '0.1.32',
date: '2026-06-21',
highlights: [
'Mix Calculator: Added search.',
'Ingredients: Added ingredient categories.',
'Throughput: Tidy-up and refinements.'
]
},
{
version: '0.1.31',
date: '2026-06-18',
highlights: [
'Mix Editor: Multi-row editing.',
'App: Bug fixes & improvements.'
]
},
{
version: '0.1.30',
date: '2026-06-18',
highlights: [
'Throughput: Overview now shows today-only mix cards.',
'Mix Editor: Fixed an error when saving a mix formula.'
]
},
{
version: '0.1.29',
date: '2026-06-18',
highlights: [
'Mix Editor: Editing a % no longer rebalances the other ingredients.'
]
},
{
version: '0.1.28',
date: '2026-06-17',
highlights: [
'Editor & Throughput: Updates and improvements.'
]
},
{
version: '0.1.27',
date: '2026-06-16',
highlights: [
'Editor: Edit a mixs resolved formula directly, with % and kg entry on each row.',
'Editor: New mix and new ingredient buttons.',
'Throughput: Power BI / external API now live.',
'App: Security hardening on API responses.'
]
},
{
version: '0.1.23',
date: '2026-06-15',
highlights: [
'App: Improvements & bug fixes.'
]
},
{ {
version: '0.1.22', version: '0.1.22',
date: '2026-06-15', date: '2026-06-15',
@@ -24,6 +112,13 @@ export const changelog: ChangelogEntry[] = [
'Web App - Throughput module is now live.' 'Web App - Throughput module is now live.'
] ]
}, },
{
version: '0.1.21',
date: '2026-06-14',
highlights: [
'Mix Calculator: Composer restyle.'
]
},
{ {
version: '0.1.20', version: '0.1.20',
date: '2026-06-13', date: '2026-06-13',
@@ -32,6 +127,14 @@ export const changelog: ChangelogEntry[] = [
'App - Bug fixes' 'App - Bug fixes'
] ]
}, },
{
version: '0.1.19',
date: '2026-06-13',
highlights: [
'Throughput: Overview view added.',
'App: Responsive header.'
]
},
{ {
version: '0.1.18', version: '0.1.18',
date: '2026-06-12', date: '2026-06-12',
@@ -62,9 +165,16 @@ export const changelog: ChangelogEntry[] = [
highlights: [ highlights: [
'Mix Calculator: Changed from selecting Product to Mix.', 'Mix Calculator: Changed from selecting Product to Mix.',
'Web app design improved', 'Web app design improved',
'Throughput tab ready for testing', 'Throughput tab ready for testing',
'Costing Editor tab ready for testing' 'Costing Editor tab ready for testing'
] ]
},
{
version: '0.1.11',
date: '2026-06-03',
highlights: [
'Costing Editor: First release.'
]
} }
]; ];
@@ -1,16 +1,22 @@
<script lang="ts"> <script lang="ts">
import { Sparkles } from 'lucide-svelte'; import { Sparkles } from 'lucide-svelte';
import type { ChangelogEntry } from '$lib/changelog'; import { changelog, type ChangelogEntry } from '$lib/changelog';
let { entry, onClose }: { entry: ChangelogEntry; onClose: () => void } = $props(); let { entry, onClose }: { entry: ChangelogEntry; onClose: () => void } = $props();
const releaseDate = $derived( function formatDate(date: string): string {
new Date(`${entry.date}T00:00:00`).toLocaleDateString(undefined, { return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric' day: 'numeric'
}) });
); }
const releaseDate = $derived(formatDate(entry.date));
// Every release older than the one being shown, newest first, for the
// "Read previous changes" accordion.
const previousEntries = $derived(changelog.filter((item) => item.version !== entry.version));
</script> </script>
<div class="whats-new-backdrop" role="presentation" onclick={onClose}> <div class="whats-new-backdrop" role="presentation" onclick={onClose}>
@@ -42,6 +48,27 @@
{/each} {/each}
</ul> </ul>
{#if previousEntries.length}
<details class="whats-new-history">
<summary>Read previous changes</summary>
<ol class="history-list">
{#each previousEntries as item (item.version)}
<li class="history-entry">
<div class="history-head">
<span class="history-version">v{item.version}</span>
<span class="history-date">{formatDate(item.date)}</span>
</div>
<ul class="history-highlights">
{#each item.highlights as highlight}
<li>{highlight}</li>
{/each}
</ul>
</li>
{/each}
</ol>
</details>
{/if}
<div class="whats-new-actions"> <div class="whats-new-actions">
<button class="whats-new-button" type="button" onclick={onClose}>Got it</button> <button class="whats-new-button" type="button" onclick={onClose}>Got it</button>
</div> </div>
@@ -141,6 +168,102 @@
background: var(--color-brand); background: var(--color-brand);
} }
.whats-new-history {
border-top: 1px solid var(--color-divider);
padding-top: 1.05rem;
}
.whats-new-history > summary {
display: inline-flex;
align-items: center;
list-style: none;
color: var(--color-brand);
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.whats-new-history > summary::-webkit-details-marker {
display: none;
}
.whats-new-history > summary::before {
content: '';
width: 0.46rem;
height: 0.46rem;
margin-right: 0.55rem;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(-45deg);
transition: transform 160ms ease;
}
.whats-new-history[open] > summary::before {
transform: rotate(45deg);
}
.whats-new-history > summary:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
outline-offset: 2px;
border-radius: 0.3rem;
}
.history-list {
display: grid;
gap: 1.05rem;
margin: 1rem 0 0;
padding: 0;
list-style: none;
max-height: 16rem;
overflow-y: auto;
}
.history-head {
display: flex;
align-items: baseline;
gap: 0.6rem;
margin-bottom: 0.4rem;
}
.history-version {
font-size: 0.84rem;
font-weight: 700;
color: var(--color-text-primary);
}
.history-date {
font-size: 0.76rem;
color: var(--color-text-muted);
}
.history-highlights {
display: grid;
gap: 0.4rem;
margin: 0;
padding: 0;
list-style: none;
}
.history-highlights li {
position: relative;
padding-left: 1.1rem;
color: var(--color-text-secondary);
font-size: 0.86rem;
line-height: 1.45;
}
.history-highlights li::before {
content: '';
position: absolute;
top: 0.5rem;
left: 0.15rem;
width: 0.34rem;
height: 0.34rem;
border-radius: 999px;
background: var(--color-text-muted);
}
.whats-new-actions { .whats-new-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -0,0 +1,426 @@
<script lang="ts">
import { tick } from 'svelte';
import { Check, ChevronDown, Plus, Search, X } from 'lucide-svelte';
// A category picker with search + explicit "create new". Typing only *searches*
// — the committed value changes only when you pick an existing category or
// deliberately choose "Create new category". This keeps spelling consistent and
// makes creating a brand-new category an obvious, intentional action rather than
// a side effect of typing. The menu is portaled to <body> so the ingredients
// table's clipped (overflow:hidden) scroll container can never hide it.
let {
value = $bindable(''),
options = [],
placeholder = 'Category',
inputId,
disabled = false,
ariaLabel = 'Category',
oncreate
}: {
value?: string;
options?: string[];
placeholder?: string;
inputId?: string;
disabled?: boolean;
ariaLabel?: string;
/** Fired when the user deliberately creates a brand-new category, so the
* parent can keep it available to every other row. */
oncreate?: (category: string) => void;
} = $props();
// `query` is the ephemeral search text; `value` is the committed category.
let query = $state('');
let open = $state(false);
let highlighted = $state(0);
let root = $state<HTMLDivElement | null>(null);
let inputEl = $state<HTMLInputElement | null>(null);
let menuStyle = $state('');
// The input shows the live search text while open, and the committed value when
// closed — so an in-progress search never looks like it changed the field.
const display = $derived(open ? query : value);
const trimmed = $derived(query.trim());
const filtered = $derived.by(() => {
const q = trimmed.toLowerCase();
if (!q) return options;
return options.filter((option) => option.toLowerCase().includes(q));
});
// Offer "create" only when the typed text isn't already a category.
const exactExists = $derived(options.some((option) => option.toLowerCase() === trimmed.toLowerCase()));
const showCreate = $derived(trimmed.length > 0 && !exactExists);
// Selectable rows = filtered options, then the create row (when shown).
const rowCount = $derived(filtered.length + (showCreate ? 1 : 0));
const createIndex = $derived(showCreate ? filtered.length : -1);
function positionMenu() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
menuStyle = `top: ${rect.bottom + 4}px; left: ${rect.left}px; min-width: ${Math.max(rect.width, 220)}px;`;
}
async function openMenu() {
if (disabled) return;
query = value;
open = true;
// Highlight the create row when there's nothing to match, else the first option.
highlighted = 0;
await tick();
positionMenu();
inputEl?.select();
}
function closeMenu() {
open = false;
highlighted = 0;
}
function choose(option: string) {
value = option;
closeMenu();
}
function createNew() {
const created = trimmed;
value = created;
oncreate?.(created);
closeMenu();
}
function clear() {
value = '';
query = '';
closeMenu();
inputEl?.focus();
}
function commitHighlighted() {
if (highlighted === createIndex) {
createNew();
} else if (highlighted >= 0 && highlighted < filtered.length) {
choose(filtered[highlighted]);
}
}
function onInput(event: Event) {
query = (event.target as HTMLInputElement).value;
open = true;
highlighted = 0;
positionMenu();
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
if (!open) {
openMenu();
return;
}
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 && rowCount > 0) {
event.preventDefault();
commitHighlighted();
}
} else if (event.key === 'Escape') {
if (open) {
event.preventDefault();
closeMenu();
}
}
}
function onFocusOut(event: FocusEvent) {
// The menu lives in <body> (portaled) and its rows use mousedown+preventDefault,
// so a click on a row never blurs the input. Any real blur closes the menu and
// discards the in-progress search (the committed value is untouched).
if (root && event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) {
return;
}
closeMenu();
}
// Move the menu to <body> so no ancestor's overflow/transform can clip it.
function portal(node: HTMLElement) {
if (typeof document !== 'undefined') document.body.appendChild(node);
return {
destroy() {
node.parentNode?.removeChild(node);
}
};
}
// Keep the portaled 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={display}
{disabled}
oninput={onInput}
onfocus={openMenu}
onkeydown={onKeydown}
/>
{#if value && !disabled}
<button type="button" class="combo-clear" onmousedown={(e) => { e.preventDefault(); clear(); }} aria-label="Clear category">
<X size={14} strokeWidth={2.4} />
</button>
{:else}
<span class="combo-caret" aria-hidden="true"><ChevronDown size={15} strokeWidth={2.2} /></span>
{/if}
{#if open && !disabled}
<ul class="menu" use:portal id={inputId ? `${inputId}-list` : undefined} role="listbox" style={menuStyle}>
{#if filtered.length}
<li class="menu-label" aria-hidden="true">Categories</li>
{#each filtered as option, i (option)}
<li
class="row"
class:highlighted={i === highlighted}
class:selected={option.toLowerCase() === value.toLowerCase()}
role="option"
aria-selected={option.toLowerCase() === value.toLowerCase()}
onmousedown={(e) => {
e.preventDefault();
choose(option);
}}
onmouseenter={() => (highlighted = i)}
>
<span class="row-label">{option}</span>
{#if option.toLowerCase() === value.toLowerCase()}
<span class="row-check" aria-hidden="true"><Check size={14} strokeWidth={2.6} /></span>
{/if}
</li>
{/each}
{/if}
{#if showCreate}
<li
class="row create"
class:highlighted={highlighted === createIndex}
role="option"
aria-selected={false}
onmousedown={(e) => {
e.preventDefault();
createNew();
}}
onmouseenter={() => (highlighted = createIndex)}
>
<span class="create-icon" aria-hidden="true"><Plus size={15} strokeWidth={2.6} /></span>
<span class="create-text">Create new category <strong>{trimmed}</strong></span>
</li>
{:else if filtered.length === 0}
<li class="row empty">Start typing 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 1.7rem 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;
text-overflow: ellipsis;
}
.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;
}
.combo-caret {
position: absolute;
right: 0.5rem;
display: inline-flex;
color: var(--color-text-muted);
pointer-events: none;
}
.combo-clear {
position: absolute;
right: 0.35rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
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);
}
/* The menu is portaled to <body>, so it can't rely on inherited layout — it
positions itself fixed against the input's rect. */
.menu {
position: fixed;
z-index: 400;
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.18);
}
.menu-label {
padding: 0.3rem 0.55rem 0.2rem;
color: var(--color-text-muted);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.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;
}
/* The create action is deliberately prominent: a brand-tinted row with a + icon
so "make a new category" reads as a distinct, intentional choice. */
.row.create {
margin-top: 0.15rem;
border-top: 1px solid var(--color-divider);
padding-top: 0.5rem;
color: var(--color-brand);
font-weight: 600;
}
.row.create.highlighted {
background: var(--color-brand-tint);
}
.create-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.2rem;
height: 1.2rem;
border-radius: 50%;
background: var(--color-brand);
color: var(--color-on-brand);
}
.create-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.create-text strong {
font-weight: 700;
}
</style>
@@ -46,11 +46,18 @@
} }
function formatWhen(value: string) { function formatWhen(value: string) {
// Stored as a naive UTC timestamp; treat it as UTC for display. // Audit times are stored in UTC on the server (datetime.utcnow), serialized
const iso = value.endsWith('Z') || value.includes('+') ? value : `${value}Z`; // without a timezone suffix. Parse the parts as UTC and let the browser
const date = new Date(iso); // 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, second] = match;
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(undefined, { return date.toLocaleString('en-AU', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: '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;
@@ -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>
@@ -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;
@@ -287,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;
} }
@@ -495,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;
} }
@@ -526,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;
} }
} }
@@ -1,14 +1,11 @@
<script lang="ts"> <script lang="ts">
import { CalendarDays, CalendarRange, Carrot, Gauge, TrendingUp, Wheat } from 'lucide-svelte'; import { CalendarDays, CalendarRange, Carrot, Gauge, TrendingUp, Wheat } from 'lucide-svelte';
import { MIX_RANGES } from '$lib/components/throughput/utils';
let { let {
today, today,
weekRangeLabel, weekRangeLabel,
heroStats, heroStats,
mixTotals, mixTotals,
mixRangeKey = $bindable<(typeof MIX_RANGES)[number]['key']>('4w'),
formatDate, formatDate,
formatNumber formatNumber
}: { }: {
@@ -16,7 +13,6 @@
weekRangeLabel: string; weekRangeLabel: string;
heroStats: { today: number; thisWeek: number; avgFourWeek: number }; heroStats: { today: number; thisWeek: number; avgFourWeek: number };
mixTotals: { horse: number; grain: number }; mixTotals: { horse: number; grain: number };
mixRangeKey?: (typeof MIX_RANGES)[number]['key'];
formatDate: (value: string) => string; formatDate: (value: string) => string;
formatNumber: (value: number | null | undefined, digits?: number) => string; formatNumber: (value: number | null | undefined, digits?: number) => string;
} = $props(); } = $props();
@@ -26,18 +22,19 @@
<div class="summary-heading"> <div class="summary-heading">
<span class="summary-icon"><Gauge size={17} strokeWidth={2.2} /></span> <span class="summary-icon"><Gauge size={17} strokeWidth={2.2} /></span>
<h2>Throughput Overview</h2> <h2>Throughput Overview</h2>
<div class="range-select" role="group" aria-label="Customer mix date range">
{#each MIX_RANGES as range (range.key)}
<button
type="button"
class="range-option"
class:active={mixRangeKey === range.key}
aria-pressed={mixRangeKey === range.key}
onclick={() => (mixRangeKey = range.key)}
>{range.label}</button>
{/each}
</div>
</div> </div>
<dl class="mix-facts" aria-label="Throughput by customer today">
<div class="fact">
<dt><span class="fact-icon"><Carrot size={16} strokeWidth={2.2} /></span>Horse Mix</dt>
<dd>{formatNumber(mixTotals.horse)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">PHF Horsemix · {formatDate(today)}</p>
</div>
<div class="fact">
<dt><span class="fact-icon"><Wheat size={16} strokeWidth={2.2} /></span>Grain Mix</dt>
<dd>{formatNumber(mixTotals.grain)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">All other customers · {formatDate(today)}</p>
</div>
</dl>
<dl class="facts"> <dl class="facts">
<div class="fact"> <div class="fact">
<dt><span class="fact-icon"><CalendarDays size={16} strokeWidth={2.2} /></span>Today</dt> <dt><span class="fact-icon"><CalendarDays size={16} strokeWidth={2.2} /></span>Today</dt>
@@ -55,18 +52,6 @@
<p class="fact-sub">Per week, last 4 weeks</p> <p class="fact-sub">Per week, last 4 weeks</p>
</div> </div>
</dl> </dl>
<dl class="mix-facts" aria-label="Throughput by customer">
<div class="fact">
<dt><span class="fact-icon"><Carrot size={16} strokeWidth={2.2} /></span>Horse Mix</dt>
<dd>{formatNumber(mixTotals.horse)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">PHF Horsemix · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}</p>
</div>
<div class="fact">
<dt><span class="fact-icon"><Wheat size={16} strokeWidth={2.2} /></span>Grain Mix</dt>
<dd>{formatNumber(mixTotals.grain)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">All other customers · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}</p>
</div>
</dl>
</header> </header>
<style> <style>
@@ -106,41 +91,6 @@
color: var(--color-brand); color: var(--color-brand);
} }
.range-select {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-left: auto;
padding: 0.28rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
border-radius: 0.8rem;
background: color-mix(in srgb, var(--color-bg-surface) 55%, transparent);
}
.range-option {
padding: 0.5rem 0.95rem;
border: 0;
border-radius: 0.6rem;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.95rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
}
.range-option:hover {
color: var(--color-text-primary);
}
.range-option.active {
background: var(--color-brand);
color: #fff;
box-shadow: 0 8px 18px -14px color-mix(in srgb, var(--color-brand) 85%, transparent);
}
.facts, .facts,
.mix-facts { .mix-facts {
display: grid; display: grid;
@@ -149,8 +99,13 @@
padding: 1rem; padding: 1rem;
} }
/* Mix cards lead the overview, so they carry the top padding; the hero
figures follow and hug up against them. */
.mix-facts { .mix-facts {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
}
.facts {
padding-top: 0; padding-top: 0;
} }
@@ -243,12 +198,12 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.facts { .mix-facts {
grid-template-columns: 1fr; grid-template-columns: 1fr;
padding: 0.9rem; padding: 0.9rem;
} }
.mix-facts { .facts {
grid-template-columns: 1fr; grid-template-columns: 1fr;
padding: 0 0.9rem 0.9rem; padding: 0 0.9rem 0.9rem;
} }
@@ -15,13 +15,6 @@ export type ConfettiPiece = {
export const CONFETTI_COLORS = ['#16a34a', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444']; export const CONFETTI_COLORS = ['#16a34a', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444'];
export const MIX_RANGES = [
{ key: '7d', label: '7 days', sub: 'last 7 days', days: 7 },
{ key: '4w', label: '4 weeks', sub: 'last 4 weeks', days: 28 },
{ key: '6w', label: '6 weeks', sub: 'last 6 weeks', days: 42 },
{ key: '12w', label: '12 weeks', sub: 'last 12 weeks', days: 84 }
] as const;
export function compareText(a: string | null | undefined, b: string | null | undefined) { export function compareText(a: string | null | undefined, b: string | null | undefined) {
return (a ?? '').localeCompare(b ?? '', undefined, { sensitivity: 'base' }); return (a ?? '').localeCompare(b ?? '', undefined, { sensitivity: 'base' });
} }
+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;
}; };
+458 -65
View File
@@ -12,7 +12,7 @@
EditorMixUpdateInput, EditorMixUpdateInput,
RawMaterial RawMaterial
} from '$lib/types'; } from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, History, ListFilter, Plus, Save, Search, TriangleAlert, X } from 'lucide-svelte'; import { CheckCircle2, ChevronLeft, ChevronRight, EyeOff, FlaskConical, History, ListChecks, ListFilter, Plus, Save, Search, Trash2, TriangleAlert, X } from 'lucide-svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
let { data } = $props(); let { data } = $props();
@@ -103,57 +103,24 @@
ingredientBaseline = ingredientDrafts.map((row) => ({ ...row })); ingredientBaseline = ingredientDrafts.map((row) => ({ ...row }));
} }
// kg overrides %: recompute the reference total from the kg column, then // Editing a row's % converts only that row to kilograms against the Total mix
// re-derive every row's percentage so they always sum to 100. // anchor. Every other ingredient is left exactly as it is — changing one
function applyKgEdit() { // ingredient's share never rebalances or recalculates the rest of the recipe.
const total = ingredientDrafts.reduce((sum, row) => sum + Number(row.quantity_kg || 0), 0); // Percentages are free to sum to anything; the chip reports the running total
totalReference = round4(total); // and kg stays the canonical saved value.
ingredientDrafts = ingredientDrafts.map((row) => ({
...row,
percentage: total > 0 ? round4((Number(row.quantity_kg || 0) / total) * 100) : 0
}));
}
// Editing a row's % sets it to that share of the mix and spreads the rest
// across the other rows in their existing proportions, so the column always
// re-totals 100 and kg stays the canonical value. The total mix kg is held
// constant; only the split moves. Without this rebalance a single % edit
// would leave the total off 100 and the save guard would block the change.
function applyPercentEdit(index: number) { function applyPercentEdit(index: number) {
const total = Number(totalReference || 0); const total = Number(totalReference || 0);
const rows = ingredientDrafts;
const count = rows.length;
// Clamp to a sane 0100 share. // Clamp to a sane 0100 share for the edited row only.
let target = Number(rows[index].percentage || 0); let target = Number(ingredientDrafts[index].percentage || 0);
if (!Number.isFinite(target) || target < 0) target = 0; if (!Number.isFinite(target) || target < 0) target = 0;
if (target > 100) target = 100; if (target > 100) target = 100;
// A single ingredient is always the whole mix. ingredientDrafts = ingredientDrafts.map((row, rowIndex) =>
if (count === 1) { rowIndex === index
ingredientDrafts = rows.map((row) => ({ ? { ...row, percentage: round4(target), quantity_kg: round4((target / 100) * total) }
...row, : row
percentage: 100,
quantity_kg: round4(total)
}));
return;
}
const remaining = 100 - target;
const otherOldSum = rows.reduce(
(sum, row, rowIndex) => (rowIndex === index ? sum : sum + Number(row.percentage || 0)),
0
); );
ingredientDrafts = rows.map((row, rowIndex) => {
if (rowIndex === index) {
return { ...row, percentage: round4(target), quantity_kg: round4((target / 100) * total) };
}
// Keep the other rows' relative split; if they were all at 0, share evenly.
const share =
otherOldSum > 0 ? (Number(row.percentage || 0) / otherOldSum) * remaining : remaining / (count - 1);
return { ...row, percentage: round4(share), quantity_kg: round4((share / 100) * total) };
});
} }
// Editing the Total mix (kg) rescales every row's kg from its current %. // Editing the Total mix (kg) rescales every row's kg from its current %.
@@ -293,9 +260,10 @@
} }
function removeIngredient(index: number) { function removeIngredient(index: number) {
// Drop the row and leave the remaining ingredients' % and kg exactly as they
// are — removing one ingredient never recalculates the others.
ingredientDrafts = ingredientDrafts.filter((_, rowIndex) => rowIndex !== index); ingredientDrafts = ingredientDrafts.filter((_, rowIndex) => rowIndex !== index);
if (!ingredientDrafts.length) ingredientDrafts = [emptyIngredient()]; if (!ingredientDrafts.length) ingredientDrafts = [emptyIngredient()];
applyKgEdit();
} }
function ingredientWarnings() { function ingredientWarnings() {
@@ -312,11 +280,6 @@
if (Number(row.quantity_kg) <= 0) return [`Ingredient row ${index + 1} needs a quantity greater than zero.`]; if (Number(row.quantity_kg) <= 0) return [`Ingredient row ${index + 1} needs a quantity greater than zero.`];
} }
// Percentages must add up to 100 before a change can be saved.
if (Math.abs(percentTotal - 100) > 0.1) {
return [`Percentages must total 100% (currently ${percentTotal.toFixed(2)}%).`];
}
return []; return [];
} }
@@ -423,6 +386,91 @@
}); });
}); });
// ── Bulk select: tick multiple mixes, then delete or mark them inactive ──
let selectMode = $state(false);
let selectedIds = $state<Set<number>>(new Set());
// Which bulk action is awaiting confirmation (null = no modal open).
let bulkAction = $state<'inactive' | 'delete' | null>(null);
let bulkRunning = $state(false);
// Summary shown after a bulk run finishes (null = closed).
let bulkResult = $state<
{ action: 'inactive' | 'delete'; succeeded: number; failures: { name: string; reason: string }[] } | null
>(null);
// Select-all operates on the rows currently on screen; the selection itself
// persists across pages so a multi-page selection is possible.
const pageRowIds = $derived(table.rows.map((row) => row.id));
const selectedOnPage = $derived(pageRowIds.filter((id) => selectedIds.has(id)).length);
const allPageSelected = $derived(pageRowIds.length > 0 && selectedOnPage === pageRowIds.length);
const somePageSelected = $derived(selectedOnPage > 0 && !allPageSelected);
function toggleSelectMode() {
selectMode = !selectMode;
if (!selectMode) selectedIds = new Set();
}
function toggleRowSelected(id: number) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleSelectAll() {
const next = new Set(selectedIds);
if (allPageSelected) {
for (const id of pageRowIds) next.delete(id);
} else {
for (const id of pageRowIds) next.add(id);
}
selectedIds = next;
}
function requestBulk(action: 'inactive' | 'delete') {
if (selectedIds.size === 0) return;
bulkAction = action;
}
async function runBulk() {
const action = bulkAction;
if (!action) return;
bulkRunning = true;
const targets = rows.filter((row) => selectedIds.has(row.id));
const failures: { name: string; reason: string }[] = [];
const succeededIds = new Set<number>();
for (const row of targets) {
try {
if (action === 'delete') {
await api.deleteEditorMix(row.id);
} else {
applyMixUpdate(await api.updateEditorMix(row.id, { visible: false }));
}
succeededIds.add(row.id);
} catch (error) {
failures.push({ name: row.name, reason: error instanceof Error ? error.message : 'Could not be updated' });
}
}
if (action === 'delete') {
rows = rows.filter((row) => !succeededIds.has(row.id));
// Close the ingredient panel if its mix was just deleted.
if (expandedMixId !== null && succeededIds.has(expandedMixId)) {
expandedMixId = null;
activeFormula = null;
ingredientDrafts = [];
ingredientBaseline = [];
}
}
// Keep only failures selected so the user can see/retry them.
selectedIds = new Set([...selectedIds].filter((id) => !succeededIds.has(id)));
bulkResult = { action, succeeded: succeededIds.size, failures };
bulkAction = null;
bulkRunning = false;
}
// Jump back to the first page whenever the filtered set changes. // Jump back to the first page whenever the filtered set changes.
$effect(() => { $effect(() => {
query; query;
@@ -512,6 +560,11 @@
</div> </div>
</dl> </dl>
<button class="clear-button select-toggle" class:active={selectMode} type="button" onclick={toggleSelectMode}>
<ListChecks size={16} strokeWidth={2.2} />
{selectMode ? 'Done' : 'Select'}
</button>
<button class="apply-button new-mix-button" type="button" onclick={openCreateMix} disabled={creatingMix}> <button class="apply-button new-mix-button" type="button" onclick={openCreateMix} disabled={creatingMix}>
<Plus size={16} strokeWidth={2.4} /> <Plus size={16} strokeWidth={2.4} />
New mix New mix
@@ -580,16 +633,58 @@
</div> </div>
</div> </div>
<div class="log"> {#if selectMode}
<div class="bulk-bar" transition:fade={{ duration: 120 }} aria-label="Bulk actions">
<div class="bulk-count">
<span class="bulk-badge">{selectedIds.size}</span>
<span>selected</span>
</div>
<div class="bulk-actions">
<button class="clear-button" type="button" disabled={selectedIds.size === 0} onclick={() => requestBulk('inactive')}>
<EyeOff size={16} strokeWidth={2.2} />
Mark inactive
</button>
<button class="danger-button" type="button" disabled={selectedIds.size === 0} onclick={() => requestBulk('delete')}>
<Trash2 size={16} strokeWidth={2.2} />
Delete
</button>
</div>
</div>
{/if}
<div class="log" class:select-mode={selectMode}>
<div class="log-head"> <div class="log-head">
{#if selectMode}
<label class="select-cell">
<input
type="checkbox"
checked={allPageSelected}
indeterminate={somePageSelected}
onchange={toggleSelectAll}
aria-label="Select all mixes on this page"
/>
</label>
{/if}
<SortHeader label="Client" column="client_name" controller={table} /> <SortHeader label="Client" column="client_name" controller={table} />
<SortHeader label="Mix" column="name" controller={table} /> <SortHeader label="Mix" column="name" controller={table} />
<SortHeader label="Status" column="visible" controller={table} /> <SortHeader label="Status" column="visible" controller={table} />
<span>Actions</span> <span class="actions-head">Actions</span>
</div> </div>
{#each table.rows as row (row.id)} {#each table.rows as row (row.id)}
<div class="row" class:edited={rowDirty(row)}> <div class="row" class:edited={rowDirty(row)} class:selected={selectMode && selectedIds.has(row.id)}>
{#if selectMode}
<label class="select-cell">
<span class="cell-label">Select</span>
<input
type="checkbox"
checked={selectedIds.has(row.id)}
onchange={() => toggleRowSelected(row.id)}
aria-label={`Select ${row.name}`}
/>
</label>
{/if}
<div class="client-cell"> <div class="client-cell">
<span class="cell-label">Client</span> <span class="cell-label">Client</span>
<span class="readonly-value">{row.client_name}</span> <span class="readonly-value">{row.client_name}</span>
@@ -687,7 +782,7 @@
<div class="ingredient-footer"> <div class="ingredient-footer">
<span class="footer-total">Total {ingredientTotalKg.toFixed(2)} kg</span> <span class="footer-total">Total {ingredientTotalKg.toFixed(2)} kg</span>
<button class="clear-button" type="button" onclick={addIngredient}>Add ingredient</button> <button class="clear-button" type="button" onclick={addIngredient}>Add ingredient</button>
<button class="apply-button" type="button" disabled={savingKey === `mix-save:${row.id}` || !percentBalanced} onclick={saveIngredients}> <button class="apply-button" type="button" disabled={savingKey === `mix-save:${row.id}`} onclick={saveIngredients}>
{savingKey === `mix-save:${row.id}` ? 'Saving...' : 'Save ingredients'} {savingKey === `mix-save:${row.id}` ? 'Saving...' : 'Save ingredients'}
</button> </button>
</div> </div>
@@ -735,6 +830,99 @@
</div> </div>
</div> </div>
{/if} {/if}
{#if bulkAction}
<div class="modal-backdrop" role="presentation" onclick={() => { if (!bulkRunning) bulkAction = null; }}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="bulk-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape' && !bulkRunning) bulkAction = null; }}
>
<div class="modal-icon" class:danger={bulkAction === 'delete'}>
{#if bulkAction === 'delete'}
<Trash2 size={22} strokeWidth={2.2} />
{:else}
<EyeOff size={22} strokeWidth={2.2} />
{/if}
</div>
<h2 id="bulk-title" class="modal-title">
{bulkAction === 'delete' ? 'Delete' : 'Mark inactive'}
{selectedIds.size}
{selectedIds.size === 1 ? 'mix' : 'mixes'}?
</h2>
<p class="modal-text">
{#if bulkAction === 'delete'}
This permanently removes the selected mixes and their formulas. Any mix that still has linked
products can't be deleted and will be skipped.
{:else}
The selected mixes will be hidden from the active list. You can re-activate them anytime from the
Inactive filter.
{/if}
</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" disabled={bulkRunning} onclick={() => (bulkAction = null)}>Cancel</button>
<button
type="button"
class="modal-confirm"
class:neutral={bulkAction === 'inactive'}
disabled={bulkRunning}
onclick={runBulk}
>
{bulkRunning ? 'Working…' : bulkAction === 'delete' ? 'Delete mixes' : 'Mark inactive'}
</button>
</div>
</div>
</div>
{/if}
{#if bulkResult}
<div class="modal-backdrop" role="presentation" onclick={() => (bulkResult = null)}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="bulk-result-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') bulkResult = null; }}
>
<div class="modal-icon" class:danger={bulkResult.failures.length > 0} class:ok={bulkResult.failures.length === 0}>
{#if bulkResult.failures.length === 0}
<CheckCircle2 size={22} strokeWidth={2.2} />
{:else}
<TriangleAlert size={22} strokeWidth={2.2} />
{/if}
</div>
<h2 id="bulk-result-title" class="modal-title">
{bulkResult.action === 'delete' ? 'Delete complete' : 'Update complete'}
</h2>
<p class="modal-text">
{bulkResult.succeeded}
{bulkResult.succeeded === 1 ? 'mix' : 'mixes'}
{bulkResult.action === 'delete' ? 'deleted' : 'marked inactive'}{bulkResult.failures.length
? `, ${bulkResult.failures.length} skipped`
: ''}.
</p>
{#if bulkResult.failures.length}
<ul class="result-failures">
{#each bulkResult.failures as failure}
<li>
<strong>{failure.name}</strong>
<span>{failure.reason}</span>
</li>
{/each}
</ul>
{/if}
<div class="modal-actions">
<button type="button" class="modal-confirm neutral" onclick={() => (bulkResult = null)}>Done</button>
</div>
</div>
</div>
{/if}
</AppSecondaryRailLayout> </AppSecondaryRailLayout>
<style> <style>
@@ -879,6 +1067,81 @@
flex-shrink: 0; flex-shrink: 0;
} }
.select-toggle.active {
color: var(--color-brand);
border-color: var(--color-brand);
background: var(--color-brand-tint);
}
/* Bulk action bar: appears above the table while in select mode. */
.bulk-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 0.6rem 0.85rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 30%, var(--color-border));
border-radius: 0.7rem;
background: var(--color-brand-tint);
}
.bulk-count {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-secondary);
font-size: 0.88rem;
font-weight: 600;
}
.bulk-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
height: 1.6rem;
padding: 0 0.45rem;
border-radius: 999px;
background: var(--color-brand);
color: var(--color-on-brand);
font-size: 0.85rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bulk-actions {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.danger-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
min-height: 34px;
border-radius: 0.45rem;
padding: 0.45rem 0.65rem;
font-size: 0.86rem;
font-weight: 650;
color: var(--color-on-brand, #fff);
background: var(--color-error);
border: 1px solid var(--color-error);
cursor: pointer;
}
.danger-button:hover {
background: color-mix(in srgb, var(--color-error) 88%, black);
}
.danger-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.facts { .facts {
display: flex; display: flex;
gap: 1.35rem; gap: 1.35rem;
@@ -1177,24 +1440,47 @@
border-color: var(--color-brand); border-color: var(--color-brand);
} }
/* The table is one grid that owns the column tracks; the header and every row
are subgrids that share those exact tracks. This is what keeps the headers
lined up with the fields below — separate grids would each size their own
`auto` Actions column from their own content and drift apart. */
.log { .log {
display: flex; display: grid;
flex-direction: column; grid-template-columns:
minmax(150px, 1fr)
minmax(200px, 1.6fr)
minmax(96px, 0.5fr)
minmax(198px, auto);
column-gap: 0.55rem;
row-gap: 0;
background: var(--color-bg-surface); background: var(--color-bg-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 0.9rem; border-radius: 0.9rem;
overflow: hidden; overflow: hidden;
} }
/* A leading checkbox column appears in select mode; subgrid rows pick it up
automatically, so header and rows stay aligned. */
.log.select-mode {
grid-template-columns:
2rem
minmax(150px, 1fr)
minmax(200px, 1.6fr)
minmax(96px, 0.5fr)
minmax(198px, auto);
}
.log-head,
.row,
.ingredient-panel,
.empty {
grid-column: 1 / -1;
}
.log-head, .log-head,
.row { .row {
display: grid; display: grid;
grid-template-columns: grid-template-columns: subgrid;
minmax(170px, 1fr)
minmax(220px, 1.6fr)
minmax(110px, 0.5fr)
minmax(198px, auto);
gap: 0.55rem;
align-items: center; align-items: center;
} }
@@ -1212,6 +1498,17 @@
letter-spacing: 0; letter-spacing: 0;
} }
/* Line the header labels and read-only cell text up with the input text,
which sits one input-padding (0.5rem) in from the cell's left edge. The
Actions header instead hugs the right, above the right-aligned buttons. */
.log-head :global(.sort-header) {
padding-left: 0.5rem;
}
.actions-head {
text-align: right;
}
.row { .row {
padding: 0.58rem 0.85rem; padding: 0.58rem 0.85rem;
border-bottom: 1px solid var(--color-divider); border-bottom: 1px solid var(--color-divider);
@@ -1226,10 +1523,31 @@
background: var(--color-brand-tint); background: var(--color-brand-tint);
} }
.row.selected {
background: color-mix(in srgb, var(--color-brand) 9%, var(--color-bg-surface));
}
/* Leading checkbox cell (header select-all + per-row select). */
.select-cell {
display: flex;
align-items: center;
justify-content: center;
min-height: 34px;
}
.select-cell input {
width: 1.05rem;
min-height: 1.05rem;
margin: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
.readonly-value { .readonly-value {
display: flex; display: flex;
align-items: center; align-items: center;
min-height: 34px; min-height: 34px;
padding-left: 0.5rem;
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: 0.88rem; font-size: 0.88rem;
font-weight: 650; font-weight: 650;
@@ -1258,7 +1576,7 @@
justify-content: flex-start; justify-content: flex-start;
gap: 0.3rem; gap: 0.3rem;
min-height: 34px; min-height: 34px;
padding: 0.25rem 0; padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 0.42rem; border-radius: 0.42rem;
background: transparent; background: transparent;
@@ -1418,11 +1736,21 @@
} }
@media (max-width: 1180px) { @media (max-width: 1180px) {
/* Drop the shared grid and stack each row as its own card. */
.log,
.log.select-mode {
display: flex;
flex-direction: column;
grid-template-columns: none;
}
.log-head { .log-head {
display: none; display: none;
} }
.row { .row,
.log.select-mode .row {
display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1430,6 +1758,12 @@
display: block; display: block;
} }
/* Stacked card layout: show the checkbox inline with its label. */
.select-cell {
justify-content: flex-start;
gap: 0.5rem;
}
.row-actions { .row-actions {
justify-content: flex-start; justify-content: flex-start;
} }
@@ -1516,6 +1850,16 @@
color: var(--color-warning-text); color: var(--color-warning-text);
} }
.modal-icon.danger {
background: var(--color-error-tint, color-mix(in srgb, var(--color-error) 14%, transparent));
color: var(--color-error);
}
.modal-icon.ok {
background: color-mix(in srgb, var(--color-success) 14%, transparent);
color: var(--color-success);
}
.modal-title, .modal-title,
.modal-text { .modal-text {
margin: 0; margin: 0;
@@ -1573,9 +1917,58 @@
background: color-mix(in srgb, var(--color-error) 85%, black); background: color-mix(in srgb, var(--color-error) 85%, black);
} }
/* Non-destructive confirm (mark inactive / acknowledge result). */
.modal-confirm.neutral {
background: var(--color-brand);
border-color: var(--color-brand);
color: var(--color-on-brand);
}
.modal-confirm.neutral:hover {
background: color-mix(in srgb, var(--color-brand) 88%, black);
}
.modal-cancel:disabled,
.modal-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.modal-cancel:focus-visible, .modal-cancel:focus-visible,
.modal-confirm:focus-visible { .modal-confirm:focus-visible {
outline: 3px solid var(--color-brand); outline: 3px solid var(--color-brand);
outline-offset: 2px; outline-offset: 2px;
} }
/* Per-mix reasons shown in the result modal when some rows were skipped. */
.result-failures {
display: grid;
gap: 0.4rem;
max-height: 11rem;
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
.result-failures li {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.5rem 0.65rem;
border: 1px solid color-mix(in srgb, var(--color-error) 28%, var(--color-border));
border-radius: 0.55rem;
background: color-mix(in srgb, var(--color-error) 7%, var(--color-bg-surface));
}
.result-failures strong {
font-size: 0.9rem;
font-weight: 700;
color: var(--color-text-primary);
}
.result-failures span {
font-size: 0.82rem;
color: var(--color-text-secondary);
}
</style> </style>
+57 -12
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';
@@ -21,6 +22,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 +32,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 +60,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 +95,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 +114,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 +140,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 +170,39 @@
(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);
}) })
); );
// Categories the user has explicitly created via the combobox this session.
// Tracked separately from row values so a freshly-created category stays
// available to every row even before it's been saved to (or assigned on) any
// ingredient — otherwise it would vanish as soon as you moved off the row.
let createdCategories = $state<string[]>([]);
function registerCategory(category: string) {
const name = category.trim();
if (!name) return;
if (createdCategories.some((existing) => existing.toLowerCase() === name.toLowerCase())) return;
createdCategories = [...createdCategories, name];
}
// 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()),
...createdCategories.map((value) => value.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 +310,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>
<CategoryCombobox bind:value={newIngredient.category} options={knownCategories} placeholder="e.g. Grains" inputId="new-ingredient-category" oncreate={registerCategory} />
</label>
<label> <label>
<span>Rounding</span> <span>Rounding</span>
<select bind:value={newIngredient.rounding_decimals}> <select bind:value={newIngredient.rounding_decimals}>
@@ -325,6 +363,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 +380,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>
<CategoryCombobox bind:value={row.draft_category} options={knownCategories} placeholder="—" ariaLabel={`Category for ${row.name}`} oncreate={registerCategory} />
</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" />
@@ -832,13 +876,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;
+7 -36
View File
@@ -12,13 +12,11 @@
import ThroughputSuccessOverlay from '$lib/components/throughput/ThroughputSuccessOverlay.svelte'; import ThroughputSuccessOverlay from '$lib/components/throughput/ThroughputSuccessOverlay.svelte';
import ThroughputSummary from '$lib/components/throughput/ThroughputSummary.svelte'; import ThroughputSummary from '$lib/components/throughput/ThroughputSummary.svelte';
import { import {
MIX_RANGES,
addDays, addDays,
ausToday, ausToday,
buildConfetti, buildConfetti,
compareDate, compareDate,
compareText, compareText,
isStockEntry,
startOfWeekMonday, startOfWeekMonday,
toISODate, toISODate,
type SortDirection, type SortDirection,
@@ -71,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);
@@ -256,11 +236,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.';
@@ -431,15 +410,13 @@
return norm.includes('phf') && norm.includes('horse'); return norm.includes('phf') && norm.includes('horse');
} }
let mixRangeKey = $state<(typeof MIX_RANGES)[number]['key']>('4w'); // Today's split only: Horse Mix (PHF Horsemix) vs Grain Mix (everyone else).
const mixRange = $derived(MIX_RANGES.find((r) => r.key === mixRangeKey) ?? MIX_RANGES[1]);
const mixTotals = $derived.by(() => { const mixTotals = $derived.by(() => {
const cutoff = toISODate(addDays(ausToday(), -(mixRange.days - 1))); const todayStr = toISODate(ausToday());
let horse = 0; let horse = 0;
let grain = 0; let grain = 0;
for (const entry of statsEntries) { for (const entry of statsEntries) {
if (entry.production_date < cutoff) continue; if (entry.production_date !== todayStr) continue;
const kg = entry.calculated_kg || 0; const kg = entry.calculated_kg || 0;
const client = entry.product_id != null ? productClientById.get(entry.product_id) ?? null : null; const client = entry.product_id != null ? productClientById.get(entry.product_id) ?? null : null;
if (isHorseMixClient(client)) horse += kg; if (isHorseMixClient(client)) horse += kg;
@@ -499,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);
} }
@@ -531,7 +504,6 @@
{weekRangeLabel} {weekRangeLabel}
{heroStats} {heroStats}
{mixTotals} {mixTotals}
bind:mixRangeKey
{formatDate} {formatDate}
{formatNumber} {formatNumber}
/> />
@@ -587,7 +559,6 @@
{formatNumber} {formatNumber}
{packedMain} {packedMain}
{packedDetail} {packedDetail}
{destinationOf}
onApplyFilters={applyFilters} onApplyFilters={applyFilters}
onClearFilters={clearFilters} onClearFilters={clearFilters}
onToggleSort={toggleSort} onToggleSort={toggleSort}