v0.1.31 - Mix Editor multi edit

This commit is contained in:
2026-06-18 15:15:46 +12:00
parent 1062c038e8
commit 10722a65a6
9 changed files with 555 additions and 23 deletions
+39
View File
@@ -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,
+1 -1
View File
@@ -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 = [
+46 -1
View File
@@ -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")
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hunter-app",
"version": "0.1.30",
"version": "0.1.31",
"private": true,
"type": "module",
"scripts": {
+4
View File
@@ -410,6 +410,10 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteEditorMix: (mixId: number) =>
request<void>(`/api/editor/mixes/${mixId}`, {
method: 'DELETE'
}, 'client'),
editorMixFormula: (mixId: number) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'),
// The resolved formula matching the Mix Calculator (product-first), used by
+8
View File
@@ -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',
@@ -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',
+444 -14
View File
@@ -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<Set<number>>(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<number>();
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 @@
</div>
</dl>
<button class="clear-button select-toggle" class:active={selectMode} type="button" onclick={toggleSelectMode}>
<ListChecks size={16} strokeWidth={2.2} />
{selectMode ? 'Done' : 'Select'}
</button>
<button class="apply-button new-mix-button" type="button" onclick={openCreateMix} disabled={creatingMix}>
<Plus size={16} strokeWidth={2.4} />
New mix
@@ -543,16 +633,58 @@
</div>
</div>
<div class="log">
{#if selectMode}
<div class="bulk-bar" transition:fade={{ duration: 120 }} aria-label="Bulk actions">
<div class="bulk-count">
<span class="bulk-badge">{selectedIds.size}</span>
<span>selected</span>
</div>
<div class="bulk-actions">
<button class="clear-button" type="button" disabled={selectedIds.size === 0} onclick={() => requestBulk('inactive')}>
<EyeOff size={16} strokeWidth={2.2} />
Mark inactive
</button>
<button class="danger-button" type="button" disabled={selectedIds.size === 0} onclick={() => requestBulk('delete')}>
<Trash2 size={16} strokeWidth={2.2} />
Delete
</button>
</div>
</div>
{/if}
<div class="log" class:select-mode={selectMode}>
<div class="log-head">
{#if selectMode}
<label class="select-cell">
<input
type="checkbox"
checked={allPageSelected}
indeterminate={somePageSelected}
onchange={toggleSelectAll}
aria-label="Select all mixes on this page"
/>
</label>
{/if}
<SortHeader label="Client" column="client_name" controller={table} />
<SortHeader label="Mix" column="name" controller={table} />
<SortHeader label="Status" column="visible" controller={table} />
<span>Actions</span>
<span class="actions-head">Actions</span>
</div>
{#each table.rows as row (row.id)}
<div class="row" class:edited={rowDirty(row)}>
<div class="row" class:edited={rowDirty(row)} class:selected={selectMode && selectedIds.has(row.id)}>
{#if selectMode}
<label class="select-cell">
<span class="cell-label">Select</span>
<input
type="checkbox"
checked={selectedIds.has(row.id)}
onchange={() => toggleRowSelected(row.id)}
aria-label={`Select ${row.name}`}
/>
</label>
{/if}
<div class="client-cell">
<span class="cell-label">Client</span>
<span class="readonly-value">{row.client_name}</span>
@@ -698,6 +830,99 @@
</div>
</div>
{/if}
{#if bulkAction}
<div class="modal-backdrop" role="presentation" onclick={() => { if (!bulkRunning) bulkAction = null; }}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="bulk-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape' && !bulkRunning) bulkAction = null; }}
>
<div class="modal-icon" class:danger={bulkAction === 'delete'}>
{#if bulkAction === 'delete'}
<Trash2 size={22} strokeWidth={2.2} />
{:else}
<EyeOff size={22} strokeWidth={2.2} />
{/if}
</div>
<h2 id="bulk-title" class="modal-title">
{bulkAction === 'delete' ? 'Delete' : 'Mark inactive'}
{selectedIds.size}
{selectedIds.size === 1 ? 'mix' : 'mixes'}?
</h2>
<p class="modal-text">
{#if bulkAction === 'delete'}
This permanently removes the selected mixes and their formulas. Any mix that still has linked
products can't be deleted and will be skipped.
{:else}
The selected mixes will be hidden from the active list. You can re-activate them anytime from the
Inactive filter.
{/if}
</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" disabled={bulkRunning} onclick={() => (bulkAction = null)}>Cancel</button>
<button
type="button"
class="modal-confirm"
class:neutral={bulkAction === 'inactive'}
disabled={bulkRunning}
onclick={runBulk}
>
{bulkRunning ? 'Working…' : bulkAction === 'delete' ? 'Delete mixes' : 'Mark inactive'}
</button>
</div>
</div>
</div>
{/if}
{#if bulkResult}
<div class="modal-backdrop" role="presentation" onclick={() => (bulkResult = null)}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="bulk-result-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') bulkResult = null; }}
>
<div class="modal-icon" class:danger={bulkResult.failures.length > 0} class:ok={bulkResult.failures.length === 0}>
{#if bulkResult.failures.length === 0}
<CheckCircle2 size={22} strokeWidth={2.2} />
{:else}
<TriangleAlert size={22} strokeWidth={2.2} />
{/if}
</div>
<h2 id="bulk-result-title" class="modal-title">
{bulkResult.action === 'delete' ? 'Delete complete' : 'Update complete'}
</h2>
<p class="modal-text">
{bulkResult.succeeded}
{bulkResult.succeeded === 1 ? 'mix' : 'mixes'}
{bulkResult.action === 'delete' ? 'deleted' : 'marked inactive'}{bulkResult.failures.length
? `, ${bulkResult.failures.length} skipped`
: ''}.
</p>
{#if bulkResult.failures.length}
<ul class="result-failures">
{#each bulkResult.failures as failure}
<li>
<strong>{failure.name}</strong>
<span>{failure.reason}</span>
</li>
{/each}
</ul>
{/if}
<div class="modal-actions">
<button type="button" class="modal-confirm neutral" onclick={() => (bulkResult = null)}>Done</button>
</div>
</div>
</div>
{/if}
</AppSecondaryRailLayout>
<style>
@@ -842,6 +1067,81 @@
flex-shrink: 0;
}
.select-toggle.active {
color: var(--color-brand);
border-color: var(--color-brand);
background: var(--color-brand-tint);
}
/* Bulk action bar: appears above the table while in select mode. */
.bulk-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 0.6rem 0.85rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 30%, var(--color-border));
border-radius: 0.7rem;
background: var(--color-brand-tint);
}
.bulk-count {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-secondary);
font-size: 0.88rem;
font-weight: 600;
}
.bulk-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
height: 1.6rem;
padding: 0 0.45rem;
border-radius: 999px;
background: var(--color-brand);
color: var(--color-on-brand);
font-size: 0.85rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bulk-actions {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.danger-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
min-height: 34px;
border-radius: 0.45rem;
padding: 0.45rem 0.65rem;
font-size: 0.86rem;
font-weight: 650;
color: var(--color-on-brand, #fff);
background: var(--color-error);
border: 1px solid var(--color-error);
cursor: pointer;
}
.danger-button:hover {
background: color-mix(in srgb, var(--color-error) 88%, black);
}
.danger-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.facts {
display: flex;
gap: 1.35rem;
@@ -1140,24 +1440,47 @@
border-color: var(--color-brand);
}
/* The table is one grid that owns the column tracks; the header and every row
are subgrids that share those exact tracks. This is what keeps the headers
lined up with the fields below — separate grids would each size their own
`auto` Actions column from their own content and drift apart. */
.log {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns:
minmax(150px, 1fr)
minmax(200px, 1.6fr)
minmax(96px, 0.5fr)
minmax(198px, auto);
column-gap: 0.55rem;
row-gap: 0;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 0.9rem;
overflow: hidden;
}
/* A leading checkbox column appears in select mode; subgrid rows pick it up
automatically, so header and rows stay aligned. */
.log.select-mode {
grid-template-columns:
2rem
minmax(150px, 1fr)
minmax(200px, 1.6fr)
minmax(96px, 0.5fr)
minmax(198px, auto);
}
.log-head,
.row,
.ingredient-panel,
.empty {
grid-column: 1 / -1;
}
.log-head,
.row {
display: grid;
grid-template-columns:
minmax(170px, 1fr)
minmax(220px, 1.6fr)
minmax(110px, 0.5fr)
minmax(198px, auto);
gap: 0.55rem;
grid-template-columns: subgrid;
align-items: center;
}
@@ -1175,6 +1498,17 @@
letter-spacing: 0;
}
/* Line the header labels and read-only cell text up with the input text,
which sits one input-padding (0.5rem) in from the cell's left edge. The
Actions header instead hugs the right, above the right-aligned buttons. */
.log-head :global(.sort-header) {
padding-left: 0.5rem;
}
.actions-head {
text-align: right;
}
.row {
padding: 0.58rem 0.85rem;
border-bottom: 1px solid var(--color-divider);
@@ -1189,10 +1523,31 @@
background: var(--color-brand-tint);
}
.row.selected {
background: color-mix(in srgb, var(--color-brand) 9%, var(--color-bg-surface));
}
/* Leading checkbox cell (header select-all + per-row select). */
.select-cell {
display: flex;
align-items: center;
justify-content: center;
min-height: 34px;
}
.select-cell input {
width: 1.05rem;
min-height: 1.05rem;
margin: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
.readonly-value {
display: flex;
align-items: center;
min-height: 34px;
padding-left: 0.5rem;
color: var(--color-text-primary);
font-size: 0.88rem;
font-weight: 650;
@@ -1221,7 +1576,7 @@
justify-content: flex-start;
gap: 0.3rem;
min-height: 34px;
padding: 0.25rem 0;
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 0.42rem;
background: transparent;
@@ -1381,11 +1736,21 @@
}
@media (max-width: 1180px) {
/* Drop the shared grid and stack each row as its own card. */
.log,
.log.select-mode {
display: flex;
flex-direction: column;
grid-template-columns: none;
}
.log-head {
display: none;
}
.row {
.row,
.log.select-mode .row {
display: grid;
grid-template-columns: 1fr;
}
@@ -1393,6 +1758,12 @@
display: block;
}
/* Stacked card layout: show the checkbox inline with its label. */
.select-cell {
justify-content: flex-start;
gap: 0.5rem;
}
.row-actions {
justify-content: flex-start;
}
@@ -1479,6 +1850,16 @@
color: var(--color-warning-text);
}
.modal-icon.danger {
background: var(--color-error-tint, color-mix(in srgb, var(--color-error) 14%, transparent));
color: var(--color-error);
}
.modal-icon.ok {
background: color-mix(in srgb, var(--color-success) 14%, transparent);
color: var(--color-success);
}
.modal-title,
.modal-text {
margin: 0;
@@ -1536,9 +1917,58 @@
background: color-mix(in srgb, var(--color-error) 85%, black);
}
/* Non-destructive confirm (mark inactive / acknowledge result). */
.modal-confirm.neutral {
background: var(--color-brand);
border-color: var(--color-brand);
color: var(--color-on-brand);
}
.modal-confirm.neutral:hover {
background: color-mix(in srgb, var(--color-brand) 88%, black);
}
.modal-cancel:disabled,
.modal-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.modal-cancel:focus-visible,
.modal-confirm:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
}
/* Per-mix reasons shown in the result modal when some rows were skipped. */
.result-failures {
display: grid;
gap: 0.4rem;
max-height: 11rem;
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
.result-failures li {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.5rem 0.65rem;
border: 1px solid color-mix(in srgb, var(--color-error) 28%, var(--color-border));
border-radius: 0.55rem;
background: color-mix(in srgb, var(--color-error) 7%, var(--color-bg-surface));
}
.result-failures strong {
font-size: 0.9rem;
font-weight: 700;
color: var(--color-text-primary);
}
.result-failures span {
font-size: 0.82rem;
color: var(--color-text-secondary);
}
</style>