Files
data-entry-app/frontend/src/routes/ordering/manage/customers/+page.svelte
T

171 lines
6.0 KiB
Svelte
Raw Normal View History

<script lang="ts">
import { tick } from 'svelte';
2026-06-16 14:43:17 +12:00
import { Building2, Plus } from 'lucide-svelte';
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';
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)
);
});
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: '' };
}
$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;
try {
customers = await api.orderingAdmin.customers();
} catch {}
}
2026-06-16 14:43:17 +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-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>
</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-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}
</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>