@
v0.1.28 - Version bump and editor/throughput updates Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @
This commit is contained in:
+37
-13
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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": {
|
||||||
|
|||||||
@@ -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')) {
|
||||||
|
|||||||
@@ -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 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 %.
|
// 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user