Files
data-entry-app/frontend/src/routes/product-costing/+page.svelte
T

1558 lines
43 KiB
Svelte
Raw Normal View History

2026-06-09 21:28:53 +12:00
<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>