v0.1.28 - Version bump and editor/throughput updates

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 22:51:29 +12:00
co-authored by Claude Opus 4.8
parent 3f8279af10
commit 1dd48bc771
10 changed files with 395 additions and 72 deletions
+37 -13
View File
@@ -86,12 +86,17 @@ def _serialize_product_formula(product: Product) -> dict:
def _serialize_mix_row(mix: Mix, *, visible_count: int, product_count: int) -> dict: def _serialize_mix_row(mix: Mix, *, visible_count: int, product_count: int) -> dict:
# Status is product-driven once a mix has products (Active = at least one
# visible product). A mix with no products yet has nothing to fan out to, so
# it falls back to its own `status` column — that's what lets a brand-new
# mix read as Active instead of being stuck Inactive and hidden.
visible = visible_count > 0 if product_count > 0 else mix.status == "active"
return { return {
"id": mix.id, "id": mix.id,
"tenant_id": mix.tenant_id, "tenant_id": mix.tenant_id,
"client_name": mix.client_name, "client_name": mix.client_name,
"name": mix.name, "name": mix.name,
"visible": visible_count > 0, "visible": visible,
"product_count": product_count, "product_count": product_count,
"visible_product_count": visible_count, "visible_product_count": visible_count,
"notes": mix.notes, "notes": mix.notes,
@@ -319,6 +324,9 @@ def create_editor_mix(
client_name=payload.client_name.strip(), client_name=payload.client_name.strip(),
name=payload.name.strip(), name=payload.name.strip(),
notes=payload.notes, notes=payload.notes,
# Active by default so a freshly created mix shows under the default
# "Active" filter rather than being hidden until it has a visible product.
status="active",
) )
db.add(mix) db.add(mix)
db.flush() db.flush()
@@ -348,27 +356,43 @@ def update_editor_mix(
raise HTTPException(status_code=404, detail="Mix not found") raise HTTPException(status_code=404, detail="Mix not found")
updates = payload.model_dump(exclude_unset=True) updates = payload.model_dump(exclude_unset=True)
# `visible` is a virtual field: it fans out to the visibility of every product # `visible` is a virtual field: for a mix with products it fans out to the
# under the mix rather than mapping to a mix column. # visibility of every product; for a product-less mix it maps to the mix's
# own `status` column so the toggle still persists.
visible = updates.pop("visible", None) visible = updates.pop("visible", None)
product_total = (
db.scalar(
select(func.count())
.select_from(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
)
or 0
)
before = {field: getattr(mix, field) for field in updates} before = {field: getattr(mix, field) for field in updates}
if visible is not None: if visible is not None:
visible_before = db.scalar( if product_total > 0:
select(func.count()) visible_before = db.scalar(
.select_from(Product) select(func.count())
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible) .select_from(Product)
) .where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible)
before["visible"] = bool(visible_before) )
before["visible"] = bool(visible_before)
else:
before["visible"] = mix.status == "active"
for field, value in updates.items(): for field, value in updates.items():
setattr(mix, field, value) setattr(mix, field, value)
if visible is not None: if visible is not None:
for product in db.scalars( if product_total > 0:
select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id) for product in db.scalars(
).all(): select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
product.visible = visible ).all():
product.visible = visible
else:
mix.status = "active" if visible else "inactive"
after = dict(updates) after = dict(updates)
if visible is not None: if visible is not None:
+61 -2
View File
@@ -66,9 +66,15 @@ class MigrationReport:
created_tables: tuple[str, ...] = () created_tables: tuple[str, ...] = ()
added_columns: tuple[str, ...] = () added_columns: tuple[str, ...] = ()
synced_tenant_rows: dict[str, int] = field(default_factory=dict) synced_tenant_rows: dict[str, int] = field(default_factory=dict)
resynced_sequences: tuple[str, ...] = ()
def has_changes(self) -> bool: def has_changes(self) -> bool:
return bool(self.created_tables or self.added_columns or self.synced_tenant_rows) return bool(
self.created_tables
or self.added_columns
or self.synced_tenant_rows
or self.resynced_sequences
)
def summary(self) -> str: def summary(self) -> str:
parts: list[str] = [] parts: list[str] = []
@@ -79,6 +85,8 @@ class MigrationReport:
if self.synced_tenant_rows: if self.synced_tenant_rows:
counts = ", ".join(f"{table}={count}" for table, count in sorted(self.synced_tenant_rows.items())) counts = ", ".join(f"{table}={count}" for table, count in sorted(self.synced_tenant_rows.items()))
parts.append(f"synced tenant rows: {counts}") parts.append(f"synced tenant rows: {counts}")
if self.resynced_sequences:
parts.append(f"resynced sequences: {', '.join(self.resynced_sequences)}")
return "; ".join(parts) if parts else "schema already up to date" return "; ".join(parts) if parts else "schema already up to date"
@@ -435,7 +443,58 @@ def sync_product_visibility(engine: Engine) -> int:
return result.rowcount or 0 return result.rowcount or 0
def resync_identity_sequences(engine: Engine) -> tuple[str, ...]:
"""Realign Postgres identity sequences with each table's current MAX(id).
After a bulk import that carries original primary keys across (the SQLite →
Postgres migration inserts rows with their existing ids), every table's
sequence still points at its starting value. The next INSERT then reuses an
id that already exists and fails with ``duplicate key value violates unique
constraint`` — which is why creating a new mix/ingredient/product saved fine
on SQLite but not on production Postgres.
This advances each ``id`` sequence to MAX(id) so the next INSERT continues
cleanly. It is a no-op on SQLite and idempotent on Postgres, so it is safe to
run on every startup. A per-table failure is skipped rather than aborting the
whole boot.
"""
if engine.dialect.name != "postgresql":
return ()
resynced: list[str] = []
inspector = inspect(engine)
with engine.begin() as connection:
for table_name in inspector.get_table_names():
if not any(column["name"] == "id" for column in inspector.get_columns(table_name)):
continue
try:
sequence = connection.execute(
text("SELECT pg_get_serial_sequence(:table, 'id')"),
{"table": table_name},
).scalar()
if not sequence:
continue
max_id = connection.execute(text(f'SELECT MAX(id) FROM "{table_name}"')).scalar()
if max_id is None:
continue
connection.execute(
text("SELECT setval(:sequence, :value, true)"),
{"sequence": sequence, "value": int(max_id)},
)
resynced.append(table_name)
except Exception:
# A single problematic table must not block startup; the others
# still get realigned.
continue
return tuple(resynced)
def bootstrap_schema(engine: Engine, metadata: MetaData) -> MigrationReport: def bootstrap_schema(engine: Engine, metadata: MetaData) -> MigrationReport:
created_tables = ensure_metadata_tables(engine, metadata) created_tables = ensure_metadata_tables(engine, metadata)
added_columns = ensure_tenant_columns(engine) + ensure_legacy_columns(engine) added_columns = ensure_tenant_columns(engine) + ensure_legacy_columns(engine)
return MigrationReport(created_tables=created_tables, added_columns=added_columns) resynced_sequences = resync_identity_sequences(engine)
return MigrationReport(
created_tables=created_tables,
added_columns=added_columns,
resynced_sequences=resynced_sequences,
)
+1
View File
@@ -117,6 +117,7 @@ def ensure_database_ready() -> MigrationReport:
**tenant_sync_report, **tenant_sync_report,
**({"products_visibility": hidden_product_count} if hidden_product_count else {}), **({"products_visibility": hidden_product_count} if hidden_product_count else {}),
}, },
resynced_sequences=schema_report.resynced_sequences,
) )
logger.info("Database startup checks complete: %s", report.summary()) logger.info("Database startup checks complete: %s", report.summary())
_database_ready = True _database_ready = True
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hunter-backend" name = "hunter-backend"
version = "0.1.27" version = "0.1.28"
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 = [
+19 -11
View File
@@ -457,19 +457,27 @@ def migrate():
# Re-enable FK checks # Re-enable FK checks
dst_conn.execute(text("SET session_replication_role = 'origin'")) dst_conn.execute(text("SET session_replication_role = 'origin'"))
# Reset auto-increment sequences # Reset auto-increment sequences for EVERY table with an id sequence — not
# just the ones we copied above — so later inserts (e.g. editor_change_events)
# don't collide with pre-existing ids. Leaving a sequence behind MAX(id) is
# what makes "create new mix" fail with a duplicate-key error on Postgres.
print("\n Resetting sequences...") print("\n Resetting sequences...")
with dst.begin() as conn: with dst.begin() as conn:
for table_name in TABLE_ORDER: all_tables = inspect(dst).get_table_names()
try: for table_name in all_tables:
conn.execute(text( sequence = conn.execute(text(
f"SELECT setval(" "SELECT pg_get_serial_sequence(:table, 'id')"
f" pg_get_serial_sequence('{table_name}', 'id')," ), {"table": table_name}).scalar()
f" COALESCE((SELECT MAX(id) FROM {table_name}), 1)" if not sequence:
f")" continue
)) max_id = conn.execute(text(f'SELECT MAX(id) FROM "{table_name}"')).scalar()
except Exception: if max_id is None:
pass continue
conn.execute(text("SELECT setval(:sequence, :value, true)"), {
"sequence": sequence,
"value": int(max_id),
})
print(f" SEQ {table_name:<45} -> {max_id}")
print(f"\n Migration complete. {sum(totals.values())} rows across {len(totals)} tables.") print(f"\n Migration complete. {sum(totals.values())} rows across {len(totals)} tables.")
return totals return totals
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.27", "version": "0.1.28",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.27", "version": "0.1.28",
"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.27", "version": "0.1.28",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+8 -6
View File
@@ -133,21 +133,23 @@
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null); const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null); const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null);
const visibleAccessControlItem = $derived(sessionCanOpenClientAccess($clientSession) ? accessControlItem : null); const visibleAccessControlItem = $derived(sessionCanOpenClientAccess($clientSession) ? accessControlItem : null);
// Grouped desktop rail: Dashboard, a collapsible "Operations" family (costing // Grouped desktop rail: Dashboard, a collapsible "Operations" family (mix
// tools plus throughput), then the standalone ordering/insights modules. Built // calculator plus throughput), a "Costing" family (product costing and the
// from the same access-filtered items, so a role only ever sees the families it // editors), then the standalone ordering/insights modules. Built from the same
// may open. // access-filtered items, so a role only ever sees the families it may open.
const navEntries = $derived( const navEntries = $derived(
buildClientNavEntries({ buildClientNavEntries({
dashboard: visibleDashboardItem, dashboard: visibleDashboardItem,
costing: [ operations: [
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []), ...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleThroughputItem ? [visibleThroughputItem] : [])
],
costing: [
...(visibleProductCostingItem ? [visibleProductCostingItem] : []), ...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []), ...(visibleEditorItem ? [visibleEditorItem] : []),
...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []), ...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []),
...visibleWorkingDocumentItems ...visibleWorkingDocumentItems
], ],
throughput: visibleThroughputItem,
ordering: visibleOrderingEntry, ordering: visibleOrderingEntry,
reporting: visibleReportingItem reporting: visibleReportingItem
}) })
@@ -292,15 +292,14 @@ export const baseSearchItems: SearchItem[] = [
* Callers pass only the modules the current session may see; empty families * Callers pass only the modules the current session may see; empty families
* collapse away so a role with one costing tool never gets an empty group. * collapse away so a role with one costing tool never gets an empty group.
* *
* Workflow-family layout: Dashboard, then an "Operations" group (the calculator, * Workflow-family layout: Dashboard, then an "Operations" group (the mix
* costing, editor, master tools, and throughput), then Ordering and Insights * calculator and throughput) and a "Costing" group (product costing, mix and
* modules. Costing tools live inside Operations for the time being until they * ingredient editors, and master tools), then Ordering and Insights modules.
* grow into a family of their own.
*/ */
export function buildClientNavEntries(visible: { export function buildClientNavEntries(visible: {
dashboard?: NavItem | null; dashboard?: NavItem | null;
operations: NavItem[];
costing: NavItem[]; costing: NavItem[];
throughput?: NavItem | null;
ordering?: NavEntry | null; ordering?: NavEntry | null;
reporting?: NavItem | null; reporting?: NavItem | null;
}): NavEntry[] { }): NavEntry[] {
@@ -310,14 +309,17 @@ export function buildClientNavEntries(visible: {
entries.push({ kind: 'item', item: visible.dashboard }); entries.push({ kind: 'item', item: visible.dashboard });
} }
const operationsChildren = [ if (visible.operations.length) {
...visible.costing,
...(visible.throughput ? [visible.throughput] : [])
];
if (operationsChildren.length) {
entries.push({ entries.push({
kind: 'group', kind: 'group',
group: { id: 'operations', label: 'Operations', icon: Layers, children: operationsChildren } group: { id: 'operations', label: 'Operations', icon: Layers, children: visible.operations }
});
}
if (visible.costing.length) {
entries.push({
kind: 'group',
group: { id: 'costing', label: 'Costing', icon: BadgeDollarSign, children: visible.costing }
}); });
} }
@@ -408,15 +410,15 @@ export function pageMeta(pathname: string): PageMeta {
} }
if (pathname.startsWith('/product-costing')) { if (pathname.startsWith('/product-costing')) {
return { title: productCostingItem.label, category: 'Operations', icon: productCostingItem.icon }; return { title: productCostingItem.label, category: 'Costing', icon: productCostingItem.icon };
} }
if (pathname.startsWith('/editor')) { if (pathname.startsWith('/editor')) {
return { title: editorItem.label, category: 'Operations', icon: editorItem.icon }; return { title: editorItem.label, category: 'Costing', icon: editorItem.icon };
} }
if (pathname.startsWith('/ingredients')) { if (pathname.startsWith('/ingredients')) {
return { title: ingredientsEditorItem.label, category: 'Operations', icon: ingredientsEditorItem.icon }; return { title: ingredientsEditorItem.label, category: 'Costing', icon: ingredientsEditorItem.icon };
} }
if (pathname.startsWith('/raw-materials')) { if (pathname.startsWith('/raw-materials')) {
+249 -22
View File
@@ -12,7 +12,7 @@
EditorMixUpdateInput, EditorMixUpdateInput,
RawMaterial RawMaterial
} from '$lib/types'; } from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, History, ListFilter, Plus, Save, Search, X } from 'lucide-svelte'; import { ChevronLeft, ChevronRight, FlaskConical, History, ListFilter, Plus, Save, Search, TriangleAlert, X } from 'lucide-svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
let { data } = $props(); let { data } = $props();
@@ -38,11 +38,18 @@
let expandedMixId = $state<number | null>(null); let expandedMixId = $state<number | null>(null);
let activeFormula = $state<EditorResolvedMixFormula | null>(null); let activeFormula = $state<EditorResolvedMixFormula | null>(null);
let ingredientDrafts = $state<DraftIngredient[]>([]); let ingredientDrafts = $state<DraftIngredient[]>([]);
// Snapshot of the loaded formula, so we can tell whether the open panel has
// unsaved edits before letting the user leave it.
let ingredientBaseline = $state<DraftIngredient[]>([]);
// The reference total used to convert between % and kg. Editing a kg cell // The reference total used to convert between % and kg. Editing a kg cell
// redefines it (kg is the source of truth); editing the Total mix field // redefines it (kg is the source of truth); editing the Total mix field
// rescales every row's kg from its %. // rescales every row's kg from its %.
let totalReference = $state(0); let totalReference = $state(0);
// The row the user is trying to open/close while the current panel has unsaved
// ingredient edits (null = no pending switch). Drives the confirm dialog.
let pendingRow = $state<EditableRow | null>(null);
// The mix whose change history is open in the modal (null = closed). // The mix whose change history is open in the modal (null = closed).
let historyMix = $state<EditableRow | null>(null); let historyMix = $state<EditableRow | null>(null);
@@ -92,6 +99,8 @@
activeFormula = formula; activeFormula = formula;
totalReference = formula.total_kg || 0; totalReference = formula.total_kg || 0;
ingredientDrafts = formula.ingredients.length ? formula.ingredients.map(ingredientToDraft) : [emptyIngredient()]; ingredientDrafts = formula.ingredients.length ? formula.ingredients.map(ingredientToDraft) : [emptyIngredient()];
// Reset the baseline so the freshly loaded (or just-saved) formula reads clean.
ingredientBaseline = ingredientDrafts.map((row) => ({ ...row }));
} }
// kg overrides %: recompute the reference total from the kg column, then // kg overrides %: recompute the reference total from the kg column, then
@@ -105,16 +114,46 @@
})); }));
} }
// % overrides kg: convert this row's percentage to kg against the locked // Editing a row's % sets it to that share of the mix and spreads the rest
// reference total. Other rows are untouched, so the percentage total will // across the other rows in their existing proportions, so the column always
// read off 100 until the rest are adjusted (the save guard enforces 100%). // re-totals 100 and kg stays the canonical value. The total mix kg is held
// constant; only the split moves. Without this rebalance a single % edit
// would leave the total off 100 and the save guard would block the change.
function applyPercentEdit(index: number) { function applyPercentEdit(index: number) {
const total = totalReference; const total = Number(totalReference || 0);
ingredientDrafts = ingredientDrafts.map((row, rowIndex) => const rows = ingredientDrafts;
rowIndex === index const count = rows.length;
? { ...row, quantity_kg: total > 0 ? round4((Number(row.percentage || 0) / 100) * total) : 0 }
: row // Clamp to a sane 0100 share.
let target = Number(rows[index].percentage || 0);
if (!Number.isFinite(target) || target < 0) target = 0;
if (target > 100) target = 100;
// A single ingredient is always the whole mix.
if (count === 1) {
ingredientDrafts = rows.map((row) => ({
...row,
percentage: 100,
quantity_kg: round4(total)
}));
return;
}
const remaining = 100 - target;
const otherOldSum = rows.reduce(
(sum, row, rowIndex) => (rowIndex === index ? sum : sum + Number(row.percentage || 0)),
0
); );
ingredientDrafts = rows.map((row, rowIndex) => {
if (rowIndex === index) {
return { ...row, percentage: round4(target), quantity_kg: round4((target / 100) * total) };
}
// Keep the other rows' relative split; if they were all at 0, share evenly.
const share =
otherOldSum > 0 ? (Number(row.percentage || 0) / otherOldSum) * remaining : remaining / (count - 1);
return { ...row, percentage: round4(share), quantity_kg: round4((share / 100) * total) };
});
} }
// Editing the Total mix (kg) rescales every row's kg from its current %. // Editing the Total mix (kg) rescales every row's kg from its current %.
@@ -211,11 +250,28 @@
} }
} }
// Gate panel switches: if the open formula has unsaved edits, ask before
// leaving it (opening another row, or closing this one, both discard them).
function requestToggleIngredients(row: EditableRow) {
if (expandedMixId !== null && ingredientsDirty) {
pendingRow = row;
return;
}
toggleIngredients(row);
}
function confirmDiscardChanges() {
const target = pendingRow;
pendingRow = null;
if (target) toggleIngredients(target);
}
async function toggleIngredients(row: EditableRow) { async function toggleIngredients(row: EditableRow) {
if (expandedMixId === row.id) { if (expandedMixId === row.id) {
expandedMixId = null; expandedMixId = null;
activeFormula = null; activeFormula = null;
ingredientDrafts = []; ingredientDrafts = [];
ingredientBaseline = [];
return; return;
} }
@@ -351,6 +407,22 @@
); );
const percentBalanced = $derived(Math.abs(percentTotal - 100) <= 0.1); const percentBalanced = $derived(Math.abs(percentTotal - 100) <= 0.1);
// True when the open ingredient panel differs from the formula we loaded.
// kg is the canonical value, so comparing kg (plus raw material and notes)
// captures both % and kg edits.
const ingredientsDirty = $derived.by(() => {
if (ingredientBaseline.length !== ingredientDrafts.length) return true;
return ingredientDrafts.some((row, index) => {
const base = ingredientBaseline[index];
return (
!base ||
row.raw_material_id !== base.raw_material_id ||
round4(Number(row.quantity_kg || 0)) !== round4(Number(base.quantity_kg || 0)) ||
(row.notes ?? '') !== (base.notes ?? '')
);
});
});
// 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;
@@ -456,11 +528,17 @@
<div class="create-fields"> <div class="create-fields">
<label> <label>
<span>Client</span> <span>Client <span class="req">*</span></span>
<input bind:value={newMixClient} list="editor-client-options" placeholder="Client name" /> <input
bind:value={newMixClient}
list="editor-client-options"
placeholder="Search clients or type a new one"
autocomplete="off"
/>
<small class="field-hint">Pick an existing client from the list, or type a new client name.</small>
</label> </label>
<label> <label>
<span>Mix name</span> <span>Mix name <span class="req">*</span></span>
<!-- svelte-ignore a11y_autofocus --> <!-- svelte-ignore a11y_autofocus -->
<input bind:value={newMixName} placeholder="Mix name" autofocus /> <input bind:value={newMixName} placeholder="Mix name" autofocus />
</label> </label>
@@ -531,7 +609,7 @@
</div> </div>
<div class="row-actions"> <div class="row-actions">
<button class="clear-button" type="button" onclick={() => toggleIngredients(row)}> <button class="clear-button" type="button" onclick={() => requestToggleIngredients(row)}>
<FlaskConical size={16} strokeWidth={2.2} /> <FlaskConical size={16} strokeWidth={2.2} />
{expandedMixId === row.id ? 'Close ingredients' : savingKey === `mix-load:${row.id}` ? 'Loading...' : 'Ingredients'} {expandedMixId === row.id ? 'Close ingredients' : savingKey === `mix-load:${row.id}` ? 'Loading...' : 'Ingredients'}
</button> </button>
@@ -577,7 +655,7 @@
<div class="ingredient-grid"> <div class="ingredient-grid">
<span class="grid-label">Raw material</span> <span class="grid-label">Raw material</span>
<span class="grid-label">%</span> <span class="grid-label">%</span>
<span class="grid-label">kg</span> <span class="grid-label">kg (auto)</span>
<span class="grid-label">Notes</span> <span class="grid-label">Notes</span>
<span class="grid-label">Remove</span> <span class="grid-label">Remove</span>
@@ -595,14 +673,12 @@
step="0.0001" step="0.0001"
aria-label={`Percentage for ${rawMaterialName(ingredient.raw_material_id)}`} aria-label={`Percentage for ${rawMaterialName(ingredient.raw_material_id)}`}
/> />
<input <!-- kg is derived from % against the Total mix anchor and stays the
bind:value={ingredient.quantity_kg} canonical saved value; it is read-only here so % is the single
onchange={applyKgEdit} point of entry (the two cells used to conflict). -->
type="number" <span class="kg-readout" aria-label={`Quantity for ${rawMaterialName(ingredient.raw_material_id)}`}>
min="0" {Number(ingredient.quantity_kg || 0).toFixed(2)}
step="0.0001" </span>
aria-label={`Quantity for ${rawMaterialName(ingredient.raw_material_id)}`}
/>
<input bind:value={ingredient.notes} aria-label={`Notes for ${rawMaterialName(ingredient.raw_material_id)}`} /> <input bind:value={ingredient.notes} aria-label={`Notes for ${rawMaterialName(ingredient.raw_material_id)}`} />
<button class="clear-button remove-button" type="button" onclick={() => removeIngredient(index)}>Remove</button> <button class="clear-button remove-button" type="button" onclick={() => removeIngredient(index)}>Remove</button>
{/each} {/each}
@@ -635,6 +711,30 @@
onClose={() => (historyMix = null)} onClose={() => (historyMix = null)}
/> />
{/if} {/if}
{#if pendingRow}
<div class="modal-backdrop" role="presentation" onclick={() => (pendingRow = null)}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="unsaved-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') pendingRow = null; }}
>
<div class="modal-icon"><TriangleAlert size={22} strokeWidth={2.2} /></div>
<h2 id="unsaved-title" class="modal-title">Unsaved changes</h2>
<p class="modal-text">
You have unsaved ingredient changes. Leaving this mix will discard them.
</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={() => (pendingRow = null)}>Keep editing</button>
<button type="button" class="modal-confirm" onclick={confirmDiscardChanges}>Discard changes</button>
</div>
</div>
</div>
{/if}
</AppSecondaryRailLayout> </AppSecondaryRailLayout>
<style> <style>
@@ -816,6 +916,19 @@
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
gap: 0.75rem; gap: 0.75rem;
align-items: start;
}
.req {
color: var(--color-error);
font-weight: 700;
}
.field-hint {
margin-top: 0.1rem;
color: var(--color-text-muted);
font-size: 0.76rem;
font-weight: 500;
} }
.create-actions { .create-actions {
@@ -1260,6 +1373,19 @@
color: var(--color-error); color: var(--color-error);
} }
/* Read-only kg cell: looks like a quiet field, not an input, so it reads as a
calculated value rather than something editable. */
.kg-readout {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0.38rem 0.5rem;
color: var(--color-text-secondary);
font-size: 0.88rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.footer-total { .footer-total {
margin-right: auto; margin-right: auto;
color: var(--color-text-secondary); color: var(--color-text-secondary);
@@ -1351,4 +1477,105 @@
text-align: left; text-align: left;
} }
} }
/* Unsaved-changes confirm dialog, mirroring the throughput delete dialog. */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--color-text-primary) 32%, transparent);
backdrop-filter: blur(6px);
}
.modal-card {
width: min(28rem, 100%);
display: grid;
gap: 0.7rem;
padding: 1.6rem;
border: 1px solid var(--color-border);
border-radius: 1rem;
background: var(--color-bg-surface);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
}
.modal-card:focus {
outline: none;
}
.modal-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.8rem;
height: 2.8rem;
border-radius: 0.8rem;
background: var(--color-warning-tint);
color: var(--color-warning-text);
}
.modal-title,
.modal-text {
margin: 0;
}
.modal-title {
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text-primary);
}
.modal-text {
font-size: 0.98rem;
line-height: 1.5;
color: var(--color-text-secondary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 0.55rem;
}
.modal-cancel,
.modal-confirm {
min-height: 44px;
padding: 0.6rem 1.15rem;
border-radius: 0.7rem;
font-size: 0.98rem;
font-weight: 650;
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease;
}
.modal-cancel {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
}
.modal-cancel:hover {
color: var(--color-text-primary);
border-color: var(--color-text-muted);
}
.modal-confirm {
background: var(--color-error);
border: 1px solid var(--color-error);
color: #fff;
}
.modal-confirm:hover {
background: color-mix(in srgb, var(--color-error) 85%, black);
}
.modal-cancel:focus-visible,
.modal-confirm:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
}
</style> </style>