v0.1.11b fixes

This commit is contained in:
2026-06-03 15:09:21 +12:00
parent cf968e802b
commit daa6e60a69
11 changed files with 304 additions and 43 deletions
+151 -4
View File
@@ -1,12 +1,21 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import or_, select from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, joinedload from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload, selectinload
from app.api.deps import AuthSession, get_auth_session from app.api.deps import AuthSession, get_auth_session
from app.db.session import get_db from app.db.session import get_db
from app.models.mix import Mix from app.models.mix import Mix
from app.models.product import Product from app.models.product import Product, ProductIngredient
from app.schemas.editor import EditorMixUpdate, EditorProductRow, EditorProductUpdate from app.models.raw_material import RawMaterial
from app.schemas.editor import (
EditorMixUpdate,
EditorProductFormulaRead,
EditorProductIngredientCreate,
EditorProductIngredientUpdate,
EditorProductRow,
EditorProductUpdate,
)
from app.services.client_access_service import has_access_level from app.services.client_access_service import has_access_level
router = APIRouter(prefix="/api/editor", tags=["editor"]) router = APIRouter(prefix="/api/editor", tags=["editor"])
@@ -30,6 +39,41 @@ def _serialize_row(product: Product) -> dict:
} }
def _serialize_product_formula(product: Product) -> dict:
ingredients = [
{
"id": ingredient.id,
"raw_material_id": ingredient.raw_material_id,
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}",
"quantity_kg": ingredient.quantity_kg,
"sort_order": ingredient.sort_order,
"notes": ingredient.notes,
}
for ingredient in sorted(product.ingredients, key=lambda item: (item.sort_order, item.raw_material.name if item.raw_material else ""))
]
return {
"id": product.id,
"tenant_id": product.tenant_id,
"client_name": product.client_name,
"name": product.name,
"mix_id": product.mix_id,
"mix_name": product.mix.name if product.mix else "",
"ingredients": ingredients,
"total_kg": round(sum(ingredient["quantity_kg"] for ingredient in ingredients), 4),
}
def _load_editor_product_formula(db: Session, *, product_id: int, tenant_id: str) -> Product | None:
return db.scalar(
select(Product)
.where(Product.id == product_id, Product.tenant_id == tenant_id)
.options(
joinedload(Product.mix),
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
)
)
def _require_editor_session( def _require_editor_session(
session: AuthSession = Depends(get_auth_session), session: AuthSession = Depends(get_auth_session),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -135,3 +179,106 @@ def update_editor_mix(
.order_by(Product.client_name, Product.name, Product.id) .order_by(Product.client_name, Product.name, Product.id)
).all() ).all()
return [_serialize_row(product) for product in products] return [_serialize_row(product) for product in products]
@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
def get_editor_product_ingredients(
product_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
return _serialize_product_formula(product)
@router.post("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead, status_code=201)
def add_editor_product_ingredient(
product_id: int,
payload: EditorProductIngredientCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Raw material not found")
next_sort_order = (
db.scalar(
select(func.coalesce(func.max(ProductIngredient.sort_order), 0)).where(ProductIngredient.product_id == product_id)
)
or 0
) + 1
db.add(
ProductIngredient(
tenant_id=session.tenant_id or "",
product_id=product_id,
raw_material_id=payload.raw_material_id,
quantity_kg=payload.quantity_kg,
sort_order=next_sort_order,
notes=payload.notes,
)
)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=400, detail="Raw material is already on this product") from exc
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
return _serialize_product_formula(product)
@router.patch("/products/{product_id}/ingredients/{ingredient_id}", response_model=EditorProductFormulaRead)
def update_editor_product_ingredient(
product_id: int,
ingredient_id: int,
payload: EditorProductIngredientUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(ProductIngredient)
.join(Product)
.where(
ProductIngredient.id == ingredient_id,
ProductIngredient.product_id == product_id,
Product.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(ingredient, field, value)
db.commit()
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
return _serialize_product_formula(product)
@router.delete("/products/{product_id}/ingredients/{ingredient_id}", response_model=EditorProductFormulaRead)
def delete_editor_product_ingredient(
product_id: int,
ingredient_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(ProductIngredient)
.join(Product)
.where(
ProductIngredient.id == ingredient_id,
ProductIngredient.product_id == product_id,
Product.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
db.delete(ingredient)
db.commit()
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
return _serialize_product_formula(product)
+35
View File
@@ -36,3 +36,38 @@ class EditorMixUpdate(BaseModel):
client_name: str | None = Field(default=None, min_length=1, max_length=255) client_name: str | None = Field(default=None, min_length=1, max_length=255)
name: str | None = Field(default=None, min_length=1, max_length=255) name: str | None = Field(default=None, min_length=1, max_length=255)
notes: str | None = Field(default=None, max_length=2000) notes: str | None = Field(default=None, max_length=2000)
class EditorProductIngredientCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
raw_material_id: int
quantity_kg: float = Field(gt=0)
notes: str | None = Field(default=None, max_length=1000)
class EditorProductIngredientUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
quantity_kg: float | None = Field(default=None, gt=0)
notes: str | None = Field(default=None, max_length=1000)
class EditorProductIngredientRead(BaseModel):
id: int
raw_material_id: int
raw_material_name: str
quantity_kg: float
sort_order: int
notes: str | None
class EditorProductFormulaRead(BaseModel):
id: int
tenant_id: str
client_name: str
name: str
mix_id: int
mix_name: str
ingredients: list[EditorProductIngredientRead]
total_kg: float
+43 -1
View File
@@ -8,7 +8,7 @@ from pathlib import Path
import re import re
from openpyxl import load_workbook from openpyxl import load_workbook
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.db.session import Base, SessionLocal, engine from app.db.session import Base, SessionLocal, engine
@@ -726,6 +726,45 @@ def _upsert_product_ingredients(
db.delete(ingredient) db.delete(ingredient)
def seed_product_ingredients_from_workbook(db) -> dict[str, int]:
"""Backfill row-specific product formulas for databases seeded before this table existed."""
try:
formula_workbook = _load_workbook("mix_quantites_per_client_per_pr")
except FileNotFoundError:
logger.info("Skipping product ingredient backfill because formula workbook is missing")
return {"formulas": 0, "products_with_formulas": 0, "backfilled": 0}
product_ingredient_rows = _read_product_ingredient_rows(formula_workbook)
if not product_ingredient_rows:
return {"formulas": 0, "products_with_formulas": 0, "backfilled": 0}
raw_material_map = {
material.name: material
for material in db.scalars(select(RawMaterial).where(RawMaterial.tenant_id == TENANT_ID)).all()
}
if not raw_material_map:
return {"formulas": len(product_ingredient_rows), "products_with_formulas": 0, "backfilled": 0}
had_product_ingredients = (
db.scalar(select(ProductIngredient.id).where(ProductIngredient.tenant_id == TENANT_ID).limit(1)) is not None
)
_upsert_product_ingredients(
db,
product_rows=[],
product_ingredient_rows=product_ingredient_rows,
raw_material_map=raw_material_map,
)
db.flush()
products_with_formulas = db.scalar(
select(func.count(func.distinct(ProductIngredient.product_id))).where(ProductIngredient.tenant_id == TENANT_ID)
)
return {
"formulas": len(product_ingredient_rows),
"products_with_formulas": int(products_with_formulas or 0),
"backfilled": 0 if had_product_ingredients else int(products_with_formulas or 0),
}
def _infer_throughput_bag_size(product: Product) -> float | None: def _infer_throughput_bag_size(product: Product) -> float | None:
if product.sale_type == "bulka": if product.sale_type == "bulka":
return None return None
@@ -1027,6 +1066,9 @@ def seed_startup_basics():
seed_client_access(db) seed_client_access(db)
seed_access(db) seed_access(db)
seed_throughput_workbook(db) seed_throughput_workbook(db)
report = seed_product_ingredients_from_workbook(db)
if report["backfilled"]:
logger.info("Product ingredients backfilled from workbook: %s", report)
db.commit() db.commit()
+2 -2
View File
@@ -287,12 +287,12 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
available_table_height = table_top - margin - strip_height - table_header_height - table_bottom_padding available_table_height = table_top - margin - strip_height - table_header_height - table_bottom_padding
row_count = max(len(session_record.lines), 1) row_count = max(len(session_record.lines), 1)
row_height = clamp(available_table_height / row_count, 16, 32) row_height = clamp(available_table_height / row_count, 16, 32)
table_font_size = clamp(row_height * 0.36, 7, 11) table_font_size = clamp(row_height * 0.44, 8.5, 12.5)
table_height = table_header_height + (row_height * row_count) table_height = table_header_height + (row_height * row_count)
table_bottom = table_top - table_height table_bottom = table_top - table_height
pdf.setFillColor(palette["muted"]) pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica-Bold", 7.5) pdf.setFont("Helvetica-Bold", 8.5)
pdf.drawString(margin + 4, table_top - 7, "RAW MATERIAL") pdf.drawString(margin + 4, table_top - 7, "RAW MATERIAL")
pdf.drawString(margin + content_width - 190, table_top - 7, "REQUIRED KG") pdf.drawString(margin + content_width - 190, table_top - 7, "REQUIRED KG")
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.11", "version": "0.1.11b",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.11", "version": "0.1.11b",
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"lucide-svelte": "^1.0.1" "lucide-svelte": "^1.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.11", "version": "0.1.11b",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+17
View File
@@ -20,6 +20,7 @@ import type {
ClientUserUpdateInput, ClientUserUpdateInput,
LoginResponse, LoginResponse,
EditorMixUpdateInput, EditorMixUpdateInput,
EditorProductFormula,
EditorProductRow, EditorProductRow,
EditorProductUpdateInput, EditorProductUpdateInput,
MixCalculatorCreateInput, MixCalculatorCreateInput,
@@ -340,6 +341,22 @@ export const api = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'client'), }, 'client'),
editorProductFormula: (productId: number) =>
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients`, {}, 'client'),
addEditorProductIngredient: (productId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients`, {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateEditorProductIngredient: (productId: number, ingredientId: number, payload: MixIngredientUpdateInput) =>
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteEditorProductIngredient: (productId: number, ingredientId: number) =>
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, {
method: 'DELETE'
}, 'client'),
productCosts: (fetcher?: ApiFetch) => productCosts: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher), cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher), scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
@@ -285,16 +285,16 @@
th { th {
background: #fff; background: #fff;
font-size: 0.6rem; font-size: 0.7rem;
} }
td { td {
font-size: 0.7rem; font-size: 0.82rem;
vertical-align: top; vertical-align: top;
} }
td strong { td strong {
font-size: 0.72rem; font-size: 0.84rem;
font-weight: 700; font-weight: 700;
color: #000; color: #000;
} }
@@ -1,7 +1,6 @@
import { import {
Calculator, Calculator,
ClipboardPenLine, ClipboardPenLine,
FlaskConical,
Gauge, Gauge,
LayoutDashboard, LayoutDashboard,
ShieldCheck, ShieldCheck,
@@ -86,7 +85,8 @@ export const throughputItem: NavItem = {
}; };
export const workingDocumentItems: NavItem[] = [ export const workingDocumentItems: NavItem[] = [
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' } // Mix Master remains available through the existing route and access logic,
// but is temporarily hidden from the sidebar.
]; ];
export const accessControlItem: NavItem = { export const accessControlItem: NavItem = {
+20
View File
@@ -241,6 +241,26 @@ export type EditorMixUpdateInput = {
notes?: string | null; notes?: string | null;
}; };
export type EditorProductIngredient = {
id: number;
raw_material_id: number;
raw_material_name: string;
quantity_kg: number;
sort_order: number;
notes: string | null;
};
export type EditorProductFormula = {
id: number;
tenant_id: string;
client_name: string;
name: string;
mix_id: number;
mix_name: string;
ingredients: EditorProductIngredient[];
total_kg: number;
};
export type Scenario = { export type Scenario = {
id: number; id: number;
name: string; name: string;
+28 -28
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api'; import { api } from '$lib/api';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import type { EditorProductRow, Mix, MixIngredient, RawMaterial } from '$lib/types'; import type { EditorProductFormula, EditorProductIngredient, EditorProductRow, RawMaterial } from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Save, Search, X } from 'lucide-svelte'; import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Save, Search, X } from 'lucide-svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -31,8 +31,8 @@
let page = $state(1); let page = $state(1);
let pageSize = $state(25); let pageSize = $state(25);
let savingKey = $state<string | null>(null); let savingKey = $state<string | null>(null);
let expandedMixId = $state<number | null>(null); let expandedProductId = $state<number | null>(null);
let activeMix = $state<Mix | null>(null); let activeFormula = $state<EditorProductFormula | null>(null);
let ingredientDrafts = $state<DraftIngredient[]>([]); let ingredientDrafts = $state<DraftIngredient[]>([]);
function toEditableRow(row: EditorProductRow): EditableRow { function toEditableRow(row: EditorProductRow): EditableRow {
@@ -52,7 +52,7 @@
} }
}); });
function ingredientToDraft(ingredient: MixIngredient): DraftIngredient { function ingredientToDraft(ingredient: EditorProductIngredient): DraftIngredient {
return { return {
id: ingredient.id, id: ingredient.id,
raw_material_id: ingredient.raw_material_id, raw_material_id: ingredient.raw_material_id,
@@ -70,9 +70,9 @@
}; };
} }
function loadIngredientDrafts(mix: Mix) { function loadIngredientDrafts(formula: EditorProductFormula) {
activeMix = mix; activeFormula = formula;
ingredientDrafts = mix.ingredients.length ? mix.ingredients.map(ingredientToDraft) : [emptyIngredient()]; ingredientDrafts = formula.ingredients.length ? formula.ingredients.map(ingredientToDraft) : [emptyIngredient()];
} }
function productDirty(row: EditableRow) { function productDirty(row: EditableRow) {
@@ -142,20 +142,20 @@
} }
async function toggleIngredients(row: EditableRow) { async function toggleIngredients(row: EditableRow) {
if (expandedMixId === row.mix_id) { if (expandedProductId === row.id) {
expandedMixId = null; expandedProductId = null;
activeMix = null; activeFormula = null;
ingredientDrafts = []; ingredientDrafts = [];
return; return;
} }
expandedMixId = row.mix_id; expandedProductId = row.id;
savingKey = `mix-load:${row.mix_id}`; savingKey = `product-load:${row.id}`;
try { try {
loadIngredientDrafts(await api.mix(row.mix_id)); loadIngredientDrafts(await api.editorProductFormula(row.id));
} catch (error) { } catch (error) {
expandedMixId = null; expandedProductId = null;
toast.error(error instanceof Error ? error.message : 'Unable to load ingredients'); toast.error(error instanceof Error ? error.message : 'Unable to load ingredients');
} finally { } finally {
savingKey = null; savingKey = null;
@@ -176,7 +176,7 @@
.map((row) => row.raw_material_id) .map((row) => row.raw_material_id)
.filter((rawMaterialId): rawMaterialId is number => rawMaterialId !== null); .filter((rawMaterialId): rawMaterialId is number => rawMaterialId !== null);
if (!activeMix) return ['Open a mix before saving ingredients.']; if (!activeFormula) return ['Open a product before saving ingredients.'];
if (!chosen.length) return ['Add at least one raw material.']; if (!chosen.length) return ['Add at least one raw material.'];
if (new Set(chosen).size !== chosen.length) return ['Each raw material can only appear once in a mix.']; if (new Set(chosen).size !== chosen.length) return ['Each raw material can only appear once in a mix.'];
@@ -194,9 +194,9 @@
toast.error(warnings[0]); toast.error(warnings[0]);
return; return;
} }
if (!activeMix) return; if (!activeFormula) return;
savingKey = `mix-save:${activeMix.id}`; savingKey = `product-save:${activeFormula.id}`;
try { try {
const cleanRows = ingredientDrafts.map((row) => ({ const cleanRows = ingredientDrafts.map((row) => ({
@@ -205,20 +205,20 @@
quantity_kg: Number(row.quantity_kg), quantity_kg: Number(row.quantity_kg),
notes: row.notes.trim() || null notes: row.notes.trim() || null
})); }));
const originalById = new Map(activeMix.ingredients.map((ingredient) => [ingredient.id, ingredient])); const originalById = new Map(activeFormula.ingredients.map((ingredient) => [ingredient.id, ingredient]));
const keptIds = new Set(cleanRows.filter((row) => row.id !== null).map((row) => row.id as number)); const keptIds = new Set(cleanRows.filter((row) => row.id !== null).map((row) => row.id as number));
for (const ingredient of activeMix.ingredients) { for (const ingredient of activeFormula.ingredients) {
const draft = cleanRows.find((row) => row.id === ingredient.id); const draft = cleanRows.find((row) => row.id === ingredient.id);
if (!keptIds.has(ingredient.id) || (draft && draft.raw_material_id !== ingredient.raw_material_id)) { if (!keptIds.has(ingredient.id) || (draft && draft.raw_material_id !== ingredient.raw_material_id)) {
await api.deleteMixIngredient(activeMix.id, ingredient.id); await api.deleteEditorProductIngredient(activeFormula.id, ingredient.id);
} }
} }
for (const row of cleanRows) { for (const row of cleanRows) {
const original = row.id === null ? null : originalById.get(row.id); const original = row.id === null ? null : originalById.get(row.id);
if (!original || original.raw_material_id !== row.raw_material_id) { if (!original || original.raw_material_id !== row.raw_material_id) {
await api.addMixIngredient(activeMix.id, { await api.addEditorProductIngredient(activeFormula.id, {
raw_material_id: row.raw_material_id, raw_material_id: row.raw_material_id,
quantity_kg: row.quantity_kg, quantity_kg: row.quantity_kg,
notes: row.notes notes: row.notes
@@ -227,14 +227,14 @@
} }
if (original.quantity_kg !== row.quantity_kg || (original.notes ?? null) !== row.notes) { if (original.quantity_kg !== row.quantity_kg || (original.notes ?? null) !== row.notes) {
await api.updateMixIngredient(activeMix.id, original.id, { await api.updateEditorProductIngredient(activeFormula.id, original.id, {
quantity_kg: row.quantity_kg, quantity_kg: row.quantity_kg,
notes: row.notes notes: row.notes
}); });
} }
} }
loadIngredientDrafts(await api.mix(activeMix.id)); loadIngredientDrafts(await api.editorProductFormula(activeFormula.id));
toast.success('Ingredients saved'); toast.success('Ingredients saved');
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to save ingredients'); toast.error(error instanceof Error ? error.message : 'Unable to save ingredients');
@@ -513,7 +513,7 @@
<div class="row-actions"> <div class="row-actions">
<button class="clear-button" type="button" onclick={() => toggleIngredients(row)}> <button class="clear-button" type="button" onclick={() => toggleIngredients(row)}>
<FlaskConical size={16} strokeWidth={2.2} /> <FlaskConical size={16} strokeWidth={2.2} />
{expandedMixId === row.mix_id ? 'Close ingredients' : savingKey === `mix-load:${row.mix_id}` ? 'Loading...' : 'Ingredients'} {expandedProductId === row.id ? 'Close ingredients' : savingKey === `product-load:${row.id}` ? 'Loading...' : 'Ingredients'}
</button> </button>
<button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}> <button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}>
<Save size={16} strokeWidth={2.4} /> <Save size={16} strokeWidth={2.4} />
@@ -525,12 +525,12 @@
</div> </div>
</div> </div>
{#if expandedMixId === row.mix_id} {#if expandedProductId === row.id}
<div class="ingredient-panel" transition:fade={{ duration: 120 }}> <div class="ingredient-panel" transition:fade={{ duration: 120 }}>
<div class="ingredient-head"> <div class="ingredient-head">
<div> <div>
<span>Ingredients</span> <span>Ingredients</span>
<strong>{activeMix?.client_name ?? row.mix_client_name} / {activeMix?.name ?? row.mix_name}</strong> <strong>{activeFormula?.client_name ?? row.client_name} / {activeFormula?.name ?? row.name}</strong>
</div> </div>
<div class="ingredient-summary"> <div class="ingredient-summary">
<span>{ingredientDrafts.length} rows</span> <span>{ingredientDrafts.length} rows</span>
@@ -564,8 +564,8 @@
<div class="ingredient-footer"> <div class="ingredient-footer">
<button class="clear-button" type="button" onclick={addIngredient}>Add ingredient</button> <button class="clear-button" type="button" onclick={addIngredient}>Add ingredient</button>
<button class="apply-button" type="button" disabled={savingKey === `mix-save:${row.mix_id}`} onclick={saveIngredients}> <button class="apply-button" type="button" disabled={savingKey === `product-save:${row.id}`} onclick={saveIngredients}>
{savingKey === `mix-save:${row.mix_id}` ? 'Saving...' : 'Save ingredients'} {savingKey === `product-save:${row.id}` ? 'Saving...' : 'Save ingredients'}
</button> </button>
</div> </div>
</div> </div>