From 10722a65a6201eca12c26eb0f69c3e35178f8d7e Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Thu, 18 Jun 2026 15:15:46 +1200 Subject: [PATCH] v0.1.31 - Mix Editor multi edit --- backend/app/api/editor.py | 39 ++ backend/pyproject.toml | 2 +- backend/tests/test_editor_formula.py | 47 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/lib/api.ts | 4 + frontend/src/lib/changelog.ts | 8 + .../editor/ChangeHistoryModal.svelte | 14 +- frontend/src/routes/editor/+page.svelte | 458 +++++++++++++++++- 9 files changed, 555 insertions(+), 23 deletions(-) diff --git a/backend/app/api/editor.py b/backend/app/api/editor.py index 8b111f1..128d207 100644 --- a/backend/app/api/editor.py +++ b/backend/app/api/editor.py @@ -423,6 +423,45 @@ def update_editor_mix( return _serialize_mix_row(mix, visible_count=visible_count, product_count=total) +@router.delete("/mixes/{mix_id}", status_code=204) +def delete_editor_mix( + mix_id: int, + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + """Delete a mix that no product depends on. + + A product must reference a mix (`products.mix_id` is NOT NULL), so a mix that + still drives products can't be removed without orphaning them — those should + be marked inactive instead. The mix's own ingredient rows cascade away with + it via the `delete-orphan` relationship. + """ + mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) + if mix is None: + raise HTTPException(status_code=404, detail="Mix not found") + + product_total = ( + db.scalar( + select(func.count()) + .select_from(Product) + .where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id) + ) + or 0 + ) + if product_total > 0: + raise HTTPException( + status_code=409, + detail=( + f"This mix has {product_total} linked product" + f"{'s' if product_total != 1 else ''}. Mark it inactive or remove its products first." + ), + ) + + db.delete(mix) + db.commit() + return None + + @router.get("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead) def get_editor_mix_ingredients( mix_id: int, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 695daf3..26b75f8 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.30" +version = "0.1.31" description = "Costing platform MVP backend (API for Hunter)" requires-python = ">=3.11" dependencies = [ diff --git a/backend/tests/test_editor_formula.py b/backend/tests/test_editor_formula.py index 93e721f..27a1de4 100644 --- a/backend/tests/test_editor_formula.py +++ b/backend/tests/test_editor_formula.py @@ -10,8 +10,11 @@ from __future__ import annotations from sqlalchemy import create_engine, select from sqlalchemy.orm import Session, sessionmaker +import pytest +from fastapi import HTTPException + from app.api.deps import AuthSession -from app.api.editor import replace_editor_mix_formula +from app.api.editor import delete_editor_mix, replace_editor_mix_formula from app.db.session import Base from app.models.mix import Mix, MixIngredient from app.models.product import Product, ProductIngredient @@ -175,6 +178,48 @@ def test_replace_product_formula_writes_product_ingredients(): ] +def test_delete_mix_without_products_removes_mix_and_ingredients(): + """A product-less mix can be deleted; its ingredient rows cascade away.""" + db = _session() + maize = _raw(db, "Maize") + mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix") + db.add(mix) + db.flush() + db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50)) + db.commit() + mix_id = mix.id + + delete_editor_mix(mix_id, session=_editor_session(), db=db) + + assert db.scalar(select(Mix).where(Mix.id == mix_id)) is None + assert db.scalars(select(MixIngredient).where(MixIngredient.mix_id == mix_id)).first() is None + + +def test_delete_mix_with_products_is_refused(): + """A mix that still drives products can't be deleted (409) — products must + keep a mix, so the user marks it inactive instead.""" + db = _session() + mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Layer Mix") + db.add(mix) + db.flush() + db.add( + Product( + tenant_id=TENANT, client_name="Hunter", name="Layer 20kg", mix_id=mix.id, + unit_of_measure="20kg bag", visible=True, + ) + ) + db.commit() + mix_id = mix.id + + with pytest.raises(HTTPException) as excinfo: + delete_editor_mix(mix_id, session=_editor_session(), db=db) + + assert excinfo.value.status_code == 409 + assert "linked product" in excinfo.value.detail + # The mix is left intact. + assert db.scalar(select(Mix).where(Mix.id == mix_id)) is not None + + def test_representative_product_prefers_20kg_bag(): db = _session() maize = _raw(db, "Maize") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 30b45b0..de3b135 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hunter-app", - "version": "0.1.30", + "version": "0.1.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hunter-app", - "version": "0.1.30", + "version": "0.1.31", "dependencies": { "@fontsource/inter": "^5.2.8", "lucide-svelte": "^1.0.1" diff --git a/frontend/package.json b/frontend/package.json index 6d0a208..35f1e8e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.30", + "version": "0.1.31", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0616104..48dac85 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -410,6 +410,10 @@ export const api = { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), + deleteEditorMix: (mixId: number) => + request(`/api/editor/mixes/${mixId}`, { + method: 'DELETE' + }, 'client'), editorMixFormula: (mixId: number) => request(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'), // The resolved formula matching the Mix Calculator (product-first), used by diff --git a/frontend/src/lib/changelog.ts b/frontend/src/lib/changelog.ts index 5fe1d98..7e54ed2 100644 --- a/frontend/src/lib/changelog.ts +++ b/frontend/src/lib/changelog.ts @@ -17,6 +17,14 @@ export type ChangelogEntry = { export const APP_VERSION: string = packageInfo.version; export const changelog: ChangelogEntry[] = [ + { + version: '0.1.31', + date: '2026-06-18', + highlights: [ + 'App: Bug fixes & improvements.', + 'App: Throughput module is now live.' + ] + }, { version: '0.1.22', date: '2026-06-15', diff --git a/frontend/src/lib/components/editor/ChangeHistoryModal.svelte b/frontend/src/lib/components/editor/ChangeHistoryModal.svelte index 6e23937..0a6cae2 100644 --- a/frontend/src/lib/components/editor/ChangeHistoryModal.svelte +++ b/frontend/src/lib/components/editor/ChangeHistoryModal.svelte @@ -46,11 +46,17 @@ } function formatWhen(value: string) { - // Stored as a naive UTC timestamp; treat it as UTC for display. - const iso = value.endsWith('Z') || value.includes('+') ? value : `${value}Z`; - const date = new Date(iso); + // The server records audit times in its own (Australian) local time. Show + // those wall-clock values verbatim — do NOT re-interpret them as UTC or + // shift them into the viewer's timezone, or a 2pm edit reads as 12am. + // Parse the date parts directly so the display never moves with the + // viewer's location (AU, NZ, or anywhere else all see the server time). + const match = value.match(/(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/); + if (!match) return value; + const [, year, month, day, hour, minute] = match.map(Number); + const date = new Date(year, month - 1, day, hour, minute); if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString(undefined, { + return date.toLocaleString('en-AU', { year: 'numeric', month: 'short', day: 'numeric', diff --git a/frontend/src/routes/editor/+page.svelte b/frontend/src/routes/editor/+page.svelte index bd0bf4a..e2a3a61 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, TriangleAlert, X } from 'lucide-svelte'; + import { CheckCircle2, ChevronLeft, ChevronRight, EyeOff, FlaskConical, History, ListChecks, ListFilter, Plus, Save, Search, Trash2, TriangleAlert, X } from 'lucide-svelte'; import { fade } from 'svelte/transition'; let { data } = $props(); @@ -386,6 +386,91 @@ }); }); + // ── Bulk select: tick multiple mixes, then delete or mark them inactive ── + let selectMode = $state(false); + let selectedIds = $state>(new Set()); + // Which bulk action is awaiting confirmation (null = no modal open). + let bulkAction = $state<'inactive' | 'delete' | null>(null); + let bulkRunning = $state(false); + // Summary shown after a bulk run finishes (null = closed). + let bulkResult = $state< + { action: 'inactive' | 'delete'; succeeded: number; failures: { name: string; reason: string }[] } | null + >(null); + + // Select-all operates on the rows currently on screen; the selection itself + // persists across pages so a multi-page selection is possible. + const pageRowIds = $derived(table.rows.map((row) => row.id)); + const selectedOnPage = $derived(pageRowIds.filter((id) => selectedIds.has(id)).length); + const allPageSelected = $derived(pageRowIds.length > 0 && selectedOnPage === pageRowIds.length); + const somePageSelected = $derived(selectedOnPage > 0 && !allPageSelected); + + function toggleSelectMode() { + selectMode = !selectMode; + if (!selectMode) selectedIds = new Set(); + } + + function toggleRowSelected(id: number) { + const next = new Set(selectedIds); + if (next.has(id)) next.delete(id); + else next.add(id); + selectedIds = next; + } + + function toggleSelectAll() { + const next = new Set(selectedIds); + if (allPageSelected) { + for (const id of pageRowIds) next.delete(id); + } else { + for (const id of pageRowIds) next.add(id); + } + selectedIds = next; + } + + function requestBulk(action: 'inactive' | 'delete') { + if (selectedIds.size === 0) return; + bulkAction = action; + } + + async function runBulk() { + const action = bulkAction; + if (!action) return; + bulkRunning = true; + + const targets = rows.filter((row) => selectedIds.has(row.id)); + const failures: { name: string; reason: string }[] = []; + const succeededIds = new Set(); + + for (const row of targets) { + try { + if (action === 'delete') { + await api.deleteEditorMix(row.id); + } else { + applyMixUpdate(await api.updateEditorMix(row.id, { visible: false })); + } + succeededIds.add(row.id); + } catch (error) { + failures.push({ name: row.name, reason: error instanceof Error ? error.message : 'Could not be updated' }); + } + } + + if (action === 'delete') { + rows = rows.filter((row) => !succeededIds.has(row.id)); + // Close the ingredient panel if its mix was just deleted. + if (expandedMixId !== null && succeededIds.has(expandedMixId)) { + expandedMixId = null; + activeFormula = null; + ingredientDrafts = []; + ingredientBaseline = []; + } + } + + // Keep only failures selected so the user can see/retry them. + selectedIds = new Set([...selectedIds].filter((id) => !succeededIds.has(id))); + bulkResult = { action, succeeded: succeededIds.size, failures }; + bulkAction = null; + bulkRunning = false; + } + // Jump back to the first page whenever the filtered set changes. $effect(() => { query; @@ -475,6 +560,11 @@ + + + + + + {/if} + +
+ {#if selectMode} + + {/if} - Actions + Actions
{#each table.rows as row (row.id)} -
+
+ {#if selectMode} + + {/if} +
Client {row.client_name} @@ -698,6 +830,99 @@
{/if} + + {#if bulkAction} + + {/if} + + {#if bulkResult} + + {/if}