v0.1.27
Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live. Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator). Fix: Security headers on all API responses (hardening) Add: New mix button available on the Mix Editor. Add: New ingredient button available on the Ingredient Editor
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { Building2, Plus } from 'lucide-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';
|
||||
import CustomerWorkspace from '$lib/components/ordering/CustomerWorkspace.svelte';
|
||||
import type { OrderingCustomer } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -12,30 +15,49 @@
|
||||
customers = data.customers ?? [];
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
|
||||
function openNewCustomer() {
|
||||
newCustomer = { name: '', client_code: '' };
|
||||
showNewCustomer = true;
|
||||
}
|
||||
function closeNewCustomer() {
|
||||
showNewCustomer = false;
|
||||
function toggleNewCustomer() {
|
||||
showNewCustomer = !showNewCustomer;
|
||||
if (showNewCustomer) newCustomer = { name: '', client_code: '' };
|
||||
}
|
||||
$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() {
|
||||
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 {}
|
||||
}
|
||||
|
||||
async function createCustomer() {
|
||||
if (!newCustomer.name || !newCustomer.client_code) return toast.error('Name and code are required.');
|
||||
try {
|
||||
@@ -48,137 +70,101 @@
|
||||
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>
|
||||
<svelte:window onkeydown={handleWindowKeydown} />
|
||||
|
||||
{#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 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>
|
||||
|
||||
<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>
|
||||
{#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}
|
||||
|
||||
<p class="muted mt">Manage discounts and per-product pricing for this customer on the <a href="/ordering/manage/pricing">Pricing</a> page.</p>
|
||||
<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>
|
||||
{/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}
|
||||
<!-- ── 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>
|
||||
|
||||
Reference in New Issue
Block a user