This commit is contained in:
2026-05-08 09:06:14 +12:00
parent 1533b5aa9b
commit 9afc3170ff
22 changed files with 2710 additions and 549 deletions
+26
View File
@@ -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 />
+51 -35
View File
@@ -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;
}
+3 -3
View File
@@ -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 {
+22 -20
View File
@@ -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;
}
+4 -4
View File
@@ -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
+650
View File
@@ -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>
+459 -89
View File
@@ -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>&copy; {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;
}
}