v1.4 - Login fixes, etc

This commit is contained in:
2026-04-27 21:53:36 +12:00
parent 8cf9bfb441
commit c9580ac2eb
33 changed files with 2283 additions and 202 deletions
+157 -14
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { api } from '$lib/api';
import { clientSession } from '$lib/session';
import { clientSession, sessionHydrated } from '$lib/session';
import type { Mix, ProductCostBreakdown, RawMaterial } from '$lib/types';
type Segment = {
@@ -42,7 +41,6 @@
try {
const session = await api.clientLogin(email, password);
clientSession.set(session);
await invalidateAll();
} catch (error) {
loginError = error instanceof Error ? error.message : 'Unable to sign in';
} finally {
@@ -253,7 +251,23 @@
const focusCards = $derived(buildFocusCards());
</script>
{#if !$clientSession}
{#if !$sessionHydrated}
<section class="dashboard-intro">
<div>
<p class="eyebrow">Client Workspace</p>
<h2>Restoring your workspace.</h2>
<p>Checking the saved client session before deciding whether sign-in is required.</p>
</div>
</section>
<section class="workspace-banner login-banner loading-banner">
<div>
<p class="eyebrow">Checking Session</p>
<h3>Hold while the app restores your client access state.</h3>
<p>The sign-in form only appears if no valid local session is available.</p>
</div>
</section>
{:else if !$clientSession}
<section class="dashboard-intro">
<div>
<p class="eyebrow">Client Workspace</p>
@@ -515,7 +529,7 @@
<tbody>
{#each focusCards as card}
<tr>
<td class="task-cell">
<td class="task-cell" data-label="Focus">
<div class="table-item">
<span class={`task-icon ${card.tone}`}>{card.code}</span>
<div>
@@ -524,19 +538,21 @@
</div>
</div>
</td>
<td>
<td data-label="Owner">
<div class="owner-chip">
<span>HP</span>
<strong>Hunter Premium Produce</strong>
</div>
</td>
<td>
<td data-label="Reference Date">
<div class="due-block">
<strong>{card.detail}</strong>
<span>Current checkpoint</span>
</div>
</td>
<td><span class={`status-chip ${card.tone}`}>{card.tone === 'warning' ? 'Watch' : 'On track'}</span></td>
<td data-label="Status">
<span class={`status-chip ${card.tone}`}>{card.tone === 'warning' ? 'Watch' : 'On track'}</span>
</td>
</tr>
{/each}
</tbody>
@@ -681,6 +697,10 @@
align-items: center;
}
.loading-banner {
min-height: 11rem;
}
.signin-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -759,8 +779,10 @@
.dashboard-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.15fr) 0.72fr;
grid-template-columns: minmax(0, 1.12fr) minmax(0, 1.02fr) minmax(16rem, 0.76fr);
grid-template-areas: 'market gauge metrics';
gap: 1rem;
align-items: stretch;
}
.analysis-grid {
@@ -775,6 +797,14 @@
gap: 1rem;
}
.market-card {
grid-area: market;
}
.gauge-card {
grid-area: gauge;
}
.card-toolbar {
align-items: flex-start;
margin-bottom: 1rem;
@@ -948,6 +978,7 @@
}
.metric-stack {
grid-area: metrics;
display: grid;
gap: 1rem;
}
@@ -1130,6 +1161,7 @@
table {
width: 100%;
min-width: 46rem;
border-collapse: separate;
border-spacing: 0 0.7rem;
}
@@ -1293,11 +1325,29 @@
}
@media (max-width: 1320px) {
.workspace-banner,
.dashboard-grid,
.analysis-grid,
.detail-grid,
.workspace-banner {
align-items: stretch;
}
.focus-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dashboard-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas:
'market gauge'
'metrics metrics';
}
.metric-stack {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1120px) {
.analysis-grid,
.detail-grid {
grid-template-columns: 1fr;
}
@@ -1306,6 +1356,24 @@
}
}
@media (max-width: 900px) {
.dashboard-grid {
grid-template-columns: 1fr;
grid-template-areas:
'market'
'gauge'
'metrics';
}
.metric-stack {
grid-template-columns: 1fr;
}
.focus-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 860px) {
.dashboard-intro,
.intro-actions,
@@ -1320,7 +1388,6 @@
}
.preview-facts,
.metric-stack,
.signin-form {
grid-template-columns: 1fr;
}
@@ -1339,4 +1406,80 @@
row-gap: 0.5rem;
}
}
@media (max-width: 760px) {
.workspace-banner,
.panel-card,
.preview-body {
padding: 1rem;
}
table,
thead,
tbody,
tr,
td {
display: block;
width: 100%;
}
table {
min-width: 0;
border-spacing: 0;
}
thead {
display: none;
}
tbody {
display: grid;
gap: 0.9rem;
}
tbody tr {
padding: 0.3rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
tbody td {
padding: 0.78rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
tbody td:first-child,
tbody td:last-child {
border: none;
border-radius: 0;
}
tbody td + td {
border-top: 1px solid var(--line);
}
tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.task-cell {
min-width: 0;
}
.table-item,
.owner-chip {
align-items: flex-start;
}
}
</style>
+6 -6
View File
@@ -1,7 +1,7 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return {
rawMaterials: [],
@@ -14,11 +14,11 @@ export async function load() {
try {
const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([
api.rawMaterials(),
api.mixes(),
api.productCosts(),
api.scenarios(),
api.dataQuality()
api.rawMaterials(fetch),
api.mixes(fetch),
api.productCosts(fetch),
api.scenarios(fetch),
api.dataQuality(fetch)
]);
return {
+15 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { api } from '$lib/api';
import { adminSession } from '$lib/session';
import { adminSession, sessionHydrated } from '$lib/session';
let { data } = $props();
@@ -65,7 +65,15 @@
</div>
</section>
{#if !$adminSession}
{#if !$sessionHydrated}
<section class="signin-card loading-card">
<div class="signin-copy">
<p class="eyebrow">Checking Session</p>
<h3>Restoring the Lean 101 admin session before deciding whether sign-in is needed.</h3>
<p>The admin sign-in form only appears when no saved operator session is available.</p>
</div>
</section>
{:else if !$adminSession}
<section class="signin-card">
<div class="signin-copy">
<p class="eyebrow">Admin Sign-In</p>
@@ -253,6 +261,11 @@
align-items: center;
}
.loading-card {
grid-template-columns: 1fr;
min-height: 10rem;
}
.signin-copy h3,
.live-banner h3,
.card-toolbar h3 {
+2 -2
View File
@@ -1,7 +1,7 @@
import { hasStoredAdminSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
export async function load({ fetch }) {
if (!hasStoredAdminSession()) {
return {
clients: [],
@@ -16,7 +16,7 @@ export async function load() {
}
try {
const [clients, exportPreview] = await Promise.all([api.clientAccess(), api.clientAccessExport()]);
const [clients, exportPreview] = await Promise.all([api.clientAccess(fetch), api.clientAccessExport(fetch)]);
return {
clients,
@@ -1,7 +1,7 @@
import { hasStoredAdminSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
export async function load({ fetch }) {
if (!hasStoredAdminSession()) {
return {
clients: [],
@@ -16,7 +16,7 @@ export async function load() {
}
try {
const [clients, exportPreview] = await Promise.all([api.clientAccess(), api.clientAccessExport()]);
const [clients, exportPreview] = await Promise.all([api.clientAccess(fetch), api.clientAccessExport(fetch)]);
return {
clients,
+111
View File
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const apiMocks = vi.hoisted(() => ({
rawMaterials: vi.fn(),
mixes: vi.fn(),
mix: vi.fn(),
products: vi.fn(),
productCosts: vi.fn(),
scenarios: vi.fn(),
dataQuality: vi.fn(),
clientAccess: vi.fn(),
clientAccessExport: vi.fn()
}));
const sessionMocks = vi.hoisted(() => ({
hasStoredClientSession: vi.fn(),
hasStoredAdminSession: vi.fn()
}));
vi.mock('$lib/api', () => ({
api: apiMocks
}));
vi.mock('$lib/session', () => sessionMocks);
import { load as homeLoad } from './+page';
import { load as adminLoad } from './admin/+page';
import { load as mixesLoad } from './mixes/+page';
import { load as mixNewLoad } from './mixes/new/+page';
import { load as mixDetailLoad } from './mixes/[id]/+page';
import { load as productsLoad } from './products/+page';
import { load as rawMaterialsLoad } from './raw-materials/+page';
import { load as scenariosLoad } from './scenarios/+page';
describe('route loaders use the SvelteKit fetch argument', () => {
const fetcher = vi.fn() as typeof fetch;
beforeEach(() => {
vi.clearAllMocks();
sessionMocks.hasStoredClientSession.mockReturnValue(true);
sessionMocks.hasStoredAdminSession.mockReturnValue(true);
apiMocks.rawMaterials.mockResolvedValue([{ id: 1 }]);
apiMocks.mixes.mockResolvedValue([{ id: 2 }]);
apiMocks.mix.mockResolvedValue({ id: 42 });
apiMocks.products.mockResolvedValue([{ id: 3 }]);
apiMocks.productCosts.mockResolvedValue([{ id: 4 }]);
apiMocks.scenarios.mockResolvedValue([{ id: 5 }]);
apiMocks.dataQuality.mockResolvedValue([{ id: 6 }]);
apiMocks.clientAccess.mockResolvedValue([{ id: 7 }]);
apiMocks.clientAccessExport.mockResolvedValue({ generated_at: '', clients: [] });
});
it('passes fetch through the home page loader', async () => {
await homeLoad({ fetch: fetcher } as never);
expect(apiMocks.rawMaterials).toHaveBeenCalledWith(fetcher);
expect(apiMocks.mixes).toHaveBeenCalledWith(fetcher);
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
expect(apiMocks.scenarios).toHaveBeenCalledWith(fetcher);
expect(apiMocks.dataQuality).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the raw materials loader', async () => {
await rawMaterialsLoad({ fetch: fetcher } as never);
expect(apiMocks.rawMaterials).toHaveBeenCalledWith(fetcher);
expect(apiMocks.mixes).toHaveBeenCalledWith(fetcher);
expect(apiMocks.products).toHaveBeenCalledWith(fetcher);
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the mixes loader', async () => {
await mixesLoad({ fetch: fetcher } as never);
expect(apiMocks.mixes).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the new mix loader', async () => {
await mixNewLoad({ fetch: fetcher } as never);
expect(apiMocks.rawMaterials).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the mix detail loader', async () => {
await mixDetailLoad({ params: { id: '42' }, fetch: fetcher } as never);
expect(apiMocks.mix).toHaveBeenCalledWith(42, fetcher);
expect(apiMocks.rawMaterials).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the products loader', async () => {
await productsLoad({ fetch: fetcher } as never);
expect(apiMocks.products).toHaveBeenCalledWith(fetcher);
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the scenarios loader', async () => {
await scenariosLoad({ fetch: fetcher } as never);
expect(apiMocks.scenarios).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the admin loader', async () => {
await adminLoad({ fetch: fetcher } as never);
expect(apiMocks.clientAccess).toHaveBeenCalledWith(fetcher);
expect(apiMocks.clientAccessExport).toHaveBeenCalledWith(fetcher);
});
});
+188 -21
View File
@@ -1,7 +1,12 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
let { data } = $props();
let activeMenuId = $state<number | null>(null);
let activeMenuTrigger = $state<HTMLButtonElement | null>(null);
let menuElement = $state<HTMLDivElement | null>(null);
let menuStyle = $state('');
function currency(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
@@ -17,6 +22,86 @@
? data.mixes.reduce((sum, mix) => sum + (mix.mix_cost_per_kg ?? 0), 0) / data.mixes.length
: 0
);
const activeMix = $derived(data.mixes.find((mix) => mix.id === activeMenuId) ?? null);
function closeMenu() {
activeMenuId = null;
activeMenuTrigger = null;
menuStyle = '';
}
function positionMenu() {
if (!activeMenuTrigger || !menuElement) {
return;
}
const triggerRect = activeMenuTrigger.getBoundingClientRect();
const menuRect = menuElement.getBoundingClientRect();
const viewportPadding = 12;
const menuGap = 8;
let top = triggerRect.top - menuRect.height - menuGap;
if (top < viewportPadding) {
top = Math.min(window.innerHeight - viewportPadding - menuRect.height, triggerRect.bottom + menuGap);
}
let left = triggerRect.right - menuRect.width;
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - viewportPadding - menuRect.width));
menuStyle = `top: ${Math.max(viewportPadding, top)}px; left: ${left}px;`;
}
async function toggleMenu(id: number, event: MouseEvent) {
if (activeMenuId === id) {
closeMenu();
return;
}
activeMenuId = id;
activeMenuTrigger = event.currentTarget instanceof HTMLButtonElement ? event.currentTarget : null;
await tick();
positionMenu();
}
function handlePointerDown(event: MouseEvent) {
if (activeMenuId === null || !(event.target instanceof Node)) {
return;
}
if (menuElement?.contains(event.target) || activeMenuTrigger?.contains(event.target)) {
return;
}
closeMenu();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeMenu();
}
}
function handleViewportChange() {
if (activeMenuId !== null) {
positionMenu();
}
}
onMount(() => {
window.addEventListener('scroll', handleViewportChange, true);
window.addEventListener('resize', handleViewportChange);
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleKeydown);
return () => {
window.removeEventListener('scroll', handleViewportChange, true);
window.removeEventListener('resize', handleViewportChange);
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleKeydown);
};
});
</script>
<section class="page-intro">
@@ -77,7 +162,7 @@
<tbody>
{#each data.mixes as mix}
<tr>
<td>
<td data-label="Mix">
<div class="table-item">
<span class="row-badge">MX</span>
<div>
@@ -86,26 +171,25 @@
</div>
</div>
</td>
<td>{mix.client_name}</td>
<td>{mix.ingredients.length}</td>
<td>{mix.total_mix_kg}</td>
<td>{currency(mix.total_mix_cost)}</td>
<td>{currency(mix.mix_cost_per_kg, 4)}</td>
<td>
<td data-label="Client">{mix.client_name}</td>
<td data-label="Ingredients">{mix.ingredients.length}</td>
<td data-label="Total Kg">{mix.total_mix_kg}</td>
<td data-label="Total Cost">{currency(mix.total_mix_cost)}</td>
<td data-label="Cost / Kg">{currency(mix.mix_cost_per_kg, 4)}</td>
<td data-label="Status">
<span class={`status-pill ${mix.warnings.length ? 'warning' : 'positive'}`}>{mix.status}</span>
</td>
<td class="menu-cell">
<td class="menu-cell" data-label="Actions">
<div class="menu-wrap">
<button class="menu-trigger" type="button" onclick={() => (activeMenuId = activeMenuId === mix.id ? null : mix.id)}>
<button
aria-expanded={activeMenuId === mix.id}
aria-haspopup="menu"
class="menu-trigger"
type="button"
onclick={(event) => toggleMenu(mix.id, event)}
>
Actions
</button>
{#if activeMenuId === mix.id}
<div class="menu-panel">
<a href={`/mixes/${mix.id}`}>Edit worksheet</a>
<a href={`/mixes/${mix.id}`}>Open live cost view</a>
</div>
{/if}
</div>
</td>
</tr>
@@ -113,6 +197,13 @@
</tbody>
</table>
</div>
{#if activeMix}
<div bind:this={menuElement} class="menu-panel" role="menu" style={menuStyle}>
<a href={`/mixes/${activeMix.id}`} onclick={closeMenu}>Edit worksheet</a>
<a href={`/mixes/${activeMix.id}`} onclick={closeMenu}>Open live cost view</a>
</div>
{/if}
</section>
<style>
@@ -235,6 +326,7 @@
table {
width: 100%;
min-width: 48rem;
border-collapse: separate;
border-spacing: 0 0.54rem;
}
@@ -328,10 +420,14 @@
}
.menu-wrap {
position: relative;
display: flex;
justify-content: flex-end;
}
.menu-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line-strong);
border-radius: 0.76rem;
padding: 0.6rem 0.74rem;
@@ -342,11 +438,10 @@
}
.menu-panel {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
z-index: 10;
position: fixed;
z-index: 40;
min-width: 10rem;
width: min(14rem, calc(100vw - 1.5rem));
display: grid;
gap: 0.18rem;
padding: 0.32rem;
@@ -377,5 +472,77 @@
flex-direction: column;
align-items: flex-start;
}
.table-card {
padding: 1rem;
}
table,
thead,
tbody,
tr,
td {
display: block;
width: 100%;
}
table {
min-width: 0;
border-spacing: 0;
}
thead {
display: none;
}
tbody {
display: grid;
gap: 0.9rem;
}
tbody tr {
padding: 0.3rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
tbody td {
padding: 0.78rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
tbody td:first-child,
tbody td:last-child {
border: none;
border-radius: 0;
}
tbody td + td {
border-top: 1px solid var(--line);
}
tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.menu-wrap,
.menu-trigger {
width: 100%;
}
.menu-trigger {
justify-content: space-between;
}
}
</style>
+2 -2
View File
@@ -1,7 +1,7 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return {
mixes: []
@@ -10,7 +10,7 @@ export async function load() {
try {
return {
mixes: await api.mixes()
mixes: await api.mixes(fetch)
};
} catch {
return {
+2 -2
View File
@@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit';
import { api } from '$lib/api';
import { hasStoredClientSession } from '$lib/session';
export async function load({ params }) {
export async function load({ params, fetch }) {
const mixId = Number(params.id);
if (!Number.isFinite(mixId)) {
@@ -17,7 +17,7 @@ export async function load({ params }) {
}
try {
const [mix, rawMaterials] = await Promise.all([api.mix(mixId), api.rawMaterials()]);
const [mix, rawMaterials] = await Promise.all([api.mix(mixId, fetch), api.rawMaterials(fetch)]);
return {
mix,
+2 -2
View File
@@ -1,7 +1,7 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return {
rawMaterials: []
@@ -10,7 +10,7 @@ export async function load() {
try {
return {
rawMaterials: await api.rawMaterials()
rawMaterials: await api.rawMaterials(fetch)
};
} catch {
return {
+77 -7
View File
@@ -49,7 +49,7 @@
<section class="page-intro">
<div>
<p class="eyebrow">Output Pricing</p>
<h2>Products1</h2>
<h2>Delivered product pricing</h2>
<p>Each row carries the product, mix source, price outputs, and a quick health state in one compact layout.</p>
</div>
</section>
@@ -99,7 +99,7 @@
<tbody>
{#each rows as row}
<tr>
<td class="product-cell">
<td class="product-cell" data-label="Product">
<div class="product-item">
<span class="product-badge">{initials(row.name)}</span>
<div>
@@ -108,28 +108,28 @@
</div>
</div>
</td>
<td>
<td data-label="Mix">
<div class="mix-block">
<strong>{row.mix_name}</strong>
<span>{row.unit_of_measure}</span>
</div>
</td>
<td>
<td data-label="Sale Type">
<span class="sale-pill">{row.sale_type}</span>
</td>
<td>
<td data-label="Delivered">
<div class="number-block">
<strong>{currency(row.cost?.finished_product_delivered)}</strong>
<span>Delivered cost</span>
</div>
</td>
<td>
<td data-label="Margins">
<div class="number-block">
<strong>{currency(row.cost?.distributor_price)} / {currency(row.cost?.wholesale_price)}</strong>
<span>Distributor / wholesale</span>
</div>
</td>
<td>
<td data-label="Health">
<span class={`status-pill ${row.healthTone}`}>{row.health}</span>
</td>
</tr>
@@ -240,6 +240,7 @@
table {
width: 100%;
min-width: 48rem;
border-collapse: separate;
border-spacing: 0 0.75rem;
}
@@ -359,4 +360,73 @@
align-items: flex-start;
}
}
@media (max-width: 760px) {
.table-card {
padding: 1rem;
}
table,
thead,
tbody,
tr,
td {
display: block;
width: 100%;
}
table {
min-width: 0;
border-spacing: 0;
}
thead {
display: none;
}
tbody {
display: grid;
gap: 0.9rem;
}
tbody tr {
padding: 0.3rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
tbody td {
padding: 0.78rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
tbody td:first-child,
tbody td:last-child {
border: none;
border-radius: 0;
}
tbody td + td {
border-top: 1px solid var(--line);
}
tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.product-cell {
min-width: 0;
}
}
</style>
+2 -2
View File
@@ -1,7 +1,7 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return {
products: [],
@@ -10,7 +10,7 @@ export async function load() {
}
try {
const [products, productCosts] = await Promise.all([api.products(), api.productCosts()]);
const [products, productCosts] = await Promise.all([api.products(fetch), api.productCosts(fetch)]);
return {
products,
productCosts
+5 -5
View File
@@ -1,7 +1,7 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return {
rawMaterials: [],
@@ -13,10 +13,10 @@ export async function load() {
try {
const [rawMaterials, mixes, products, productCosts] = await Promise.all([
api.rawMaterials(),
api.mixes(),
api.products(),
api.productCosts()
api.rawMaterials(fetch),
api.mixes(fetch),
api.products(fetch),
api.productCosts(fetch)
]);
return {
+2 -2
View File
@@ -1,7 +1,7 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return {
scenarios: []
@@ -10,7 +10,7 @@ export async function load() {
try {
return {
scenarios: await api.scenarios()
scenarios: await api.scenarios(fetch)
};
} catch {
return {
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
import { clientSession } from '$lib/session';
const currentYear = new Date().getFullYear();
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Workspace Settings</p>
<h2>Account and workspace preferences.</h2>
<p>Review your current session, navigation setup, and the client workspace details shown across the app.</p>
</div>
</section>
<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>
<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>
<style>
h2,
h3,
p {
margin: 0;
}
.eyebrow {
color: #7f8e85;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-intro,
.settings-grid {
margin-bottom: 1.2rem;
}
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.details-list span {
color: var(--muted);
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.surface-card {
display: grid;
gap: 1rem;
padding: 1.2rem;
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;
background: var(--panel-soft);
}
.details-list span {
display: block;
margin-bottom: 0.28rem;
font-size: 0.84rem;
}
.details-list strong {
font-size: 0.98rem;
font-weight: 700;
}
@media (max-width: 900px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
</style>