v0.1.11 - Editor

This commit is contained in:
2026-06-03 00:17:12 +12:00
parent f5a588d631
commit cf968e802b
23 changed files with 2165 additions and 655 deletions
+22
View File
@@ -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),
+391 -45
View File
@@ -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' }];
}
+33
View File
@@ -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;
+61 -1
View File
@@ -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);
});
});
+16 -1
View File
@@ -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)