v0.1.14 - b2b portal

This commit is contained in:
2026-06-11 23:56:02 +12:00
parent 349e4a4b5b
commit 4ff372d307
48 changed files with 5845 additions and 925 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hunter-app",
"version": "0.1.12",
"version": "0.1.14",
"private": true,
"type": "module",
"scripts": {
+5 -6
View File
@@ -5,15 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.png" />
<script>
// Resolve the theme before first paint so there is no light-mode flash.
// Resolve the theme before first paint so there is no dark-mode flash.
// Dark mode is strictly opt-in: it loads only when the user has explicitly
// chosen it. Absent preference (or the legacy 'system' value) stays light,
// so the OS scheme never pulls the app into dark on its own.
(function () {
try {
var pref = localStorage.getItem('theme');
var dark =
pref === 'dark' ||
((!pref || pref === 'system') &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
document.documentElement.dataset.theme = pref === 'dark' ? 'dark' : 'light';
} catch (e) {
document.documentElement.dataset.theme = 'light';
}
+95 -1
View File
@@ -29,6 +29,15 @@ import type {
RawMaterial,
RawMaterialCreateInput,
RawMaterialPriceCreateInput,
CatalogueProduct,
CustomerPricing,
CustomerVisibilityRow,
DraftOrderInput,
Order,
OrderingCustomer,
OrderingCustomerUser,
OrderingNotificationSettings,
XeroStatus,
Scenario,
ThroughputEntry,
ThroughputEntryCreateInput,
@@ -486,5 +495,90 @@ export const api = {
request<ClientAccessAccount>(`/api/client-access/features/${featureId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'manager')
}, 'manager'),
// --- B2B ordering portal (customer) ---------------------------------------
ordering: {
catalogue: (params?: { category?: string; q?: string }, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.category) search.set('category', params.category);
if (params?.q) search.set('q', params.q);
const qs = search.toString();
return cachedFetchJson<CatalogueProduct[]>(`/api/ordering/catalogue${qs ? `?${qs}` : ''}`, 'client', fetcher);
},
product: (productId: number, quantity = 1, fetcher?: ApiFetch) =>
request<CatalogueProduct>(`/api/ordering/catalogue/${productId}?quantity=${quantity}`, { method: 'GET' }, 'client', fetcher),
orders: (statusFilter?: string, fetcher?: ApiFetch) =>
cachedFetchJson<Order[]>(`/api/ordering/orders${statusFilter ? `?status=${statusFilter}` : ''}`, 'client', fetcher),
order: (orderId: number, fetcher?: ApiFetch) =>
request<Order>(`/api/ordering/orders/${orderId}`, { method: 'GET' }, 'client', fetcher),
createDraft: (payload: DraftOrderInput) =>
request<Order>('/api/ordering/orders', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
updateDraft: (orderId: number, payload: Partial<DraftOrderInput>) =>
request<Order>(`/api/ordering/orders/${orderId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
deleteDraft: (orderId: number) =>
request<void>(`/api/ordering/orders/${orderId}`, { method: 'DELETE' }, 'client'),
submit: (orderId: number, payload: Partial<DraftOrderInput> = {}) =>
request<Order>(`/api/ordering/orders/${orderId}/submit`, { method: 'POST', body: JSON.stringify(payload) }, 'client'),
reorder: (orderId: number) =>
request<Order>(`/api/ordering/orders/${orderId}/reorder`, { method: 'POST' }, 'client'),
confirmationPdf: (orderId: number) =>
requestBlob(`/api/ordering/orders/${orderId}/confirmation.pdf`, {}, 'client')
},
// --- B2B ordering portal (admin) ------------------------------------------
orderingAdmin: {
customers: (fetcher?: ApiFetch) => cachedFetchJson<OrderingCustomer[]>('/api/ordering-admin/customers', 'client', fetcher),
createCustomer: (payload: { name: string; client_code: string; tenant_id?: string; notes?: string }) =>
request<OrderingCustomer>('/api/ordering-admin/customers', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
updateCustomer: (customerId: number, payload: { name?: string; status?: string; notes?: string }) =>
request<OrderingCustomer>(`/api/ordering-admin/customers/${customerId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
customerUsers: (customerId: number, fetcher?: ApiFetch) =>
cachedFetchJson<OrderingCustomerUser[]>(`/api/ordering-admin/customers/${customerId}/users`, 'client', fetcher),
createCustomerUser: (customerId: number, payload: { full_name: string; email: string; role: string }) =>
request<OrderingCustomerUser>(`/api/ordering-admin/customers/${customerId}/users`, { method: 'POST', body: JSON.stringify(payload) }, 'client'),
updateCustomerUser: (customerId: number, userId: number, payload: { full_name?: string; role?: string; status?: string }) =>
request<OrderingCustomerUser>(`/api/ordering-admin/customers/${customerId}/users/${userId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
products: (fetcher?: ApiFetch) => cachedFetchJson<CatalogueProduct[]>('/api/ordering-admin/products', 'client', fetcher),
createProduct: (payload: Partial<CatalogueProduct>) =>
request<CatalogueProduct>('/api/ordering-admin/products', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
updateProduct: (productId: number, payload: Partial<CatalogueProduct>) =>
request<CatalogueProduct>(`/api/ordering-admin/products/${productId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
visibility: (customerId: number, fetcher?: ApiFetch) =>
cachedFetchJson<CustomerVisibilityRow[]>(`/api/ordering-admin/customers/${customerId}/visibility`, 'client', fetcher),
setVisibility: (customerId: number, payload: { product_id: number; visible: boolean }) =>
request(`/api/ordering-admin/customers/${customerId}/visibility`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
pricing: (customerId: number, fetcher?: ApiFetch) =>
cachedFetchJson<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/pricing`, 'client', fetcher),
setAssignment: (customerId: number, payload: { price_list_id: number | null; discount_percent: number }) =>
request<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/assignment`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
setProductPrice: (
customerId: number,
payload: { product_id: number; unit_price: number | null; rule_type: string; contract_reference?: string | null; notes?: string | null; active?: boolean }
) => request<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/product-prices`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
deleteProductPrice: (customerId: number, productId: number) =>
request<void>(`/api/ordering-admin/customers/${customerId}/product-prices/${productId}`, { method: 'DELETE' }, 'client'),
orders: (params?: { status?: string; customer_id?: number }, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.status) search.set('status', params.status);
if (params?.customer_id != null) search.set('customer_id', String(params.customer_id));
const qs = search.toString();
return cachedFetchJson<Order[]>(`/api/ordering-admin/orders${qs ? `?${qs}` : ''}`, 'client', fetcher);
},
order: (orderId: number, fetcher?: ApiFetch) =>
request<Order>(`/api/ordering-admin/orders/${orderId}`, { method: 'GET' }, 'client', fetcher),
updateStatus: (orderId: number, payload: { to_status: string; note?: string }) =>
request<Order>(`/api/ordering-admin/orders/${orderId}/status`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
overrideLine: (orderId: number, lineId: number, payload: { quantity?: number; unit_price?: number; reason?: string }) =>
request<Order>(`/api/ordering-admin/orders/${orderId}/lines/${lineId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
reopen: (orderId: number, note?: string) =>
request<Order>(`/api/ordering-admin/orders/${orderId}/reopen`, { method: 'POST', body: JSON.stringify({ note }) }, 'client'),
sendToXero: (orderId: number) =>
request<Order>(`/api/ordering-admin/orders/${orderId}/send-to-xero`, { method: 'POST' }, 'client'),
notificationSettings: (fetcher?: ApiFetch) =>
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)
}
};
+48
View File
@@ -0,0 +1,48 @@
import packageInfo from '../../package.json';
/**
* Release notes shown in the "What's new" dialog. This is the single source of
* truth for the changelog: add a new entry at the top of `changelog` whenever
* the version in package.json is bumped, and the dialog will surface it once per
* user on their next login (see $lib/whats-new and WhatsNewDialog.svelte).
*/
export type ChangelogEntry = {
version: string;
/** ISO date (YYYY-MM-DD) the version shipped. */
date: string;
highlights: string[];
};
/** The running app version, read straight from package.json at build time. */
export const APP_VERSION: string = packageInfo.version;
export const changelog: ChangelogEntry[] = [
{
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.'
]
},
{
version: '0.1.12',
date: '2026-06-10',
highlights: [
'Mix Calculator: Changed from selecting Product to Mix.',
'Web app design improved',
'Throughput tab ready for testing',
'Costing Editor tab ready for testing'
]
}
];
/** The changelog entry matching a specific version, if one exists. */
export function changelogFor(version: string): ChangelogEntry | undefined {
return changelog.find((entry) => entry.version === version);
}
/** The entry for the version the app is currently running, if documented. */
export const currentChangelog: ChangelogEntry | undefined = changelogFor(APP_VERSION);
@@ -1,391 +0,0 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { api } from '$lib/api';
import { adminSession, sessionHydrated } from '$lib/session';
const navigation = [
{ href: '/admin', label: 'Overview', shortLabel: 'OV' },
{ href: '/admin/client-access', label: 'Client Access', shortLabel: 'CA' }
];
let { children } = $props();
let isRestoringSession = $state(false);
let restoredSessionKey = $state<string | null>(null);
function matchesRoute(href: string, pathname: string) {
return href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
}
function pageTitle(pathname: string) {
return navigation.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Overview';
}
function initials(name: string) {
return name
.split(' ')
.map((piece) => piece[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
async function signOut() {
try {
await api.adminLogout();
} catch {
// Clearing the local session remains the safe fallback.
} finally {
adminSession.clear();
}
}
const isProtectedRoute = $derived(page.url.pathname !== '/admin');
$effect(() => {
const hydrated = $sessionHydrated;
const sessionKey = $adminSession ? `${$adminSession.role}:${$adminSession.email}` : null;
if (!hydrated) {
return;
}
if (!sessionKey) {
isRestoringSession = false;
restoredSessionKey = null;
return;
}
if (restoredSessionKey === sessionKey) {
return;
}
restoredSessionKey = sessionKey;
isRestoringSession = true;
invalidateAll().finally(() => {
if (restoredSessionKey === sessionKey) {
isRestoringSession = false;
}
});
});
</script>
<svelte:head>
<title>{pageTitle(page.url.pathname)} | Lean 101 Admin Panel</title>
</svelte:head>
<div class="admin-shell">
<aside class="admin-sidebar">
<a class="admin-brand" href="/admin">
<span class="brand-mark">L1</span>
<span>Lean 101 Admin Panel</span>
</a>
<p class="admin-copy">
Internal workspace for Lean 101 operators managing client access and controlled workspace changes.
</p>
<nav class="admin-nav" aria-label="Admin navigation">
{#each navigation as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="admin-footer">
<a href="/">Open client workspace</a>
{#if $adminSession}
<button type="button" onclick={signOut}>Sign out</button>
{/if}
</div>
</aside>
<div class="admin-main">
<header class="admin-topbar">
<div>
<p class="eyebrow">Admin Area</p>
<h1>{pageTitle(page.url.pathname)}</h1>
</div>
{#if !$sessionHydrated}
<div class="profile-card guest">
<span class="profile-avatar">A</span>
<div>
<strong>Checking saved session</strong>
<span>Restoring admin access</span>
</div>
</div>
{:else if $adminSession}
<div class="profile-card">
<span class="profile-avatar">{initials($adminSession.name)}</span>
<div>
<strong>{$adminSession.name}</strong>
<span>{$adminSession.email}</span>
</div>
</div>
{:else}
<div class="profile-card guest">
<span class="profile-avatar">A</span>
<div>
<strong>Admin sign-in required</strong>
<span>Use `/admin` to authenticate</span>
</div>
</div>
{/if}
</header>
<main class="admin-content">
{#if isProtectedRoute && (!$sessionHydrated || isRestoringSession)}
<section class="locked-card loading-card">
<p class="eyebrow">Checking Session</p>
<h2>Restoring the Lean 101 admin workspace.</h2>
<p>Refreshing the current route with the saved operator session before prompting for sign-in.</p>
</section>
{:else if isProtectedRoute && !$adminSession}
<section class="locked-card">
<p class="eyebrow">Restricted</p>
<h2>Sign in through the Lean 101 Admin Panel to continue.</h2>
<p>Client access controls are only available inside the separate admin workspace.</p>
<a href="/admin">Go to admin sign-in</a>
</section>
{:else}
{@render children()}
{/if}
</main>
</div>
</div>
<style>
.admin-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
background: var(--color-bg-app);
color: var(--color-text-primary);
}
.admin-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.1rem;
border-right: 1px solid rgba(34, 54, 45, 0.12);
background: rgba(20, 29, 24, 0.96);
color: #f4f7f1;
}
.admin-brand {
display: inline-flex;
align-items: center;
gap: 0.8rem;
font-size: 1.05rem;
font-weight: 700;
}
.brand-mark,
.nav-icon,
.profile-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-weight: 700;
letter-spacing: 0.04em;
}
.brand-mark {
width: 2rem;
height: 2rem;
border-radius: 0.72rem;
color: #0f1713;
background: linear-gradient(135deg, #cfe4b8 0%, #83c98b 100%);
}
.admin-copy {
margin: 0;
color: rgba(244, 247, 241, 0.74);
line-height: 1.55;
}
.admin-nav {
display: grid;
gap: 0.4rem;
}
.admin-nav a {
display: flex;
align-items: center;
gap: 0.72rem;
padding: 0.82rem 0.78rem;
border-radius: 0.9rem;
color: rgba(244, 247, 241, 0.88);
transition: background-color 140ms ease;
}
.admin-nav a:hover,
.admin-nav a.active {
background: rgba(207, 228, 184, 0.16);
}
.admin-nav a.active {
color: #ffffff;
}
.nav-icon {
width: 1.65rem;
height: 1.65rem;
border-radius: 0.58rem;
color: #0f1713;
background: linear-gradient(135deg, #cfe4b8 0%, #83c98b 100%);
font-size: 0.7rem;
}
.admin-footer {
margin-top: auto;
display: grid;
gap: 0.6rem;
}
.admin-footer a,
.admin-footer button {
padding: 0.82rem 0.88rem;
border: 1px solid rgba(244, 247, 241, 0.14);
border-radius: 0.88rem;
background: rgba(255, 255, 255, 0.04);
color: inherit;
text-align: left;
cursor: pointer;
}
.admin-main {
min-width: 0;
display: flex;
flex-direction: column;
}
.admin-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.4rem;
border-bottom: 1px solid rgba(34, 54, 45, 0.1);
background: rgba(247, 248, 244, 0.85);
backdrop-filter: blur(12px);
}
.eyebrow {
margin: 0 0 0.18rem;
color: #66806e;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.admin-topbar h1 {
margin: 0;
font-size: 1.7rem;
}
.profile-card {
display: flex;
align-items: center;
gap: 0.72rem;
padding: 0.45rem 0.52rem;
border: 1px solid rgba(34, 54, 45, 0.1);
border-radius: 0.95rem;
background: rgba(255, 255, 255, 0.82);
}
.profile-avatar {
width: 2.2rem;
height: 2.2rem;
border-radius: 999px;
color: #ffffff;
background: linear-gradient(135deg, #4f8860 0%, #203028 100%);
}
.profile-card strong,
.profile-card span {
display: block;
}
.profile-card span {
margin-top: 0.14rem;
color: #6b7f72;
font-size: 0.82rem;
}
.guest .profile-avatar {
background: linear-gradient(135deg, #c4d0c8 0%, #7b8b80 100%);
}
.admin-content {
min-width: 0;
padding: 1.4rem;
}
.locked-card {
max-width: 42rem;
padding: 1.35rem;
border: 1px solid rgba(34, 54, 45, 0.1);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.82);
box-shadow: none;
}
.loading-card {
min-height: 10rem;
}
.locked-card h2,
.locked-card p {
margin: 0;
}
.locked-card h2 {
margin-top: 0.35rem;
font-size: clamp(1.8rem, 3vw, 2.3rem);
}
.locked-card p:last-of-type {
margin-top: 0.45rem;
color: #5d7166;
}
.locked-card a {
display: inline-flex;
margin-top: 1rem;
padding: 0.82rem 0.95rem;
border-radius: 0.9rem;
background: #203028;
color: #ffffff;
font-weight: 600;
}
@media (max-width: 980px) {
.admin-shell {
grid-template-columns: 1fr;
}
.admin-sidebar {
border-right: none;
border-bottom: 1px solid rgba(34, 54, 45, 0.12);
}
}
@media (max-width: 720px) {
.admin-topbar {
flex-direction: column;
align-items: flex-start;
}
.admin-content {
padding: 1rem;
}
}
</style>
@@ -4,6 +4,9 @@
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte';
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
import WhatsNewDialog from '$lib/components/WhatsNewDialog.svelte';
import { currentChangelog } from '$lib/changelog';
import { hasSeenVersion, markVersionSeen } from '$lib/whats-new';
import { invalidateAll } from '$app/navigation';
import { goto } from '$app/navigation';
import { page } from '$app/state';
@@ -17,6 +20,8 @@
canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator,
canOpenMixMaster as sessionCanOpenMixMaster,
canOpenCustomerOrdering as sessionCanOpenCustomerOrdering,
canManageOrdering as sessionCanManageOrdering,
canOpenProductCosting as sessionCanOpenProductCosting,
canOpenReporting as sessionCanOpenReporting,
canOpenSettings as sessionCanOpenSettings,
@@ -36,6 +41,7 @@
footerLinks,
matchesRoute,
mixCalculatorItem,
orderingItem,
pageTitle,
productCostingItem,
reportingItem,
@@ -65,6 +71,10 @@
let userMenuOpen = $state(false);
let navOpen = $state(false);
let showBottomNav = $state(false);
let whatsNewOpen = $state(false);
// The user identity we've already run the "what's new" check for this mount,
// so the dialog is evaluated once per login rather than on every navigation.
let whatsNewCheckedFor = $state<string | null>(null);
let isRestoringSession = $state(false);
let restoredSessionKey = $state<string | null>(null);
let seededSearchItems = $state<SearchItem[]>([]);
@@ -102,6 +112,17 @@
const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null);
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
// Ordering serves two audiences: internal staff get the management console
// (/ordering/manage), customers get the catalogue (/ordering).
const canManageOrdering = $derived(sessionCanManageOrdering($clientSession));
const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($clientSession));
const visibleOrderingItem = $derived(
canManageOrdering
? { ...orderingItem, href: '/ordering/manage', label: 'Order Management', shortLabel: 'OM' }
: canOpenCustomerOrdering
? orderingItem
: null
);
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
// Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the
@@ -117,6 +138,7 @@
...visibleWorkingDocumentItems
],
throughput: visibleThroughputItem,
ordering: visibleOrderingItem,
reporting: visibleReportingItem
})
);
@@ -331,6 +353,33 @@
goto(workspaceHomeHref, { replaceState: true });
});
// Surface the release notes once per version per user, right after login.
// hasSeenVersion keeps this to a single appearance: once dismissed (which
// records the version), it won't return until the next version ships.
$effect(() => {
if (!$sessionHydrated || !$clientSession || !currentChangelog) {
return;
}
const userKey = `${$clientSession.role}:${$clientSession.email}:${$clientSession.user_id ?? ''}`;
if (whatsNewCheckedFor === userKey) {
return;
}
whatsNewCheckedFor = userKey;
if (!hasSeenVersion(userKey, currentChangelog.version)) {
whatsNewOpen = true;
}
});
function dismissWhatsNew() {
if ($clientSession && currentChangelog) {
const userKey = `${$clientSession.role}:${$clientSession.email}:${$clientSession.user_id ?? ''}`;
markVersionSeen(userKey, currentChangelog.version);
}
whatsNewOpen = false;
}
onMount(() => {
syncViewport();
@@ -639,6 +688,10 @@
{/if}
{#if $clientSession && whatsNewOpen && currentChangelog}
<WhatsNewDialog entry={currentChangelog} onClose={dismissWhatsNew} />
{/if}
{#if $clientSession && paletteOpen}
<div class="palette-overlay" role="presentation" onclick={() => (paletteOpen = false)}>
<div
@@ -0,0 +1,383 @@
<script lang="ts">
import { api } from '$lib/api';
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { ShoppingCart, LogOut } from 'lucide-svelte';
import { clientSession, sessionHydrated } from '$lib/session';
let { children } = $props();
const isRootRoute = $derived(page.url.pathname === '/');
const currentYear = new Date().getFullYear();
const userName = $derived($clientSession?.name ?? '');
const userInitials = $derived(
($clientSession?.name ?? '')
.split(' ')
.slice(0, 2)
.map((word: string) => word[0])
.join('')
.toUpperCase() || '?'
);
const navItems = [{ href: '/ordering', label: 'Order Catalogue', icon: ShoppingCart }];
function isActive(href: string) {
return href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
}
async function signOut() {
try {
await api.clientLogout();
} catch {
// Clearing the local session is the safe fallback.
} finally {
clientSession.clear();
await goto('/', { replaceState: true });
}
}
// Keep the saved session fresh on reload (mirrors the workspace shell).
let restoredSessionKey = $state<string | null>(null);
$effect(() => {
const hydrated = $sessionHydrated;
const sessionKey = $clientSession ? `${$clientSession.role}:${$clientSession.email}:${$clientSession.user_id ?? ''}` : null;
if (!hydrated || !sessionKey || restoredSessionKey === sessionKey) {
return;
}
restoredSessionKey = sessionKey;
api
.clientSession()
.then((session) => {
restoredSessionKey = `${session.role}:${session.email}:${session.user_id ?? ''}`;
clientSession.set(session);
return invalidateAll();
})
.catch(() => {
restoredSessionKey = null;
clientSession.clear();
});
});
// Signed-out customers go back to the sign-in screen; the portal landing is
// always the catalogue, never the internal dashboard/login root.
$effect(() => {
if (!$sessionHydrated) return;
if (!$clientSession && !isRootRoute) {
goto('/', { replaceState: true });
} else if ($clientSession && isRootRoute) {
goto('/ordering', { replaceState: true });
}
});
</script>
<svelte:head>
<title>Customer Ordering Portal | Hunter Premium Produce</title>
</svelte:head>
{#if !$clientSession}
{#if isRootRoute}
{@render children()}
{:else}
<div class="loading-screen">
<p>Returning you to sign in…</p>
</div>
{/if}
{:else}
<div class="portal">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark"><ShoppingCart size={20} strokeWidth={2} /></span>
<div class="brand-text">
<span class="brand-name">Hunter Premium Produce</span>
<span class="brand-sub">Customer Ordering Portal</span>
</div>
</div>
<nav class="nav" aria-label="Customer portal navigation">
{#each navItems as item}
{@const Icon = item.icon}
<a class="nav-row" class:active={isActive(item.href)} href={item.href}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.85} /></span>
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="sidebar-foot">
<div class="account">
<span class="avatar">{userInitials}</span>
<span class="account-name">{userName}</span>
</div>
<button class="signout" type="button" onclick={signOut}>
<LogOut size={16} strokeWidth={1.9} />
<span>Sign out</span>
</button>
<small class="copyright">&copy; {currentYear} Hunter Premium Produce</small>
</div>
</aside>
<div class="main">
<header class="topbar">
<h1>Customer Ordering Portal</h1>
<div class="topbar-account">
<span class="avatar small">{userInitials}</span>
<span class="topbar-name">{userName}</span>
</div>
</header>
<main class="content">
{@render children()}
</main>
</div>
</div>
{/if}
<style>
.portal {
display: grid;
grid-template-columns: 16rem minmax(0, 1fr);
min-height: 100vh;
background: #f4f7f4;
color: #1f2a24;
}
.loading-screen {
display: grid;
place-items: center;
min-height: 100vh;
color: #5f7266;
background: #f4f7f4;
}
/* ── Sidebar ─────────────────────────────────────────────── */
.sidebar {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.4rem 1.05rem;
background: #1f3a2c;
color: #e7efe9;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 0.7rem;
padding-bottom: 1.1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.4rem;
height: 2.4rem;
border-radius: 0.8rem;
background: rgba(255, 255, 255, 0.12);
color: #fff;
flex-shrink: 0;
}
.brand-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.brand-name {
font-size: 0.98rem;
font-weight: 700;
line-height: 1.2;
}
.brand-sub {
font-size: 0.78rem;
color: rgba(231, 239, 233, 0.7);
}
.nav {
display: grid;
gap: 0.25rem;
}
.nav-row {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.7rem 0.8rem;
border-radius: 0.75rem;
color: rgba(231, 239, 233, 0.85);
text-decoration: none;
font-size: 0.92rem;
font-weight: 500;
transition: background-color 140ms ease, color 140ms ease;
}
.nav-row:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.nav-row.active {
background: #fff;
color: #1f3a2c;
font-weight: 600;
}
.nav-icon {
display: inline-flex;
flex-shrink: 0;
}
.sidebar-foot {
margin-top: auto;
display: grid;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.account {
display: flex;
align-items: center;
gap: 0.6rem;
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-size: 0.78rem;
font-weight: 700;
flex-shrink: 0;
}
.avatar.small {
width: 1.85rem;
height: 1.85rem;
background: #1f3a2c;
}
.account-name {
font-size: 0.86rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.signout {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.8rem;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 0.7rem;
background: transparent;
color: #e7efe9;
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
transition: background-color 140ms ease;
}
.signout:hover {
background: rgba(255, 255, 255, 0.1);
}
.copyright {
font-size: 0.7rem;
color: rgba(231, 239, 233, 0.55);
}
/* ── Main ────────────────────────────────────────────────── */
.main {
display: flex;
flex-direction: column;
min-width: 0;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.05rem 1.6rem;
background: #fff;
border-bottom: 1px solid rgba(31, 58, 44, 0.1);
position: sticky;
top: 0;
z-index: 5;
}
.topbar h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
color: #1f3a2c;
}
.topbar-account {
display: flex;
align-items: center;
gap: 0.55rem;
}
.topbar-name {
font-size: 0.88rem;
font-weight: 600;
color: #1f2a24;
}
.content {
flex: 1;
padding: 1.6rem;
min-width: 0;
}
@media (max-width: 820px) {
.portal {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.brand {
border-bottom: none;
padding-bottom: 0;
flex: 1;
}
.nav {
grid-auto-flow: column;
}
.sidebar-foot {
margin-top: 0;
border-top: none;
padding-top: 0;
grid-auto-flow: column;
align-items: center;
}
.copyright {
display: none;
}
}
</style>
@@ -0,0 +1,173 @@
<script lang="ts">
import { Sparkles } from 'lucide-svelte';
import type { ChangelogEntry } from '$lib/changelog';
let { entry, onClose }: { entry: ChangelogEntry; onClose: () => void } = $props();
const releaseDate = $derived(
new Date(`${entry.date}T00:00:00`).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
})
);
</script>
<div class="whats-new-backdrop" role="presentation" onclick={onClose}>
<div
class="whats-new"
role="dialog"
aria-modal="true"
aria-labelledby="whats-new-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => {
if (event.key === 'Escape') {
onClose();
}
}}
>
<div class="whats-new-head">
<span class="whats-new-mark"><Sparkles size={20} strokeWidth={1.75} /></span>
<div>
<p class="whats-new-kicker">What's new · v{entry.version}</p>
<h2 id="whats-new-title">A few updates in this release</h2>
<p class="whats-new-date">{releaseDate}</p>
</div>
</div>
<ul class="whats-new-list">
{#each entry.highlights as highlight}
<li>{highlight}</li>
{/each}
</ul>
<div class="whats-new-actions">
<button class="whats-new-button" type="button" onclick={onClose}>Got it</button>
</div>
</div>
</div>
<style>
h2,
p {
margin: 0;
}
.whats-new-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(11, 18, 14, 0.45);
backdrop-filter: blur(10px);
}
.whats-new {
width: min(34rem, 100%);
display: grid;
gap: 1.15rem;
padding: 1.5rem;
border: 1px solid var(--color-border);
border-radius: 1.1rem;
background: var(--color-bg-surface);
color: var(--color-text-primary);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.22);
}
.whats-new-head {
display: flex;
align-items: flex-start;
gap: 0.9rem;
}
.whats-new-mark {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.6rem;
height: 2.6rem;
border-radius: 0.82rem;
color: var(--color-on-brand);
background: var(--color-brand);
}
.whats-new-kicker {
color: var(--color-brand);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.whats-new-head h2 {
margin-top: 0.22rem;
font-size: 1.28rem;
letter-spacing: -0.02em;
}
.whats-new-date {
margin-top: 0.18rem;
color: var(--color-text-muted);
font-size: 0.84rem;
}
.whats-new-list {
display: grid;
gap: 0.7rem;
margin: 0;
padding: 0;
list-style: none;
}
.whats-new-list li {
position: relative;
padding-left: 1.5rem;
color: var(--color-text-secondary);
line-height: 1.5;
}
.whats-new-list li::before {
content: '';
position: absolute;
top: 0.5rem;
left: 0.3rem;
width: 0.46rem;
height: 0.46rem;
border-radius: 999px;
background: var(--color-brand);
}
.whats-new-actions {
display: flex;
justify-content: flex-end;
}
.whats-new-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.6rem;
padding: 0.72rem 1.3rem;
border: 1px solid var(--color-brand);
border-radius: var(--radius-control);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
cursor: pointer;
transition: background-color 160ms ease, border-color 160ms ease;
}
.whats-new-button:hover {
background: var(--color-brand-hover);
border-color: var(--color-brand-hover);
}
.whats-new-button:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
outline-offset: 2px;
}
</style>
@@ -521,13 +521,19 @@
padding: 0.78rem 0.82rem;
border: 1px solid var(--line-strong);
border-radius: 0.6rem;
background: #fff;
background: var(--color-input-bg);
color: var(--text);
transition:
border-color 160ms ease,
box-shadow 160ms ease;
}
input::placeholder,
textarea::placeholder {
color: var(--color-text-muted);
opacity: 1;
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
@@ -203,8 +203,9 @@
</aside>
<style>
/* Light monochrome rail with a dark selected pill. The rail keeps its own palette
via the --sidebar-* tokens, independent of the content theme. */
/* Monochrome rail with a blue selected pill. Colours come from the --sidebar-*
tokens, which are overridden in dark mode (see theme.css) so the rail themes
alongside the content instead of staying a bright light strip. */
.sidebar {
display: flex;
flex-direction: column;
@@ -6,6 +6,7 @@ import {
Layers,
LayoutDashboard,
ShieldCheck,
ShoppingCart,
TrendingUp
} from 'lucide-svelte';
import type { ComponentType } from 'svelte';
@@ -112,6 +113,14 @@ export const throughputItem: NavItem = {
badge: 'test'
};
export const orderingItem: NavItem = {
href: '/ordering',
label: 'Ordering',
shortLabel: 'OR',
icon: ShoppingCart,
moduleKey: 'ordering'
};
export const workingDocumentItems: NavItem[] = [
// Mix Master remains available through the existing route and access logic,
// but is temporarily hidden from the sidebar.
@@ -210,6 +219,7 @@ export function buildClientNavEntries(visible: {
dashboard?: NavItem | null;
costing: NavItem[];
throughput?: NavItem | null;
ordering?: NavItem | null;
reporting?: NavItem | null;
}): NavEntry[] {
const entries: NavEntry[] = [];
@@ -225,6 +235,10 @@ export function buildClientNavEntries(visible: {
});
}
if (visible.ordering) {
entries.push({ kind: 'item', item: visible.ordering });
}
if (visible.throughput) {
entries.push({ kind: 'item', item: visible.throughput });
}
+22 -1
View File
@@ -34,6 +34,9 @@
--color-surface-hover: oklch(0.955 0.004 240);
--color-surface-selected: color-mix(in srgb, var(--color-brand) 10%, var(--color-bg-surface));
/* ── Form inputs: a touch recessed from the card surface ── */
--color-input-bg: var(--panel-soft);
/* ── Borders ────────────────────────────────────────────── */
--color-border: oklch(0.92 0.005 240);
--color-divider: oklch(0.94 0.004 240);
@@ -117,10 +120,28 @@
--color-surface-hover: oklch(0.27 0.006 240);
--color-surface-selected: color-mix(in srgb, var(--color-brand) 14%, var(--color-bg-surface));
/* Form inputs: sit just above the card so fields don't read as
black holes punched into the surface. */
--color-input-bg: oklch(0.25 0.005 240);
/* ── Borders ────────────────────────────────────────────── */
--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-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-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);
/* ── Text (neutral) ─────────────────────────────────────── */
--color-text-primary: oklch(0.96 0.003 240);
--color-text-secondary: oklch(0.78 0.006 240);
@@ -448,7 +469,7 @@ a {
padding: 0.82rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--panel-soft);
background: var(--color-input-bg);
color: var(--color-text-primary);
transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 160ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 160ms cubic-bezier(0.22, 1, 0.36, 1);
+8 -18
View File
@@ -6,18 +6,17 @@ export type ResolvedTheme = 'light' | 'dark';
const STORAGE_KEY = 'theme';
function systemTheme(): ResolvedTheme {
return browser && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// Dark mode is strictly opt-in. Light is the default and the OS colour scheme is
// deliberately ignored: only an explicit stored 'dark' resolves to dark, so the
// app never loads dark on its own. The legacy 'system' value collapses to light.
function resolve(pref: ThemePreference): ResolvedTheme {
return pref === 'system' ? systemTheme() : pref;
return pref === 'dark' ? 'dark' : 'light';
}
function readPreference(): ThemePreference {
if (!browser) return 'system';
if (!browser) return 'light';
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system';
return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'light';
}
function applyResolved(theme: ResolvedTheme) {
@@ -26,10 +25,10 @@ function applyResolved(theme: ResolvedTheme) {
}
}
/** The user's stored choice (may be 'system'). */
/** The user's stored choice (may be the legacy 'system'). */
export const themePreference = writable<ThemePreference>(readPreference());
/** The theme actually painted right now ('system' collapsed to light/dark). */
/** The theme actually painted right now. */
export const resolvedTheme = writable<ResolvedTheme>(resolve(readPreference()));
if (browser) {
@@ -39,15 +38,6 @@ if (browser) {
resolvedTheme.set(next);
applyResolved(next);
});
// Follow the OS only while the user is on 'system'.
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (readPreference() === 'system') {
const next = systemTheme();
resolvedTheme.set(next);
applyResolved(next);
}
});
}
/** Flip between light and dark, committing to an explicit preference. */
+165
View File
@@ -637,3 +637,168 @@ export type ThroughputProductCreateInput = {
};
export type ThroughputProductUpdateInput = Partial<ThroughputProductCreateInput>;
// --- B2B ordering portal ----------------------------------------------------
export type OrderingPriceInfo = {
unit_price: number | null;
price_source: 'fixed' | 'contract' | 'price_list' | 'tiered' | 'base' | 'quote';
price_rule_id: number | null;
discount_percent: number;
requires_quote: boolean;
label: string;
};
export type CatalogueProduct = {
id: number;
name: string;
sku: string;
description?: string | null;
category: string;
image_url?: string | null;
unit_size?: string | null;
unit_of_measure: string;
min_order_quantity: number;
stock_status: string;
active: boolean;
requires_quote: boolean;
base_price?: number | null;
created_at?: string;
price?: OrderingPriceInfo;
};
export type OrderLine = {
id: number;
product_id: number;
product_name: string;
product_sku: string;
quantity: number;
unit_price: number | null;
line_total: number | null;
requires_quote: boolean;
price_source: string;
discount_percent: number;
notes?: string | null;
// admin-only
resolved_unit_price?: number | null;
admin_override_price?: number | null;
admin_override_reason?: string | null;
price_rule_id?: number | null;
};
export type OrderStatusHistoryEntry = {
id: number;
from_status: string | null;
to_status: string;
actor_type: string;
actor_name?: string | null;
note?: string | null;
created_at: string;
};
export type Order = {
id: number;
order_number: string | null;
status: string;
client_account_id: number;
created_by_name?: string | null;
purchase_order_number?: string | null;
delivery_notes?: string | null;
requested_delivery_date?: string | null;
fulfilment_method: string;
subtotal_ex_gst: number;
requires_quote: boolean;
submitted_at?: string | null;
created_at: string;
updated_at: string;
editable: boolean;
lines: OrderLine[];
// admin-only
raw_status?: string;
admin_notes?: string | null;
reopened?: boolean;
xero_status?: string;
xero_invoice_id?: string | null;
customer_name?: string | null;
status_history?: OrderStatusHistoryEntry[];
notifications?: { channel: string; recipients: string[]; delivered: boolean; detail: string }[];
xero_result?: { status: string; stubbed: boolean; message: string; xero_invoice_id: string | null };
};
export type OrderLineInput = { product_id: number; quantity: number; notes?: string | null };
export type DraftOrderInput = {
lines: OrderLineInput[];
purchase_order_number?: string | null;
delivery_notes?: string | null;
requested_delivery_date?: string | null;
fulfilment_method?: string;
};
export type OrderingCustomer = {
id: number;
name: string;
client_code: string;
tenant_id: string;
status: string;
notes?: string | null;
user_count: number;
price_list_id: number | null;
discount_percent: number;
created_at: string;
};
export type OrderingCustomerUser = {
id: number;
client_account_id: number;
full_name: string;
email: string;
role: string;
status: string;
created_at: string;
};
export type PriceTierInput = { min_quantity: number; unit_price: number };
export type CustomerPricing = {
customer_id: number;
price_list_id: number | null;
discount_percent: number;
product_prices: {
id: number;
product_id: number;
unit_price: number | null;
rule_type: string;
contract_reference?: string | null;
notes?: string | null;
active: boolean;
tiers: { id: number; min_quantity: number; unit_price: number }[];
}[];
};
export type CustomerVisibilityRow = {
product_id: number;
name: string;
sku: string;
category: string;
visible: boolean;
};
export type OrderingNotificationSettings = {
internal_recipients: string | null;
send_customer_confirmation: boolean;
require_po_number: boolean;
from_email: string | null;
};
export type XeroStatus = {
connection: { configured: boolean; mode: string; base_url: string; checked_at: string; missing_env: string[] };
recent_syncs: {
id: number;
order_id: number;
status: string;
xero_invoice_id: string | null;
response_message: string | null;
created_at: string;
}[];
};
+37
View File
@@ -0,0 +1,37 @@
import { browser } from '$app/environment';
/**
* Tracks which release-notes version a user has already seen, so the "What's
* new" dialog shows exactly once per version per user rather than on every
* login. State is kept client-side in localStorage, keyed per user, so a fresh
* browser/device will re-show the current version's notes once.
*/
const STORAGE_PREFIX = 'hsf:whats-new:seen';
function storageKey(userKey: string): string {
return `${STORAGE_PREFIX}:${userKey}`;
}
/**
* True if this user has already acknowledged the given version. Errs on the
* side of "seen" when storage is unavailable (SSR, private mode) so we never
* pop the dialog where we can't record that it was dismissed.
*/
export function hasSeenVersion(userKey: string, version: string): boolean {
if (!browser) return true;
try {
return window.localStorage.getItem(storageKey(userKey)) === version;
} catch {
return true;
}
}
/** Record that this user has seen the given version's release notes. */
export function markVersionSeen(userKey: string, version: string): void {
if (!browser) return;
try {
window.localStorage.setItem(storageKey(userKey), version);
} catch {
// A storage failure just means the dialog may reappear next login; harmless.
}
}
+57
View File
@@ -117,6 +117,52 @@ export function canOpenReporting(session: AppSession | null | undefined) {
return canOpenProducts(session);
}
// Internal staff who manage the customer ordering portal (catalogue, pricing,
// order lifecycle). These are Hunter Stock Feeds users signing in via the
// internal access system — not customers.
export function canManageOrdering(session: AppSession | null | undefined) {
return !!session && session.role === 'internal' && hasPermission(session, 'manage_ordering');
}
// B2B customers (ClientUser accounts) who browse the catalogue and place orders.
export function canOpenCustomerOrdering(session: AppSession | null | undefined) {
return !!session && session.role !== 'internal' && hasModuleAccess(session, 'ordering');
}
// Either audience may open the Ordering area (the nav/route resolves which view).
export function canOpenOrdering(session: AppSession | null | undefined) {
return canManageOrdering(session) || canOpenCustomerOrdering(session);
}
export function canPlaceOrders(session: AppSession | null | undefined) {
return !!session && session.role !== 'internal' && hasModuleAccess(session, 'ordering', 'edit');
}
// A "customer portal" session is a B2B ordering customer (ClientUser) whose
// access is limited to ordering — they get the dedicated, stripped-down
// Customer Ordering Portal shell rather than the internal staff workspace.
// Internal staff and costing-portal client users (who also have mix/product/
// throughput access) keep the full workspace.
const STAFF_ONLY_MODULES = [
'mix_calculator',
'products',
'mix_master',
'operations_throughput',
'scenarios',
'raw_materials',
'client_access'
];
export function isCustomerPortalSession(session: AppSession | null | undefined) {
if (!session || session.role !== 'client') {
return false;
}
if (!canOpenCustomerOrdering(session)) {
return false;
}
return !STAFF_ONLY_MODULES.some((moduleKey) => hasModuleAccess(session, moduleKey));
}
export function canOpenSettings(session: AppSession | null | undefined) {
if (!session) {
return false;
@@ -167,10 +213,20 @@ export const routeAccessRules: RouteAccessRule[] = [
path: '/throughput',
roles: ['admin', 'operations', 'full', 'client'],
matches: (pathname) => hasPathPrefix(pathname, '/throughput')
},
{
path: '/ordering',
roles: ['admin', 'full', 'client'],
matches: (pathname) => hasPathPrefix(pathname, '/ordering')
}
];
export function getDefaultRouteForRole(session: AppSession | null | undefined) {
// B2B ordering customers land directly in the ordering portal.
if (isCustomerPortalSession(session)) {
return '/ordering';
}
const role = getWorkspaceRole(session);
if (role === 'operations') {
@@ -213,6 +269,7 @@ export function canAccessRoute(session: AppSession | null | undefined, pathname:
if (pathname.startsWith('/settings')) return canOpenSettings(session);
if (pathname.startsWith('/client-access')) return canOpenClientAccess(session);
if (pathname.startsWith('/throughput')) return canOpenThroughput(session);
if (pathname.startsWith('/ordering')) return canOpenOrdering(session);
return true;
}
+9 -5
View File
@@ -7,15 +7,19 @@
import '$lib/theme';
import { beforeNavigate, afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import AdminShell from '$lib/components/AdminShell.svelte';
import ClientShell from '$lib/components/ClientShell.svelte';
import CustomerPortalShell from '$lib/components/CustomerPortalShell.svelte';
import Toast from '$lib/components/Toast.svelte';
import { clientSession } from '$lib/session';
import { isCustomerPortalSession } from '$lib/workspace-access';
import { toast } from '$lib/toast';
let { children } = $props();
const isAdminRoute = $derived(page.url.pathname === '/admin' || page.url.pathname.startsWith('/admin/'));
const isPrintableRoute = $derived(page.url.pathname.startsWith('/mix-calculator/') && page.url.pathname.endsWith('/print'));
// B2B ordering customers get the dedicated, stripped-down portal shell;
// internal staff and costing-portal users keep the full workspace shell.
const isCustomerPortal = $derived(isCustomerPortalSession($clientSession));
let navToastId: string | null = null;
let navTimer: ReturnType<typeof setTimeout> | null = null;
@@ -41,10 +45,10 @@
{#if isPrintableRoute}
{@render children()}
{:else if isAdminRoute}
<AdminShell>
{:else if isCustomerPortal}
<CustomerPortalShell>
{@render children()}
</AdminShell>
</CustomerPortalShell>
{:else}
<ClientShell>
{@render children()}
+10 -3
View File
@@ -63,9 +63,16 @@
try {
// Authenticates against the internal Hunter Stock Feeds role/permission
// system. The response is shape-compatible with the legacy client
// session, so the rest of the app continues to work unchanged.
const session = await api.internalLogin(email, password);
// system first. If that fails (e.g. a B2B ordering-portal customer, who
// lives in the ClientUser table and signs in with the shared client
// password), fall back to the client login. Both responses are
// shape-compatible with the client session.
let session;
try {
session = await api.internalLogin(email, password);
} catch {
session = await api.clientLogin(email, password);
}
const targetHref = getWorkspaceHomeHref(session);
postLoginRedirecting = targetHref !== '/';
clientSession.set(session);
-394
View File
@@ -1,394 +0,0 @@
<script lang="ts">
import { api } from '$lib/api';
import { adminSession, sessionHydrated } from '$lib/session';
let { data } = $props();
let email = $state('');
let password = $state('');
let isLoggingIn = $state(false);
let loginError = $state('');
function formatDate(value: string | null | undefined) {
if (!value) {
return 'No preview generated';
}
return new Intl.DateTimeFormat('en-NZ', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
}).format(new Date(value));
}
async function handleLogin(event: SubmitEvent) {
event.preventDefault();
loginError = '';
isLoggingIn = true;
try {
const session = await api.adminLogin(email, password);
adminSession.set(session);
} catch (error) {
loginError = error instanceof Error ? error.message : 'Unable to sign in';
} finally {
isLoggingIn = false;
}
}
const totalUsers = $derived(data.clients.reduce((sum, client) => sum + client.users.length, 0));
const totalFeatures = $derived(data.clients.reduce((sum, client) => sum + client.enabled_feature_count, 0));
</script>
<section class="hero-card">
<div class="hero-copy">
<p class="eyebrow">Lean 101 Admin Panel</p>
<h2>Separate operator login and client access controls from the Hunter Premium Produce workspace.</h2>
<p>Use this admin surface for internal access changes, export validation, and operator-only workflows.</p>
</div>
<div class="hero-stats">
<article>
<span>Managed clients</span>
<strong>{data.clients.length}</strong>
</article>
<article>
<span>Total users</span>
<strong>{totalUsers}</strong>
</article>
<article>
<span>Enabled features</span>
<strong>{totalFeatures}</strong>
</article>
</div>
</section>
{#if !$sessionHydrated}
<section class="signin-card loading-card">
<div class="signin-copy">
<p class="eyebrow">Checking Session</p>
<h3>Restoring the Lean 101 admin session before deciding whether sign-in is needed.</h3>
<p>The admin sign-in form only appears when no saved operator session is available.</p>
</div>
</section>
{:else if !$adminSession}
<section class="signin-card">
<div class="signin-copy">
<p class="eyebrow">Admin Sign-In</p>
<h3>Authenticate here to unlock the admin navigation and client access controls.</h3>
<p>The public client workspace no longer exposes this operator sign-in.</p>
</div>
<form class="signin-form" onsubmit={handleLogin}>
<input bind:value={email} type="email" autocomplete="username" placeholder="Email" />
<input bind:value={password} type="password" autocomplete="current-password" placeholder="Password" />
<button class="primary-button" type="submit" disabled={isLoggingIn}>
{isLoggingIn ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div class="signin-meta">
{#if loginError}
<strong>{loginError}</strong>
{/if}
</div>
</section>
{:else}
<section class="live-banner">
<div>
<p class="eyebrow">Session Active</p>
<h3>{$adminSession.name} is signed in to the Lean 101 Admin Panel.</h3>
<p>Open the client access workspace to manage users, feature flags, and the Power BI export preview.</p>
</div>
<div class="live-actions">
<a class="primary-button" href="/admin/client-access">Open Client Access</a>
<a class="secondary-button" href="/">View Hunter workspace</a>
</div>
</section>
{/if}
<section class="detail-grid">
<article class="surface-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Scope</p>
<h3>What belongs in admin</h3>
</div>
</div>
<div class="bullet-list">
<article>
<strong>Client access control</strong>
<span>Manage new users, existing users, and feature access by client.</span>
</article>
<article>
<strong>Power BI export validation</strong>
<span>Verify the live export payload after each access change.</span>
</article>
<article>
<strong>Operator-only sign-in</strong>
<span>Keep internal authentication separate from the client workspace at `/`.</span>
</article>
</div>
</article>
<article class="surface-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Preview Snapshot</p>
<h3>Current export summary</h3>
<p>Last generated {formatDate(data.exportPreview.generated_at)}</p>
</div>
</div>
<div class="preview-stats">
<article>
<span>Client rows</span>
<strong>{data.exportPreview.client_rows.length}</strong>
</article>
<article>
<span>User rows</span>
<strong>{data.exportPreview.user_rows.length}</strong>
</article>
<article>
<span>Feature rows</span>
<strong>{data.exportPreview.feature_rows.length}</strong>
</article>
</div>
</article>
</section>
<style>
h2,
h3,
p {
margin: 0;
}
.eyebrow {
color: #6e8576;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero-card,
.signin-card,
.live-banner,
.surface-card {
border: 1px solid rgba(34, 54, 45, 0.1);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.84);
box-shadow: none;
}
.hero-card,
.signin-card,
.live-banner,
.detail-grid {
margin-bottom: 1.25rem;
}
.hero-card,
.signin-card,
.live-banner,
.surface-card {
padding: 1.25rem;
}
.hero-card,
.hero-stats,
.detail-grid,
.preview-stats {
display: grid;
gap: 1rem;
}
.hero-card {
grid-template-columns: minmax(0, 1.2fr) 0.85fr;
align-items: end;
}
.hero-copy h2 {
margin: 0.35rem 0 0.45rem;
max-width: 16ch;
font-size: clamp(2rem, 3vw, 2.6rem);
line-height: 1.02;
}
.hero-copy p:last-child,
.signin-copy p:last-child,
.live-banner p:last-child,
.bullet-list span,
.preview-stats span,
.card-toolbar p {
color: #5f7266;
}
.hero-stats,
.preview-stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.hero-stats article,
.preview-stats article {
padding: 1rem;
border-radius: 1rem;
background: rgba(243, 247, 241, 0.95);
border: 1px solid rgba(34, 54, 45, 0.08);
}
.hero-stats span,
.preview-stats span {
display: block;
font-size: 0.84rem;
}
.hero-stats strong,
.preview-stats strong {
display: block;
margin-top: 0.35rem;
font-size: 1.8rem;
}
.signin-card {
display: grid;
grid-template-columns: 1.2fr 1fr auto;
gap: 1rem;
align-items: center;
}
.loading-card {
grid-template-columns: 1fr;
min-height: 10rem;
}
.signin-copy h3,
.live-banner h3,
.card-toolbar h3 {
margin: 0.28rem 0 0.35rem;
font-size: 1.2rem;
}
.signin-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.signin-form input {
width: 100%;
padding: 0.9rem 0.95rem;
border: 1px solid rgba(34, 54, 45, 0.12);
border-radius: 0.85rem;
background: rgba(248, 251, 249, 0.92);
}
.signin-meta {
display: grid;
gap: 0.35rem;
justify-items: end;
color: #5f7266;
font-size: 0.9rem;
}
.signin-meta strong {
color: #b33636;
}
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.85rem;
padding: 0.85rem 1rem;
font-weight: 600;
text-decoration: none;
}
.primary-button {
border: none;
color: #fff;
background: var(--color-brand);
box-shadow: none;
}
.secondary-button {
border: 1px solid rgba(34, 54, 45, 0.12);
color: #203028;
background: rgba(255, 255, 255, 0.9);
}
.primary-button:disabled {
opacity: 0.72;
cursor: wait;
}
.live-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.live-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.detail-grid {
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.9fr);
}
.card-toolbar {
margin-bottom: 1rem;
}
.bullet-list {
display: grid;
gap: 0.9rem;
}
.bullet-list article {
padding: 0.95rem 1rem;
border-radius: 1rem;
background: rgba(243, 247, 241, 0.95);
border: 1px solid rgba(34, 54, 45, 0.08);
}
.bullet-list strong,
.preview-stats strong {
font-weight: 700;
}
.bullet-list span {
display: block;
margin-top: 0.25rem;
}
@media (max-width: 1120px) {
.hero-card,
.signin-card,
.detail-grid,
.hero-stats,
.preview-stats {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.live-banner {
flex-direction: column;
align-items: flex-start;
}
.signin-form {
grid-template-columns: 1fr;
}
}
</style>
-41
View File
@@ -1,41 +0,0 @@
import { hasStoredAdminSession } from '$lib/session';
import { api } from '$lib/api';
export async function load({ fetch }) {
if (!hasStoredAdminSession()) {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
permission_rows: [],
audit_rows: [],
clients: []
}
};
}
try {
const [clients, exportPreview] = await Promise.all([api.clientAccess(fetch), api.clientAccessExport(fetch)]);
return {
clients,
exportPreview
};
} catch {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
permission_rows: [],
audit_rows: [],
clients: []
}
};
}
}
@@ -1,7 +0,0 @@
<script lang="ts">
import ClientAccessWorkspace from '$lib/components/ClientAccessWorkspace.svelte';
let { data } = $props();
</script>
<ClientAccessWorkspace {data} />
@@ -1,41 +0,0 @@
import { hasStoredAdminSession } from '$lib/session';
import { api } from '$lib/api';
export async function load({ fetch }) {
if (!hasStoredAdminSession()) {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
permission_rows: [],
audit_rows: [],
clients: []
}
};
}
try {
const [clients, exportPreview] = await Promise.all([api.clientAccess(fetch), api.clientAccessExport(fetch)]);
return {
clients,
exportPreview
};
} catch {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
permission_rows: [],
audit_rows: [],
clients: []
}
};
}
}
-8
View File
@@ -30,7 +30,6 @@ vi.mock('$lib/api', () => ({
vi.mock('$lib/session', () => sessionMocks);
import { load as homeLoad } from './+page';
import { load as adminLoad } from './admin/+page';
import { load as mixesLoad } from './mixes/+page';
import { load as mixNewLoad } from './mixes/new/+page';
import { load as mixDetailLoad } from './mixes/[id]/+page';
@@ -153,11 +152,4 @@ describe('route loaders use the SvelteKit fetch argument', () => {
expect(apiMocks.scenarios).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the admin loader', async () => {
await adminLoad({ fetch: fetcher } as never);
expect(apiMocks.clientAccess).toHaveBeenCalledWith(fetcher);
expect(apiMocks.clientAccessExport).toHaveBeenCalledWith(fetcher);
});
});
+401
View File
@@ -0,0 +1,401 @@
<script lang="ts">
import { api } from '$lib/api';
import { clientSession } from '$lib/session';
import { canPlaceOrders } from '$lib/workspace-access';
import { toast } from '$lib/toast';
import type { CatalogueProduct, Order } from '$lib/types';
let { data } = $props();
// Catalogue is read-only from the loader; orders is a mutable copy that
// refreshOrders() reassigns, seeded from `data` via an effect.
const catalogue = $derived<CatalogueProduct[]>(data.catalogue ?? []);
let orders = $state<Order[]>([]);
$effect(() => {
orders = data.orders ?? [];
});
let category = $state<string>('all');
let search = $state('');
// Cart: product_id -> quantity
let cart = $state<Record<number, number>>({});
let editingOrderId = $state<number | null>(null);
let poNumber = $state('');
let deliveryNotes = $state('');
let requestedDate = $state('');
let fulfilment = $state<'delivery' | 'pickup'>('delivery');
let busy = $state(false);
const canOrder = $derived(canPlaceOrders($clientSession));
const categories = $derived(['all', ...Array.from(new Set(catalogue.map((p) => p.category)))]);
const productById = $derived(Object.fromEntries(catalogue.map((p) => [p.id, p])) as Record<number, CatalogueProduct>);
const filtered = $derived(
catalogue.filter((p) => {
if (category !== 'all' && p.category !== category) return false;
if (search) {
const n = search.toLowerCase();
return p.name.toLowerCase().includes(n) || p.sku.toLowerCase().includes(n);
}
return true;
})
);
const cartLines = $derived(
Object.entries(cart)
.map(([id, qty]) => ({ product: productById[Number(id)], qty }))
.filter((l) => l.product)
);
const cartCount = $derived(cartLines.length);
const cartSubtotal = $derived(
cartLines.reduce((sum, l) => sum + (l.product.price?.unit_price != null ? l.product.price.unit_price * l.qty : 0), 0)
);
const cartHasQuote = $derived(cartLines.some((l) => l.product.price?.requires_quote));
function money(value: number | null | undefined) {
if (value == null) return '—';
return new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }).format(value);
}
function statusLabel(s: string) {
return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
function addToCart(p: CatalogueProduct) {
const current = cart[p.id] ?? 0;
cart[p.id] = current ? current + 1 : Math.max(1, p.min_order_quantity);
}
function setQty(id: number, qty: number) {
if (qty <= 0) {
delete cart[id];
cart = { ...cart };
} else {
cart[id] = qty;
}
}
function clearCart() {
cart = {};
editingOrderId = null;
poNumber = '';
deliveryNotes = '';
requestedDate = '';
fulfilment = 'delivery';
}
function buildPayload() {
return {
lines: cartLines.map((l) => ({ product_id: l.product.id, quantity: l.qty })),
purchase_order_number: poNumber || null,
delivery_notes: deliveryNotes || null,
requested_delivery_date: requestedDate ? new Date(requestedDate).toISOString() : null,
fulfilment_method: fulfilment
};
}
async function refreshOrders() {
try {
orders = await api.ordering.orders();
} catch {
/* ignore */
}
}
async function saveDraft() {
if (!cartCount) return toast.error('Add at least one product first.');
busy = true;
try {
const payload = buildPayload();
const saved = editingOrderId
? await api.ordering.updateDraft(editingOrderId, payload)
: await api.ordering.createDraft(payload);
editingOrderId = saved.id;
toast.success(`Draft saved (${money(saved.subtotal_ex_gst)} ex GST).`);
await refreshOrders();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save draft.');
} finally {
busy = false;
}
}
async function submitOrder() {
if (!cartCount) return toast.error('Add at least one product first.');
busy = true;
try {
const payload = buildPayload();
const saved = editingOrderId
? await api.ordering.updateDraft(editingOrderId, payload)
: await api.ordering.createDraft(payload);
const submitted = await api.ordering.submit(saved.id, {});
toast.success(`Order ${submitted.order_number} submitted.`);
clearCart();
await refreshOrders();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not submit order.');
} finally {
busy = false;
}
}
async function editDraft(order: Order) {
cart = Object.fromEntries(order.lines.map((l) => [l.product_id, l.quantity]));
editingOrderId = order.id;
poNumber = order.purchase_order_number ?? '';
deliveryNotes = order.delivery_notes ?? '';
requestedDate = order.requested_delivery_date ? order.requested_delivery_date.slice(0, 10) : '';
fulfilment = (order.fulfilment_method as 'delivery' | 'pickup') ?? 'delivery';
toast.add('Draft loaded into the order builder.', 'info');
}
async function reorder(order: Order) {
busy = true;
try {
const draft = await api.ordering.reorder(order.id);
await editDraft(draft);
await refreshOrders();
toast.success('Reorder draft created.');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not reorder.');
} finally {
busy = false;
}
}
async function downloadPdf(order: Order) {
try {
const blob = await api.ordering.confirmationPdf(order.id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `order-${order.order_number ?? order.id}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not download PDF.');
}
}
</script>
<div class="ordering">
<header class="page-head">
<div>
<p class="eyebrow">Ordering Portal</p>
<h1>Order catalogue</h1>
<p class="sub">Your account-specific products and pricing. Prices exclude GST.</p>
</div>
</header>
<div class="layout">
<!-- Catalogue -->
<section class="catalogue surface-card">
<div class="toolbar">
<input class="search" placeholder="Search products or SKU…" bind:value={search} />
<div class="chips">
{#each categories as cat}
<button class="chip" class:active={category === cat} onclick={() => (category = cat)}>
{cat === 'all' ? 'All' : statusLabel(cat)}
</button>
{/each}
</div>
</div>
{#if !filtered.length}
<p class="empty">No products available.</p>
{:else}
<div class="grid">
{#each filtered as p (p.id)}
<article class="product">
<div class="product-top">
<span class="cat-tag">{statusLabel(p.category)}</span>
{#if p.stock_status !== 'in_stock'}<span class="stock">{statusLabel(p.stock_status)}</span>{/if}
</div>
<h3>{p.name}</h3>
<p class="sku">{p.sku} · {p.unit_of_measure}</p>
{#if p.description}<p class="desc">{p.description}</p>{/if}
<div class="price-row">
{#if p.price?.requires_quote}
<span class="quote-badge">Quote required</span>
{:else}
<strong>{money(p.price?.unit_price)}</strong>
<span class="price-label">{p.price?.label}</span>
{/if}
</div>
<div class="product-foot">
<span class="moq">Min {p.min_order_quantity}</span>
{#if canOrder}
<button class="add-btn" onclick={() => addToCart(p)} disabled={p.price?.requires_quote}>
{p.price?.requires_quote ? 'Quote' : 'Add'}
</button>
{/if}
</div>
</article>
{/each}
</div>
{/if}
</section>
<!-- Cart / order builder -->
<aside class="cart surface-card">
<div class="cart-head">
<h2>{editingOrderId ? 'Editing draft' : 'Order builder'}</h2>
{#if cartCount}<button class="link" onclick={clearCart}>Clear</button>{/if}
</div>
{#if !canOrder}
<p class="empty">Your role has view-only access. Contact an account owner to place orders.</p>
{:else if !cartCount}
<p class="empty">Add products from the catalogue to build an order.</p>
{:else}
<ul class="cart-lines">
{#each cartLines as line (line.product.id)}
<li>
<div class="cl-main">
<span class="cl-name">{line.product.name}</span>
<button class="link remove" onclick={() => setQty(line.product.id, 0)}>Remove</button>
</div>
<div class="cl-foot">
<input
class="qty"
type="number"
min={line.product.min_order_quantity}
step="1"
value={line.qty}
oninput={(e) => setQty(line.product.id, Number(e.currentTarget.value))}
/>
<span class="cl-unit">{line.product.price?.requires_quote ? 'Quote' : money(line.product.price?.unit_price)}</span>
<span class="cl-total">
{line.product.price?.unit_price != null ? money(line.product.price.unit_price * line.qty) : '—'}
</span>
</div>
</li>
{/each}
</ul>
<div class="totals">
<span>Subtotal (ex GST)</span>
<strong>{money(cartSubtotal)}</strong>
</div>
{#if cartHasQuote}<p class="quote-note">Some items need a quote — our team will confirm pricing.</p>{/if}
<div class="order-fields">
<label>PO number <input bind:value={poNumber} placeholder="Optional" /></label>
<label>Requested date <input type="date" bind:value={requestedDate} /></label>
<label class="full">
Fulfilment
<select bind:value={fulfilment}>
<option value="delivery">Delivery</option>
<option value="pickup">Pickup</option>
</select>
</label>
<label class="full">Delivery notes <textarea bind:value={deliveryNotes} rows="2" placeholder="Optional"></textarea></label>
</div>
<div class="cart-actions">
<button class="secondary" onclick={saveDraft} disabled={busy}>Save draft</button>
<button class="primary" onclick={submitOrder} disabled={busy}>Submit order</button>
</div>
{/if}
</aside>
</div>
<!-- Order history -->
<section class="history surface-card">
<h2>Your orders</h2>
{#if !orders.length}
<p class="empty">No orders yet.</p>
{:else}
<table>
<thead>
<tr><th>Order</th><th>Status</th><th>Items</th><th>Subtotal</th><th>Created</th><th></th></tr>
</thead>
<tbody>
{#each orders as o (o.id)}
<tr>
<td>{o.order_number ?? `Draft #${o.id}`}</td>
<td><span class="pill pill-{o.status}">{statusLabel(o.status)}</span></td>
<td>{o.lines.length}</td>
<td>{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
<td>{new Date(o.created_at).toLocaleDateString('en-AU')}</td>
<td class="row-actions">
{#if o.editable && canOrder}<button class="link" onclick={() => editDraft(o)}>Edit</button>{/if}
{#if canOrder}<button class="link" onclick={() => reorder(o)}>Reorder</button>{/if}
{#if o.order_number}<button class="link" onclick={() => downloadPdf(o)}>PDF</button>{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
</div>
<style>
.ordering { display: grid; gap: 1.25rem; }
h1 { margin: 0.2rem 0; font-size: 1.5rem; }
h2 { margin: 0 0 0.75rem; font-size: 1.05rem; }
h3 { margin: 0; font-size: 0.98rem; }
p { margin: 0; }
.eyebrow { color: var(--color-brand, #2f6f4f); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.sub { color: #64776b; font-size: 0.88rem; }
.surface-card { border: 1px solid rgba(34, 54, 45, 0.12); border-radius: 1rem; background: var(--surface, rgba(255,255,255,0.9)); padding: 1.1rem; }
.layout { display: grid; grid-template-columns: minmax(0, 1fr) 22rem; gap: 1.25rem; align-items: start; }
.toolbar { display: grid; gap: 0.6rem; margin-bottom: 1rem; }
.search { width: 100%; padding: 0.6rem 0.75rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.7rem; }
.chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.chip { padding: 0.3rem 0.7rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 999px; background: transparent; cursor: pointer; font-size: 0.8rem; }
.chip.active { background: var(--color-brand, #2f6f4f); color: #fff; border-color: transparent; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); gap: 0.85rem; }
.product { display: flex; flex-direction: column; gap: 0.4rem; padding: 0.85rem; border: 1px solid rgba(34,54,45,0.1); border-radius: 0.85rem; background: rgba(248,251,249,0.6); }
.product-top { display: flex; justify-content: space-between; gap: 0.5rem; }
.cat-tag { font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #6e8576; }
.stock { font-size: 0.66rem; font-weight: 700; color: #b07a1a; }
.sku { color: #7c8c82; font-size: 0.76rem; }
.desc { color: #64776b; font-size: 0.8rem; line-height: 1.3; }
.price-row { display: flex; align-items: baseline; gap: 0.5rem; margin-top: auto; }
.price-row strong { font-size: 1.05rem; }
.price-label { font-size: 0.7rem; color: #7c8c82; }
.quote-badge { font-size: 0.78rem; font-weight: 600; color: #b07a1a; }
.product-foot { display: flex; align-items: center; justify-content: space-between; }
.moq { font-size: 0.72rem; color: #7c8c82; }
.add-btn, .primary, .secondary { border-radius: 0.7rem; padding: 0.45rem 0.85rem; font-weight: 600; cursor: pointer; border: none; }
.add-btn { background: var(--color-brand, #2f6f4f); color: #fff; }
.add-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.cart-head { display: flex; justify-content: space-between; align-items: center; }
.cart-lines { list-style: none; margin: 0.5rem 0; padding: 0; display: grid; gap: 0.6rem; }
.cart-lines li { border-bottom: 1px solid rgba(34,54,45,0.08); padding-bottom: 0.5rem; }
.cl-main { display: flex; justify-content: space-between; gap: 0.5rem; }
.cl-name { font-size: 0.86rem; font-weight: 600; }
.cl-foot { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.35rem; }
.qty { width: 4rem; padding: 0.3rem 0.4rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.5rem; }
.cl-unit { font-size: 0.78rem; color: #7c8c82; }
.cl-total { margin-left: auto; font-weight: 600; font-size: 0.86rem; }
.totals { display: flex; justify-content: space-between; align-items: baseline; padding: 0.6rem 0; border-top: 1px solid rgba(34,54,45,0.12); }
.totals strong { font-size: 1.1rem; }
.quote-note { font-size: 0.76rem; color: #b07a1a; }
.order-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 0.55rem; margin: 0.6rem 0; }
.order-fields label { display: grid; gap: 0.2rem; font-size: 0.74rem; color: #64776b; }
.order-fields .full { grid-column: 1 / -1; }
.order-fields input, .order-fields select, .order-fields textarea { padding: 0.45rem 0.5rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.5rem; font: inherit; }
.cart-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.primary { background: var(--color-brand, #2f6f4f); color: #fff; }
.secondary { background: rgba(34,54,45,0.08); color: #22362d; }
.primary:disabled, .secondary:disabled { opacity: 0.6; cursor: wait; }
.link { background: none; border: none; color: var(--color-brand, #2f6f4f); cursor: pointer; font-size: 0.8rem; padding: 0; }
.remove { color: #b33636; }
.empty { color: #7c8c82; font-size: 0.86rem; padding: 0.5rem 0; }
table { width: 100%; border-collapse: collapse; font-size: 0.86rem; }
th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid rgba(34,54,45,0.08); }
th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: #7c8c82; }
.row-actions { display: flex; gap: 0.6rem; }
.pill { padding: 0.18rem 0.55rem; border-radius: 999px; font-size: 0.72rem; font-weight: 600; background: rgba(34,54,45,0.08); }
.pill-draft { background: #eee; color: #555; }
.pill-submitted, .pill-processing { background: #fdf0d5; color: #8a5a00; }
.pill-confirmed { background: #d8ece0; color: #1f6b46; }
.pill-dispatched, .pill-ready { background: #d5e4fd; color: #234e8a; }
.pill-completed { background: #d8ece0; color: #1f6b46; }
.pill-cancelled { background: #f7d5d5; color: #8a2323; }
@media (max-width: 960px) { .layout { grid-template-columns: 1fr; } }
</style>
+27
View File
@@ -0,0 +1,27 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { canManageOrdering, canOpenCustomerOrdering, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { catalogue: [], orders: [], canOrder: false };
}
const session = getStoredClientSession();
// Internal staff manage orders rather than place them — send them to the
// management console.
if (canManageOrdering(session)) {
throw redirect(307, '/ordering/manage');
}
if (!canOpenCustomerOrdering(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const [catalogue, orders] = await Promise.all([api.ordering.catalogue(undefined, fetch), api.ordering.orders(undefined, fetch)]);
return { catalogue, orders, canOrder: true };
} catch {
return { catalogue: [], orders: [], canOrder: true };
}
}
@@ -0,0 +1,591 @@
<script lang="ts">
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import type {
CatalogueProduct,
CustomerPricing,
CustomerVisibilityRow,
Order,
OrderingCustomer,
OrderingCustomerUser,
OrderingNotificationSettings,
XeroStatus
} from '$lib/types';
let { data } = $props();
type Tab = 'orders' | 'products' | 'customers' | 'settings';
let tab = $state<Tab>('orders');
// Mutable local copies of the loader data (refresh helpers reassign these).
// Seeded from `data` via an effect so navigation re-syncs without the
// "only captures the initial value" warning.
let orders = $state<Order[]>([]);
let products = $state<CatalogueProduct[]>([]);
let customers = $state<OrderingCustomer[]>([]);
let xero = $state<XeroStatus | null>(null);
$effect(() => {
orders = data.orders ?? [];
products = data.products ?? [];
customers = data.customers ?? [];
xero = data.xero ?? null;
});
const STATUSES = [
'submitted',
'under_review',
'confirmed',
'sent_to_xero',
'in_production',
'ready_for_pickup',
'dispatched',
'completed',
'cancelled'
];
const CATEGORIES = ['grains', 'premixed', 'bags', 'bulk_loads', 'custom_blends', 'services'];
function money(v: number | null | undefined) {
if (v == null) return '—';
return new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }).format(v);
}
function label(s: string) {
return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
// --- Orders ---------------------------------------------------------------
let selectedOrder = $state<Order | null>(null);
let statusChoice = $state('');
async function openOrder(o: Order) {
try {
selectedOrder = await api.orderingAdmin.order(o.id);
statusChoice = '';
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not load order.');
}
}
async function refreshOrders() {
try {
orders = await api.orderingAdmin.orders();
} catch {}
}
async function applyStatus() {
if (!selectedOrder || !statusChoice) return;
try {
selectedOrder = await api.orderingAdmin.updateStatus(selectedOrder.id, { to_status: statusChoice });
toast.success(`Status set to ${label(statusChoice)}.`);
statusChoice = '';
await refreshOrders();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Status change rejected.');
}
}
async function overrideLine(lineId: number, value: string) {
if (!selectedOrder || value === '') return;
try {
selectedOrder = await api.orderingAdmin.overrideLine(selectedOrder.id, lineId, { unit_price: Number(value), reason: 'Admin override' });
toast.success('Line price overridden.');
await refreshOrders();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Override failed.');
}
}
async function sendToXero() {
if (!selectedOrder) return;
try {
selectedOrder = await api.orderingAdmin.sendToXero(selectedOrder.id);
const r = selectedOrder.xero_result;
toast.success(`Xero ${r?.status}${r?.stubbed ? ' (stub)' : ''}: ${r?.xero_invoice_id ?? ''}`);
await refreshOrders();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Xero submission failed.');
}
}
async function reopenOrder() {
if (!selectedOrder) return;
try {
selectedOrder = await api.orderingAdmin.reopen(selectedOrder.id);
toast.success('Order reopened to draft.');
await refreshOrders();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not reopen.');
}
}
// --- Products -------------------------------------------------------------
let newProduct = $state<Record<string, any>>({ name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null, requires_quote: false, active: true });
async function refreshProducts() {
try {
products = await api.orderingAdmin.products();
} catch {}
}
async function createProduct() {
if (!newProduct.name || !newProduct.sku) return toast.error('Name and SKU are required.');
try {
await api.orderingAdmin.createProduct({ ...newProduct, base_price: newProduct.base_price === null || newProduct.base_price === '' ? null : Number(newProduct.base_price) });
toast.success('Product created.');
newProduct = { name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null, requires_quote: false, active: true };
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not create product.');
}
}
async function toggleProductActive(p: CatalogueProduct) {
try {
await api.orderingAdmin.updateProduct(p.id, { active: !p.active });
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function saveProductPrice(p: CatalogueProduct, value: string) {
try {
await api.orderingAdmin.updateProduct(p.id, { base_price: value === '' ? null : Number(value) });
toast.success('Base price updated.');
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
// --- Customers ------------------------------------------------------------
let newCustomer = $state({ name: '', client_code: '' });
let selectedCustomer = $state<OrderingCustomer | null>(null);
let custUsers = $state<OrderingCustomerUser[]>([]);
let custPricing = $state<CustomerPricing | null>(null);
let custVisibility = $state<CustomerVisibilityRow[]>([]);
let newUser = $state({ full_name: '', email: '', role: 'buyer' });
let discountInput = $state(0);
let newPrice = $state<Record<string, any>>({ product_id: '', unit_price: '', rule_type: 'fixed' });
async function refreshCustomers() {
try {
customers = await api.orderingAdmin.customers();
} catch {}
}
async function createCustomer() {
if (!newCustomer.name || !newCustomer.client_code) return toast.error('Name and code are required.');
try {
await api.orderingAdmin.createCustomer(newCustomer);
toast.success('Customer created.');
newCustomer = { name: '', client_code: '' };
await refreshCustomers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not create customer.');
}
}
async function openCustomer(c: OrderingCustomer) {
selectedCustomer = c;
discountInput = c.discount_percent;
try {
[custUsers, custPricing, custVisibility] = await Promise.all([
api.orderingAdmin.customerUsers(c.id),
api.orderingAdmin.pricing(c.id),
api.orderingAdmin.visibility(c.id)
]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not load customer.');
}
}
async function toggleCustomerStatus(c: OrderingCustomer) {
try {
const updated = await api.orderingAdmin.updateCustomer(c.id, { status: c.status === 'active' ? 'disabled' : 'active' });
toast.success(`Customer ${updated.status}.`);
await refreshCustomers();
if (selectedCustomer?.id === c.id) selectedCustomer = updated;
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function addUser() {
if (!selectedCustomer) return;
if (!newUser.full_name || !newUser.email) return toast.error('Name and email required.');
try {
await api.orderingAdmin.createCustomerUser(selectedCustomer.id, newUser);
toast.success('User invited.');
newUser = { full_name: '', email: '', role: 'buyer' };
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
await refreshCustomers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not add user.');
}
}
async function toggleUserStatus(u: OrderingCustomerUser) {
if (!selectedCustomer) return;
try {
const next = u.status === 'suspended' ? 'active' : 'suspended';
await api.orderingAdmin.updateCustomerUser(selectedCustomer.id, u.id, { status: next });
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function saveDiscount() {
if (!selectedCustomer) return;
try {
custPricing = await api.orderingAdmin.setAssignment(selectedCustomer.id, { price_list_id: custPricing?.price_list_id ?? null, discount_percent: Number(discountInput) });
toast.success('Discount saved.');
await refreshCustomers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save discount.');
}
}
async function addProductPrice() {
if (!selectedCustomer || !newPrice.product_id) return toast.error('Choose a product.');
try {
custPricing = await api.orderingAdmin.setProductPrice(selectedCustomer.id, {
product_id: Number(newPrice.product_id),
unit_price: newPrice.rule_type === 'quote' || newPrice.unit_price === '' ? null : Number(newPrice.unit_price),
rule_type: newPrice.rule_type
});
toast.success('Customer price saved.');
newPrice = { product_id: '', unit_price: '', rule_type: 'fixed' };
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save price.');
}
}
async function removeProductPrice(productId: number) {
if (!selectedCustomer) return;
try {
await api.orderingAdmin.deleteProductPrice(selectedCustomer.id, productId);
custPricing = await api.orderingAdmin.pricing(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not remove price.');
}
}
async function toggleVisibility(row: CustomerVisibilityRow) {
if (!selectedCustomer) return;
try {
await api.orderingAdmin.setVisibility(selectedCustomer.id, { product_id: row.product_id, visible: !row.visible });
custVisibility = await api.orderingAdmin.visibility(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
function productName(id: number) {
return products.find((p) => p.id === id)?.name ?? `#${id}`;
}
// --- Settings -------------------------------------------------------------
let settings = $state<OrderingNotificationSettings | null>(null);
async function loadSettings() {
try {
settings = await api.orderingAdmin.notificationSettings();
xero = await api.orderingAdmin.xeroStatus();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not load settings.');
}
}
async function saveSettings() {
if (!settings) return;
try {
settings = await api.orderingAdmin.updateNotificationSettings(settings);
toast.success('Settings saved.');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save settings.');
}
}
$effect(() => {
if (tab === 'settings' && !settings) loadSettings();
});
</script>
<div class="admin-ordering">
<header>
<p class="eyebrow">Ordering</p>
<h1>Order management</h1>
</header>
<nav class="tabs">
<button class:active={tab === 'orders'} onclick={() => (tab = 'orders')}>Orders</button>
<button class:active={tab === 'products'} onclick={() => (tab = 'products')}>Products</button>
<button class:active={tab === 'customers'} onclick={() => (tab = 'customers')}>Customers & Pricing</button>
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings & Xero</button>
</nav>
{#if tab === 'orders'}
<div class="split">
<section class="surface-card">
<h2>Order queue</h2>
{#if !orders.length}
<p class="empty">No submitted orders.</p>
{:else}
<table>
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Subtotal</th><th>Xero</th></tr></thead>
<tbody>
{#each orders as o (o.id)}
<tr class:selected={selectedOrder?.id === o.id} onclick={() => openOrder(o)}>
<td>{o.order_number ?? `#${o.id}`}</td>
<td>{o.customer_name}</td>
<td><span class="pill">{label(o.status)}</span></td>
<td>{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
<td>{o.xero_status ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
{#if selectedOrder}
<section class="surface-card detail">
<h2>{selectedOrder.order_number ?? `Order #${selectedOrder.id}`}</h2>
<p class="muted">{selectedOrder.customer_name} · {label(selectedOrder.status)} · PO {selectedOrder.purchase_order_number ?? '—'}</p>
<table class="lines">
<thead><tr><th>Product</th><th>Qty</th><th>Unit</th><th>Override</th><th>Total</th></tr></thead>
<tbody>
{#each selectedOrder.lines as l (l.id)}
<tr>
<td>{l.product_name}</td>
<td>{l.quantity}</td>
<td>{l.requires_quote ? 'Quote' : money(l.resolved_unit_price ?? l.unit_price)}</td>
<td>
<input class="ovr" type="number" step="0.01" placeholder={l.admin_override_price != null ? String(l.admin_override_price) : 'set'}
onchange={(e) => overrideLine(l.id, e.currentTarget.value)} />
</td>
<td>{money(l.line_total)}</td>
</tr>
{/each}
</tbody>
</table>
<div class="detail-total"><span>Subtotal (ex GST)</span><strong>{money(selectedOrder.subtotal_ex_gst)}</strong></div>
<div class="actions">
<select bind:value={statusChoice}>
<option value="">Change status…</option>
{#each STATUSES as s}<option value={s}>{label(s)}</option>{/each}
</select>
<button class="primary" onclick={applyStatus} disabled={!statusChoice}>Apply</button>
<button class="secondary" onclick={sendToXero}>Send to Xero</button>
<button class="secondary" onclick={reopenOrder}>Reopen</button>
</div>
{#if selectedOrder.status_history?.length}
<details class="history">
<summary>Status history ({selectedOrder.status_history.length})</summary>
<ul>
{#each selectedOrder.status_history as h}
<li>{label(h.from_status ?? 'new')}{label(h.to_status)} · {h.actor_name ?? h.actor_type} · {new Date(h.created_at).toLocaleString('en-AU')}</li>
{/each}
</ul>
</details>
{/if}
</section>
{/if}
</div>
{/if}
{#if tab === 'products'}
<section class="surface-card">
<h2>New product</h2>
<div class="form-grid">
<label>Name<input bind:value={newProduct.name} /></label>
<label>SKU<input bind:value={newProduct.sku} /></label>
<label>Category
<select bind:value={newProduct.category}>{#each CATEGORIES as c}<option value={c}>{label(c)}</option>{/each}</select>
</label>
<label>Unit of measure<input bind:value={newProduct.unit_of_measure} /></label>
<label>Min order qty<input type="number" bind:value={newProduct.min_order_quantity} /></label>
<label>Base price (ex GST)<input type="number" step="0.01" bind:value={newProduct.base_price} /></label>
<label class="check"><input type="checkbox" bind:checked={newProduct.requires_quote} /> Requires quote</label>
<button class="primary" onclick={createProduct}>Create product</button>
</div>
</section>
<section class="surface-card">
<h2>Catalogue ({products.length})</h2>
<table>
<thead><tr><th>Name</th><th>SKU</th><th>Category</th><th>Base price</th><th>Active</th><th></th></tr></thead>
<tbody>
{#each products as p (p.id)}
<tr>
<td>{p.name}{#if p.requires_quote}<span class="tag">quote</span>{/if}</td>
<td>{p.sku}</td>
<td>{label(p.category)}</td>
<td><input class="inline" type="number" step="0.01" value={p.base_price ?? ''} onchange={(e) => saveProductPrice(p, e.currentTarget.value)} /></td>
<td>{p.active ? 'Yes' : 'No'}</td>
<td><button class="link" onclick={() => toggleProductActive(p)}>{p.active ? 'Disable' : 'Enable'}</button></td>
</tr>
{/each}
</tbody>
</table>
</section>
{/if}
{#if tab === 'customers'}
<div class="split">
<section class="surface-card">
<h2>New customer</h2>
<div class="form-row">
<input placeholder="Company name" bind:value={newCustomer.name} />
<input placeholder="Code (e.g. ACME)" bind:value={newCustomer.client_code} />
<button class="primary" onclick={createCustomer}>Create</button>
</div>
<h2 class="mt">Customers ({customers.length})</h2>
<table>
<thead><tr><th>Name</th><th>Code</th><th>Users</th><th>Status</th><th></th></tr></thead>
<tbody>
{#each customers as c (c.id)}
<tr class:selected={selectedCustomer?.id === c.id}>
<td><button class="link" onclick={() => openCustomer(c)}>{c.name}</button></td>
<td>{c.client_code}</td>
<td>{c.user_count}</td>
<td><span class="pill">{c.status}</span></td>
<td><button class="link" onclick={() => toggleCustomerStatus(c)}>{c.status === 'active' ? 'Disable' : 'Enable'}</button></td>
</tr>
{/each}
</tbody>
</table>
</section>
{#if selectedCustomer}
<section class="surface-card detail">
<h2>{selectedCustomer.name}</h2>
<h3>Users</h3>
<ul class="mini">
{#each custUsers as u (u.id)}
<li>{u.full_name} · {u.email} · {u.role} · {u.status}
<button class="link" onclick={() => toggleUserStatus(u)}>{u.status === 'suspended' ? 'Reactivate' : 'Suspend'}</button>
</li>
{/each}
</ul>
<div class="form-row">
<input placeholder="Full name" bind:value={newUser.full_name} />
<input placeholder="Email" bind:value={newUser.email} />
<select bind:value={newUser.role}>
<option value="owner">Owner</option><option value="buyer">Buyer</option>
<option value="accounts">Accounts</option><option value="viewer">Viewer</option>
</select>
<button class="secondary" onclick={addUser}>Invite</button>
</div>
<h3 class="mt">Pricing</h3>
<div class="form-row">
<label class="inline-label">Default discount %
<input type="number" step="0.5" bind:value={discountInput} />
</label>
<button class="secondary" onclick={saveDiscount}>Save discount</button>
</div>
{#if custPricing?.product_prices.length}
<ul class="mini">
{#each custPricing.product_prices as pp (pp.id)}
<li>{productName(pp.product_id)} · {pp.rule_type} · {pp.unit_price != null ? money(pp.unit_price) : 'quote'}
<button class="link" onclick={() => removeProductPrice(pp.product_id)}>Remove</button>
</li>
{/each}
</ul>
{/if}
<div class="form-row">
<select bind:value={newPrice.product_id}>
<option value="">Product…</option>
{#each products as p}<option value={p.id}>{p.name}</option>{/each}
</select>
<select bind:value={newPrice.rule_type}>
<option value="fixed">Fixed</option><option value="contract">Contract</option><option value="quote">Quote</option>
</select>
<input type="number" step="0.01" placeholder="Unit price" bind:value={newPrice.unit_price} disabled={newPrice.rule_type === 'quote'} />
<button class="secondary" onclick={addProductPrice}>Set price</button>
</div>
<h3 class="mt">Product visibility</h3>
<ul class="mini visibility">
{#each custVisibility as row (row.product_id)}
<li>
<label class="check"><input type="checkbox" checked={row.visible} onchange={() => toggleVisibility(row)} /> {row.name}</label>
</li>
{/each}
</ul>
</section>
{/if}
</div>
{/if}
{#if tab === 'settings'}
<div class="split">
<section class="surface-card">
<h2>Notification settings</h2>
{#if settings}
<div class="form-grid">
<label class="full">Internal recipients (comma separated)<input bind:value={settings.internal_recipients} /></label>
<label class="full">From email<input bind:value={settings.from_email} /></label>
<label class="check"><input type="checkbox" bind:checked={settings.send_customer_confirmation} /> Send customer confirmation</label>
<label class="check"><input type="checkbox" bind:checked={settings.require_po_number} /> Require PO number on submit</label>
<button class="primary" onclick={saveSettings}>Save settings</button>
</div>
{:else}
<p class="empty">Loading…</p>
{/if}
</section>
<section class="surface-card">
<h2>Xero integration</h2>
{#if xero}
<p class="muted">Mode: <strong>{xero.connection.mode}</strong> · {xero.connection.configured ? 'Configured' : 'Not configured (stub mode)'}</p>
{#if xero.connection.missing_env.length}
<p class="muted">Missing env: {xero.connection.missing_env.join(', ')}</p>
{/if}
<h3>Recent syncs</h3>
{#if !xero.recent_syncs.length}
<p class="empty">No Xero submissions yet.</p>
{:else}
<ul class="mini">
{#each xero.recent_syncs as s (s.id)}
<li>Order {s.order_id} · {s.status} · {s.xero_invoice_id ?? '—'} · {new Date(s.created_at).toLocaleString('en-AU')}</li>
{/each}
</ul>
{/if}
{:else}
<p class="empty">Loading…</p>
{/if}
</section>
</div>
{/if}
</div>
<style>
.admin-ordering { display: grid; gap: 1rem; }
h1 { margin: 0.15rem 0; font-size: 1.4rem; }
h2 { margin: 0 0 0.7rem; font-size: 1.05rem; }
h3 { margin: 0 0 0.4rem; font-size: 0.92rem; }
.mt { margin-top: 1rem; }
.eyebrow { color: #6e8576; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.muted { color: #64776b; font-size: 0.84rem; margin: 0 0 0.6rem; }
.surface-card { border: 1px solid rgba(34,54,45,0.12); border-radius: 1rem; background: rgba(255,255,255,0.92); padding: 1.1rem; }
.tabs { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.tabs button { padding: 0.45rem 0.9rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.7rem; background: transparent; cursor: pointer; font-weight: 600; font-size: 0.86rem; }
.tabs button.active { background: var(--color-brand, #2f6f4f); color: #fff; border-color: transparent; }
.split { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 1rem; align-items: start; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th, td { text-align: left; padding: 0.45rem 0.55rem; border-bottom: 1px solid rgba(34,54,45,0.08); }
th { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: #7c8c82; }
tbody tr { cursor: pointer; }
tbody tr.selected { background: rgba(47,111,79,0.08); }
.pill { padding: 0.16rem 0.5rem; border-radius: 999px; font-size: 0.72rem; font-weight: 600; background: rgba(34,54,45,0.08); }
.tag { margin-left: 0.4rem; font-size: 0.64rem; padding: 0.05rem 0.35rem; border-radius: 999px; background: #fdf0d5; color: #8a5a00; }
.empty { color: #7c8c82; font-size: 0.85rem; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; align-items: end; }
.form-grid .full { grid-column: 1 / -1; }
.form-grid label, .form-row label { display: grid; gap: 0.2rem; font-size: 0.76rem; color: #64776b; }
.form-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.6rem; }
.form-row input, .form-row select, .form-grid input, .form-grid select { padding: 0.45rem 0.55rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.55rem; font: inherit; }
.check { display: flex; flex-direction: row; align-items: center; gap: 0.4rem; }
.inline-label { flex-direction: row; align-items: center; gap: 0.45rem; }
.inline { width: 6rem; padding: 0.3rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.45rem; }
.ovr { width: 5rem; padding: 0.3rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.45rem; }
.primary, .secondary { border-radius: 0.6rem; padding: 0.5rem 0.9rem; font-weight: 600; cursor: pointer; border: none; }
.primary { background: var(--color-brand, #2f6f4f); color: #fff; }
.secondary { background: rgba(34,54,45,0.08); color: #22362d; }
.link { background: none; border: none; color: var(--color-brand, #2f6f4f); cursor: pointer; font-size: 0.8rem; padding: 0; }
.lines td { font-size: 0.82rem; }
.detail-total { display: flex; justify-content: space-between; padding: 0.5rem 0; border-top: 1px solid rgba(34,54,45,0.12); margin: 0.4rem 0; }
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.actions select { padding: 0.45rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.55rem; }
.history { margin-top: 0.8rem; font-size: 0.8rem; }
.mini { list-style: none; margin: 0.3rem 0 0.6rem; padding: 0; display: grid; gap: 0.3rem; font-size: 0.82rem; }
.mini li { display: flex; gap: 0.5rem; align-items: center; justify-content: space-between; }
.visibility li { justify-content: flex-start; }
@media (max-width: 1000px) { .split, .form-grid { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,30 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { canManageOrdering, getWorkspaceHomeHref } from '$lib/workspace-access';
const EMPTY = { orders: [], products: [], customers: [], xero: null } as const;
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { ...EMPTY };
}
const session = getStoredClientSession();
if (!canManageOrdering(session)) {
// Customers (or anyone without manage rights) don't belong here.
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const [orders, products, customers, xero] = await Promise.all([
api.orderingAdmin.orders(undefined, fetch),
api.orderingAdmin.products(fetch),
api.orderingAdmin.customers(fetch),
api.orderingAdmin.xeroStatus(fetch)
]);
return { orders, products, customers, xero };
} catch {
return { ...EMPTY };
}
}