v0.1.19 - Throughput overview & responsive header
- Throughput: new "Throughput Overview" header with Gauge icon and brand-green badge icons on each card (Today, This week, 4-week average, Horse Mix, Grain Mix) - Throughput: inline rolling-range selector (7d / 4w / 6w / 12w, default 4 weeks) driving the customer-mix cards; stats window widened to 12 weeks so switching range is a pure client-side re-filter - Throughput: cards collapse to a single even 5-across row on laptop and up, with container-query value text that scales to each card's width - Throughput: date logic pinned to Australian Eastern time (fixes the day-early date); This week subtitle shows the Mon-Sun date range - Throughput: subtler tinted add-form; removed the inline-entry kicker and the "Open full form" link - Topbar: fix cramped laptop header - action toggles no longer wrap above the user button; search drops to its own row earlier Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import '$lib/ordering/manage.css';
|
||||
import { findOrderingSection } from '$lib/navigation/client-navigation';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// The header mirrors the active section: "Order Management" eyebrow above, then
|
||||
// the current page's name (Orders, Products, …, or a nested page like Xero).
|
||||
// Reuse the rail's section finder so the title stays in sync with navigation.
|
||||
const sectionLabel = $derived(findOrderingSection(page.url.pathname)?.label ?? 'Orders');
|
||||
</script>
|
||||
|
||||
<!-- Section navigation lives in the primary left rail (and the mobile drawer),
|
||||
so the console pages don't repeat it inline. -->
|
||||
<div class="manage-shell">
|
||||
<header>
|
||||
<p class="eyebrow">Order Management</p>
|
||||
<h1>{sectionLabel}</h1>
|
||||
</header>
|
||||
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.manage-shell { display: grid; gap: 1rem; }
|
||||
h1 { margin: 0.15rem 0; font-size: 1.4rem; letter-spacing: -0.02em; }
|
||||
.eyebrow { color: var(--color-text-muted); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
|
||||
import { canManageOrdering, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||
|
||||
// Single access guard for the whole Order Management family. Each child route
|
||||
// (orders, products, customers, pricing, settings, integrations) loads its own
|
||||
// data; this layout just keeps non-managers out of all of them in one place.
|
||||
export async function load() {
|
||||
if (!hasStoredClientSession()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
if (!canManageOrdering(session)) {
|
||||
throw redirect(307, getWorkspaceHomeHref(session));
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -1,59 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import type {
|
||||
CatalogueProduct,
|
||||
CustomerPricing,
|
||||
CustomerVisibilityRow,
|
||||
Order,
|
||||
OrderingCustomer,
|
||||
OrderingCustomerUser,
|
||||
OrderingNotificationSettings,
|
||||
XeroStatus
|
||||
} from '$lib/types';
|
||||
import { money, label, statusTone, ORDER_STATUSES } from '$lib/ordering/format';
|
||||
import type { Order } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
type Tab = 'orders' | 'products' | 'customers' | 'settings';
|
||||
let tab = $state<Tab>('orders');
|
||||
|
||||
// Mutable local copies of the loader data (refresh helpers reassign these).
|
||||
// Seeded from `data` via an effect so navigation re-syncs without the
|
||||
// "only captures the initial value" warning.
|
||||
let orders = $state<Order[]>([]);
|
||||
let products = $state<CatalogueProduct[]>([]);
|
||||
let customers = $state<OrderingCustomer[]>([]);
|
||||
let xero = $state<XeroStatus | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
orders = data.orders ?? [];
|
||||
products = data.products ?? [];
|
||||
customers = data.customers ?? [];
|
||||
xero = data.xero ?? null;
|
||||
});
|
||||
|
||||
const STATUSES = [
|
||||
'submitted',
|
||||
'under_review',
|
||||
'confirmed',
|
||||
'sent_to_xero',
|
||||
'in_production',
|
||||
'ready_for_pickup',
|
||||
'dispatched',
|
||||
'completed',
|
||||
'cancelled'
|
||||
];
|
||||
const CATEGORIES = ['grains', 'premixed', 'bags', 'bulk_loads', 'custom_blends', 'services'];
|
||||
|
||||
function money(v: number | null | undefined) {
|
||||
if (v == null) return '—';
|
||||
return new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }).format(v);
|
||||
}
|
||||
function label(s: string) {
|
||||
return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// --- Orders ---------------------------------------------------------------
|
||||
let selectedOrder = $state<Order | null>(null);
|
||||
let statusChoice = $state('');
|
||||
|
||||
@@ -65,6 +22,10 @@
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load order.');
|
||||
}
|
||||
}
|
||||
function closeOrder() {
|
||||
selectedOrder = null;
|
||||
statusChoice = '';
|
||||
}
|
||||
async function refreshOrders() {
|
||||
try {
|
||||
orders = await api.orderingAdmin.orders();
|
||||
@@ -112,480 +73,85 @@
|
||||
toast.error(e instanceof Error ? e.message : 'Could not reopen.');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Products -------------------------------------------------------------
|
||||
let newProduct = $state<Record<string, any>>({ name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null, requires_quote: false, active: true });
|
||||
|
||||
async function refreshProducts() {
|
||||
try {
|
||||
products = await api.orderingAdmin.products();
|
||||
} catch {}
|
||||
}
|
||||
async function createProduct() {
|
||||
if (!newProduct.name || !newProduct.sku) return toast.error('Name and SKU are required.');
|
||||
try {
|
||||
await api.orderingAdmin.createProduct({ ...newProduct, base_price: newProduct.base_price === null || newProduct.base_price === '' ? null : Number(newProduct.base_price) });
|
||||
toast.success('Product created.');
|
||||
newProduct = { name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null, requires_quote: false, active: true };
|
||||
await refreshProducts();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not create product.');
|
||||
}
|
||||
}
|
||||
async function toggleProductActive(p: CatalogueProduct) {
|
||||
try {
|
||||
await api.orderingAdmin.updateProduct(p.id, { active: !p.active });
|
||||
await refreshProducts();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
async function saveProductPrice(p: CatalogueProduct, value: string) {
|
||||
try {
|
||||
await api.orderingAdmin.updateProduct(p.id, { base_price: value === '' ? null : Number(value) });
|
||||
toast.success('Base price updated.');
|
||||
await refreshProducts();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Customers ------------------------------------------------------------
|
||||
let newCustomer = $state({ name: '', client_code: '' });
|
||||
let selectedCustomer = $state<OrderingCustomer | null>(null);
|
||||
let custUsers = $state<OrderingCustomerUser[]>([]);
|
||||
let custPricing = $state<CustomerPricing | null>(null);
|
||||
let custVisibility = $state<CustomerVisibilityRow[]>([]);
|
||||
let newUser = $state({ full_name: '', email: '', role: 'buyer' });
|
||||
let discountInput = $state(0);
|
||||
let newPrice = $state<Record<string, any>>({ product_id: '', unit_price: '', rule_type: 'fixed' });
|
||||
|
||||
async function refreshCustomers() {
|
||||
try {
|
||||
customers = await api.orderingAdmin.customers();
|
||||
} catch {}
|
||||
}
|
||||
async function createCustomer() {
|
||||
if (!newCustomer.name || !newCustomer.client_code) return toast.error('Name and code are required.');
|
||||
try {
|
||||
await api.orderingAdmin.createCustomer(newCustomer);
|
||||
toast.success('Customer created.');
|
||||
newCustomer = { name: '', client_code: '' };
|
||||
await refreshCustomers();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not create customer.');
|
||||
}
|
||||
}
|
||||
async function openCustomer(c: OrderingCustomer) {
|
||||
selectedCustomer = c;
|
||||
discountInput = c.discount_percent;
|
||||
try {
|
||||
[custUsers, custPricing, custVisibility] = await Promise.all([
|
||||
api.orderingAdmin.customerUsers(c.id),
|
||||
api.orderingAdmin.pricing(c.id),
|
||||
api.orderingAdmin.visibility(c.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load customer.');
|
||||
}
|
||||
}
|
||||
async function toggleCustomerStatus(c: OrderingCustomer) {
|
||||
try {
|
||||
const updated = await api.orderingAdmin.updateCustomer(c.id, { status: c.status === 'active' ? 'disabled' : 'active' });
|
||||
toast.success(`Customer ${updated.status}.`);
|
||||
await refreshCustomers();
|
||||
if (selectedCustomer?.id === c.id) selectedCustomer = updated;
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
async function addUser() {
|
||||
if (!selectedCustomer) return;
|
||||
if (!newUser.full_name || !newUser.email) return toast.error('Name and email required.');
|
||||
try {
|
||||
await api.orderingAdmin.createCustomerUser(selectedCustomer.id, newUser);
|
||||
toast.success('User invited.');
|
||||
newUser = { full_name: '', email: '', role: 'buyer' };
|
||||
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
|
||||
await refreshCustomers();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not add user.');
|
||||
}
|
||||
}
|
||||
async function toggleUserStatus(u: OrderingCustomerUser) {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
const next = u.status === 'suspended' ? 'active' : 'suspended';
|
||||
await api.orderingAdmin.updateCustomerUser(selectedCustomer.id, u.id, { status: next });
|
||||
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
async function saveDiscount() {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
custPricing = await api.orderingAdmin.setAssignment(selectedCustomer.id, { price_list_id: custPricing?.price_list_id ?? null, discount_percent: Number(discountInput) });
|
||||
toast.success('Discount saved.');
|
||||
await refreshCustomers();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not save discount.');
|
||||
}
|
||||
}
|
||||
async function addProductPrice() {
|
||||
if (!selectedCustomer || !newPrice.product_id) return toast.error('Choose a product.');
|
||||
try {
|
||||
custPricing = await api.orderingAdmin.setProductPrice(selectedCustomer.id, {
|
||||
product_id: Number(newPrice.product_id),
|
||||
unit_price: newPrice.rule_type === 'quote' || newPrice.unit_price === '' ? null : Number(newPrice.unit_price),
|
||||
rule_type: newPrice.rule_type
|
||||
});
|
||||
toast.success('Customer price saved.');
|
||||
newPrice = { product_id: '', unit_price: '', rule_type: 'fixed' };
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not save price.');
|
||||
}
|
||||
}
|
||||
async function removeProductPrice(productId: number) {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
await api.orderingAdmin.deleteProductPrice(selectedCustomer.id, productId);
|
||||
custPricing = await api.orderingAdmin.pricing(selectedCustomer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not remove price.');
|
||||
}
|
||||
}
|
||||
async function toggleVisibility(row: CustomerVisibilityRow) {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
await api.orderingAdmin.setVisibility(selectedCustomer.id, { product_id: row.product_id, visible: !row.visible });
|
||||
custVisibility = await api.orderingAdmin.visibility(selectedCustomer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
function productName(id: number) {
|
||||
return products.find((p) => p.id === id)?.name ?? `#${id}`;
|
||||
}
|
||||
|
||||
// --- Settings -------------------------------------------------------------
|
||||
let settings = $state<OrderingNotificationSettings | null>(null);
|
||||
async function loadSettings() {
|
||||
try {
|
||||
settings = await api.orderingAdmin.notificationSettings();
|
||||
xero = await api.orderingAdmin.xeroStatus();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load settings.');
|
||||
}
|
||||
}
|
||||
async function saveSettings() {
|
||||
if (!settings) return;
|
||||
try {
|
||||
settings = await api.orderingAdmin.updateNotificationSettings(settings);
|
||||
toast.success('Settings saved.');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not save settings.');
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
if (tab === 'settings' && !settings) loadSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="admin-ordering">
|
||||
<header>
|
||||
<p class="eyebrow">Ordering</p>
|
||||
<h1>Order management</h1>
|
||||
</header>
|
||||
<section class="surface-card">
|
||||
<div class="card-head">
|
||||
<h2>Orders ({orders.length})</h2>
|
||||
</div>
|
||||
{#if !orders.length}
|
||||
<p class="empty">No submitted orders.</p>
|
||||
{:else}
|
||||
<table class="clickable">
|
||||
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Subtotal</th><th>Xero</th></tr></thead>
|
||||
<tbody>
|
||||
{#each orders as o (o.id)}
|
||||
<tr class:selected={selectedOrder?.id === o.id} onclick={() => openOrder(o)}>
|
||||
<td>{o.order_number ?? `#${o.id}`}</td>
|
||||
<td>{o.customer_name}</td>
|
||||
<td><span class="pill {statusTone(o.status)}">{label(o.status)}</span></td>
|
||||
<td>{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
|
||||
<td>{o.xero_status ?? '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<nav class="tabs">
|
||||
<button class:active={tab === 'orders'} onclick={() => (tab = 'orders')}>Orders</button>
|
||||
<button class:active={tab === 'products'} onclick={() => (tab = 'products')}>Products</button>
|
||||
<button class:active={tab === 'customers'} onclick={() => (tab = 'customers')}>Customers & Pricing</button>
|
||||
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings & Xero</button>
|
||||
</nav>
|
||||
{#if selectedOrder}
|
||||
<div class="modal-backdrop" role="presentation" onclick={closeOrder}>
|
||||
<div
|
||||
class="modal wide detail"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Order detail"
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => { if (event.key === 'Escape') closeOrder(); }}
|
||||
>
|
||||
<h2>{selectedOrder.order_number ?? `Order #${selectedOrder.id}`}</h2>
|
||||
<p class="muted">{selectedOrder.customer_name} · {label(selectedOrder.status)} · PO {selectedOrder.purchase_order_number ?? '—'}</p>
|
||||
<table class="lines">
|
||||
<thead><tr><th>Product</th><th>Qty</th><th>Unit</th><th>Override</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
{#each selectedOrder.lines as l (l.id)}
|
||||
<tr>
|
||||
<td>{l.product_name}</td>
|
||||
<td>{l.quantity}</td>
|
||||
<td>{l.requires_quote ? 'Quote' : money(l.resolved_unit_price ?? l.unit_price)}</td>
|
||||
<td>
|
||||
<input class="ovr" type="number" step="0.01" placeholder={l.admin_override_price != null ? String(l.admin_override_price) : 'set'}
|
||||
onchange={(e) => overrideLine(l.id, e.currentTarget.value)} />
|
||||
</td>
|
||||
<td>{money(l.line_total)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="detail-total"><span>Subtotal (ex GST)</span><strong>{money(selectedOrder.subtotal_ex_gst)}</strong></div>
|
||||
|
||||
{#if tab === 'orders'}
|
||||
<div class="split">
|
||||
<section class="surface-card">
|
||||
<h2>Order queue</h2>
|
||||
{#if !orders.length}
|
||||
<p class="empty">No submitted orders.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Subtotal</th><th>Xero</th></tr></thead>
|
||||
<tbody>
|
||||
{#each orders as o (o.id)}
|
||||
<tr class:selected={selectedOrder?.id === o.id} onclick={() => openOrder(o)}>
|
||||
<td>{o.order_number ?? `#${o.id}`}</td>
|
||||
<td>{o.customer_name}</td>
|
||||
<td><span class="pill">{label(o.status)}</span></td>
|
||||
<td>{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
|
||||
<td>{o.xero_status ?? '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if selectedOrder}
|
||||
<section class="surface-card detail">
|
||||
<h2>{selectedOrder.order_number ?? `Order #${selectedOrder.id}`}</h2>
|
||||
<p class="muted">{selectedOrder.customer_name} · {label(selectedOrder.status)} · PO {selectedOrder.purchase_order_number ?? '—'}</p>
|
||||
<table class="lines">
|
||||
<thead><tr><th>Product</th><th>Qty</th><th>Unit</th><th>Override</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
{#each selectedOrder.lines as l (l.id)}
|
||||
<tr>
|
||||
<td>{l.product_name}</td>
|
||||
<td>{l.quantity}</td>
|
||||
<td>{l.requires_quote ? 'Quote' : money(l.resolved_unit_price ?? l.unit_price)}</td>
|
||||
<td>
|
||||
<input class="ovr" type="number" step="0.01" placeholder={l.admin_override_price != null ? String(l.admin_override_price) : 'set'}
|
||||
onchange={(e) => overrideLine(l.id, e.currentTarget.value)} />
|
||||
</td>
|
||||
<td>{money(l.line_total)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="detail-total"><span>Subtotal (ex GST)</span><strong>{money(selectedOrder.subtotal_ex_gst)}</strong></div>
|
||||
|
||||
<div class="actions">
|
||||
<select bind:value={statusChoice}>
|
||||
<option value="">Change status…</option>
|
||||
{#each STATUSES as s}<option value={s}>{label(s)}</option>{/each}
|
||||
</select>
|
||||
<button class="primary" onclick={applyStatus} disabled={!statusChoice}>Apply</button>
|
||||
<button class="secondary" onclick={sendToXero}>Send to Xero</button>
|
||||
<button class="secondary" onclick={reopenOrder}>Reopen</button>
|
||||
</div>
|
||||
|
||||
{#if selectedOrder.status_history?.length}
|
||||
<details class="history">
|
||||
<summary>Status history ({selectedOrder.status_history.length})</summary>
|
||||
<ul>
|
||||
{#each selectedOrder.status_history as h}
|
||||
<li>{label(h.from_status ?? 'new')} → {label(h.to_status)} · {h.actor_name ?? h.actor_type} · {new Date(h.created_at).toLocaleString('en-AU')}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<select bind:value={statusChoice}>
|
||||
<option value="">Change status…</option>
|
||||
{#each ORDER_STATUSES as s}<option value={s}>{label(s)}</option>{/each}
|
||||
</select>
|
||||
<button class="primary" onclick={applyStatus} disabled={!statusChoice}>Apply</button>
|
||||
<button class="secondary" onclick={sendToXero}>Send to Xero</button>
|
||||
<button class="secondary" onclick={reopenOrder}>Reopen</button>
|
||||
<button class="secondary" onclick={closeOrder}>Close</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'products'}
|
||||
<section class="surface-card">
|
||||
<h2>New product</h2>
|
||||
<div class="form-grid">
|
||||
<label>Name<input bind:value={newProduct.name} /></label>
|
||||
<label>SKU<input bind:value={newProduct.sku} /></label>
|
||||
<label>Category
|
||||
<select bind:value={newProduct.category}>{#each CATEGORIES as c}<option value={c}>{label(c)}</option>{/each}</select>
|
||||
</label>
|
||||
<label>Unit of measure<input bind:value={newProduct.unit_of_measure} /></label>
|
||||
<label>Min order qty<input type="number" bind:value={newProduct.min_order_quantity} /></label>
|
||||
<label>Base price (ex GST)<input type="number" step="0.01" bind:value={newProduct.base_price} /></label>
|
||||
<label class="check"><input type="checkbox" bind:checked={newProduct.requires_quote} /> Requires quote</label>
|
||||
<button class="primary" onclick={createProduct}>Create product</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface-card">
|
||||
<h2>Catalogue ({products.length})</h2>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>SKU</th><th>Category</th><th>Base price</th><th>Active</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each products as p (p.id)}
|
||||
<tr>
|
||||
<td>{p.name}{#if p.requires_quote}<span class="tag">quote</span>{/if}</td>
|
||||
<td>{p.sku}</td>
|
||||
<td>{label(p.category)}</td>
|
||||
<td><input class="inline" type="number" step="0.01" value={p.base_price ?? ''} onchange={(e) => saveProductPrice(p, e.currentTarget.value)} /></td>
|
||||
<td>{p.active ? 'Yes' : 'No'}</td>
|
||||
<td><button class="link" onclick={() => toggleProductActive(p)}>{p.active ? 'Disable' : 'Enable'}</button></td>
|
||||
</tr>
|
||||
{#if selectedOrder.status_history?.length}
|
||||
<details class="history">
|
||||
<summary>Status history ({selectedOrder.status_history.length})</summary>
|
||||
<ul>
|
||||
{#each selectedOrder.status_history as h}
|
||||
<li>{label(h.from_status ?? 'new')} → {label(h.to_status)} · {h.actor_name ?? h.actor_type} · {new Date(h.created_at).toLocaleString('en-AU')}</li>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'customers'}
|
||||
<div class="split">
|
||||
<section class="surface-card">
|
||||
<h2>New customer</h2>
|
||||
<div class="form-row">
|
||||
<input placeholder="Company name" bind:value={newCustomer.name} />
|
||||
<input placeholder="Code (e.g. ACME)" bind:value={newCustomer.client_code} />
|
||||
<button class="primary" onclick={createCustomer}>Create</button>
|
||||
</div>
|
||||
<h2 class="mt">Customers ({customers.length})</h2>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Code</th><th>Users</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each customers as c (c.id)}
|
||||
<tr class:selected={selectedCustomer?.id === c.id}>
|
||||
<td><button class="link" onclick={() => openCustomer(c)}>{c.name}</button></td>
|
||||
<td>{c.client_code}</td>
|
||||
<td>{c.user_count}</td>
|
||||
<td><span class="pill">{c.status}</span></td>
|
||||
<td><button class="link" onclick={() => toggleCustomerStatus(c)}>{c.status === 'active' ? 'Disable' : 'Enable'}</button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{#if selectedCustomer}
|
||||
<section class="surface-card detail">
|
||||
<h2>{selectedCustomer.name}</h2>
|
||||
|
||||
<h3>Users</h3>
|
||||
<ul class="mini">
|
||||
{#each custUsers as u (u.id)}
|
||||
<li>{u.full_name} · {u.email} · {u.role} · {u.status}
|
||||
<button class="link" onclick={() => toggleUserStatus(u)}>{u.status === 'suspended' ? 'Reactivate' : 'Suspend'}</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="form-row">
|
||||
<input placeholder="Full name" bind:value={newUser.full_name} />
|
||||
<input placeholder="Email" bind:value={newUser.email} />
|
||||
<select bind:value={newUser.role}>
|
||||
<option value="owner">Owner</option><option value="buyer">Buyer</option>
|
||||
<option value="accounts">Accounts</option><option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<button class="secondary" onclick={addUser}>Invite</button>
|
||||
</div>
|
||||
|
||||
<h3 class="mt">Pricing</h3>
|
||||
<div class="form-row">
|
||||
<label class="inline-label">Default discount %
|
||||
<input type="number" step="0.5" bind:value={discountInput} />
|
||||
</label>
|
||||
<button class="secondary" onclick={saveDiscount}>Save discount</button>
|
||||
</div>
|
||||
{#if custPricing?.product_prices.length}
|
||||
<ul class="mini">
|
||||
{#each custPricing.product_prices as pp (pp.id)}
|
||||
<li>{productName(pp.product_id)} · {pp.rule_type} · {pp.unit_price != null ? money(pp.unit_price) : 'quote'}
|
||||
<button class="link" onclick={() => removeProductPrice(pp.product_id)}>Remove</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<div class="form-row">
|
||||
<select bind:value={newPrice.product_id}>
|
||||
<option value="">Product…</option>
|
||||
{#each products as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={newPrice.rule_type}>
|
||||
<option value="fixed">Fixed</option><option value="contract">Contract</option><option value="quote">Quote</option>
|
||||
</select>
|
||||
<input type="number" step="0.01" placeholder="Unit price" bind:value={newPrice.unit_price} disabled={newPrice.rule_type === 'quote'} />
|
||||
<button class="secondary" onclick={addProductPrice}>Set price</button>
|
||||
</div>
|
||||
|
||||
<h3 class="mt">Product visibility</h3>
|
||||
<ul class="mini visibility">
|
||||
{#each custVisibility as row (row.product_id)}
|
||||
<li>
|
||||
<label class="check"><input type="checkbox" checked={row.visible} onchange={() => toggleVisibility(row)} /> {row.name}</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'settings'}
|
||||
<div class="split">
|
||||
<section class="surface-card">
|
||||
<h2>Notification settings</h2>
|
||||
{#if settings}
|
||||
<div class="form-grid">
|
||||
<label class="full">Internal recipients (comma separated)<input bind:value={settings.internal_recipients} /></label>
|
||||
<label class="full">From email<input bind:value={settings.from_email} /></label>
|
||||
<label class="check"><input type="checkbox" bind:checked={settings.send_customer_confirmation} /> Send customer confirmation</label>
|
||||
<label class="check"><input type="checkbox" bind:checked={settings.require_po_number} /> Require PO number on submit</label>
|
||||
<button class="primary" onclick={saveSettings}>Save settings</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">Loading…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="surface-card">
|
||||
<h2>Xero integration</h2>
|
||||
{#if xero}
|
||||
<p class="muted">Mode: <strong>{xero.connection.mode}</strong> · {xero.connection.configured ? 'Configured' : 'Not configured (stub mode)'}</p>
|
||||
{#if xero.connection.missing_env.length}
|
||||
<p class="muted">Missing env: {xero.connection.missing_env.join(', ')}</p>
|
||||
{/if}
|
||||
<h3>Recent syncs</h3>
|
||||
{#if !xero.recent_syncs.length}
|
||||
<p class="empty">No Xero submissions yet.</p>
|
||||
{:else}
|
||||
<ul class="mini">
|
||||
{#each xero.recent_syncs as s (s.id)}
|
||||
<li>Order {s.order_id} · {s.status} · {s.xero_invoice_id ?? '—'} · {new Date(s.created_at).toLocaleString('en-AU')}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="empty">Loading…</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.admin-ordering { display: grid; gap: 1rem; }
|
||||
h1 { margin: 0.15rem 0; font-size: 1.4rem; }
|
||||
h2 { margin: 0 0 0.7rem; font-size: 1.05rem; }
|
||||
h3 { margin: 0 0 0.4rem; font-size: 0.92rem; }
|
||||
.mt { margin-top: 1rem; }
|
||||
.eyebrow { color: #6e8576; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.muted { color: #64776b; font-size: 0.84rem; margin: 0 0 0.6rem; }
|
||||
.surface-card { border: 1px solid rgba(34,54,45,0.12); border-radius: 1rem; background: rgba(255,255,255,0.92); padding: 1.1rem; }
|
||||
.tabs { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.tabs button { padding: 0.45rem 0.9rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.7rem; background: transparent; cursor: pointer; font-weight: 600; font-size: 0.86rem; }
|
||||
.tabs button.active { background: var(--color-brand, #2f6f4f); color: #fff; border-color: transparent; }
|
||||
.split { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 1rem; align-items: start; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
th, td { text-align: left; padding: 0.45rem 0.55rem; border-bottom: 1px solid rgba(34,54,45,0.08); }
|
||||
th { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: #7c8c82; }
|
||||
tbody tr { cursor: pointer; }
|
||||
tbody tr.selected { background: rgba(47,111,79,0.08); }
|
||||
.pill { padding: 0.16rem 0.5rem; border-radius: 999px; font-size: 0.72rem; font-weight: 600; background: rgba(34,54,45,0.08); }
|
||||
.tag { margin-left: 0.4rem; font-size: 0.64rem; padding: 0.05rem 0.35rem; border-radius: 999px; background: #fdf0d5; color: #8a5a00; }
|
||||
.empty { color: #7c8c82; font-size: 0.85rem; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; align-items: end; }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
.form-grid label, .form-row label { display: grid; gap: 0.2rem; font-size: 0.76rem; color: #64776b; }
|
||||
.form-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.6rem; }
|
||||
.form-row input, .form-row select, .form-grid input, .form-grid select { padding: 0.45rem 0.55rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.55rem; font: inherit; }
|
||||
.check { display: flex; flex-direction: row; align-items: center; gap: 0.4rem; }
|
||||
.inline-label { flex-direction: row; align-items: center; gap: 0.45rem; }
|
||||
.inline { width: 6rem; padding: 0.3rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.45rem; }
|
||||
.ovr { width: 5rem; padding: 0.3rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.45rem; }
|
||||
.primary, .secondary { border-radius: 0.6rem; padding: 0.5rem 0.9rem; font-weight: 600; cursor: pointer; border: none; }
|
||||
.primary { background: var(--color-brand, #2f6f4f); color: #fff; }
|
||||
.secondary { background: rgba(34,54,45,0.08); color: #22362d; }
|
||||
.link { background: none; border: none; color: var(--color-brand, #2f6f4f); cursor: pointer; font-size: 0.8rem; padding: 0; }
|
||||
.lines td { font-size: 0.82rem; }
|
||||
.detail-total { display: flex; justify-content: space-between; padding: 0.5rem 0; border-top: 1px solid rgba(34,54,45,0.12); margin: 0.4rem 0; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
|
||||
.actions select { padding: 0.45rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.55rem; }
|
||||
.history { margin-top: 0.8rem; font-size: 0.8rem; }
|
||||
.mini { list-style: none; margin: 0.3rem 0 0.6rem; padding: 0; display: grid; gap: 0.3rem; font-size: 0.82rem; }
|
||||
.mini li { display: flex; gap: 0.5rem; align-items: center; justify-content: space-between; }
|
||||
.visibility li { justify-content: flex-start; }
|
||||
@media (max-width: 1000px) { .split, .form-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
import { canManageOrdering, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||
|
||||
const EMPTY = { orders: [], products: [], customers: [], xero: null } as const;
|
||||
import type { Order } from '$lib/types';
|
||||
|
||||
// Orders queue. Access is already enforced by the family +layout.ts guard.
|
||||
export async function load({ fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return { ...EMPTY };
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
if (!canManageOrdering(session)) {
|
||||
// Customers (or anyone without manage rights) don't belong here.
|
||||
throw redirect(307, getWorkspaceHomeHref(session));
|
||||
return { orders: [] as Order[] };
|
||||
}
|
||||
|
||||
try {
|
||||
const [orders, products, customers, xero] = await Promise.all([
|
||||
api.orderingAdmin.orders(undefined, fetch),
|
||||
api.orderingAdmin.products(fetch),
|
||||
api.orderingAdmin.customers(fetch),
|
||||
api.orderingAdmin.xeroStatus(fetch)
|
||||
]);
|
||||
return { orders, products, customers, xero };
|
||||
const orders = await api.orderingAdmin.orders(undefined, fetch);
|
||||
return { orders };
|
||||
} catch {
|
||||
return { ...EMPTY };
|
||||
return { orders: [] as Order[] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import { statusTone } from '$lib/ordering/format';
|
||||
import type { CustomerVisibilityRow, OrderingCustomer, OrderingCustomerUser } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let customers = $state<OrderingCustomer[]>([]);
|
||||
$effect(() => {
|
||||
customers = data.customers ?? [];
|
||||
});
|
||||
|
||||
let newCustomer = $state({ name: '', client_code: '' });
|
||||
let showNewCustomer = $state(false);
|
||||
let newCustomerNameInput: HTMLInputElement | null = $state(null);
|
||||
|
||||
function openNewCustomer() {
|
||||
newCustomer = { name: '', client_code: '' };
|
||||
showNewCustomer = true;
|
||||
}
|
||||
function closeNewCustomer() {
|
||||
showNewCustomer = false;
|
||||
}
|
||||
$effect(() => {
|
||||
if (showNewCustomer) tick().then(() => newCustomerNameInput?.focus());
|
||||
});
|
||||
let selectedCustomer = $state<OrderingCustomer | null>(null);
|
||||
let custUsers = $state<OrderingCustomerUser[]>([]);
|
||||
let custVisibility = $state<CustomerVisibilityRow[]>([]);
|
||||
let newUser = $state({ full_name: '', email: '', role: 'buyer' });
|
||||
|
||||
async function refreshCustomers() {
|
||||
try {
|
||||
customers = await api.orderingAdmin.customers();
|
||||
} catch {}
|
||||
}
|
||||
async function createCustomer() {
|
||||
if (!newCustomer.name || !newCustomer.client_code) return toast.error('Name and code are required.');
|
||||
try {
|
||||
await api.orderingAdmin.createCustomer(newCustomer);
|
||||
toast.success('Customer created.');
|
||||
newCustomer = { name: '', client_code: '' };
|
||||
showNewCustomer = false;
|
||||
await refreshCustomers();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not create customer.');
|
||||
}
|
||||
}
|
||||
async function openCustomer(c: OrderingCustomer) {
|
||||
selectedCustomer = c;
|
||||
try {
|
||||
[custUsers, custVisibility] = await Promise.all([
|
||||
api.orderingAdmin.customerUsers(c.id),
|
||||
api.orderingAdmin.visibility(c.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load customer.');
|
||||
}
|
||||
}
|
||||
async function toggleCustomerStatus(c: OrderingCustomer) {
|
||||
try {
|
||||
const updated = await api.orderingAdmin.updateCustomer(c.id, { status: c.status === 'active' ? 'disabled' : 'active' });
|
||||
toast.success(`Customer ${updated.status}.`);
|
||||
await refreshCustomers();
|
||||
if (selectedCustomer?.id === c.id) selectedCustomer = updated;
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
async function addUser() {
|
||||
if (!selectedCustomer) return;
|
||||
if (!newUser.full_name || !newUser.email) return toast.error('Name and email required.');
|
||||
try {
|
||||
await api.orderingAdmin.createCustomerUser(selectedCustomer.id, newUser);
|
||||
toast.success('User invited.');
|
||||
newUser = { full_name: '', email: '', role: 'buyer' };
|
||||
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
|
||||
await refreshCustomers();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not add user.');
|
||||
}
|
||||
}
|
||||
async function toggleUserStatus(u: OrderingCustomerUser) {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
const next = u.status === 'suspended' ? 'active' : 'suspended';
|
||||
await api.orderingAdmin.updateCustomerUser(selectedCustomer.id, u.id, { status: next });
|
||||
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
async function toggleVisibility(row: CustomerVisibilityRow) {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
await api.orderingAdmin.setVisibility(selectedCustomer.id, { product_id: row.product_id, visible: !row.visible });
|
||||
custVisibility = await api.orderingAdmin.visibility(selectedCustomer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="surface-card">
|
||||
<div class="card-head">
|
||||
<h2>Customers ({customers.length})</h2>
|
||||
<button class="primary" onclick={openNewCustomer}>New customer</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Code</th><th>Users</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each customers as c (c.id)}
|
||||
<tr class:selected={selectedCustomer?.id === c.id}>
|
||||
<td><button class="link" onclick={() => openCustomer(c)}>{c.name}</button></td>
|
||||
<td>{c.client_code}</td>
|
||||
<td>{c.user_count}</td>
|
||||
<td><span class="pill {statusTone(c.status)}">{c.status}</span></td>
|
||||
<td><button class="link" onclick={() => toggleCustomerStatus(c)}>{c.status === 'active' ? 'Disable' : 'Enable'}</button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{#if selectedCustomer}
|
||||
<section class="surface-card detail">
|
||||
<h2>{selectedCustomer.name}</h2>
|
||||
|
||||
<h3>Users</h3>
|
||||
<ul class="mini">
|
||||
{#each custUsers as u (u.id)}
|
||||
<li>{u.full_name} · {u.email} · {u.role} · {u.status}
|
||||
<button class="link" onclick={() => toggleUserStatus(u)}>{u.status === 'suspended' ? 'Reactivate' : 'Suspend'}</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="form-row">
|
||||
<input placeholder="Full name" bind:value={newUser.full_name} />
|
||||
<input placeholder="Email" bind:value={newUser.email} />
|
||||
<select bind:value={newUser.role}>
|
||||
<option value="owner">Owner</option><option value="buyer">Buyer</option>
|
||||
<option value="accounts">Accounts</option><option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<button class="secondary" onclick={addUser}>Invite</button>
|
||||
</div>
|
||||
|
||||
<h3 class="mt">Product visibility</h3>
|
||||
<ul class="mini visibility">
|
||||
{#each custVisibility as row (row.product_id)}
|
||||
<li>
|
||||
<label class="check"><input type="checkbox" checked={row.visible} onchange={() => toggleVisibility(row)} /> {row.name}</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<p class="muted mt">Manage discounts and per-product pricing for this customer on the <a href="/ordering/manage/pricing">Pricing</a> page.</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if showNewCustomer}
|
||||
<div class="modal-backdrop" role="presentation" onclick={closeNewCustomer}>
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="New customer"
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => { if (event.key === 'Escape') closeNewCustomer(); }}
|
||||
>
|
||||
<h2>New customer</h2>
|
||||
<div class="form-grid">
|
||||
<label class="full">Company name<input bind:this={newCustomerNameInput} bind:value={newCustomer.name} /></label>
|
||||
<label class="full">Client code<input placeholder="e.g. ACME" bind:value={newCustomer.client_code} /></label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="secondary" onclick={closeNewCustomer}>Cancel</button>
|
||||
<button class="primary" onclick={createCustomer}>Create customer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
import type { OrderingCustomer } from '$lib/types';
|
||||
|
||||
// Customer accounts. Access enforced by the family +layout.ts guard.
|
||||
export async function load({ fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return { customers: [] as OrderingCustomer[] };
|
||||
}
|
||||
|
||||
try {
|
||||
const customers = await api.orderingAdmin.customers(fetch);
|
||||
return { customers };
|
||||
} catch {
|
||||
return { customers: [] as OrderingCustomer[] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { Plug } from 'lucide-svelte';
|
||||
|
||||
// Connected systems. Each integration has its own page nested under this one
|
||||
// (and a matching submenu entry in the rail).
|
||||
const integrations = [
|
||||
{
|
||||
href: '/ordering/manage/integrations/xero',
|
||||
name: 'Xero',
|
||||
description: 'Send confirmed order invoices to Xero and map customers to their Xero contact.'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<section class="surface-card">
|
||||
<h2>Integrations</h2>
|
||||
<p class="muted">Connect the ordering portal to the systems you already use.</p>
|
||||
<ul class="integration-list">
|
||||
{#each integrations as it (it.href)}
|
||||
<li>
|
||||
<a class="integration" href={it.href}>
|
||||
<span class="ico"><Plug size={18} strokeWidth={1.75} /></span>
|
||||
<span class="body">
|
||||
<span class="name">{it.name}</span>
|
||||
<span class="desc">{it.description}</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.integration-list { list-style: none; margin: 0.5rem 0 0; padding: 0; display: grid; gap: 0.6rem; }
|
||||
.integration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--panel-soft);
|
||||
transition: border-color 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
.integration:hover { border-color: var(--color-brand); background: var(--color-surface-hover); }
|
||||
.ico {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-brand);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.body { display: grid; gap: 0.15rem; min-width: 0; }
|
||||
.name { font-weight: 700; font-size: 0.95rem; color: var(--color-text-primary); }
|
||||
.desc { font-size: 0.82rem; color: var(--color-text-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
// Integrations landing. Individual integrations (Xero) load their own data on
|
||||
// their nested routes. Access enforced by the family +layout.ts guard.
|
||||
export function load() {
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import type { XeroContact, XeroContactLinkRow, XeroStatus } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let xero = $state<XeroStatus | null>(null);
|
||||
let contacts = $state<XeroContact[]>([]);
|
||||
let contactsStubbed = $state(false);
|
||||
let links = $state<XeroContactLinkRow[]>([]);
|
||||
// The contact selected in each customer's dropdown, keyed by customer id. Seeded
|
||||
// from the saved link or the server's suggested match so the operator usually
|
||||
// just confirms.
|
||||
let choice = $state<Record<number, string>>({});
|
||||
|
||||
// Hydrate local state from the loader. Read only from `data` here — referencing
|
||||
// a writable state we also assign (e.g. `links`) inside the same effect would
|
||||
// retrigger it forever (svelte effect_update_depth_exceeded).
|
||||
$effect(() => {
|
||||
const nextLinks = data.links ?? [];
|
||||
xero = data.xero ?? null;
|
||||
contacts = data.contacts ?? [];
|
||||
contactsStubbed = data.contactsStubbed ?? false;
|
||||
links = nextLinks;
|
||||
choice = Object.fromEntries(
|
||||
nextLinks.map((l) => [l.customer_id, l.xero_contact_id ?? l.suggested_contact_id ?? ''])
|
||||
);
|
||||
});
|
||||
|
||||
const linkedCount = $derived(links.filter((l) => l.linked).length);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [status, list, rows] = await Promise.all([
|
||||
api.orderingAdmin.xeroStatus(),
|
||||
api.orderingAdmin.xeroContacts(),
|
||||
api.orderingAdmin.xeroContactLinks()
|
||||
]);
|
||||
xero = status;
|
||||
contacts = list.contacts;
|
||||
contactsStubbed = list.stubbed;
|
||||
links = rows;
|
||||
choice = Object.fromEntries(
|
||||
rows.map((l) => [l.customer_id, l.xero_contact_id ?? l.suggested_contact_id ?? ''])
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function saveLink(row: XeroContactLinkRow) {
|
||||
const contactId = choice[row.customer_id];
|
||||
if (!contactId) return toast.error('Choose a Xero contact first.');
|
||||
const contact = contacts.find((c) => c.contact_id === contactId);
|
||||
try {
|
||||
await api.orderingAdmin.linkCustomerToXero(row.customer_id, {
|
||||
xero_contact_id: contactId,
|
||||
xero_contact_name: contact?.name ?? null,
|
||||
xero_contact_email: contact?.email ?? null
|
||||
});
|
||||
toast.success(`${row.customer_name} linked to ${contact?.name ?? 'Xero contact'}.`);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not link customer.');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLink(row: XeroContactLinkRow) {
|
||||
try {
|
||||
await api.orderingAdmin.unlinkCustomerFromXero(row.customer_id);
|
||||
toast.success(`${row.customer_name} unlinked.`);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not unlink customer.');
|
||||
}
|
||||
}
|
||||
|
||||
function isDirty(row: XeroContactLinkRow): boolean {
|
||||
const sel = choice[row.customer_id] ?? '';
|
||||
return !!sel && sel !== (row.xero_contact_id ?? '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="surface-card">
|
||||
<h2>Xero connection</h2>
|
||||
{#if xero}
|
||||
<p class="muted">Mode: <strong>{xero.connection.mode}</strong> · {xero.connection.configured ? 'Configured' : 'Not configured (stub mode)'}</p>
|
||||
{#if xero.connection.missing_env.length}
|
||||
<p class="muted">Missing env: {xero.connection.missing_env.join(', ')}</p>
|
||||
{/if}
|
||||
<p class="muted">
|
||||
Customers linked to a Xero contact:
|
||||
<strong>{xero.contact_links.linked}</strong> of {xero.contact_links.total}
|
||||
{#if xero.contact_links.unlinked}· <span class="warn-text">{xero.contact_links.unlinked} unlinked</span>{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="empty">Could not load integration status.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="surface-card">
|
||||
<div class="card-head">
|
||||
<h2>Customer ↔ Xero contact mapping</h2>
|
||||
<span class="count">{linkedCount}/{links.length} linked</span>
|
||||
</div>
|
||||
<p class="muted">
|
||||
Link each customer in our database to its contact in Xero. Once linked, that
|
||||
customer's order invoices are raised against the matched Xero contact instead
|
||||
of being matched by code.
|
||||
{#if contactsStubbed}<br />Showing <strong>sample</strong> Xero contacts — live contacts appear once Xero credentials are configured.{/if}
|
||||
</p>
|
||||
|
||||
{#if !links.length}
|
||||
<p class="empty">No customers yet.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Customer</th><th>Code</th><th>Xero contact</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each links as row (row.customer_id)}
|
||||
<tr>
|
||||
<td>{row.customer_name}</td>
|
||||
<td>{row.client_code}</td>
|
||||
<td>
|
||||
<select bind:value={choice[row.customer_id]}>
|
||||
<option value="">— Not linked —</option>
|
||||
{#each contacts as c (c.contact_id)}
|
||||
<option value={c.contact_id}>{c.name}{c.email ? ` (${c.email})` : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if !row.linked && row.suggested_contact_id}
|
||||
<span class="hint">suggested match</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if row.linked}
|
||||
<span class="pill pos">Linked</span>
|
||||
{:else}
|
||||
<span class="pill warn">Unlinked</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<button
|
||||
class="primary sm"
|
||||
onclick={() => saveLink(row)}
|
||||
disabled={!choice[row.customer_id] || (row.linked && !isDirty(row))}
|
||||
>
|
||||
{row.linked ? 'Update' : 'Link'}
|
||||
</button>
|
||||
{#if row.linked}
|
||||
<button class="link" onclick={() => removeLink(row)}>Unlink</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="surface-card">
|
||||
<h2>Recent syncs</h2>
|
||||
{#if !xero || !xero.recent_syncs.length}
|
||||
<p class="empty">No Xero submissions yet.</p>
|
||||
{:else}
|
||||
<ul class="mini">
|
||||
{#each xero.recent_syncs as s (s.id)}
|
||||
<li>Order {s.order_id} · {s.status} · {s.xero_invoice_id ?? '—'} · {new Date(s.created_at).toLocaleString('en-AU')}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.count { font-size: 0.78rem; font-weight: 600; color: var(--color-text-muted); }
|
||||
.row-actions { display: flex; align-items: center; gap: 0.6rem; white-space: nowrap; }
|
||||
.primary.sm { min-height: 1.9rem; padding: 0.3rem 0.7rem; font-size: 0.8rem; }
|
||||
.hint { display: block; margin-top: 0.2rem; font-size: 0.68rem; color: var(--color-info); }
|
||||
.warn-text { color: var(--color-warning-text); font-weight: 600; }
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
import type { XeroContact, XeroContactLinkRow, XeroStatus } from '$lib/types';
|
||||
|
||||
// Xero integration status + customer→contact mapping. Access enforced by the
|
||||
// family +layout.ts guard.
|
||||
export async function load({ fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return {
|
||||
xero: null as XeroStatus | null,
|
||||
contacts: [] as XeroContact[],
|
||||
contactsStubbed: false,
|
||||
links: [] as XeroContactLinkRow[]
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const [xero, contactList, links] = await Promise.all([
|
||||
api.orderingAdmin.xeroStatus(fetch),
|
||||
api.orderingAdmin.xeroContacts(fetch),
|
||||
api.orderingAdmin.xeroContactLinks(fetch)
|
||||
]);
|
||||
return {
|
||||
xero,
|
||||
contacts: contactList.contacts,
|
||||
contactsStubbed: contactList.stubbed,
|
||||
links
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
xero: null as XeroStatus | null,
|
||||
contacts: [] as XeroContact[],
|
||||
contactsStubbed: false,
|
||||
links: [] as XeroContactLinkRow[]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import { money } from '$lib/ordering/format';
|
||||
import type { CatalogueProduct, CustomerPricing, OrderingCustomer } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let customers = $state<OrderingCustomer[]>([]);
|
||||
let products = $state<CatalogueProduct[]>([]);
|
||||
$effect(() => {
|
||||
customers = data.customers ?? [];
|
||||
products = data.products ?? [];
|
||||
});
|
||||
|
||||
let selectedId = $state('');
|
||||
let selectedCustomer = $derived(customers.find((c) => String(c.id) === selectedId) ?? null);
|
||||
let custPricing = $state<CustomerPricing | null>(null);
|
||||
let discountInput = $state(0);
|
||||
let newPrice = $state<Record<string, any>>({ product_id: '', unit_price: '', rule_type: 'fixed' });
|
||||
|
||||
async function loadPricing() {
|
||||
custPricing = null;
|
||||
if (!selectedCustomer) return;
|
||||
discountInput = selectedCustomer.discount_percent;
|
||||
try {
|
||||
custPricing = await api.orderingAdmin.pricing(selectedCustomer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load pricing.');
|
||||
}
|
||||
}
|
||||
async function saveDiscount() {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
custPricing = await api.orderingAdmin.setAssignment(selectedCustomer.id, { price_list_id: custPricing?.price_list_id ?? null, discount_percent: Number(discountInput) });
|
||||
toast.success('Discount saved.');
|
||||
customers = await api.orderingAdmin.customers();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not save discount.');
|
||||
}
|
||||
}
|
||||
async function addProductPrice() {
|
||||
if (!selectedCustomer || !newPrice.product_id) return toast.error('Choose a product.');
|
||||
try {
|
||||
custPricing = await api.orderingAdmin.setProductPrice(selectedCustomer.id, {
|
||||
product_id: Number(newPrice.product_id),
|
||||
unit_price: newPrice.rule_type === 'quote' || newPrice.unit_price === '' ? null : Number(newPrice.unit_price),
|
||||
rule_type: newPrice.rule_type
|
||||
});
|
||||
toast.success('Customer price saved.');
|
||||
newPrice = { product_id: '', unit_price: '', rule_type: 'fixed' };
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not save price.');
|
||||
}
|
||||
}
|
||||
async function removeProductPrice(productId: number) {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
await api.orderingAdmin.deleteProductPrice(selectedCustomer.id, productId);
|
||||
custPricing = await api.orderingAdmin.pricing(selectedCustomer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not remove price.');
|
||||
}
|
||||
}
|
||||
function productName(id: number) {
|
||||
return products.find((p) => p.id === id)?.name ?? `#${id}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="surface-card">
|
||||
<h2>Customer pricing</h2>
|
||||
<div class="form-row">
|
||||
<label class="inline-label">Customer
|
||||
<select bind:value={selectedId} onchange={loadPricing}>
|
||||
<option value="">Select a customer…</option>
|
||||
{#each customers as c (c.id)}<option value={String(c.id)}>{c.name} ({c.client_code})</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if !selectedCustomer}
|
||||
<p class="empty">Choose a customer to view and edit their discount and per-product prices.</p>
|
||||
{:else}
|
||||
<h3 class="mt">Default discount</h3>
|
||||
<div class="form-row">
|
||||
<label class="inline-label">Default discount %
|
||||
<input type="number" step="0.5" bind:value={discountInput} />
|
||||
</label>
|
||||
<button class="secondary" onclick={saveDiscount}>Save discount</button>
|
||||
</div>
|
||||
|
||||
<h3 class="mt">Per-product prices</h3>
|
||||
{#if custPricing?.product_prices.length}
|
||||
<ul class="mini">
|
||||
{#each custPricing.product_prices as pp (pp.id)}
|
||||
<li>{productName(pp.product_id)} · {pp.rule_type} · {pp.unit_price != null ? money(pp.unit_price) : 'quote'}
|
||||
<button class="link" onclick={() => removeProductPrice(pp.product_id)}>Remove</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty">No product-specific prices. The default discount applies to base prices.</p>
|
||||
{/if}
|
||||
<div class="form-row">
|
||||
<select bind:value={newPrice.product_id}>
|
||||
<option value="">Product…</option>
|
||||
{#each products as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={newPrice.rule_type}>
|
||||
<option value="fixed">Fixed</option><option value="contract">Contract</option><option value="quote">Quote</option>
|
||||
</select>
|
||||
<input type="number" step="0.01" placeholder="Unit price" bind:value={newPrice.unit_price} disabled={newPrice.rule_type === 'quote'} />
|
||||
<button class="secondary" onclick={addProductPrice}>Set price</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
import type { CatalogueProduct, OrderingCustomer } from '$lib/types';
|
||||
|
||||
// Pricing needs the customer list (to choose whose pricing to edit) and the
|
||||
// product catalogue (for the price-rule product picker). Per-customer pricing
|
||||
// itself is loaded client-side once a customer is selected.
|
||||
export async function load({ fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return { customers: [] as OrderingCustomer[], products: [] as CatalogueProduct[] };
|
||||
}
|
||||
|
||||
try {
|
||||
const [customers, products] = await Promise.all([
|
||||
api.orderingAdmin.customers(fetch),
|
||||
api.orderingAdmin.products(fetch)
|
||||
]);
|
||||
return { customers, products };
|
||||
} catch {
|
||||
return { customers: [] as OrderingCustomer[], products: [] as CatalogueProduct[] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import { label, PRODUCT_CATEGORIES } from '$lib/ordering/format';
|
||||
import type { CatalogueProduct } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let products = $state<CatalogueProduct[]>([]);
|
||||
$effect(() => {
|
||||
products = data.products ?? [];
|
||||
});
|
||||
|
||||
const blankProduct = () => ({ name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null as number | null, requires_quote: false, active: true });
|
||||
let newProduct = $state<Record<string, any>>(blankProduct());
|
||||
let showNewProduct = $state(false);
|
||||
let newProductNameInput: HTMLInputElement | null = $state(null);
|
||||
|
||||
function openNewProduct() {
|
||||
newProduct = blankProduct();
|
||||
showNewProduct = true;
|
||||
}
|
||||
function closeNewProduct() {
|
||||
showNewProduct = false;
|
||||
}
|
||||
$effect(() => {
|
||||
if (showNewProduct) tick().then(() => newProductNameInput?.focus());
|
||||
});
|
||||
|
||||
async function refreshProducts() {
|
||||
try {
|
||||
products = await api.orderingAdmin.products();
|
||||
} catch {}
|
||||
}
|
||||
async function createProduct() {
|
||||
if (!newProduct.name || !newProduct.sku) return toast.error('Name and SKU are required.');
|
||||
try {
|
||||
await api.orderingAdmin.createProduct({ ...newProduct, base_price: newProduct.base_price === null || newProduct.base_price === '' ? null : Number(newProduct.base_price) });
|
||||
toast.success('Product created.');
|
||||
newProduct = blankProduct();
|
||||
showNewProduct = false;
|
||||
await refreshProducts();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not create product.');
|
||||
}
|
||||
}
|
||||
async function toggleProductActive(p: CatalogueProduct) {
|
||||
try {
|
||||
await api.orderingAdmin.updateProduct(p.id, { active: !p.active });
|
||||
await refreshProducts();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
async function saveProductPrice(p: CatalogueProduct, value: string) {
|
||||
try {
|
||||
await api.orderingAdmin.updateProduct(p.id, { base_price: value === '' ? null : Number(value) });
|
||||
toast.success('Base price updated.');
|
||||
await refreshProducts();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="surface-card">
|
||||
<div class="card-head">
|
||||
<h2>Catalogue ({products.length})</h2>
|
||||
<button class="primary" onclick={openNewProduct}>New product</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>SKU</th><th>Category</th><th>Base price</th><th>Active</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each products as p (p.id)}
|
||||
<tr>
|
||||
<td>{p.name}{#if p.requires_quote}<span class="tag">quote</span>{/if}</td>
|
||||
<td>{p.sku}</td>
|
||||
<td>{label(p.category)}</td>
|
||||
<td><input class="inline" type="number" step="0.01" value={p.base_price ?? ''} onchange={(e) => saveProductPrice(p, e.currentTarget.value)} /></td>
|
||||
<td>{p.active ? 'Yes' : 'No'}</td>
|
||||
<td><button class="link" onclick={() => toggleProductActive(p)}>{p.active ? 'Disable' : 'Enable'}</button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{#if showNewProduct}
|
||||
<div class="modal-backdrop" role="presentation" onclick={closeNewProduct}>
|
||||
<div
|
||||
class="modal wide"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="New product"
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => { if (event.key === 'Escape') closeNewProduct(); }}
|
||||
>
|
||||
<h2>New product</h2>
|
||||
<div class="form-grid">
|
||||
<label>Name<input bind:this={newProductNameInput} bind:value={newProduct.name} /></label>
|
||||
<label>SKU<input bind:value={newProduct.sku} /></label>
|
||||
<label>Category
|
||||
<select bind:value={newProduct.category}>{#each PRODUCT_CATEGORIES as c}<option value={c}>{label(c)}</option>{/each}</select>
|
||||
</label>
|
||||
<label>Unit of measure<input bind:value={newProduct.unit_of_measure} /></label>
|
||||
<label>Min order qty<input type="number" bind:value={newProduct.min_order_quantity} /></label>
|
||||
<label>Base price (ex GST)<input type="number" step="0.01" bind:value={newProduct.base_price} /></label>
|
||||
<label class="check full"><input type="checkbox" bind:checked={newProduct.requires_quote} /> Requires quote</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="secondary" onclick={closeNewProduct}>Cancel</button>
|
||||
<button class="primary" onclick={createProduct}>Create product</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
import type { CatalogueProduct } from '$lib/types';
|
||||
|
||||
// Catalogue products. Access enforced by the family +layout.ts guard.
|
||||
export async function load({ fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return { products: [] as CatalogueProduct[] };
|
||||
}
|
||||
|
||||
try {
|
||||
const products = await api.orderingAdmin.products(fetch);
|
||||
return { products };
|
||||
} catch {
|
||||
return { products: [] as CatalogueProduct[] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import type { OrderingNotificationSettings } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let settings = $state<OrderingNotificationSettings | null>(null);
|
||||
$effect(() => {
|
||||
settings = data.settings ?? null;
|
||||
});
|
||||
|
||||
async function saveSettings() {
|
||||
if (!settings) return;
|
||||
try {
|
||||
settings = await api.orderingAdmin.updateNotificationSettings(settings);
|
||||
toast.success('Settings saved.');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not save settings.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="surface-card">
|
||||
<h2>Notification settings</h2>
|
||||
{#if settings}
|
||||
<div class="form-grid">
|
||||
<label class="full">Internal recipients (comma separated)<input bind:value={settings.internal_recipients} /></label>
|
||||
<label class="full">From email<input bind:value={settings.from_email} /></label>
|
||||
<label class="check"><input type="checkbox" bind:checked={settings.send_customer_confirmation} /> Send customer confirmation</label>
|
||||
<label class="check"><input type="checkbox" bind:checked={settings.require_po_number} /> Require PO number on submit</label>
|
||||
<button class="primary" onclick={saveSettings}>Save settings</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">Could not load settings.</p>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
import type { OrderingNotificationSettings } from '$lib/types';
|
||||
|
||||
// Notification settings. Access enforced by the family +layout.ts guard.
|
||||
export async function load({ fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return { settings: null as OrderingNotificationSettings | null };
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await api.orderingAdmin.notificationSettings(fetch);
|
||||
return { settings };
|
||||
} catch {
|
||||
return { settings: null as OrderingNotificationSettings | null };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user