diff --git a/backend/app/api/editor.py b/backend/app/api/editor.py index 5843311..e015131 100644 --- a/backend/app/api/editor.py +++ b/backend/app/api/editor.py @@ -86,12 +86,17 @@ def _serialize_product_formula(product: Product) -> 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 { "id": mix.id, "tenant_id": mix.tenant_id, "client_name": mix.client_name, "name": mix.name, - "visible": visible_count > 0, + "visible": visible, "product_count": product_count, "visible_product_count": visible_count, "notes": mix.notes, @@ -319,6 +324,9 @@ def create_editor_mix( client_name=payload.client_name.strip(), name=payload.name.strip(), 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.flush() @@ -348,27 +356,43 @@ def update_editor_mix( raise HTTPException(status_code=404, detail="Mix not found") updates = payload.model_dump(exclude_unset=True) - # `visible` is a virtual field: it fans out to the visibility of every product - # under the mix rather than mapping to a mix column. + # `visible` is a virtual field: for a mix with products it fans out to the + # 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) + 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} if visible is not None: - visible_before = db.scalar( - select(func.count()) - .select_from(Product) - .where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible) - ) - before["visible"] = bool(visible_before) + if product_total > 0: + visible_before = db.scalar( + select(func.count()) + .select_from(Product) + .where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible) + ) + before["visible"] = bool(visible_before) + else: + before["visible"] = mix.status == "active" for field, value in updates.items(): setattr(mix, field, value) if visible is not None: - for product in db.scalars( - select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id) - ).all(): - product.visible = visible + if product_total > 0: + for product in db.scalars( + select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id) + ).all(): + product.visible = visible + else: + mix.status = "active" if visible else "inactive" after = dict(updates) if visible is not None: diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index d5b334d..bef7592 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -66,9 +66,15 @@ class MigrationReport: created_tables: tuple[str, ...] = () added_columns: tuple[str, ...] = () synced_tenant_rows: dict[str, int] = field(default_factory=dict) + resynced_sequences: tuple[str, ...] = () 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: parts: list[str] = [] @@ -79,6 +85,8 @@ class MigrationReport: if self.synced_tenant_rows: counts = ", ".join(f"{table}={count}" for table, count in sorted(self.synced_tenant_rows.items())) 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" @@ -435,7 +443,58 @@ def sync_product_visibility(engine: Engine) -> int: 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: created_tables = ensure_metadata_tables(engine, metadata) 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, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 15384ff..85f8f77 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -117,6 +117,7 @@ def ensure_database_ready() -> MigrationReport: **tenant_sync_report, **({"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()) _database_ready = True diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2936be1..4f7d54b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hunter-backend" -version = "0.1.27" +version = "0.1.28" description = "Costing platform MVP backend (API for Hunter)" requires-python = ">=3.11" dependencies = [ diff --git a/deploy/migrate-to-postgres.sh b/deploy/migrate-to-postgres.sh index aafd26e..dec8801 100644 --- a/deploy/migrate-to-postgres.sh +++ b/deploy/migrate-to-postgres.sh @@ -457,19 +457,27 @@ def migrate(): # Re-enable FK checks 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...") with dst.begin() as conn: - for table_name in TABLE_ORDER: - try: - conn.execute(text( - f"SELECT setval(" - f" pg_get_serial_sequence('{table_name}', 'id')," - f" COALESCE((SELECT MAX(id) FROM {table_name}), 1)" - f")" - )) - except Exception: - pass + all_tables = inspect(dst).get_table_names() + for table_name in all_tables: + sequence = conn.execute(text( + "SELECT pg_get_serial_sequence(:table, 'id')" + ), {"table": table_name}).scalar() + if not sequence: + continue + max_id = conn.execute(text(f'SELECT MAX(id) FROM "{table_name}"')).scalar() + if max_id is None: + 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.") return totals diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a0f41bc..94001b1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hunter-app", - "version": "0.1.27", + "version": "0.1.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hunter-app", - "version": "0.1.27", + "version": "0.1.28", "dependencies": { "@fontsource/inter": "^5.2.8", "lucide-svelte": "^1.0.1" diff --git a/frontend/package.json b/frontend/package.json index c6bf368..ca72b53 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.27", + "version": "0.1.28", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 7237108..1207982 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -133,21 +133,23 @@ const visibleEditorItem = $derived(canOpenEditor ? editorItem : null); const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null); const visibleAccessControlItem = $derived(sessionCanOpenClientAccess($clientSession) ? accessControlItem : null); - // Grouped desktop rail: Dashboard, a collapsible "Operations" family (costing - // tools plus throughput), then the standalone ordering/insights modules. Built - // from the same access-filtered items, so a role only ever sees the families it - // may open. + // Grouped desktop rail: Dashboard, a collapsible "Operations" family (mix + // calculator plus throughput), a "Costing" family (product costing and the + // editors), then the standalone ordering/insights modules. Built from the same + // access-filtered items, so a role only ever sees the families it may open. const navEntries = $derived( buildClientNavEntries({ dashboard: visibleDashboardItem, - costing: [ + operations: [ ...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []), + ...(visibleThroughputItem ? [visibleThroughputItem] : []) + ], + costing: [ ...(visibleProductCostingItem ? [visibleProductCostingItem] : []), ...(visibleEditorItem ? [visibleEditorItem] : []), ...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []), ...visibleWorkingDocumentItems ], - throughput: visibleThroughputItem, ordering: visibleOrderingEntry, reporting: visibleReportingItem }) diff --git a/frontend/src/lib/navigation/client-navigation.ts b/frontend/src/lib/navigation/client-navigation.ts index 96b8723..2dbf766 100644 --- a/frontend/src/lib/navigation/client-navigation.ts +++ b/frontend/src/lib/navigation/client-navigation.ts @@ -292,15 +292,14 @@ export const baseSearchItems: SearchItem[] = [ * 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. * - * Workflow-family layout: Dashboard, then an "Operations" group (the calculator, - * costing, editor, master tools, and throughput), then Ordering and Insights - * modules. Costing tools live inside Operations for the time being until they - * grow into a family of their own. + * Workflow-family layout: Dashboard, then an "Operations" group (the mix + * calculator and throughput) and a "Costing" group (product costing, mix and + * ingredient editors, and master tools), then Ordering and Insights modules. */ export function buildClientNavEntries(visible: { dashboard?: NavItem | null; + operations: NavItem[]; costing: NavItem[]; - throughput?: NavItem | null; ordering?: NavEntry | null; reporting?: NavItem | null; }): NavEntry[] { @@ -310,14 +309,17 @@ export function buildClientNavEntries(visible: { entries.push({ kind: 'item', item: visible.dashboard }); } - const operationsChildren = [ - ...visible.costing, - ...(visible.throughput ? [visible.throughput] : []) - ]; - if (operationsChildren.length) { + if (visible.operations.length) { entries.push({ 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')) { - return { title: productCostingItem.label, category: 'Operations', icon: productCostingItem.icon }; + return { title: productCostingItem.label, category: 'Costing', icon: productCostingItem.icon }; } 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')) { - return { title: ingredientsEditorItem.label, category: 'Operations', icon: ingredientsEditorItem.icon }; + return { title: ingredientsEditorItem.label, category: 'Costing', icon: ingredientsEditorItem.icon }; } if (pathname.startsWith('/raw-materials')) { diff --git a/frontend/src/routes/editor/+page.svelte b/frontend/src/routes/editor/+page.svelte index b9dbf6b..3e55969 100644 --- a/frontend/src/routes/editor/+page.svelte +++ b/frontend/src/routes/editor/+page.svelte @@ -12,7 +12,7 @@ EditorMixUpdateInput, RawMaterial } 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'; let { data } = $props(); @@ -38,11 +38,18 @@ let expandedMixId = $state(null); let activeFormula = $state(null); let ingredientDrafts = $state([]); + // 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([]); // 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 // rescales every row's kg from its %. 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(null); + // The mix whose change history is open in the modal (null = closed). let historyMix = $state(null); @@ -92,6 +99,8 @@ activeFormula = formula; totalReference = formula.total_kg || 0; 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 @@ -105,16 +114,46 @@ })); } - // % overrides kg: convert this row's percentage to kg against the locked - // reference total. Other rows are untouched, so the percentage total will - // read off 100 until the rest are adjusted (the save guard enforces 100%). + // Editing a row's % sets it to that share of the mix and spreads the rest + // across the other rows in their existing proportions, so the column always + // re-totals 100 and kg stays the canonical value. The total mix kg is held + // constant; only the split moves. Without this rebalance a single % edit + // would leave the total off 100 and the save guard would block the change. function applyPercentEdit(index: number) { - const total = totalReference; - ingredientDrafts = ingredientDrafts.map((row, rowIndex) => - rowIndex === index - ? { ...row, quantity_kg: total > 0 ? round4((Number(row.percentage || 0) / 100) * total) : 0 } - : row + const total = Number(totalReference || 0); + const rows = ingredientDrafts; + const count = rows.length; + + // Clamp to a sane 0–100 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 %. @@ -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) { if (expandedMixId === row.id) { expandedMixId = null; activeFormula = null; ingredientDrafts = []; + ingredientBaseline = []; return; } @@ -351,6 +407,22 @@ ); 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. $effect(() => { query; @@ -456,11 +528,17 @@
@@ -531,7 +609,7 @@
- @@ -577,7 +655,7 @@
Raw material % - kg + kg (auto) Notes Remove @@ -595,14 +673,12 @@ step="0.0001" aria-label={`Percentage for ${rawMaterialName(ingredient.raw_material_id)}`} /> - + + + {Number(ingredient.quantity_kg || 0).toFixed(2)} + {/each} @@ -635,6 +711,30 @@ onClose={() => (historyMix = null)} /> {/if} + + {#if pendingRow} + + {/if}