v0.1.14 - b2b portal
This commit is contained in:
+95
-1
@@ -29,6 +29,15 @@ import type {
|
||||
RawMaterial,
|
||||
RawMaterialCreateInput,
|
||||
RawMaterialPriceCreateInput,
|
||||
CatalogueProduct,
|
||||
CustomerPricing,
|
||||
CustomerVisibilityRow,
|
||||
DraftOrderInput,
|
||||
Order,
|
||||
OrderingCustomer,
|
||||
OrderingCustomerUser,
|
||||
OrderingNotificationSettings,
|
||||
XeroStatus,
|
||||
Scenario,
|
||||
ThroughputEntry,
|
||||
ThroughputEntryCreateInput,
|
||||
@@ -486,5 +495,90 @@ export const api = {
|
||||
request<ClientAccessAccount>(`/api/client-access/features/${featureId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'manager')
|
||||
}, 'manager'),
|
||||
|
||||
// --- B2B ordering portal (customer) ---------------------------------------
|
||||
ordering: {
|
||||
catalogue: (params?: { category?: string; q?: string }, fetcher?: ApiFetch) => {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.category) search.set('category', params.category);
|
||||
if (params?.q) search.set('q', params.q);
|
||||
const qs = search.toString();
|
||||
return cachedFetchJson<CatalogueProduct[]>(`/api/ordering/catalogue${qs ? `?${qs}` : ''}`, 'client', fetcher);
|
||||
},
|
||||
product: (productId: number, quantity = 1, fetcher?: ApiFetch) =>
|
||||
request<CatalogueProduct>(`/api/ordering/catalogue/${productId}?quantity=${quantity}`, { method: 'GET' }, 'client', fetcher),
|
||||
orders: (statusFilter?: string, fetcher?: ApiFetch) =>
|
||||
cachedFetchJson<Order[]>(`/api/ordering/orders${statusFilter ? `?status=${statusFilter}` : ''}`, 'client', fetcher),
|
||||
order: (orderId: number, fetcher?: ApiFetch) =>
|
||||
request<Order>(`/api/ordering/orders/${orderId}`, { method: 'GET' }, 'client', fetcher),
|
||||
createDraft: (payload: DraftOrderInput) =>
|
||||
request<Order>('/api/ordering/orders', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
|
||||
updateDraft: (orderId: number, payload: Partial<DraftOrderInput>) =>
|
||||
request<Order>(`/api/ordering/orders/${orderId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
||||
deleteDraft: (orderId: number) =>
|
||||
request<void>(`/api/ordering/orders/${orderId}`, { method: 'DELETE' }, 'client'),
|
||||
submit: (orderId: number, payload: Partial<DraftOrderInput> = {}) =>
|
||||
request<Order>(`/api/ordering/orders/${orderId}/submit`, { method: 'POST', body: JSON.stringify(payload) }, 'client'),
|
||||
reorder: (orderId: number) =>
|
||||
request<Order>(`/api/ordering/orders/${orderId}/reorder`, { method: 'POST' }, 'client'),
|
||||
confirmationPdf: (orderId: number) =>
|
||||
requestBlob(`/api/ordering/orders/${orderId}/confirmation.pdf`, {}, 'client')
|
||||
},
|
||||
|
||||
// --- B2B ordering portal (admin) ------------------------------------------
|
||||
orderingAdmin: {
|
||||
customers: (fetcher?: ApiFetch) => cachedFetchJson<OrderingCustomer[]>('/api/ordering-admin/customers', 'client', fetcher),
|
||||
createCustomer: (payload: { name: string; client_code: string; tenant_id?: string; notes?: string }) =>
|
||||
request<OrderingCustomer>('/api/ordering-admin/customers', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
|
||||
updateCustomer: (customerId: number, payload: { name?: string; status?: string; notes?: string }) =>
|
||||
request<OrderingCustomer>(`/api/ordering-admin/customers/${customerId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
||||
customerUsers: (customerId: number, fetcher?: ApiFetch) =>
|
||||
cachedFetchJson<OrderingCustomerUser[]>(`/api/ordering-admin/customers/${customerId}/users`, 'client', fetcher),
|
||||
createCustomerUser: (customerId: number, payload: { full_name: string; email: string; role: string }) =>
|
||||
request<OrderingCustomerUser>(`/api/ordering-admin/customers/${customerId}/users`, { method: 'POST', body: JSON.stringify(payload) }, 'client'),
|
||||
updateCustomerUser: (customerId: number, userId: number, payload: { full_name?: string; role?: string; status?: string }) =>
|
||||
request<OrderingCustomerUser>(`/api/ordering-admin/customers/${customerId}/users/${userId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
||||
products: (fetcher?: ApiFetch) => cachedFetchJson<CatalogueProduct[]>('/api/ordering-admin/products', 'client', fetcher),
|
||||
createProduct: (payload: Partial<CatalogueProduct>) =>
|
||||
request<CatalogueProduct>('/api/ordering-admin/products', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
|
||||
updateProduct: (productId: number, payload: Partial<CatalogueProduct>) =>
|
||||
request<CatalogueProduct>(`/api/ordering-admin/products/${productId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
||||
visibility: (customerId: number, fetcher?: ApiFetch) =>
|
||||
cachedFetchJson<CustomerVisibilityRow[]>(`/api/ordering-admin/customers/${customerId}/visibility`, 'client', fetcher),
|
||||
setVisibility: (customerId: number, payload: { product_id: number; visible: boolean }) =>
|
||||
request(`/api/ordering-admin/customers/${customerId}/visibility`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
|
||||
pricing: (customerId: number, fetcher?: ApiFetch) =>
|
||||
cachedFetchJson<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/pricing`, 'client', fetcher),
|
||||
setAssignment: (customerId: number, payload: { price_list_id: number | null; discount_percent: number }) =>
|
||||
request<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/assignment`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
|
||||
setProductPrice: (
|
||||
customerId: number,
|
||||
payload: { product_id: number; unit_price: number | null; rule_type: string; contract_reference?: string | null; notes?: string | null; active?: boolean }
|
||||
) => request<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/product-prices`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
|
||||
deleteProductPrice: (customerId: number, productId: number) =>
|
||||
request<void>(`/api/ordering-admin/customers/${customerId}/product-prices/${productId}`, { method: 'DELETE' }, 'client'),
|
||||
orders: (params?: { status?: string; customer_id?: number }, fetcher?: ApiFetch) => {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.status) search.set('status', params.status);
|
||||
if (params?.customer_id != null) search.set('customer_id', String(params.customer_id));
|
||||
const qs = search.toString();
|
||||
return cachedFetchJson<Order[]>(`/api/ordering-admin/orders${qs ? `?${qs}` : ''}`, 'client', fetcher);
|
||||
},
|
||||
order: (orderId: number, fetcher?: ApiFetch) =>
|
||||
request<Order>(`/api/ordering-admin/orders/${orderId}`, { method: 'GET' }, 'client', fetcher),
|
||||
updateStatus: (orderId: number, payload: { to_status: string; note?: string }) =>
|
||||
request<Order>(`/api/ordering-admin/orders/${orderId}/status`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
||||
overrideLine: (orderId: number, lineId: number, payload: { quantity?: number; unit_price?: number; reason?: string }) =>
|
||||
request<Order>(`/api/ordering-admin/orders/${orderId}/lines/${lineId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
||||
reopen: (orderId: number, note?: string) =>
|
||||
request<Order>(`/api/ordering-admin/orders/${orderId}/reopen`, { method: 'POST', body: JSON.stringify({ note }) }, 'client'),
|
||||
sendToXero: (orderId: number) =>
|
||||
request<Order>(`/api/ordering-admin/orders/${orderId}/send-to-xero`, { method: 'POST' }, 'client'),
|
||||
notificationSettings: (fetcher?: ApiFetch) =>
|
||||
cachedFetchJson<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', 'client', fetcher),
|
||||
updateNotificationSettings: (payload: Partial<OrderingNotificationSettings>) =>
|
||||
request<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
||||
xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson<XeroStatus>('/api/ordering-admin/xero/status', 'client', fetcher)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import packageInfo from '../../package.json';
|
||||
|
||||
/**
|
||||
* Release notes shown in the "What's new" dialog. This is the single source of
|
||||
* truth for the changelog: add a new entry at the top of `changelog` whenever
|
||||
* the version in package.json is bumped, and the dialog will surface it once per
|
||||
* user on their next login (see $lib/whats-new and WhatsNewDialog.svelte).
|
||||
*/
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
/** ISO date (YYYY-MM-DD) the version shipped. */
|
||||
date: string;
|
||||
highlights: string[];
|
||||
};
|
||||
|
||||
/** The running app version, read straight from package.json at build time. */
|
||||
export const APP_VERSION: string = packageInfo.version;
|
||||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
{
|
||||
version: '0.1.14',
|
||||
date: '2026-06-11',
|
||||
highlights: [
|
||||
'New: private B2B customer ordering portal — customers browse their catalogue, see account-specific pricing, and submit orders.',
|
||||
'Order management console for internal staff: review orders, manage products, pricing, and the full order lifecycle.',
|
||||
'Customer-specific pricing engine (fixed, contract, price lists, tiered, and quote-only) calculated on the backend.',
|
||||
'Order confirmations (PDF) and Xero submission, behind a clean integration layer.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.12',
|
||||
date: '2026-06-10',
|
||||
highlights: [
|
||||
'Mix Calculator: Changed from selecting Product to Mix.',
|
||||
'Web app design improved',
|
||||
'Throughput tab ready for testing',
|
||||
'Costing Editor tab ready for testing'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/** The changelog entry matching a specific version, if one exists. */
|
||||
export function changelogFor(version: string): ChangelogEntry | undefined {
|
||||
return changelog.find((entry) => entry.version === version);
|
||||
}
|
||||
|
||||
/** The entry for the version the app is currently running, if documented. */
|
||||
export const currentChangelog: ChangelogEntry | undefined = changelogFor(APP_VERSION);
|
||||
@@ -1,391 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { api } from '$lib/api';
|
||||
import { adminSession, sessionHydrated } from '$lib/session';
|
||||
|
||||
const navigation = [
|
||||
{ href: '/admin', label: 'Overview', shortLabel: 'OV' },
|
||||
{ href: '/admin/client-access', label: 'Client Access', shortLabel: 'CA' }
|
||||
];
|
||||
|
||||
let { children } = $props();
|
||||
let isRestoringSession = $state(false);
|
||||
let restoredSessionKey = $state<string | null>(null);
|
||||
|
||||
function matchesRoute(href: string, pathname: string) {
|
||||
return href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
||||
}
|
||||
|
||||
function pageTitle(pathname: string) {
|
||||
return navigation.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Overview';
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((piece) => piece[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await api.adminLogout();
|
||||
} catch {
|
||||
// Clearing the local session remains the safe fallback.
|
||||
} finally {
|
||||
adminSession.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const isProtectedRoute = $derived(page.url.pathname !== '/admin');
|
||||
|
||||
$effect(() => {
|
||||
const hydrated = $sessionHydrated;
|
||||
const sessionKey = $adminSession ? `${$adminSession.role}:${$adminSession.email}` : null;
|
||||
|
||||
if (!hydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionKey) {
|
||||
isRestoringSession = false;
|
||||
restoredSessionKey = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoredSessionKey === sessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
restoredSessionKey = sessionKey;
|
||||
isRestoringSession = true;
|
||||
|
||||
invalidateAll().finally(() => {
|
||||
if (restoredSessionKey === sessionKey) {
|
||||
isRestoringSession = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle(page.url.pathname)} | Lean 101 Admin Panel</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="admin-shell">
|
||||
<aside class="admin-sidebar">
|
||||
<a class="admin-brand" href="/admin">
|
||||
<span class="brand-mark">L1</span>
|
||||
<span>Lean 101 Admin Panel</span>
|
||||
</a>
|
||||
|
||||
<p class="admin-copy">
|
||||
Internal workspace for Lean 101 operators managing client access and controlled workspace changes.
|
||||
</p>
|
||||
|
||||
<nav class="admin-nav" aria-label="Admin navigation">
|
||||
{#each navigation 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="admin-footer">
|
||||
<a href="/">Open client workspace</a>
|
||||
{#if $adminSession}
|
||||
<button type="button" onclick={signOut}>Sign out</button>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="admin-main">
|
||||
<header class="admin-topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Admin Area</p>
|
||||
<h1>{pageTitle(page.url.pathname)}</h1>
|
||||
</div>
|
||||
|
||||
{#if !$sessionHydrated}
|
||||
<div class="profile-card guest">
|
||||
<span class="profile-avatar">A</span>
|
||||
<div>
|
||||
<strong>Checking saved session</strong>
|
||||
<span>Restoring admin access</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if $adminSession}
|
||||
<div class="profile-card">
|
||||
<span class="profile-avatar">{initials($adminSession.name)}</span>
|
||||
<div>
|
||||
<strong>{$adminSession.name}</strong>
|
||||
<span>{$adminSession.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="profile-card guest">
|
||||
<span class="profile-avatar">A</span>
|
||||
<div>
|
||||
<strong>Admin sign-in required</strong>
|
||||
<span>Use `/admin` to authenticate</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<main class="admin-content">
|
||||
{#if isProtectedRoute && (!$sessionHydrated || isRestoringSession)}
|
||||
<section class="locked-card loading-card">
|
||||
<p class="eyebrow">Checking Session</p>
|
||||
<h2>Restoring the Lean 101 admin workspace.</h2>
|
||||
<p>Refreshing the current route with the saved operator session before prompting for sign-in.</p>
|
||||
</section>
|
||||
{:else if isProtectedRoute && !$adminSession}
|
||||
<section class="locked-card">
|
||||
<p class="eyebrow">Restricted</p>
|
||||
<h2>Sign in through the Lean 101 Admin Panel to continue.</h2>
|
||||
<p>Client access controls are only available inside the separate admin workspace.</p>
|
||||
<a href="/admin">Go to admin sign-in</a>
|
||||
</section>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.admin-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
background: var(--color-bg-app);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.1rem;
|
||||
border-right: 1px solid rgba(34, 54, 45, 0.12);
|
||||
background: rgba(20, 29, 24, 0.96);
|
||||
color: #f4f7f1;
|
||||
}
|
||||
|
||||
.admin-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-mark,
|
||||
.nav-icon,
|
||||
.profile-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.72rem;
|
||||
color: #0f1713;
|
||||
background: linear-gradient(135deg, #cfe4b8 0%, #83c98b 100%);
|
||||
}
|
||||
|
||||
.admin-copy {
|
||||
margin: 0;
|
||||
color: rgba(244, 247, 241, 0.74);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.admin-nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.72rem;
|
||||
padding: 0.82rem 0.78rem;
|
||||
border-radius: 0.9rem;
|
||||
color: rgba(244, 247, 241, 0.88);
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
|
||||
.admin-nav a:hover,
|
||||
.admin-nav a.active {
|
||||
background: rgba(207, 228, 184, 0.16);
|
||||
}
|
||||
|
||||
.admin-nav a.active {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
border-radius: 0.58rem;
|
||||
color: #0f1713;
|
||||
background: linear-gradient(135deg, #cfe4b8 0%, #83c98b 100%);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.admin-footer {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.admin-footer a,
|
||||
.admin-footer button {
|
||||
padding: 0.82rem 0.88rem;
|
||||
border: 1px solid rgba(244, 247, 241, 0.14);
|
||||
border-radius: 0.88rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.4rem;
|
||||
border-bottom: 1px solid rgba(34, 54, 45, 0.1);
|
||||
background: rgba(247, 248, 244, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.18rem;
|
||||
color: #66806e;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.72rem;
|
||||
padding: 0.45rem 0.52rem;
|
||||
border: 1px solid rgba(34, 54, 45, 0.1);
|
||||
border-radius: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
border-radius: 999px;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #4f8860 0%, #203028 100%);
|
||||
}
|
||||
|
||||
.profile-card strong,
|
||||
.profile-card span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-card span {
|
||||
margin-top: 0.14rem;
|
||||
color: #6b7f72;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.guest .profile-avatar {
|
||||
background: linear-gradient(135deg, #c4d0c8 0%, #7b8b80 100%);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
min-width: 0;
|
||||
padding: 1.4rem;
|
||||
}
|
||||
|
||||
.locked-card {
|
||||
max-width: 42rem;
|
||||
padding: 1.35rem;
|
||||
border: 1px solid rgba(34, 54, 45, 0.1);
|
||||
border-radius: 1.35rem;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.locked-card h2,
|
||||
.locked-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.locked-card h2 {
|
||||
margin-top: 0.35rem;
|
||||
font-size: clamp(1.8rem, 3vw, 2.3rem);
|
||||
}
|
||||
|
||||
.locked-card p:last-of-type {
|
||||
margin-top: 0.45rem;
|
||||
color: #5d7166;
|
||||
}
|
||||
|
||||
.locked-card a {
|
||||
display: inline-flex;
|
||||
margin-top: 1rem;
|
||||
padding: 0.82rem 0.95rem;
|
||||
border-radius: 0.9rem;
|
||||
background: #203028;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.admin-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(34, 54, 45, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.admin-topbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,9 @@
|
||||
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
|
||||
import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte';
|
||||
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
|
||||
import WhatsNewDialog from '$lib/components/WhatsNewDialog.svelte';
|
||||
import { currentChangelog } from '$lib/changelog';
|
||||
import { hasSeenVersion, markVersionSeen } from '$lib/whats-new';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
@@ -17,6 +20,8 @@
|
||||
canOpenEditor as sessionCanOpenEditor,
|
||||
canOpenMixCalculator as sessionCanOpenMixCalculator,
|
||||
canOpenMixMaster as sessionCanOpenMixMaster,
|
||||
canOpenCustomerOrdering as sessionCanOpenCustomerOrdering,
|
||||
canManageOrdering as sessionCanManageOrdering,
|
||||
canOpenProductCosting as sessionCanOpenProductCosting,
|
||||
canOpenReporting as sessionCanOpenReporting,
|
||||
canOpenSettings as sessionCanOpenSettings,
|
||||
@@ -36,6 +41,7 @@
|
||||
footerLinks,
|
||||
matchesRoute,
|
||||
mixCalculatorItem,
|
||||
orderingItem,
|
||||
pageTitle,
|
||||
productCostingItem,
|
||||
reportingItem,
|
||||
@@ -65,6 +71,10 @@
|
||||
let userMenuOpen = $state(false);
|
||||
let navOpen = $state(false);
|
||||
let showBottomNav = $state(false);
|
||||
let whatsNewOpen = $state(false);
|
||||
// The user identity we've already run the "what's new" check for this mount,
|
||||
// so the dialog is evaluated once per login rather than on every navigation.
|
||||
let whatsNewCheckedFor = $state<string | null>(null);
|
||||
let isRestoringSession = $state(false);
|
||||
let restoredSessionKey = $state<string | null>(null);
|
||||
let seededSearchItems = $state<SearchItem[]>([]);
|
||||
@@ -102,6 +112,17 @@
|
||||
const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null);
|
||||
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
|
||||
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
|
||||
// Ordering serves two audiences: internal staff get the management console
|
||||
// (/ordering/manage), customers get the catalogue (/ordering).
|
||||
const canManageOrdering = $derived(sessionCanManageOrdering($clientSession));
|
||||
const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($clientSession));
|
||||
const visibleOrderingItem = $derived(
|
||||
canManageOrdering
|
||||
? { ...orderingItem, href: '/ordering/manage', label: 'Order Management', shortLabel: 'OM' }
|
||||
: canOpenCustomerOrdering
|
||||
? orderingItem
|
||||
: null
|
||||
);
|
||||
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
|
||||
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
|
||||
// Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the
|
||||
@@ -117,6 +138,7 @@
|
||||
...visibleWorkingDocumentItems
|
||||
],
|
||||
throughput: visibleThroughputItem,
|
||||
ordering: visibleOrderingItem,
|
||||
reporting: visibleReportingItem
|
||||
})
|
||||
);
|
||||
@@ -331,6 +353,33 @@
|
||||
goto(workspaceHomeHref, { replaceState: true });
|
||||
});
|
||||
|
||||
// Surface the release notes once per version per user, right after login.
|
||||
// hasSeenVersion keeps this to a single appearance: once dismissed (which
|
||||
// records the version), it won't return until the next version ships.
|
||||
$effect(() => {
|
||||
if (!$sessionHydrated || !$clientSession || !currentChangelog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userKey = `${$clientSession.role}:${$clientSession.email}:${$clientSession.user_id ?? ''}`;
|
||||
if (whatsNewCheckedFor === userKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
whatsNewCheckedFor = userKey;
|
||||
if (!hasSeenVersion(userKey, currentChangelog.version)) {
|
||||
whatsNewOpen = true;
|
||||
}
|
||||
});
|
||||
|
||||
function dismissWhatsNew() {
|
||||
if ($clientSession && currentChangelog) {
|
||||
const userKey = `${$clientSession.role}:${$clientSession.email}:${$clientSession.user_id ?? ''}`;
|
||||
markVersionSeen(userKey, currentChangelog.version);
|
||||
}
|
||||
whatsNewOpen = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
syncViewport();
|
||||
|
||||
@@ -639,6 +688,10 @@
|
||||
|
||||
{/if}
|
||||
|
||||
{#if $clientSession && whatsNewOpen && currentChangelog}
|
||||
<WhatsNewDialog entry={currentChangelog} onClose={dismissWhatsNew} />
|
||||
{/if}
|
||||
|
||||
{#if $clientSession && paletteOpen}
|
||||
<div class="palette-overlay" role="presentation" onclick={() => (paletteOpen = false)}>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { ShoppingCart, LogOut } from 'lucide-svelte';
|
||||
import { clientSession, sessionHydrated } from '$lib/session';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const isRootRoute = $derived(page.url.pathname === '/');
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const userName = $derived($clientSession?.name ?? '');
|
||||
const userInitials = $derived(
|
||||
($clientSession?.name ?? '')
|
||||
.split(' ')
|
||||
.slice(0, 2)
|
||||
.map((word: string) => word[0])
|
||||
.join('')
|
||||
.toUpperCase() || '?'
|
||||
);
|
||||
|
||||
const navItems = [{ href: '/ordering', label: 'Order Catalogue', icon: ShoppingCart }];
|
||||
|
||||
function isActive(href: string) {
|
||||
return href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await api.clientLogout();
|
||||
} catch {
|
||||
// Clearing the local session is the safe fallback.
|
||||
} finally {
|
||||
clientSession.clear();
|
||||
await goto('/', { replaceState: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the saved session fresh on reload (mirrors the workspace shell).
|
||||
let restoredSessionKey = $state<string | null>(null);
|
||||
$effect(() => {
|
||||
const hydrated = $sessionHydrated;
|
||||
const sessionKey = $clientSession ? `${$clientSession.role}:${$clientSession.email}:${$clientSession.user_id ?? ''}` : null;
|
||||
if (!hydrated || !sessionKey || restoredSessionKey === sessionKey) {
|
||||
return;
|
||||
}
|
||||
restoredSessionKey = sessionKey;
|
||||
api
|
||||
.clientSession()
|
||||
.then((session) => {
|
||||
restoredSessionKey = `${session.role}:${session.email}:${session.user_id ?? ''}`;
|
||||
clientSession.set(session);
|
||||
return invalidateAll();
|
||||
})
|
||||
.catch(() => {
|
||||
restoredSessionKey = null;
|
||||
clientSession.clear();
|
||||
});
|
||||
});
|
||||
|
||||
// Signed-out customers go back to the sign-in screen; the portal landing is
|
||||
// always the catalogue, never the internal dashboard/login root.
|
||||
$effect(() => {
|
||||
if (!$sessionHydrated) return;
|
||||
if (!$clientSession && !isRootRoute) {
|
||||
goto('/', { replaceState: true });
|
||||
} else if ($clientSession && isRootRoute) {
|
||||
goto('/ordering', { replaceState: true });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Customer Ordering Portal | Hunter Premium Produce</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !$clientSession}
|
||||
{#if isRootRoute}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<div class="loading-screen">
|
||||
<p>Returning you to sign in…</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="portal">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark"><ShoppingCart size={20} strokeWidth={2} /></span>
|
||||
<div class="brand-text">
|
||||
<span class="brand-name">Hunter Premium Produce</span>
|
||||
<span class="brand-sub">Customer Ordering Portal</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav" aria-label="Customer portal navigation">
|
||||
{#each navItems as item}
|
||||
{@const Icon = item.icon}
|
||||
<a class="nav-row" class:active={isActive(item.href)} href={item.href}>
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.85} /></span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<div class="account">
|
||||
<span class="avatar">{userInitials}</span>
|
||||
<span class="account-name">{userName}</span>
|
||||
</div>
|
||||
<button class="signout" type="button" onclick={signOut}>
|
||||
<LogOut size={16} strokeWidth={1.9} />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
<small class="copyright">© {currentYear} Hunter Premium Produce</small>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<h1>Customer Ordering Portal</h1>
|
||||
<div class="topbar-account">
|
||||
<span class="avatar small">{userInitials}</span>
|
||||
<span class="topbar-name">{userName}</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="content">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.portal {
|
||||
display: grid;
|
||||
grid-template-columns: 16rem minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
background: #f4f7f4;
|
||||
color: #1f2a24;
|
||||
}
|
||||
|
||||
.loading-screen {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
color: #5f7266;
|
||||
background: #f4f7f4;
|
||||
}
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1.4rem 1.05rem;
|
||||
background: #1f3a2c;
|
||||
color: #e7efe9;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding-bottom: 1.1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 0.8rem;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 0.78rem;
|
||||
color: rgba(231, 239, 233, 0.7);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 0.75rem;
|
||||
color: rgba(231, 239, 233, 0.85);
|
||||
text-decoration: none;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.nav-row:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-row.active {
|
||||
background: #fff;
|
||||
color: #1f3a2c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-foot {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar.small {
|
||||
width: 1.85rem;
|
||||
height: 1.85rem;
|
||||
background: #1f3a2c;
|
||||
}
|
||||
|
||||
.account-name {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.signout {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 0.7rem;
|
||||
background: transparent;
|
||||
color: #e7efe9;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
|
||||
.signout:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.copyright {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(231, 239, 233, 0.55);
|
||||
}
|
||||
|
||||
/* ── Main ────────────────────────────────────────────────── */
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.05rem 1.6rem;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(31, 58, 44, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #1f3a2c;
|
||||
}
|
||||
|
||||
.topbar-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.topbar-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: #1f2a24;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 1.6rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.portal {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav {
|
||||
grid-auto-flow: column;
|
||||
}
|
||||
|
||||
.sidebar-foot {
|
||||
margin-top: 0;
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
grid-auto-flow: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<script lang="ts">
|
||||
import { Sparkles } from 'lucide-svelte';
|
||||
import type { ChangelogEntry } from '$lib/changelog';
|
||||
|
||||
let { entry, onClose }: { entry: ChangelogEntry; onClose: () => void } = $props();
|
||||
|
||||
const releaseDate = $derived(
|
||||
new Date(`${entry.date}T00:00:00`).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="whats-new-backdrop" role="presentation" onclick={onClose}>
|
||||
<div
|
||||
class="whats-new"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="whats-new-title"
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="whats-new-head">
|
||||
<span class="whats-new-mark"><Sparkles size={20} strokeWidth={1.75} /></span>
|
||||
<div>
|
||||
<p class="whats-new-kicker">What's new · v{entry.version}</p>
|
||||
<h2 id="whats-new-title">A few updates in this release</h2>
|
||||
<p class="whats-new-date">{releaseDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="whats-new-list">
|
||||
{#each entry.highlights as highlight}
|
||||
<li>{highlight}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="whats-new-actions">
|
||||
<button class="whats-new-button" type="button" onclick={onClose}>Got it</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.whats-new-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(11, 18, 14, 0.45);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.whats-new {
|
||||
width: min(34rem, 100%);
|
||||
display: grid;
|
||||
gap: 1.15rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1.1rem;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.whats-new-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.whats-new-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
border-radius: 0.82rem;
|
||||
color: var(--color-on-brand);
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
.whats-new-kicker {
|
||||
color: var(--color-brand);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.whats-new-head h2 {
|
||||
margin-top: 0.22rem;
|
||||
font-size: 1.28rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.whats-new-date {
|
||||
margin-top: 0.18rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.whats-new-list {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.whats-new-list li {
|
||||
position: relative;
|
||||
padding-left: 1.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.whats-new-list li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.3rem;
|
||||
width: 0.46rem;
|
||||
height: 0.46rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
.whats-new-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.whats-new-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.6rem;
|
||||
padding: 0.72rem 1.3rem;
|
||||
border: 1px solid var(--color-brand);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
.whats-new-button:hover {
|
||||
background: var(--color-brand-hover);
|
||||
border-color: var(--color-brand-hover);
|
||||
}
|
||||
|
||||
.whats-new-button:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -521,13 +521,19 @@
|
||||
padding: 0.78rem 0.82rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 0.6rem;
|
||||
background: #fff;
|
||||
background: var(--color-input-bg);
|
||||
color: var(--text);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
|
||||
@@ -203,8 +203,9 @@
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
/* Light monochrome rail with a dark selected pill. The rail keeps its own palette
|
||||
via the --sidebar-* tokens, independent of the content theme. */
|
||||
/* Monochrome rail with a blue selected pill. Colours come from the --sidebar-*
|
||||
tokens, which are overridden in dark mode (see theme.css) so the rail themes
|
||||
alongside the content instead of staying a bright light strip. */
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
TrendingUp
|
||||
} from 'lucide-svelte';
|
||||
import type { ComponentType } from 'svelte';
|
||||
@@ -112,6 +113,14 @@ export const throughputItem: NavItem = {
|
||||
badge: 'test'
|
||||
};
|
||||
|
||||
export const orderingItem: NavItem = {
|
||||
href: '/ordering',
|
||||
label: 'Ordering',
|
||||
shortLabel: 'OR',
|
||||
icon: ShoppingCart,
|
||||
moduleKey: 'ordering'
|
||||
};
|
||||
|
||||
export const workingDocumentItems: NavItem[] = [
|
||||
// Mix Master remains available through the existing route and access logic,
|
||||
// but is temporarily hidden from the sidebar.
|
||||
@@ -210,6 +219,7 @@ export function buildClientNavEntries(visible: {
|
||||
dashboard?: NavItem | null;
|
||||
costing: NavItem[];
|
||||
throughput?: NavItem | null;
|
||||
ordering?: NavItem | null;
|
||||
reporting?: NavItem | null;
|
||||
}): NavEntry[] {
|
||||
const entries: NavEntry[] = [];
|
||||
@@ -225,6 +235,10 @@ export function buildClientNavEntries(visible: {
|
||||
});
|
||||
}
|
||||
|
||||
if (visible.ordering) {
|
||||
entries.push({ kind: 'item', item: visible.ordering });
|
||||
}
|
||||
|
||||
if (visible.throughput) {
|
||||
entries.push({ kind: 'item', item: visible.throughput });
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
--color-surface-hover: oklch(0.955 0.004 240);
|
||||
--color-surface-selected: color-mix(in srgb, var(--color-brand) 10%, var(--color-bg-surface));
|
||||
|
||||
/* ── Form inputs: a touch recessed from the card surface ── */
|
||||
--color-input-bg: var(--panel-soft);
|
||||
|
||||
/* ── Borders ────────────────────────────────────────────── */
|
||||
--color-border: oklch(0.92 0.005 240);
|
||||
--color-divider: oklch(0.94 0.004 240);
|
||||
@@ -117,10 +120,28 @@
|
||||
--color-surface-hover: oklch(0.27 0.006 240);
|
||||
--color-surface-selected: color-mix(in srgb, var(--color-brand) 14%, var(--color-bg-surface));
|
||||
|
||||
/* ── Form inputs: sit just above the card so fields don't read as
|
||||
black holes punched into the surface. ── */
|
||||
--color-input-bg: oklch(0.25 0.005 240);
|
||||
|
||||
/* ── Borders ────────────────────────────────────────────── */
|
||||
--color-border: oklch(0.32 0.006 240);
|
||||
--color-divider: oklch(0.28 0.005 240);
|
||||
|
||||
/* ── Sidebar: dark rail tuned to the content theme so it stops
|
||||
rendering as a bright light strip in dark mode. Active item keeps
|
||||
the blue pill from light mode. ── */
|
||||
--sidebar-bg: oklch(0.2 0.005 240);
|
||||
--sidebar-hover: oklch(0.27 0.006 240);
|
||||
--sidebar-active-bg: #3290d9;
|
||||
--sidebar-active-text: var(--color-on-brand);
|
||||
--sidebar-border: oklch(0.3 0.006 240);
|
||||
--sidebar-text: oklch(0.78 0.006 240);
|
||||
--sidebar-text-strong: oklch(0.96 0.003 240);
|
||||
--sidebar-text-muted: oklch(0.6 0.008 240);
|
||||
--sidebar-icon: oklch(0.68 0.008 240);
|
||||
--sidebar-logo-bg: oklch(0.26 0.006 240);
|
||||
|
||||
/* ── Text (neutral) ─────────────────────────────────────── */
|
||||
--color-text-primary: oklch(0.96 0.003 240);
|
||||
--color-text-secondary: oklch(0.78 0.006 240);
|
||||
@@ -448,7 +469,7 @@ a {
|
||||
padding: 0.82rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--panel-soft);
|
||||
background: var(--color-input-bg);
|
||||
color: var(--color-text-primary);
|
||||
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);
|
||||
|
||||
@@ -6,18 +6,17 @@ export type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
const STORAGE_KEY = 'theme';
|
||||
|
||||
function systemTheme(): ResolvedTheme {
|
||||
return browser && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
// Dark mode is strictly opt-in. Light is the default and the OS colour scheme is
|
||||
// deliberately ignored: only an explicit stored 'dark' resolves to dark, so the
|
||||
// app never loads dark on its own. The legacy 'system' value collapses to light.
|
||||
function resolve(pref: ThemePreference): ResolvedTheme {
|
||||
return pref === 'system' ? systemTheme() : pref;
|
||||
return pref === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function readPreference(): ThemePreference {
|
||||
if (!browser) return 'system';
|
||||
if (!browser) return 'light';
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system';
|
||||
return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'light';
|
||||
}
|
||||
|
||||
function applyResolved(theme: ResolvedTheme) {
|
||||
@@ -26,10 +25,10 @@ function applyResolved(theme: ResolvedTheme) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The user's stored choice (may be 'system'). */
|
||||
/** The user's stored choice (may be the legacy 'system'). */
|
||||
export const themePreference = writable<ThemePreference>(readPreference());
|
||||
|
||||
/** The theme actually painted right now ('system' collapsed to light/dark). */
|
||||
/** The theme actually painted right now. */
|
||||
export const resolvedTheme = writable<ResolvedTheme>(resolve(readPreference()));
|
||||
|
||||
if (browser) {
|
||||
@@ -39,15 +38,6 @@ if (browser) {
|
||||
resolvedTheme.set(next);
|
||||
applyResolved(next);
|
||||
});
|
||||
|
||||
// Follow the OS only while the user is on 'system'.
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (readPreference() === 'system') {
|
||||
const next = systemTheme();
|
||||
resolvedTheme.set(next);
|
||||
applyResolved(next);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Flip between light and dark, committing to an explicit preference. */
|
||||
|
||||
@@ -637,3 +637,168 @@ export type ThroughputProductCreateInput = {
|
||||
};
|
||||
|
||||
export type ThroughputProductUpdateInput = Partial<ThroughputProductCreateInput>;
|
||||
|
||||
// --- B2B ordering portal ----------------------------------------------------
|
||||
|
||||
export type OrderingPriceInfo = {
|
||||
unit_price: number | null;
|
||||
price_source: 'fixed' | 'contract' | 'price_list' | 'tiered' | 'base' | 'quote';
|
||||
price_rule_id: number | null;
|
||||
discount_percent: number;
|
||||
requires_quote: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type CatalogueProduct = {
|
||||
id: number;
|
||||
name: string;
|
||||
sku: string;
|
||||
description?: string | null;
|
||||
category: string;
|
||||
image_url?: string | null;
|
||||
unit_size?: string | null;
|
||||
unit_of_measure: string;
|
||||
min_order_quantity: number;
|
||||
stock_status: string;
|
||||
active: boolean;
|
||||
requires_quote: boolean;
|
||||
base_price?: number | null;
|
||||
created_at?: string;
|
||||
price?: OrderingPriceInfo;
|
||||
};
|
||||
|
||||
export type OrderLine = {
|
||||
id: number;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_sku: string;
|
||||
quantity: number;
|
||||
unit_price: number | null;
|
||||
line_total: number | null;
|
||||
requires_quote: boolean;
|
||||
price_source: string;
|
||||
discount_percent: number;
|
||||
notes?: string | null;
|
||||
// admin-only
|
||||
resolved_unit_price?: number | null;
|
||||
admin_override_price?: number | null;
|
||||
admin_override_reason?: string | null;
|
||||
price_rule_id?: number | null;
|
||||
};
|
||||
|
||||
export type OrderStatusHistoryEntry = {
|
||||
id: number;
|
||||
from_status: string | null;
|
||||
to_status: string;
|
||||
actor_type: string;
|
||||
actor_name?: string | null;
|
||||
note?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Order = {
|
||||
id: number;
|
||||
order_number: string | null;
|
||||
status: string;
|
||||
client_account_id: number;
|
||||
created_by_name?: string | null;
|
||||
purchase_order_number?: string | null;
|
||||
delivery_notes?: string | null;
|
||||
requested_delivery_date?: string | null;
|
||||
fulfilment_method: string;
|
||||
subtotal_ex_gst: number;
|
||||
requires_quote: boolean;
|
||||
submitted_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
editable: boolean;
|
||||
lines: OrderLine[];
|
||||
// admin-only
|
||||
raw_status?: string;
|
||||
admin_notes?: string | null;
|
||||
reopened?: boolean;
|
||||
xero_status?: string;
|
||||
xero_invoice_id?: string | null;
|
||||
customer_name?: string | null;
|
||||
status_history?: OrderStatusHistoryEntry[];
|
||||
notifications?: { channel: string; recipients: string[]; delivered: boolean; detail: string }[];
|
||||
xero_result?: { status: string; stubbed: boolean; message: string; xero_invoice_id: string | null };
|
||||
};
|
||||
|
||||
export type OrderLineInput = { product_id: number; quantity: number; notes?: string | null };
|
||||
|
||||
export type DraftOrderInput = {
|
||||
lines: OrderLineInput[];
|
||||
purchase_order_number?: string | null;
|
||||
delivery_notes?: string | null;
|
||||
requested_delivery_date?: string | null;
|
||||
fulfilment_method?: string;
|
||||
};
|
||||
|
||||
export type OrderingCustomer = {
|
||||
id: number;
|
||||
name: string;
|
||||
client_code: string;
|
||||
tenant_id: string;
|
||||
status: string;
|
||||
notes?: string | null;
|
||||
user_count: number;
|
||||
price_list_id: number | null;
|
||||
discount_percent: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type OrderingCustomerUser = {
|
||||
id: number;
|
||||
client_account_id: number;
|
||||
full_name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type PriceTierInput = { min_quantity: number; unit_price: number };
|
||||
|
||||
export type CustomerPricing = {
|
||||
customer_id: number;
|
||||
price_list_id: number | null;
|
||||
discount_percent: number;
|
||||
product_prices: {
|
||||
id: number;
|
||||
product_id: number;
|
||||
unit_price: number | null;
|
||||
rule_type: string;
|
||||
contract_reference?: string | null;
|
||||
notes?: string | null;
|
||||
active: boolean;
|
||||
tiers: { id: number; min_quantity: number; unit_price: number }[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export type CustomerVisibilityRow = {
|
||||
product_id: number;
|
||||
name: string;
|
||||
sku: string;
|
||||
category: string;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export type OrderingNotificationSettings = {
|
||||
internal_recipients: string | null;
|
||||
send_customer_confirmation: boolean;
|
||||
require_po_number: boolean;
|
||||
from_email: string | null;
|
||||
};
|
||||
|
||||
export type XeroStatus = {
|
||||
connection: { configured: boolean; mode: string; base_url: string; checked_at: string; missing_env: string[] };
|
||||
recent_syncs: {
|
||||
id: number;
|
||||
order_id: number;
|
||||
status: string;
|
||||
xero_invoice_id: string | null;
|
||||
response_message: string | null;
|
||||
created_at: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
/**
|
||||
* Tracks which release-notes version a user has already seen, so the "What's
|
||||
* new" dialog shows exactly once per version per user rather than on every
|
||||
* login. State is kept client-side in localStorage, keyed per user, so a fresh
|
||||
* browser/device will re-show the current version's notes once.
|
||||
*/
|
||||
const STORAGE_PREFIX = 'hsf:whats-new:seen';
|
||||
|
||||
function storageKey(userKey: string): string {
|
||||
return `${STORAGE_PREFIX}:${userKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if this user has already acknowledged the given version. Errs on the
|
||||
* side of "seen" when storage is unavailable (SSR, private mode) so we never
|
||||
* pop the dialog where we can't record that it was dismissed.
|
||||
*/
|
||||
export function hasSeenVersion(userKey: string, version: string): boolean {
|
||||
if (!browser) return true;
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey(userKey)) === version;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Record that this user has seen the given version's release notes. */
|
||||
export function markVersionSeen(userKey: string, version: string): void {
|
||||
if (!browser) return;
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(userKey), version);
|
||||
} catch {
|
||||
// A storage failure just means the dialog may reappear next login; harmless.
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,52 @@ export function canOpenReporting(session: AppSession | null | undefined) {
|
||||
return canOpenProducts(session);
|
||||
}
|
||||
|
||||
// Internal staff who manage the customer ordering portal (catalogue, pricing,
|
||||
// order lifecycle). These are Hunter Stock Feeds users signing in via the
|
||||
// internal access system — not customers.
|
||||
export function canManageOrdering(session: AppSession | null | undefined) {
|
||||
return !!session && session.role === 'internal' && hasPermission(session, 'manage_ordering');
|
||||
}
|
||||
|
||||
// B2B customers (ClientUser accounts) who browse the catalogue and place orders.
|
||||
export function canOpenCustomerOrdering(session: AppSession | null | undefined) {
|
||||
return !!session && session.role !== 'internal' && hasModuleAccess(session, 'ordering');
|
||||
}
|
||||
|
||||
// Either audience may open the Ordering area (the nav/route resolves which view).
|
||||
export function canOpenOrdering(session: AppSession | null | undefined) {
|
||||
return canManageOrdering(session) || canOpenCustomerOrdering(session);
|
||||
}
|
||||
|
||||
export function canPlaceOrders(session: AppSession | null | undefined) {
|
||||
return !!session && session.role !== 'internal' && hasModuleAccess(session, 'ordering', 'edit');
|
||||
}
|
||||
|
||||
// A "customer portal" session is a B2B ordering customer (ClientUser) whose
|
||||
// access is limited to ordering — they get the dedicated, stripped-down
|
||||
// Customer Ordering Portal shell rather than the internal staff workspace.
|
||||
// Internal staff and costing-portal client users (who also have mix/product/
|
||||
// throughput access) keep the full workspace.
|
||||
const STAFF_ONLY_MODULES = [
|
||||
'mix_calculator',
|
||||
'products',
|
||||
'mix_master',
|
||||
'operations_throughput',
|
||||
'scenarios',
|
||||
'raw_materials',
|
||||
'client_access'
|
||||
];
|
||||
|
||||
export function isCustomerPortalSession(session: AppSession | null | undefined) {
|
||||
if (!session || session.role !== 'client') {
|
||||
return false;
|
||||
}
|
||||
if (!canOpenCustomerOrdering(session)) {
|
||||
return false;
|
||||
}
|
||||
return !STAFF_ONLY_MODULES.some((moduleKey) => hasModuleAccess(session, moduleKey));
|
||||
}
|
||||
|
||||
export function canOpenSettings(session: AppSession | null | undefined) {
|
||||
if (!session) {
|
||||
return false;
|
||||
@@ -167,10 +213,20 @@ export const routeAccessRules: RouteAccessRule[] = [
|
||||
path: '/throughput',
|
||||
roles: ['admin', 'operations', 'full', 'client'],
|
||||
matches: (pathname) => hasPathPrefix(pathname, '/throughput')
|
||||
},
|
||||
{
|
||||
path: '/ordering',
|
||||
roles: ['admin', 'full', 'client'],
|
||||
matches: (pathname) => hasPathPrefix(pathname, '/ordering')
|
||||
}
|
||||
];
|
||||
|
||||
export function getDefaultRouteForRole(session: AppSession | null | undefined) {
|
||||
// B2B ordering customers land directly in the ordering portal.
|
||||
if (isCustomerPortalSession(session)) {
|
||||
return '/ordering';
|
||||
}
|
||||
|
||||
const role = getWorkspaceRole(session);
|
||||
|
||||
if (role === 'operations') {
|
||||
@@ -213,6 +269,7 @@ export function canAccessRoute(session: AppSession | null | undefined, pathname:
|
||||
if (pathname.startsWith('/settings')) return canOpenSettings(session);
|
||||
if (pathname.startsWith('/client-access')) return canOpenClientAccess(session);
|
||||
if (pathname.startsWith('/throughput')) return canOpenThroughput(session);
|
||||
if (pathname.startsWith('/ordering')) return canOpenOrdering(session);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user