2026-06-13 10:00:15 +12:00
|
|
|
<script lang="ts">
|
|
|
|
|
import { tick } from 'svelte';
|
2026-06-16 14:43:17 +12:00
|
|
|
import { Building2, Plus } from 'lucide-svelte';
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
import { api } from '$lib/api';
|
|
|
|
|
import { toast } from '$lib/toast';
|
|
|
|
|
import { statusTone } from '$lib/ordering/format';
|
2026-06-16 14:43:17 +12:00
|
|
|
import CustomerWorkspace from '$lib/components/ordering/CustomerWorkspace.svelte';
|
|
|
|
|
import type { OrderingCustomer } from '$lib/types';
|
2026-06-13 10:00:15 +12:00
|
|
|
|
|
|
|
|
let { data } = $props();
|
|
|
|
|
|
|
|
|
|
let customers = $state<OrderingCustomer[]>([]);
|
|
|
|
|
$effect(() => {
|
|
|
|
|
customers = data.customers ?? [];
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
// ── Customer list: search + inline create ──────────────────────────────────
|
|
|
|
|
let listQuery = $state('');
|
|
|
|
|
const filteredCustomers = $derived.by(() => {
|
|
|
|
|
const q = listQuery.trim().toLowerCase();
|
|
|
|
|
if (!q) return customers;
|
|
|
|
|
return customers.filter(
|
|
|
|
|
(c) => c.name.toLowerCase().includes(q) || c.client_code.toLowerCase().includes(q)
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
let newCustomer = $state({ name: '', client_code: '' });
|
|
|
|
|
let showNewCustomer = $state(false);
|
|
|
|
|
let newCustomerNameInput: HTMLInputElement | null = $state(null);
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
function toggleNewCustomer() {
|
|
|
|
|
showNewCustomer = !showNewCustomer;
|
|
|
|
|
if (showNewCustomer) newCustomer = { name: '', client_code: '' };
|
2026-06-13 10:00:15 +12:00
|
|
|
}
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (showNewCustomer) tick().then(() => newCustomerNameInput?.focus());
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
let selectedCustomer = $state<OrderingCustomer | null>(null);
|
|
|
|
|
|
|
|
|
|
function openCustomer(c: OrderingCustomer) {
|
|
|
|
|
selectedCustomer = c;
|
|
|
|
|
}
|
|
|
|
|
function closeDetail() {
|
|
|
|
|
selectedCustomer = null;
|
|
|
|
|
}
|
|
|
|
|
function handleWindowKeydown(event: KeyboardEvent) {
|
|
|
|
|
if (event.key !== 'Escape') return;
|
|
|
|
|
if (showNewCustomer) showNewCustomer = false;
|
|
|
|
|
else if (selectedCustomer) closeDetail();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refreshCustomers(updated?: OrderingCustomer) {
|
|
|
|
|
if (updated && selectedCustomer?.id === updated.id) selectedCustomer = updated;
|
2026-06-13 10:00:15 +12:00
|
|
|
try {
|
|
|
|
|
customers = await api.orderingAdmin.customers();
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
2026-06-16 14:43:17 +12:00
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
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.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
<svelte:window onkeydown={handleWindowKeydown} />
|
2026-06-13 10:00:15 +12:00
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
<div class="console-split">
|
|
|
|
|
<!-- ── Left: customer roster ─────────────────────────────────────────────── -->
|
|
|
|
|
<section class="surface-card">
|
|
|
|
|
<div class="card-head">
|
|
|
|
|
<h2>Customers <span class="count">{filteredCustomers.length}</span></h2>
|
|
|
|
|
<button class="primary" onclick={toggleNewCustomer}>
|
|
|
|
|
<Plus size={16} strokeWidth={2.2} aria-hidden="true" />
|
|
|
|
|
{showNewCustomer ? 'Cancel' : 'New customer'}
|
|
|
|
|
</button>
|
2026-06-13 10:00:15 +12:00
|
|
|
</div>
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
{#if showNewCustomer}
|
|
|
|
|
<div class="create-panel">
|
|
|
|
|
<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={toggleNewCustomer}>Cancel</button>
|
|
|
|
|
<button class="primary" onclick={createCustomer}>Create customer</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
2026-06-13 10:00:15 +12:00
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
<input class="list-search" type="search" placeholder="Search name or code" bind:value={listQuery} />
|
|
|
|
|
|
|
|
|
|
{#if filteredCustomers.length}
|
|
|
|
|
<table class="clickable">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr><th>Customer</th><th class="amt">Users</th><th>Status</th></tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{#each filteredCustomers as c (c.id)}
|
|
|
|
|
<tr
|
|
|
|
|
class:selected={selectedCustomer?.id === c.id}
|
|
|
|
|
tabindex="0"
|
|
|
|
|
role="button"
|
|
|
|
|
aria-pressed={selectedCustomer?.id === c.id}
|
|
|
|
|
onclick={() => openCustomer(c)}
|
|
|
|
|
onkeydown={(e) => {
|
|
|
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
openCustomer(c);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<td>
|
|
|
|
|
<div class="id-cell">
|
|
|
|
|
<span class="id-name">{c.name}</span>
|
|
|
|
|
<span class="id-sub">{c.client_code}{c.discount_percent ? ` · ${c.discount_percent}% off` : ''}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
<td class="amt">{c.user_count}</td>
|
|
|
|
|
<td><span class="pill {statusTone(c.status)}">{c.status}</span></td>
|
|
|
|
|
</tr>
|
|
|
|
|
{/each}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
{:else if customers.length}
|
|
|
|
|
<p class="empty">No customers match “{listQuery}”.</p>
|
|
|
|
|
{:else}
|
|
|
|
|
<p class="empty">No customers yet. Create one to start managing access.</p>
|
|
|
|
|
{/if}
|
2026-06-13 10:00:15 +12:00
|
|
|
</section>
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
<!-- ── Right: customer workspace ─────────────────────────────────────────── -->
|
|
|
|
|
{#if selectedCustomer}
|
|
|
|
|
<CustomerWorkspace customer={selectedCustomer} onChanged={refreshCustomers} onClose={closeDetail} />
|
|
|
|
|
{:else}
|
|
|
|
|
<section class="surface-card detail empty-detail">
|
|
|
|
|
<span class="empty-icon" aria-hidden="true"><Building2 size={26} strokeWidth={1.7} /></span>
|
|
|
|
|
<h2>Select a customer</h2>
|
|
|
|
|
<p class="muted">
|
|
|
|
|
Pick a company to open its workspace: details, people, catalogue access, orders, mixes, and history in one place.
|
|
|
|
|
</p>
|
|
|
|
|
</section>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
.amt { text-align: right; font-variant-numeric: tabular-nums; }
|
|
|
|
|
|
|
|
|
|
.primary { gap: 0.4rem; }
|
|
|
|
|
.primary :global(svg) { display: block; }
|
|
|
|
|
|
|
|
|
|
.clickable tbody tr:focus-visible {
|
|
|
|
|
outline: 2px solid var(--color-brand);
|
|
|
|
|
outline-offset: -2px;
|
|
|
|
|
}
|
|
|
|
|
</style>
|