Move working documents to its own area, rename dashboard

This commit is contained in:
2026-04-29 01:21:16 +12:00
parent 7e9663fa06
commit 761ebb050d
32 changed files with 1779 additions and 526 deletions
+18 -6
View File
@@ -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
+163 -87
View File
@@ -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
View File
@@ -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
};
+28
View File
@@ -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;
+36
View File
@@ -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 = {
+8 -6
View File
@@ -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 {
+4
View File
@@ -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: []
}
};
+28 -3
View File
@@ -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();
}
}
+14 -2
View File
@@ -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 () => {
+4 -2
View File
@@ -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 {
+13 -2
View File
@@ -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,
+4 -2
View File
@@ -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 {
+7 -2
View File
@@ -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
+7 -5
View File
@@ -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 {
+4 -2
View File
@@ -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 {