v0.1.28 - Version bump and editor/throughput updates

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 22:51:29 +12:00
co-authored by Claude Opus 4.8
parent 3f8279af10
commit 1dd48bc771
10 changed files with 395 additions and 72 deletions
+2 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hunter-app",
"version": "0.1.27",
"version": "0.1.28",
"private": true,
"type": "module",
"scripts": {
+8 -6
View File
@@ -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')) {
+249 -22
View File
@@ -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 0100 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>