v0.1.11 - Editor
This commit is contained in:
@@ -19,6 +19,9 @@ import type {
|
||||
ClientUserModulePermission,
|
||||
ClientUserUpdateInput,
|
||||
LoginResponse,
|
||||
EditorMixUpdateInput,
|
||||
EditorProductRow,
|
||||
EditorProductUpdateInput,
|
||||
MixCalculatorCreateInput,
|
||||
MixCalculatorOptions,
|
||||
MixCalculatorPreview,
|
||||
@@ -318,6 +321,25 @@ export const api = {
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
|
||||
editorProducts: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.q) search.set('q', params.q);
|
||||
if (params?.client_name) search.set('client_name', params.client_name);
|
||||
if (params?.limit) search.set('limit', String(params.limit));
|
||||
const qs = search.toString();
|
||||
const path = qs ? `/api/editor/products?${qs}` : '/api/editor/products';
|
||||
return cachedFetchJson<EditorProductRow[]>(path, [], 'client', fetcher);
|
||||
},
|
||||
updateEditorProduct: (productId: number, payload: EditorProductUpdateInput) =>
|
||||
request<EditorProductRow>(`/api/editor/products/${productId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) =>
|
||||
request<EditorProductRow[]>(`/api/editor/mixes/${mixId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
productCosts: (fetcher?: ApiFetch) =>
|
||||
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
|
||||
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
|
||||
|
||||
@@ -14,12 +14,10 @@
|
||||
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
|
||||
canOpenClientAccess as sessionCanOpenClientAccess,
|
||||
canOpenDashboard as sessionCanOpenDashboard,
|
||||
canOpenEditor as sessionCanOpenEditor,
|
||||
canOpenMixCalculator as sessionCanOpenMixCalculator,
|
||||
canOpenMixMaster as sessionCanOpenMixMaster,
|
||||
canOpenProducts as sessionCanOpenProducts,
|
||||
canOpenRawMaterials as sessionCanOpenRawMaterials,
|
||||
canOpenReporting as sessionCanOpenReporting,
|
||||
canOpenScenarios as sessionCanOpenScenarios,
|
||||
canOpenSettings as sessionCanOpenSettings,
|
||||
canOpenThroughput as sessionCanOpenThroughput,
|
||||
canUseWorkspaceSearch as sessionCanUseWorkspaceSearch,
|
||||
@@ -32,6 +30,7 @@
|
||||
baseSearchItems,
|
||||
clientBreadcrumbs,
|
||||
dashboardItem,
|
||||
editorItem,
|
||||
footerLinks,
|
||||
matchesRoute,
|
||||
mixCalculatorItem,
|
||||
@@ -47,11 +46,11 @@
|
||||
import packageInfo from '../../../package.json';
|
||||
import {
|
||||
Calculator,
|
||||
DollarSign,
|
||||
Search,
|
||||
LogOut,
|
||||
Plus,
|
||||
Menu
|
||||
Menu,
|
||||
Settings
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let { children } = $props();
|
||||
@@ -71,13 +70,11 @@
|
||||
const appVersion = `v${packageInfo.version}`;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
|
||||
const canOpenRawMaterials = $derived(sessionCanOpenRawMaterials($clientSession));
|
||||
const canOpenMixMaster = $derived(sessionCanOpenMixMaster($clientSession));
|
||||
const canCreateMixWorksheet = $derived(sessionCanCreateMixWorksheet($clientSession));
|
||||
const canOpenMixCalculator = $derived(sessionCanOpenMixCalculator($clientSession));
|
||||
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
|
||||
const canOpenProducts = $derived(sessionCanOpenProducts($clientSession));
|
||||
const canOpenScenarios = $derived(sessionCanOpenScenarios($clientSession));
|
||||
const canOpenEditor = $derived(sessionCanOpenEditor($clientSession));
|
||||
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
|
||||
const canOpenClientAccess = $derived(sessionCanOpenClientAccess($clientSession));
|
||||
const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession));
|
||||
@@ -94,10 +91,7 @@
|
||||
!$clientSession
|
||||
? workingDocumentItems
|
||||
: workingDocumentItems.filter((item) => {
|
||||
if (item.href === '/raw-materials') return canOpenRawMaterials;
|
||||
if (item.href === '/mixes') return canOpenMixMaster;
|
||||
if (item.href === '/products') return canOpenProducts;
|
||||
if (item.href === '/scenarios') return canOpenScenarios;
|
||||
return !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey);
|
||||
})
|
||||
);
|
||||
@@ -105,6 +99,7 @@
|
||||
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
|
||||
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
|
||||
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
|
||||
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
|
||||
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
|
||||
const workspaceRole = $derived(getWorkspaceRole($clientSession));
|
||||
const visibleFooterLinks = $derived([
|
||||
@@ -126,14 +121,12 @@
|
||||
const visibleBaseSearchItems = $derived(
|
||||
baseSearchItems.filter((item) => {
|
||||
if (item.href === '/') return canOpenDashboard;
|
||||
if (item.href === '/raw-materials') return canOpenRawMaterials;
|
||||
if (item.href === '/mixes') return canOpenMixMaster;
|
||||
if (item.href === '/mixes/new') return canCreateMixWorksheet;
|
||||
if (item.href === '/mix-calculator') return canOpenMixCalculator;
|
||||
if (item.href === '/products') return canOpenProducts;
|
||||
if (item.href === '/editor') return canOpenEditor;
|
||||
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
|
||||
if (item.href === '/settings') return canOpenSettings;
|
||||
if (item.href === '/scenarios') return canOpenScenarios;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
@@ -266,24 +259,17 @@
|
||||
seededSearchKey = sessionKey;
|
||||
|
||||
Promise.all([
|
||||
sessionCanOpenProducts(session) ? api.products() : Promise.resolve([]),
|
||||
sessionCanOpenMixMaster(session) ? api.mixes() : Promise.resolve([]),
|
||||
featureFlags.mixCalculatorSessionHistory && sessionCanOpenMixCalculator(session)
|
||||
? api.mixCalculatorSessions()
|
||||
: Promise.resolve([])
|
||||
])
|
||||
.then(([products, mixes, sessions]) => {
|
||||
.then(([mixes, sessions]) => {
|
||||
if (seededSearchKey !== sessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
seededSearchItems = [
|
||||
...products.map((product) => ({
|
||||
href: '/products',
|
||||
label: product.name,
|
||||
description: `Product · ${product.client_name} · ${product.mix_name}`,
|
||||
keywords: `product ${product.name} ${product.client_name} ${product.mix_name} ${product.unit_of_measure}`
|
||||
})),
|
||||
...mixes.map((mix) => ({
|
||||
href: `/mixes/${mix.id}`,
|
||||
label: mix.name,
|
||||
@@ -394,6 +380,7 @@
|
||||
primaryItems={[
|
||||
...(visibleDashboardItem ? [visibleDashboardItem] : []),
|
||||
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
|
||||
...(visibleEditorItem ? [visibleEditorItem] : []),
|
||||
...(visibleThroughputItem ? [visibleThroughputItem] : []),
|
||||
...(visibleReportingItem ? [visibleReportingItem] : [])
|
||||
]}
|
||||
@@ -458,16 +445,13 @@
|
||||
{#if canCreateMixSession}
|
||||
<a href="/mix-calculator">Create mix session</a>
|
||||
{/if}
|
||||
{#if canOpenProducts}
|
||||
<a href="/products">Review delivered pricing</a>
|
||||
{/if}
|
||||
{#if canUseWorkspaceSearch}
|
||||
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canOpenProducts || canUseWorkspaceSearch}
|
||||
{#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canUseWorkspaceSearch}
|
||||
<button
|
||||
aria-expanded={quickMenuOpen}
|
||||
aria-label="Open quick access menu"
|
||||
@@ -553,6 +537,15 @@
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if visibleEditorItem}
|
||||
{@const Icon = visibleEditorItem.icon}
|
||||
<a class:active={matchesRoute(visibleEditorItem.href, page.url.pathname)} href={visibleEditorItem.href} onclick={() => (navOpen = false)}>
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{visibleEditorItem.label}</span>
|
||||
{#if visibleEditorItem.badge}<span class="drawer-badge">{visibleEditorItem.badge}</span>{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if visibleReportingItem}
|
||||
{@const Icon = visibleReportingItem.icon}
|
||||
<a class:active={matchesRoute(visibleReportingItem.href, page.url.pathname)} href={visibleReportingItem.href} onclick={() => (navOpen = false)}>
|
||||
@@ -593,12 +586,6 @@
|
||||
<span>Change settings</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if canOpenProducts}
|
||||
<a href="/products" onclick={() => (navOpen = false)}>
|
||||
<span class="nav-icon"><DollarSign size={18} strokeWidth={1.75} /></span>
|
||||
<span>Review delivered pricing</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if canUseWorkspaceSearch}
|
||||
<button type="button" onclick={() => openPalette('')}>
|
||||
<span class="nav-icon"><Search size={18} strokeWidth={1.75} /></span>
|
||||
@@ -645,7 +632,7 @@
|
||||
>
|
||||
<div class="palette-input-row">
|
||||
<span class="search-icon"></span>
|
||||
<input bind:this={paletteInput} bind:value={paletteQuery} placeholder="Search products, mixes, sessions, and pages..." />
|
||||
<input bind:this={paletteInput} bind:value={paletteQuery} placeholder="Search mixes, sessions, and pages..." />
|
||||
<kbd>Esc</kbd>
|
||||
</div>
|
||||
|
||||
@@ -663,7 +650,7 @@
|
||||
{:else}
|
||||
<div class="palette-empty">
|
||||
<strong>No results</strong>
|
||||
<span>Try searching for mixes, products, scenarios, or pricing.</span>
|
||||
<span>Try searching for mixes, sessions, or pages.</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -674,27 +661,31 @@
|
||||
<style>
|
||||
:global(:root) {
|
||||
/* ── Brand ──────────────────────────────────────────────── */
|
||||
--color-brand: #15803d;
|
||||
--color-brand-tint: #f0fdf4;
|
||||
--color-brand: oklch(0.54 0.15 149);
|
||||
--color-brand-hover: oklch(0.47 0.14 149);
|
||||
--color-brand-tint: oklch(0.98 0.02 149);
|
||||
|
||||
/* ── Surfaces ───────────────────────────────────────────── */
|
||||
--color-bg-app: #f6f8fa;
|
||||
--color-bg-surface: #ffffff;
|
||||
--color-bg-app: oklch(0.975 0.006 150);
|
||||
--color-bg-surface: oklch(0.997 0.004 150);
|
||||
--color-bg-elevated: oklch(0.99 0.005 150);
|
||||
|
||||
/* ── Borders ────────────────────────────────────────────── */
|
||||
--color-border: #e1e4e8;
|
||||
--color-divider: #eaecef;
|
||||
--color-border: oklch(0.905 0.012 150);
|
||||
--color-divider: oklch(0.935 0.009 150);
|
||||
|
||||
/* ── Text ───────────────────────────────────────────────── */
|
||||
--color-text-primary: #24292f;
|
||||
--color-text-secondary: #57606a;
|
||||
--color-text-muted: #8b949e;
|
||||
--color-text-primary: oklch(0.26 0.015 150);
|
||||
--color-text-secondary: oklch(0.44 0.018 150);
|
||||
--color-text-muted: oklch(0.62 0.018 150);
|
||||
|
||||
/* ── Semantic ───────────────────────────────────────────── */
|
||||
--color-success: #1a7f37;
|
||||
--color-warning: #bf8700;
|
||||
--color-error: #cf222e;
|
||||
--color-info: #0969da;
|
||||
--color-warning-tint: oklch(0.975 0.035 78);
|
||||
--color-info-tint: oklch(0.97 0.025 230);
|
||||
|
||||
/* ── Legacy aliases (keep old token names working) ───────── */
|
||||
--bg: var(--color-bg-app);
|
||||
@@ -705,10 +696,15 @@
|
||||
--text: var(--color-text-primary);
|
||||
--muted: var(--color-text-muted);
|
||||
--green: var(--color-brand);
|
||||
--green-deep: #1a1f1c;
|
||||
--green-deep: oklch(0.25 0.018 150);
|
||||
--green-soft: var(--color-brand-tint);
|
||||
--blue-soft: #e8f4ff;
|
||||
--blue-soft: var(--color-info-tint);
|
||||
--shadow: none; /* flat design — use borders, not shadows */
|
||||
--radius-panel: 1.2rem;
|
||||
--radius-control: 0.82rem;
|
||||
--radius-row: 0.95rem;
|
||||
--space-page: 1.25rem;
|
||||
--space-card: 1.15rem;
|
||||
}
|
||||
|
||||
:global(html, body) {
|
||||
@@ -741,6 +737,356 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:global(:focus-visible) {
|
||||
outline: 3px solid color-mix(in srgb, var(--color-brand) 38%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
:global(.ui-stack) {
|
||||
display: grid;
|
||||
gap: var(--space-page);
|
||||
}
|
||||
|
||||
:global(.ui-panel),
|
||||
:global(.ui-metric-card) {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
:global(.ui-panel) {
|
||||
padding: var(--space-card);
|
||||
}
|
||||
|
||||
:global(.ui-panel-soft) {
|
||||
background: var(--panel-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-row);
|
||||
}
|
||||
|
||||
:global(.ui-section-heading) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
:global(.ui-section-heading h3),
|
||||
:global(.ui-section-heading h4) {
|
||||
margin: 0.18rem 0 0;
|
||||
font-size: 1.06rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
:global(.ui-eyebrow) {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
:global(.ui-muted) {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
:global(.ui-metric-row) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
:global(.ui-metric-card) {
|
||||
padding: 1.05rem 1.1rem;
|
||||
}
|
||||
|
||||
:global(.ui-metric-card span) {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
:global(.ui-metric-card strong) {
|
||||
display: block;
|
||||
margin: 0.5rem 0 0.28rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:global(.ui-metric-card p) {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
:global(.ui-button) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.6rem;
|
||||
padding: 0.72rem 0.9rem;
|
||||
border-radius: var(--radius-control);
|
||||
font-weight: 600;
|
||||
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), color 160ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
:global(.ui-button.primary) {
|
||||
border: 1px solid var(--color-brand);
|
||||
color: oklch(0.99 0.004 150);
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
:global(.ui-button.primary:hover:not(:disabled)) {
|
||||
background: var(--color-brand-hover);
|
||||
border-color: var(--color-brand-hover);
|
||||
}
|
||||
|
||||
:global(.ui-button.secondary) {
|
||||
border: 1px solid var(--line-strong);
|
||||
color: var(--text);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
:global(.ui-button.secondary:hover:not(:disabled)) {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
:global(.ui-button:disabled) {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:global(.ui-pill) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.74rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.ui-pill.positive) {
|
||||
color: var(--green-deep);
|
||||
background: var(--green-soft);
|
||||
}
|
||||
|
||||
:global(.ui-pill.warning) {
|
||||
color: oklch(0.45 0.11 69);
|
||||
background: var(--color-warning-tint);
|
||||
}
|
||||
|
||||
:global(.ui-pill.neutral) {
|
||||
color: var(--color-text-secondary);
|
||||
background: color-mix(in srgb, var(--panel-soft) 74%, var(--panel));
|
||||
}
|
||||
|
||||
:global(.ui-table-wrap) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:global(.ui-table) {
|
||||
width: 100%;
|
||||
min-width: 48rem;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 0.65rem;
|
||||
}
|
||||
|
||||
:global(.ui-table th),
|
||||
:global(.ui-table td) {
|
||||
padding: 0.9rem 0.95rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.ui-table th) {
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
:global(.ui-table tbody td) {
|
||||
background: var(--panel-soft);
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
:global(.ui-table tbody td:first-child) {
|
||||
border-left: 1px solid var(--line);
|
||||
border-radius: var(--radius-row) 0 0 var(--radius-row);
|
||||
}
|
||||
|
||||
:global(.ui-table tbody td:last-child) {
|
||||
border-right: 1px solid var(--line);
|
||||
border-radius: 0 var(--radius-row) var(--radius-row) 0;
|
||||
}
|
||||
|
||||
:global(.ui-table-identity) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.74rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.ui-row-mark) {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.76rem;
|
||||
color: oklch(0.99 0.004 150);
|
||||
background: var(--green-deep);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
:global(.ui-table-identity strong),
|
||||
:global(.ui-number-block strong) {
|
||||
display: block;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
:global(.ui-table-identity span),
|
||||
:global(.ui-number-block span) {
|
||||
display: block;
|
||||
margin-top: 0.16rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
:global(.ui-number-block) {
|
||||
display: grid;
|
||||
gap: 0.08rem;
|
||||
}
|
||||
|
||||
:global(.ui-form-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
:global(.ui-form-grid.compact) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
:global(.ui-field) {
|
||||
display: grid;
|
||||
gap: 0.36rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.ui-field input),
|
||||
:global(.ui-field textarea),
|
||||
:global(.ui-field select) {
|
||||
width: 100%;
|
||||
padding: 0.82rem 0.9rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1), border-color 160ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 160ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
:global(.ui-field input:focus),
|
||||
:global(.ui-field textarea:focus),
|
||||
:global(.ui-field select:focus) {
|
||||
outline: none;
|
||||
border-color: var(--color-brand);
|
||||
background: var(--panel);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
:global(.ui-metric-row) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
:global(.ui-section-heading) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
:global(.ui-table) {
|
||||
min-width: 0;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
:global(.ui-table),
|
||||
:global(.ui-table thead),
|
||||
:global(.ui-table tbody),
|
||||
:global(.ui-table tr),
|
||||
:global(.ui-table td) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.ui-table thead) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.ui-table tbody) {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
:global(.ui-table tbody tr) {
|
||||
padding: 0.3rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-row);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
:global(.ui-table tbody td) {
|
||||
padding: 0.76rem 0.8rem;
|
||||
white-space: normal;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.ui-table tbody td:first-child),
|
||||
:global(.ui-table tbody td:last-child) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:global(.ui-table tbody td + td) {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
:global(.ui-table tbody td::before) {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
:global(.ui-form-grid),
|
||||
:global(.ui-form-grid.compact) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 252px minmax(0, 1fr);
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import {
|
||||
Boxes,
|
||||
Calculator,
|
||||
ClipboardList,
|
||||
DollarSign,
|
||||
ClipboardPenLine,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
LayoutDashboard,
|
||||
ShieldCheck,
|
||||
TrendingUp,
|
||||
Wheat,
|
||||
Workflow
|
||||
TrendingUp
|
||||
} from 'lucide-svelte';
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
@@ -63,6 +59,15 @@ export const mixCalculatorItem: NavItem = {
|
||||
moduleKey: 'mix_calculator'
|
||||
};
|
||||
|
||||
export const editorItem: NavItem = {
|
||||
href: '/editor',
|
||||
label: 'Editor',
|
||||
shortLabel: 'ED',
|
||||
icon: ClipboardPenLine,
|
||||
moduleKey: 'products',
|
||||
badge: 'test'
|
||||
};
|
||||
|
||||
export const reportingItem: NavItem = {
|
||||
href: '/reporting',
|
||||
label: 'Reporting',
|
||||
@@ -81,10 +86,7 @@ export const throughputItem: NavItem = {
|
||||
};
|
||||
|
||||
export const workingDocumentItems: NavItem[] = [
|
||||
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', icon: Wheat, moduleKey: 'raw_materials' },
|
||||
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' },
|
||||
{ href: '/products', label: 'Products', shortLabel: 'PR', icon: Boxes, moduleKey: 'products' },
|
||||
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', icon: Workflow, moduleKey: 'scenarios' }
|
||||
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' }
|
||||
];
|
||||
|
||||
export const accessControlItem: NavItem = {
|
||||
@@ -99,28 +101,25 @@ export const clientNavigationItems: NavItem[] = [
|
||||
dashboardItem,
|
||||
mixCalculatorItem,
|
||||
throughputItem,
|
||||
...workingDocumentItems,
|
||||
editorItem,
|
||||
accessControlItem
|
||||
];
|
||||
|
||||
export const footerLinks: FooterLink[] = [
|
||||
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP', icon: DollarSign },
|
||||
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV', icon: ClipboardList }
|
||||
];
|
||||
export const footerLinks: FooterLink[] = [];
|
||||
|
||||
export const baseSearchItems: SearchItem[] = [
|
||||
{
|
||||
href: '/editor',
|
||||
label: 'Open Editor',
|
||||
description: 'Edit client, product, and mix naming from one table.',
|
||||
keywords: 'editor products mixes clients names bulk table phf horse manning'
|
||||
},
|
||||
{
|
||||
href: '/',
|
||||
label: 'Open Dashboard',
|
||||
description: 'Jump to the Hunter Premium Produce workspace summary.',
|
||||
keywords: 'hunter premium produce overview dashboard workspace home'
|
||||
},
|
||||
{
|
||||
href: '/raw-materials',
|
||||
label: 'Open Raw Materials',
|
||||
description: 'Review live input costs that feed the pricing model.',
|
||||
keywords: 'raw materials pricing inputs costs supplier'
|
||||
},
|
||||
{
|
||||
href: '/mixes',
|
||||
label: 'Open Mix Master',
|
||||
@@ -149,12 +148,6 @@ export const baseSearchItems: SearchItem[] = [
|
||||
description: 'Run a new client-specific mix calculation session.',
|
||||
keywords: 'new mix calculator session client batch size product bags print'
|
||||
},
|
||||
{
|
||||
href: '/products',
|
||||
label: 'Open Products',
|
||||
description: 'Review delivered product pricing and margins.',
|
||||
keywords: 'products pricing margins delivered outputs'
|
||||
},
|
||||
{
|
||||
href: '/reporting',
|
||||
label: 'Open Reporting',
|
||||
@@ -167,12 +160,6 @@ export const baseSearchItems: SearchItem[] = [
|
||||
description: 'Review account details and workspace preferences.',
|
||||
keywords: 'settings account preferences profile workspace'
|
||||
},
|
||||
{
|
||||
href: '/scenarios',
|
||||
label: 'Open Scenarios',
|
||||
description: 'Inspect planning scenarios and overrides.',
|
||||
keywords: 'scenarios sandbox overrides compare planning'
|
||||
}
|
||||
];
|
||||
|
||||
export function matchesRoute(href: string, pathname: string) {
|
||||
@@ -198,6 +185,10 @@ export function clientBreadcrumbs(pathname: string, session?: AppSession | null)
|
||||
return [...crumbs, { label: 'Mix Calculator' }];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/editor')) {
|
||||
return [...crumbs, { label: 'Editor' }];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/mixes')) {
|
||||
return [...crumbs, { label: 'Mix Master' }];
|
||||
}
|
||||
|
||||
@@ -208,6 +208,39 @@ export type ProductCostBreakdown = {
|
||||
inputs?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type EditorProductRow = {
|
||||
id: number;
|
||||
tenant_id: string;
|
||||
client_name: string;
|
||||
item_id: string | null;
|
||||
name: string;
|
||||
mix_id: number;
|
||||
mix_client_name: string;
|
||||
mix_name: string;
|
||||
sale_type: string;
|
||||
unit_of_measure: string;
|
||||
visible: boolean;
|
||||
product_notes: string | null;
|
||||
mix_notes: string | null;
|
||||
};
|
||||
|
||||
export type EditorProductUpdateInput = {
|
||||
client_name?: string;
|
||||
item_id?: string | null;
|
||||
name?: string;
|
||||
mix_id?: number;
|
||||
sale_type?: string;
|
||||
unit_of_measure?: string;
|
||||
visible?: boolean;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type EditorMixUpdateInput = {
|
||||
client_name?: string;
|
||||
name?: string;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type Scenario = {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { canAccessRoute, getDefaultRouteForRole, getWorkspaceRole } from './workspace-access';
|
||||
import { canAccessRoute, canOpenEditor, getDefaultRouteForRole, getWorkspaceRole } from './workspace-access';
|
||||
|
||||
describe('workspace access policy', () => {
|
||||
const operationsSession = {
|
||||
@@ -21,6 +21,51 @@ describe('workspace access policy', () => {
|
||||
token: 'token'
|
||||
};
|
||||
|
||||
const fullAccessSession = {
|
||||
role: 'internal',
|
||||
role_name: 'Full Access',
|
||||
permissions: ['edit_products', 'edit_mixes'],
|
||||
name: 'Full User',
|
||||
email: 'full@example.com',
|
||||
token: 'token'
|
||||
};
|
||||
|
||||
const leanSession = {
|
||||
role: 'internal',
|
||||
role_name: 'lean',
|
||||
permissions: [
|
||||
'view_dashboard',
|
||||
'edit_products',
|
||||
'edit_mixes',
|
||||
'edit_scenarios',
|
||||
'manage_client_access',
|
||||
'view_settings'
|
||||
],
|
||||
module_permissions: {
|
||||
dashboard: 'view',
|
||||
products: 'edit',
|
||||
mix_master: 'edit',
|
||||
scenarios: 'edit',
|
||||
client_access: 'manage'
|
||||
},
|
||||
name: 'Lean User',
|
||||
email: 'lean@example.com',
|
||||
token: 'token'
|
||||
};
|
||||
|
||||
const ownerSession = {
|
||||
role: 'client',
|
||||
client_role: 'superadmin',
|
||||
module_permissions: {
|
||||
products: 'edit',
|
||||
mix_master: 'edit',
|
||||
client_access: 'manage'
|
||||
},
|
||||
name: 'Owner User',
|
||||
email: 'owner@example.com',
|
||||
token: 'token'
|
||||
};
|
||||
|
||||
it('classifies operations users and sends them to mix calculator by default', () => {
|
||||
expect(getWorkspaceRole(operationsSession)).toBe('operations');
|
||||
expect(getDefaultRouteForRole(operationsSession)).toBe('/mix-calculator');
|
||||
@@ -35,4 +80,19 @@ describe('workspace access policy', () => {
|
||||
expect(getWorkspaceRole(adminSession)).toBe('admin');
|
||||
expect(canAccessRoute(adminSession, '/')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats lean users as owner-level internal admins', () => {
|
||||
expect(getWorkspaceRole(leanSession)).toBe('admin');
|
||||
expect(canOpenEditor(leanSession)).toBe(true);
|
||||
expect(canAccessRoute(leanSession, '/scenarios')).toBe(true);
|
||||
expect(canAccessRoute(leanSession, '/client-access')).toBe(true);
|
||||
});
|
||||
|
||||
it('limits editor access to internal admin sessions', () => {
|
||||
expect(canOpenEditor(adminSession)).toBe(true);
|
||||
expect(canOpenEditor(ownerSession)).toBe(false);
|
||||
expect(canOpenEditor(fullAccessSession)).toBe(false);
|
||||
expect(canAccessRoute(ownerSession, '/editor')).toBe(false);
|
||||
expect(canAccessRoute(fullAccessSession, '/editor')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,10 @@ function canAccessWorkspaceArea(
|
||||
return hasModuleAccess(session, moduleKey, minimumLevel);
|
||||
}
|
||||
|
||||
function isLeanAdminRole(session: AppSession | null | undefined) {
|
||||
return session?.role === 'admin' || (session?.role === 'internal' && ['admin', 'lean'].includes(session.role_name?.toLowerCase() ?? ''));
|
||||
}
|
||||
|
||||
export function getWorkspaceRole(session: AppSession | null | undefined): WorkspaceRole {
|
||||
if (!session) {
|
||||
return 'unknown';
|
||||
@@ -42,7 +46,7 @@ export function getWorkspaceRole(session: AppSession | null | undefined): Worksp
|
||||
return 'client';
|
||||
}
|
||||
|
||||
if (session.role_name === 'Admin') {
|
||||
if (isLeanAdminRole(session)) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
@@ -85,6 +89,14 @@ export function canOpenProducts(session: AppSession | null | undefined) {
|
||||
return canAccessWorkspaceArea(session, 'products', ['view_products', 'edit_products']);
|
||||
}
|
||||
|
||||
export function canOpenEditor(session: AppSession | null | undefined) {
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isLeanAdminRole(session);
|
||||
}
|
||||
|
||||
export function canOpenScenarios(session: AppSession | null | undefined) {
|
||||
return !!session && hasModuleAccess(session, 'scenarios');
|
||||
}
|
||||
@@ -129,6 +141,7 @@ export const routeAccessRules: RouteAccessRule[] = [
|
||||
},
|
||||
{ path: '/mixes', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/mixes') },
|
||||
{ path: '/products', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/products') },
|
||||
{ path: '/editor', roles: ['admin'], matches: (pathname) => hasPathPrefix(pathname, '/editor') },
|
||||
{ path: '/reporting', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/reporting') },
|
||||
{ path: '/scenarios', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/scenarios') },
|
||||
{
|
||||
@@ -184,6 +197,7 @@ export function canAccessRoute(session: AppSession | null | undefined, pathname:
|
||||
if (pathname.startsWith('/raw-materials')) return canOpenRawMaterials(session);
|
||||
if (pathname.startsWith('/mixes')) return canOpenMixMaster(session);
|
||||
if (pathname.startsWith('/products')) return canOpenProducts(session);
|
||||
if (pathname.startsWith('/editor')) return canOpenEditor(session);
|
||||
if (pathname.startsWith('/scenarios')) return canOpenScenarios(session);
|
||||
if (pathname.startsWith('/reporting')) return canOpenReporting(session);
|
||||
if (pathname.startsWith('/settings')) return canOpenSettings(session);
|
||||
@@ -197,6 +211,7 @@ export function canUseWorkspaceSearch(session: AppSession | null | undefined) {
|
||||
canOpenDashboard(session) ||
|
||||
canOpenRawMaterials(session) ||
|
||||
canOpenMixMaster(session) ||
|
||||
canOpenEditor(session) ||
|
||||
canOpenMixCalculator(session) ||
|
||||
canOpenProducts(session) ||
|
||||
canOpenScenarios(session)
|
||||
|
||||
Reference in New Issue
Block a user