v0.1.27
Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live. Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator). Fix: Security headers on all API responses (hardening) Add: New mix button available on the Mix Editor. Add: New ingredient button available on the Ingredient Editor
This commit is contained in:
@@ -437,7 +437,6 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p,
|
||||
@@ -453,21 +452,12 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-intro,
|
||||
.metric-row,
|
||||
.workspace-grid,
|
||||
.preview-grid {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.page-intro h2 {
|
||||
margin: 0.35rem 0 0.45rem;
|
||||
max-width: 18ch;
|
||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-intro p:last-child,
|
||||
.metric-card p,
|
||||
.card-toolbar p,
|
||||
.client-row span,
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
import SortHeader from '$lib/table/SortHeader.svelte';
|
||||
import { TableController } from '$lib/table/table.svelte';
|
||||
import type {
|
||||
EditorMixFormula,
|
||||
EditorMixIngredient,
|
||||
EditorResolvedMixFormula,
|
||||
EditorResolvedMixIngredient,
|
||||
EditorMixRow,
|
||||
EditorMixUpdateInput,
|
||||
RawMaterial
|
||||
} from '$lib/types';
|
||||
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Save, Search, X } from 'lucide-svelte';
|
||||
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -22,9 +22,10 @@
|
||||
};
|
||||
|
||||
type DraftIngredient = {
|
||||
id: number | null;
|
||||
raw_material_id: number | null;
|
||||
quantity_kg: number;
|
||||
// Percentage is an entry aid; kilograms remain the canonical saved value.
|
||||
percentage: number;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
@@ -34,8 +35,17 @@
|
||||
let visibilityFilter = $state<'all' | 'visible' | 'hidden'>('visible');
|
||||
let savingKey = $state<string | null>(null);
|
||||
let expandedMixId = $state<number | null>(null);
|
||||
let activeFormula = $state<EditorMixFormula | null>(null);
|
||||
let activeFormula = $state<EditorResolvedMixFormula | null>(null);
|
||||
let ingredientDrafts = $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);
|
||||
|
||||
// Inline "create new mix" form state.
|
||||
let creatingMix = $state(false);
|
||||
let newMixClient = $state('');
|
||||
let newMixName = $state('');
|
||||
|
||||
function toEditableRow(row: EditorMixRow): EditableRow {
|
||||
return {
|
||||
@@ -51,29 +61,67 @@
|
||||
}
|
||||
});
|
||||
|
||||
function ingredientToDraft(ingredient: EditorMixIngredient): DraftIngredient {
|
||||
// Round to avoid binary-float noise in the inputs (e.g. 59.60000000001).
|
||||
function round4(value: number) {
|
||||
return Math.round(value * 1e4) / 1e4;
|
||||
}
|
||||
|
||||
function ingredientToDraft(ingredient: EditorResolvedMixIngredient): DraftIngredient {
|
||||
return {
|
||||
id: ingredient.id,
|
||||
raw_material_id: ingredient.raw_material_id,
|
||||
quantity_kg: ingredient.quantity_kg,
|
||||
percentage: ingredient.mix_percentage,
|
||||
notes: ingredient.notes ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
function emptyIngredient(): DraftIngredient {
|
||||
return {
|
||||
id: null,
|
||||
raw_material_id: (data.rawMaterials as RawMaterial[])[0]?.id ?? null,
|
||||
quantity_kg: 0,
|
||||
percentage: 0,
|
||||
notes: ''
|
||||
};
|
||||
}
|
||||
|
||||
function loadIngredientDrafts(formula: EditorMixFormula) {
|
||||
function loadIngredientDrafts(formula: EditorResolvedMixFormula) {
|
||||
activeFormula = formula;
|
||||
totalReference = formula.total_kg || 0;
|
||||
ingredientDrafts = formula.ingredients.length ? formula.ingredients.map(ingredientToDraft) : [emptyIngredient()];
|
||||
}
|
||||
|
||||
// kg overrides %: recompute the reference total from the kg column, then
|
||||
// re-derive every row's percentage so they always sum to 100.
|
||||
function applyKgEdit() {
|
||||
const total = ingredientDrafts.reduce((sum, row) => sum + Number(row.quantity_kg || 0), 0);
|
||||
totalReference = round4(total);
|
||||
ingredientDrafts = ingredientDrafts.map((row) => ({
|
||||
...row,
|
||||
percentage: total > 0 ? round4((Number(row.quantity_kg || 0) / total) * 100) : 0
|
||||
}));
|
||||
}
|
||||
|
||||
// % 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%).
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
// Editing the Total mix (kg) rescales every row's kg from its current %.
|
||||
function applyTotalEdit() {
|
||||
const total = Number(totalReference || 0);
|
||||
ingredientDrafts = ingredientDrafts.map((row) => ({
|
||||
...row,
|
||||
quantity_kg: total > 0 ? round4((Number(row.percentage || 0) / 100) * total) : 0
|
||||
}));
|
||||
}
|
||||
|
||||
function rowDirty(row: EditableRow) {
|
||||
return row.draft_mix_name !== row.name || row.draft_visible !== row.visible;
|
||||
}
|
||||
@@ -104,6 +152,61 @@
|
||||
rows = rows.map((candidate) => (candidate.id === row.id ? toEditableRow(candidate) : candidate));
|
||||
}
|
||||
|
||||
function openCreateMix() {
|
||||
creatingMix = true;
|
||||
// Prefill the client from the active filter so creating several mixes for
|
||||
// one client is quick.
|
||||
newMixClient = clientFilter !== 'all' ? clientFilter : '';
|
||||
newMixName = '';
|
||||
}
|
||||
|
||||
function cancelCreateMix() {
|
||||
creatingMix = false;
|
||||
newMixClient = '';
|
||||
newMixName = '';
|
||||
}
|
||||
|
||||
async function createMix() {
|
||||
if (!newMixClient.trim()) {
|
||||
toast.error('Client name is required.');
|
||||
return;
|
||||
}
|
||||
if (!newMixName.trim()) {
|
||||
toast.error('Mix name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
savingKey = 'mix-create';
|
||||
|
||||
try {
|
||||
const created = await api.createEditorMix({
|
||||
client_name: newMixClient.trim(),
|
||||
name: newMixName.trim()
|
||||
});
|
||||
|
||||
rows = [toEditableRow(created), ...rows];
|
||||
cancelCreateMix();
|
||||
|
||||
// A new mix is Inactive (no products yet) and may sit under any client, so
|
||||
// clear filters and sorting to guarantee it surfaces at the top, then open
|
||||
// its ingredient panel so the recipe can be built straight away.
|
||||
query = '';
|
||||
clientFilter = 'all';
|
||||
visibilityFilter = 'all';
|
||||
table.sortKey = null;
|
||||
table.reset();
|
||||
|
||||
toast.success('Mix created');
|
||||
|
||||
const newRow = rows.find((row) => row.id === created.id);
|
||||
if (newRow) await toggleIngredients(newRow);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to create mix');
|
||||
} finally {
|
||||
savingKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleIngredients(row: EditableRow) {
|
||||
if (expandedMixId === row.id) {
|
||||
expandedMixId = null;
|
||||
@@ -116,7 +219,7 @@
|
||||
savingKey = `mix-load:${row.id}`;
|
||||
|
||||
try {
|
||||
loadIngredientDrafts(await api.editorMixFormula(row.id));
|
||||
loadIngredientDrafts(await api.editorMixResolvedFormula(row.id));
|
||||
} catch (error) {
|
||||
expandedMixId = null;
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to load ingredients');
|
||||
@@ -132,6 +235,7 @@
|
||||
function removeIngredient(index: number) {
|
||||
ingredientDrafts = ingredientDrafts.filter((_, rowIndex) => rowIndex !== index);
|
||||
if (!ingredientDrafts.length) ingredientDrafts = [emptyIngredient()];
|
||||
applyKgEdit();
|
||||
}
|
||||
|
||||
function ingredientWarnings() {
|
||||
@@ -148,6 +252,11 @@
|
||||
if (Number(row.quantity_kg) <= 0) return [`Ingredient row ${index + 1} needs a quantity greater than zero.`];
|
||||
}
|
||||
|
||||
// Percentages must add up to 100 before a change can be saved.
|
||||
if (Math.abs(percentTotal - 100) > 0.1) {
|
||||
return [`Percentages must total 100% (currently ${percentTotal.toFixed(2)}%).`];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -162,42 +271,13 @@
|
||||
savingKey = `mix-save:${activeFormula.id}`;
|
||||
|
||||
try {
|
||||
const cleanRows = ingredientDrafts.map((row) => ({
|
||||
id: row.id,
|
||||
const payloadRows = ingredientDrafts.map((row) => ({
|
||||
raw_material_id: row.raw_material_id as number,
|
||||
quantity_kg: Number(row.quantity_kg),
|
||||
notes: row.notes.trim() || null
|
||||
}));
|
||||
const originalById = new Map(activeFormula.ingredients.map((ingredient) => [ingredient.id, ingredient]));
|
||||
const keptIds = new Set(cleanRows.filter((row) => row.id !== null).map((row) => row.id as number));
|
||||
|
||||
for (const ingredient of activeFormula.ingredients) {
|
||||
const draft = cleanRows.find((row) => row.id === ingredient.id);
|
||||
if (!keptIds.has(ingredient.id) || (draft && draft.raw_material_id !== ingredient.raw_material_id)) {
|
||||
await api.deleteEditorMixIngredient(activeFormula.id, ingredient.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of cleanRows) {
|
||||
const original = row.id === null ? null : originalById.get(row.id);
|
||||
if (!original || original.raw_material_id !== row.raw_material_id) {
|
||||
await api.addEditorMixIngredient(activeFormula.id, {
|
||||
raw_material_id: row.raw_material_id,
|
||||
quantity_kg: row.quantity_kg,
|
||||
notes: row.notes
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (original.quantity_kg !== row.quantity_kg || (original.notes ?? null) !== row.notes) {
|
||||
await api.updateEditorMixIngredient(activeFormula.id, original.id, {
|
||||
quantity_kg: row.quantity_kg,
|
||||
notes: row.notes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadIngredientDrafts(await api.editorMixFormula(activeFormula.id));
|
||||
loadIngredientDrafts(await api.replaceEditorMixFormula(activeFormula.id, payloadRows));
|
||||
toast.success('Ingredients saved');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to save ingredients');
|
||||
@@ -262,6 +342,10 @@
|
||||
const ingredientTotalKg = $derived(
|
||||
ingredientDrafts.reduce((sum, ingredient) => sum + Number(ingredient.quantity_kg || 0), 0)
|
||||
);
|
||||
const percentTotal = $derived(
|
||||
ingredientDrafts.reduce((sum, ingredient) => sum + Number(ingredient.percentage || 0), 0)
|
||||
);
|
||||
const percentBalanced = $derived(Math.abs(percentTotal - 100) <= 0.1);
|
||||
|
||||
// Jump back to the first page whenever the filtered set changes.
|
||||
$effect(() => {
|
||||
@@ -340,18 +424,59 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl class="facts">
|
||||
<div class="fact">
|
||||
<dt>Mixes</dt>
|
||||
<dd>{visibleRows.length}</dd>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<dt>Unsaved</dt>
|
||||
<dd>{dirtyCount}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="status-actions">
|
||||
<dl class="facts">
|
||||
<div class="fact">
|
||||
<dt>Mixes</dt>
|
||||
<dd>{visibleRows.length}</dd>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<dt>Unsaved</dt>
|
||||
<dd>{dirtyCount}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<button class="apply-button new-mix-button" type="button" onclick={openCreateMix} disabled={creatingMix}>
|
||||
<Plus size={16} strokeWidth={2.4} />
|
||||
New mix
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if creatingMix}
|
||||
<form class="create-panel" transition:fade={{ duration: 120 }} onsubmit={(event) => { event.preventDefault(); createMix(); }}>
|
||||
<div class="create-head">
|
||||
<strong>New mix</strong>
|
||||
<span>Create a mix, then build its formula below.</span>
|
||||
</div>
|
||||
|
||||
<div class="create-fields">
|
||||
<label>
|
||||
<span>Client</span>
|
||||
<input bind:value={newMixClient} list="editor-client-options" placeholder="Client name" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Mix name</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input bind:value={newMixName} placeholder="Mix name" autofocus />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<datalist id="editor-client-options">
|
||||
{#each clientOptions as client}
|
||||
<option value={client}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
|
||||
<div class="create-actions">
|
||||
<button class="clear-button" type="button" onclick={cancelCreateMix}>Cancel</button>
|
||||
<button class="apply-button" type="submit" disabled={savingKey === 'mix-create'}>
|
||||
{savingKey === 'mix-create' ? 'Creating...' : 'Create mix'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="pagination-bar" aria-label="Mix table pagination">
|
||||
<span>{table.pageStart}-{table.pageEnd} of {table.total}</span>
|
||||
<label class="page-size">
|
||||
@@ -420,17 +545,30 @@
|
||||
<div class="ingredient-panel" transition:fade={{ duration: 120 }}>
|
||||
<div class="ingredient-head">
|
||||
<div>
|
||||
<span>Ingredients</span>
|
||||
<span>Ingredients{#if activeFormula} · {activeFormula.source === 'product' ? 'product formula' : 'mix master'}{/if}</span>
|
||||
<strong>{activeFormula?.client_name ?? row.client_name} / {activeFormula?.name ?? row.name}</strong>
|
||||
</div>
|
||||
<div class="ingredient-summary">
|
||||
<span>{ingredientDrafts.length} rows</span>
|
||||
<span>{ingredientTotalKg.toFixed(2)} kg</span>
|
||||
<label class="total-field">
|
||||
<span>Total mix (kg)</span>
|
||||
<input
|
||||
bind:value={totalReference}
|
||||
onchange={applyTotalEdit}
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.0001"
|
||||
aria-label="Total mix kilograms"
|
||||
/>
|
||||
</label>
|
||||
<span class="percent-chip" class:off={!percentBalanced} aria-live="polite">
|
||||
{percentTotal.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">Notes</span>
|
||||
<span class="grid-label">Remove</span>
|
||||
@@ -441,8 +579,17 @@
|
||||
<option value={material.id}>{material.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input
|
||||
bind:value={ingredient.percentage}
|
||||
onchange={() => applyPercentEdit(index)}
|
||||
type="number"
|
||||
min="0"
|
||||
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"
|
||||
@@ -454,8 +601,9 @@
|
||||
</div>
|
||||
|
||||
<div class="ingredient-footer">
|
||||
<span class="footer-total">Total {ingredientTotalKg.toFixed(2)} kg</span>
|
||||
<button class="clear-button" type="button" onclick={addIngredient}>Add ingredient</button>
|
||||
<button class="apply-button" type="button" disabled={savingKey === `mix-save:${row.id}`} onclick={saveIngredients}>
|
||||
<button class="apply-button" type="button" disabled={savingKey === `mix-save:${row.id}` || !percentBalanced} onclick={saveIngredients}>
|
||||
{savingKey === `mix-save:${row.id}` ? 'Saving...' : 'Save ingredients'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -602,12 +750,69 @@
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.new-mix-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.facts {
|
||||
display: flex;
|
||||
gap: 1.35rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.create-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem 1.1rem;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 32%, var(--color-border));
|
||||
border-radius: 0.9rem;
|
||||
}
|
||||
|
||||
.create-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.create-head strong {
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.create-head span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.create-fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.create-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.create-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.fact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1004,11 +1209,47 @@
|
||||
|
||||
.ingredient-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(110px, 0.25fr) minmax(180px, 0.7fr) auto;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(88px, 0.22fr) minmax(110px, 0.25fr) minmax(160px, 0.6fr) auto;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.total-field {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.total-field input {
|
||||
width: 7.5rem;
|
||||
}
|
||||
|
||||
.percent-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-success) 40%, var(--color-border));
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-success);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.percent-chip.off {
|
||||
border-color: color-mix(in srgb, var(--color-error) 45%, var(--color-border));
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.footer-total {
|
||||
margin-right: auto;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
color: var(--color-error);
|
||||
border-color: color-mix(in srgb, var(--color-error) 38%, var(--color-border));
|
||||
|
||||
@@ -180,14 +180,6 @@
|
||||
</script>
|
||||
|
||||
<div class="ordering">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow">Ordering Portal</p>
|
||||
<h1>Order catalogue</h1>
|
||||
<p class="sub">Your account-specific products and pricing. Prices exclude GST.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<!-- Catalogue -->
|
||||
<section class="catalogue surface-card">
|
||||
@@ -334,12 +326,9 @@
|
||||
|
||||
<style>
|
||||
.ordering { display: grid; gap: 1.25rem; }
|
||||
h1 { margin: 0.2rem 0; font-size: 1.5rem; }
|
||||
h2 { margin: 0 0 0.75rem; font-size: 1.05rem; }
|
||||
h3 { margin: 0; font-size: 0.98rem; }
|
||||
p { margin: 0; }
|
||||
.eyebrow { color: var(--color-brand, #2f6f4f); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.sub { color: #64776b; font-size: 0.88rem; }
|
||||
.surface-card { border: 1px solid rgba(34, 54, 45, 0.12); border-radius: 1rem; background: var(--surface, rgba(255,255,255,0.9)); padding: 1.1rem; }
|
||||
.layout { display: grid; grid-template-columns: minmax(0, 1fr) 22rem; gap: 1.25rem; align-items: start; }
|
||||
.toolbar { display: grid; gap: 0.6rem; margin-bottom: 1rem; }
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import '$lib/ordering/manage.css';
|
||||
import { findOrderingSection } from '$lib/navigation/client-navigation';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// The header mirrors the active section: "Order Management" eyebrow above, then
|
||||
// the current page's name (Orders, Products, …, or a nested page like Xero).
|
||||
// Reuse the rail's section finder so the title stays in sync with navigation.
|
||||
const sectionLabel = $derived(findOrderingSection(page.url.pathname)?.label ?? 'Orders');
|
||||
</script>
|
||||
|
||||
<!-- Section navigation lives in the primary left rail (and the mobile drawer),
|
||||
so the console pages don't repeat it inline. -->
|
||||
<div class="manage-shell">
|
||||
<header>
|
||||
<p class="eyebrow">Order Management</p>
|
||||
<h1>{sectionLabel}</h1>
|
||||
</header>
|
||||
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.manage-shell { display: grid; gap: 1rem; }
|
||||
h1 { margin: 0.15rem 0; font-size: 1.4rem; letter-spacing: -0.02em; }
|
||||
.eyebrow { color: var(--color-text-muted); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { ClipboardList, X } from 'lucide-svelte';
|
||||
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import { tooltip } from '$lib/actions/tooltip';
|
||||
import { money, label, statusTone, ORDER_STATUSES } from '$lib/ordering/format';
|
||||
import type { Order } from '$lib/types';
|
||||
|
||||
@@ -11,6 +14,16 @@
|
||||
orders = data.orders ?? [];
|
||||
});
|
||||
|
||||
let listQuery = $state('');
|
||||
const filteredOrders = $derived.by(() => {
|
||||
const q = listQuery.trim().toLowerCase();
|
||||
if (!q) return orders;
|
||||
return orders.filter((o) => {
|
||||
const ref = (o.order_number ?? `#${o.id}`).toLowerCase();
|
||||
return ref.includes(q) || (o.customer_name ?? '').toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
let selectedOrder = $state<Order | null>(null);
|
||||
let statusChoice = $state('');
|
||||
|
||||
@@ -26,6 +39,9 @@
|
||||
selectedOrder = null;
|
||||
statusChoice = '';
|
||||
}
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && selectedOrder) closeOrder();
|
||||
}
|
||||
async function refreshOrders() {
|
||||
try {
|
||||
orders = await api.orderingAdmin.orders();
|
||||
@@ -45,7 +61,10 @@
|
||||
async function overrideLine(lineId: number, value: string) {
|
||||
if (!selectedOrder || value === '') return;
|
||||
try {
|
||||
selectedOrder = await api.orderingAdmin.overrideLine(selectedOrder.id, lineId, { unit_price: Number(value), reason: 'Admin override' });
|
||||
selectedOrder = await api.orderingAdmin.overrideLine(selectedOrder.id, lineId, {
|
||||
unit_price: Number(value),
|
||||
reason: 'Admin override'
|
||||
});
|
||||
toast.success('Line price overridden.');
|
||||
await refreshOrders();
|
||||
} catch (e) {
|
||||
@@ -75,83 +94,167 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="surface-card">
|
||||
<div class="card-head">
|
||||
<h2>Orders ({orders.length})</h2>
|
||||
</div>
|
||||
{#if !orders.length}
|
||||
<p class="empty">No submitted orders.</p>
|
||||
{:else}
|
||||
<table class="clickable">
|
||||
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Subtotal</th><th>Xero</th></tr></thead>
|
||||
<tbody>
|
||||
{#each orders as o (o.id)}
|
||||
<tr class:selected={selectedOrder?.id === o.id} onclick={() => openOrder(o)}>
|
||||
<td>{o.order_number ?? `#${o.id}`}</td>
|
||||
<td>{o.customer_name}</td>
|
||||
<td><span class="pill {statusTone(o.status)}">{label(o.status)}</span></td>
|
||||
<td>{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
|
||||
<td>{o.xero_status ?? '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
<svelte:window onkeydown={handleWindowKeydown} />
|
||||
|
||||
{#if selectedOrder}
|
||||
<div class="modal-backdrop" role="presentation" onclick={closeOrder}>
|
||||
<div
|
||||
class="modal wide detail"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Order detail"
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => { if (event.key === 'Escape') closeOrder(); }}
|
||||
>
|
||||
<h2>{selectedOrder.order_number ?? `Order #${selectedOrder.id}`}</h2>
|
||||
<p class="muted">{selectedOrder.customer_name} · {label(selectedOrder.status)} · PO {selectedOrder.purchase_order_number ?? '—'}</p>
|
||||
<table class="lines">
|
||||
<thead><tr><th>Product</th><th>Qty</th><th>Unit</th><th>Override</th><th>Total</th></tr></thead>
|
||||
<div class="console-split">
|
||||
<!-- ── Left: order queue ─────────────────────────────────────────────────── -->
|
||||
<section class="surface-card">
|
||||
<div class="card-head">
|
||||
<h2>Orders <span class="count">{filteredOrders.length}</span></h2>
|
||||
</div>
|
||||
|
||||
<input class="list-search" type="search" placeholder="Search order or customer" bind:value={listQuery} />
|
||||
|
||||
{#if filteredOrders.length}
|
||||
<table class="clickable">
|
||||
<thead>
|
||||
<tr><th>Order</th><th>Status</th><th class="amt">Subtotal</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each selectedOrder.lines as l (l.id)}
|
||||
<tr>
|
||||
<td>{l.product_name}</td>
|
||||
<td>{l.quantity}</td>
|
||||
<td>{l.requires_quote ? 'Quote' : money(l.resolved_unit_price ?? l.unit_price)}</td>
|
||||
{#each filteredOrders as o (o.id)}
|
||||
<tr
|
||||
class:selected={selectedOrder?.id === o.id}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-pressed={selectedOrder?.id === o.id}
|
||||
onclick={() => openOrder(o)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openOrder(o);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<input class="ovr" type="number" step="0.01" placeholder={l.admin_override_price != null ? String(l.admin_override_price) : 'set'}
|
||||
onchange={(e) => overrideLine(l.id, e.currentTarget.value)} />
|
||||
<div class="id-cell">
|
||||
<span class="id-name">{o.order_number ?? `#${o.id}`}</span>
|
||||
<span class="id-sub">{o.customer_name ?? 'Unknown customer'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{money(l.line_total)}</td>
|
||||
<td><span class="pill {statusTone(o.status)}">{label(o.status)}</span></td>
|
||||
<td class="amt">{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="detail-total"><span>Subtotal (ex GST)</span><strong>{money(selectedOrder.subtotal_ex_gst)}</strong></div>
|
||||
{:else if orders.length}
|
||||
<p class="empty">No orders match “{listQuery}”.</p>
|
||||
{:else}
|
||||
<p class="empty">No submitted orders yet.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<select bind:value={statusChoice}>
|
||||
<option value="">Change status…</option>
|
||||
{#each ORDER_STATUSES as s}<option value={s}>{label(s)}</option>{/each}
|
||||
</select>
|
||||
<button class="primary" onclick={applyStatus} disabled={!statusChoice}>Apply</button>
|
||||
<button class="secondary" onclick={sendToXero}>Send to Xero</button>
|
||||
<button class="secondary" onclick={reopenOrder}>Reopen</button>
|
||||
<button class="secondary" onclick={closeOrder}>Close</button>
|
||||
<!-- ── Right: order detail ───────────────────────────────────────────────── -->
|
||||
{#if selectedOrder}
|
||||
<section class="surface-card detail workspace">
|
||||
<div class="workspace-head">
|
||||
<div class="detail-head head-row">
|
||||
<div class="detail-title">
|
||||
<p class="eyebrow">{selectedOrder.customer_name ?? 'Order'}</p>
|
||||
<h2>{selectedOrder.order_number ?? `Order #${selectedOrder.id}`}</h2>
|
||||
</div>
|
||||
<div class="detail-head-actions">
|
||||
<span class="pill {statusTone(selectedOrder.status)}">{label(selectedOrder.status)}</span>
|
||||
<button
|
||||
class="icon-btn"
|
||||
onclick={closeOrder}
|
||||
aria-label="Close order"
|
||||
use:tooltip={{ label: 'Close (Esc)', placement: 'bottom' }}
|
||||
>
|
||||
<X size={17} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions order-toolbar">
|
||||
<select bind:value={statusChoice} aria-label="Change status">
|
||||
<option value="">Change status…</option>
|
||||
{#each ORDER_STATUSES as s}<option value={s}>{label(s)}</option>{/each}
|
||||
</select>
|
||||
<button class="primary" onclick={applyStatus} disabled={!statusChoice}>Apply</button>
|
||||
<button class="secondary" onclick={sendToXero} use:tooltip={'Create or update the Xero invoice'}>Send to Xero</button>
|
||||
<button class="secondary" onclick={reopenOrder} use:tooltip={'Return this order to draft for editing'}>Reopen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedOrder.status_history?.length}
|
||||
<details class="history">
|
||||
<summary>Status history ({selectedOrder.status_history.length})</summary>
|
||||
<ul>
|
||||
{#each selectedOrder.status_history as h}
|
||||
<li>{label(h.from_status ?? 'new')} → {label(h.to_status)} · {h.actor_name ?? h.actor_type} · {new Date(h.created_at).toLocaleString('en-AU')}</li>
|
||||
<div class="workspace-body">
|
||||
<div class="meta">
|
||||
<div class="meta-item"><span>PO number</span><strong>{selectedOrder.purchase_order_number ?? '—'}</strong></div>
|
||||
<div class="meta-item"><span>Fulfilment</span><strong>{label(selectedOrder.fulfilment_method)}</strong></div>
|
||||
<div class="meta-item"><span>Xero</span><strong>{selectedOrder.xero_status ?? 'Not sent'}</strong></div>
|
||||
</div>
|
||||
|
||||
<table class="lines">
|
||||
<thead>
|
||||
<tr><th>Product</th><th class="amt">Qty</th><th class="amt">Unit</th><th>Override</th><th class="amt">Total</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each selectedOrder.lines as l (l.id)}
|
||||
<tr>
|
||||
<td>{l.product_name}</td>
|
||||
<td class="amt">{l.quantity}</td>
|
||||
<td class="amt">{l.requires_quote ? 'Quote' : money(l.resolved_unit_price ?? l.unit_price)}</td>
|
||||
<td>
|
||||
<input
|
||||
class="ovr"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder={l.admin_override_price != null ? String(l.admin_override_price) : 'set'}
|
||||
onchange={(e) => overrideLine(l.id, e.currentTarget.value)}
|
||||
/>
|
||||
</td>
|
||||
<td class="amt">{money(l.line_total)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="detail-total"><span>Subtotal (ex GST)</span><strong>{money(selectedOrder.subtotal_ex_gst)}</strong></div>
|
||||
|
||||
{#if selectedOrder.status_history?.length}
|
||||
<details class="history">
|
||||
<summary>Status history ({selectedOrder.status_history.length})</summary>
|
||||
<ul>
|
||||
{#each selectedOrder.status_history as h}
|
||||
<li>
|
||||
{label(h.from_status ?? 'new')} → {label(h.to_status)} ·
|
||||
{h.actor_name ?? h.actor_type} ·
|
||||
{new Date(h.created_at).toLocaleString('en-AU')}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="surface-card detail empty-detail">
|
||||
<span class="empty-icon" aria-hidden="true"><ClipboardList size={26} strokeWidth={1.7} /></span>
|
||||
<h2>Select an order</h2>
|
||||
<p class="muted">Choose an order from the queue to review its lines, adjust pricing, and move it through fulfilment.</p>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Right-align numeric columns for clean scanning. */
|
||||
.amt {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clickable tbody tr:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* The header already carries the divider, so the title row drops its own. */
|
||||
.head-row {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.order-toolbar {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { Building2, Plus } from 'lucide-svelte';
|
||||
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import { statusTone } from '$lib/ordering/format';
|
||||
import type { CustomerVisibilityRow, OrderingCustomer, OrderingCustomerUser } from '$lib/types';
|
||||
import CustomerWorkspace from '$lib/components/ordering/CustomerWorkspace.svelte';
|
||||
import type { OrderingCustomer } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -12,30 +15,49 @@
|
||||
customers = data.customers ?? [];
|
||||
});
|
||||
|
||||
// ── Customer list: search + inline create ──────────────────────────────────
|
||||
let listQuery = $state('');
|
||||
const filteredCustomers = $derived.by(() => {
|
||||
const q = listQuery.trim().toLowerCase();
|
||||
if (!q) return customers;
|
||||
return customers.filter(
|
||||
(c) => c.name.toLowerCase().includes(q) || c.client_code.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
let newCustomer = $state({ name: '', client_code: '' });
|
||||
let showNewCustomer = $state(false);
|
||||
let newCustomerNameInput: HTMLInputElement | null = $state(null);
|
||||
|
||||
function openNewCustomer() {
|
||||
newCustomer = { name: '', client_code: '' };
|
||||
showNewCustomer = true;
|
||||
}
|
||||
function closeNewCustomer() {
|
||||
showNewCustomer = false;
|
||||
function toggleNewCustomer() {
|
||||
showNewCustomer = !showNewCustomer;
|
||||
if (showNewCustomer) newCustomer = { name: '', client_code: '' };
|
||||
}
|
||||
$effect(() => {
|
||||
if (showNewCustomer) tick().then(() => newCustomerNameInput?.focus());
|
||||
});
|
||||
let selectedCustomer = $state<OrderingCustomer | null>(null);
|
||||
let custUsers = $state<OrderingCustomerUser[]>([]);
|
||||
let custVisibility = $state<CustomerVisibilityRow[]>([]);
|
||||
let newUser = $state({ full_name: '', email: '', role: 'buyer' });
|
||||
|
||||
async function refreshCustomers() {
|
||||
let selectedCustomer = $state<OrderingCustomer | null>(null);
|
||||
|
||||
function openCustomer(c: OrderingCustomer) {
|
||||
selectedCustomer = c;
|
||||
}
|
||||
function closeDetail() {
|
||||
selectedCustomer = null;
|
||||
}
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (showNewCustomer) showNewCustomer = false;
|
||||
else if (selectedCustomer) closeDetail();
|
||||
}
|
||||
|
||||
async function refreshCustomers(updated?: OrderingCustomer) {
|
||||
if (updated && selectedCustomer?.id === updated.id) selectedCustomer = updated;
|
||||
try {
|
||||
customers = await api.orderingAdmin.customers();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function createCustomer() {
|
||||
if (!newCustomer.name || !newCustomer.client_code) return toast.error('Name and code are required.');
|
||||
try {
|
||||
@@ -48,137 +70,101 @@
|
||||
toast.error(e instanceof Error ? e.message : 'Could not create customer.');
|
||||
}
|
||||
}
|
||||
async function openCustomer(c: OrderingCustomer) {
|
||||
selectedCustomer = c;
|
||||
try {
|
||||
[custUsers, custVisibility] = await Promise.all([
|
||||
api.orderingAdmin.customerUsers(c.id),
|
||||
api.orderingAdmin.visibility(c.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load customer.');
|
||||
}
|
||||
}
|
||||
async function toggleCustomerStatus(c: OrderingCustomer) {
|
||||
try {
|
||||
const updated = await api.orderingAdmin.updateCustomer(c.id, { status: c.status === 'active' ? 'disabled' : 'active' });
|
||||
toast.success(`Customer ${updated.status}.`);
|
||||
await refreshCustomers();
|
||||
if (selectedCustomer?.id === c.id) selectedCustomer = updated;
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
async function addUser() {
|
||||
if (!selectedCustomer) return;
|
||||
if (!newUser.full_name || !newUser.email) return toast.error('Name and email required.');
|
||||
try {
|
||||
await api.orderingAdmin.createCustomerUser(selectedCustomer.id, newUser);
|
||||
toast.success('User invited.');
|
||||
newUser = { full_name: '', email: '', role: 'buyer' };
|
||||
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
|
||||
await refreshCustomers();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not add user.');
|
||||
}
|
||||
}
|
||||
async function toggleUserStatus(u: OrderingCustomerUser) {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
const next = u.status === 'suspended' ? 'active' : 'suspended';
|
||||
await api.orderingAdmin.updateCustomerUser(selectedCustomer.id, u.id, { status: next });
|
||||
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
async function toggleVisibility(row: CustomerVisibilityRow) {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
await api.orderingAdmin.setVisibility(selectedCustomer.id, { product_id: row.product_id, visible: !row.visible });
|
||||
custVisibility = await api.orderingAdmin.visibility(selectedCustomer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="surface-card">
|
||||
<div class="card-head">
|
||||
<h2>Customers ({customers.length})</h2>
|
||||
<button class="primary" onclick={openNewCustomer}>New customer</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Code</th><th>Users</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each customers as c (c.id)}
|
||||
<tr class:selected={selectedCustomer?.id === c.id}>
|
||||
<td><button class="link" onclick={() => openCustomer(c)}>{c.name}</button></td>
|
||||
<td>{c.client_code}</td>
|
||||
<td>{c.user_count}</td>
|
||||
<td><span class="pill {statusTone(c.status)}">{c.status}</span></td>
|
||||
<td><button class="link" onclick={() => toggleCustomerStatus(c)}>{c.status === 'active' ? 'Disable' : 'Enable'}</button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<svelte:window onkeydown={handleWindowKeydown} />
|
||||
|
||||
{#if selectedCustomer}
|
||||
<section class="surface-card detail">
|
||||
<h2>{selectedCustomer.name}</h2>
|
||||
|
||||
<h3>Users</h3>
|
||||
<ul class="mini">
|
||||
{#each custUsers as u (u.id)}
|
||||
<li>{u.full_name} · {u.email} · {u.role} · {u.status}
|
||||
<button class="link" onclick={() => toggleUserStatus(u)}>{u.status === 'suspended' ? 'Reactivate' : 'Suspend'}</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="form-row">
|
||||
<input placeholder="Full name" bind:value={newUser.full_name} />
|
||||
<input placeholder="Email" bind:value={newUser.email} />
|
||||
<select bind:value={newUser.role}>
|
||||
<option value="owner">Owner</option><option value="buyer">Buyer</option>
|
||||
<option value="accounts">Accounts</option><option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<button class="secondary" onclick={addUser}>Invite</button>
|
||||
<div class="console-split">
|
||||
<!-- ── Left: customer roster ─────────────────────────────────────────────── -->
|
||||
<section class="surface-card">
|
||||
<div class="card-head">
|
||||
<h2>Customers <span class="count">{filteredCustomers.length}</span></h2>
|
||||
<button class="primary" onclick={toggleNewCustomer}>
|
||||
<Plus size={16} strokeWidth={2.2} aria-hidden="true" />
|
||||
{showNewCustomer ? 'Cancel' : 'New customer'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 class="mt">Product visibility</h3>
|
||||
<ul class="mini visibility">
|
||||
{#each custVisibility as row (row.product_id)}
|
||||
<li>
|
||||
<label class="check"><input type="checkbox" checked={row.visible} onchange={() => toggleVisibility(row)} /> {row.name}</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if showNewCustomer}
|
||||
<div class="create-panel">
|
||||
<div class="form-grid">
|
||||
<label class="full">Company name
|
||||
<input bind:this={newCustomerNameInput} bind:value={newCustomer.name} />
|
||||
</label>
|
||||
<label class="full">Client code
|
||||
<input placeholder="e.g. ACME" bind:value={newCustomer.client_code} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="secondary" onclick={toggleNewCustomer}>Cancel</button>
|
||||
<button class="primary" onclick={createCustomer}>Create customer</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="muted mt">Manage discounts and per-product pricing for this customer on the <a href="/ordering/manage/pricing">Pricing</a> page.</p>
|
||||
<input class="list-search" type="search" placeholder="Search name or code" bind:value={listQuery} />
|
||||
|
||||
{#if filteredCustomers.length}
|
||||
<table class="clickable">
|
||||
<thead>
|
||||
<tr><th>Customer</th><th class="amt">Users</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredCustomers as c (c.id)}
|
||||
<tr
|
||||
class:selected={selectedCustomer?.id === c.id}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-pressed={selectedCustomer?.id === c.id}
|
||||
onclick={() => openCustomer(c)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openCustomer(c);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<div class="id-cell">
|
||||
<span class="id-name">{c.name}</span>
|
||||
<span class="id-sub">{c.client_code}{c.discount_percent ? ` · ${c.discount_percent}% off` : ''}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="amt">{c.user_count}</td>
|
||||
<td><span class="pill {statusTone(c.status)}">{c.status}</span></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else if customers.length}
|
||||
<p class="empty">No customers match “{listQuery}”.</p>
|
||||
{:else}
|
||||
<p class="empty">No customers yet. Create one to start managing access.</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if showNewCustomer}
|
||||
<div class="modal-backdrop" role="presentation" onclick={closeNewCustomer}>
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="New customer"
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => { if (event.key === 'Escape') closeNewCustomer(); }}
|
||||
>
|
||||
<h2>New customer</h2>
|
||||
<div class="form-grid">
|
||||
<label class="full">Company name<input bind:this={newCustomerNameInput} bind:value={newCustomer.name} /></label>
|
||||
<label class="full">Client code<input placeholder="e.g. ACME" bind:value={newCustomer.client_code} /></label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="secondary" onclick={closeNewCustomer}>Cancel</button>
|
||||
<button class="primary" onclick={createCustomer}>Create customer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- ── Right: customer workspace ─────────────────────────────────────────── -->
|
||||
{#if selectedCustomer}
|
||||
<CustomerWorkspace customer={selectedCustomer} onChanged={refreshCustomers} onClose={closeDetail} />
|
||||
{:else}
|
||||
<section class="surface-card detail empty-detail">
|
||||
<span class="empty-icon" aria-hidden="true"><Building2 size={26} strokeWidth={1.7} /></span>
|
||||
<h2>Select a customer</h2>
|
||||
<p class="muted">
|
||||
Pick a company to open its workspace: details, people, catalogue access, orders, mixes, and history in one place.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.amt { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.primary { gap: 0.4rem; }
|
||||
.primary :global(svg) { display: block; }
|
||||
|
||||
.clickable tbody tr:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { Plus } from 'lucide-svelte';
|
||||
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import { tooltip } from '$lib/actions/tooltip';
|
||||
import { label, PRODUCT_CATEGORIES } from '$lib/ordering/format';
|
||||
import type { CatalogueProduct } from '$lib/types';
|
||||
|
||||
@@ -12,17 +15,35 @@
|
||||
products = data.products ?? [];
|
||||
});
|
||||
|
||||
const blankProduct = () => ({ name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null as number | null, requires_quote: false, active: true });
|
||||
let listQuery = $state('');
|
||||
const filteredProducts = $derived.by(() => {
|
||||
const q = listQuery.trim().toLowerCase();
|
||||
if (!q) return products;
|
||||
return products.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.sku.toLowerCase().includes(q) ||
|
||||
label(p.category).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const blankProduct = () => ({
|
||||
name: '',
|
||||
sku: '',
|
||||
category: 'grains',
|
||||
unit_of_measure: '20kg bag',
|
||||
min_order_quantity: 1,
|
||||
base_price: null as number | null,
|
||||
requires_quote: false,
|
||||
active: true
|
||||
});
|
||||
let newProduct = $state<Record<string, any>>(blankProduct());
|
||||
let showNewProduct = $state(false);
|
||||
let newProductNameInput: HTMLInputElement | null = $state(null);
|
||||
|
||||
function openNewProduct() {
|
||||
newProduct = blankProduct();
|
||||
showNewProduct = true;
|
||||
}
|
||||
function closeNewProduct() {
|
||||
showNewProduct = false;
|
||||
function toggleNewProduct() {
|
||||
showNewProduct = !showNewProduct;
|
||||
if (showNewProduct) newProduct = blankProduct();
|
||||
}
|
||||
$effect(() => {
|
||||
if (showNewProduct) tick().then(() => newProductNameInput?.focus());
|
||||
@@ -36,7 +57,11 @@
|
||||
async function createProduct() {
|
||||
if (!newProduct.name || !newProduct.sku) return toast.error('Name and SKU are required.');
|
||||
try {
|
||||
await api.orderingAdmin.createProduct({ ...newProduct, base_price: newProduct.base_price === null || newProduct.base_price === '' ? null : Number(newProduct.base_price) });
|
||||
await api.orderingAdmin.createProduct({
|
||||
...newProduct,
|
||||
base_price:
|
||||
newProduct.base_price === null || newProduct.base_price === '' ? null : Number(newProduct.base_price)
|
||||
});
|
||||
toast.success('Product created.');
|
||||
newProduct = blankProduct();
|
||||
showNewProduct = false;
|
||||
@@ -66,43 +91,22 @@
|
||||
|
||||
<section class="surface-card">
|
||||
<div class="card-head">
|
||||
<h2>Catalogue ({products.length})</h2>
|
||||
<button class="primary" onclick={openNewProduct}>New product</button>
|
||||
<h2>Catalogue <span class="count">{filteredProducts.length}</span></h2>
|
||||
<button class="primary" onclick={toggleNewProduct}>
|
||||
<Plus size={16} strokeWidth={2.2} aria-hidden="true" />
|
||||
{showNewProduct ? 'Cancel' : 'New product'}
|
||||
</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>SKU</th><th>Category</th><th>Base price</th><th>Active</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each products as p (p.id)}
|
||||
<tr>
|
||||
<td>{p.name}{#if p.requires_quote}<span class="tag">quote</span>{/if}</td>
|
||||
<td>{p.sku}</td>
|
||||
<td>{label(p.category)}</td>
|
||||
<td><input class="inline" type="number" step="0.01" value={p.base_price ?? ''} onchange={(e) => saveProductPrice(p, e.currentTarget.value)} /></td>
|
||||
<td>{p.active ? 'Yes' : 'No'}</td>
|
||||
<td><button class="link" onclick={() => toggleProductActive(p)}>{p.active ? 'Disable' : 'Enable'}</button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{#if showNewProduct}
|
||||
<div class="modal-backdrop" role="presentation" onclick={closeNewProduct}>
|
||||
<div
|
||||
class="modal wide"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="New product"
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => { if (event.key === 'Escape') closeNewProduct(); }}
|
||||
>
|
||||
<h2>New product</h2>
|
||||
{#if showNewProduct}
|
||||
<div class="create-panel">
|
||||
<div class="form-grid">
|
||||
<label>Name<input bind:this={newProductNameInput} bind:value={newProduct.name} /></label>
|
||||
<label>SKU<input bind:value={newProduct.sku} /></label>
|
||||
<label>Category
|
||||
<select bind:value={newProduct.category}>{#each PRODUCT_CATEGORIES as c}<option value={c}>{label(c)}</option>{/each}</select>
|
||||
<select bind:value={newProduct.category}>
|
||||
{#each PRODUCT_CATEGORIES as c}<option value={c}>{label(c)}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>Unit of measure<input bind:value={newProduct.unit_of_measure} /></label>
|
||||
<label>Min order qty<input type="number" bind:value={newProduct.min_order_quantity} /></label>
|
||||
@@ -110,9 +114,97 @@
|
||||
<label class="check full"><input type="checkbox" bind:checked={newProduct.requires_quote} /> Requires quote</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="secondary" onclick={closeNewProduct}>Cancel</button>
|
||||
<button class="secondary" onclick={toggleNewProduct}>Cancel</button>
|
||||
<button class="primary" onclick={createProduct}>Create product</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<input class="list-search" type="search" placeholder="Search name, SKU, or category" bind:value={listQuery} />
|
||||
|
||||
{#if filteredProducts.length}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Product</th><th>Category</th><th>Base price</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredProducts as p (p.id)}
|
||||
<tr class:dimmed={!p.active}>
|
||||
<td>
|
||||
<div class="id-cell">
|
||||
<span class="id-name">{p.name}{#if p.requires_quote}<span class="tag">quote</span>{/if}</span>
|
||||
<span class="id-sub">{p.sku} · {p.unit_of_measure}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{label(p.category)}</td>
|
||||
<td>
|
||||
{#if p.requires_quote}
|
||||
<span class="by-quote">By quote</span>
|
||||
{:else}
|
||||
<span class="price-field">
|
||||
<span class="price-prefix">$</span>
|
||||
<input
|
||||
class="inline price-input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={p.base_price ?? ''}
|
||||
placeholder="0.00"
|
||||
onchange={(e) => saveProductPrice(p, e.currentTarget.value)}
|
||||
/>
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td><span class="pill {p.active ? 'pos' : 'muted-pill'}">{p.active ? 'Active' : 'Disabled'}</span></td>
|
||||
<td class="row-action">
|
||||
<button
|
||||
class="link"
|
||||
onclick={() => toggleProductActive(p)}
|
||||
use:tooltip={p.active ? 'Hide from all customer catalogues' : 'Make orderable again'}
|
||||
>
|
||||
{p.active ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else if products.length}
|
||||
<p class="empty">No products match “{listQuery}”.</p>
|
||||
{:else}
|
||||
<p class="empty">No products yet. Create one to build the catalogue.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.primary { gap: 0.4rem; }
|
||||
.primary :global(svg) { display: block; }
|
||||
|
||||
/* Inactive rows read as muted without disappearing. */
|
||||
tr.dimmed .id-name,
|
||||
tr.dimmed td { color: var(--color-text-muted); }
|
||||
|
||||
.by-quote {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.price-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.price-prefix {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.price-input {
|
||||
width: 5.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.row-action {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -262,19 +262,12 @@
|
||||
</script>
|
||||
|
||||
<section class="costing-shell">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<span class="eyebrow">Alpha</span>
|
||||
<h2>Product Costing</h2>
|
||||
<p>Check prices, fix warnings, and update costing settings.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="primary-button" disabled={recalculating} type="button" onclick={recalculateAll}>
|
||||
<span class:spin={recalculating} aria-hidden="true"><RefreshCcw size={17} /></span>
|
||||
{recalculating ? 'Updating' : 'Update Prices'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="head-actions">
|
||||
<button class="primary-button" disabled={recalculating} type="button" onclick={recalculateAll}>
|
||||
<span class:spin={recalculating} aria-hidden="true"><RefreshCcw size={17} /></span>
|
||||
{recalculating ? 'Updating' : 'Update Prices'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="health-strip" aria-label="Product costing health summary">
|
||||
<article class="health-card">
|
||||
@@ -715,7 +708,6 @@
|
||||
animation: spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
@@ -763,7 +755,6 @@
|
||||
--costing-warn-row: var(--color-warning);
|
||||
}
|
||||
|
||||
.page-head,
|
||||
.health-strip,
|
||||
.workspace-grid,
|
||||
.section-toolbar,
|
||||
@@ -776,13 +767,6 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
padding: 0.35rem 0 0.15rem;
|
||||
}
|
||||
|
||||
.page-head p,
|
||||
.section-toolbar p,
|
||||
.block-heading span,
|
||||
small,
|
||||
@@ -795,22 +779,6 @@
|
||||
color: var(--costing-muted);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 0.12rem;
|
||||
color: var(--green-deep);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-head h2 {
|
||||
font-size: 1.85rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -162,7 +162,6 @@
|
||||
<section class="add-entry">
|
||||
<header class="page-header">
|
||||
<a class="back-link" href="/throughput"><ArrowLeft size={16} /> Back to log</a>
|
||||
<h1>Add Throughput Entry</h1>
|
||||
</header>
|
||||
|
||||
{#if successMessage}
|
||||
@@ -275,10 +274,6 @@
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user