Move working documents to its own area, rename dashboard
This commit is contained in:
+18
-6
@@ -13,6 +13,7 @@ import type {
|
||||
ClientAccessAccount,
|
||||
ClientAccessPowerBiExport,
|
||||
ClientUserCreateInput,
|
||||
ClientUserModulePermission,
|
||||
ClientUserUpdateInput,
|
||||
LoginResponse,
|
||||
Mix,
|
||||
@@ -30,7 +31,7 @@ import { getStoredAdminSession, getStoredClientSession } from '$lib/session';
|
||||
|
||||
const DEFAULT_API_PORT = env.PUBLIC_API_PORT || '8000';
|
||||
|
||||
type AuthMode = 'none' | 'client' | 'admin';
|
||||
type AuthMode = 'none' | 'client' | 'admin' | 'manager';
|
||||
type ApiFetch = typeof fetch;
|
||||
|
||||
function getApiBaseUrl() {
|
||||
@@ -63,6 +64,10 @@ function getToken(auth: AuthMode) {
|
||||
return getStoredAdminSession()?.token ?? null;
|
||||
}
|
||||
|
||||
if (auth === 'manager') {
|
||||
return getStoredAdminSession()?.token ?? getStoredClientSession()?.token ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -127,9 +132,9 @@ export const api = {
|
||||
productCosts: (fetcher?: ApiFetch) =>
|
||||
fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
|
||||
scenarios: (fetcher?: ApiFetch) => fetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
|
||||
clientAccess: (fetcher?: ApiFetch) => fetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'admin', fetcher),
|
||||
clientAccess: (fetcher?: ApiFetch) => fetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher),
|
||||
clientAccessExport: (fetcher?: ApiFetch) =>
|
||||
fetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'admin', fetcher),
|
||||
fetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
|
||||
dataQuality: (fetcher?: ApiFetch) => fetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher),
|
||||
clientLogin: (email: string, password: string) =>
|
||||
request<LoginResponse>('/api/auth/client/login', {
|
||||
@@ -141,6 +146,8 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password })
|
||||
}),
|
||||
clientSession: (fetcher?: ApiFetch) => request<LoginResponse>('/api/auth/client/session', { method: 'GET' }, 'client', fetcher),
|
||||
adminSession: (fetcher?: ApiFetch) => request<LoginResponse>('/api/auth/admin/session', { method: 'GET' }, 'admin', fetcher),
|
||||
login: (email: string, password: string) =>
|
||||
request<LoginResponse>('/api/auth/client/login', {
|
||||
method: 'POST',
|
||||
@@ -184,15 +191,20 @@ export const api = {
|
||||
request<ClientAccessAccount>('/api/client-access/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'admin'),
|
||||
}, 'manager'),
|
||||
updateClientUser: (userId: number, payload: ClientUserUpdateInput) =>
|
||||
request<ClientAccessAccount>(`/api/client-access/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'admin'),
|
||||
}, 'manager'),
|
||||
updateClientUserModulePermission: (userId: number, permission: Pick<ClientUserModulePermission, 'module_key'>, payload: { access_level: string }) =>
|
||||
request<ClientAccessAccount>(`/api/client-access/users/${userId}/module-permissions/${permission.module_key}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'manager'),
|
||||
updateClientFeature: (featureId: number, payload: { enabled: boolean }) =>
|
||||
request<ClientAccessAccount>(`/api/client-access/features/${featureId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'admin')
|
||||
}, 'manager')
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { clientSession, sessionHydrated } from '$lib/session';
|
||||
import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import packageInfo from '../../../package.json';
|
||||
|
||||
@@ -18,22 +18,23 @@
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
icon?: 'home';
|
||||
moduleKey?: string;
|
||||
};
|
||||
|
||||
const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home' };
|
||||
const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home', moduleKey: 'dashboard' };
|
||||
const workingDocumentItems: NavItem[] = [
|
||||
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM' },
|
||||
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM' },
|
||||
{ href: '/products', label: 'Products', shortLabel: 'PR' },
|
||||
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC' }
|
||||
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', moduleKey: 'raw_materials' },
|
||||
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', moduleKey: 'mix_master' },
|
||||
{ href: '/products', label: 'Products', shortLabel: 'PR', moduleKey: 'products' },
|
||||
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', moduleKey: 'scenarios' }
|
||||
];
|
||||
const navigation = [dashboardItem, ...workingDocumentItems];
|
||||
const accessControlItem: NavItem = { href: '/client-access', label: 'Client Access', shortLabel: 'AC', moduleKey: 'client_access' };
|
||||
const navigation = [dashboardItem, ...workingDocumentItems, accessControlItem];
|
||||
|
||||
const footerLinks = [
|
||||
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' },
|
||||
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV' }
|
||||
];
|
||||
const primaryBottomNavigation = [dashboardItem, ...workingDocumentItems.slice(0, 3)];
|
||||
|
||||
const searchItems: SearchItem[] = [
|
||||
{
|
||||
@@ -88,12 +89,30 @@
|
||||
let quickMenuOpen = $state(false);
|
||||
let userMenuOpen = $state(false);
|
||||
let navOpen = $state(false);
|
||||
let workingDocumentsExpanded = $state(true);
|
||||
let showBottomNav = $state(false);
|
||||
let isRestoringSession = $state(false);
|
||||
let restoredToken = $state<string | null>(null);
|
||||
let paletteInput: HTMLInputElement | null = $state(null);
|
||||
const appVersion = `v${packageInfo.version}`;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const visibleDashboardItem = $derived(
|
||||
!$clientSession || !dashboardItem.moduleKey || hasModuleAccess($clientSession, dashboardItem.moduleKey) ? dashboardItem : null
|
||||
);
|
||||
const visibleWorkingDocumentItems = $derived(
|
||||
!$clientSession
|
||||
? workingDocumentItems
|
||||
: workingDocumentItems.filter((item) => !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey))
|
||||
);
|
||||
const visibleFooterLinks = $derived([
|
||||
...footerLinks,
|
||||
...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage')
|
||||
? []
|
||||
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel }])
|
||||
]);
|
||||
const primaryBottomNavigation = $derived(
|
||||
[...(visibleDashboardItem ? [visibleDashboardItem] : []), ...visibleWorkingDocumentItems.slice(0, 3)]
|
||||
);
|
||||
|
||||
function matchesRoute(href: string, pathname: string) {
|
||||
return href === '/' ? pathname === '/' : pathname.startsWith(href);
|
||||
@@ -111,7 +130,8 @@
|
||||
'/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce',
|
||||
'/products': 'Track delivered product pricing and margin views',
|
||||
'/settings': 'Review your workspace profile and application settings',
|
||||
'/scenarios': 'Compare alternate pricing and production assumptions'
|
||||
'/scenarios': 'Compare alternate pricing and production assumptions',
|
||||
'/client-access': 'Manage user access, module permissions, and audit history'
|
||||
};
|
||||
|
||||
return descriptions[pathname] ?? 'Hunter Premium Produce client workspace';
|
||||
@@ -256,50 +276,63 @@
|
||||
</button>
|
||||
|
||||
<nav class="nav-list" aria-label="Client navigation">
|
||||
<a class:active={matchesRoute(dashboardItem.href, page.url.pathname)} href={dashboardItem.href}>
|
||||
<span class="nav-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M3.75 10.5 12 3.75l8.25 6.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.85"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.25 9.75v9h13.5v-9"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.85"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.125 18.75v-5.25h3.75v5.25"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.85"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>{dashboardItem.label}</span>
|
||||
</a>
|
||||
{#if visibleDashboardItem}
|
||||
<a class:active={matchesRoute(visibleDashboardItem.href, page.url.pathname)} href={visibleDashboardItem.href}>
|
||||
<span class="nav-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M3.75 10.5 12 3.75l8.25 6.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.85"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.25 9.75v9h13.5v-9"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.85"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.125 18.75v-5.25h3.75v5.25"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.85"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>{visibleDashboardItem.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<div class="nav-group" aria-label="Working documents">
|
||||
<p class="nav-group-label">Working Documents</p>
|
||||
<nav class="nav-sublist" aria-label="Working document pages">
|
||||
{#each workingDocumentItems as item}
|
||||
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
|
||||
<span class="nav-icon">{item.shortLabel}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="nav-group" aria-label="Working documents" hidden={!visibleWorkingDocumentItems.length}>
|
||||
<button
|
||||
aria-controls="working-documents-nav"
|
||||
aria-expanded={workingDocumentsExpanded}
|
||||
class="nav-group-toggle"
|
||||
type="button"
|
||||
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
|
||||
>
|
||||
<span class="nav-group-label">Working Documents</span>
|
||||
<span class:open={workingDocumentsExpanded} class="chevron"></span>
|
||||
</button>
|
||||
{#if workingDocumentsExpanded}
|
||||
<nav class="nav-sublist" id="working-documents-nav" aria-label="Working document pages">
|
||||
{#each visibleWorkingDocumentItems as item}
|
||||
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
|
||||
<span class="nav-icon">{item.shortLabel}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
{#each footerLinks as item}
|
||||
{#each visibleFooterLinks as item}
|
||||
<a href={item.href}>
|
||||
<span class="nav-icon muted">{item.shortLabel}</span>
|
||||
<span>{item.label}</span>
|
||||
@@ -498,43 +531,58 @@
|
||||
|
||||
<div class="drawer-grid">
|
||||
<nav class="drawer-section" aria-label="All workspace pages">
|
||||
<a class:active={matchesRoute(dashboardItem.href, page.url.pathname)} href={dashboardItem.href} onclick={() => (navOpen = false)}>
|
||||
<span class="nav-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M3.75 10.5 12 3.75l8.25 6.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.85"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.25 9.75v9h13.5v-9"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.85"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.125 18.75v-5.25h3.75v5.25"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.85"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>{dashboardItem.label}</span>
|
||||
</a>
|
||||
{#if visibleDashboardItem}
|
||||
<a class:active={matchesRoute(visibleDashboardItem.href, page.url.pathname)} href={visibleDashboardItem.href} onclick={() => (navOpen = false)}>
|
||||
<span class="nav-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M3.75 10.5 12 3.75l8.25 6.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.85"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.25 9.75v9h13.5v-9"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.85"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.125 18.75v-5.25h3.75v5.25"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.85"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>{visibleDashboardItem.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<div class="drawer-group">
|
||||
<p class="drawer-group-label">Working Documents</p>
|
||||
{#each workingDocumentItems as item}
|
||||
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href} onclick={() => (navOpen = false)}>
|
||||
<span class="nav-icon">{item.shortLabel}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
<div class="drawer-group" hidden={!visibleWorkingDocumentItems.length}>
|
||||
<button
|
||||
aria-controls="drawer-working-documents-nav"
|
||||
aria-expanded={workingDocumentsExpanded}
|
||||
class="nav-group-toggle drawer-group-toggle"
|
||||
type="button"
|
||||
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
|
||||
>
|
||||
<span class="drawer-group-label">Working Documents</span>
|
||||
<span class:open={workingDocumentsExpanded} class="chevron"></span>
|
||||
</button>
|
||||
{#if workingDocumentsExpanded}
|
||||
<div id="drawer-working-documents-nav" class="drawer-sublist">
|
||||
{#each visibleWorkingDocumentItems as item}
|
||||
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href} onclick={() => (navOpen = false)}>
|
||||
<span class="nav-icon">{item.shortLabel}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -565,7 +613,7 @@
|
||||
</div>
|
||||
|
||||
<div class="drawer-footer">
|
||||
{#each footerLinks as item}
|
||||
{#each visibleFooterLinks as item}
|
||||
<a href={item.href} onclick={() => (navOpen = false)}>
|
||||
<span>{item.label}</span>
|
||||
<small>{item.shortLabel}</small>
|
||||
@@ -683,6 +731,10 @@
|
||||
padding: 0.9rem;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--line);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-body {
|
||||
@@ -865,6 +917,19 @@
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.nav-group-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0 0.68rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-group-label,
|
||||
.drawer-group-label {
|
||||
margin: 0;
|
||||
@@ -876,7 +941,7 @@
|
||||
}
|
||||
|
||||
.nav-group-label {
|
||||
padding: 0 0.68rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-sublist {
|
||||
@@ -917,6 +982,7 @@
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding-top: 0.6rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-meta {
|
||||
@@ -925,6 +991,7 @@
|
||||
padding: 0.85rem 0.3rem 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-meta small {
|
||||
@@ -1429,10 +1496,19 @@
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
|
||||
.drawer-group-label {
|
||||
.drawer-group-toggle {
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.drawer-group-label {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.drawer-sublist {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
|
||||
+153
-8
@@ -8,6 +8,46 @@ import type {
|
||||
Scenario
|
||||
} from '$lib/types';
|
||||
|
||||
const MODULE_PERMISSIONS = {
|
||||
superadmin: {
|
||||
dashboard: 'edit',
|
||||
raw_materials: 'edit',
|
||||
mix_master: 'edit',
|
||||
products: 'edit',
|
||||
scenarios: 'edit',
|
||||
powerbi_export: 'edit',
|
||||
client_access: 'manage'
|
||||
},
|
||||
operator: {
|
||||
dashboard: 'edit',
|
||||
raw_materials: 'edit',
|
||||
mix_master: 'edit',
|
||||
products: 'edit',
|
||||
scenarios: 'edit',
|
||||
powerbi_export: 'none',
|
||||
client_access: 'none'
|
||||
},
|
||||
viewer: {
|
||||
dashboard: 'view',
|
||||
raw_materials: 'none',
|
||||
mix_master: 'none',
|
||||
products: 'view',
|
||||
scenarios: 'none',
|
||||
powerbi_export: 'view',
|
||||
client_access: 'none'
|
||||
}
|
||||
} as const;
|
||||
|
||||
const MODULE_DETAILS = [
|
||||
['dashboard', 'Dashboard', 'workspace', 'Top-level operational dashboard'],
|
||||
['raw_materials', 'Raw Materials', 'costing', 'Maintain live material costs and versions'],
|
||||
['mix_master', 'Mix Master', 'costing', 'Create and maintain mix worksheets'],
|
||||
['products', 'Products', 'pricing', 'Review finished product pricing'],
|
||||
['scenarios', 'Scenarios', 'planning', 'Run scenario overrides and comparisons'],
|
||||
['powerbi_export', 'Power BI Export', 'reporting', 'Expose client access data to BI consumers'],
|
||||
['client_access', 'Client Access', 'administration', 'Manage user access, module permissions, and audit history']
|
||||
] as const;
|
||||
|
||||
export const mockRawMaterials: RawMaterial[] = [
|
||||
{
|
||||
id: 1,
|
||||
@@ -117,19 +157,31 @@ export const mockClientAccess: ClientAccessAccount[] = [
|
||||
created_at: '2026-04-20T09:00:00',
|
||||
active_user_count: 1,
|
||||
new_user_count: 1,
|
||||
enabled_feature_count: 6,
|
||||
total_feature_count: 6,
|
||||
enabled_feature_count: 7,
|
||||
total_feature_count: 7,
|
||||
users: [
|
||||
{
|
||||
id: 1,
|
||||
client_account_id: 1,
|
||||
full_name: 'Amelia Hart',
|
||||
email: 'operator@example.com',
|
||||
role: 'admin',
|
||||
role: 'superadmin',
|
||||
status: 'active',
|
||||
is_new_user: false,
|
||||
last_login_at: '2026-04-24T11:30:00',
|
||||
created_at: '2026-04-20T09:00:00'
|
||||
created_at: '2026-04-20T09:00:00',
|
||||
module_permissions: MODULE_DETAILS.map(([module_key, module_name, module_group, description], index) => ({
|
||||
id: index + 1,
|
||||
client_account_id: 1,
|
||||
client_user_id: 1,
|
||||
module_key,
|
||||
module_name,
|
||||
module_group,
|
||||
description,
|
||||
access_level: MODULE_PERMISSIONS.superadmin[module_key],
|
||||
updated_at: '2026-04-24T15:00:00',
|
||||
created_at: '2026-04-20T09:00:00'
|
||||
}))
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@@ -140,7 +192,19 @@ export const mockClientAccess: ClientAccessAccount[] = [
|
||||
status: 'invited',
|
||||
is_new_user: true,
|
||||
last_login_at: null,
|
||||
created_at: '2026-04-24T15:00:00'
|
||||
created_at: '2026-04-24T15:00:00',
|
||||
module_permissions: MODULE_DETAILS.map(([module_key, module_name, module_group, description], index) => ({
|
||||
id: index + 101,
|
||||
client_account_id: 1,
|
||||
client_user_id: 2,
|
||||
module_key,
|
||||
module_name,
|
||||
module_group,
|
||||
description,
|
||||
access_level: MODULE_PERMISSIONS.operator[module_key],
|
||||
updated_at: '2026-04-24T15:00:00',
|
||||
created_at: '2026-04-24T15:00:00'
|
||||
}))
|
||||
}
|
||||
],
|
||||
features: [
|
||||
@@ -209,6 +273,33 @@ export const mockClientAccess: ClientAccessAccount[] = [
|
||||
enabled: true,
|
||||
updated_at: '2026-04-24T15:00:00',
|
||||
created_at: '2026-04-20T09:00:00'
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
client_account_id: 1,
|
||||
feature_key: 'client_access',
|
||||
feature_name: 'Client Access',
|
||||
feature_group: 'administration',
|
||||
description: 'Manage user access, module permissions, and audit history',
|
||||
enabled: true,
|
||||
updated_at: '2026-04-24T15:00:00',
|
||||
created_at: '2026-04-20T09:00:00'
|
||||
}
|
||||
],
|
||||
audit_history: [
|
||||
{
|
||||
id: 1,
|
||||
client_account_id: 1,
|
||||
actor_type: 'lean_admin',
|
||||
actor_name: 'Lean 101 Seeder',
|
||||
actor_email: 'system@lean101.local',
|
||||
actor_role: 'system',
|
||||
action: 'client_access.seeded',
|
||||
target_type: 'client_account',
|
||||
target_id: 1,
|
||||
module_key: 'client_access',
|
||||
summary: 'Initial client access controls, module permissions, and feature flags were seeded.',
|
||||
created_at: '2026-04-20T09:00:00'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -224,7 +315,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
|
||||
active_user_count: 1,
|
||||
new_user_count: 0,
|
||||
enabled_feature_count: 3,
|
||||
total_feature_count: 6,
|
||||
total_feature_count: 7,
|
||||
users: [
|
||||
{
|
||||
id: 3,
|
||||
@@ -235,7 +326,19 @@ export const mockClientAccess: ClientAccessAccount[] = [
|
||||
status: 'active',
|
||||
is_new_user: false,
|
||||
last_login_at: '2026-04-22T09:10:00',
|
||||
created_at: '2026-04-21T10:00:00'
|
||||
created_at: '2026-04-21T10:00:00',
|
||||
module_permissions: MODULE_DETAILS.map(([module_key, module_name, module_group, description], index) => ({
|
||||
id: index + 201,
|
||||
client_account_id: 2,
|
||||
client_user_id: 3,
|
||||
module_key,
|
||||
module_name,
|
||||
module_group,
|
||||
description,
|
||||
access_level: MODULE_PERMISSIONS.viewer[module_key],
|
||||
updated_at: '2026-04-22T09:10:00',
|
||||
created_at: '2026-04-21T10:00:00'
|
||||
}))
|
||||
}
|
||||
],
|
||||
features: [
|
||||
@@ -304,8 +407,20 @@ export const mockClientAccess: ClientAccessAccount[] = [
|
||||
enabled: true,
|
||||
updated_at: '2026-04-22T09:10:00',
|
||||
created_at: '2026-04-21T10:00:00'
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
client_account_id: 2,
|
||||
feature_key: 'client_access',
|
||||
feature_name: 'Client Access',
|
||||
feature_group: 'administration',
|
||||
description: 'Manage user access, module permissions, and audit history',
|
||||
enabled: false,
|
||||
updated_at: '2026-04-22T09:10:00',
|
||||
created_at: '2026-04-21T10:00:00'
|
||||
}
|
||||
]
|
||||
],
|
||||
audit_history: []
|
||||
}
|
||||
];
|
||||
|
||||
@@ -349,5 +464,35 @@ export const mockClientAccessExport: ClientAccessPowerBiExport = {
|
||||
updated_at: feature.updated_at
|
||||
}))
|
||||
),
|
||||
permission_rows: mockClientAccess.flatMap((client) =>
|
||||
client.users.flatMap((user) =>
|
||||
user.module_permissions.map((permission) => ({
|
||||
client_id: client.id,
|
||||
client_name: client.name,
|
||||
user_id: user.id,
|
||||
user_email: user.email,
|
||||
module_key: permission.module_key,
|
||||
module_name: permission.module_name,
|
||||
module_group: permission.module_group,
|
||||
access_level: permission.access_level,
|
||||
updated_at: permission.updated_at
|
||||
}))
|
||||
)
|
||||
),
|
||||
audit_rows: mockClientAccess.flatMap((client) =>
|
||||
client.audit_history.map((event) => ({
|
||||
client_id: client.id,
|
||||
client_name: client.name,
|
||||
event_id: event.id,
|
||||
actor_email: event.actor_email,
|
||||
actor_role: event.actor_role,
|
||||
action: event.action,
|
||||
target_type: event.target_type,
|
||||
target_id: event.target_id,
|
||||
module_key: event.module_key,
|
||||
summary: event.summary,
|
||||
created_at: event.created_at
|
||||
}))
|
||||
),
|
||||
clients: mockClientAccess
|
||||
};
|
||||
|
||||
@@ -7,6 +7,17 @@ export type AppSession = {
|
||||
role: string;
|
||||
token: string;
|
||||
tenant_id?: string | null;
|
||||
client_role?: string | null;
|
||||
user_id?: number | null;
|
||||
client_account_id?: number | null;
|
||||
module_permissions?: Record<string, string>;
|
||||
};
|
||||
|
||||
const ACCESS_LEVEL_ORDER: Record<string, number> = {
|
||||
none: 0,
|
||||
view: 1,
|
||||
edit: 2,
|
||||
manage: 3
|
||||
};
|
||||
|
||||
const CLIENT_STORAGE_KEY = 'data-entry-app-client-session';
|
||||
@@ -74,6 +85,23 @@ export function hasStoredAdminSession() {
|
||||
return getStoredAdminSession() !== null;
|
||||
}
|
||||
|
||||
export function hasModuleAccess(
|
||||
session: AppSession | null | undefined,
|
||||
moduleKey: string,
|
||||
minimumLevel: 'view' | 'edit' | 'manage' = 'view'
|
||||
) {
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.role === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentLevel = session.module_permissions?.[moduleKey] ?? 'none';
|
||||
return (ACCESS_LEVEL_ORDER[currentLevel] ?? 0) >= ACCESS_LEVEL_ORDER[minimumLevel];
|
||||
}
|
||||
|
||||
export const sessionHydrated = readable(false, (set) => {
|
||||
if (!browser) {
|
||||
return undefined;
|
||||
|
||||
@@ -130,6 +130,20 @@ export type ClientAccessUser = {
|
||||
is_new_user: boolean;
|
||||
last_login_at?: string | null;
|
||||
created_at: string;
|
||||
module_permissions: ClientUserModulePermission[];
|
||||
};
|
||||
|
||||
export type ClientUserModulePermission = {
|
||||
id: number;
|
||||
client_account_id: number;
|
||||
client_user_id: number;
|
||||
module_key: string;
|
||||
module_name: string;
|
||||
module_group: string;
|
||||
description?: string | null;
|
||||
access_level: string;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ClientAccessFeature = {
|
||||
@@ -159,6 +173,22 @@ export type ClientAccessAccount = {
|
||||
new_user_count: number;
|
||||
enabled_feature_count: number;
|
||||
total_feature_count: number;
|
||||
audit_history: ClientAccessAuditEvent[];
|
||||
};
|
||||
|
||||
export type ClientAccessAuditEvent = {
|
||||
id: number;
|
||||
client_account_id: number;
|
||||
actor_type: string;
|
||||
actor_name: string;
|
||||
actor_email: string;
|
||||
actor_role: string;
|
||||
action: string;
|
||||
target_type: string;
|
||||
target_id?: number | null;
|
||||
module_key?: string | null;
|
||||
summary: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ClientAccessExportRow = Record<string, unknown>;
|
||||
@@ -168,6 +198,8 @@ export type ClientAccessPowerBiExport = {
|
||||
client_rows: ClientAccessExportRow[];
|
||||
user_rows: ClientAccessExportRow[];
|
||||
feature_rows: ClientAccessExportRow[];
|
||||
permission_rows: ClientAccessExportRow[];
|
||||
audit_rows: ClientAccessExportRow[];
|
||||
clients: ClientAccessAccount[];
|
||||
};
|
||||
|
||||
@@ -177,6 +209,10 @@ export type LoginResponse = {
|
||||
role: string;
|
||||
token: string;
|
||||
tenant_id?: string | null;
|
||||
client_role?: string | null;
|
||||
user_id?: number | null;
|
||||
client_account_id?: number | null;
|
||||
module_permissions?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type RawMaterialCreateInput = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
@@ -12,13 +12,15 @@ export async function load({ fetch }) {
|
||||
};
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
|
||||
try {
|
||||
const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([
|
||||
api.rawMaterials(fetch),
|
||||
api.mixes(fetch),
|
||||
api.productCosts(fetch),
|
||||
api.scenarios(fetch),
|
||||
api.dataQuality(fetch)
|
||||
hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([]),
|
||||
hasModuleAccess(session, 'mix_master') ? api.mixes(fetch) : Promise.resolve([]),
|
||||
hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([]),
|
||||
hasModuleAccess(session, 'scenarios') ? api.scenarios(fetch) : Promise.resolve([]),
|
||||
hasModuleAccess(session, 'dashboard') ? api.dataQuality(fetch) : Promise.resolve([])
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,8 @@ export async function load({ fetch }) {
|
||||
client_rows: [],
|
||||
user_rows: [],
|
||||
feature_rows: [],
|
||||
permission_rows: [],
|
||||
audit_rows: [],
|
||||
clients: []
|
||||
}
|
||||
};
|
||||
@@ -30,6 +32,8 @@ export async function load({ fetch }) {
|
||||
client_rows: [],
|
||||
user_rows: [],
|
||||
feature_rows: [],
|
||||
permission_rows: [],
|
||||
audit_rows: [],
|
||||
clients: []
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ export async function load({ fetch }) {
|
||||
client_rows: [],
|
||||
user_rows: [],
|
||||
feature_rows: [],
|
||||
permission_rows: [],
|
||||
audit_rows: [],
|
||||
clients: []
|
||||
}
|
||||
};
|
||||
@@ -30,6 +32,8 @@ export async function load({ fetch }) {
|
||||
client_rows: [],
|
||||
user_rows: [],
|
||||
feature_rows: [],
|
||||
permission_rows: [],
|
||||
audit_rows: [],
|
||||
clients: []
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { api } from '$lib/api';
|
||||
import { hasStoredAdminSession, hasStoredClientSession } from '$lib/session';
|
||||
|
||||
export function load() {
|
||||
throw redirect(307, '/admin/client-access');
|
||||
function emptyPayload() {
|
||||
return {
|
||||
clients: [],
|
||||
exportPreview: {
|
||||
generated_at: '',
|
||||
client_rows: [],
|
||||
user_rows: [],
|
||||
feature_rows: [],
|
||||
permission_rows: [],
|
||||
audit_rows: [],
|
||||
clients: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function load({ fetch }) {
|
||||
if (!hasStoredAdminSession() && !hasStoredClientSession()) {
|
||||
return emptyPayload();
|
||||
}
|
||||
|
||||
try {
|
||||
const [clients, exportPreview] = await Promise.all([api.clientAccess(fetch), api.clientAccessExport(fetch)]);
|
||||
return { clients, exportPreview };
|
||||
} catch {
|
||||
return emptyPayload();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@ const apiMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
getStoredClientSession: vi.fn(),
|
||||
hasStoredClientSession: vi.fn(),
|
||||
hasStoredAdminSession: vi.fn()
|
||||
hasStoredAdminSession: vi.fn(),
|
||||
hasModuleAccess: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api', () => ({
|
||||
@@ -39,6 +41,8 @@ describe('route loaders use the SvelteKit fetch argument', () => {
|
||||
vi.clearAllMocks();
|
||||
sessionMocks.hasStoredClientSession.mockReturnValue(true);
|
||||
sessionMocks.hasStoredAdminSession.mockReturnValue(true);
|
||||
sessionMocks.getStoredClientSession.mockReturnValue({ role: 'client', module_permissions: {} });
|
||||
sessionMocks.hasModuleAccess.mockReturnValue(true);
|
||||
|
||||
apiMocks.rawMaterials.mockResolvedValue([{ id: 1 }]);
|
||||
apiMocks.mixes.mockResolvedValue([{ id: 2 }]);
|
||||
@@ -48,7 +52,15 @@ describe('route loaders use the SvelteKit fetch argument', () => {
|
||||
apiMocks.scenarios.mockResolvedValue([{ id: 5 }]);
|
||||
apiMocks.dataQuality.mockResolvedValue([{ id: 6 }]);
|
||||
apiMocks.clientAccess.mockResolvedValue([{ id: 7 }]);
|
||||
apiMocks.clientAccessExport.mockResolvedValue({ generated_at: '', clients: [] });
|
||||
apiMocks.clientAccessExport.mockResolvedValue({
|
||||
generated_at: '',
|
||||
client_rows: [],
|
||||
user_rows: [],
|
||||
feature_rows: [],
|
||||
permission_rows: [],
|
||||
audit_rows: [],
|
||||
clients: []
|
||||
});
|
||||
});
|
||||
|
||||
it('passes fetch through the home page loader', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
@@ -8,9 +8,11 @@ export async function load({ fetch }) {
|
||||
};
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
|
||||
try {
|
||||
return {
|
||||
mixes: await api.mixes(fetch)
|
||||
mixes: hasModuleAccess(session, 'mix_master') ? await api.mixes(fetch) : []
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { api } from '$lib/api';
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||
|
||||
export async function load({ params, fetch }) {
|
||||
const mixId = Number(params.id);
|
||||
@@ -16,8 +16,19 @@ export async function load({ params, fetch }) {
|
||||
};
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
if (!hasModuleAccess(session, 'mix_master')) {
|
||||
return {
|
||||
mix: null,
|
||||
rawMaterials: []
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const [mix, rawMaterials] = await Promise.all([api.mix(mixId, fetch), api.rawMaterials(fetch)]);
|
||||
const [mix, rawMaterials] = await Promise.all([
|
||||
api.mix(mixId, fetch),
|
||||
hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([])
|
||||
]);
|
||||
|
||||
return {
|
||||
mix,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
@@ -8,9 +8,11 @@ export async function load({ fetch }) {
|
||||
};
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
|
||||
try {
|
||||
return {
|
||||
rawMaterials: await api.rawMaterials(fetch)
|
||||
rawMaterials: hasModuleAccess(session, 'mix_master') && hasModuleAccess(session, 'raw_materials') ? await api.rawMaterials(fetch) : []
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
@@ -9,8 +9,13 @@ export async function load({ fetch }) {
|
||||
};
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
|
||||
try {
|
||||
const [products, productCosts] = await Promise.all([api.products(fetch), api.productCosts(fetch)]);
|
||||
const [products, productCosts] = await Promise.all([
|
||||
hasModuleAccess(session, 'products') ? api.products(fetch) : Promise.resolve([]),
|
||||
hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([])
|
||||
]);
|
||||
return {
|
||||
products,
|
||||
productCosts
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
@@ -11,12 +11,14 @@ export async function load({ fetch }) {
|
||||
};
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
|
||||
try {
|
||||
const [rawMaterials, mixes, products, productCosts] = await Promise.all([
|
||||
api.rawMaterials(fetch),
|
||||
api.mixes(fetch),
|
||||
api.products(fetch),
|
||||
api.productCosts(fetch)
|
||||
hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([]),
|
||||
hasModuleAccess(session, 'mix_master') ? api.mixes(fetch) : Promise.resolve([]),
|
||||
hasModuleAccess(session, 'products') ? api.products(fetch) : Promise.resolve([]),
|
||||
hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([])
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
@@ -8,9 +8,11 @@ export async function load({ fetch }) {
|
||||
};
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
|
||||
try {
|
||||
return {
|
||||
scenarios: await api.scenarios(fetch)
|
||||
scenarios: hasModuleAccess(session, 'scenarios') ? await api.scenarios(fetch) : []
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user