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:
|
||||
# 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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.27",
|
||||
"version": "0.1.28",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
let activeFormula = $state<EditorResolvedMixFormula | null>(null);
|
||||
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
|
||||
// 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<EditableRow | null>(null);
|
||||
|
||||
// The mix whose change history is open in the modal (null = closed).
|
||||
let historyMix = $state<EditableRow | null>(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 @@
|
||||
|
||||
<div class="create-fields">
|
||||
<label>
|
||||
<span>Client</span>
|
||||
<input bind:value={newMixClient} list="editor-client-options" placeholder="Client name" />
|
||||
<span>Client <span class="req">*</span></span>
|
||||
<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>
|
||||
<span>Mix name</span>
|
||||
<span>Mix name <span class="req">*</span></span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input bind:value={newMixName} placeholder="Mix name" autofocus />
|
||||
</label>
|
||||
@@ -531,7 +609,7 @@
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
{expandedMixId === row.id ? 'Close ingredients' : savingKey === `mix-load:${row.id}` ? 'Loading...' : 'Ingredients'}
|
||||
</button>
|
||||
@@ -577,7 +655,7 @@
|
||||
<div class="ingredient-grid">
|
||||
<span class="grid-label">Raw material</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">Remove</span>
|
||||
|
||||
@@ -595,14 +673,12 @@
|
||||
step="0.0001"
|
||||
aria-label={`Percentage for ${rawMaterialName(ingredient.raw_material_id)}`}
|
||||
/>
|
||||
<input
|
||||
bind:value={ingredient.quantity_kg}
|
||||
onchange={applyKgEdit}
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.0001"
|
||||
aria-label={`Quantity for ${rawMaterialName(ingredient.raw_material_id)}`}
|
||||
/>
|
||||
<!-- kg is derived from % against the Total mix anchor and stays the
|
||||
canonical saved value; it is read-only here so % is the single
|
||||
point of entry (the two cells used to conflict). -->
|
||||
<span class="kg-readout" aria-label={`Quantity for ${rawMaterialName(ingredient.raw_material_id)}`}>
|
||||
{Number(ingredient.quantity_kg || 0).toFixed(2)}
|
||||
</span>
|
||||
<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>
|
||||
{/each}
|
||||
@@ -635,6 +711,30 @@
|
||||
onClose={() => (historyMix = null)}
|
||||
/>
|
||||
{/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>
|
||||
|
||||
<style>
|
||||
@@ -816,6 +916,19 @@
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
|
||||
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 {
|
||||
@@ -1260,6 +1373,19 @@
|
||||
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 {
|
||||
margin-right: auto;
|
||||
color: var(--color-text-secondary);
|
||||
@@ -1351,4 +1477,105 @@
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user