185 lines
6.9 KiB
Svelte
185 lines
6.9 KiB
Svelte
<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}
|