Compare commits
6
Commits
1062c038e8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a4d9d77e5 | ||
|
|
dc50e0538e | ||
|
|
c9f233dc0e | ||
|
|
87878e70fc | ||
|
|
696f1e7b09 | ||
|
|
10722a65a6 |
@@ -423,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,
|
||||||
@@ -799,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,
|
||||||
@@ -846,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)
|
||||||
@@ -889,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)
|
||||||
@@ -902,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",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hunter-backend"
|
name = "hunter-backend"
|
||||||
version = "0.1.30"
|
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 = [
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ from __future__ import annotations
|
|||||||
from sqlalchemy import create_engine, select
|
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.deps import AuthSession
|
||||||
from app.api.editor import replace_editor_mix_formula
|
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
|
||||||
@@ -175,6 +178,48 @@ def test_replace_product_formula_writes_product_ingredients():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.30",
|
"version": "0.1.36",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.30",
|
"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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.30",
|
"version": "0.1.36",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 mix’s 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -386,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;
|
||||||
@@ -475,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
|
||||||
@@ -543,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>
|
||||||
@@ -698,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>
|
||||||
@@ -842,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;
|
||||||
@@ -1140,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1175,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);
|
||||||
@@ -1189,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;
|
||||||
@@ -1221,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;
|
||||||
@@ -1381,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1393,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;
|
||||||
}
|
}
|
||||||
@@ -1479,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;
|
||||||
@@ -1536,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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
buildConfetti,
|
buildConfetti,
|
||||||
compareDate,
|
compareDate,
|
||||||
compareText,
|
compareText,
|
||||||
isStockEntry,
|
|
||||||
startOfWeekMonday,
|
startOfWeekMonday,
|
||||||
toISODate,
|
toISODate,
|
||||||
type SortDirection,
|
type SortDirection,
|
||||||
@@ -70,24 +69,6 @@
|
|||||||
statsEntries = statsEntries.filter((e) => e.id !== id);
|
statsEntries = statsEntries.filter((e) => e.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The destination shown in the log: an order (with job number), stock, or a
|
|
||||||
// split across both.
|
|
||||||
function destinationOf(entry: ThroughputEntry): { label: string; detail: string | null } {
|
|
||||||
const unit = entry.quantity_type === 'bags' ? 'bags' : 'kg';
|
|
||||||
if (entry.for_order && entry.for_stock) {
|
|
||||||
const stock = entry.stock_quantity != null ? `${formatNumber(entry.stock_quantity, 1)} ${unit} to stock` : 'split';
|
|
||||||
const job = entry.job_number ? `Order ${entry.job_number}` : 'Order';
|
|
||||||
return { label: 'Split', detail: `${job} · ${stock}` };
|
|
||||||
}
|
|
||||||
if (entry.for_order) {
|
|
||||||
return { label: 'Order', detail: entry.job_number ? `Job ${entry.job_number}` : null };
|
|
||||||
}
|
|
||||||
if (isStockEntry(entry)) {
|
|
||||||
return { label: 'Stock', detail: null };
|
|
||||||
}
|
|
||||||
return { label: '—', detail: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Inline "spreadsheet" add row ──────────────────────────────
|
// ── Inline "spreadsheet" add row ──────────────────────────────
|
||||||
const today = toISODate(ausToday());
|
const today = toISODate(ausToday());
|
||||||
let nDate = $state(today);
|
let nDate = $state(today);
|
||||||
@@ -255,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.';
|
||||||
@@ -496,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);
|
||||||
}
|
}
|
||||||
@@ -583,7 +559,6 @@
|
|||||||
{formatNumber}
|
{formatNumber}
|
||||||
{packedMain}
|
{packedMain}
|
||||||
{packedDetail}
|
{packedDetail}
|
||||||
{destinationOf}
|
|
||||||
onApplyFilters={applyFilters}
|
onApplyFilters={applyFilters}
|
||||||
onClearFilters={clearFilters}
|
onClearFilters={clearFilters}
|
||||||
onToggleSort={toggleSort}
|
onToggleSort={toggleSort}
|
||||||
|
|||||||
Reference in New Issue
Block a user