v0.1.19 - Throughput overview & responsive header

- Throughput: new "Throughput Overview" header with Gauge icon and brand-green
  badge icons on each card (Today, This week, 4-week average, Horse Mix, Grain Mix)
- Throughput: inline rolling-range selector (7d / 4w / 6w / 12w, default 4 weeks)
  driving the customer-mix cards; stats window widened to 12 weeks so switching
  range is a pure client-side re-filter
- Throughput: cards collapse to a single even 5-across row on laptop and up,
  with container-query value text that scales to each card's width
- Throughput: date logic pinned to Australian Eastern time (fixes the day-early
  date); This week subtitle shows the Mon-Sun date range
- Throughput: subtler tinted add-form; removed the inline-entry kicker and the
  "Open full form" link
- Topbar: fix cramped laptop header - action toggles no longer wrap above the
  user button; search drops to its own row earlier

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 10:01:10 +12:00
co-authored by Claude Opus 4.8
parent 4ff372d307
commit 2de82776cb
64 changed files with 6034 additions and 1134 deletions
+100
View File
@@ -0,0 +1,100 @@
/**
* Modern hover/focus tooltip action.
*
* Renders a styled bubble appended to <body> with fixed positioning, so it is
* never clipped by an overflow:hidden ancestor (e.g. the topbar). Themes via the
* shared design tokens — see `.app-tooltip` in styles/theme.css.
*
* Usage: <button use:tooltip={'Switch to dark mode'}>…</button>
* <button use:tooltip={{ label: 'Whats new', placement: 'bottom' }}>…</button>
*/
type Placement = 'top' | 'bottom';
type TooltipOptions = string | { label: string; placement?: Placement; delay?: number };
const GAP = 8;
const DEFAULT_DELAY = 300;
function normalize(options: TooltipOptions): { label: string; placement: Placement; delay: number } {
if (typeof options === 'string') {
return { label: options, placement: 'bottom', delay: DEFAULT_DELAY };
}
return {
label: options.label,
placement: options.placement ?? 'bottom',
delay: options.delay ?? DEFAULT_DELAY
};
}
export function tooltip(node: HTMLElement, options: TooltipOptions) {
let current = normalize(options);
let bubble: HTMLDivElement | null = null;
let showTimer: ReturnType<typeof setTimeout> | null = null;
function position() {
if (!bubble) return;
const anchor = node.getBoundingClientRect();
const tip = bubble.getBoundingClientRect();
let left = anchor.left + anchor.width / 2 - tip.width / 2;
left = Math.max(GAP, Math.min(left, window.innerWidth - tip.width - GAP));
const top =
current.placement === 'top'
? anchor.top - tip.height - GAP
: anchor.bottom + GAP;
bubble.style.left = `${Math.round(left)}px`;
bubble.style.top = `${Math.round(top)}px`;
}
function show() {
if (bubble || !current.label) return;
bubble = document.createElement('div');
bubble.className = 'app-tooltip';
bubble.setAttribute('role', 'tooltip');
bubble.dataset.placement = current.placement;
bubble.textContent = current.label;
document.body.appendChild(bubble);
position();
// Trigger the fade/translate transition on the next frame.
requestAnimationFrame(() => bubble?.classList.add('is-visible'));
}
function scheduleShow() {
clearTimeout(showTimer ?? undefined);
showTimer = setTimeout(show, current.delay);
}
function hide() {
clearTimeout(showTimer ?? undefined);
showTimer = null;
bubble?.remove();
bubble = null;
}
node.addEventListener('mouseenter', scheduleShow);
node.addEventListener('mouseleave', hide);
node.addEventListener('focus', show);
node.addEventListener('blur', hide);
node.addEventListener('click', hide);
return {
update(next: TooltipOptions) {
current = normalize(next);
if (bubble) {
bubble.textContent = current.label;
bubble.dataset.placement = current.placement;
position();
}
},
destroy() {
hide();
node.removeEventListener('mouseenter', scheduleShow);
node.removeEventListener('mouseleave', hide);
node.removeEventListener('focus', show);
node.removeEventListener('blur', hide);
node.removeEventListener('click', hide);
}
};
}
+109 -2
View File
@@ -9,6 +9,11 @@ import type {
ClientUserUpdateInput,
LoginResponse,
EditorMixUpdateInput,
EditorMixRow,
EditorMixFormula,
EditorIngredientRow,
EditorIngredientCreateInput,
EditorIngredientUpdateInput,
EditorProductFormula,
EditorProductRow,
EditorProductUpdateInput,
@@ -38,10 +43,14 @@ import type {
OrderingCustomerUser,
OrderingNotificationSettings,
XeroStatus,
XeroContactList,
XeroContactLinkRow,
Scenario,
ThroughputEntry,
ThroughputEntryCreateInput,
ThroughputEntryUpdateInput,
ThroughputEntryListParams,
ThroughputImportResult,
ThroughputProduct,
ThroughputProductCreateInput,
ThroughputProductUpdateInput
@@ -250,6 +259,45 @@ async function request<T>(
}
}
// Multipart upload. Unlike `request`, we must NOT set Content-Type ourselves —
// the browser sets `multipart/form-data` with the correct boundary when given a
// FormData body. Mirrors `request`'s auth/cache/error handling otherwise.
async function uploadFile<T>(
path: string,
formData: FormData,
auth: AuthMode = 'none',
fetcher: ApiFetch = fetch
): Promise<T> {
try {
const response = await fetcher(resolveRequestUrl(path, fetcher), {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!response.ok) {
let message = 'Request failed';
try {
const body = (await response.json()) as { detail?: string };
message = body.detail ?? message;
} catch {
message = response.statusText || message;
}
throw new Error(message);
}
if (browser) {
clearApiCache();
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
} catch (error) {
throw normalizeRequestError(error);
}
}
async function requestBlob(
path: string,
options: RequestInit = {},
@@ -330,11 +378,36 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
editorMixes: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.q) search.set('q', params.q);
if (params?.client_name) search.set('client_name', params.client_name);
if (params?.limit) search.set('limit', String(params.limit));
const qs = search.toString();
const path = qs ? `/api/editor/mixes?${qs}` : '/api/editor/mixes';
return cachedFetchJson<EditorMixRow[]>(path, 'client', fetcher);
},
updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) =>
request<EditorProductRow[]>(`/api/editor/mixes/${mixId}`, {
request<EditorMixRow>(`/api/editor/mixes/${mixId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
editorMixFormula: (mixId: number) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'),
addEditorMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateEditorMixIngredient: (mixId: number, ingredientId: number, payload: MixIngredientUpdateInput) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteEditorMixIngredient: (mixId: number, ingredientId: number) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, {
method: 'DELETE'
}, 'client'),
editorProductFormula: (productId: number) =>
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients`, {}, 'client'),
addEditorProductIngredient: (productId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
@@ -351,6 +424,18 @@ export const api = {
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, {
method: 'DELETE'
}, 'client'),
editorIngredients: (fetcher?: ApiFetch) =>
cachedFetchJson<EditorIngredientRow[]>('/api/editor/ingredients', 'client', fetcher),
createEditorIngredient: (payload: EditorIngredientCreateInput) =>
request<EditorIngredientRow>('/api/editor/ingredients', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateEditorIngredient: (ingredientId: number, payload: EditorIngredientUpdateInput) =>
request<EditorIngredientRow>(`/api/editor/ingredients/${ingredientId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
productCosts: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher),
productCostingItems: (fetcher?: ApiFetch) =>
@@ -391,6 +476,18 @@ export const api = {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateThroughputEntry: (entryId: number, payload: ThroughputEntryUpdateInput) =>
request<ThroughputEntry>(`/api/throughput/entries/${entryId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteThroughputEntry: (entryId: number) =>
request<void>(`/api/throughput/entries/${entryId}`, { method: 'DELETE' }, 'client'),
importThroughputEntries: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return uploadFile<ThroughputImportResult>('/api/throughput/import', formData, 'client');
},
createThroughputProduct: (payload: ThroughputProductCreateInput) =>
request<ThroughputProduct>('/api/throughput/products', {
method: 'POST',
@@ -579,6 +676,16 @@ export const api = {
cachedFetchJson<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', 'client', fetcher),
updateNotificationSettings: (payload: Partial<OrderingNotificationSettings>) =>
request<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson<XeroStatus>('/api/ordering-admin/xero/status', 'client', fetcher)
xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson<XeroStatus>('/api/ordering-admin/xero/status', 'client', fetcher),
xeroContacts: (fetcher?: ApiFetch) =>
cachedFetchJson<XeroContactList>('/api/ordering-admin/xero/contacts', 'client', fetcher),
xeroContactLinks: (fetcher?: ApiFetch) =>
cachedFetchJson<XeroContactLinkRow[]>('/api/ordering-admin/xero/contact-links', 'client', fetcher),
linkCustomerToXero: (
customerId: number,
payload: { xero_contact_id: string; xero_contact_name?: string | null; xero_contact_email?: string | null }
) => request(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
unlinkCustomerFromXero: (customerId: number) =>
request<void>(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'DELETE' }, 'client')
}
};
+19 -5
View File
@@ -8,7 +8,7 @@ import packageInfo from '../../package.json';
*/
export type ChangelogEntry = {
version: string;
/** ISO date (YYYY-MM-DD) the version shipped. */
/** ISO date (YYYY-MM-DD) the version shipped. */a
date: string;
highlights: string[];
};
@@ -17,14 +17,28 @@ export type ChangelogEntry = {
export const APP_VERSION: string = packageInfo.version;
export const changelog: ChangelogEntry[] = [
{
version: '0.1.18',
date: '2026-06-12',
highlights: [
'App - Improvements',
'App - Bug fixes'
]
},
{
version: '0.1.17',
date: '2026-06-12',
highlights: [
'App - Improvements',
'App - Mix Calculator bug fixes'
]
},
{
version: '0.1.14',
date: '2026-06-11',
highlights: [
'New: private B2B customer ordering portal — customers browse their catalogue, see account-specific pricing, and submit orders.',
'Order management console for internal staff: review orders, manage products, pricing, and the full order lifecycle.',
'Customer-specific pricing engine (fixed, contract, price lists, tiered, and quote-only) calculated on the backend.',
'Order confirmations (PDF) and Xero submission, behind a clean integration layer.'
'Web App: Improved mix calculator',
'Web App: Improved design'
]
},
{
+114 -11
View File
@@ -9,43 +9,146 @@
</script>
{#if blocked}
<section class="auth-gate-card">
<p class="auth-gate-label">{label}</p>
<h2>{title}</h2>
<p>{detail}</p>
</section>
<div class="auth-gate-screen">
<section class="auth-gate-card" role="status" aria-live="polite">
<div class="auth-gate-loader" aria-hidden="true">
<span class="ring"></span>
<span class="ring ring-2"></span>
<span class="dot"></span>
</div>
<p class="auth-gate-label">{label}</p>
<h2>{title}</h2>
<p class="auth-gate-detail">{detail}</p>
</section>
</div>
{:else}
{@render children()}
{/if}
<style>
.auth-gate-screen {
display: grid;
place-items: center;
width: 100%;
min-height: 100%;
height: 100%;
padding: 2rem;
}
.auth-gate-card {
display: grid;
justify-items: center;
gap: 0.5rem;
padding: 1.35rem 1.4rem;
width: min(26rem, 100%);
padding: 2.1rem 2rem 2.25rem;
border: 1px solid var(--line);
border-radius: 1rem;
border-radius: 1.25rem;
background: var(--panel);
text-align: center;
box-shadow: 0 18px 50px -24px rgba(0, 0, 0, 0.4);
animation: auth-gate-in 380ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* ── Animated loader ───────────────────────────────────────── */
.auth-gate-loader {
position: relative;
width: 3.1rem;
height: 3.1rem;
margin-bottom: 0.65rem;
}
.auth-gate-loader .ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2.5px solid transparent;
border-top-color: var(--color-brand, #2f9e6f);
border-right-color: color-mix(in srgb, var(--color-brand, #2f9e6f) 45%, transparent);
animation: auth-gate-spin 0.85s linear infinite;
}
.auth-gate-loader .ring-2 {
inset: 0.42rem;
border-top-color: color-mix(in srgb, var(--color-brand, #2f9e6f) 60%, transparent);
border-right-color: transparent;
animation-duration: 1.25s;
animation-direction: reverse;
}
.auth-gate-loader .dot {
position: absolute;
inset: 0;
margin: auto;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--color-brand, #2f9e6f);
animation: auth-gate-pulse 1.1s ease-in-out infinite;
}
.auth-gate-label {
margin: 0;
color: var(--muted);
font-size: 0.76rem;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.auth-gate-card h2 {
margin: 0;
font-size: 1.18rem;
font-size: 1.2rem;
}
.auth-gate-card p:last-child {
.auth-gate-detail {
margin: 0;
max-width: 22rem;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.55;
}
@keyframes auth-gate-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes auth-gate-spin {
to {
transform: rotate(360deg);
}
}
@keyframes auth-gate-pulse {
0%,
100% {
transform: scale(0.7);
opacity: 0.55;
}
50% {
transform: scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.auth-gate-card {
animation: none;
}
.auth-gate-loader .ring,
.auth-gate-loader .ring-2 {
animation-duration: 1.6s;
}
.auth-gate-loader .dot {
animation: none;
}
}
</style>
+57 -12
View File
@@ -15,7 +15,6 @@
import {
canCreateMixSession as sessionCanCreateMixSession,
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
canOpenClientAccess as sessionCanOpenClientAccess,
canOpenDashboard as sessionCanOpenDashboard,
canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator,
@@ -32,21 +31,24 @@
isWorkspaceRouteAllowed
} from '$lib/workspace-access';
import {
accessControlItem,
baseSearchItems,
buildClientNavEntries,
clientBreadcrumbs,
dashboardItem,
editorItem,
ingredientsEditorItem,
footerLinks,
matchesRoute,
mixCalculatorItem,
orderingItem,
orderingManageChildren,
orderingManageGroup,
pageTitle,
productCostingItem,
reportingItem,
throughputItem,
type FooterLink,
type NavEntry,
type SearchItem,
type NavItem,
workingDocumentItems
@@ -89,7 +91,6 @@
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
const canOpenEditor = $derived(sessionCanOpenEditor($clientSession));
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
const canOpenClientAccess = $derived(sessionCanOpenClientAccess($clientSession));
const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession));
const workspaceHomeHref = $derived(sessionWorkspaceHomeHref($clientSession));
const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname));
@@ -116,15 +117,19 @@
// (/ordering/manage), customers get the catalogue (/ordering).
const canManageOrdering = $derived(sessionCanManageOrdering($clientSession));
const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($clientSession));
const visibleOrderingItem = $derived(
// Internal staff get the collapsible "Order Management" family (queue +
// products/customers/pricing/settings/integrations); customers get the single
// catalogue link. Either way it becomes one NavEntry the rail can render.
const visibleOrderingEntry = $derived<NavEntry | null>(
canManageOrdering
? { ...orderingItem, href: '/ordering/manage', label: 'Order Management', shortLabel: 'OM' }
? { kind: 'group', group: orderingManageGroup }
: canOpenCustomerOrdering
? orderingItem
? { kind: 'item', item: orderingItem }
: null
);
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null);
// Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the
// standalone operations/insights modules. Built from the same access-filtered
// items, so a role only ever sees the families it may open.
@@ -135,20 +140,18 @@
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []),
...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []),
...visibleWorkingDocumentItems
],
throughput: visibleThroughputItem,
ordering: visibleOrderingItem,
ordering: visibleOrderingEntry,
reporting: visibleReportingItem
})
);
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
const workspaceRole = $derived(getWorkspaceRole($clientSession));
const visibleFooterLinks = $derived([
...(!isOperationsUser ? footerLinks : []),
...(!canOpenClientAccess
? []
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel, icon: accessControlItem.icon }])
...(!isOperationsUser ? footerLinks : [])
] as FooterLink[]);
const primaryBottomNavigation = $derived(
[
@@ -169,6 +172,7 @@
if (item.href === '/mix-calculator') return canOpenMixCalculator;
if (item.href === '/product-costing') return sessionCanOpenProductCosting($clientSession);
if (item.href === '/editor') return canOpenEditor;
if (item.href === '/ingredients') return canOpenEditor;
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
if (item.href === '/settings') return canOpenSettings;
return true;
@@ -219,12 +223,18 @@
}
}
const filteredSearchItems = $derived(
// The palette previews at most this many rows. An admin can see every mix and
// session, so an unfiltered list would flood the dropdown; capping it keeps
// the preview short and pushes the user to type to narrow the match set.
const PALETTE_RESULT_LIMIT = 10;
const matchingSearchItems = $derived(
searchItems.filter((item) => {
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
return haystack.includes(paletteQuery.trim().toLowerCase());
})
);
const filteredSearchItems = $derived(matchingSearchItems.slice(0, PALETTE_RESULT_LIMIT));
const hiddenResultCount = $derived(matchingSearchItems.length - filteredSearchItems.length);
$effect(() => {
page.url.pathname;
@@ -476,6 +486,7 @@
}}
onOpenSettings={openSettings}
onSignOut={signOut}
onShowWhatsNew={() => (whatsNewOpen = true)}
/>
<main class="content">
@@ -627,6 +638,29 @@
</a>
{/if}
{#if canManageOrdering}
{@const GroupIcon = orderingManageGroup.icon}
<a class:active={page.url.pathname === '/ordering/manage'} href="/ordering/manage" onclick={() => (navOpen = false)}>
<span class="nav-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
<span>{orderingManageGroup.label}</span>
</a>
<div class="drawer-sublist">
{#each orderingManageChildren as child}
{@const ChildIcon = child.icon}
<a class:active={matchesRoute(child.href, page.url.pathname, child.exact)} href={child.href} onclick={() => (navOpen = false)}>
<span class="nav-icon"><ChildIcon size={18} strokeWidth={1.75} /></span>
<span>{child.label}</span>
</a>
{/each}
</div>
{:else if canOpenCustomerOrdering}
{@const Icon = orderingItem.icon}
<a class:active={matchesRoute(orderingItem.href, page.url.pathname)} href={orderingItem.href} onclick={() => (navOpen = false)}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{orderingItem.label}</span>
</a>
{/if}
{#if visibleWorkingDocumentItems.length}
<div class="drawer-sublist" id="drawer-working-documents-nav">
{#each visibleWorkingDocumentItems as item}
@@ -724,6 +758,9 @@
<small>{item.href}</small>
</button>
{/each}
{#if hiddenResultCount > 0}
<p class="palette-more">{hiddenResultCount} more {hiddenResultCount === 1 ? 'match' : 'matches'} — keep typing to narrow.</p>
{/if}
{:else}
<div class="palette-empty">
<strong>No results</strong>
@@ -1203,6 +1240,14 @@
justify-content: flex-start;
}
.palette-more {
margin: 0.25rem 0.4rem 0.15rem;
padding: 0.5rem 0.52rem 0.2rem;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.78rem;
}
.bottom-nav,
.bottom-drawer {
display: none;
@@ -103,7 +103,7 @@
<td>
<strong>{line.raw_material_name}</strong>
</td>
<td>{formatNumber(line.required_kg, 2)}kg</td>
<td>{formatNumber(line.required_kg, line.rounding_decimals ?? 2)}kg</td>
</tr>
{/each}
</tbody>
@@ -1,16 +1,20 @@
<script lang="ts">
import { Moon, Sun } from 'lucide-svelte';
import { resolvedTheme, toggleTheme } from '$lib/theme';
import { tooltip } from '$lib/actions/tooltip';
const isDark = $derived($resolvedTheme === 'dark');
const label = $derived(
isDark ? 'Switch to light mode' : 'Switch to dark mode'
);
</script>
<button
class="theme-toggle"
type="button"
onclick={toggleTheme}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={label}
use:tooltip={label}
>
{#if isDark}
<Sun size={18} strokeWidth={1.75} />
@@ -1,5 +1,5 @@
<script lang="ts">
import { Download, Printer } from 'lucide-svelte';
import { ArrowUpDown, Download, Printer } from 'lucide-svelte';
import { formatDate, formatNumber } from '$lib/format';
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
@@ -15,6 +15,37 @@
onDownloadPdf?: (() => void) | null;
} = $props();
// ── Ingredient sorting ──────────────────────────────────────────
// Default to heaviest ingredient first; clicking a header toggles direction
// (or switches column). Required kg starts descending, the name ascending.
type LineSortKey = 'raw_material_name' | 'required_kg';
let sortKey = $state<LineSortKey>('required_kg');
let sortDir = $state<'asc' | 'desc'>('desc');
function toggleSort(key: LineSortKey) {
if (sortKey === key) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
return;
}
sortKey = key;
sortDir = key === 'required_kg' ? 'desc' : 'asc';
}
function ariaSort(key: LineSortKey): 'ascending' | 'descending' | 'none' {
if (sortKey !== key) return 'none';
return sortDir === 'asc' ? 'ascending' : 'descending';
}
const sortedLines = $derived.by(() => {
const dir = sortDir === 'asc' ? 1 : -1;
return [...(preview?.lines ?? [])].sort((a, b) => {
const result =
sortKey === 'required_kg'
? (a.required_kg ?? 0) - (b.required_kg ?? 0)
: a.raw_material_name.localeCompare(b.raw_material_name, undefined, { sensitivity: 'base' });
return result * dir;
});
});
</script>
<article class="result-card">
@@ -81,17 +112,37 @@
<table>
<thead>
<tr>
<th>Raw material</th>
<th>Required kg</th>
<th aria-sort={ariaSort('raw_material_name')}>
<button
type="button"
class="sort-head"
class:active={sortKey === 'raw_material_name'}
onclick={() => toggleSort('raw_material_name')}
>
<span>Raw material</span>
<ArrowUpDown size={13} strokeWidth={2.1} aria-hidden="true" />
</button>
</th>
<th aria-sort={ariaSort('required_kg')}>
<button
type="button"
class="sort-head"
class:active={sortKey === 'required_kg'}
onclick={() => toggleSort('required_kg')}
>
<span>Required kg</span>
<ArrowUpDown size={13} strokeWidth={2.1} aria-hidden="true" />
</button>
</th>
</tr>
</thead>
<tbody>
{#each preview.lines as line}
{#each sortedLines as line}
<tr>
<td data-label="Raw material">
<strong>{line.raw_material_name}</strong>
</td>
<td data-label="Required kg">{formatNumber(line.required_kg, 2)}kg</td>
<td data-label="Required kg">{formatNumber(line.required_kg, line.rounding_decimals ?? 2)}kg</td>
</tr>
{/each}
</tbody>
@@ -278,6 +329,42 @@
text-transform: uppercase;
}
/* Clickable header: inherits the th look, adds a sort affordance. */
.sort-head {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
cursor: pointer;
transition: color 140ms ease;
}
.sort-head :global(svg) {
opacity: 0.45;
transition: opacity 140ms ease;
}
.sort-head:hover,
.sort-head.active {
color: var(--text);
}
.sort-head.active :global(svg) {
opacity: 1;
}
.sort-head:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
outline-offset: 2px;
border-radius: 0.3rem;
}
.primary-button,
.secondary-button {
display: inline-flex;
@@ -57,7 +57,6 @@
.secondary-rail-layout-content > :global(*) {
flex: 1 0 auto;
height: 100%;
min-height: 100%;
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { ChevronDown, LogOut, Settings } from 'lucide-svelte';
import type { ComponentType } from 'svelte';
@@ -68,35 +69,133 @@
return null;
});
// Open the active group once each time it changes. Because this only fires on
// a *change* of activeGroupId, a user who manually closes the group they're
// standing in won't have it reopened under them.
// Open the active group once each time it changes, collapsing any other open
// group so only one family is ever expanded (accordion). Because this only
// fires on a *change* of activeGroupId, a user who manually closes the group
// they're standing in won't have it reopened under them.
$effect(() => {
const id = activeGroupId;
if (id && lastAutoExpanded !== id) {
if (!openGroups[id]) {
openGroups[id] = true;
if (id) {
if (lastAutoExpanded !== id) {
openGroups = { [id]: true };
persistOpenState();
lastAutoExpanded = id;
}
lastAutoExpanded = id;
} else if (lastAutoExpanded !== null) {
// Just landed on a standalone module (Dashboard, Throughput, Reporting)
// from inside a family. Collapse the open family so the rail tidies itself,
// and forget the last auto-expanded group so returning to it — say
// Throughput → Order Management — fires the expand again. The
// `lastAutoExpanded !== null` guard makes this a one-shot per transition, so
// a group the user manually opens while on a standalone page stays open.
openGroups = {};
persistOpenState();
lastAutoExpanded = null;
}
});
const isOpen = (id: string) => openGroups[id] ?? false;
// Accordion: opening a group collapses every other group; closing just shuts
// the one. So expanding Order Management folds an already-open Costing away.
function toggleGroup(id: string) {
openGroups[id] = !isOpen(id);
openGroups = isOpen(id) ? {} : { [id]: true };
persistOpenState();
}
const moduleCount = $derived.by(() =>
entries.reduce((count, entry) => count + (entry.kind === 'item' ? 1 : entry.group.children.length), 0)
);
// Expand a group without ever collapsing it. Used by a linkable group header
// (Order Management) so clicking the label reveals its submenu the same way a
// toggle group does — the route may not change (you're already on its page),
// so the auto-expand effect can't be relied on to open it. The chevron remains
// the way to collapse the family.
function openGroup(id: string) {
if (isOpen(id)) return;
openGroups = { [id]: true };
persistOpenState();
}
// ── Third-level submenus (e.g. Integrations → Xero) ─────────────
// Tracked independently of the top-level accordion: a nested submenu can be
// open at the same time as its parent group, and toggling a top-level family
// must not wipe a sibling's nested state. Keyed by "<groupId>:<childHref>".
const SUB_STORAGE_KEY = 'hsf:nav:open-subgroups';
function restoreSubState(): Record<string, boolean> {
if (typeof window === 'undefined') return {};
try {
return JSON.parse(window.sessionStorage.getItem(SUB_STORAGE_KEY) ?? '{}');
} catch {
return {};
}
}
let openSubGroups = $state<Record<string, boolean>>(restoreSubState());
let lastAutoExpandedSub = $state<string | null>(null);
function persistSubState() {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(SUB_STORAGE_KEY, JSON.stringify(openSubGroups));
} catch {
// Private-mode storage failures shouldn't break navigation.
}
}
const subKey = (groupId: string, child: NavItem) => `${groupId}:${child.href}`;
/** True when a child row owns a nested submenu whose grandchild is active. */
function subGroupActive(child: NavItem) {
return child.children?.some((g) => matchesRoute(g.href, currentPath, g.exact)) ?? false;
}
const activeSubKey = $derived.by(() => {
for (const entry of entries) {
if (entry.kind !== 'group') continue;
for (const child of entry.group.children) {
if (child.children?.length && subGroupActive(child)) {
return subKey(entry.group.id, child);
}
}
}
return null;
});
// Auto-open the submenu holding the current page, once per change. `untrack`
// reads the current map without making it a dependency — otherwise writing it
// back would retrigger this effect endlessly.
$effect(() => {
const key = activeSubKey;
if (key) {
if (lastAutoExpandedSub !== key) {
openSubGroups = { ...untrack(() => openSubGroups), [key]: true };
persistSubState();
lastAutoExpandedSub = key;
}
} else {
lastAutoExpandedSub = null;
}
});
const isSubOpen = (key: string) => openSubGroups[key] ?? false;
function toggleSubGroup(key: string) {
openSubGroups = { ...openSubGroups, [key]: !isSubOpen(key) };
persistSubState();
}
// Reveal a nested submenu without collapsing it, mirroring openGroup for the
// third level: clicking the Integrations label opens its connected-systems
// list even when the route doesn't change.
function openSubGroup(key: string) {
if (isSubOpen(key)) return;
openSubGroups = { ...openSubGroups, [key]: true };
persistSubState();
}
</script>
{#snippet leafLink(item: NavItem, showIcon: boolean)}
{@const Icon = item.icon}
<a class="rail-row" class:active={matchesRoute(item.href, currentPath)} href={item.href}>
<a class="rail-row" class:active={matchesRoute(item.href, currentPath, item.exact)} href={item.href}>
{#if showIcon && Icon}
<span class="rail-icon"><Icon size={18} strokeWidth={1.75} /></span>
{/if}
@@ -120,14 +219,12 @@
<span class="brand-wordmark">Hunter Premium Produce</span>
<span class="brand-subtitle">Operations workspace</span>
</a>
<span class="module-pill">{moduleCount} modules</span>
</div>
<div class="sidebar-body">
<div class="rail-scroll">
<div class="rail-section-head">
<p class="rail-section-label">Modules</p>
<span class="rail-section-count">{moduleCount}</span>
</div>
<nav class="rail-nav" aria-label="Workspace navigation">
@@ -139,28 +236,96 @@
{@const GroupIcon = group.icon}
{@const groupActive = groupHasActiveChild(group, currentPath)}
{@const open = isOpen(group.id)}
{@const headerHref = group.href ?? group.children[0]?.href}
<div class="rail-group">
<button
type="button"
class="rail-row rail-group-toggle"
class:within-active={groupActive && !open}
aria-expanded={open}
onclick={() => toggleGroup(group.id)}
>
<span class="rail-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
<span class="rail-text">{group.label}</span>
<span class="rail-group-meta">
<span class="rail-group-count">{group.children.length}</span>
<span class="rail-chevron" class:open aria-hidden="true">
<ChevronDown size={15} strokeWidth={2} />
{#if headerHref}
<!-- Every family header both navigates and reveals its submenu:
the label goes to the family's landing route (Order Management
→ its queue; Costing → its first tool) and opens the child
list, while the chevron toggles independently. The header never
takes the full active pill — that belongs to the matching child
row — it only gets the subtle within-active emphasis when
collapsed. -->
<div class="rail-group-head" class:within-active={groupActive && !open}>
<a
class="rail-row rail-group-link"
href={headerHref}
onclick={() => openGroup(group.id)}
>
<span class="rail-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
<span class="rail-text">{group.label}</span>
</a>
<button
type="button"
class="rail-chevron-btn"
aria-expanded={open}
aria-label={`${open ? 'Collapse' : 'Expand'} ${group.label}`}
onclick={() => toggleGroup(group.id)}
>
<span class="rail-chevron" class:open aria-hidden="true">
<ChevronDown size={15} strokeWidth={2} />
</span>
</button>
</div>
{:else}
<button
type="button"
class="rail-row rail-group-toggle"
class:within-active={groupActive && !open}
aria-expanded={open}
onclick={() => toggleGroup(group.id)}
>
<span class="rail-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
<span class="rail-text">{group.label}</span>
<span class="rail-group-meta">
<span class="rail-chevron" class:open aria-hidden="true">
<ChevronDown size={15} strokeWidth={2} />
</span>
</span>
</span>
</button>
</button>
{/if}
{#if open}
<div class="rail-children">
{#each group.children as child}
{@render leafLink(child, false)}
{#if child.children?.length}
{@const key = subKey(group.id, child)}
{@const subOpen = isSubOpen(key)}
{@const subActive = subGroupActive(child)}
<!-- Third layer: child row links to its own page; chevron
reveals the nested submenu (e.g. Integrations → Xero). -->
<div class="rail-group-head rail-subgroup-head" class:within-active={subActive && !subOpen}>
<a
class="rail-row rail-group-link"
class:active={matchesRoute(child.href, currentPath, child.exact)}
href={child.href}
onclick={() => openSubGroup(key)}
>
<span class="rail-text">{child.label}</span>
{#if child.badge}<span class="rail-badge">{child.badge}</span>{/if}
</a>
<button
type="button"
class="rail-chevron-btn"
aria-expanded={subOpen}
aria-label={`${subOpen ? 'Collapse' : 'Expand'} ${child.label}`}
onclick={() => toggleSubGroup(key)}
>
<span class="rail-chevron" class:open={subOpen} aria-hidden="true">
<ChevronDown size={15} strokeWidth={2} />
</span>
</button>
</div>
{#if subOpen}
<div class="rail-children rail-subchildren">
{#each child.children as grandchild}
{@render leafLink(grandchild, false)}
{/each}
</div>
{/if}
{:else}
{@render leafLink(child, false)}
{/if}
{/each}
</div>
{/if}
@@ -255,20 +420,6 @@
padding: 0 0.5rem;
}
.rail-section-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
height: 1.35rem;
padding: 0 0.42rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-text-strong) 6%, transparent);
color: var(--sidebar-text-muted);
font-size: 0.7rem;
font-weight: 700;
}
.brand-row {
display: flex;
align-items: flex-start;
@@ -309,20 +460,6 @@
line-height: 1.3;
}
.module-pill {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.34rem 0.58rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-active-bg) 10%, transparent);
color: var(--sidebar-active-bg);
font-size: 0.7rem;
font-weight: 700;
white-space: nowrap;
}
/* ── Navigation rows ─────────────────────────────────────────── */
.rail-nav {
display: grid;
@@ -425,32 +562,91 @@
gap: 0.42rem;
}
.rail-group-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.35rem;
height: 1.2rem;
padding: 0 0.32rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-text-strong) 6%, transparent);
color: var(--sidebar-text-muted);
font-size: 0.66rem;
font-weight: 700;
line-height: 1;
}
.rail-group-toggle.within-active {
color: var(--sidebar-text-strong);
font-weight: 600;
}
.rail-group-toggle.within-active .rail-group-count {
.rail-group-toggle.within-active .rail-icon,
.rail-group-toggle.within-active .rail-chevron {
color: var(--sidebar-text-strong);
}
.rail-group-toggle.within-active .rail-icon,
.rail-group-toggle.within-active .rail-chevron {
/* ── Linkable group header (label links, chevron toggles) ────── */
/* The label and chevron stay separate click targets (navigate vs toggle) but
share one hover pill on the wrapper, so the whole header — icon, title,
chevron — lights up as a single button instead of two halves. */
.rail-group-head {
display: flex;
align-items: center;
gap: 0;
border-radius: 0.8rem;
transition: background-color 160ms ease;
}
.rail-group-head:hover {
background: var(--sidebar-hover);
}
.rail-group-head:hover .rail-group-link,
.rail-group-head:hover .rail-icon,
.rail-group-head:hover .rail-chevron {
color: var(--sidebar-text-strong);
}
/* Let the wrapper own the hover background; the inner targets stay transparent
so they don't paint a second, mismatched pill on top. */
.rail-group-head .rail-group-link:hover,
.rail-group-head .rail-chevron-btn:hover {
background: transparent;
}
/* Category headers carry the lighter second-level row treatment (size, weight,
radius); only the leading icon and chevron mark them as parents. Standalone
top-level destinations (Dashboard, Throughput) keep the larger base row. */
.rail-group-head .rail-group-link,
.rail-group-toggle {
min-height: 2.45rem;
padding: 0.48rem 0.62rem 0.48rem 0.72rem;
font-size: 0.88rem;
border-radius: 0.8rem;
}
.rail-group-head .rail-group-link {
flex: 1;
min-width: 0;
}
.rail-group-head.within-active .rail-group-link {
color: var(--sidebar-text-strong);
font-weight: 600;
}
.rail-group-head.within-active .rail-group-link .rail-icon {
color: var(--sidebar-text-strong);
}
.rail-chevron-btn {
display: inline-flex;
align-items: center;
gap: 0.42rem;
flex-shrink: 0;
min-height: 2.45rem;
padding: 0 0.58rem;
border: none;
border-radius: 0.8rem;
background: transparent;
color: var(--sidebar-icon);
cursor: pointer;
transition: background-color 160ms ease, color 160ms ease;
}
.rail-chevron-btn:hover {
background: var(--sidebar-hover);
color: var(--sidebar-text-strong);
}
.rail-group-head.within-active .rail-chevron-btn {
color: var(--sidebar-text-strong);
}
@@ -493,6 +689,23 @@
border-radius: 0.8rem;
}
/* Third level: nest the submenu a little deeper than its parent row, with its
own guide line, so the hierarchy reads as Group Section Item. */
.rail-subgroup-head {
margin-left: 0.1rem;
}
.rail-subchildren {
margin-left: 0.5rem;
padding-left: 0.95rem;
animation: none;
}
.rail-subchildren .rail-row {
min-height: 2.2rem;
font-size: 0.85rem;
}
@keyframes rail-reveal {
from {
opacity: 0;
@@ -531,14 +744,20 @@
font-size: 0.76rem;
}
.sidebar-meta-top,
.sidebar-meta-bottom {
.sidebar-meta-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.sidebar-meta-bottom {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.45rem;
}
.sidebar-meta-foot small {
font-size: 0.72rem;
line-height: 1.35;
@@ -1,8 +1,9 @@
<script lang="ts">
import { Settings } from 'lucide-svelte';
import { Settings, Sparkles } from 'lucide-svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
import { tooltip } from '$lib/actions/tooltip';
import type { AppSession } from '$lib/session';
import type { Crumb } from '$lib/navigation/client-navigation';
@@ -18,7 +19,8 @@
onOpenPalette,
onToggleUserMenu,
onOpenSettings,
onSignOut
onSignOut,
onShowWhatsNew
}: {
breadcrumbs: Crumb[];
title: string;
@@ -32,6 +34,7 @@
onToggleUserMenu: () => void;
onOpenSettings: () => void;
onSignOut: () => void;
onShowWhatsNew: () => void;
} = $props();
</script>
@@ -64,6 +67,16 @@
{/if}
<div class="topbar-actions">
<button
class="whats-new-toggle"
type="button"
onclick={onShowWhatsNew}
aria-label="What's new"
use:tooltip={"What's new — latest updates and changes"}
>
<Sparkles size={18} strokeWidth={1.75} />
</button>
<ThemeToggle />
<div class="menu-wrap user-menu-wrap">
@@ -122,7 +135,10 @@
<style>
.topbar {
display: grid;
grid-template-columns: 1fr minmax(20rem, 36rem) 1fr;
/* Left flexes/truncates, search shrinks within a cap, and the actions take
exactly their content width (auto) so they're never squeezed into
wrapping. */
grid-template-columns: minmax(0, 1fr) minmax(0, 30rem) auto;
align-items: center;
gap: 0.75rem;
padding: 0.72rem 1.2rem;
@@ -141,7 +157,8 @@
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding-right: 0.9rem;
/* Extra right padding gives the scaled-up logo room before the divider. */
padding-right: 1.6rem;
border-right: 1px solid var(--color-border);
}
@@ -149,6 +166,16 @@
height: 2rem;
width: auto;
display: block;
/* Zoom the wide logo lockup in so the wordmark is legible, without growing
the header: transform scales the visual only, leaving the 2rem layout box
(and therefore the topbar height) untouched. Anchored left so it grows
rightward from the topbar's left padding edge. */
transform: scale(1.45);
transform-origin: left center;
}
.topbar-copy {
min-width: 0;
}
.topbar-copy h1 {
@@ -156,6 +183,11 @@
font-size: 1.34rem;
font-weight: 700;
letter-spacing: -0.01em;
/* Yield gracefully when space is tight rather than pushing the actions
into a wrap. */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.breadcrumbs {
@@ -202,11 +234,33 @@
.topbar-actions {
display: flex;
align-items: center;
gap: 0.68rem;
flex-wrap: wrap;
gap: 0.6rem;
/* Never let the What's-new / theme toggles wrap above the user button. */
flex-wrap: nowrap;
justify-content: flex-end;
}
/* Matches the ThemeToggle button so the two sit as a pair. */
.whats-new-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.6rem;
height: 2.6rem;
flex-shrink: 0;
border: 1px solid var(--color-border);
border-radius: 0.82rem;
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
}
.whats-new-toggle:hover {
background: var(--color-surface-hover);
color: var(--color-text-primary);
}
.workspace-label {
color: var(--muted);
font-size: 0.76rem;
@@ -390,7 +444,10 @@
background: var(--panel-soft);
}
@media (max-width: 1180px) {
/* Drop the search to its own row early: with the 252px sidebar present, a
laptop's content width is already tight well above the sidebar's own
collapse point, so keep the top row to brand + actions only. */
@media (max-width: 1280px) {
.topbar {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
@@ -417,13 +474,19 @@
}
.topbar-brand {
padding-right: 0.6rem;
padding-right: 1.1rem;
}
.topbar-brand img {
height: 1.6rem;
}
/* On phones let the user button drop onto its own line below the icon
toggles again (the desktop nowrap rule would otherwise overflow). */
.topbar-actions {
flex-wrap: wrap;
}
.user-trigger {
min-width: auto;
width: 100%;
@@ -61,6 +61,15 @@
}
});
// Reflect a selection set from outside (e.g. when an entry is loaded into the
// composer to be edited) so the search box shows the chosen product, not blank.
$effect(() => {
if (productId && !focused) {
const match = products.find((p) => String(p.id) === productId);
if (match) query = label(match);
}
});
// If the active client no longer contains the selected product, drop it.
$effect(() => {
if (selected && clientName && (selected.client_name ?? '') !== clientName) {
@@ -120,20 +129,18 @@
</script>
<div class="picker" bind:this={root} onfocusin={() => (focused = true)} onfocusout={onFocusOut}>
<div class="client-row">
<label class="client-label" for={`${inputId}-client`}>Client</label>
<select
id={`${inputId}-client`}
class="client-select"
bind:value={clientName}
{disabled}
>
<option value="">All clients</option>
{#each clients as client (client)}
<option value={client}>{client}</option>
{/each}
</select>
</div>
<select
id={`${inputId}-client`}
class="client-select"
bind:value={clientName}
aria-label="Filter by client"
{disabled}
>
<option value="">All clients</option>
{#each clients as client (client)}
<option value={client}>{client}</option>
{/each}
</select>
<div class="combo" role="combobox" aria-expanded={open} aria-haspopup="listbox" aria-controls={`${inputId}-list`}>
<span class="combo-icon" aria-hidden="true"><Search size={16} strokeWidth={2.2} /></span>
@@ -196,30 +203,23 @@
</div>
<style>
/* Client filter and product search sit side by side so the product is never
stacked underneath the client. They wrap to two rows only when the cell is
too narrow to keep both readable. */
.picker {
display: flex;
flex-direction: column;
gap: 0.4rem;
flex-direction: row;
align-items: stretch;
gap: 0.45rem;
min-width: 0;
}
.client-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.client-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
white-space: nowrap;
}
.client-select {
flex: 1 1 auto;
min-width: 0;
min-height: 40px;
flex: 0 1 8.5rem;
min-width: 6rem;
min-height: 48px;
padding: 0.4rem 0.55rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.5rem;
border-radius: 0.55rem;
font: inherit;
background: var(--color-bg-surface, #fff);
color: var(--color-text-primary, #111827);
@@ -228,8 +228,17 @@
position: relative;
display: flex;
align-items: center;
flex: 1 1 auto;
min-width: 0;
}
@media (max-width: 560px) {
.picker {
flex-wrap: wrap;
}
.client-select {
flex: 1 1 100%;
}
}
.combo-icon {
position: absolute;
left: 0.6rem;
@@ -276,7 +285,7 @@
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 50;
z-index: 200;
margin: 0;
padding: 0.25rem;
list-style: none;
+130 -10
View File
@@ -2,12 +2,20 @@ import {
BadgeDollarSign,
Calculator,
ClipboardPenLine,
FlaskConical,
Gauge,
Layers,
LayoutDashboard,
Link2,
ListOrdered,
Package,
Plug,
ShieldCheck,
ShoppingCart,
TrendingUp
SlidersHorizontal,
Tags,
TrendingUp,
Users
} from 'lucide-svelte';
import type { ComponentType } from 'svelte';
@@ -31,6 +39,18 @@ export type NavItem = {
icon: ComponentType;
moduleKey?: string;
badge?: string;
/**
* Highlight this row only on an exact pathname match instead of a prefix
* match. Needed for parent routes like `/ordering/manage` that are a prefix
* of their siblings (`/ordering/manage/products`).
*/
exact?: boolean;
/**
* Optional third-level submenu. A child with `children` renders as its own
* collapsible row inside a group (e.g. Integrations Xero). The row stays a
* link to its own `href`; a chevron toggles the nested list.
*/
children?: NavItem[];
};
export type FooterLink = {
@@ -50,6 +70,14 @@ export type NavGroup = {
label: string;
icon: ComponentType;
children: NavItem[];
/**
* When set, the group header is itself a link (clicking it navigates here)
* while a separate chevron still toggles the child list. Used by Order
* Management: clicking the header lands on the order queue.
*/
href?: string;
/** Exact-match the header link's active state (see NavItem.exact). */
exact?: boolean;
};
/** The rail is a sequence of standalone items and collapsible groups. */
@@ -96,6 +124,15 @@ export const editorItem: NavItem = {
badge: 'test'
};
export const ingredientsEditorItem: NavItem = {
href: '/ingredients',
label: 'Ingredients Editor',
shortLabel: 'IE',
icon: FlaskConical,
moduleKey: 'products',
badge: 'test'
};
export const reportingItem: NavItem = {
href: '/reporting',
label: 'Reporting',
@@ -121,6 +158,37 @@ export const orderingItem: NavItem = {
moduleKey: 'ordering'
};
/** Third-level submenu under Integrations. Each connected system is its own row. */
export const integrationsChildren: NavItem[] = [
{ href: '/ordering/manage/integrations/xero', label: 'Xero', shortLabel: 'XE', icon: Link2, moduleKey: 'ordering' }
];
/**
* Children of the internal "Order Management" family. The first entry points at
* the management root (`/ordering/manage`) which renders the order queue, so it
* needs `exact` matching to avoid lighting up on its sibling routes. Integrations
* is itself a parent: it links to the integrations landing and expands to its
* connected systems (Xero) as a third sidebar layer.
*/
export const orderingManageChildren: NavItem[] = [
{ href: '/ordering/manage', label: 'Orders', shortLabel: 'OQ', icon: ListOrdered, moduleKey: 'ordering', exact: true },
{ href: '/ordering/manage/products', label: 'Products', shortLabel: 'PR', icon: Package, moduleKey: 'ordering' },
{ href: '/ordering/manage/customers', label: 'Customers', shortLabel: 'CU', icon: Users, moduleKey: 'ordering' },
{ href: '/ordering/manage/pricing', label: 'Pricing', shortLabel: 'PX', icon: Tags, moduleKey: 'ordering' },
{ href: '/ordering/manage/settings', label: 'Settings', shortLabel: 'ST', icon: SlidersHorizontal, moduleKey: 'ordering' },
{ href: '/ordering/manage/integrations', label: 'Integrations', shortLabel: 'IN', icon: Plug, moduleKey: 'ordering', exact: true, children: integrationsChildren }
];
/** The collapsible Order Management family for internal staff. */
export const orderingManageGroup: NavGroup = {
id: 'ordering',
label: 'Order Management',
icon: ShoppingCart,
href: '/ordering/manage',
exact: true,
children: orderingManageChildren
};
export const workingDocumentItems: NavItem[] = [
// Mix Master remains available through the existing route and access logic,
// but is temporarily hidden from the sidebar.
@@ -140,6 +208,7 @@ export const clientNavigationItems: NavItem[] = [
productCostingItem,
throughputItem,
editorItem,
ingredientsEditorItem,
accessControlItem
];
@@ -155,8 +224,14 @@ export const baseSearchItems: SearchItem[] = [
{
href: '/editor',
label: 'Open Mix Editor',
description: 'Edit client, product, and mix naming from one table.',
keywords: 'editor products mixes clients names bulk table phf horse manning'
description: 'Edit mix names, status, and ingredients from one table.',
keywords: 'editor mixes clients names status ingredients recipe table phf horse manning'
},
{
href: '/ingredients',
label: 'Open Ingredients Editor',
description: 'Curate the raw material ingredients available to mixes.',
keywords: 'ingredients editor raw materials supplier unit kg per unit catalogue mixes'
},
{
href: '/',
@@ -219,7 +294,7 @@ export function buildClientNavEntries(visible: {
dashboard?: NavItem | null;
costing: NavItem[];
throughput?: NavItem | null;
ordering?: NavItem | null;
ordering?: NavEntry | null;
reporting?: NavItem | null;
}): NavEntry[] {
const entries: NavEntry[] = [];
@@ -236,7 +311,7 @@ export function buildClientNavEntries(visible: {
}
if (visible.ordering) {
entries.push({ kind: 'item', item: visible.ordering });
entries.push(visible.ordering);
}
if (visible.throughput) {
@@ -250,16 +325,47 @@ export function buildClientNavEntries(visible: {
return entries;
}
/** True when any of a group's children matches the current route. */
export function groupHasActiveChild(group: NavGroup, pathname: string) {
return group.children.some((child) => matchesRoute(child.href, pathname));
/** True when a row or any of its nested children matches the current route. */
function itemOrChildActive(item: NavItem, pathname: string): boolean {
if (matchesRoute(item.href, pathname, item.exact)) return true;
return item.children?.some((child) => matchesRoute(child.href, pathname, child.exact)) ?? false;
}
export function matchesRoute(href: string, pathname: string) {
return href === '/' ? pathname === '/' : pathname.startsWith(href);
/** True when any of a group's children (or grandchildren) matches the route. */
export function groupHasActiveChild(group: NavGroup, pathname: string) {
return group.children.some((child) => itemOrChildActive(child, pathname));
}
export function matchesRoute(href: string, pathname: string, exact = false) {
if (href === '/') return pathname === '/';
if (exact) return pathname === href;
return pathname.startsWith(href);
}
/**
* Resolve the deepest Order Management section row for a path, descending into
* third-level submenus (Integrations Xero) so headers and breadcrumbs name the
* actual page rather than the parent. Returns null for the management root.
*/
export function findOrderingSection(pathname: string): NavItem | null {
for (const child of orderingManageChildren) {
// Check grandchildren first so a nested page (Xero) wins over its parent.
for (const grandchild of child.children ?? []) {
if (matchesRoute(grandchild.href, pathname, grandchild.exact)) return grandchild;
}
if (matchesRoute(child.href, pathname, child.exact)) return child;
}
return null;
}
export function pageTitle(pathname: string) {
if (pathname.startsWith('/ordering/manage')) {
const section = findOrderingSection(pathname);
return section && section.href !== '/ordering/manage'
? `Order Management · ${section.label}`
: 'Order Management';
}
if (pathname.startsWith('/ordering')) return 'Ordering';
return clientNavigationItems.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard';
}
@@ -297,6 +403,20 @@ export function clientBreadcrumbs(pathname: string, session?: AppSession | null)
return base;
}
if (pathname.startsWith('/ordering/manage')) {
const section = findOrderingSection(pathname);
if (!section || section.href === '/ordering/manage') {
return [...crumbs, { label: 'Order Management' }];
}
const chain: Crumb[] = [...crumbs, { label: 'Order Management', href: '/ordering/manage' }];
// When the section is a nested grandchild (e.g. Xero), include its parent
// (Integrations) as an intermediate crumb.
const parent = orderingManageChildren.find((c) => c.children?.includes(section));
if (parent) chain.push({ label: parent.label, href: parent.href });
chain.push({ label: section.label });
return chain;
}
const sectionMap: Record<string, string> = {
'/raw-materials': 'Raw Materials',
'/product-costing': 'Product Costing',
+63
View File
@@ -0,0 +1,63 @@
// Shared formatting helpers for the ordering management console. Kept tiny and
// dependency-free so each split route page (orders, products, pricing, …) can
// import them instead of re-declaring the same money/label functions.
const AUD = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
/** Format a value as AUD, or an em dash when null/undefined. */
export function money(value: number | null | undefined): string {
if (value == null) return '—';
return AUD.format(value);
}
/** Turn a snake_case status/category key into Title Case for display. */
export function label(value: string): string {
return value.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Order workflow statuses, in the order they typically progress. */
export const ORDER_STATUSES = [
'submitted',
'under_review',
'confirmed',
'sent_to_xero',
'in_production',
'ready_for_pickup',
'dispatched',
'completed',
'cancelled'
] as const;
/**
* Map an order / customer status to a pill tone class so states are scannable
* at a glance: warn = needs attention, info = in flight, pos = done/active,
* danger = cancelled. Unknown values fall back to the neutral base pill.
*/
const STATUS_TONE: Record<string, string> = {
submitted: 'warn',
under_review: 'warn',
confirmed: 'pos',
completed: 'pos',
active: 'pos',
cancelled: 'danger',
disabled: 'danger',
suspended: 'danger',
sent_to_xero: 'info',
in_production: 'info',
ready_for_pickup: 'info',
dispatched: 'info'
};
export function statusTone(value: string): string {
return STATUS_TONE[value] ?? '';
}
/** Catalogue product categories. */
export const PRODUCT_CATEGORIES = [
'grains',
'premixed',
'bags',
'bulk_loads',
'custom_blends',
'services'
] as const;
+222
View File
@@ -0,0 +1,222 @@
/*
* Shared styles for the Order Management family. Imported once by
* routes/ordering/manage/+layout.svelte (so the rules are global) but every
* selector is scoped under `.manage-shell` the layout wrapper that contains
* all child route markup so nothing leaks into the rest of the app.
*
* Everything resolves from the design tokens in $lib/styles/theme.css so the
* console re-themes (light/dark) automatically and stays visually consistent
* with the rest of the app. No hard-coded colours.
*/
.manage-shell h2 { margin: 0 0 0.75rem; font-size: 1.02rem; font-weight: 700; letter-spacing: -0.01em; }
.manage-shell h3 { margin: 0 0 0.45rem; font-size: 0.86rem; font-weight: 700; color: var(--color-text-secondary); }
.manage-shell .mt { margin-top: 1.15rem; }
.manage-shell .muted { color: var(--color-text-muted); font-size: 0.84rem; margin: 0 0 0.65rem; }
.manage-shell .muted a { color: var(--color-brand); font-weight: 600; }
.manage-shell .muted a:hover { text-decoration: underline; }
.manage-shell .surface-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-panel);
background: var(--color-bg-surface);
padding: var(--space-card);
}
.manage-shell .split {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
/* ── Tables: compact console rows, divider-separated ────────── */
.manage-shell table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.manage-shell th,
.manage-shell td { text-align: left; padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--color-divider); }
.manage-shell tr:last-child td { border-bottom: none; }
.manage-shell th {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
border-bottom-color: var(--color-border);
}
.manage-shell tbody tr { transition: background-color 140ms cubic-bezier(0.22, 1, 0.36, 1); }
.manage-shell tbody tr:hover { background: var(--color-surface-hover); }
.manage-shell tbody tr.selected { background: var(--color-surface-selected); }
.manage-shell table.clickable tbody tr { cursor: pointer; }
/* ── Status pills: neutral by default, tinted by tone ──────── */
.manage-shell .pill {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 0.18rem 0.55rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
text-transform: capitalize;
white-space: nowrap;
color: var(--color-text-secondary);
background: color-mix(in srgb, var(--panel-soft) 70%, var(--color-bg-surface));
}
.manage-shell .pill.pos { color: var(--color-success-text); background: var(--color-success-tint); }
.manage-shell .pill.warn { color: var(--color-warning-text); background: var(--color-warning-tint); }
.manage-shell .pill.info { color: var(--color-info); background: var(--color-info-tint); }
.manage-shell .pill.danger { color: var(--color-error); background: color-mix(in srgb, var(--color-error) 14%, var(--color-bg-surface)); }
.manage-shell .tag {
margin-left: 0.4rem;
font-size: 0.64rem;
font-weight: 600;
padding: 0.08rem 0.4rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--color-warning-tint);
color: var(--color-warning-text);
}
.manage-shell .empty { color: var(--color-text-muted); font-size: 0.85rem; margin: 0.2rem 0; }
/* ── Forms ──────────────────────────────────────────────────── */
.manage-shell .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem 0.65rem; align-items: end; }
.manage-shell .form-grid .full { grid-column: 1 / -1; }
.manage-shell .form-grid label,
.manage-shell .form-row label { display: grid; gap: 0.28rem; font-size: 0.76rem; font-weight: 600; color: var(--color-text-secondary); }
.manage-shell .form-row { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: center; margin-bottom: 0.6rem; }
.manage-shell .form-row input,
.manage-shell .form-row select,
.manage-shell .form-grid input,
.manage-shell .form-grid select,
.manage-shell .actions select,
.manage-shell .inline,
.manage-shell .ovr {
padding: 0.5rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-input-bg);
color: var(--color-text-primary);
font: inherit;
transition: border-color 140ms cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 140ms cubic-bezier(0.22, 1, 0.36, 1), background-color 140ms cubic-bezier(0.22, 1, 0.36, 1);
}
.manage-shell input:focus,
.manage-shell select:focus {
outline: none;
border-color: var(--color-brand);
background: var(--color-bg-surface);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 20%, transparent);
}
.manage-shell input:disabled,
.manage-shell select:disabled { opacity: 0.55; cursor: not-allowed; }
.manage-shell .check { display: flex; flex-direction: row; align-items: center; gap: 0.45rem; font-weight: 600; color: var(--color-text-secondary); }
.manage-shell .check input { accent-color: var(--color-brand); width: 1rem; height: 1rem; }
.manage-shell .inline-label { flex-direction: row; align-items: center; gap: 0.5rem; }
.manage-shell .inline { width: 6.5rem; padding: 0.4rem 0.5rem; }
.manage-shell .ovr { width: 5.5rem; padding: 0.4rem 0.5rem; }
/* ── Buttons ────────────────────────────────────────────────── */
.manage-shell .primary,
.manage-shell .secondary {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.3rem;
border-radius: var(--radius-control);
padding: 0.5rem 0.95rem;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: background-color 150ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.manage-shell .primary { background: var(--color-brand); border-color: var(--color-brand); color: var(--color-on-brand); }
.manage-shell .primary:hover:not(:disabled) { background: var(--color-brand-hover); border-color: var(--color-brand-hover); }
.manage-shell .secondary { background: var(--color-bg-surface); border-color: var(--color-border); color: var(--color-text-primary); }
.manage-shell .secondary:hover:not(:disabled) { background: var(--color-surface-hover); }
.manage-shell .primary:disabled,
.manage-shell .secondary:disabled { opacity: 0.55; cursor: not-allowed; }
.manage-shell .link {
background: none;
border: none;
color: var(--color-brand);
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
padding: 0;
}
.manage-shell .link:hover { text-decoration: underline; }
/* ── Order / customer detail ────────────────────────────────── */
.manage-shell .lines td { font-size: 0.82rem; }
.manage-shell .detail-total {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.65rem 0.05rem;
border-top: 1px solid var(--color-border);
margin: 0.5rem 0 0.85rem;
font-size: 0.86rem;
color: var(--color-text-secondary);
}
.manage-shell .detail-total strong { font-size: 1.05rem; color: var(--color-text-primary); }
.manage-shell .actions { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: center; }
.manage-shell .history { margin-top: 0.95rem; font-size: 0.8rem; color: var(--color-text-secondary); }
.manage-shell .history summary { cursor: pointer; font-weight: 600; color: var(--color-text-secondary); }
.manage-shell .history summary:hover { color: var(--color-text-primary); }
.manage-shell .history ul { margin: 0.5rem 0 0; padding-left: 1.1rem; display: grid; gap: 0.3rem; }
.manage-shell .mini { list-style: none; margin: 0.3rem 0 0.7rem; padding: 0; display: grid; gap: 0.4rem; font-size: 0.82rem; }
.manage-shell .mini li {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.55rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--panel-soft);
}
.manage-shell .visibility li { justify-content: flex-start; }
/* ── Card header: title on the left, primary action on the right ── */
.manage-shell .card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.85rem;
margin-bottom: 0.85rem;
}
.manage-shell .card-head h2 { margin: 0; }
/* ── Modal ──────────────────────────────────────────────────── */
.manage-shell .modal-backdrop {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: start center;
padding: 10vh 1rem 1rem;
background: color-mix(in srgb, var(--color-text-primary) 32%, transparent);
backdrop-filter: blur(8px);
}
.manage-shell .modal {
width: min(30rem, 100%);
border: 1px solid var(--color-border);
border-radius: var(--radius-panel);
background: var(--color-bg-surface);
padding: var(--space-card);
}
.manage-shell .modal.wide { width: min(40rem, 100%); }
.manage-shell .modal h2 { margin: 0 0 1rem; }
.manage-shell .modal .actions { justify-content: flex-end; margin-top: 1.15rem; }
@media (max-width: 1000px) {
.manage-shell .split,
.manage-shell .form-grid { grid-template-columns: 1fr; }
}
+68 -19
View File
@@ -46,18 +46,20 @@
--color-text-secondary: oklch(0.45 0.008 240);
--color-text-muted: oklch(0.6 0.01 240);
/* Sidebar: light monochrome rail with the current item shown as the
selected pill. Shared across themes so navigation stays consistent. */
--sidebar-bg: oklch(0.985 0.001 240);
--sidebar-hover: oklch(0.952 0.003 240);
--sidebar-active-bg: #3290d9;
--sidebar-active-text: var(--color-on-brand);
--sidebar-border: oklch(0.9 0.004 240);
--sidebar-text: oklch(0.34 0.006 240);
--sidebar-text-strong: oklch(0.16 0.004 240);
--sidebar-text-muted: oklch(0.56 0.008 240);
--sidebar-icon: oklch(0.42 0.006 240);
--sidebar-logo-bg: oklch(0.98 0.003 240);
/* Sidebar: deep-green rail matching the customer ordering portal
(CustomerPortalShell). The current item shows as a white pill with
green text. Held constant across light/dark themes so internal staff
and customers see the same navigation styling. */
--sidebar-bg: #1f3a2c;
--sidebar-hover: rgba(255, 255, 255, 0.08);
--sidebar-active-bg: #ffffff;
--sidebar-active-text: #1f3a2c;
--sidebar-border: rgba(255, 255, 255, 0.12);
--sidebar-text: rgba(231, 239, 233, 0.85);
--sidebar-text-strong: #ffffff;
--sidebar-text-muted: rgba(231, 239, 233, 0.6);
--sidebar-icon: rgba(231, 239, 233, 0.8);
--sidebar-logo-bg: rgba(255, 255, 255, 0.1);
/* ── Semantic ───────────────────────────────────────────── */
--color-success: oklch(0.66 0.16 162); /* emerald, cohesive with accent */
@@ -128,19 +130,20 @@
--color-border: oklch(0.32 0.006 240);
--color-divider: oklch(0.28 0.005 240);
/* Sidebar: dark rail tuned to the content theme so it stops
rendering as a bright light strip in dark mode. Active item keeps
the blue pill from light mode. */
/* Sidebar: in dark mode the rail drops the deep-green and joins the
neutral dark surfaces, sitting a touch below the app canvas so it
reads as a distinct rail. The active item becomes a green-tinted pill
with bright brand text rather than the light-mode white chip. */
--sidebar-bg: oklch(0.2 0.005 240);
--sidebar-hover: oklch(0.27 0.006 240);
--sidebar-active-bg: #3290d9;
--sidebar-active-text: var(--color-on-brand);
--sidebar-active-bg: color-mix(in srgb, var(--color-brand) 22%, var(--color-bg-surface));
--sidebar-active-text: oklch(0.9 0.07 162);
--sidebar-border: oklch(0.3 0.006 240);
--sidebar-text: oklch(0.78 0.006 240);
--sidebar-text-strong: oklch(0.96 0.003 240);
--sidebar-text-muted: oklch(0.6 0.008 240);
--sidebar-icon: oklch(0.68 0.008 240);
--sidebar-logo-bg: oklch(0.26 0.006 240);
--sidebar-icon: oklch(0.72 0.006 240);
--sidebar-logo-bg: rgba(255, 255, 255, 0.08);
/* ── Text (neutral) ─────────────────────────────────────── */
--color-text-primary: oklch(0.96 0.003 240);
@@ -560,3 +563,49 @@ a {
grid-template-columns: 1fr;
}
}
/* ============================================================================
Tooltip (rendered to <body> by the use:tooltip action)
----------------------------------------------------------------------------
Auto-inverts via the text/surface tokens: a near-black bubble in light mode,
a near-white bubble in dark mode readable contrast in both.
============================================================================ */
.app-tooltip {
position: fixed;
z-index: 1000;
padding: 0.36rem 0.55rem;
border-radius: 0.5rem;
background: var(--color-text-primary);
color: var(--color-bg-surface);
font-size: 0.76rem;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0.005em;
white-space: normal;
overflow-wrap: break-word;
text-align: center;
width: max-content;
max-width: min(16rem, calc(100vw - 1rem));
pointer-events: none;
box-shadow: 0 6px 20px -6px rgba(0, 0, 0, 0.35), 0 2px 6px -2px rgba(0, 0, 0, 0.25);
opacity: 0;
transform: translateY(-2px);
transition: opacity 130ms ease, transform 130ms ease;
}
.app-tooltip[data-placement='top'] {
transform: translateY(2px);
}
.app-tooltip.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.app-tooltip {
transition: opacity 130ms ease;
transform: none;
}
}
+85
View File
@@ -0,0 +1,85 @@
<script lang="ts">
import { ChevronDown, ChevronsUpDown, ChevronUp } from 'lucide-svelte';
import type { TableController } from './table.svelte';
let {
label,
column,
controller
}: {
label: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
controller: TableController<any>;
column: string;
} = $props();
const active = $derived(controller.sortKey === column);
</script>
<button
type="button"
class="sort-header"
class:active
aria-label={`Sort by ${label}${active ? (controller.sortDir === 'asc' ? ', ascending' : ', descending') : ''}`}
onclick={() => controller.toggleSort(column)}
>
<span>{label}</span>
{#if active}
{#if controller.sortDir === 'asc'}
<ChevronUp size={13} strokeWidth={2.6} />
{:else}
<ChevronDown size={13} strokeWidth={2.6} />
{/if}
{:else}
<ChevronsUpDown size={13} strokeWidth={2} />
{/if}
</button>
<style>
.sort-header {
display: inline-flex;
align-items: center;
gap: 0.28rem;
width: 100%;
min-height: 26px;
padding: 0;
border: 0;
background: transparent;
/* Inherit the .log-head typography (size/weight/transform/letter-spacing). */
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
text-align: left;
cursor: pointer;
user-select: none;
}
.sort-header :global(svg) {
flex-shrink: 0;
opacity: 0.4;
transition: opacity 120ms ease;
}
.sort-header:hover {
color: var(--color-text-primary);
}
.sort-header:hover :global(svg) {
opacity: 0.7;
}
.sort-header.active {
color: var(--color-brand);
}
.sort-header.active :global(svg) {
opacity: 1;
}
.sort-header:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
border-radius: 0.3rem;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
// Shared client-side table controller: sorting + pagination over an already
// filtered row set. Both the Mix Editor and Ingredients Editor feed their
// filtered `visibleRows` in and render `rows` out, so the two surfaces stay
// behaviourally identical. Keeping the page math here (rather than ad-hoc
// `$effect`s per page) means navigation can't get clamped back to page 1.
export type SortDir = 'asc' | 'desc';
export type SortValue = string | number | boolean | null | undefined;
export type Accessor<T> = (row: T) => SortValue;
const PAGE_SIZES = [10, 25, 50, 100] as const;
function isEmpty(value: SortValue): boolean {
return value === null || value === undefined || value === '';
}
function compareNonEmpty(a: SortValue, b: SortValue): number {
if (typeof a === 'number' && typeof b === 'number') return a - b;
if (typeof a === 'boolean' && typeof b === 'boolean') {
return a === b ? 0 : a ? -1 : 1;
}
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}
export class TableController<T> {
readonly pageSizes = PAGE_SIZES;
page = $state(1);
pageSize = $state(25);
sortKey = $state<string | null>(null);
sortDir = $state<SortDir>('asc');
private readonly source: () => readonly T[];
private readonly accessors: Record<string, Accessor<T>>;
constructor(source: () => readonly T[], accessors: Record<string, Accessor<T>> = {}) {
this.source = source;
this.accessors = accessors;
}
readonly sorted = $derived.by<readonly T[]>(() => {
const rows = this.source();
const accessor = this.sortKey ? this.accessors[this.sortKey] : undefined;
if (!accessor) return rows;
const dir = this.sortDir === 'asc' ? 1 : -1;
// Empties always sort last, regardless of direction.
return [...rows].sort((a, b) => {
const av = accessor(a);
const bv = accessor(b);
const aEmpty = isEmpty(av);
const bEmpty = isEmpty(bv);
if (aEmpty || bEmpty) {
if (aEmpty && bEmpty) return 0;
return aEmpty ? 1 : -1;
}
return compareNonEmpty(av, bv) * dir;
});
});
readonly total = $derived(this.sorted.length);
readonly totalPages = $derived(Math.max(1, Math.ceil(this.total / this.pageSize)));
// The page we actually show. Derived clamping means shrinking the result set
// (via filters or a larger page size) can never strand the view on an empty page.
readonly currentPage = $derived(Math.min(Math.max(1, this.page), this.totalPages));
readonly pageStart = $derived(this.total === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1);
readonly pageEnd = $derived(Math.min(this.total, this.currentPage * this.pageSize));
readonly rows = $derived(this.sorted.slice(this.pageStart === 0 ? 0 : this.pageStart - 1, this.pageEnd));
readonly canPrev = $derived(this.currentPage > 1);
readonly canNext = $derived(this.currentPage < this.totalPages);
toggleSort(key: string): void {
if (!this.accessors[key]) return;
if (this.sortKey === key) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sortKey = key;
this.sortDir = 'asc';
}
this.page = 1;
}
setPage(next: number): void {
this.page = Math.min(Math.max(1, next), this.totalPages);
}
next(): void {
this.setPage(this.currentPage + 1);
}
prev(): void {
this.setPage(this.currentPage - 1);
}
reset(): void {
this.page = 1;
}
}
+92
View File
@@ -99,6 +99,7 @@ export type MixCalculatorLine = {
required_kg: number;
mix_percentage: number;
unit: string;
rounding_decimals?: number;
sort_order: number;
};
@@ -306,6 +307,35 @@ export type EditorMixUpdateInput = {
client_name?: string;
name?: string;
notes?: string | null;
visible?: boolean;
};
export type EditorMixRow = {
id: number;
tenant_id: string;
client_name: string;
name: string;
visible: boolean;
product_count: number;
visible_product_count: number;
notes: string | null;
};
export type EditorMixIngredient = {
id: number;
raw_material_id: number;
raw_material_name: string;
quantity_kg: number;
notes: string | null;
};
export type EditorMixFormula = {
id: number;
tenant_id: string;
client_name: string;
name: string;
ingredients: EditorMixIngredient[];
total_kg: number;
};
export type EditorProductIngredient = {
@@ -328,6 +358,32 @@ export type EditorProductFormula = {
total_kg: number;
};
export type EditorIngredientRow = {
id: number;
name: string;
supplier: string | null;
unit_of_measure: string;
kg_per_unit: number;
status: string;
rounding_decimals: number;
notes: string | null;
cost_per_kg: number | null;
usage_count: number;
created_at: string;
};
export type EditorIngredientCreateInput = {
name: string;
supplier?: string | null;
unit_of_measure: string;
kg_per_unit: number;
status?: string;
rounding_decimals?: number;
notes?: string | null;
};
export type EditorIngredientUpdateInput = Partial<EditorIngredientCreateInput>;
export type Scenario = {
id: number;
name: string;
@@ -616,6 +672,15 @@ export type ThroughputEntryCreateInput = {
notes?: string | null;
};
export type ThroughputEntryUpdateInput = Partial<ThroughputEntryCreateInput>;
export type ThroughputImportResult = {
entries_imported: number;
entries_skipped: number;
products_created: number;
errors: string[];
};
export type ThroughputEntryListParams = {
date_from?: string;
date_to?: string;
@@ -745,6 +810,8 @@ export type OrderingCustomer = {
user_count: number;
price_list_id: number | null;
discount_percent: number;
xero_contact_id?: string | null;
xero_contact_name?: string | null;
created_at: string;
};
@@ -793,6 +860,7 @@ export type OrderingNotificationSettings = {
export type XeroStatus = {
connection: { configured: boolean; mode: string; base_url: string; checked_at: string; missing_env: string[] };
contact_links: { linked: number; total: number; unlinked: number };
recent_syncs: {
id: number;
order_id: number;
@@ -802,3 +870,27 @@ export type XeroStatus = {
created_at: string;
}[];
};
export type XeroContact = {
contact_id: string;
name: string;
email: string | null;
status: string;
};
export type XeroContactList = {
contacts: XeroContact[];
stubbed: boolean;
};
export type XeroContactLinkRow = {
customer_id: number;
customer_name: string;
client_code: string;
linked: boolean;
xero_contact_id: string | null;
xero_contact_name: string | null;
xero_contact_email: string | null;
last_synced_at: string | null;
suggested_contact_id: string | null;
};
+1
View File
@@ -264,6 +264,7 @@ export function canAccessRoute(session: AppSession | null | undefined, pathname:
if (pathname.startsWith('/product-costing')) return canOpenProductCosting(session);
if (pathname.startsWith('/products')) return canOpenProducts(session);
if (pathname.startsWith('/editor')) return canOpenEditor(session);
if (pathname.startsWith('/ingredients')) return canOpenEditor(session);
if (pathname.startsWith('/scenarios')) return canOpenScenarios(session);
if (pathname.startsWith('/reporting')) return canOpenReporting(session);
if (pathname.startsWith('/settings')) return canOpenSettings(session);
+9 -1
View File
@@ -1,7 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canOpenDashboard, getWorkspaceHomeHref } from '$lib/workspace-access';
import { canOpenDashboard, getWorkspaceHomeHref, isCustomerPortalSession } from '$lib/workspace-access';
import type { DashboardSummary } from '$lib/types';
const EMPTY_SUMMARY: DashboardSummary = {
@@ -22,6 +22,14 @@ export function load({ fetch }) {
}
const session = getStoredClientSession();
// B2B ordering customers must never land on (or flash) the internal
// dashboard — even if their account happens to carry the dashboard module.
// Send them straight to their catalogue before any dashboard data loads.
if (isCustomerPortalSession(session)) {
throw redirect(307, '/ordering');
}
if (!canOpenDashboard(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
+91 -227
View File
@@ -2,17 +2,22 @@
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import type { EditorProductFormula, EditorProductIngredient, EditorProductRow, RawMaterial } from '$lib/types';
import SortHeader from '$lib/table/SortHeader.svelte';
import { TableController } from '$lib/table/table.svelte';
import type {
EditorMixFormula,
EditorMixIngredient,
EditorMixRow,
EditorMixUpdateInput,
RawMaterial
} from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Save, Search, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
let { data } = $props();
type EditableRow = EditorProductRow & {
draft_product_name: string;
type EditableRow = EditorMixRow & {
draft_mix_name: string;
draft_sale_type: string;
draft_unit_of_measure: string;
draft_visible: boolean;
};
@@ -25,24 +30,17 @@
let rows = $state<EditableRow[]>([]);
let query = $state('');
let productFilter = $state('');
let clientFilter = $state('all');
let visibilityFilter = $state<'all' | 'visible' | 'hidden'>('visible');
let packFilter = $state('all');
let page = $state(1);
let pageSize = $state(25);
let savingKey = $state<string | null>(null);
let expandedProductId = $state<number | null>(null);
let activeFormula = $state<EditorProductFormula | null>(null);
let expandedMixId = $state<number | null>(null);
let activeFormula = $state<EditorMixFormula | null>(null);
let ingredientDrafts = $state<DraftIngredient[]>([]);
function toEditableRow(row: EditorProductRow): EditableRow {
function toEditableRow(row: EditorMixRow): EditableRow {
return {
...row,
draft_product_name: row.name,
draft_mix_name: row.mix_name,
draft_sale_type: row.sale_type,
draft_unit_of_measure: row.unit_of_measure,
draft_mix_name: row.name,
draft_visible: row.visible
};
}
@@ -53,7 +51,7 @@
}
});
function ingredientToDraft(ingredient: EditorProductIngredient): DraftIngredient {
function ingredientToDraft(ingredient: EditorMixIngredient): DraftIngredient {
return {
id: ingredient.id,
raw_material_id: ingredient.raw_material_id,
@@ -71,68 +69,32 @@
};
}
function loadIngredientDrafts(formula: EditorProductFormula) {
function loadIngredientDrafts(formula: EditorMixFormula) {
activeFormula = formula;
ingredientDrafts = formula.ingredients.length ? formula.ingredients.map(ingredientToDraft) : [emptyIngredient()];
}
function productDirty(row: EditableRow) {
return (
row.draft_product_name !== row.name ||
row.draft_sale_type !== row.sale_type ||
row.draft_unit_of_measure !== row.unit_of_measure ||
row.draft_visible !== row.visible
);
}
function mixDirty(row: EditableRow) {
return row.draft_mix_name !== row.mix_name;
}
function rowDirty(row: EditableRow) {
return productDirty(row) || mixDirty(row);
return row.draft_mix_name !== row.name || row.draft_visible !== row.visible;
}
function applyProductUpdate(updated: EditorProductRow) {
function applyMixUpdate(updated: EditorMixRow) {
rows = rows.map((row) => (row.id === updated.id ? toEditableRow(updated) : row));
}
function applyMixUpdate(updatedRows: EditorProductRow[]) {
const updatedById = new Map(updatedRows.map((row) => [row.id, row]));
rows = rows.map((row) => {
const updated = updatedById.get(row.id);
return updated ? toEditableRow(updated) : row;
});
}
async function saveRow(row: EditableRow) {
if (!rowDirty(row)) return;
savingKey = `row:${row.id}`;
try {
if (productDirty(row)) {
applyProductUpdate(
await api.updateEditorProduct(row.id, {
name: row.draft_product_name.trim(),
sale_type: row.draft_sale_type.trim(),
unit_of_measure: row.draft_unit_of_measure.trim(),
visible: row.draft_visible
})
);
}
const payload: EditorMixUpdateInput = {};
if (row.draft_mix_name.trim() !== row.name) payload.name = row.draft_mix_name.trim();
if (row.draft_visible !== row.visible) payload.visible = row.draft_visible;
const current = rows.find((candidate) => candidate.id === row.id) ?? row;
if (mixDirty(current)) {
applyMixUpdate(
await api.updateEditorMix(current.mix_id, {
name: current.draft_mix_name.trim()
})
);
}
toast.success('Row saved');
applyMixUpdate(await api.updateEditorMix(row.id, payload));
toast.success('Mix saved');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to save row');
toast.error(error instanceof Error ? error.message : 'Unable to save mix');
} finally {
savingKey = null;
}
@@ -143,20 +105,20 @@
}
async function toggleIngredients(row: EditableRow) {
if (expandedProductId === row.id) {
expandedProductId = null;
if (expandedMixId === row.id) {
expandedMixId = null;
activeFormula = null;
ingredientDrafts = [];
return;
}
expandedProductId = row.id;
savingKey = `product-load:${row.id}`;
expandedMixId = row.id;
savingKey = `mix-load:${row.id}`;
try {
loadIngredientDrafts(await api.editorProductFormula(row.id));
loadIngredientDrafts(await api.editorMixFormula(row.id));
} catch (error) {
expandedProductId = null;
expandedMixId = null;
toast.error(error instanceof Error ? error.message : 'Unable to load ingredients');
} finally {
savingKey = null;
@@ -177,7 +139,7 @@
.map((row) => row.raw_material_id)
.filter((rawMaterialId): rawMaterialId is number => rawMaterialId !== null);
if (!activeFormula) return ['Open a product before saving ingredients.'];
if (!activeFormula) return ['Open a mix before saving ingredients.'];
if (!chosen.length) return ['Add at least one raw material.'];
if (new Set(chosen).size !== chosen.length) return ['Each raw material can only appear once in a mix.'];
@@ -197,7 +159,7 @@
}
if (!activeFormula) return;
savingKey = `product-save:${activeFormula.id}`;
savingKey = `mix-save:${activeFormula.id}`;
try {
const cleanRows = ingredientDrafts.map((row) => ({
@@ -212,14 +174,14 @@
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.deleteEditorProductIngredient(activeFormula.id, ingredient.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.addEditorProductIngredient(activeFormula.id, {
await api.addEditorMixIngredient(activeFormula.id, {
raw_material_id: row.raw_material_id,
quantity_kg: row.quantity_kg,
notes: row.notes
@@ -228,14 +190,14 @@
}
if (original.quantity_kg !== row.quantity_kg || (original.notes ?? null) !== row.notes) {
await api.updateEditorProductIngredient(activeFormula.id, original.id, {
await api.updateEditorMixIngredient(activeFormula.id, original.id, {
quantity_kg: row.quantity_kg,
notes: row.notes
});
}
}
loadIngredientDrafts(await api.editorProductFormula(activeFormula.id));
loadIngredientDrafts(await api.editorMixFormula(activeFormula.id));
toast.success('Ingredients saved');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to save ingredients');
@@ -250,25 +212,15 @@
function clearFilters() {
query = '';
productFilter = '';
clientFilter = 'all';
visibilityFilter = 'visible';
packFilter = 'all';
page = 1;
table.reset();
}
function setVisibilityFilter(value: 'all' | 'visible' | 'hidden') {
visibilityFilter = value;
}
function previousPage() {
page = Math.max(1, page - 1);
}
function nextPage() {
page = Math.min(totalPages, page + 1);
}
const clientOptions = $derived(
Array.from(
new Set(
@@ -286,57 +238,38 @@
const visibleRows = $derived(
rows.filter((row) => {
const term = query.trim().toLowerCase();
const productTerm = productFilter.trim().toLowerCase();
const clientMatches = clientFilter === 'all' || row.client_name === clientFilter;
const productMatches = !productTerm || row.name.toLowerCase().includes(productTerm);
const visibilityMatches =
visibilityFilter === 'all' ||
(visibilityFilter === 'visible' && row.visible) ||
(visibilityFilter === 'hidden' && !row.visible);
const packMatches = packFilter === 'all' || row.sale_type === packFilter;
if (!clientMatches) return false;
if (!productMatches || !visibilityMatches || !packMatches) return false;
if (!visibilityMatches) return false;
if (!term) return true;
return [
row.client_name,
row.name,
row.item_id ?? '',
row.mix_name,
row.mix_client_name,
row.sale_type,
row.unit_of_measure
]
.join(' ')
.toLowerCase()
.includes(term);
return [row.client_name, row.name].join(' ').toLowerCase().includes(term);
})
);
const table = new TableController<EditableRow>(() => visibleRows, {
client_name: (row) => row.client_name,
name: (row) => row.name,
visible: (row) => row.visible
});
const dirtyCount = $derived(rows.filter(rowDirty).length);
const uniqueMixCount = $derived(new Set(visibleRows.map((row) => row.mix_id)).size);
const filtersActive = $derived(Boolean(query.trim() || productFilter.trim() || clientFilter !== 'all' || visibilityFilter !== 'visible' || packFilter !== 'all'));
const packOptions = $derived(Array.from(new Set(rows.map((row) => row.sale_type))).sort());
const totalPages = $derived(Math.max(1, Math.ceil(visibleRows.length / pageSize)));
const pageStart = $derived(visibleRows.length === 0 ? 0 : (Math.min(page, totalPages) - 1) * pageSize + 1);
const pageEnd = $derived(Math.min(visibleRows.length, Math.min(page, totalPages) * pageSize));
const paginatedRows = $derived(visibleRows.slice(pageStart === 0 ? 0 : pageStart - 1, pageEnd));
const filtersActive = $derived(Boolean(query.trim() || clientFilter !== 'all' || visibilityFilter !== 'visible'));
const ingredientTotalKg = $derived(
ingredientDrafts.reduce((sum, ingredient) => sum + Number(ingredient.quantity_kg || 0), 0)
);
// Jump back to the first page whenever the filtered set changes.
$effect(() => {
query;
productFilter;
clientFilter;
visibilityFilter;
packFilter;
pageSize;
page = 1;
});
$effect(() => {
if (page > totalPages) page = totalPages;
table.pageSize;
table.reset();
});
$effect(() => {
@@ -356,8 +289,8 @@
<ListFilter size={16} strokeWidth={1.8} />
</div>
<div class="rail-identity-text">
<p class="identity-name">Filter products</p>
<p class="identity-role">{visibleRows.length} matching rows</p>
<p class="identity-name">Filter mixes</p>
<p class="identity-role">{visibleRows.length} matching mixes</p>
</div>
</div>
@@ -366,7 +299,7 @@
<span>Search</span>
<div class="search-input">
<Search size={17} strokeWidth={2.2} />
<input bind:value={query} type="search" placeholder="Client, product, mix, item ID, unit" />
<input bind:value={query} type="search" placeholder="Client or mix name" />
</div>
</label>
@@ -389,21 +322,6 @@
</select>
</label>
<label>
<span>Product</span>
<input bind:value={productFilter} type="search" placeholder="Product name" />
</label>
<label>
<span>Pack</span>
<select bind:value={packFilter}>
<option value="all">All packs</option>
{#each packOptions as option}
<option value={option}>{option}</option>
{/each}
</select>
</label>
{#if filtersActive}
<button type="button" class="clear-button rail-clear" onclick={clearFilters}>
<X size={16} strokeWidth={2.4} /> Clear filters
@@ -418,18 +336,14 @@
<div class="editor-status">
<span>
<strong>Mix Editor</strong>
<small>{visibleRows.length} products across {uniqueMixCount} mixes</small>
<small>{visibleRows.length} mixes</small>
</span>
</div>
<dl class="facts">
<div class="fact">
<dt>Products</dt>
<dd>{visibleRows.length}</dd>
</div>
<div class="fact">
<dt>Mixes</dt>
<dd>{uniqueMixCount}</dd>
<dd>{visibleRows.length}</dd>
</div>
<div class="fact">
<dt>Unsaved</dt>
@@ -438,76 +352,47 @@
</dl>
</div>
<div class="pagination-bar" aria-label="Product table pagination">
<span>{pageStart}-{pageEnd} of {visibleRows.length}</span>
<div class="pagination-bar" aria-label="Mix table pagination">
<span>{table.pageStart}-{table.pageEnd} of {table.total}</span>
<label class="page-size">
<span>Rows</span>
<select bind:value={pageSize}>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<select bind:value={table.pageSize}>
{#each table.pageSizes as size}
<option value={size}>{size}</option>
{/each}
</select>
</label>
<div class="page-controls">
<button type="button" class="clear-button icon-button" disabled={page <= 1} onclick={previousPage} aria-label="Previous page">
<button type="button" class="clear-button icon-button" disabled={!table.canPrev} onclick={() => table.prev()} aria-label="Previous page">
<ChevronLeft size={16} strokeWidth={2.4} />
</button>
<span>Page {Math.min(page, totalPages)} of {totalPages}</span>
<button type="button" class="clear-button icon-button" disabled={page >= totalPages} onclick={nextPage} aria-label="Next page">
<span>Page {table.currentPage} of {table.totalPages}</span>
<button type="button" class="clear-button icon-button" disabled={!table.canNext} onclick={() => table.next()} aria-label="Next page">
<ChevronRight size={16} strokeWidth={2.4} />
</button>
</div>
</div>
<div class="log">
<div class="log-head" aria-hidden="true">
<span>Client</span>
<span>ID</span>
<span>Product</span>
<span>Mix</span>
<span>Pack</span>
<span>Unit / Bag</span>
<span>Status</span>
<div class="log-head">
<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>
</div>
{#each paginatedRows as row (row.id)}
{#each table.rows as row (row.id)}
<div class="row" class:edited={rowDirty(row)}>
<div class="client-cell">
<span class="cell-label">Client</span>
<span class="readonly-value">{row.client_name}</span>
</div>
<div class="id-cell">
<span class="cell-label">ID</span>
<span class="id-value">{row.item_id ?? '-'}</span>
</div>
<div class="product-cell">
<span class="cell-label">Product</span>
<input bind:value={row.draft_product_name} aria-label="End product name" />
</div>
<div class="mix-cell">
<span class="cell-label">Mix</span>
<input bind:value={row.draft_mix_name} aria-label="Mix name" />
</div>
<div class="pack-cell">
<span class="cell-label">Pack</span>
<select bind:value={row.draft_sale_type} aria-label="Sale type">
<option value="standard">standard</option>
<option value="bulka">bulka</option>
<option value="per_unit">per_unit</option>
</select>
</div>
<div class="unit-cell">
<span class="cell-label">Unit</span>
<input bind:value={row.draft_unit_of_measure} aria-label="Unit of measure" />
</div>
<div class="status-cell">
<span class="cell-label">Status</span>
<label class="status-toggle" class:on={row.draft_visible}>
@@ -519,11 +404,11 @@
<div class="row-actions">
<button class="clear-button" type="button" onclick={() => toggleIngredients(row)}>
<FlaskConical size={16} strokeWidth={2.2} />
{expandedProductId === row.id ? 'Close ingredients' : savingKey === `product-load:${row.id}` ? 'Loading...' : 'Ingredients'}
{expandedMixId === row.id ? 'Close ingredients' : savingKey === `mix-load:${row.id}` ? 'Loading...' : 'Ingredients'}
</button>
<button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}>
<Save size={16} strokeWidth={2.4} />
{savingKey === `row:${row.id}` ? 'Saving...' : 'Save row'}
{savingKey === `row:${row.id}` ? 'Saving...' : 'Save mix'}
</button>
{#if rowDirty(row)}
<button class="link-button" type="button" onclick={() => resetRow(row)}>Reset</button>
@@ -531,7 +416,7 @@
</div>
</div>
{#if expandedProductId === row.id}
{#if expandedMixId === row.id}
<div class="ingredient-panel" transition:fade={{ duration: 120 }}>
<div class="ingredient-head">
<div>
@@ -570,15 +455,15 @@
<div class="ingredient-footer">
<button class="clear-button" type="button" onclick={addIngredient}>Add ingredient</button>
<button class="apply-button" type="button" disabled={savingKey === `product-save:${row.id}`} onclick={saveIngredients}>
{savingKey === `product-save:${row.id}` ? 'Saving...' : 'Save ingredients'}
<button class="apply-button" type="button" disabled={savingKey === `mix-save:${row.id}`} onclick={saveIngredients}>
{savingKey === `mix-save:${row.id}` ? 'Saving...' : 'Save ingredients'}
</button>
</div>
</div>
{/if}
{:else}
<div class="empty">
<p>No products match your search</p>
<p>No mixes match your search</p>
<button type="button" class="clear-button" onclick={clearFilters}>Clear filters</button>
</div>
{/each}
@@ -593,7 +478,7 @@
gap: 0.9rem;
min-height: 100%;
padding: 1rem 1.15rem 2rem;
background: #e8eee9;
background: var(--color-bg-app);
}
:global(.secondary-rail-layout) {
@@ -602,7 +487,7 @@
:global(.secondary-rail-layout-panel),
:global(.secondary-rail-layout-content) {
background: #e8eee9;
background: var(--color-bg-app);
}
.filter-rail {
@@ -613,7 +498,7 @@
gap: 0.25rem;
height: 100%;
min-height: calc(100vh - 8.5rem);
background: color-mix(in srgb, var(--panel-soft) 46%, #dfe7e1);
background: var(--color-bg-surface);
border-right: 1px solid var(--line);
overflow-y: auto;
}
@@ -621,7 +506,7 @@
.rail-label {
margin: 0;
padding: 1rem 1rem 0.15rem;
color: color-mix(in srgb, var(--muted) 88%, #a3aea7);
color: var(--color-text-muted);
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.14em;
@@ -643,10 +528,10 @@
flex-shrink: 0;
width: 2.15rem;
height: 2.15rem;
border: 1px solid color-mix(in srgb, var(--line) 72%, transparent);
border: 1px solid color-mix(in srgb, var(--color-brand) 22%, var(--color-border));
border-radius: 50%;
background: color-mix(in srgb, var(--panel) 80%, #edf2ee);
color: #6b786f;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.rail-identity-text {
@@ -659,7 +544,7 @@
}
.identity-name {
color: #526059;
color: var(--color-text-primary);
font-size: 0.8rem;
font-weight: 600;
overflow: hidden;
@@ -668,7 +553,7 @@
}
.identity-role {
color: #8a9790;
color: var(--color-text-muted);
font-size: 0.72rem;
}
@@ -690,7 +575,7 @@
flex-wrap: wrap;
padding: 0.9rem 1.1rem;
background: var(--color-brand-tint);
border: 1px solid #bfe6c8;
border: 1px solid color-mix(in srgb, var(--color-brand) 32%, var(--color-border));
border-radius: 0.9rem;
}
@@ -784,7 +669,7 @@
}
.apply-button {
color: #fbfdfa;
color: var(--color-on-brand);
background: var(--color-brand);
border: 1px solid var(--color-brand);
}
@@ -969,13 +854,9 @@
.row {
display: grid;
grid-template-columns:
minmax(150px, 0.9fr)
minmax(72px, 0.32fr)
minmax(210px, 1.35fr)
minmax(170px, 1fr)
minmax(105px, 0.55fr)
minmax(130px, 0.7fr)
minmax(78px, 0.34fr)
minmax(220px, 1.6fr)
minmax(110px, 0.5fr)
minmax(198px, auto);
gap: 0.55rem;
align-items: center;
@@ -1002,7 +883,7 @@
}
.row:hover {
background: #fafbfc;
background: var(--color-surface-hover);
}
.row.edited {
@@ -1021,29 +902,12 @@
white-space: nowrap;
}
.id-value {
display: flex;
align-items: center;
min-height: 34px;
color: var(--color-text-secondary);
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-label {
display: none;
}
.client-cell,
.id-cell,
.product-cell,
.mix-cell,
.pack-cell,
.unit-cell,
.status-cell {
display: flex;
flex-direction: column;
@@ -1102,7 +966,7 @@
flex-direction: column;
gap: 1rem;
padding: 0.85rem;
background: #f8fbf8;
background: var(--panel-soft);
border-bottom: 1px solid var(--color-divider);
}
@@ -1146,8 +1010,8 @@
}
.remove-button {
color: #8a1622;
border-color: #e2a8af;
color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 38%, var(--color-border));
}
.ingredient-footer {
+1 -1
View File
@@ -15,7 +15,7 @@ export async function load({ fetch }) {
try {
const [rows, rawMaterials] = await Promise.all([
api.editorProducts({ limit: 1000 }, fetch),
api.editorMixes({ limit: 1000 }, fetch),
api.rawMaterials(fetch)
]);
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canOpenEditor, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { ingredients: [] };
}
const session = getStoredClientSession();
if (!canOpenEditor(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const ingredients = await api.editorIngredients(fetch);
return { ingredients };
} catch {
return { ingredients: [] };
}
}
@@ -0,0 +1,29 @@
<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>
@@ -0,0 +1,19 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canManageOrdering, getWorkspaceHomeHref } from '$lib/workspace-access';
// Single access guard for the whole Order Management family. Each child route
// (orders, products, customers, pricing, settings, integrations) loads its own
// data; this layout just keeps non-managers out of all of them in one place.
export async function load() {
if (!hasStoredClientSession()) {
return {};
}
const session = getStoredClientSession();
if (!canManageOrdering(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
return {};
}
+81 -515
View File
@@ -1,59 +1,16 @@
<script lang="ts">
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import type {
CatalogueProduct,
CustomerPricing,
CustomerVisibilityRow,
Order,
OrderingCustomer,
OrderingCustomerUser,
OrderingNotificationSettings,
XeroStatus
} from '$lib/types';
import { money, label, statusTone, ORDER_STATUSES } from '$lib/ordering/format';
import type { Order } from '$lib/types';
let { data } = $props();
type Tab = 'orders' | 'products' | 'customers' | 'settings';
let tab = $state<Tab>('orders');
// Mutable local copies of the loader data (refresh helpers reassign these).
// Seeded from `data` via an effect so navigation re-syncs without the
// "only captures the initial value" warning.
let orders = $state<Order[]>([]);
let products = $state<CatalogueProduct[]>([]);
let customers = $state<OrderingCustomer[]>([]);
let xero = $state<XeroStatus | null>(null);
$effect(() => {
orders = data.orders ?? [];
products = data.products ?? [];
customers = data.customers ?? [];
xero = data.xero ?? null;
});
const STATUSES = [
'submitted',
'under_review',
'confirmed',
'sent_to_xero',
'in_production',
'ready_for_pickup',
'dispatched',
'completed',
'cancelled'
];
const CATEGORIES = ['grains', 'premixed', 'bags', 'bulk_loads', 'custom_blends', 'services'];
function money(v: number | null | undefined) {
if (v == null) return '—';
return new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }).format(v);
}
function label(s: string) {
return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
// --- Orders ---------------------------------------------------------------
let selectedOrder = $state<Order | null>(null);
let statusChoice = $state('');
@@ -65,6 +22,10 @@
toast.error(e instanceof Error ? e.message : 'Could not load order.');
}
}
function closeOrder() {
selectedOrder = null;
statusChoice = '';
}
async function refreshOrders() {
try {
orders = await api.orderingAdmin.orders();
@@ -112,480 +73,85 @@
toast.error(e instanceof Error ? e.message : 'Could not reopen.');
}
}
// --- Products -------------------------------------------------------------
let newProduct = $state<Record<string, any>>({ name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null, requires_quote: false, active: true });
async function refreshProducts() {
try {
products = await api.orderingAdmin.products();
} catch {}
}
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) });
toast.success('Product created.');
newProduct = { name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null, requires_quote: false, active: true };
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not create product.');
}
}
async function toggleProductActive(p: CatalogueProduct) {
try {
await api.orderingAdmin.updateProduct(p.id, { active: !p.active });
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function saveProductPrice(p: CatalogueProduct, value: string) {
try {
await api.orderingAdmin.updateProduct(p.id, { base_price: value === '' ? null : Number(value) });
toast.success('Base price updated.');
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
// --- Customers ------------------------------------------------------------
let newCustomer = $state({ name: '', client_code: '' });
let selectedCustomer = $state<OrderingCustomer | null>(null);
let custUsers = $state<OrderingCustomerUser[]>([]);
let custPricing = $state<CustomerPricing | null>(null);
let custVisibility = $state<CustomerVisibilityRow[]>([]);
let newUser = $state({ full_name: '', email: '', role: 'buyer' });
let discountInput = $state(0);
let newPrice = $state<Record<string, any>>({ product_id: '', unit_price: '', rule_type: 'fixed' });
async function refreshCustomers() {
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 {
await api.orderingAdmin.createCustomer(newCustomer);
toast.success('Customer created.');
newCustomer = { name: '', client_code: '' };
await refreshCustomers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not create customer.');
}
}
async function openCustomer(c: OrderingCustomer) {
selectedCustomer = c;
discountInput = c.discount_percent;
try {
[custUsers, custPricing, custVisibility] = await Promise.all([
api.orderingAdmin.customerUsers(c.id),
api.orderingAdmin.pricing(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 saveDiscount() {
if (!selectedCustomer) return;
try {
custPricing = await api.orderingAdmin.setAssignment(selectedCustomer.id, { price_list_id: custPricing?.price_list_id ?? null, discount_percent: Number(discountInput) });
toast.success('Discount saved.');
await refreshCustomers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save discount.');
}
}
async function addProductPrice() {
if (!selectedCustomer || !newPrice.product_id) return toast.error('Choose a product.');
try {
custPricing = await api.orderingAdmin.setProductPrice(selectedCustomer.id, {
product_id: Number(newPrice.product_id),
unit_price: newPrice.rule_type === 'quote' || newPrice.unit_price === '' ? null : Number(newPrice.unit_price),
rule_type: newPrice.rule_type
});
toast.success('Customer price saved.');
newPrice = { product_id: '', unit_price: '', rule_type: 'fixed' };
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save price.');
}
}
async function removeProductPrice(productId: number) {
if (!selectedCustomer) return;
try {
await api.orderingAdmin.deleteProductPrice(selectedCustomer.id, productId);
custPricing = await api.orderingAdmin.pricing(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not remove price.');
}
}
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.');
}
}
function productName(id: number) {
return products.find((p) => p.id === id)?.name ?? `#${id}`;
}
// --- Settings -------------------------------------------------------------
let settings = $state<OrderingNotificationSettings | null>(null);
async function loadSettings() {
try {
settings = await api.orderingAdmin.notificationSettings();
xero = await api.orderingAdmin.xeroStatus();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not load settings.');
}
}
async function saveSettings() {
if (!settings) return;
try {
settings = await api.orderingAdmin.updateNotificationSettings(settings);
toast.success('Settings saved.');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save settings.');
}
}
$effect(() => {
if (tab === 'settings' && !settings) loadSettings();
});
</script>
<div class="admin-ordering">
<header>
<p class="eyebrow">Ordering</p>
<h1>Order management</h1>
</header>
<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>
<nav class="tabs">
<button class:active={tab === 'orders'} onclick={() => (tab = 'orders')}>Orders</button>
<button class:active={tab === 'products'} onclick={() => (tab = 'products')}>Products</button>
<button class:active={tab === 'customers'} onclick={() => (tab = 'customers')}>Customers & Pricing</button>
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings & Xero</button>
</nav>
{#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>
<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>
<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>{money(l.line_total)}</td>
</tr>
{/each}
</tbody>
</table>
<div class="detail-total"><span>Subtotal (ex GST)</span><strong>{money(selectedOrder.subtotal_ex_gst)}</strong></div>
{#if tab === 'orders'}
<div class="split">
<section class="surface-card">
<h2>Order queue</h2>
{#if !orders.length}
<p class="empty">No submitted orders.</p>
{:else}
<table>
<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">{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>
{#if selectedOrder}
<section class="surface-card detail">
<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>
<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>
<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>{money(l.line_total)}</td>
</tr>
{/each}
</tbody>
</table>
<div class="detail-total"><span>Subtotal (ex GST)</span><strong>{money(selectedOrder.subtotal_ex_gst)}</strong></div>
<div class="actions">
<select bind:value={statusChoice}>
<option value="">Change status…</option>
{#each 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>
</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}
</section>
{/if}
<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>
</div>
{/if}
{#if tab === 'products'}
<section class="surface-card">
<h2>New product</h2>
<div class="form-grid">
<label>Name<input bind:value={newProduct.name} /></label>
<label>SKU<input bind:value={newProduct.sku} /></label>
<label>Category
<select bind:value={newProduct.category}>{#each 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>
<label>Base price (ex GST)<input type="number" step="0.01" bind:value={newProduct.base_price} /></label>
<label class="check"><input type="checkbox" bind:checked={newProduct.requires_quote} /> Requires quote</label>
<button class="primary" onclick={createProduct}>Create product</button>
</div>
</section>
<section class="surface-card">
<h2>Catalogue ({products.length})</h2>
<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>
{#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}
</tbody>
</table>
</section>
{/if}
{#if tab === 'customers'}
<div class="split">
<section class="surface-card">
<h2>New customer</h2>
<div class="form-row">
<input placeholder="Company name" bind:value={newCustomer.name} />
<input placeholder="Code (e.g. ACME)" bind:value={newCustomer.client_code} />
<button class="primary" onclick={createCustomer}>Create</button>
</div>
<h2 class="mt">Customers ({customers.length})</h2>
<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">{c.status}</span></td>
<td><button class="link" onclick={() => toggleCustomerStatus(c)}>{c.status === 'active' ? 'Disable' : 'Enable'}</button></td>
</tr>
{/each}
</tbody>
</table>
</section>
{#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>
<h3 class="mt">Pricing</h3>
<div class="form-row">
<label class="inline-label">Default discount %
<input type="number" step="0.5" bind:value={discountInput} />
</label>
<button class="secondary" onclick={saveDiscount}>Save discount</button>
</div>
{#if custPricing?.product_prices.length}
<ul class="mini">
{#each custPricing.product_prices as pp (pp.id)}
<li>{productName(pp.product_id)} · {pp.rule_type} · {pp.unit_price != null ? money(pp.unit_price) : 'quote'}
<button class="link" onclick={() => removeProductPrice(pp.product_id)}>Remove</button>
</li>
{/each}
</ul>
{/if}
<div class="form-row">
<select bind:value={newPrice.product_id}>
<option value="">Product…</option>
{#each products as p}<option value={p.id}>{p.name}</option>{/each}
</select>
<select bind:value={newPrice.rule_type}>
<option value="fixed">Fixed</option><option value="contract">Contract</option><option value="quote">Quote</option>
</select>
<input type="number" step="0.01" placeholder="Unit price" bind:value={newPrice.unit_price} disabled={newPrice.rule_type === 'quote'} />
<button class="secondary" onclick={addProductPrice}>Set price</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>
</section>
{/if}
</div>
{/if}
{#if tab === 'settings'}
<div class="split">
<section class="surface-card">
<h2>Notification settings</h2>
{#if settings}
<div class="form-grid">
<label class="full">Internal recipients (comma separated)<input bind:value={settings.internal_recipients} /></label>
<label class="full">From email<input bind:value={settings.from_email} /></label>
<label class="check"><input type="checkbox" bind:checked={settings.send_customer_confirmation} /> Send customer confirmation</label>
<label class="check"><input type="checkbox" bind:checked={settings.require_po_number} /> Require PO number on submit</label>
<button class="primary" onclick={saveSettings}>Save settings</button>
</div>
{:else}
<p class="empty">Loading…</p>
{/if}
</section>
<section class="surface-card">
<h2>Xero integration</h2>
{#if xero}
<p class="muted">Mode: <strong>{xero.connection.mode}</strong> · {xero.connection.configured ? 'Configured' : 'Not configured (stub mode)'}</p>
{#if xero.connection.missing_env.length}
<p class="muted">Missing env: {xero.connection.missing_env.join(', ')}</p>
{/if}
<h3>Recent syncs</h3>
{#if !xero.recent_syncs.length}
<p class="empty">No Xero submissions yet.</p>
{:else}
<ul class="mini">
{#each xero.recent_syncs as s (s.id)}
<li>Order {s.order_id} · {s.status} · {s.xero_invoice_id ?? '—'} · {new Date(s.created_at).toLocaleString('en-AU')}</li>
{/each}
</ul>
{/if}
{:else}
<p class="empty">Loading…</p>
{/if}
</section>
</div>
{/if}
</div>
<style>
.admin-ordering { display: grid; gap: 1rem; }
h1 { margin: 0.15rem 0; font-size: 1.4rem; }
h2 { margin: 0 0 0.7rem; font-size: 1.05rem; }
h3 { margin: 0 0 0.4rem; font-size: 0.92rem; }
.mt { margin-top: 1rem; }
.eyebrow { color: #6e8576; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.muted { color: #64776b; font-size: 0.84rem; margin: 0 0 0.6rem; }
.surface-card { border: 1px solid rgba(34,54,45,0.12); border-radius: 1rem; background: rgba(255,255,255,0.92); padding: 1.1rem; }
.tabs { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.tabs button { padding: 0.45rem 0.9rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.7rem; background: transparent; cursor: pointer; font-weight: 600; font-size: 0.86rem; }
.tabs button.active { background: var(--color-brand, #2f6f4f); color: #fff; border-color: transparent; }
.split { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 1rem; align-items: start; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th, td { text-align: left; padding: 0.45rem 0.55rem; border-bottom: 1px solid rgba(34,54,45,0.08); }
th { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: #7c8c82; }
tbody tr { cursor: pointer; }
tbody tr.selected { background: rgba(47,111,79,0.08); }
.pill { padding: 0.16rem 0.5rem; border-radius: 999px; font-size: 0.72rem; font-weight: 600; background: rgba(34,54,45,0.08); }
.tag { margin-left: 0.4rem; font-size: 0.64rem; padding: 0.05rem 0.35rem; border-radius: 999px; background: #fdf0d5; color: #8a5a00; }
.empty { color: #7c8c82; font-size: 0.85rem; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; align-items: end; }
.form-grid .full { grid-column: 1 / -1; }
.form-grid label, .form-row label { display: grid; gap: 0.2rem; font-size: 0.76rem; color: #64776b; }
.form-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.6rem; }
.form-row input, .form-row select, .form-grid input, .form-grid select { padding: 0.45rem 0.55rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.55rem; font: inherit; }
.check { display: flex; flex-direction: row; align-items: center; gap: 0.4rem; }
.inline-label { flex-direction: row; align-items: center; gap: 0.45rem; }
.inline { width: 6rem; padding: 0.3rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.45rem; }
.ovr { width: 5rem; padding: 0.3rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.45rem; }
.primary, .secondary { border-radius: 0.6rem; padding: 0.5rem 0.9rem; font-weight: 600; cursor: pointer; border: none; }
.primary { background: var(--color-brand, #2f6f4f); color: #fff; }
.secondary { background: rgba(34,54,45,0.08); color: #22362d; }
.link { background: none; border: none; color: var(--color-brand, #2f6f4f); cursor: pointer; font-size: 0.8rem; padding: 0; }
.lines td { font-size: 0.82rem; }
.detail-total { display: flex; justify-content: space-between; padding: 0.5rem 0; border-top: 1px solid rgba(34,54,45,0.12); margin: 0.4rem 0; }
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.actions select { padding: 0.45rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.55rem; }
.history { margin-top: 0.8rem; font-size: 0.8rem; }
.mini { list-style: none; margin: 0.3rem 0 0.6rem; padding: 0; display: grid; gap: 0.3rem; font-size: 0.82rem; }
.mini li { display: flex; gap: 0.5rem; align-items: center; justify-content: space-between; }
.visibility li { justify-content: flex-start; }
@media (max-width: 1000px) { .split, .form-grid { grid-template-columns: 1fr; } }
</style>
</ul>
</details>
{/if}
</div>
</div>
{/if}
+7 -20
View File
@@ -1,30 +1,17 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { canManageOrdering, getWorkspaceHomeHref } from '$lib/workspace-access';
const EMPTY = { orders: [], products: [], customers: [], xero: null } as const;
import type { Order } from '$lib/types';
// Orders queue. Access is already enforced by the family +layout.ts guard.
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { ...EMPTY };
}
const session = getStoredClientSession();
if (!canManageOrdering(session)) {
// Customers (or anyone without manage rights) don't belong here.
throw redirect(307, getWorkspaceHomeHref(session));
return { orders: [] as Order[] };
}
try {
const [orders, products, customers, xero] = await Promise.all([
api.orderingAdmin.orders(undefined, fetch),
api.orderingAdmin.products(fetch),
api.orderingAdmin.customers(fetch),
api.orderingAdmin.xeroStatus(fetch)
]);
return { orders, products, customers, xero };
const orders = await api.orderingAdmin.orders(undefined, fetch);
return { orders };
} catch {
return { ...EMPTY };
return { orders: [] as Order[] };
}
}
@@ -0,0 +1,184 @@
<script lang="ts">
import { tick } from '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';
let { data } = $props();
let customers = $state<OrderingCustomer[]>([]);
$effect(() => {
customers = data.customers ?? [];
});
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;
}
$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() {
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 {
await api.orderingAdmin.createCustomer(newCustomer);
toast.success('Customer created.');
newCustomer = { name: '', client_code: '' };
showNewCustomer = false;
await refreshCustomers();
} catch (e) {
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>
{#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>
<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>
<p class="muted mt">Manage discounts and per-product pricing for this customer on the <a href="/ordering/manage/pricing">Pricing</a> page.</p>
</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}
@@ -0,0 +1,17 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import type { OrderingCustomer } from '$lib/types';
// Customer accounts. Access enforced by the family +layout.ts guard.
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { customers: [] as OrderingCustomer[] };
}
try {
const customers = await api.orderingAdmin.customers(fetch);
return { customers };
} catch {
return { customers: [] as OrderingCustomer[] };
}
}
@@ -0,0 +1,60 @@
<script lang="ts">
import { Plug } from 'lucide-svelte';
// Connected systems. Each integration has its own page nested under this one
// (and a matching submenu entry in the rail).
const integrations = [
{
href: '/ordering/manage/integrations/xero',
name: 'Xero',
description: 'Send confirmed order invoices to Xero and map customers to their Xero contact.'
}
];
</script>
<section class="surface-card">
<h2>Integrations</h2>
<p class="muted">Connect the ordering portal to the systems you already use.</p>
<ul class="integration-list">
{#each integrations as it (it.href)}
<li>
<a class="integration" href={it.href}>
<span class="ico"><Plug size={18} strokeWidth={1.75} /></span>
<span class="body">
<span class="name">{it.name}</span>
<span class="desc">{it.description}</span>
</span>
</a>
</li>
{/each}
</ul>
</section>
<style>
.integration-list { list-style: none; margin: 0.5rem 0 0; padding: 0; display: grid; gap: 0.6rem; }
.integration {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.85rem 0.95rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--panel-soft);
transition: border-color 140ms ease, background-color 140ms ease;
}
.integration:hover { border-color: var(--color-brand); background: var(--color-surface-hover); }
.ico {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
border-radius: var(--radius-control);
background: var(--color-bg-surface);
color: var(--color-brand);
flex-shrink: 0;
}
.body { display: grid; gap: 0.15rem; min-width: 0; }
.name { font-weight: 700; font-size: 0.95rem; color: var(--color-text-primary); }
.desc { font-size: 0.82rem; color: var(--color-text-muted); }
</style>
@@ -0,0 +1,5 @@
// Integrations landing. Individual integrations (Xero) load their own data on
// their nested routes. Access enforced by the family +layout.ts guard.
export function load() {
return {};
}
@@ -0,0 +1,180 @@
<script lang="ts">
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import type { XeroContact, XeroContactLinkRow, XeroStatus } from '$lib/types';
let { data } = $props();
let xero = $state<XeroStatus | null>(null);
let contacts = $state<XeroContact[]>([]);
let contactsStubbed = $state(false);
let links = $state<XeroContactLinkRow[]>([]);
// The contact selected in each customer's dropdown, keyed by customer id. Seeded
// from the saved link or the server's suggested match so the operator usually
// just confirms.
let choice = $state<Record<number, string>>({});
// Hydrate local state from the loader. Read only from `data` here — referencing
// a writable state we also assign (e.g. `links`) inside the same effect would
// retrigger it forever (svelte effect_update_depth_exceeded).
$effect(() => {
const nextLinks = data.links ?? [];
xero = data.xero ?? null;
contacts = data.contacts ?? [];
contactsStubbed = data.contactsStubbed ?? false;
links = nextLinks;
choice = Object.fromEntries(
nextLinks.map((l) => [l.customer_id, l.xero_contact_id ?? l.suggested_contact_id ?? ''])
);
});
const linkedCount = $derived(links.filter((l) => l.linked).length);
async function refresh() {
try {
const [status, list, rows] = await Promise.all([
api.orderingAdmin.xeroStatus(),
api.orderingAdmin.xeroContacts(),
api.orderingAdmin.xeroContactLinks()
]);
xero = status;
contacts = list.contacts;
contactsStubbed = list.stubbed;
links = rows;
choice = Object.fromEntries(
rows.map((l) => [l.customer_id, l.xero_contact_id ?? l.suggested_contact_id ?? ''])
);
} catch {}
}
async function saveLink(row: XeroContactLinkRow) {
const contactId = choice[row.customer_id];
if (!contactId) return toast.error('Choose a Xero contact first.');
const contact = contacts.find((c) => c.contact_id === contactId);
try {
await api.orderingAdmin.linkCustomerToXero(row.customer_id, {
xero_contact_id: contactId,
xero_contact_name: contact?.name ?? null,
xero_contact_email: contact?.email ?? null
});
toast.success(`${row.customer_name} linked to ${contact?.name ?? 'Xero contact'}.`);
await refresh();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not link customer.');
}
}
async function removeLink(row: XeroContactLinkRow) {
try {
await api.orderingAdmin.unlinkCustomerFromXero(row.customer_id);
toast.success(`${row.customer_name} unlinked.`);
await refresh();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not unlink customer.');
}
}
function isDirty(row: XeroContactLinkRow): boolean {
const sel = choice[row.customer_id] ?? '';
return !!sel && sel !== (row.xero_contact_id ?? '');
}
</script>
<section class="surface-card">
<h2>Xero connection</h2>
{#if xero}
<p class="muted">Mode: <strong>{xero.connection.mode}</strong> · {xero.connection.configured ? 'Configured' : 'Not configured (stub mode)'}</p>
{#if xero.connection.missing_env.length}
<p class="muted">Missing env: {xero.connection.missing_env.join(', ')}</p>
{/if}
<p class="muted">
Customers linked to a Xero contact:
<strong>{xero.contact_links.linked}</strong> of {xero.contact_links.total}
{#if xero.contact_links.unlinked}· <span class="warn-text">{xero.contact_links.unlinked} unlinked</span>{/if}
</p>
{:else}
<p class="empty">Could not load integration status.</p>
{/if}
</section>
<section class="surface-card">
<div class="card-head">
<h2>Customer ↔ Xero contact mapping</h2>
<span class="count">{linkedCount}/{links.length} linked</span>
</div>
<p class="muted">
Link each customer in our database to its contact in Xero. Once linked, that
customer's order invoices are raised against the matched Xero contact instead
of being matched by code.
{#if contactsStubbed}<br />Showing <strong>sample</strong> Xero contacts — live contacts appear once Xero credentials are configured.{/if}
</p>
{#if !links.length}
<p class="empty">No customers yet.</p>
{:else}
<table>
<thead>
<tr><th>Customer</th><th>Code</th><th>Xero contact</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{#each links as row (row.customer_id)}
<tr>
<td>{row.customer_name}</td>
<td>{row.client_code}</td>
<td>
<select bind:value={choice[row.customer_id]}>
<option value="">— Not linked —</option>
{#each contacts as c (c.contact_id)}
<option value={c.contact_id}>{c.name}{c.email ? ` (${c.email})` : ''}</option>
{/each}
</select>
{#if !row.linked && row.suggested_contact_id}
<span class="hint">suggested match</span>
{/if}
</td>
<td>
{#if row.linked}
<span class="pill pos">Linked</span>
{:else}
<span class="pill warn">Unlinked</span>
{/if}
</td>
<td class="row-actions">
<button
class="primary sm"
onclick={() => saveLink(row)}
disabled={!choice[row.customer_id] || (row.linked && !isDirty(row))}
>
{row.linked ? 'Update' : 'Link'}
</button>
{#if row.linked}
<button class="link" onclick={() => removeLink(row)}>Unlink</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<section class="surface-card">
<h2>Recent syncs</h2>
{#if !xero || !xero.recent_syncs.length}
<p class="empty">No Xero submissions yet.</p>
{:else}
<ul class="mini">
{#each xero.recent_syncs as s (s.id)}
<li>Order {s.order_id} · {s.status} · {s.xero_invoice_id ?? '—'} · {new Date(s.created_at).toLocaleString('en-AU')}</li>
{/each}
</ul>
{/if}
</section>
<style>
.count { font-size: 0.78rem; font-weight: 600; color: var(--color-text-muted); }
.row-actions { display: flex; align-items: center; gap: 0.6rem; white-space: nowrap; }
.primary.sm { min-height: 1.9rem; padding: 0.3rem 0.7rem; font-size: 0.8rem; }
.hint { display: block; margin-top: 0.2rem; font-size: 0.68rem; color: var(--color-info); }
.warn-text { color: var(--color-warning-text); font-weight: 600; }
</style>
@@ -0,0 +1,37 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import type { XeroContact, XeroContactLinkRow, XeroStatus } from '$lib/types';
// Xero integration status + customer→contact mapping. Access enforced by the
// family +layout.ts guard.
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return {
xero: null as XeroStatus | null,
contacts: [] as XeroContact[],
contactsStubbed: false,
links: [] as XeroContactLinkRow[]
};
}
try {
const [xero, contactList, links] = await Promise.all([
api.orderingAdmin.xeroStatus(fetch),
api.orderingAdmin.xeroContacts(fetch),
api.orderingAdmin.xeroContactLinks(fetch)
]);
return {
xero,
contacts: contactList.contacts,
contactsStubbed: contactList.stubbed,
links
};
} catch {
return {
xero: null as XeroStatus | null,
contacts: [] as XeroContact[],
contactsStubbed: false,
links: [] as XeroContactLinkRow[]
};
}
}
@@ -0,0 +1,116 @@
<script lang="ts">
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import { money } from '$lib/ordering/format';
import type { CatalogueProduct, CustomerPricing, OrderingCustomer } from '$lib/types';
let { data } = $props();
let customers = $state<OrderingCustomer[]>([]);
let products = $state<CatalogueProduct[]>([]);
$effect(() => {
customers = data.customers ?? [];
products = data.products ?? [];
});
let selectedId = $state('');
let selectedCustomer = $derived(customers.find((c) => String(c.id) === selectedId) ?? null);
let custPricing = $state<CustomerPricing | null>(null);
let discountInput = $state(0);
let newPrice = $state<Record<string, any>>({ product_id: '', unit_price: '', rule_type: 'fixed' });
async function loadPricing() {
custPricing = null;
if (!selectedCustomer) return;
discountInput = selectedCustomer.discount_percent;
try {
custPricing = await api.orderingAdmin.pricing(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not load pricing.');
}
}
async function saveDiscount() {
if (!selectedCustomer) return;
try {
custPricing = await api.orderingAdmin.setAssignment(selectedCustomer.id, { price_list_id: custPricing?.price_list_id ?? null, discount_percent: Number(discountInput) });
toast.success('Discount saved.');
customers = await api.orderingAdmin.customers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save discount.');
}
}
async function addProductPrice() {
if (!selectedCustomer || !newPrice.product_id) return toast.error('Choose a product.');
try {
custPricing = await api.orderingAdmin.setProductPrice(selectedCustomer.id, {
product_id: Number(newPrice.product_id),
unit_price: newPrice.rule_type === 'quote' || newPrice.unit_price === '' ? null : Number(newPrice.unit_price),
rule_type: newPrice.rule_type
});
toast.success('Customer price saved.');
newPrice = { product_id: '', unit_price: '', rule_type: 'fixed' };
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save price.');
}
}
async function removeProductPrice(productId: number) {
if (!selectedCustomer) return;
try {
await api.orderingAdmin.deleteProductPrice(selectedCustomer.id, productId);
custPricing = await api.orderingAdmin.pricing(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not remove price.');
}
}
function productName(id: number) {
return products.find((p) => p.id === id)?.name ?? `#${id}`;
}
</script>
<section class="surface-card">
<h2>Customer pricing</h2>
<div class="form-row">
<label class="inline-label">Customer
<select bind:value={selectedId} onchange={loadPricing}>
<option value="">Select a customer…</option>
{#each customers as c (c.id)}<option value={String(c.id)}>{c.name} ({c.client_code})</option>{/each}
</select>
</label>
</div>
{#if !selectedCustomer}
<p class="empty">Choose a customer to view and edit their discount and per-product prices.</p>
{:else}
<h3 class="mt">Default discount</h3>
<div class="form-row">
<label class="inline-label">Default discount %
<input type="number" step="0.5" bind:value={discountInput} />
</label>
<button class="secondary" onclick={saveDiscount}>Save discount</button>
</div>
<h3 class="mt">Per-product prices</h3>
{#if custPricing?.product_prices.length}
<ul class="mini">
{#each custPricing.product_prices as pp (pp.id)}
<li>{productName(pp.product_id)} · {pp.rule_type} · {pp.unit_price != null ? money(pp.unit_price) : 'quote'}
<button class="link" onclick={() => removeProductPrice(pp.product_id)}>Remove</button>
</li>
{/each}
</ul>
{:else}
<p class="empty">No product-specific prices. The default discount applies to base prices.</p>
{/if}
<div class="form-row">
<select bind:value={newPrice.product_id}>
<option value="">Product…</option>
{#each products as p}<option value={p.id}>{p.name}</option>{/each}
</select>
<select bind:value={newPrice.rule_type}>
<option value="fixed">Fixed</option><option value="contract">Contract</option><option value="quote">Quote</option>
</select>
<input type="number" step="0.01" placeholder="Unit price" bind:value={newPrice.unit_price} disabled={newPrice.rule_type === 'quote'} />
<button class="secondary" onclick={addProductPrice}>Set price</button>
</div>
{/if}
</section>
@@ -0,0 +1,22 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import type { CatalogueProduct, OrderingCustomer } from '$lib/types';
// Pricing needs the customer list (to choose whose pricing to edit) and the
// product catalogue (for the price-rule product picker). Per-customer pricing
// itself is loaded client-side once a customer is selected.
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { customers: [] as OrderingCustomer[], products: [] as CatalogueProduct[] };
}
try {
const [customers, products] = await Promise.all([
api.orderingAdmin.customers(fetch),
api.orderingAdmin.products(fetch)
]);
return { customers, products };
} catch {
return { customers: [] as OrderingCustomer[], products: [] as CatalogueProduct[] };
}
}
@@ -0,0 +1,118 @@
<script lang="ts">
import { tick } from 'svelte';
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import { label, PRODUCT_CATEGORIES } from '$lib/ordering/format';
import type { CatalogueProduct } from '$lib/types';
let { data } = $props();
let products = $state<CatalogueProduct[]>([]);
$effect(() => {
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 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;
}
$effect(() => {
if (showNewProduct) tick().then(() => newProductNameInput?.focus());
});
async function refreshProducts() {
try {
products = await api.orderingAdmin.products();
} catch {}
}
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) });
toast.success('Product created.');
newProduct = blankProduct();
showNewProduct = false;
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not create product.');
}
}
async function toggleProductActive(p: CatalogueProduct) {
try {
await api.orderingAdmin.updateProduct(p.id, { active: !p.active });
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function saveProductPrice(p: CatalogueProduct, value: string) {
try {
await api.orderingAdmin.updateProduct(p.id, { base_price: value === '' ? null : Number(value) });
toast.success('Base price updated.');
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
</script>
<section class="surface-card">
<div class="card-head">
<h2>Catalogue ({products.length})</h2>
<button class="primary" onclick={openNewProduct}>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>
<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>
</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>
<label>Base price (ex GST)<input type="number" step="0.01" bind:value={newProduct.base_price} /></label>
<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="primary" onclick={createProduct}>Create product</button>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,17 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import type { CatalogueProduct } from '$lib/types';
// Catalogue products. Access enforced by the family +layout.ts guard.
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { products: [] as CatalogueProduct[] };
}
try {
const products = await api.orderingAdmin.products(fetch);
return { products };
} catch {
return { products: [] as CatalogueProduct[] };
}
}
@@ -0,0 +1,37 @@
<script lang="ts">
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import type { OrderingNotificationSettings } from '$lib/types';
let { data } = $props();
let settings = $state<OrderingNotificationSettings | null>(null);
$effect(() => {
settings = data.settings ?? null;
});
async function saveSettings() {
if (!settings) return;
try {
settings = await api.orderingAdmin.updateNotificationSettings(settings);
toast.success('Settings saved.');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save settings.');
}
}
</script>
<section class="surface-card">
<h2>Notification settings</h2>
{#if settings}
<div class="form-grid">
<label class="full">Internal recipients (comma separated)<input bind:value={settings.internal_recipients} /></label>
<label class="full">From email<input bind:value={settings.from_email} /></label>
<label class="check"><input type="checkbox" bind:checked={settings.send_customer_confirmation} /> Send customer confirmation</label>
<label class="check"><input type="checkbox" bind:checked={settings.require_po_number} /> Require PO number on submit</label>
<button class="primary" onclick={saveSettings}>Save settings</button>
</div>
{:else}
<p class="empty">Could not load settings.</p>
{/if}
</section>
@@ -0,0 +1,17 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import type { OrderingNotificationSettings } from '$lib/types';
// Notification settings. Access enforced by the family +layout.ts guard.
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { settings: null as OrderingNotificationSettings | null };
}
try {
const settings = await api.orderingAdmin.notificationSettings(fetch);
return { settings };
} catch {
return { settings: null as OrderingNotificationSettings | null };
}
}
@@ -735,11 +735,34 @@
--costing-line: oklch(88% 0.014 145);
--costing-line-strong: oklch(78% 0.02 145);
--costing-warn: oklch(58% 0.14 72);
--costing-input-bg: oklch(99% 0.004 145);
--costing-warn-soft: oklch(95% 0.055 82);
--costing-warn-box: oklch(96% 0.045 84);
--costing-warn-border: oklch(84% 0.085 78);
--costing-warn-row: oklch(92% 0.08 81);
display: grid;
gap: 1.05rem;
color: var(--costing-ink);
}
/* Dark mode: remap the costing palette onto the neutral dark theme tokens so
the worksheet themes alongside the rest of the app instead of staying a
bright light card. The warn tints lean on the global warning tokens. */
:global([data-theme='dark']) .costing-shell {
--costing-ink: var(--color-text-primary);
--costing-muted: var(--color-text-muted);
--costing-panel: var(--color-bg-surface);
--costing-soft: var(--color-bg-app);
--costing-line: var(--color-border);
--costing-line-strong: var(--color-border);
--costing-warn: var(--color-warning-text);
--costing-input-bg: var(--color-input-bg);
--costing-warn-soft: var(--color-warning-tint);
--costing-warn-box: var(--color-warning-tint);
--costing-warn-border: color-mix(in srgb, var(--color-warning) 45%, var(--color-border));
--costing-warn-row: var(--color-warning);
}
.page-head,
.health-strip,
.workspace-grid,
@@ -809,7 +832,7 @@
padding: 0.38rem 0.62rem;
border-radius: 999px;
color: var(--costing-warn);
background: oklch(95% 0.055 82);
background: var(--costing-warn-soft);
font-size: 0.8rem;
font-weight: 800;
}
@@ -881,7 +904,7 @@
}
.health-card.warning {
background: color-mix(in srgb, oklch(91% 0.08 80) 34%, var(--costing-panel));
background: color-mix(in srgb, var(--costing-warn-row) 34%, var(--costing-panel));
}
.health-card strong {
@@ -978,7 +1001,7 @@
padding: 0.55rem 0.65rem;
border: 1px solid var(--costing-line-strong);
border-radius: 0.66rem;
background: oklch(99% 0.004 145);
background: var(--costing-input-bg);
color: var(--costing-ink);
}
@@ -995,7 +1018,7 @@
align-items: center;
border: 1px solid var(--costing-line-strong);
border-radius: 0.66rem;
background: oklch(99% 0.004 145);
background: var(--costing-input-bg);
overflow: hidden;
}
@@ -1085,7 +1108,7 @@
}
tbody tr.warn td {
background: color-mix(in srgb, oklch(93% 0.08 83) 45%, var(--costing-panel));
background: color-mix(in srgb, var(--costing-warn-row) 45%, var(--costing-panel));
}
tbody td {
@@ -1166,7 +1189,7 @@
.status-pill.warning {
color: var(--costing-warn);
background: oklch(95% 0.055 82);
background: var(--costing-warn-soft);
}
.skeleton-row td {
@@ -1257,10 +1280,10 @@
align-items: flex-start;
padding: 0.78rem;
margin-bottom: 0.85rem;
border: 1px solid oklch(84% 0.085 78);
border: 1px solid var(--costing-warn-border);
border-radius: 0.82rem;
color: var(--costing-warn);
background: oklch(96% 0.045 84);
background: var(--costing-warn-box);
font-size: 0.85rem;
font-weight: 700;
}
+13 -13
View File
@@ -617,7 +617,7 @@
}
.eyebrow {
color: #7f8e85;
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
@@ -680,14 +680,14 @@
.feedback.success {
color: var(--green-deep);
border-color: #d8ecdf;
background: #f6fcf8;
border-color: color-mix(in srgb, var(--color-success) 22%, var(--color-border));
background: color-mix(in srgb, var(--color-success-tint) 55%, var(--color-bg-surface));
}
.feedback.error {
color: #a03737;
border-color: #f0d9d9;
background: #fff8f8;
color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 22%, var(--color-border));
background: color-mix(in srgb, var(--color-error) 8%, var(--color-bg-surface));
}
.metric-row,
@@ -839,7 +839,7 @@
label {
display: grid;
gap: 0.35rem;
color: #53645b;
color: var(--color-text-secondary);
font-size: 0.9rem;
font-weight: 600;
}
@@ -860,7 +860,7 @@
padding: 0.85rem 1rem;
border: none;
border-radius: 0.9rem;
color: #fff;
color: var(--color-on-brand);
background: var(--color-brand);
box-shadow: none;
font-weight: 600;
@@ -955,13 +955,13 @@
}
.material-icon.active {
color: #fff;
color: var(--color-on-brand);
background: var(--color-brand);
}
.material-icon.muted {
color: #55685f;
background: #e9efeb;
color: var(--color-text-secondary);
background: var(--color-surface-hover);
}
.status-pill {
@@ -981,8 +981,8 @@
}
.status-pill.neutral {
color: #5a6c63;
background: #edf2ef;
color: var(--color-text-secondary);
background: var(--color-surface-hover);
}
.material-grid {
+343 -5
View File
@@ -3,12 +3,17 @@
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import { clientSession } from '$lib/session';
import { canEditThroughput } from '$lib/workspace-access';
import { toast } from '$lib/toast';
import { CircleUserRound, LockKeyhole } from 'lucide-svelte';
import type { ThroughputImportResult } from '$lib/types';
import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert } from 'lucide-svelte';
type Section = 'profile' | 'security';
type Section = 'profile' | 'security' | 'import';
let activeSection = $state<Section>('profile');
// Only operators who can edit throughput see (and can use) the import tool.
const canImportThroughput = $derived(canEditThroughput($clientSession));
let name = $state($clientSession?.name ?? '');
let email = $state($clientSession?.email ?? '');
@@ -69,6 +74,90 @@
}
}
// ── Throughput import ─────────────────────────────────────────
let importFile = $state<File | null>(null);
let importing = $state(false);
let importResult = $state<ThroughputImportResult | null>(null);
let importError = $state('');
function onImportFileChange(event: Event) {
const input = event.currentTarget as HTMLInputElement;
importFile = input.files?.[0] ?? null;
importResult = null;
importError = '';
}
async function runImport() {
if (!importFile) {
importError = 'Choose a CSV or spreadsheet file first.';
return;
}
importing = true;
importError = '';
importResult = null;
const tid = toast.loading('Importing entries…');
try {
const result = await api.importThroughputEntries(importFile);
importResult = result;
toast.dismiss(tid);
if (result.entries_imported > 0) {
toast.success(
`Imported ${result.entries_imported} ${result.entries_imported === 1 ? 'entry' : 'entries'}`
);
} else {
toast.error('No entries were imported. Check the file and try again.');
}
} catch (err: unknown) {
toast.dismiss(tid);
const msg = err instanceof Error ? err.message : 'Import failed';
importError = msg;
toast.error(msg);
} finally {
importing = false;
}
}
// Build a small sample CSV in the browser so operators have a working header
// row to copy from. The backend matches these headers case-insensitively.
function downloadTemplate() {
const headers = [
'Date',
'Product',
'Item ID',
'Quantity',
'Type',
'Bag Size',
'Packed By',
'For Order',
'Job Number',
'For Stock',
'Stock Quantity',
'Notes'
];
const example = [
'2026-06-12',
'Specialty Pigeon Breeder',
'',
'40',
'bags',
'20',
'Jane Doe',
'yes',
'JOB1234',
'no',
'',
'First run of the day'
];
const csv = `${headers.join(',')}\n${example.join(',')}\n`;
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'throughput-import-template.csv';
link.click();
URL.revokeObjectURL(url);
}
const initials = $derived(
($clientSession?.name ?? '')
.split(' ')
@@ -78,12 +167,13 @@
.toUpperCase() || '?'
);
const navItems: { id: Section; label: string; icon: typeof CircleUserRound }[] = [
const navItems = $derived<{ id: Section; label: string; icon: typeof CircleUserRound }[]>([
{ id: 'profile', label: 'Profile', icon: CircleUserRound },
{ id: 'security', label: 'Security', icon: LockKeyhole },
];
...(canImportThroughput ? [{ id: 'import' as Section, label: 'Import', icon: Upload }] : []),
]);
const railGroups = [{ items: navItems }];
const railGroups = $derived([{ items: navItems }]);
</script>
<AppSecondaryRailLayout>
@@ -164,6 +254,92 @@
</div>
</form>
</div>
{:else if activeSection === 'import' && canImportThroughput}
<div class="panel-section">
<header class="panel-header">
<h2>Import throughput entries</h2>
<p>Upload a CSV or Excel (.xlsx) file of packing runs. Each row is saved as a throughput entry.</p>
</header>
<div class="import-body">
<div class="import-help">
<h3>Required columns</h3>
<p>
Your file needs a header row with at least <strong>Date</strong>, <strong>Product</strong>
and <strong>Quantity</strong> columns. These optional columns are also recognised:
</p>
<ul>
<li><strong>Type</strong><code>bags</code> or <code>kg</code> (inferred from bag size if omitted)</li>
<li><strong>Bag Size</strong> — kg per bag (required when packing as bags)</li>
<li><strong>Item ID</strong> — matches an existing product; otherwise matched by name</li>
<li><strong>Packed By</strong>, <strong>Notes</strong></li>
<li><strong>For Order</strong>, <strong>Job Number</strong>, <strong>For Stock</strong>, <strong>Stock Quantity</strong></li>
</ul>
<p class="import-note">
Products that don't already exist are created automatically. Dates accept
<code>YYYY-MM-DD</code> or <code>DD/MM/YYYY</code>.
</p>
<button type="button" class="btn-link" onclick={downloadTemplate}>
<FileSpreadsheet size={15} strokeWidth={2.2} /> Download CSV template
</button>
</div>
<div class="import-control">
<label class="file-drop" class:has-file={!!importFile}>
<input
type="file"
accept=".csv,.xlsx,.xlsm,.xls,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onchange={onImportFileChange}
/>
<Upload size={22} strokeWidth={2} />
<span class="file-drop-label">
{importFile ? importFile.name : 'Choose a CSV or .xlsx file'}
</span>
{#if importFile}
<span class="file-drop-size">{(importFile.size / 1024).toFixed(1)} KB</span>
{/if}
</label>
{#if importError}
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {importError}</p>
{/if}
{#if importResult}
<div class="import-result" role="status">
<p class="import-result-head">
Imported <strong>{importResult.entries_imported}</strong>
{importResult.entries_imported === 1 ? 'entry' : 'entries'}.
</p>
<ul class="import-result-stats">
{#if importResult.products_created > 0}
<li>{importResult.products_created} new product{importResult.products_created === 1 ? '' : 's'} created</li>
{/if}
{#if importResult.entries_skipped > 0}
<li>{importResult.entries_skipped} row{importResult.entries_skipped === 1 ? '' : 's'} skipped</li>
{/if}
</ul>
{#if importResult.errors.length > 0}
<details class="import-errors">
<summary>{importResult.errors.length} issue{importResult.errors.length === 1 ? '' : 's'} to review</summary>
<ul>
{#each importResult.errors as err (err)}
<li>{err}</li>
{/each}
</ul>
</details>
{/if}
</div>
{/if}
<div class="form-footer">
<button class="btn-primary" type="button" disabled={importing || !importFile} onclick={runImport}>
{importing ? 'Importing…' : 'Import entries'}
</button>
</div>
</div>
</div>
</div>
{/if}
</div>
</AppSecondaryRailLayout>
@@ -288,11 +464,173 @@
cursor: not-allowed;
}
/* ── Import ─────────────────────────────────────────────────── */
.import-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 1.5rem;
padding: 1.5rem 1.75rem;
}
.import-help h3 {
margin: 0 0 0.5rem;
font-size: 0.92rem;
font-weight: 700;
color: var(--text);
}
.import-help p {
margin: 0 0 0.65rem;
font-size: 0.85rem;
line-height: 1.5;
color: var(--muted);
}
.import-help ul {
margin: 0 0 0.65rem;
padding-left: 1.1rem;
display: grid;
gap: 0.3rem;
font-size: 0.84rem;
color: var(--muted);
}
.import-help code {
padding: 0.05rem 0.32rem;
border-radius: 0.35rem;
background: var(--panel-soft);
border: 1px solid var(--line);
font-size: 0.8rem;
}
.import-note {
font-size: 0.82rem;
}
.btn-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0;
background: none;
border: none;
color: var(--color-brand);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.btn-link:hover {
text-decoration: underline;
}
.import-control {
display: grid;
gap: 1rem;
align-content: start;
}
.file-drop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem 1.25rem;
border: 1.5px dashed var(--line);
border-radius: 0.75rem;
background: var(--panel-soft);
color: var(--muted);
cursor: pointer;
text-align: center;
transition: border-color 140ms ease, color 140ms ease;
}
.file-drop:hover {
border-color: var(--color-brand);
color: var(--text);
}
.file-drop.has-file {
border-style: solid;
border-color: var(--color-brand);
color: var(--text);
}
.file-drop input {
display: none;
}
.file-drop-label {
font-size: 0.88rem;
font-weight: 600;
word-break: break-all;
}
.file-drop-size {
font-size: 0.78rem;
color: var(--muted);
}
.form-error {
display: flex;
align-items: center;
gap: 0.4rem;
}
.import-result {
padding: 0.85rem 1rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 25%, transparent);
border-radius: 0.6rem;
background: color-mix(in srgb, var(--color-brand) 7%, transparent);
}
.import-result-head {
margin: 0;
font-size: 0.9rem;
color: var(--text);
}
.import-result-stats {
margin: 0.45rem 0 0;
padding-left: 1.1rem;
display: grid;
gap: 0.2rem;
font-size: 0.83rem;
color: var(--muted);
}
.import-errors {
margin-top: 0.6rem;
font-size: 0.83rem;
color: var(--muted);
}
.import-errors summary {
cursor: pointer;
font-weight: 600;
color: var(--text);
}
.import-errors ul {
margin: 0.45rem 0 0;
padding-left: 1.1rem;
display: grid;
gap: 0.25rem;
max-height: 12rem;
overflow-y: auto;
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 720px) {
.field-row {
grid-template-columns: 1fr;
}
.import-body {
grid-template-columns: 1fr;
}
}
</style>
File diff suppressed because it is too large Load Diff
+13 -4
View File
@@ -5,7 +5,7 @@ import { canOpenThroughput, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { entries: [], products: [] };
return { entries: [], statsEntries: [], products: [] };
}
const session = getStoredClientSession();
@@ -18,13 +18,22 @@ export async function load({ fetch }) {
recentFrom.setDate(recentFrom.getDate() - 30);
const dateFrom = recentFrom.toISOString().slice(0, 10);
// The overview cards are computed in the browser: the hero cards (today /
// this week / 4-week average) plus the customer-mix cards whose rolling range
// the operator can switch up to 12 weeks. Pull that widest window once so
// changing range is a pure re-filter with no extra request.
const statsFrom = new Date();
statsFrom.setDate(statsFrom.getDate() - 84);
const statsDateFrom = statsFrom.toISOString().slice(0, 10);
try {
const [entries, products] = await Promise.all([
const [entries, statsEntries, products] = await Promise.all([
api.throughputEntries({ date_from: dateFrom, limit: 200 }, fetch),
api.throughputEntries({ date_from: statsDateFrom, limit: 1000 }, fetch),
api.throughputProducts(fetch)
]);
return { entries, products };
return { entries, statsEntries, products };
} catch {
return { entries: [], products: [] };
return { entries: [], statsEntries: [], products: [] };
}
}