Backend
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "data-entry-app-frontend",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -284,6 +284,11 @@ export const api = {
|
||||
}),
|
||||
internalSession: (fetcher?: ApiFetch) =>
|
||||
request<LoginResponse>('/api/access/me', { method: 'GET' }, 'client', fetcher),
|
||||
updateMe: (payload: { name?: string; email?: string; current_password?: string; new_password?: string }) =>
|
||||
request<LoginResponse>('/api/access/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
adminLogin: (email: string, password: string) =>
|
||||
request<LoginResponse>('/api/auth/admin/login', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -326,7 +326,7 @@
|
||||
border: 1px solid rgba(34, 54, 45, 0.1);
|
||||
border-radius: 1.35rem;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
|
||||
@@ -775,7 +775,7 @@
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.8rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
background: var(--color-brand);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -861,8 +861,8 @@
|
||||
border-radius: 0.85rem;
|
||||
padding: 0.85rem 1rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.2);
|
||||
background: var(--color-brand);
|
||||
box-shadow: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -990,7 +990,7 @@
|
||||
.feature-toggle.enabled {
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
.feature-toggle:disabled {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
ShieldCheck,
|
||||
DollarSign,
|
||||
ClipboardList,
|
||||
TrendingUp,
|
||||
Search,
|
||||
LogOut,
|
||||
Plus,
|
||||
@@ -49,6 +50,7 @@
|
||||
icon: Calculator,
|
||||
moduleKey: 'mix_calculator'
|
||||
};
|
||||
const reportingItem: NavItem = { href: '/reporting', label: 'Reporting', shortLabel: 'RP', icon: TrendingUp, moduleKey: 'products' };
|
||||
const workingDocumentItems: NavItem[] = [
|
||||
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', icon: Wheat, moduleKey: 'raw_materials' },
|
||||
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' },
|
||||
@@ -111,6 +113,12 @@
|
||||
description: 'Review delivered product pricing and margins.',
|
||||
keywords: 'products pricing margins delivered outputs'
|
||||
},
|
||||
{
|
||||
href: '/reporting',
|
||||
label: 'Open Reporting',
|
||||
description: 'View raw material costs, mix summaries, product pricing, and data quality reports.',
|
||||
keywords: 'reporting reports raw materials mix cost product pricing data quality price review'
|
||||
},
|
||||
{
|
||||
href: '/settings',
|
||||
label: 'Open Workspace Settings',
|
||||
@@ -141,7 +149,7 @@
|
||||
let seededSearchToken = $state<string | null>(null);
|
||||
let paletteInput: HTMLInputElement | null = $state(null);
|
||||
const appVersion = `v${packageInfo.version}`;
|
||||
const releaseStage = 'Alpha';
|
||||
const releaseStage = 'Beta';
|
||||
const currentYear = new Date().getFullYear();
|
||||
const visibleDashboardItem = $derived(
|
||||
!$clientSession || !dashboardItem.moduleKey || hasModuleAccess($clientSession, dashboardItem.moduleKey) ? dashboardItem : null
|
||||
@@ -156,6 +164,11 @@
|
||||
? mixCalculatorItem
|
||||
: null
|
||||
);
|
||||
const visibleReportingItem = $derived(
|
||||
!$clientSession || !reportingItem.moduleKey || hasModuleAccess($clientSession, reportingItem.moduleKey)
|
||||
? reportingItem
|
||||
: null
|
||||
);
|
||||
const visibleFooterLinks = $derived([
|
||||
...footerLinks,
|
||||
...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage')
|
||||
@@ -214,6 +227,7 @@
|
||||
'/products': 'Products',
|
||||
'/scenarios': 'Scenarios',
|
||||
'/client-access': 'Client Access',
|
||||
'/reporting': 'Reporting',
|
||||
'/settings': 'Settings'
|
||||
};
|
||||
const section = sectionMap[pathname];
|
||||
@@ -414,6 +428,15 @@
|
||||
window.removeEventListener('resize', syncViewport);
|
||||
};
|
||||
});
|
||||
|
||||
const userInitials = $derived(
|
||||
($clientSession?.name ?? '')
|
||||
.split(' ')
|
||||
.slice(0, 2)
|
||||
.map((w: string) => w[0])
|
||||
.join('')
|
||||
.toUpperCase() || '?'
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -464,6 +487,14 @@
|
||||
<span>{visibleMixCalculatorItem.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if visibleReportingItem}
|
||||
{@const Icon = visibleReportingItem.icon}
|
||||
<a class:active={matchesRoute(visibleReportingItem.href, page.url.pathname)} href={visibleReportingItem.href}>
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{visibleReportingItem.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
{#if visibleWorkingDocumentItems.length}
|
||||
@@ -494,17 +525,31 @@
|
||||
|
||||
<div class="sidebar-meta">
|
||||
{#if $clientSession}
|
||||
<button class="sidebar-signout" class:active={matchesRoute('/settings', page.url.pathname)} type="button" onclick={openSettings}>
|
||||
<span class="nav-icon"><Settings size={18} strokeWidth={1.75} /></span>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
<button class="sidebar-signout" type="button" onclick={() => clientSession.clear()}>
|
||||
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
{/if}
|
||||
<div class="sidebar-meta-foot">
|
||||
<span class="version-pill">
|
||||
<span>{appVersion}</span>
|
||||
<div class="sidebar-meta-top">
|
||||
<span class="version-pill">
|
||||
<span class="meta-label">Build</span>
|
||||
<span>{appVersion}</span>
|
||||
</span>
|
||||
<span class="release-pill">{releaseStage}</span>
|
||||
</span>
|
||||
<small>© {currentYear} Hunter Premium Produce</small>
|
||||
</div>
|
||||
<div class="sidebar-meta-bottom">
|
||||
<small>© {currentYear} Hunter Premium Produce</small>
|
||||
<div class="powered-by">
|
||||
<span>Powered by</span>
|
||||
<img src="/lean101-isotipo.png" alt="Lean 101" class="lean101-logo" />
|
||||
<strong>Lean 101</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -548,7 +593,10 @@
|
||||
quickMenuOpen = false;
|
||||
}}
|
||||
>
|
||||
<span class={`user-status-dot ${$clientSession ? 'live' : 'idle'}`}></span>
|
||||
<span class="user-avatar-wrap">
|
||||
<span class="user-avatar">{$clientSession ? userInitials : '?'}</span>
|
||||
<span class={`user-status-dot ${$clientSession ? 'live' : 'idle'}`}></span>
|
||||
</span>
|
||||
<span class="user-trigger-copy">
|
||||
<span class="workspace-label">{$sessionHydrated ? ($clientSession ? 'Signed in' : 'Signed out') : 'Checking session'}</span>
|
||||
<strong>{$sessionHydrated ? ($clientSession ? $clientSession.name || 'Client account' : 'Sign in required') : 'Restoring workspace access'}</strong>
|
||||
@@ -559,22 +607,28 @@
|
||||
{#if userMenuOpen}
|
||||
<div class="menu-panel user-menu-panel">
|
||||
<div class="user-menu-summary">
|
||||
<strong>
|
||||
{$sessionHydrated
|
||||
? $clientSession
|
||||
? $clientSession.name || 'Client account'
|
||||
: 'Client session inactive'
|
||||
: 'Checking saved client session'}
|
||||
</strong>
|
||||
<span>
|
||||
{$sessionHydrated
|
||||
? $clientSession
|
||||
? $clientSession.email
|
||||
: 'Return to the dashboard page to sign in.'
|
||||
: 'Waiting for the browser session check to complete.'}
|
||||
</span>
|
||||
<span class="user-menu-avatar">{$clientSession ? userInitials : '?'}</span>
|
||||
<div class="user-menu-summary-text">
|
||||
<strong>
|
||||
{$sessionHydrated
|
||||
? $clientSession
|
||||
? $clientSession.name || 'Client account'
|
||||
: 'Client session inactive'
|
||||
: 'Checking saved client session'}
|
||||
</strong>
|
||||
<span>
|
||||
{$sessionHydrated
|
||||
? $clientSession
|
||||
? $clientSession.email
|
||||
: 'Return to the dashboard page to sign in.'
|
||||
: 'Waiting for the browser session check to complete.'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick={openSettings}>Change settings</button>
|
||||
<button type="button" class="menu-settings-btn" onclick={openSettings}>
|
||||
<Settings size={15} strokeWidth={1.75} />
|
||||
Settings
|
||||
</button>
|
||||
{#if $clientSession}
|
||||
<button type="button" onclick={() => clientSession.clear()}>Log out</button>
|
||||
{:else if !$sessionHydrated}
|
||||
@@ -689,6 +743,14 @@
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if visibleReportingItem}
|
||||
{@const Icon = visibleReportingItem.icon}
|
||||
<a class:active={matchesRoute(visibleReportingItem.href, page.url.pathname)} href={visibleReportingItem.href} onclick={() => (navOpen = false)}>
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{visibleReportingItem.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if visibleWorkingDocumentItems.length}
|
||||
<div class="drawer-sublist" id="drawer-working-documents-nav">
|
||||
{#each visibleWorkingDocumentItems as item}
|
||||
@@ -793,26 +855,51 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
:global(:root) {
|
||||
--bg: #f4f7f5;
|
||||
--panel: #ffffff;
|
||||
--panel-soft: #f8fbf9;
|
||||
--line: #e5ece7;
|
||||
--line-strong: #d9e4dd;
|
||||
--text: #18231d;
|
||||
--muted: #6d7d74;
|
||||
--green: #22a95e;
|
||||
--green-deep: #148249;
|
||||
--green-soft: #eaf8ef;
|
||||
--blue-soft: #eef7ff;
|
||||
--shadow: 0 10px 30px rgba(15, 23, 17, 0.06);
|
||||
/* ── Brand ──────────────────────────────────────────────── */
|
||||
--color-brand: #15803d;
|
||||
--color-brand-tint: #f0fdf4;
|
||||
|
||||
/* ── Surfaces ───────────────────────────────────────────── */
|
||||
--color-bg-app: #f6f8fa;
|
||||
--color-bg-surface: #ffffff;
|
||||
|
||||
/* ── Borders ────────────────────────────────────────────── */
|
||||
--color-border: #e1e4e8;
|
||||
--color-divider: #eaecef;
|
||||
|
||||
/* ── Text ───────────────────────────────────────────────── */
|
||||
--color-text-primary: #24292f;
|
||||
--color-text-secondary: #57606a;
|
||||
--color-text-muted: #8b949e;
|
||||
|
||||
/* ── Semantic ───────────────────────────────────────────── */
|
||||
--color-success: #1a7f37;
|
||||
--color-warning: #bf8700;
|
||||
--color-error: #cf222e;
|
||||
--color-info: #0969da;
|
||||
|
||||
/* ── Legacy aliases (keep old token names working) ───────── */
|
||||
--bg: var(--color-bg-app);
|
||||
--panel: var(--color-bg-surface);
|
||||
--panel-soft: var(--color-bg-app);
|
||||
--line: var(--color-border);
|
||||
--line-strong: var(--color-border);
|
||||
--text: var(--color-text-primary);
|
||||
--muted: var(--color-text-muted);
|
||||
--green: var(--color-brand);
|
||||
--green-deep: #1a1f1c;
|
||||
--green-soft: var(--color-brand-tint);
|
||||
--blue-soft: #e8f4ff;
|
||||
--shadow: none; /* flat design — use borders, not shadows */
|
||||
}
|
||||
|
||||
:global(html, body) {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
background: var(--color-bg-app);
|
||||
color: var(--color-text-primary);
|
||||
font-family: Inter, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:global(*) {
|
||||
@@ -921,9 +1008,12 @@
|
||||
}
|
||||
|
||||
.nav-list a:hover .nav-icon,
|
||||
.nav-list a.active .nav-icon,
|
||||
.sidebar-signout:hover .nav-icon {
|
||||
color: var(--green-deep);
|
||||
color: #304038;
|
||||
}
|
||||
|
||||
.nav-list a.active .nav-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-icon-mask {
|
||||
@@ -1058,13 +1148,13 @@
|
||||
}
|
||||
|
||||
.nav-list a:hover {
|
||||
background: rgba(234, 248, 239, 0.55);
|
||||
color: var(--green-deep);
|
||||
background: var(--panel-soft);
|
||||
color: #304038;
|
||||
}
|
||||
|
||||
.nav-list a.active {
|
||||
background: var(--green-soft);
|
||||
color: var(--green-deep);
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1076,7 +1166,7 @@
|
||||
bottom: 0.45rem;
|
||||
width: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--green-deep);
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
@@ -1102,11 +1192,11 @@
|
||||
|
||||
.nav-group-toggle:hover,
|
||||
.nav-group-toggle.active {
|
||||
background: var(--green-soft);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
.nav-group-toggle.active {
|
||||
color: var(--green-deep);
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1172,42 +1262,101 @@
|
||||
}
|
||||
|
||||
.sidebar-signout:hover {
|
||||
background: rgba(234, 248, 239, 0.55);
|
||||
color: var(--green-deep);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.sidebar-signout.active {
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-signout.active .nav-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-meta-foot {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0.7rem 0.55rem 0;
|
||||
gap: 0.55rem;
|
||||
padding: 0.8rem 0.55rem 0;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.sidebar-meta-top,
|
||||
.sidebar-meta-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.sidebar-meta-foot small {
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.powered-by {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.powered-by span {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.powered-by strong {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
color: #5e6c64;
|
||||
}
|
||||
|
||||
.lean101-logo {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
object-fit: contain;
|
||||
opacity: 0.72;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.version-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.24rem 0.56rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-soft);
|
||||
color: #5e6c64;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.release-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.24rem 0.58rem;
|
||||
border: 1px solid rgba(20, 130, 73, 0.16);
|
||||
padding: 0.2rem 0.52rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, transparent);
|
||||
border-radius: 999px;
|
||||
background: #eaf8ef;
|
||||
color: #148249;
|
||||
font-size: 0.68rem;
|
||||
background: color-mix(in srgb, var(--color-brand) 8%, white);
|
||||
color: var(--color-brand);
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -1215,11 +1364,14 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.95fr) minmax(20rem, 1.1fr) auto;
|
||||
grid-template-columns: 1fr minmax(20rem, 36rem) 1fr;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
padding: 0.86rem 1.34rem;
|
||||
@@ -1277,7 +1429,7 @@
|
||||
}
|
||||
|
||||
.topbar-search {
|
||||
width: min(100%, 36rem);
|
||||
width: 100%;
|
||||
min-height: 3rem;
|
||||
background: #fff;
|
||||
}
|
||||
@@ -1334,23 +1486,90 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-status-dot {
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
.user-avatar-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: var(--green-deep);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.user-status-dot {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--panel-soft);
|
||||
background: #b4c0ba;
|
||||
box-shadow: 0 0 0 0.24rem rgba(180, 192, 186, 0.2);
|
||||
}
|
||||
|
||||
.user-status-dot.live {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 0 0.24rem rgba(34, 169, 94, 0.14);
|
||||
background: #4ade80;
|
||||
}
|
||||
|
||||
.user-status-dot.idle {
|
||||
background: #c08b3d;
|
||||
box-shadow: 0 0 0 0.24rem rgba(192, 139, 61, 0.14);
|
||||
}
|
||||
|
||||
.user-menu-avatar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--green-deep);
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.user-menu-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.72rem 0.78rem;
|
||||
border-radius: 0.82rem;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.user-menu-summary-text {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-menu-summary-text strong {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-menu-summary-text span {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-trigger-copy {
|
||||
@@ -1374,17 +1593,11 @@
|
||||
min-width: 16rem;
|
||||
}
|
||||
|
||||
.user-menu-summary {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.72rem 0.78rem;
|
||||
border-radius: 0.82rem;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.user-menu-summary span {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
.menu-settings-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
@@ -1409,10 +1622,10 @@
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.96rem;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.1);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -1433,9 +1646,9 @@
|
||||
padding: 0.88rem 1.05rem;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%);
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
box-shadow: 0 18px 36px rgba(23, 75, 45, 0.26);
|
||||
box-shadow: none;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
@@ -1497,8 +1710,11 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 1.34rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.locked-card {
|
||||
@@ -1534,7 +1750,7 @@
|
||||
margin-top: 1rem;
|
||||
padding: 0.78rem 0.92rem;
|
||||
border-radius: 0.88rem;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1552,10 +1768,10 @@
|
||||
|
||||
.palette {
|
||||
width: min(44rem, 100%);
|
||||
border: 1px solid rgba(217, 228, 221, 0.9);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1.2rem;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 17, 0.16);
|
||||
background: var(--color-bg-surface);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1668,7 +1884,7 @@
|
||||
border: 1px solid rgba(217, 228, 221, 0.92);
|
||||
border-radius: 1.35rem;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 17, 0.16);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
@@ -1695,8 +1911,8 @@
|
||||
|
||||
.bottom-nav a.active,
|
||||
.bottom-nav button.active {
|
||||
color: var(--green-deep);
|
||||
background: linear-gradient(180deg, #f4fbf7 0%, #e8f6ee 100%);
|
||||
color: var(--color-brand);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
.bottom-nav-icon {
|
||||
@@ -1707,13 +1923,13 @@
|
||||
justify-content: center;
|
||||
border-radius: 0.78rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
background: var(--green-deep);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.bottom-nav-icon.muted {
|
||||
background: linear-gradient(135deg, #96a49c 0%, #718077 100%);
|
||||
background: #8b949e;
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
@@ -1729,7 +1945,7 @@
|
||||
border-radius: 1.6rem 1.6rem 0 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(248, 251, 249, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%);
|
||||
box-shadow: 0 -20px 45px rgba(15, 23, 17, 0.16);
|
||||
box-shadow: 0 -2px 8px rgba(0,0,0,0.06);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
|
||||
.primary-button {
|
||||
border: none;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { api } from '$lib/api';
|
||||
import { featureFlags } from '$lib/features';
|
||||
import { clientSession, hasModuleAccess } from '$lib/session';
|
||||
import { toast } from '$lib/toast';
|
||||
import type {
|
||||
MixCalculatorCreateInput,
|
||||
MixCalculatorOptions,
|
||||
@@ -156,14 +157,18 @@
|
||||
async function calculatePreview() {
|
||||
const payload = buildPayload();
|
||||
if (!payload) {
|
||||
toast.error(formError);
|
||||
return;
|
||||
}
|
||||
|
||||
previewLoading = true;
|
||||
const tid = toast.loading('Calculating…');
|
||||
try {
|
||||
preview = await api.previewMixCalculatorSession(payload);
|
||||
formSuccess = 'Calculation refreshed from the saved product mix.';
|
||||
toast.dismiss(tid);
|
||||
} catch (error) {
|
||||
toast.dismiss(tid);
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to calculate the mix session.');
|
||||
formError = error instanceof Error ? error.message : 'Unable to calculate the mix session.';
|
||||
} finally {
|
||||
previewLoading = false;
|
||||
@@ -195,15 +200,20 @@
|
||||
}
|
||||
|
||||
saveLoading = true;
|
||||
const tid = toast.loading(mode === 'update' ? 'Saving session…' : 'Creating session…');
|
||||
try {
|
||||
const saved =
|
||||
mode === 'update' && initialSession
|
||||
? await api.updateMixCalculatorSession(initialSession.id, payload)
|
||||
: await api.createMixCalculatorSession(payload);
|
||||
|
||||
toast.dismiss(tid);
|
||||
toast.success(mode === 'update' ? 'Session saved' : 'Session created');
|
||||
const target = destination === 'print' ? `/mix-calculator/${saved.id}/print` : `/mix-calculator/${saved.id}`;
|
||||
await goto(target);
|
||||
} catch (error) {
|
||||
toast.dismiss(tid);
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to save the mix calculator session.');
|
||||
formError = error instanceof Error ? error.message : 'Unable to save the mix calculator session.';
|
||||
saveLoading = false;
|
||||
}
|
||||
@@ -427,8 +437,28 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<strong>No calculation yet</strong>
|
||||
<span>Choose a client, product, date, and batch size, then run the calculator.</span>
|
||||
<div class="empty-shimmer-metrics">
|
||||
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
|
||||
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
|
||||
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
|
||||
</div>
|
||||
<div class="empty-state-copy">
|
||||
<div class="empty-icon" aria-hidden="true">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<strong>No calculation yet</strong>
|
||||
<span>Choose a client, product, date, and batch size on the left, then click Calculate mix.</span>
|
||||
</div>
|
||||
<div class="empty-shimmer-rows">
|
||||
{#each [1,2,3,4,5] as _}
|
||||
<div class="shimmer-row">
|
||||
<div class="shimmer-line wide"></div>
|
||||
<div class="shimmer-line medium"></div>
|
||||
<div class="shimmer-line medium"></div>
|
||||
<div class="shimmer-line short"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
@@ -750,7 +780,7 @@
|
||||
|
||||
.primary-button {
|
||||
border: none;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -765,14 +795,14 @@
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
box-shadow: 0 14px 28px rgba(23, 75, 45, 0.22);
|
||||
box-shadow: none;
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
.secondary-button:hover:not(:disabled) {
|
||||
border-color: #9fb0a6;
|
||||
background: #f6faf7;
|
||||
box-shadow: 0 10px 22px rgba(24, 38, 29, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
@@ -863,12 +893,122 @@
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.empty-shimmer-metrics {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
place-items: start;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.shimmer-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.85rem;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.empty-state-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem 1.5rem;
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.empty-state-copy strong {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.empty-state-copy span {
|
||||
max-width: 26rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.28rem;
|
||||
height: 2.2rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.empty-icon span {
|
||||
width: 0.38rem;
|
||||
border-radius: 999px 999px 0 0;
|
||||
background: var(--color-border);
|
||||
animation: bar-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.empty-icon span:nth-child(1) { height: 60%; animation-delay: 0s; }
|
||||
.empty-icon span:nth-child(2) { height: 100%; animation-delay: 0.2s; }
|
||||
.empty-icon span:nth-child(3) { height: 40%; animation-delay: 0.4s; }
|
||||
|
||||
@keyframes bar-pulse {
|
||||
0%, 100% { opacity: 0.35; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.empty-shimmer-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.shimmer-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 0.75fr;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.78rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.shimmer-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.shimmer-line {
|
||||
height: 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-border) 25%,
|
||||
color-mix(in srgb, var(--color-border) 40%, white) 50%,
|
||||
var(--color-border) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shimmer-line.short { width: 40%; }
|
||||
.shimmer-line.medium { width: 65%; }
|
||||
.shimmer-line.wide { width: 90%; }
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.workspace-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { clientSession } from '$lib/session';
|
||||
import { toast } from '$lib/toast';
|
||||
import type { Mix, MixIngredient, RawMaterial } from '$lib/types';
|
||||
|
||||
type DraftIngredient = {
|
||||
@@ -166,16 +167,14 @@
|
||||
}
|
||||
|
||||
async function saveMix() {
|
||||
feedback = '';
|
||||
errorMessage = '';
|
||||
|
||||
const validationWarnings = getDraftWarnings();
|
||||
if (validationWarnings.length) {
|
||||
errorMessage = validationWarnings[0];
|
||||
toast.error(validationWarnings[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
const tid = toast.loading(savedMix ? 'Saving mix…' : 'Creating mix…');
|
||||
|
||||
try {
|
||||
const cleanIngredients = getCleanIngredients();
|
||||
@@ -194,6 +193,8 @@
|
||||
}))
|
||||
});
|
||||
|
||||
toast.dismiss(tid);
|
||||
toast.success('Mix created');
|
||||
await invalidateAll();
|
||||
await goto(`/mixes/${created.id}`);
|
||||
return;
|
||||
@@ -263,9 +264,11 @@
|
||||
const refreshed = await api.mix(savedMix.id);
|
||||
await invalidateAll();
|
||||
loadDraftFromMix(refreshed);
|
||||
feedback = 'Mix saved with updated ingredient costing.';
|
||||
toast.dismiss(tid);
|
||||
toast.success('Mix saved');
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : 'Unable to save mix';
|
||||
toast.dismiss(tid);
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to save mix');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
@@ -644,8 +647,8 @@
|
||||
.primary-button {
|
||||
border: none;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.18);
|
||||
background: var(--color-brand);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { toast, type Toast } from '$lib/toast';
|
||||
|
||||
function icon(type: Toast['type']) {
|
||||
if (type === 'success') return '✓';
|
||||
if (type === 'error') return '✕';
|
||||
if (type === 'loading') return null; // spinner shown separately
|
||||
return 'ℹ';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $toast.length}
|
||||
<div class="toast-stack" aria-live="polite" aria-atomic="false">
|
||||
{#each $toast as t (t.id)}
|
||||
<div class="toast toast--{t.type}" role={t.type === 'error' ? 'alert' : 'status'}>
|
||||
<span class="toast-icon" aria-hidden="true">
|
||||
{#if t.type === 'loading'}
|
||||
<span class="spinner"></span>
|
||||
{:else}
|
||||
{icon(t.type)}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="toast-message">{t.message}</span>
|
||||
{#if t.type !== 'loading'}
|
||||
<button
|
||||
class="toast-close"
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
onclick={() => toast.dismiss(t.id)}
|
||||
>✕</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
width: max-content;
|
||||
max-width: min(28rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.7rem 1rem 0.7rem 0.85rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
pointer-events: all;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.14);
|
||||
animation: slide-up 180ms ease both;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(0.5rem); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.toast--info {
|
||||
background: #1a1f1c;
|
||||
color: #f0f4f1;
|
||||
}
|
||||
|
||||
.toast--success {
|
||||
background: #1a3326;
|
||||
color: #a3f0c5;
|
||||
border: 1px solid #2a6645;
|
||||
}
|
||||
|
||||
.toast--error {
|
||||
background: #3b1212;
|
||||
color: #fca5a5;
|
||||
border: 1px solid #7f2020;
|
||||
}
|
||||
|
||||
.toast--loading {
|
||||
background: #1a1f1c;
|
||||
color: #d1e8da;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.82rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast--success .toast-icon { color: #4ade80; }
|
||||
.toast--error .toast-icon { color: #f87171; }
|
||||
.toast--info .toast-icon { color: #93c5fd; }
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.25rem;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.5;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
display: block;
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: #d1e8da;
|
||||
border-radius: 50%;
|
||||
animation: spin 600ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.toast-stack {
|
||||
bottom: 5rem;
|
||||
width: calc(100vw - 2rem);
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type ToastType = 'info' | 'success' | 'error' | 'loading';
|
||||
|
||||
export type Toast = {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
duration?: number; // ms — undefined means the toast stays until manually dismissed
|
||||
};
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update } = writable<Toast[]>([]);
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
function add(message: string, type: ToastType = 'info', duration?: number): string {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const resolved = duration ?? (type === 'loading' ? undefined : type === 'error' ? 5000 : 3000);
|
||||
|
||||
update((toasts) => [...toasts, { id, message, type, duration: resolved }]);
|
||||
|
||||
if (resolved !== undefined) {
|
||||
timers.set(id, setTimeout(() => dismiss(id), resolved));
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function dismiss(id: string) {
|
||||
clearTimeout(timers.get(id));
|
||||
timers.delete(id);
|
||||
update((toasts) => toasts.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
function success(message: string) {
|
||||
return add(message, 'success');
|
||||
}
|
||||
|
||||
function error(message: string) {
|
||||
return add(message, 'error');
|
||||
}
|
||||
|
||||
function loading(message: string): string {
|
||||
return add(message, 'loading');
|
||||
}
|
||||
|
||||
return { subscribe, add, dismiss, success, error, loading };
|
||||
}
|
||||
|
||||
export const toast = createToastStore();
|
||||
@@ -1,12 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { beforeNavigate, afterNavigate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import AdminShell from '$lib/components/AdminShell.svelte';
|
||||
import ClientShell from '$lib/components/ClientShell.svelte';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const isAdminRoute = $derived(page.url.pathname === '/admin' || page.url.pathname.startsWith('/admin/'));
|
||||
const isPrintableRoute = $derived(page.url.pathname.startsWith('/mix-calculator/') && page.url.pathname.endsWith('/print'));
|
||||
|
||||
let navToastId: string | null = null;
|
||||
let navTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
beforeNavigate(() => {
|
||||
navTimer = setTimeout(() => {
|
||||
navToastId = toast.loading('Loading…');
|
||||
navTimer = null;
|
||||
}, 150);
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
if (navTimer !== null) {
|
||||
clearTimeout(navTimer);
|
||||
navTimer = null;
|
||||
}
|
||||
if (navToastId) {
|
||||
toast.dismiss(navToastId);
|
||||
navToastId = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isPrintableRoute}
|
||||
@@ -20,3 +44,5 @@
|
||||
{@render children()}
|
||||
</ClientShell>
|
||||
{/if}
|
||||
|
||||
<Toast />
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
let passwordInput: HTMLInputElement | null = null;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const appVersion = `v${packageInfo.version}`;
|
||||
const releaseStage = 'Alpha';
|
||||
const releaseStage = 'Beta';
|
||||
|
||||
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep'];
|
||||
|
||||
@@ -141,9 +141,9 @@
|
||||
|
||||
function buildSegments(current: DashboardSummary | null) {
|
||||
return [
|
||||
{ label: 'Materials', value: current?.raw_materials?.count ?? 0, color: '#2c9b5f' },
|
||||
{ label: 'Mixes', value: current?.mixes?.count ?? 0, color: '#d7802a' },
|
||||
{ label: 'Products', value: current?.products?.count ?? 0, color: '#286ea7' }
|
||||
{ label: 'Materials', value: current?.raw_materials?.count ?? 0, color: '#15803d' },
|
||||
{ label: 'Mixes', value: current?.mixes?.count ?? 0, color: '#bf8700' },
|
||||
{ label: 'Products', value: current?.products?.count ?? 0, color: '#0969da' }
|
||||
];
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
y1: Number(inner.y.toFixed(2)),
|
||||
x2: Number(outer.x.toFixed(2)),
|
||||
y2: Number(outer.y.toFixed(2)),
|
||||
color: activeStop?.color ?? '#2c9b5f'
|
||||
color: activeStop?.color ?? '#15803d'
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -325,7 +325,8 @@
|
||||
|
||||
<div class="auth-footer">
|
||||
<div class="lean-brand">
|
||||
<img class="footer-login-logo" src="/logo-hsf.png" alt="Lean 101" />
|
||||
<img class="lean-isotipo" src="/lean101-isotipo.png" alt="Lean 101" />
|
||||
<span class="powered-by-label">Powered by Lean 101</span>
|
||||
</div>
|
||||
<div class="auth-meta">
|
||||
<span class="version-badge">
|
||||
@@ -387,7 +388,8 @@
|
||||
|
||||
<div class="auth-footer">
|
||||
<div class="lean-brand">
|
||||
<img class="footer-login-logo" src="/logo-hsf.png" alt="Lean 101" />
|
||||
<img class="lean-isotipo" src="/lean101-isotipo.png" alt="Lean 101" />
|
||||
<span class="powered-by-label">Powered by Lean 101</span>
|
||||
</div>
|
||||
<div class="auth-meta">
|
||||
<span class="version-badge">
|
||||
@@ -579,13 +581,13 @@
|
||||
<svg viewBox="0 0 100 56" preserveAspectRatio="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="chart-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#59c97f" stop-opacity="0.32" />
|
||||
<stop offset="100%" stop-color="#59c97f" stop-opacity="0.02" />
|
||||
<stop offset="0%" stop-color="#15803d" stop-opacity="0.22" />
|
||||
<stop offset="100%" stop-color="#15803d" stop-opacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path d={trendArea} fill="url(#chart-fill)"></path>
|
||||
<path d={trendLine} fill="none" stroke="#2ba560" stroke-width="1.6" stroke-linecap="round"></path>
|
||||
<path d={trendLine} fill="none" stroke="#15803d" stroke-width="1.6" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -753,7 +755,7 @@
|
||||
radial-gradient(circle at top left, rgba(115, 197, 146, 0.16), transparent 32%),
|
||||
radial-gradient(circle at bottom right, rgba(33, 94, 60, 0.1), transparent 30%),
|
||||
rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 28px 70px rgba(17, 37, 25, 0.14);
|
||||
box-shadow: none;
|
||||
backdrop-filter: blur(14px);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -833,10 +835,10 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.48rem 0.8rem;
|
||||
border: 1px solid rgba(44, 123, 72, 0.12);
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 16%, transparent);
|
||||
border-radius: 999px;
|
||||
background: rgba(240, 249, 244, 0.96);
|
||||
color: #1e6a3d;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-success);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -858,10 +860,10 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.28rem 0.62rem;
|
||||
border: 1px solid rgba(20, 130, 73, 0.16);
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 16%, transparent);
|
||||
border-radius: 999px;
|
||||
background: #eaf8ef;
|
||||
color: #148249;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-success);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
@@ -910,8 +912,8 @@
|
||||
height: 0.95rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%);
|
||||
box-shadow: 0 0 0 0 rgba(47, 123, 72, 0.28);
|
||||
background: var(--color-brand);
|
||||
box-shadow: 0 0 0 0 rgba(21, 128, 61, 0.28);
|
||||
animation: pulse 1.8s ease-out infinite;
|
||||
}
|
||||
|
||||
@@ -946,8 +948,8 @@
|
||||
|
||||
.auth-form input:focus {
|
||||
outline: none;
|
||||
border-color: #4d9668;
|
||||
box-shadow: 0 0 0 0.24rem rgba(77, 150, 104, 0.12);
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 0.24rem color-mix(in srgb, var(--color-brand) 12%, transparent);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@@ -975,6 +977,20 @@
|
||||
.lean-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.lean-isotipo {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
object-fit: contain;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.powered-by-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.auth-meta {
|
||||
@@ -987,17 +1003,17 @@
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(47, 123, 72, 0.26);
|
||||
box-shadow: 0 0 0 0 rgba(21, 128, 61, 0.26);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 0.7rem rgba(47, 123, 72, 0);
|
||||
box-shadow: 0 0 0 0.7rem rgba(21, 128, 61, 0);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(47, 123, 72, 0);
|
||||
box-shadow: 0 0 0 0 rgba(21, 128, 61, 0);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
@@ -1102,8 +1118,8 @@
|
||||
.primary-button {
|
||||
border: none;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%);
|
||||
box-shadow: 0 8px 20px rgba(23, 75, 45, 0.2);
|
||||
background: var(--color-brand);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
@@ -1195,7 +1211,7 @@
|
||||
justify-content: center;
|
||||
border-radius: 0.75rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%);
|
||||
background: var(--green-deep);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -1297,7 +1313,7 @@
|
||||
|
||||
.toggle-pill .active {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%);
|
||||
background: var(--green-deep);
|
||||
}
|
||||
|
||||
.market-layout {
|
||||
@@ -1441,8 +1457,8 @@
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #eef8f1 0%, #dff5e8 100%);
|
||||
border: 1px solid #d3eadb;
|
||||
background: var(--color-brand-tint);
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 15%, transparent);
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
@@ -1457,11 +1473,11 @@
|
||||
margin-top: 0.4rem;
|
||||
border-radius: 1.25rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(88, 197, 121, 0.06) 0%, rgba(88, 197, 121, 0.01) 100%),
|
||||
linear-gradient(180deg, rgba(21, 128, 61, 0.05) 0%, rgba(21, 128, 61, 0.01) 100%),
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent 0 3.45rem,
|
||||
rgba(196, 226, 205, 0.45) 3.45rem 3.55rem
|
||||
rgba(21, 128, 61, 0.08) 3.45rem 3.55rem
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1481,7 +1497,7 @@
|
||||
border-radius: 0.8rem;
|
||||
background: #1e2420;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 20px rgba(15, 23, 17, 0.16);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.focus-badge span {
|
||||
@@ -1700,7 +1716,7 @@
|
||||
|
||||
.owner-chip span {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%);
|
||||
background: var(--green-deep);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@@ -1764,7 +1780,7 @@
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%);
|
||||
background: var(--color-brand);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
border: 1px solid rgba(34, 54, 45, 0.1);
|
||||
border-radius: 1.35rem;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.06);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
@@ -313,8 +313,8 @@
|
||||
.primary-button {
|
||||
border: none;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #4f8860 0%, #203028 100%);
|
||||
box-shadow: 0 8px 20px rgba(32, 48, 40, 0.18);
|
||||
background: var(--color-brand);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import type { ClientAccessAccount, ClientAccessFeature, ClientAccessPowerBiExport } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -59,20 +60,19 @@
|
||||
|
||||
async function handleCreateUser(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
formError = '';
|
||||
formSuccess = '';
|
||||
|
||||
if (!selectedClientId) {
|
||||
formError = 'Select a client before creating a user.';
|
||||
toast.error('Select a client before creating a user.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fullName.trim() || !email.trim()) {
|
||||
formError = 'Name and email are required.';
|
||||
toast.error('Name and email are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
const tid = toast.loading('Creating user…');
|
||||
try {
|
||||
const updatedClient = await api.createClientUser({
|
||||
client_account_id: selectedClientId,
|
||||
@@ -89,9 +89,11 @@
|
||||
role = 'viewer';
|
||||
status = 'invited';
|
||||
isNewUser = true;
|
||||
formSuccess = 'User created and included in the export preview.';
|
||||
toast.dismiss(tid);
|
||||
toast.success('User created');
|
||||
} catch (error) {
|
||||
formError = error instanceof Error ? error.message : 'Unable to create client user';
|
||||
toast.dismiss(tid);
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to create client user');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
@@ -99,16 +101,16 @@
|
||||
|
||||
async function updateUser(userId: number, payload: { role?: string; status?: string; is_new_user?: boolean }) {
|
||||
savingUserId = userId;
|
||||
formError = '';
|
||||
formSuccess = '';
|
||||
|
||||
const tid = toast.loading('Updating user…');
|
||||
try {
|
||||
const updatedClient = await api.updateClientUser(userId, payload);
|
||||
replaceClient(updatedClient);
|
||||
await refreshExportPreview();
|
||||
formSuccess = 'User access updated.';
|
||||
toast.dismiss(tid);
|
||||
toast.success('User access updated');
|
||||
} catch (error) {
|
||||
formError = error instanceof Error ? error.message : 'Unable to update client user';
|
||||
toast.dismiss(tid);
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to update client user');
|
||||
} finally {
|
||||
savingUserId = null;
|
||||
}
|
||||
@@ -116,16 +118,16 @@
|
||||
|
||||
async function toggleFeature(feature: ClientAccessFeature) {
|
||||
savingFeatureId = feature.id;
|
||||
formError = '';
|
||||
formSuccess = '';
|
||||
|
||||
const tid = toast.loading('Updating feature…');
|
||||
try {
|
||||
const updatedClient = await api.updateClientFeature(feature.id, { enabled: !feature.enabled });
|
||||
replaceClient(updatedClient);
|
||||
await refreshExportPreview();
|
||||
formSuccess = `${feature.feature_name} ${feature.enabled ? 'disabled' : 'enabled'}.`;
|
||||
toast.dismiss(tid);
|
||||
toast.success(`${feature.feature_name} ${feature.enabled ? 'disabled' : 'enabled'}`);
|
||||
} catch (error) {
|
||||
formError = error instanceof Error ? error.message : 'Unable to update feature access';
|
||||
toast.dismiss(tid);
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to update feature access');
|
||||
} finally {
|
||||
savingFeatureId = null;
|
||||
}
|
||||
@@ -596,7 +598,7 @@
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.8rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
background: var(--color-brand);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -682,8 +684,8 @@
|
||||
border-radius: 0.85rem;
|
||||
padding: 0.85rem 1rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.2);
|
||||
background: var(--color-brand);
|
||||
box-shadow: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -795,7 +797,7 @@
|
||||
.feature-toggle.enabled {
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
.feature-toggle:disabled {
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
justify-content: center;
|
||||
padding: 0.78rem 0.96rem;
|
||||
border-radius: 0.9rem;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -270,8 +270,8 @@
|
||||
padding: 0.74rem 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.18);
|
||||
background: var(--color-brand);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
@@ -384,7 +384,7 @@
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.72rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
background: var(--color-brand);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -444,7 +444,7 @@
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.84rem;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.1);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.menu-panel a {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,650 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
Wheat,
|
||||
FlaskConical,
|
||||
Boxes,
|
||||
AlertTriangle,
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
} from 'lucide-svelte';
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
type ReportId =
|
||||
| 'summary'
|
||||
| 'raw-material-costs'
|
||||
| 'mix-cost-summary'
|
||||
| 'product-pricing'
|
||||
| 'data-quality'
|
||||
| 'price-review';
|
||||
|
||||
type ReportItem = {
|
||||
id: ReportId;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: ComponentType;
|
||||
group: string;
|
||||
};
|
||||
|
||||
const reports: ReportItem[] = [
|
||||
{
|
||||
id: 'summary',
|
||||
label: 'Overview',
|
||||
description: 'High-level snapshot of the current costing model.',
|
||||
icon: BarChart3,
|
||||
group: 'Overview',
|
||||
},
|
||||
{
|
||||
id: 'raw-material-costs',
|
||||
label: 'Raw Material Costs',
|
||||
description: 'Current prices, waste assumptions, and cost per kg for all tracked inputs.',
|
||||
icon: Wheat,
|
||||
group: 'Costing',
|
||||
},
|
||||
{
|
||||
id: 'mix-cost-summary',
|
||||
label: 'Mix Cost Summary',
|
||||
description: 'Per-mix cost per kg across the current mix master.',
|
||||
icon: FlaskConical,
|
||||
group: 'Costing',
|
||||
},
|
||||
{
|
||||
id: 'product-pricing',
|
||||
label: 'Product Pricing',
|
||||
description: 'Finished delivered cost and selling price outputs by product.',
|
||||
icon: Boxes,
|
||||
group: 'Costing',
|
||||
},
|
||||
{
|
||||
id: 'data-quality',
|
||||
label: 'Data Quality',
|
||||
description: 'Items with missing costs, unresolved warnings, or stale inputs.',
|
||||
icon: AlertTriangle,
|
||||
group: 'Quality',
|
||||
},
|
||||
{
|
||||
id: 'price-review',
|
||||
label: 'Price Review',
|
||||
description: 'Current vs proposed pricing status across all products.',
|
||||
icon: ClipboardCheck,
|
||||
group: 'Quality',
|
||||
},
|
||||
];
|
||||
|
||||
const groups = [...new Set(reports.map((r) => r.group))];
|
||||
|
||||
let activeId = $state<ReportId>('summary');
|
||||
|
||||
const activeReport = $derived(reports.find((r) => r.id === activeId) ?? reports[0]);
|
||||
</script>
|
||||
|
||||
<div class="reporting-layout">
|
||||
<nav class="report-nav" aria-label="Report navigation">
|
||||
<p class="nav-section-label">Reporting</p>
|
||||
|
||||
<div class="nav-identity">
|
||||
<div class="nav-avatar" aria-hidden="true">
|
||||
<TrendingUp size={16} strokeWidth={1.75} />
|
||||
</div>
|
||||
<div class="nav-identity-text">
|
||||
<p class="identity-name">Workspace reports</p>
|
||||
<p class="identity-role">Costing and quality views</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each groups as group}
|
||||
<div class="nav-group">
|
||||
<p class="nav-group-label">{group}</p>
|
||||
{#each reports.filter((r) => r.group === group) as report}
|
||||
{@const Icon = report.icon}
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item"
|
||||
class:active={activeId === report.id}
|
||||
onclick={() => (activeId = report.id)}
|
||||
>
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{report.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="report-panel">
|
||||
{#if activeReport}
|
||||
{@const PanelIcon = activeReport.icon}
|
||||
<header class="panel-header">
|
||||
<div class="panel-header-icon" aria-hidden="true">
|
||||
<PanelIcon size={16} strokeWidth={1.75} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="panel-eyebrow">{activeReport.group}</p>
|
||||
<h2>{activeReport.label}</h2>
|
||||
<p class="panel-description">{activeReport.description}</p>
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<div class="panel-body">
|
||||
{#if activeId === 'summary'}
|
||||
<div class="report-placeholder">
|
||||
<div class="placeholder-icon" aria-hidden="true">
|
||||
<BarChart3 size={32} strokeWidth={1.25} />
|
||||
</div>
|
||||
<strong>Overview report</strong>
|
||||
<span>A summary of raw material count, mix count, product pricing outputs, and open data quality issues will appear here.</span>
|
||||
<div class="placeholder-chips">
|
||||
<span class="chip">Raw Material Costs</span>
|
||||
<span class="chip">Mix Cost Summary</span>
|
||||
<span class="chip">Product Pricing</span>
|
||||
<span class="chip">Data Quality</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if activeId === 'raw-material-costs'}
|
||||
<div class="report-placeholder">
|
||||
<div class="placeholder-icon" aria-hidden="true">
|
||||
<Wheat size={32} strokeWidth={1.25} />
|
||||
</div>
|
||||
<strong>Raw Material Costs</strong>
|
||||
<span>Market value, waste percentage, cost per unit, and cost per kg for every tracked raw material will display here.</span>
|
||||
<div class="placeholder-table-preview">
|
||||
<div class="preview-header-row">
|
||||
<span>Raw Material</span><span>Market Value</span><span>Waste %</span><span>Cost / Kg</span>
|
||||
</div>
|
||||
{#each [1, 2, 3, 4] as _}
|
||||
<div class="preview-data-row">
|
||||
<div class="shimmer wide"></div>
|
||||
<div class="shimmer medium"></div>
|
||||
<div class="shimmer short"></div>
|
||||
<div class="shimmer medium"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if activeId === 'mix-cost-summary'}
|
||||
<div class="report-placeholder">
|
||||
<div class="placeholder-icon" aria-hidden="true">
|
||||
<FlaskConical size={32} strokeWidth={1.25} />
|
||||
</div>
|
||||
<strong>Mix Cost Summary</strong>
|
||||
<span>Total ingredients, total kg, total mix cost, and cost per kg for each mix in the current mix master will appear here.</span>
|
||||
<div class="placeholder-table-preview">
|
||||
<div class="preview-header-row">
|
||||
<span>Mix Name</span><span>Client</span><span>Ingredients</span><span>Cost / Kg</span>
|
||||
</div>
|
||||
{#each [1, 2, 3, 4] as _}
|
||||
<div class="preview-data-row">
|
||||
<div class="shimmer wide"></div>
|
||||
<div class="shimmer medium"></div>
|
||||
<div class="shimmer short"></div>
|
||||
<div class="shimmer medium"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if activeId === 'product-pricing'}
|
||||
<div class="report-placeholder">
|
||||
<div class="placeholder-icon" aria-hidden="true">
|
||||
<Boxes size={32} strokeWidth={1.25} />
|
||||
</div>
|
||||
<strong>Product Pricing Output</strong>
|
||||
<span>Cleaned product cost, process costs, packaging, freight, finished delivered cost, and selling price by product will appear here.</span>
|
||||
<div class="placeholder-table-preview">
|
||||
<div class="preview-header-row">
|
||||
<span>Product</span><span>Cleaned Cost</span><span>Delivered</span><span>Distributor</span>
|
||||
</div>
|
||||
{#each [1, 2, 3, 4] as _}
|
||||
<div class="preview-data-row">
|
||||
<div class="shimmer wide"></div>
|
||||
<div class="shimmer medium"></div>
|
||||
<div class="shimmer medium"></div>
|
||||
<div class="shimmer medium"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if activeId === 'data-quality'}
|
||||
<div class="report-placeholder">
|
||||
<div class="placeholder-icon warning" aria-hidden="true">
|
||||
<AlertTriangle size={32} strokeWidth={1.25} />
|
||||
</div>
|
||||
<strong>Data Quality Issues</strong>
|
||||
<span>Raw materials missing costs, mixes with unresolved warnings, and products with stale or missing inputs will be listed here.</span>
|
||||
<div class="placeholder-chips">
|
||||
<span class="chip warning">Missing prices</span>
|
||||
<span class="chip warning">Unresolved notes</span>
|
||||
<span class="chip warning">Zero quantities</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if activeId === 'price-review'}
|
||||
<div class="report-placeholder">
|
||||
<div class="placeholder-icon" aria-hidden="true">
|
||||
<ClipboardCheck size={32} strokeWidth={1.25} />
|
||||
</div>
|
||||
<strong>Price Review Status</strong>
|
||||
<span>Current vs proposed wholesale and distributor pricing, with review status and delta for each product line, will appear here.</span>
|
||||
<div class="placeholder-table-preview">
|
||||
<div class="preview-header-row">
|
||||
<span>Product</span><span>Current</span><span>Proposed</span><span>Status</span>
|
||||
</div>
|
||||
{#each [1, 2, 3, 4] as _}
|
||||
<div class="preview-data-row">
|
||||
<div class="shimmer wide"></div>
|
||||
<div class="shimmer medium"></div>
|
||||
<div class="shimmer medium"></div>
|
||||
<div class="shimmer short"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.reporting-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 15rem minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
min-height: calc(100vh - 8.5rem);
|
||||
max-height: calc(100vh - 8.5rem);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.15rem;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.report-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 8.5rem);
|
||||
padding: 1.1rem 0.85rem 0.85rem;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-section-label {
|
||||
margin: 0 0.55rem 0.3rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0 0.25rem 0.9rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.nav-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--green-deep);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-identity-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.identity-name {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.identity-role {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.nav-group + .nav-group {
|
||||
padding-top: 0.7rem;
|
||||
}
|
||||
|
||||
.nav-group-label {
|
||||
margin: 0.15rem 0.55rem 0.3rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.6rem;
|
||||
border: none;
|
||||
border-radius: 0.7rem;
|
||||
background: transparent;
|
||||
color: #3a4a41;
|
||||
font-size: 0.93rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
color: #6d7d74;
|
||||
border-radius: 0.55rem;
|
||||
transition: color 140ms ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--panel-soft);
|
||||
color: #304038;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item:hover .nav-icon {
|
||||
color: #304038;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -0.85rem;
|
||||
top: 0.45rem;
|
||||
bottom: 0.45rem;
|
||||
width: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
.report-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--panel);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.panel-header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 0.72rem;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 15%, transparent);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.panel-eyebrow {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0.2rem 0 0.3rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.panel-description {
|
||||
margin: 0;
|
||||
font-size: 0.84rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Report placeholders ───────────────────────────────────────── */
|
||||
|
||||
.report-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 2.5rem 1.5rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 15%, transparent);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.placeholder-icon.warning {
|
||||
background: #fff6e6;
|
||||
color: #b06a10;
|
||||
border-color: rgba(176, 106, 16, 0.2);
|
||||
}
|
||||
|
||||
.report-placeholder strong {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.report-placeholder span {
|
||||
max-width: 36rem;
|
||||
font-size: 0.87rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.placeholder-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.72rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 12%, transparent);
|
||||
}
|
||||
|
||||
.chip.warning {
|
||||
background: #fff6e6;
|
||||
color: #b06a10;
|
||||
border-color: rgba(176, 106, 16, 0.15);
|
||||
}
|
||||
|
||||
/* ── Shimmer table preview ─────────────────────────────────────── */
|
||||
|
||||
.placeholder-table-preview {
|
||||
width: 100%;
|
||||
max-width: 44rem;
|
||||
margin-top: 1.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.9rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-header-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--panel-soft);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.preview-data-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.72rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.preview-data-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
height: 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-border) 25%,
|
||||
color-mix(in srgb, var(--color-border) 40%, white) 50%,
|
||||
var(--color-border) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shimmer.short { width: 45%; }
|
||||
.shimmer.medium { width: 70%; }
|
||||
.shimmer.wide { width: 92%; }
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.reporting-layout {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: auto;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.report-nav {
|
||||
position: static;
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.nav-group-label {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.panel-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-header-row,
|
||||
.preview-data-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.preview-header-row span:nth-child(n+3),
|
||||
.preview-data-row .shimmer:nth-child(n+3) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,126 +1,496 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { clientSession } from '$lib/session';
|
||||
import { toast } from '$lib/toast';
|
||||
import { CircleUserRound, LockKeyhole } from 'lucide-svelte';
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
</script>
|
||||
type Section = 'profile' | 'security';
|
||||
let activeSection = $state<Section>('profile');
|
||||
|
||||
<section class="settings-grid">
|
||||
<article class="surface-card">
|
||||
<p class="eyebrow">Session</p>
|
||||
<h3>Signed-in account</h3>
|
||||
<div class="details-list">
|
||||
<div>
|
||||
<span>Name</span>
|
||||
<strong>{$clientSession?.name ?? 'No active session'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Email</span>
|
||||
<strong>{$clientSession?.email ?? 'Sign in required'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Role</span>
|
||||
<strong>{$clientSession?.role ?? 'Client'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
let name = $state($clientSession?.name ?? '');
|
||||
let email = $state($clientSession?.email ?? '');
|
||||
|
||||
<article class="surface-card">
|
||||
<p class="eyebrow">Display</p>
|
||||
<h3>Navigation behaviour</h3>
|
||||
<div class="details-list">
|
||||
<div>
|
||||
<span>Desktop</span>
|
||||
<strong>Left rail navigation</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>iPad / Tablet</span>
|
||||
<strong>Bottom navigation drawer</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Copyright</span>
|
||||
<strong>© {currentYear} Hunter Premium Produce</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
let currentPassword = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirmPassword = $state('');
|
||||
|
||||
<style>
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
let profileSaving = $state(false);
|
||||
let passwordSaving = $state(false);
|
||||
let passwordError = $state('');
|
||||
|
||||
async function saveProfile() {
|
||||
profileSaving = true;
|
||||
const tid = toast.loading('Saving profile…');
|
||||
try {
|
||||
const updated = await api.updateMe({ name: name.trim(), email: email.trim().toLowerCase() });
|
||||
clientSession.set({
|
||||
...$clientSession!,
|
||||
name: updated.name,
|
||||
email: updated.email,
|
||||
token: updated.token ?? $clientSession!.token,
|
||||
});
|
||||
toast.dismiss(tid);
|
||||
toast.success('Profile updated');
|
||||
} catch (err: unknown) {
|
||||
toast.dismiss(tid);
|
||||
toast.error(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
profileSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #7f8e85;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
async function savePassword() {
|
||||
passwordError = '';
|
||||
if (newPassword !== confirmPassword) {
|
||||
passwordError = 'New passwords do not match';
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
passwordError = 'Password must be at least 8 characters';
|
||||
return;
|
||||
}
|
||||
passwordSaving = true;
|
||||
const tid = toast.loading('Updating password…');
|
||||
try {
|
||||
await api.updateMe({ current_password: currentPassword, new_password: newPassword });
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
confirmPassword = '';
|
||||
toast.dismiss(tid);
|
||||
toast.success('Password updated');
|
||||
} catch (err: unknown) {
|
||||
toast.dismiss(tid);
|
||||
const msg = err instanceof Error ? err.message : 'An error occurred';
|
||||
passwordError = msg;
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
passwordSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
const initials = $derived(
|
||||
($clientSession?.name ?? '')
|
||||
.split(' ')
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0])
|
||||
.join('')
|
||||
.toUpperCase() || '?'
|
||||
);
|
||||
|
||||
const navItems: { id: Section; label: string; icon: typeof CircleUserRound }[] = [
|
||||
{ id: 'profile', label: 'Profile', icon: CircleUserRound },
|
||||
{ id: 'security', label: 'Security', icon: LockKeyhole },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="settings-layout">
|
||||
<nav class="settings-nav" aria-label="Settings sections">
|
||||
<p class="nav-section-label">Settings</p>
|
||||
|
||||
<div class="nav-identity">
|
||||
<div class="avatar" aria-hidden="true">{initials}</div>
|
||||
<div class="identity-text">
|
||||
<p class="identity-name">{$clientSession?.name ?? 'Unknown'}</p>
|
||||
<p class="identity-role">{$clientSession?.role_name ?? $clientSession?.role ?? 'User'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{#each navItems as item}
|
||||
{@const Icon = item.icon}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item"
|
||||
class:active={activeSection === item.id}
|
||||
onclick={() => (activeSection = item.id)}
|
||||
>
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="settings-panel">
|
||||
{#if activeSection === 'profile'}
|
||||
<div class="panel-section">
|
||||
<header class="panel-header">
|
||||
<h2>Profile</h2>
|
||||
<p>Update your display name and email address.</p>
|
||||
</header>
|
||||
|
||||
<form class="panel-form" onsubmit={(e) => { e.preventDefault(); saveProfile(); }}>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="name">Full name</label>
|
||||
<input id="name" type="text" bind:value={name} autocomplete="name" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email">Email address</label>
|
||||
<input id="email" type="email" bind:value={email} autocomplete="email" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button class="btn-primary" type="submit" disabled={profileSaving}>
|
||||
{profileSaving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{:else if activeSection === 'security'}
|
||||
<div class="panel-section">
|
||||
<header class="panel-header">
|
||||
<h2>Security</h2>
|
||||
<p>Choose a strong password with at least 8 characters.</p>
|
||||
</header>
|
||||
|
||||
<form class="panel-form" onsubmit={(e) => { e.preventDefault(); savePassword(); }}>
|
||||
<div class="field">
|
||||
<label for="current-password">Current password</label>
|
||||
<input id="current-password" type="password" bind:value={currentPassword} autocomplete="current-password" required />
|
||||
</div>
|
||||
|
||||
<div class="divider" aria-hidden="true"></div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="new-password">New password</label>
|
||||
<input id="new-password" type="password" bind:value={newPassword} autocomplete="new-password" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="confirm-password">Confirm new password</label>
|
||||
<input id="confirm-password" type="password" bind:value={confirmPassword} autocomplete="new-password" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if passwordError}
|
||||
<p class="form-error">{passwordError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="form-footer">
|
||||
<button class="btn-primary" type="submit" disabled={passwordSaving}>
|
||||
{passwordSaving ? 'Updating…' : 'Update password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 15rem minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
min-height: calc(100vh - 8.5rem);
|
||||
max-height: calc(100vh - 8.5rem);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.15rem;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 8.5rem);
|
||||
padding: 1.1rem 0.85rem 0.85rem;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-section-label {
|
||||
margin: 0 0.55rem 0.3rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-intro,
|
||||
.settings-grid {
|
||||
margin-bottom: 1.2rem;
|
||||
.nav-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0 0.25rem 0.9rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.page-intro h2 {
|
||||
margin: 0.35rem 0 0.45rem;
|
||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--green-deep);
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.identity-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.identity-name {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.identity-role {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.settings-nav ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.settings-nav li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.6rem;
|
||||
border: none;
|
||||
border-radius: 0.7rem;
|
||||
background: transparent;
|
||||
color: #3a4a41;
|
||||
font-size: 0.93rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
color: #6d7d74;
|
||||
border-radius: 0.55rem;
|
||||
transition: color 140ms ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--panel-soft);
|
||||
color: #304038;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item:hover .nav-icon {
|
||||
color: #304038;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -0.85rem;
|
||||
top: 0.45rem;
|
||||
bottom: 0.45rem;
|
||||
width: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--panel);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 1.5rem 1.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-intro p:last-child,
|
||||
.details-list span {
|
||||
.panel-header p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
/* ── Form ───────────────────────────────────────────────────── */
|
||||
|
||||
.panel-form {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
padding: 1.5rem 1.75rem;
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.surface-card {
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.2rem;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 0.62rem 0.85rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.surface-card h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.details-list {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.details-list div {
|
||||
padding: 0.9rem 0.95rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.95rem;
|
||||
border-radius: 0.6rem;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.details-list span {
|
||||
display: block;
|
||||
margin-bottom: 0.28rem;
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--green-deep);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 0;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: color-mix(in srgb, #e53e3e 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #e53e3e 25%, transparent);
|
||||
border-radius: 0.6rem;
|
||||
color: #c53030;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.details-list strong {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
.form-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--line);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.settings-grid {
|
||||
.btn-primary {
|
||||
padding: 0.58rem 1.4rem;
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.settings-layout {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: auto;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
position: static;
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.settings-nav ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: auto;
|
||||
padding-right: 0.9rem;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Reference in New Issue
Block a user