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);