v0.1.14 - b2b portal
This commit is contained in:
@@ -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()}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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: []
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user