1558 lines
43 KiB
Svelte
1558 lines
43 KiB
Svelte
|
|
<script lang="ts">
|
||
|
|
import { api } from '$lib/api';
|
||
|
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||
|
|
import { toast } from '$lib/toast';
|
||
|
|
import type { ProductCostingInputs, ProductCostingItem, ProductCostingItemUpdateInput } from '$lib/types';
|
||
|
|
import {
|
||
|
|
AlertTriangle,
|
||
|
|
RefreshCcw,
|
||
|
|
Save,
|
||
|
|
Search,
|
||
|
|
X
|
||
|
|
} from 'lucide-svelte';
|
||
|
|
|
||
|
|
type ProductCostingData = {
|
||
|
|
items: ProductCostingItem[];
|
||
|
|
inputs: ProductCostingInputs | null;
|
||
|
|
};
|
||
|
|
|
||
|
|
let { data }: { data: ProductCostingData } = $props();
|
||
|
|
|
||
|
|
function initialItems() {
|
||
|
|
return data.items ?? [];
|
||
|
|
}
|
||
|
|
|
||
|
|
function initialInputs() {
|
||
|
|
return data.inputs ? structuredClone(data.inputs) : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function initialSelectedItem() {
|
||
|
|
return data.items?.[0] ?? null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function initialEditDraft() {
|
||
|
|
const item = initialSelectedItem();
|
||
|
|
return item ? buildDraft(item) : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
let items = $state<ProductCostingItem[]>(initialItems());
|
||
|
|
let inputs = $state<ProductCostingInputs | null>(initialInputs());
|
||
|
|
let query = $state('');
|
||
|
|
let clientFilter = $state('');
|
||
|
|
let unitFilter = $state('');
|
||
|
|
let statusFilter = $state<'all' | 'ready' | 'warnings'>('all');
|
||
|
|
let activeView = $state<'items' | 'inputs'>('items');
|
||
|
|
let selectedItem = $state<ProductCostingItem | null>(initialSelectedItem());
|
||
|
|
let editDraft = $state<ProductCostingItemUpdateInput | null>(initialEditDraft());
|
||
|
|
let recalculating = $state(false);
|
||
|
|
let savingInputs = $state(false);
|
||
|
|
let savingItem = $state(false);
|
||
|
|
let inputsDirty = $state(false);
|
||
|
|
let page = $state(1);
|
||
|
|
let pageSize = $state(25);
|
||
|
|
let tableLoading = $state(false);
|
||
|
|
|
||
|
|
const unitTypes = ['Standard', 'Bulka', '1.5 kg', 'Per Unit'];
|
||
|
|
const ownBagOptions = ['', 'Yes', 'No Bag'];
|
||
|
|
const clients = $derived(Array.from(new Set(items.map((item) => item.client_category))).sort());
|
||
|
|
const processOptions = $derived(Array.from(new Set([
|
||
|
|
...(inputs?.processes.map((process) => process.key) ?? []),
|
||
|
|
...items.map((item) => item.bagging_process).filter((value): value is string => Boolean(value))
|
||
|
|
])).sort());
|
||
|
|
const warningCount = $derived(items.filter((item) => item.warnings.length).length);
|
||
|
|
const readyCount = $derived(items.length - warningCount);
|
||
|
|
const averageDelivered = $derived(
|
||
|
|
average(items.map((item) => item.finished_product_delivered_cost).filter((value): value is number => value != null))
|
||
|
|
);
|
||
|
|
const missingLookupCount = $derived(items.filter((item) => hasWarning(item, 'lookup')).length);
|
||
|
|
const visibleItems = $derived(
|
||
|
|
items.filter((item) => {
|
||
|
|
const haystack =
|
||
|
|
`${item.client_category} ${item.item_id ?? ''} ${item.product_name} ${item.mix_product_name} ${item.unit_type}`.toLowerCase();
|
||
|
|
const matchesSearch = haystack.includes(query.trim().toLowerCase());
|
||
|
|
const matchesClient = !clientFilter || item.client_category === clientFilter;
|
||
|
|
const matchesUnit = !unitFilter || item.unit_type === unitFilter;
|
||
|
|
const matchesStatus =
|
||
|
|
statusFilter === 'all' ||
|
||
|
|
(statusFilter === 'ready' && !item.warnings.length) ||
|
||
|
|
(statusFilter === 'warnings' && item.warnings.length > 0);
|
||
|
|
|
||
|
|
return matchesSearch && matchesClient && matchesUnit && matchesStatus;
|
||
|
|
})
|
||
|
|
);
|
||
|
|
const totalPages = $derived(Math.max(1, Math.ceil(visibleItems.length / pageSize)));
|
||
|
|
const pageStart = $derived(visibleItems.length ? (page - 1) * pageSize + 1 : 0);
|
||
|
|
const pageEnd = $derived(Math.min(page * pageSize, visibleItems.length));
|
||
|
|
const pagedItems = $derived(visibleItems.slice((page - 1) * pageSize, page * pageSize));
|
||
|
|
|
||
|
|
$effect(() => {
|
||
|
|
query;
|
||
|
|
clientFilter;
|
||
|
|
unitFilter;
|
||
|
|
statusFilter;
|
||
|
|
pageSize;
|
||
|
|
page = 1;
|
||
|
|
});
|
||
|
|
|
||
|
|
$effect(() => {
|
||
|
|
if (page > totalPages) page = totalPages;
|
||
|
|
});
|
||
|
|
|
||
|
|
function average(values: number[]) {
|
||
|
|
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function hasWarning(item: ProductCostingItem, pattern: string) {
|
||
|
|
return item.warnings.some((warning) => warning.toLowerCase().includes(pattern));
|
||
|
|
}
|
||
|
|
|
||
|
|
function money(value: number | null | undefined, digits = 2) {
|
||
|
|
return value == null ? 'N/A' : `$${value.toFixed(digits)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function number(value: number | null | undefined, digits = 4) {
|
||
|
|
return value == null ? 'N/A' : value.toFixed(digits);
|
||
|
|
}
|
||
|
|
|
||
|
|
function cents(value: number | null | undefined) {
|
||
|
|
return value == null ? 'No value' : `${money(value, 4)} = ${(value * 100).toFixed(2)} cents`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function margin(value: number | null | undefined) {
|
||
|
|
return value == null ? 'Default' : `${(value * 100).toFixed(2)}%`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function marginHelp(value: number | null | undefined) {
|
||
|
|
return value == null ? 'Default margin' : `${value.toFixed(4)} = ${(value * 100).toFixed(2)}%`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function initials(name: string) {
|
||
|
|
return name
|
||
|
|
.split(/\s+/)
|
||
|
|
.filter(Boolean)
|
||
|
|
.map((piece) => piece[0])
|
||
|
|
.join('')
|
||
|
|
.slice(0, 2)
|
||
|
|
.toUpperCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildDraft(item: ProductCostingItem): ProductCostingItemUpdateInput {
|
||
|
|
return {
|
||
|
|
client_category: item.client_category,
|
||
|
|
item_id: item.item_id,
|
||
|
|
product_name: item.product_name,
|
||
|
|
mix_product_name: item.mix_product_name,
|
||
|
|
unit_type: item.unit_type,
|
||
|
|
own_bag: item.own_bag,
|
||
|
|
unit_kg: item.unit_kg,
|
||
|
|
items_per_pallet: item.items_per_pallet,
|
||
|
|
bagging_process: item.bagging_process,
|
||
|
|
manual_distributor_margin: item.manual_distributor_margin,
|
||
|
|
manual_wholesale_margin: item.manual_wholesale_margin
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function selectItem(item: ProductCostingItem) {
|
||
|
|
selectedItem = item;
|
||
|
|
editDraft = buildDraft(item);
|
||
|
|
}
|
||
|
|
|
||
|
|
function clearNullableText(value: string | null | undefined) {
|
||
|
|
const trimmed = value?.trim();
|
||
|
|
return trimmed ? trimmed : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeNumber(value: number | null | undefined) {
|
||
|
|
return value === undefined || value === null || Number.isNaN(Number(value)) ? null : Number(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
function syncPerKgFromTonne(kind: 'grading' | 'cracking') {
|
||
|
|
if (!inputs) return;
|
||
|
|
inputsDirty = true;
|
||
|
|
if (kind === 'grading') {
|
||
|
|
inputs.base.grading_per_kg = Number((inputs.base.grading_per_tonne / 1000).toFixed(6));
|
||
|
|
} else {
|
||
|
|
inputs.base.cracking_per_kg = Number((inputs.base.cracking_per_tonne / 1000).toFixed(6));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function markInputsDirty() {
|
||
|
|
inputsDirty = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function goToPage(nextPage: number) {
|
||
|
|
page = Math.min(Math.max(nextPage, 1), totalPages);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveInputs() {
|
||
|
|
if (!inputs) return;
|
||
|
|
savingInputs = true;
|
||
|
|
tableLoading = true;
|
||
|
|
const tid = toast.loading('Saving Product Costing inputs...');
|
||
|
|
try {
|
||
|
|
inputs = await api.updateProductCostingInputs(inputs);
|
||
|
|
items = await api.productCostingItemsFresh();
|
||
|
|
if (selectedItem) {
|
||
|
|
selectedItem = items.find((item) => item.id === selectedItem?.id) ?? selectedItem;
|
||
|
|
editDraft = buildDraft(selectedItem);
|
||
|
|
}
|
||
|
|
inputsDirty = false;
|
||
|
|
toast.dismiss(tid);
|
||
|
|
toast.success('Inputs saved and product costs recalculated.');
|
||
|
|
} catch (error) {
|
||
|
|
toast.dismiss(tid);
|
||
|
|
toast.error(error instanceof Error ? error.message : 'Unable to save Product Costing inputs.');
|
||
|
|
} finally {
|
||
|
|
savingInputs = false;
|
||
|
|
tableLoading = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveSelectedItem() {
|
||
|
|
if (!selectedItem || !editDraft) return;
|
||
|
|
savingItem = true;
|
||
|
|
tableLoading = true;
|
||
|
|
const tid = toast.loading('Saving costing row...');
|
||
|
|
try {
|
||
|
|
const updated = await api.updateProductCostingItem(selectedItem.id, {
|
||
|
|
...editDraft,
|
||
|
|
item_id: clearNullableText(editDraft.item_id),
|
||
|
|
own_bag: clearNullableText(editDraft.own_bag),
|
||
|
|
bagging_process: clearNullableText(editDraft.bagging_process),
|
||
|
|
unit_kg: normalizeNumber(editDraft.unit_kg),
|
||
|
|
items_per_pallet: normalizeNumber(editDraft.items_per_pallet),
|
||
|
|
manual_distributor_margin: normalizeNumber(editDraft.manual_distributor_margin),
|
||
|
|
manual_wholesale_margin: normalizeNumber(editDraft.manual_wholesale_margin)
|
||
|
|
});
|
||
|
|
items = items.map((item) => (item.id === updated.id ? updated : item));
|
||
|
|
selectedItem = updated;
|
||
|
|
editDraft = buildDraft(updated);
|
||
|
|
toast.dismiss(tid);
|
||
|
|
toast.success('Costing row saved and recalculated.');
|
||
|
|
} catch (error) {
|
||
|
|
toast.dismiss(tid);
|
||
|
|
toast.error(error instanceof Error ? error.message : 'Unable to save costing row.');
|
||
|
|
} finally {
|
||
|
|
savingItem = false;
|
||
|
|
tableLoading = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function recalculateAll() {
|
||
|
|
recalculating = true;
|
||
|
|
tableLoading = true;
|
||
|
|
const tid = toast.loading('Recalculating product costs...');
|
||
|
|
try {
|
||
|
|
await api.recalculateProductCosting();
|
||
|
|
items = await api.productCostingItemsFresh();
|
||
|
|
if (selectedItem) {
|
||
|
|
selectedItem = items.find((item) => item.id === selectedItem?.id) ?? null;
|
||
|
|
editDraft = selectedItem ? buildDraft(selectedItem) : null;
|
||
|
|
}
|
||
|
|
toast.dismiss(tid);
|
||
|
|
toast.success('Product costing recalculated.');
|
||
|
|
} catch (error) {
|
||
|
|
toast.dismiss(tid);
|
||
|
|
toast.error(error instanceof Error ? error.message : 'Unable to recalculate product costing.');
|
||
|
|
} finally {
|
||
|
|
recalculating = false;
|
||
|
|
tableLoading = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<section class="costing-shell">
|
||
|
|
<header class="page-head">
|
||
|
|
<div>
|
||
|
|
<span class="eyebrow">Alpha</span>
|
||
|
|
<h2>Product Costing</h2>
|
||
|
|
<p>Check prices, fix warnings, and update costing settings.</p>
|
||
|
|
</div>
|
||
|
|
<div class="head-actions">
|
||
|
|
<button class="primary-button" disabled={recalculating} type="button" onclick={recalculateAll}>
|
||
|
|
<span class:spin={recalculating} aria-hidden="true"><RefreshCcw size={17} /></span>
|
||
|
|
{recalculating ? 'Updating' : 'Update Prices'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<section class="health-strip" aria-label="Product costing health summary">
|
||
|
|
<article class="health-card">
|
||
|
|
<span>Products</span>
|
||
|
|
<strong>{items.length}</strong>
|
||
|
|
<p>{readyCount} ready</p>
|
||
|
|
</article>
|
||
|
|
<article class="health-card warning">
|
||
|
|
<span>Need Review</span>
|
||
|
|
<strong>{warningCount}</strong>
|
||
|
|
<p>{missingLookupCount} missing cost{missingLookupCount === 1 ? '' : 's'}</p>
|
||
|
|
</article>
|
||
|
|
<article class="health-card">
|
||
|
|
<span>Average Cost</span>
|
||
|
|
<strong>{money(averageDelivered)}</strong>
|
||
|
|
<p>Delivered</p>
|
||
|
|
</article>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section class="workspace-grid">
|
||
|
|
<div class="main-column">
|
||
|
|
<nav class="view-tabs" aria-label="Product Costing views">
|
||
|
|
<button class:active={activeView === 'items'} type="button" onclick={() => (activeView = 'items')}>
|
||
|
|
Products
|
||
|
|
</button>
|
||
|
|
<button class:active={activeView === 'inputs'} type="button" onclick={() => (activeView = 'inputs')}>
|
||
|
|
Settings
|
||
|
|
</button>
|
||
|
|
</nav>
|
||
|
|
|
||
|
|
{#if activeView === 'inputs'}
|
||
|
|
<section class="panel inputs-panel">
|
||
|
|
<div class="section-toolbar">
|
||
|
|
<div>
|
||
|
|
<h3>Settings</h3>
|
||
|
|
<p>Change the standard costs used to calculate product prices.</p>
|
||
|
|
</div>
|
||
|
|
<div class="toolbar-actions">
|
||
|
|
{#if inputsDirty}
|
||
|
|
<span class="dirty-pill">Unsaved changes</span>
|
||
|
|
{/if}
|
||
|
|
<button class="primary-button compact" disabled={!inputs || savingInputs} type="button" onclick={saveInputs}>
|
||
|
|
<Save size={16} />
|
||
|
|
{savingInputs ? 'Saving' : 'Save Settings'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{#if inputs}
|
||
|
|
<div class="input-block base-costs">
|
||
|
|
<div class="block-heading">
|
||
|
|
<strong>Work Costs</strong>
|
||
|
|
<span>Enter dollars for grading and cracking.</span>
|
||
|
|
</div>
|
||
|
|
<div class="input-grid four">
|
||
|
|
<label>
|
||
|
|
<span>Grading ($/tonne)</span>
|
||
|
|
<div class="money-field">
|
||
|
|
<i>$</i>
|
||
|
|
<input type="number" step="0.0001" bind:value={inputs.base.grading_per_tonne} oninput={() => syncPerKgFromTonne('grading')} />
|
||
|
|
</div>
|
||
|
|
<small>{money(inputs.base.grading_per_tonne, 2)} per tonne</small>
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span>Grading ($/kg)</span>
|
||
|
|
<div class="money-field">
|
||
|
|
<i>$</i>
|
||
|
|
<input type="number" step="0.000001" bind:value={inputs.base.grading_per_kg} oninput={markInputsDirty} />
|
||
|
|
</div>
|
||
|
|
<small>{cents(inputs.base.grading_per_kg)} per kg</small>
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span>Cracking ($/tonne)</span>
|
||
|
|
<div class="money-field">
|
||
|
|
<i>$</i>
|
||
|
|
<input type="number" step="0.0001" bind:value={inputs.base.cracking_per_tonne} oninput={() => syncPerKgFromTonne('cracking')} />
|
||
|
|
</div>
|
||
|
|
<small>{money(inputs.base.cracking_per_tonne, 2)} per tonne</small>
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span>Cracking ($/kg)</span>
|
||
|
|
<div class="money-field">
|
||
|
|
<i>$</i>
|
||
|
|
<input type="number" step="0.000001" bind:value={inputs.base.cracking_per_kg} oninput={markInputsDirty} />
|
||
|
|
</div>
|
||
|
|
<small>{cents(inputs.base.cracking_per_kg)} per kg</small>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="input-columns">
|
||
|
|
<div class="input-block">
|
||
|
|
<div class="block-heading">
|
||
|
|
<strong>Bagging Processes</strong>
|
||
|
|
<span>Dollars per kg. Example: 0.0400 is 4 cents.</span>
|
||
|
|
</div>
|
||
|
|
<div class="input-list">
|
||
|
|
{#each inputs.processes as process}
|
||
|
|
<label class="inline-field">
|
||
|
|
<span>{process.label}<small>{cents(process.cost)} / kg</small></span>
|
||
|
|
<div class="money-field compact-field">
|
||
|
|
<i>$</i>
|
||
|
|
<input type="number" step="0.0001" bind:value={process.cost} oninput={markInputsDirty} />
|
||
|
|
</div>
|
||
|
|
</label>
|
||
|
|
{/each}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="input-block">
|
||
|
|
<div class="block-heading">
|
||
|
|
<strong>Bag Costs</strong>
|
||
|
|
<span>Dollars per bag or unit. Example: 2.0000 is two dollars.</span>
|
||
|
|
</div>
|
||
|
|
<div class="input-list">
|
||
|
|
{#each inputs.bags as bag}
|
||
|
|
<label class="inline-field">
|
||
|
|
<span>{bag.label}<small>{money(bag.cost, 4)} per bag/unit</small></span>
|
||
|
|
<div class="money-field compact-field">
|
||
|
|
<i>$</i>
|
||
|
|
<input type="number" step="0.0001" bind:value={bag.cost} oninput={markInputsDirty} />
|
||
|
|
</div>
|
||
|
|
</label>
|
||
|
|
{/each}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="input-block">
|
||
|
|
<div class="block-heading">
|
||
|
|
<strong>Freight</strong>
|
||
|
|
<span>Dollars per pallet.</span>
|
||
|
|
</div>
|
||
|
|
<div class="input-list">
|
||
|
|
{#each inputs.freight as freight}
|
||
|
|
<label class="inline-field">
|
||
|
|
<span>{freight.label}<small>{money(freight.cost, 2)} per pallet</small></span>
|
||
|
|
<div class="money-field compact-field">
|
||
|
|
<i>$</i>
|
||
|
|
<input type="number" step="0.0001" bind:value={freight.cost} oninput={markInputsDirty} />
|
||
|
|
</div>
|
||
|
|
</label>
|
||
|
|
{/each}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="input-block">
|
||
|
|
<div class="block-heading">
|
||
|
|
<strong>Client Margins</strong>
|
||
|
|
<span>Enter decimals. Example: 0.2000 is 20%.</span>
|
||
|
|
</div>
|
||
|
|
<div class="margin-table">
|
||
|
|
{#each inputs.clients as client}
|
||
|
|
<div class="margin-row">
|
||
|
|
<strong>{client.client_category}</strong>
|
||
|
|
<label>
|
||
|
|
<span>Distributor</span>
|
||
|
|
<input type="number" min="0" max="0.9999" step="0.000001" bind:value={client.distributor_margin} oninput={markInputsDirty} />
|
||
|
|
<small>{marginHelp(client.distributor_margin)}</small>
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span>Wholesale</span>
|
||
|
|
<input type="number" min="0" max="0.9999" step="0.000001" bind:value={client.wholesale_margin} oninput={markInputsDirty} />
|
||
|
|
<small>{marginHelp(client.wholesale_margin)}</small>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
{/each}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{:else}
|
||
|
|
<div class="empty-state">
|
||
|
|
<strong>No input records loaded</strong>
|
||
|
|
<span>Refresh after the backend has seeded Product Costing inputs.</span>
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
</section>
|
||
|
|
{:else}
|
||
|
|
<section class="panel">
|
||
|
|
<div class="section-toolbar">
|
||
|
|
<div>
|
||
|
|
<h3>Products</h3>
|
||
|
|
<p>Select a product to see or change its details.</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="filter-bar">
|
||
|
|
<label class="search-field">
|
||
|
|
<span>Search</span>
|
||
|
|
<Search size={17} />
|
||
|
|
<input bind:value={query} placeholder="Search product, mix, client, item id" />
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span>Client</span>
|
||
|
|
<select bind:value={clientFilter}>
|
||
|
|
<option value="">All clients</option>
|
||
|
|
{#each clients as client}
|
||
|
|
<option value={client}>{client}</option>
|
||
|
|
{/each}
|
||
|
|
</select>
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span>Show</span>
|
||
|
|
<select bind:value={statusFilter}>
|
||
|
|
<option value="all">All products</option>
|
||
|
|
<option value="warnings">Need review</option>
|
||
|
|
<option value="ready">Ready</option>
|
||
|
|
</select>
|
||
|
|
</label>
|
||
|
|
<label class="page-size-field">
|
||
|
|
<span>Rows per page</span>
|
||
|
|
<select bind:value={pageSize}>
|
||
|
|
<option value={15}>15</option>
|
||
|
|
<option value={25}>25</option>
|
||
|
|
<option value={50}>50</option>
|
||
|
|
<option value={100}>100</option>
|
||
|
|
</select>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="table-wrap">
|
||
|
|
<table>
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Product</th>
|
||
|
|
<th>Delivered</th>
|
||
|
|
<th>Distributor</th>
|
||
|
|
<th>Wholesale</th>
|
||
|
|
<th>Review</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{#if tableLoading}
|
||
|
|
{#each Array(8) as _}
|
||
|
|
<tr class="skeleton-row">
|
||
|
|
<td class="product-cell" data-label="Product">
|
||
|
|
<div class="product-token">
|
||
|
|
<Skeleton variant="circle" width="1.9rem" height="1.9rem" radius="0.65rem" />
|
||
|
|
<div class="skeleton-lines">
|
||
|
|
<Skeleton width="11rem" height="0.72rem" />
|
||
|
|
<Skeleton width="7rem" height="0.58rem" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
<td data-label="Delivered"><Skeleton width="5.5rem" height="0.72rem" /></td>
|
||
|
|
<td data-label="Distributor"><Skeleton width="5.5rem" height="0.72rem" /></td>
|
||
|
|
<td data-label="Wholesale"><Skeleton width="5.5rem" height="0.72rem" /></td>
|
||
|
|
<td data-label="Review"><Skeleton variant="pill" width="4.8rem" height="1.25rem" /></td>
|
||
|
|
</tr>
|
||
|
|
{/each}
|
||
|
|
{:else if pagedItems.length}
|
||
|
|
{#each pagedItems as item}
|
||
|
|
<tr class:active={selectedItem?.id === item.id} class:warn={item.warnings.length > 0} onclick={() => selectItem(item)}>
|
||
|
|
<td class="product-cell" data-label="Product">
|
||
|
|
<div class="product-token">
|
||
|
|
<span>{initials(item.product_name)}</span>
|
||
|
|
<div>
|
||
|
|
<strong>{item.product_name}</strong>
|
||
|
|
<small>{item.client_category} · {item.mix_product_name}</small>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
<td data-label="Delivered">
|
||
|
|
<strong class="money">{money(item.finished_product_delivered_cost)}</strong>
|
||
|
|
</td>
|
||
|
|
<td data-label="Distributor">
|
||
|
|
<strong>{money(item.distributor_price)}</strong>
|
||
|
|
</td>
|
||
|
|
<td data-label="Wholesale">
|
||
|
|
<strong>{money(item.wholesale_price, 1)}</strong>
|
||
|
|
</td>
|
||
|
|
<td data-label="Review">
|
||
|
|
<span class={`status-pill ${item.warnings.length ? 'warning' : 'positive'}`}>
|
||
|
|
{item.warnings.length ? 'Needs review' : 'Ready'}
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{/each}
|
||
|
|
{:else}
|
||
|
|
<tr class="empty-row">
|
||
|
|
<td colspan="7">
|
||
|
|
<strong>No costing rows found</strong>
|
||
|
|
<small>Adjust the filters or search term.</small>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{/if}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
<div class="pagination-bar">
|
||
|
|
<span>{pageStart}-{pageEnd} of {visibleItems.length}</span>
|
||
|
|
<div class="pagination-controls">
|
||
|
|
<button type="button" disabled={page <= 1 || tableLoading} onclick={() => goToPage(1)}>First</button>
|
||
|
|
<button type="button" disabled={page <= 1 || tableLoading} onclick={() => goToPage(page - 1)}>Prev</button>
|
||
|
|
<strong>Page {page} of {totalPages}</strong>
|
||
|
|
<button type="button" disabled={page >= totalPages || tableLoading} onclick={() => goToPage(page + 1)}>Next</button>
|
||
|
|
<button type="button" disabled={page >= totalPages || tableLoading} onclick={() => goToPage(totalPages)}>Last</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
{/if}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<aside class="inspector" aria-label="Selected product costing detail">
|
||
|
|
{#if selectedItem && editDraft}
|
||
|
|
<div class="inspector-head">
|
||
|
|
<div>
|
||
|
|
<span>{selectedItem.client_category}</span>
|
||
|
|
<h3>{selectedItem.product_name}</h3>
|
||
|
|
</div>
|
||
|
|
<button class="icon-button" type="button" aria-label="Clear selected product" onclick={() => (selectedItem = null)}>
|
||
|
|
<X size={17} />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{#if selectedItem.warnings.length}
|
||
|
|
<div class="warning-box">
|
||
|
|
<AlertTriangle size={17} />
|
||
|
|
<div>
|
||
|
|
{#each selectedItem.warnings as warning}
|
||
|
|
<span>{warning}</span>
|
||
|
|
{/each}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
|
||
|
|
<div class="price-stack">
|
||
|
|
<div>
|
||
|
|
<span>Delivered</span>
|
||
|
|
<strong>{money(selectedItem.finished_product_delivered_cost)}</strong>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<span>Distributor</span>
|
||
|
|
<strong>{money(selectedItem.distributor_price)}</strong>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<span>Wholesale</span>
|
||
|
|
<strong>{money(selectedItem.wholesale_price, 1)}</strong>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="inspector-section">
|
||
|
|
<div class="block-heading">
|
||
|
|
<strong>Product Details</strong>
|
||
|
|
<span>Change these when the product setup is wrong.</span>
|
||
|
|
</div>
|
||
|
|
<div class="drawer-form">
|
||
|
|
<label>
|
||
|
|
<span>Client</span>
|
||
|
|
<input bind:value={editDraft.client_category} />
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span>Item ID</span>
|
||
|
|
<input bind:value={editDraft.item_id} />
|
||
|
|
</label>
|
||
|
|
<label class="wide">
|
||
|
|
<span>Product name</span>
|
||
|
|
<input bind:value={editDraft.product_name} />
|
||
|
|
</label>
|
||
|
|
<label class="wide">
|
||
|
|
<span>Mix name</span>
|
||
|
|
<input bind:value={editDraft.mix_product_name} />
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span>Unit type</span>
|
||
|
|
<select bind:value={editDraft.unit_type}>
|
||
|
|
{#each unitTypes as unit}
|
||
|
|
<option value={unit}>{unit}</option>
|
||
|
|
{/each}
|
||
|
|
</select>
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span>Own bag</span>
|
||
|
|
<select bind:value={editDraft.own_bag}>
|
||
|
|
{#each ownBagOptions as option}
|
||
|
|
<option value={option}>{option || 'Default'}</option>
|
||
|
|
{/each}
|
||
|
|
</select>
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span>Bag size kg</span>
|
||
|
|
<input type="number" min="0" step="0.001" bind:value={editDraft.unit_kg} />
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span>Bags per pallet</span>
|
||
|
|
<input type="number" min="0" step="1" bind:value={editDraft.items_per_pallet} />
|
||
|
|
</label>
|
||
|
|
<label class="wide">
|
||
|
|
<span>Bagging process</span>
|
||
|
|
<select bind:value={editDraft.bagging_process}>
|
||
|
|
<option value="">No process</option>
|
||
|
|
{#each processOptions as process}
|
||
|
|
<option value={process}>{process}</option>
|
||
|
|
{/each}
|
||
|
|
</select>
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span>Distributor margin override</span>
|
||
|
|
<input type="number" min="0" max="0.9999" step="0.000001" bind:value={editDraft.manual_distributor_margin} />
|
||
|
|
</label>
|
||
|
|
<label>
|
||
|
|
<span>Wholesale margin override</span>
|
||
|
|
<input type="number" min="0" max="0.9999" step="0.000001" bind:value={editDraft.manual_wholesale_margin} />
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
<button class="primary-button full" disabled={savingItem} type="button" onclick={saveSelectedItem}>
|
||
|
|
<Save size={16} />
|
||
|
|
{savingItem ? 'Saving' : 'Save Product'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="inspector-section">
|
||
|
|
<div class="block-heading">
|
||
|
|
<strong>Cost Details</strong>
|
||
|
|
<span>These numbers make up the delivered cost.</span>
|
||
|
|
</div>
|
||
|
|
<div class="breakdown-grid">
|
||
|
|
<div><span>Cleaned</span><strong>{number(selectedItem.cleaned_product_cost_per_kg)}</strong></div>
|
||
|
|
<div><span>Grading</span><strong>{number(selectedItem.grading_cost_per_kg)}</strong></div>
|
||
|
|
<div><span>Bagging</span><strong>{number(selectedItem.bagging_cost_per_kg)}</strong></div>
|
||
|
|
<div><span>Cracking</span><strong>{number(selectedItem.cracking_cost_per_kg)}</strong></div>
|
||
|
|
<div><span>Bag</span><strong>{money(selectedItem.bag_cost_per_unit)}</strong></div>
|
||
|
|
<div><span>Freight</span><strong>{money(selectedItem.freight_cost_per_unit)}</strong></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{:else}
|
||
|
|
<div class="empty-inspector">
|
||
|
|
<Calculator size={24} />
|
||
|
|
<strong>Select a costing row</strong>
|
||
|
|
<span>Use the table to inspect pricing and edit assumptions.</span>
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
</aside>
|
||
|
|
</section>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
:global(.spin) {
|
||
|
|
animation: spin 900ms linear infinite;
|
||
|
|
}
|
||
|
|
|
||
|
|
h2,
|
||
|
|
h3,
|
||
|
|
p {
|
||
|
|
margin: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
button,
|
||
|
|
input,
|
||
|
|
select {
|
||
|
|
font: inherit;
|
||
|
|
}
|
||
|
|
|
||
|
|
.costing-shell {
|
||
|
|
--costing-ink: oklch(25% 0.018 155);
|
||
|
|
--costing-muted: oklch(52% 0.022 155);
|
||
|
|
--costing-panel: oklch(99% 0.004 145);
|
||
|
|
--costing-soft: oklch(96.8% 0.008 145);
|
||
|
|
--costing-line: oklch(88% 0.014 145);
|
||
|
|
--costing-line-strong: oklch(78% 0.02 145);
|
||
|
|
--costing-warn: oklch(58% 0.14 72);
|
||
|
|
display: grid;
|
||
|
|
gap: 1.05rem;
|
||
|
|
color: var(--costing-ink);
|
||
|
|
}
|
||
|
|
|
||
|
|
.page-head,
|
||
|
|
.health-strip,
|
||
|
|
.workspace-grid,
|
||
|
|
.section-toolbar,
|
||
|
|
.filter-bar,
|
||
|
|
.head-actions,
|
||
|
|
.pagination-bar,
|
||
|
|
.pagination-controls,
|
||
|
|
.warning-box {
|
||
|
|
display: flex;
|
||
|
|
gap: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.page-head {
|
||
|
|
align-items: flex-end;
|
||
|
|
justify-content: space-between;
|
||
|
|
padding: 0.35rem 0 0.15rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.page-head p,
|
||
|
|
.section-toolbar p,
|
||
|
|
.block-heading span,
|
||
|
|
small,
|
||
|
|
.health-card p,
|
||
|
|
.health-card span,
|
||
|
|
.inspector-head span,
|
||
|
|
.breakdown-grid span,
|
||
|
|
.price-stack span,
|
||
|
|
.empty-inspector span {
|
||
|
|
color: var(--costing-muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.eyebrow {
|
||
|
|
display: inline-flex;
|
||
|
|
margin-bottom: 0.12rem;
|
||
|
|
color: var(--green-deep);
|
||
|
|
font-size: 0.74rem;
|
||
|
|
font-weight: 800;
|
||
|
|
letter-spacing: 0.08em;
|
||
|
|
text-transform: uppercase;
|
||
|
|
}
|
||
|
|
|
||
|
|
.page-head h2 {
|
||
|
|
font-size: 1.85rem;
|
||
|
|
font-weight: 750;
|
||
|
|
letter-spacing: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.head-actions {
|
||
|
|
align-items: center;
|
||
|
|
flex-wrap: wrap;
|
||
|
|
justify-content: flex-end;
|
||
|
|
}
|
||
|
|
|
||
|
|
.toolbar-actions {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: flex-end;
|
||
|
|
gap: 0.6rem;
|
||
|
|
flex-wrap: wrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
.dirty-pill {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
min-height: 2rem;
|
||
|
|
padding: 0.38rem 0.62rem;
|
||
|
|
border-radius: 999px;
|
||
|
|
color: var(--costing-warn);
|
||
|
|
background: oklch(95% 0.055 82);
|
||
|
|
font-size: 0.8rem;
|
||
|
|
font-weight: 800;
|
||
|
|
}
|
||
|
|
|
||
|
|
.primary-button,
|
||
|
|
.icon-button,
|
||
|
|
.view-tabs button {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
gap: 0.45rem;
|
||
|
|
min-height: 2.55rem;
|
||
|
|
border-radius: 0.72rem;
|
||
|
|
font-weight: 750;
|
||
|
|
cursor: pointer;
|
||
|
|
transition:
|
||
|
|
background-color 160ms cubic-bezier(0.22, 1, 0.36, 1),
|
||
|
|
border-color 160ms cubic-bezier(0.22, 1, 0.36, 1),
|
||
|
|
transform 160ms cubic-bezier(0.22, 1, 0.36, 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
.primary-button {
|
||
|
|
padding: 0.72rem 0.9rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.primary-button {
|
||
|
|
border: 1px solid var(--green-deep);
|
||
|
|
background: var(--color-brand);
|
||
|
|
color: oklch(99% 0.004 145);
|
||
|
|
}
|
||
|
|
|
||
|
|
.primary-button:hover:not(:disabled) {
|
||
|
|
background: var(--green-deep);
|
||
|
|
}
|
||
|
|
|
||
|
|
.primary-button:disabled {
|
||
|
|
opacity: 0.62;
|
||
|
|
cursor: progress;
|
||
|
|
}
|
||
|
|
|
||
|
|
.primary-button.compact {
|
||
|
|
min-height: 2.35rem;
|
||
|
|
padding: 0.62rem 0.78rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.primary-button.full {
|
||
|
|
width: 100%;
|
||
|
|
margin-top: 0.8rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.health-strip {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
|
|
gap: 0.8rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.health-card,
|
||
|
|
.panel,
|
||
|
|
.inspector {
|
||
|
|
border: 1px solid var(--costing-line);
|
||
|
|
background: var(--costing-panel);
|
||
|
|
box-shadow: var(--shadow);
|
||
|
|
}
|
||
|
|
|
||
|
|
.health-card {
|
||
|
|
min-height: 7.2rem;
|
||
|
|
padding: 1rem;
|
||
|
|
border-radius: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.health-card.warning {
|
||
|
|
background: color-mix(in srgb, oklch(91% 0.08 80) 34%, var(--costing-panel));
|
||
|
|
}
|
||
|
|
|
||
|
|
.health-card strong {
|
||
|
|
display: block;
|
||
|
|
margin: 0.35rem 0 0.12rem;
|
||
|
|
font-size: 1.7rem;
|
||
|
|
line-height: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.workspace-grid {
|
||
|
|
align-items: start;
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: minmax(0, 1fr) minmax(22rem, 0.34fr);
|
||
|
|
}
|
||
|
|
|
||
|
|
.main-column {
|
||
|
|
min-width: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.view-tabs {
|
||
|
|
display: inline-flex;
|
||
|
|
gap: 0.25rem;
|
||
|
|
padding: 0.28rem;
|
||
|
|
margin-bottom: 0.85rem;
|
||
|
|
border: 1px solid var(--costing-line);
|
||
|
|
border-radius: 0.9rem;
|
||
|
|
background: var(--costing-soft);
|
||
|
|
}
|
||
|
|
|
||
|
|
.view-tabs button,
|
||
|
|
.icon-button {
|
||
|
|
border: none;
|
||
|
|
background: transparent;
|
||
|
|
color: var(--costing-muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.view-tabs button {
|
||
|
|
padding: 0.58rem 0.8rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.view-tabs button.active {
|
||
|
|
background: var(--costing-panel);
|
||
|
|
color: var(--green-deep);
|
||
|
|
box-shadow: inset 0 0 0 1px var(--costing-line);
|
||
|
|
}
|
||
|
|
|
||
|
|
.panel,
|
||
|
|
.inspector {
|
||
|
|
border-radius: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.panel {
|
||
|
|
padding: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.section-toolbar {
|
||
|
|
align-items: flex-start;
|
||
|
|
justify-content: space-between;
|
||
|
|
margin-bottom: 0.95rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.section-toolbar h3,
|
||
|
|
.inspector-head h3 {
|
||
|
|
font-size: 1.08rem;
|
||
|
|
font-weight: 750;
|
||
|
|
}
|
||
|
|
|
||
|
|
.filter-bar {
|
||
|
|
align-items: end;
|
||
|
|
flex-wrap: wrap;
|
||
|
|
padding: 0.62rem;
|
||
|
|
margin-bottom: 0.6rem;
|
||
|
|
border: 1px solid var(--costing-line);
|
||
|
|
border-radius: 0.9rem;
|
||
|
|
background: var(--costing-soft);
|
||
|
|
}
|
||
|
|
|
||
|
|
label {
|
||
|
|
display: grid;
|
||
|
|
gap: 0.32rem;
|
||
|
|
min-width: 10rem;
|
||
|
|
font-size: 0.84rem;
|
||
|
|
font-weight: 750;
|
||
|
|
}
|
||
|
|
|
||
|
|
label span {
|
||
|
|
color: var(--costing-muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
input,
|
||
|
|
select {
|
||
|
|
min-height: 2.4rem;
|
||
|
|
width: 100%;
|
||
|
|
padding: 0.55rem 0.65rem;
|
||
|
|
border: 1px solid var(--costing-line-strong);
|
||
|
|
border-radius: 0.66rem;
|
||
|
|
background: oklch(99% 0.004 145);
|
||
|
|
color: var(--costing-ink);
|
||
|
|
}
|
||
|
|
|
||
|
|
input:focus,
|
||
|
|
select:focus {
|
||
|
|
outline: none;
|
||
|
|
border-color: var(--color-brand);
|
||
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
|
||
|
|
}
|
||
|
|
|
||
|
|
.money-field {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: 1.65rem minmax(0, 1fr);
|
||
|
|
align-items: center;
|
||
|
|
border: 1px solid var(--costing-line-strong);
|
||
|
|
border-radius: 0.66rem;
|
||
|
|
background: oklch(99% 0.004 145);
|
||
|
|
overflow: hidden;
|
||
|
|
}
|
||
|
|
|
||
|
|
.money-field i {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
min-height: 2.4rem;
|
||
|
|
color: var(--green-deep);
|
||
|
|
background: color-mix(in srgb, var(--color-brand) 8%, var(--costing-panel));
|
||
|
|
font-style: normal;
|
||
|
|
font-weight: 850;
|
||
|
|
}
|
||
|
|
|
||
|
|
.money-field input {
|
||
|
|
border: none;
|
||
|
|
border-radius: 0;
|
||
|
|
background: transparent;
|
||
|
|
}
|
||
|
|
|
||
|
|
.money-field:focus-within {
|
||
|
|
border-color: var(--color-brand);
|
||
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
|
||
|
|
}
|
||
|
|
|
||
|
|
.compact-field {
|
||
|
|
min-width: 8.5rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.search-field {
|
||
|
|
position: relative;
|
||
|
|
flex: 1 1 18rem;
|
||
|
|
min-width: min(100%, 18rem);
|
||
|
|
}
|
||
|
|
|
||
|
|
.search-field :global(svg) {
|
||
|
|
position: absolute;
|
||
|
|
left: 0.68rem;
|
||
|
|
bottom: 0.68rem;
|
||
|
|
color: var(--costing-muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.search-field input {
|
||
|
|
padding-left: 2.05rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.page-size-field {
|
||
|
|
min-width: 5.4rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.table-wrap {
|
||
|
|
overflow-x: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
table {
|
||
|
|
width: 100%;
|
||
|
|
min-width: 72rem;
|
||
|
|
border-collapse: separate;
|
||
|
|
border-spacing: 0 0.36rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
th,
|
||
|
|
td {
|
||
|
|
padding: 0.54rem 0.68rem;
|
||
|
|
text-align: left;
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
th {
|
||
|
|
color: var(--costing-muted);
|
||
|
|
font-size: 0.68rem;
|
||
|
|
font-weight: 800;
|
||
|
|
letter-spacing: 0.06em;
|
||
|
|
text-transform: uppercase;
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody tr {
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody tr:hover td {
|
||
|
|
background: color-mix(in srgb, var(--color-brand) 5%, var(--costing-soft));
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody tr.active td {
|
||
|
|
background: color-mix(in srgb, var(--color-brand) 9%, var(--costing-panel));
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody tr.warn td {
|
||
|
|
background: color-mix(in srgb, oklch(93% 0.08 83) 45%, var(--costing-panel));
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody td {
|
||
|
|
border-top: 1px solid var(--costing-line);
|
||
|
|
border-bottom: 1px solid var(--costing-line);
|
||
|
|
background: var(--costing-soft);
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody td:first-child {
|
||
|
|
border-left: 1px solid var(--costing-line);
|
||
|
|
border-radius: 0.62rem 0 0 0.62rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody td:last-child {
|
||
|
|
border-right: 1px solid var(--costing-line);
|
||
|
|
border-radius: 0 0.62rem 0.62rem 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
td strong,
|
||
|
|
td small {
|
||
|
|
display: block;
|
||
|
|
}
|
||
|
|
|
||
|
|
td strong {
|
||
|
|
max-width: 18rem;
|
||
|
|
overflow: hidden;
|
||
|
|
text-overflow: ellipsis;
|
||
|
|
font-size: 0.88rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
td small {
|
||
|
|
margin-top: 0.08rem;
|
||
|
|
font-size: 0.72rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.product-cell {
|
||
|
|
min-width: 17rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.product-token {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 0.58rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.product-token > span {
|
||
|
|
width: 1.9rem;
|
||
|
|
height: 1.9rem;
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
flex-shrink: 0;
|
||
|
|
border-radius: 0.62rem;
|
||
|
|
color: var(--green-deep);
|
||
|
|
background: color-mix(in srgb, var(--color-brand) 13%, var(--costing-panel));
|
||
|
|
font-size: 0.68rem;
|
||
|
|
font-weight: 850;
|
||
|
|
}
|
||
|
|
|
||
|
|
.money {
|
||
|
|
color: var(--green-deep);
|
||
|
|
}
|
||
|
|
|
||
|
|
.status-pill {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
padding: 0.28rem 0.5rem;
|
||
|
|
border-radius: 999px;
|
||
|
|
font-size: 0.72rem;
|
||
|
|
font-weight: 800;
|
||
|
|
}
|
||
|
|
|
||
|
|
.status-pill.positive {
|
||
|
|
color: var(--green-deep);
|
||
|
|
background: var(--green-soft);
|
||
|
|
}
|
||
|
|
|
||
|
|
.status-pill.warning {
|
||
|
|
color: var(--costing-warn);
|
||
|
|
background: oklch(95% 0.055 82);
|
||
|
|
}
|
||
|
|
|
||
|
|
.skeleton-row td {
|
||
|
|
cursor: progress;
|
||
|
|
}
|
||
|
|
|
||
|
|
.skeleton-lines {
|
||
|
|
display: grid;
|
||
|
|
gap: 0.24rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.empty-row td,
|
||
|
|
.empty-row:hover td {
|
||
|
|
padding: 1.4rem;
|
||
|
|
text-align: center;
|
||
|
|
background: var(--costing-soft);
|
||
|
|
}
|
||
|
|
|
||
|
|
.empty-row strong,
|
||
|
|
.empty-row small {
|
||
|
|
display: block;
|
||
|
|
}
|
||
|
|
|
||
|
|
.pagination-bar {
|
||
|
|
align-items: center;
|
||
|
|
justify-content: space-between;
|
||
|
|
gap: 0.75rem;
|
||
|
|
margin-top: 0.75rem;
|
||
|
|
color: var(--costing-muted);
|
||
|
|
font-size: 0.82rem;
|
||
|
|
font-weight: 700;
|
||
|
|
}
|
||
|
|
|
||
|
|
.pagination-controls {
|
||
|
|
align-items: center;
|
||
|
|
flex-wrap: wrap;
|
||
|
|
justify-content: flex-end;
|
||
|
|
gap: 0.35rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.pagination-controls button {
|
||
|
|
min-height: 2rem;
|
||
|
|
padding: 0.38rem 0.58rem;
|
||
|
|
border: 1px solid var(--costing-line);
|
||
|
|
border-radius: 0.55rem;
|
||
|
|
background: var(--costing-panel);
|
||
|
|
color: var(--costing-ink);
|
||
|
|
font-size: 0.78rem;
|
||
|
|
font-weight: 800;
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
.pagination-controls button:disabled {
|
||
|
|
opacity: 0.45;
|
||
|
|
cursor: not-allowed;
|
||
|
|
}
|
||
|
|
|
||
|
|
.pagination-controls strong {
|
||
|
|
padding: 0 0.35rem;
|
||
|
|
color: var(--costing-ink);
|
||
|
|
font-size: 0.78rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.inspector {
|
||
|
|
position: sticky;
|
||
|
|
top: 1rem;
|
||
|
|
max-height: calc(100vh - 2rem);
|
||
|
|
overflow: auto;
|
||
|
|
padding: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.inspector-head {
|
||
|
|
display: flex;
|
||
|
|
align-items: flex-start;
|
||
|
|
justify-content: space-between;
|
||
|
|
gap: 0.85rem;
|
||
|
|
margin-bottom: 0.85rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.icon-button {
|
||
|
|
width: 2.25rem;
|
||
|
|
min-height: 2.25rem;
|
||
|
|
border: 1px solid var(--costing-line);
|
||
|
|
background: var(--costing-soft);
|
||
|
|
}
|
||
|
|
|
||
|
|
.warning-box {
|
||
|
|
align-items: flex-start;
|
||
|
|
padding: 0.78rem;
|
||
|
|
margin-bottom: 0.85rem;
|
||
|
|
border: 1px solid oklch(84% 0.085 78);
|
||
|
|
border-radius: 0.82rem;
|
||
|
|
color: var(--costing-warn);
|
||
|
|
background: oklch(96% 0.045 84);
|
||
|
|
font-size: 0.85rem;
|
||
|
|
font-weight: 700;
|
||
|
|
}
|
||
|
|
|
||
|
|
.warning-box div {
|
||
|
|
display: grid;
|
||
|
|
gap: 0.25rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.price-stack {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
gap: 0.55rem;
|
||
|
|
margin-bottom: 0.95rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.price-stack div,
|
||
|
|
.breakdown-grid div,
|
||
|
|
.empty-inspector {
|
||
|
|
border: 1px solid var(--costing-line);
|
||
|
|
border-radius: 0.78rem;
|
||
|
|
background: var(--costing-soft);
|
||
|
|
}
|
||
|
|
|
||
|
|
.price-stack div {
|
||
|
|
display: flex;
|
||
|
|
align-items: baseline;
|
||
|
|
justify-content: space-between;
|
||
|
|
gap: 0.7rem;
|
||
|
|
padding: 0.7rem 0.78rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.price-stack strong {
|
||
|
|
font-size: 1.12rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.inspector-section {
|
||
|
|
padding-top: 0.9rem;
|
||
|
|
margin-top: 0.9rem;
|
||
|
|
border-top: 1px solid var(--costing-line);
|
||
|
|
}
|
||
|
|
|
||
|
|
.block-heading {
|
||
|
|
display: grid;
|
||
|
|
gap: 0.18rem;
|
||
|
|
margin-bottom: 0.72rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.drawer-form,
|
||
|
|
.input-grid,
|
||
|
|
.input-columns,
|
||
|
|
.breakdown-grid {
|
||
|
|
display: grid;
|
||
|
|
gap: 0.65rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.drawer-form {
|
||
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
|
|
}
|
||
|
|
|
||
|
|
.drawer-form .wide {
|
||
|
|
grid-column: 1 / -1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.breakdown-grid {
|
||
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
|
|
}
|
||
|
|
|
||
|
|
.breakdown-grid div {
|
||
|
|
padding: 0.68rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.breakdown-grid strong {
|
||
|
|
margin-top: 0.12rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.empty-inspector {
|
||
|
|
display: grid;
|
||
|
|
justify-items: center;
|
||
|
|
gap: 0.38rem;
|
||
|
|
padding: 2rem 1rem;
|
||
|
|
text-align: center;
|
||
|
|
}
|
||
|
|
|
||
|
|
.empty-inspector :global(svg) {
|
||
|
|
color: var(--green-deep);
|
||
|
|
}
|
||
|
|
|
||
|
|
.inputs-panel {
|
||
|
|
display: grid;
|
||
|
|
gap: 0.9rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.input-block {
|
||
|
|
padding: 0.92rem;
|
||
|
|
border: 1px solid var(--costing-line);
|
||
|
|
border-radius: 0.9rem;
|
||
|
|
background: var(--costing-soft);
|
||
|
|
}
|
||
|
|
|
||
|
|
.input-grid.four {
|
||
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
|
|
}
|
||
|
|
|
||
|
|
.input-columns {
|
||
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
|
|
}
|
||
|
|
|
||
|
|
.input-list {
|
||
|
|
display: grid;
|
||
|
|
gap: 0.5rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.inline-field,
|
||
|
|
.margin-row {
|
||
|
|
display: grid;
|
||
|
|
align-items: center;
|
||
|
|
gap: 0.55rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.inline-field {
|
||
|
|
grid-template-columns: minmax(0, 1fr) 8.5rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.inline-field input {
|
||
|
|
text-align: right;
|
||
|
|
}
|
||
|
|
|
||
|
|
.inline-field > span {
|
||
|
|
display: grid;
|
||
|
|
gap: 0.12rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.inline-field small,
|
||
|
|
label small {
|
||
|
|
color: var(--costing-muted);
|
||
|
|
font-size: 0.74rem;
|
||
|
|
font-weight: 650;
|
||
|
|
}
|
||
|
|
|
||
|
|
.margin-table {
|
||
|
|
display: grid;
|
||
|
|
gap: 0.55rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.margin-row {
|
||
|
|
grid-template-columns: minmax(12rem, 1fr) minmax(9rem, 0.7fr) minmax(9rem, 0.7fr);
|
||
|
|
padding: 0.68rem;
|
||
|
|
border: 1px solid var(--costing-line);
|
||
|
|
border-radius: 0.76rem;
|
||
|
|
background: var(--costing-panel);
|
||
|
|
}
|
||
|
|
|
||
|
|
.empty-state {
|
||
|
|
display: grid;
|
||
|
|
gap: 0.25rem;
|
||
|
|
padding: 1rem;
|
||
|
|
border: 1px solid var(--costing-line);
|
||
|
|
border-radius: 0.82rem;
|
||
|
|
background: var(--costing-soft);
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes spin {
|
||
|
|
to {
|
||
|
|
transform: rotate(360deg);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@media (max-width: 1240px) {
|
||
|
|
.workspace-grid {
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
}
|
||
|
|
|
||
|
|
.inspector {
|
||
|
|
position: static;
|
||
|
|
max-height: none;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@media (max-width: 980px) {
|
||
|
|
.page-head,
|
||
|
|
.section-toolbar {
|
||
|
|
flex-direction: column;
|
||
|
|
align-items: flex-start;
|
||
|
|
}
|
||
|
|
|
||
|
|
.health-strip,
|
||
|
|
.input-grid.four,
|
||
|
|
.input-columns {
|
||
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@media (max-width: 760px) {
|
||
|
|
.health-strip,
|
||
|
|
.input-grid.four,
|
||
|
|
.input-columns,
|
||
|
|
.drawer-form,
|
||
|
|
.margin-row,
|
||
|
|
.breakdown-grid {
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
}
|
||
|
|
|
||
|
|
.head-actions,
|
||
|
|
.filter-bar,
|
||
|
|
.pagination-bar {
|
||
|
|
width: 100%;
|
||
|
|
}
|
||
|
|
|
||
|
|
.head-actions .primary-button,
|
||
|
|
.filter-bar label {
|
||
|
|
width: 100%;
|
||
|
|
}
|
||
|
|
|
||
|
|
.pagination-bar,
|
||
|
|
.pagination-controls {
|
||
|
|
align-items: stretch;
|
||
|
|
flex-direction: column;
|
||
|
|
}
|
||
|
|
|
||
|
|
.pagination-controls {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
|
|
}
|
||
|
|
|
||
|
|
.pagination-controls strong {
|
||
|
|
grid-column: 1 / -1;
|
||
|
|
text-align: center;
|
||
|
|
}
|
||
|
|
|
||
|
|
table,
|
||
|
|
thead,
|
||
|
|
tbody,
|
||
|
|
tr,
|
||
|
|
td {
|
||
|
|
display: block;
|
||
|
|
width: 100%;
|
||
|
|
}
|
||
|
|
|
||
|
|
table {
|
||
|
|
min-width: 0;
|
||
|
|
border-spacing: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
thead {
|
||
|
|
display: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody {
|
||
|
|
display: grid;
|
||
|
|
gap: 0.78rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody tr {
|
||
|
|
padding: 0.35rem;
|
||
|
|
border: 1px solid var(--costing-line);
|
||
|
|
border-radius: 0.9rem;
|
||
|
|
background: var(--costing-soft);
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody td,
|
||
|
|
tbody td:first-child,
|
||
|
|
tbody td:last-child {
|
||
|
|
padding: 0.74rem 0.78rem;
|
||
|
|
white-space: normal;
|
||
|
|
border: none;
|
||
|
|
border-radius: 0;
|
||
|
|
background: transparent;
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody td + td {
|
||
|
|
border-top: 1px solid var(--costing-line);
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody td::before {
|
||
|
|
content: attr(data-label);
|
||
|
|
display: block;
|
||
|
|
margin-bottom: 0.34rem;
|
||
|
|
color: var(--costing-muted);
|
||
|
|
font-size: 0.72rem;
|
||
|
|
font-weight: 800;
|
||
|
|
letter-spacing: 0.06em;
|
||
|
|
text-transform: uppercase;
|
||
|
|
}
|
||
|
|
|
||
|
|
td strong {
|
||
|
|
max-width: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.product-cell {
|
||
|
|
min-width: 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</style>
|