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
+27 -3
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:
if product_total > 0:
visible_before = db.scalar( visible_before = db.scalar(
select(func.count()) select(func.count())
.select_from(Product) .select_from(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible) .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:
if product_total > 0:
for product in db.scalars( for product in db.scalars(
select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id) select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
).all(): ).all():
product.visible = visible 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>