117 lines
4.6 KiB
Svelte
117 lines
4.6 KiB
Svelte
<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>
|