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:
@@ -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: 'What’s 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
@@ -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')
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user