v0.1.28 - Version bump and editor/throughput updates
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
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