v1.4 - Login fixes, etc
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { api } from './api';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function jsonResponse(body: JsonRecord[] | JsonRecord) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('api fetch injection', () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'raw materials',
|
||||
call: (fetcher: typeof fetch) => api.rawMaterials(fetcher),
|
||||
path: '/api/raw-materials',
|
||||
body: [{ id: 1, name: 'Lime' }]
|
||||
},
|
||||
{
|
||||
label: 'mixes',
|
||||
call: (fetcher: typeof fetch) => api.mixes(fetcher),
|
||||
path: '/api/mixes',
|
||||
body: [{ id: 12, name: 'Orchard Blend' }]
|
||||
},
|
||||
{
|
||||
label: 'products',
|
||||
call: (fetcher: typeof fetch) => api.products(fetcher),
|
||||
path: '/api/products',
|
||||
body: [{ id: 8, name: 'Packhouse Bag' }]
|
||||
},
|
||||
{
|
||||
label: 'product costs',
|
||||
call: (fetcher: typeof fetch) => api.productCosts(fetcher),
|
||||
path: '/api/powerbi/product-costs',
|
||||
body: [{ product_id: 8, finished_product_delivered: 14.2 }]
|
||||
}
|
||||
])('uses the injected fetch for $label reads', async ({ call, path, body }) => {
|
||||
const globalFetch = vi.fn(() => {
|
||||
throw new Error('global fetch should not be used');
|
||||
}) as typeof fetch;
|
||||
const injectedFetch = vi.fn(async () => jsonResponse(body)) as typeof fetch;
|
||||
|
||||
globalThis.fetch = globalFetch;
|
||||
|
||||
await expect(call(injectedFetch)).resolves.toEqual(body);
|
||||
expect(injectedFetch).toHaveBeenCalledTimes(1);
|
||||
expect(injectedFetch.mock.calls[0]?.[0]).toBe(`http://127.0.0.1:8000${path}`);
|
||||
expect(globalFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+39
-14
@@ -28,9 +28,27 @@ import type {
|
||||
} from '$lib/types';
|
||||
import { getStoredAdminSession, getStoredClientSession } from '$lib/session';
|
||||
|
||||
const API_BASE_URL = env.PUBLIC_API_BASE_URL || 'http://localhost:8000';
|
||||
const DEFAULT_API_PORT = env.PUBLIC_API_PORT || '8000';
|
||||
|
||||
type AuthMode = 'none' | 'client' | 'admin';
|
||||
type ApiFetch = typeof fetch;
|
||||
|
||||
function getApiBaseUrl() {
|
||||
const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim();
|
||||
if (configuredBaseUrl) {
|
||||
return configuredBaseUrl.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_API_PORT}`;
|
||||
}
|
||||
|
||||
return `http://127.0.0.1:${DEFAULT_API_PORT}`;
|
||||
}
|
||||
|
||||
function buildApiUrl(path: string) {
|
||||
return `${getApiBaseUrl()}${path}`;
|
||||
}
|
||||
|
||||
function getToken(auth: AuthMode) {
|
||||
if (!browser) {
|
||||
@@ -48,10 +66,10 @@ function getToken(auth: AuthMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none'): Promise<T> {
|
||||
async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
|
||||
try {
|
||||
const token = getToken(auth);
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
const response = await fetcher(buildApiUrl(path), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -69,9 +87,14 @@ async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none'):
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit, auth: AuthMode = 'none'): Promise<T> {
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit,
|
||||
auth: AuthMode = 'none',
|
||||
fetcher: ApiFetch = fetch
|
||||
): Promise<T> {
|
||||
const token = getToken(auth);
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
const response = await fetcher(buildApiUrl(path), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
@@ -97,15 +120,17 @@ async function request<T>(path: string, options: RequestInit, auth: AuthMode = '
|
||||
}
|
||||
|
||||
export const api = {
|
||||
rawMaterials: () => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client'),
|
||||
mixes: () => fetchJson('/api/mixes', mockMixes, 'client'),
|
||||
mix: (mixId: number) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client'),
|
||||
products: () => fetchJson<Product[]>('/api/products', mockProducts, 'client'),
|
||||
productCosts: () => fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client'),
|
||||
scenarios: () => fetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client'),
|
||||
clientAccess: () => fetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'admin'),
|
||||
clientAccessExport: () => fetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'admin'),
|
||||
dataQuality: () => fetchJson('/api/powerbi/data-quality-issues', [], 'client'),
|
||||
rawMaterials: (fetcher?: ApiFetch) => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
|
||||
mixes: (fetcher?: ApiFetch) => fetchJson('/api/mixes', mockMixes, 'client', fetcher),
|
||||
mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
|
||||
products: (fetcher?: ApiFetch) => fetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
|
||||
productCosts: (fetcher?: ApiFetch) =>
|
||||
fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
|
||||
scenarios: (fetcher?: ApiFetch) => fetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
|
||||
clientAccess: (fetcher?: ApiFetch) => fetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'admin', fetcher),
|
||||
clientAccessExport: (fetcher?: ApiFetch) =>
|
||||
fetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'admin', fetcher),
|
||||
dataQuality: (fetcher?: ApiFetch) => fetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher),
|
||||
clientLogin: (email: string, password: string) =>
|
||||
request<LoginResponse>('/api/auth/client/login', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { adminSession } from '$lib/session';
|
||||
import { adminSession, sessionHydrated } from '$lib/session';
|
||||
|
||||
const navigation = [
|
||||
{ href: '/admin', label: 'Overview', shortLabel: 'OV' },
|
||||
@@ -8,6 +9,8 @@
|
||||
];
|
||||
|
||||
let { children } = $props();
|
||||
let isRestoringSession = $state(false);
|
||||
let restoredToken = $state<string | null>(null);
|
||||
|
||||
function matchesRoute(href: string, pathname: string) {
|
||||
return href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
||||
@@ -27,6 +30,34 @@
|
||||
}
|
||||
|
||||
const isProtectedRoute = $derived(page.url.pathname !== '/admin');
|
||||
|
||||
$effect(() => {
|
||||
const hydrated = $sessionHydrated;
|
||||
const token = $adminSession?.token ?? null;
|
||||
|
||||
if (!hydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
isRestoringSession = false;
|
||||
restoredToken = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoredToken === token) {
|
||||
return;
|
||||
}
|
||||
|
||||
restoredToken = token;
|
||||
isRestoringSession = true;
|
||||
|
||||
invalidateAll().finally(() => {
|
||||
if (restoredToken === token) {
|
||||
isRestoringSession = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -68,7 +99,15 @@
|
||||
<h1>{pageTitle(page.url.pathname)}</h1>
|
||||
</div>
|
||||
|
||||
{#if $adminSession}
|
||||
{#if !$sessionHydrated}
|
||||
<div class="profile-card guest">
|
||||
<span class="profile-avatar">A</span>
|
||||
<div>
|
||||
<strong>Checking saved session</strong>
|
||||
<span>Restoring admin access</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if $adminSession}
|
||||
<div class="profile-card">
|
||||
<span class="profile-avatar">{initials($adminSession.name)}</span>
|
||||
<div>
|
||||
@@ -88,7 +127,13 @@
|
||||
</header>
|
||||
|
||||
<main class="admin-content">
|
||||
{#if isProtectedRoute && !$adminSession}
|
||||
{#if isProtectedRoute && (!$sessionHydrated || isRestoringSession)}
|
||||
<section class="locked-card loading-card">
|
||||
<p class="eyebrow">Checking Session</p>
|
||||
<h2>Restoring the Lean 101 admin workspace.</h2>
|
||||
<p>Refreshing the current route with the saved operator session before prompting for sign-in.</p>
|
||||
</section>
|
||||
{:else if isProtectedRoute && !$adminSession}
|
||||
<section class="locked-card">
|
||||
<p class="eyebrow">Restricted</p>
|
||||
<h2>Sign in through the Lean 101 Admin Panel to continue.</h2>
|
||||
@@ -284,6 +329,10 @@
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.08);
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.locked-card h2,
|
||||
.locked-card p {
|
||||
margin: 0;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { clientSession } from '$lib/session';
|
||||
import { clientSession, sessionHydrated } from '$lib/session';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import packageInfo from '../../../package.json';
|
||||
|
||||
type SearchItem = {
|
||||
href: string;
|
||||
@@ -23,6 +25,7 @@
|
||||
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' },
|
||||
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV' }
|
||||
];
|
||||
const primaryBottomNavigation = navigation.slice(0, 4);
|
||||
|
||||
const searchItems: SearchItem[] = [
|
||||
{
|
||||
@@ -55,6 +58,12 @@
|
||||
description: 'Review delivered product pricing and margins.',
|
||||
keywords: 'products pricing margins delivered outputs'
|
||||
},
|
||||
{
|
||||
href: '/settings',
|
||||
label: 'Open Workspace Settings',
|
||||
description: 'Review account details and workspace preferences.',
|
||||
keywords: 'settings account preferences profile workspace'
|
||||
},
|
||||
{
|
||||
href: '/scenarios',
|
||||
label: 'Open Scenarios',
|
||||
@@ -69,7 +78,14 @@
|
||||
let paletteOpen = $state(false);
|
||||
let paletteQuery = $state('');
|
||||
let quickMenuOpen = $state(false);
|
||||
let userMenuOpen = $state(false);
|
||||
let navOpen = $state(false);
|
||||
let showBottomNav = $state(false);
|
||||
let isRestoringSession = $state(false);
|
||||
let restoredToken = $state<string | null>(null);
|
||||
let paletteInput: HTMLInputElement | null = $state(null);
|
||||
const appVersion = `v${packageInfo.version}`;
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
function matchesRoute(href: string, pathname: string) {
|
||||
return href === '/' ? pathname === '/' : pathname.startsWith(href);
|
||||
@@ -86,6 +102,7 @@
|
||||
'/mixes': 'Browse saved mix worksheets and costing outputs',
|
||||
'/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce',
|
||||
'/products': 'Track delivered product pricing and margin views',
|
||||
'/settings': 'Review your workspace profile and application settings',
|
||||
'/scenarios': 'Compare alternate pricing and production assumptions'
|
||||
};
|
||||
|
||||
@@ -96,6 +113,16 @@
|
||||
paletteQuery = query;
|
||||
paletteOpen = true;
|
||||
quickMenuOpen = false;
|
||||
userMenuOpen = false;
|
||||
navOpen = false;
|
||||
}
|
||||
|
||||
function syncViewport() {
|
||||
showBottomNav = window.innerWidth <= 1180;
|
||||
|
||||
if (!showBottomNav) {
|
||||
navOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearchItem(item: SearchItem) {
|
||||
@@ -104,6 +131,13 @@
|
||||
await goto(item.href);
|
||||
}
|
||||
|
||||
async function openSettings() {
|
||||
quickMenuOpen = false;
|
||||
userMenuOpen = false;
|
||||
navOpen = false;
|
||||
await goto('/settings');
|
||||
}
|
||||
|
||||
const filteredSearchItems = $derived(
|
||||
searchItems.filter((item) => {
|
||||
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
|
||||
@@ -114,8 +148,10 @@
|
||||
$effect(() => {
|
||||
page.url.pathname;
|
||||
quickMenuOpen = false;
|
||||
userMenuOpen = false;
|
||||
paletteOpen = false;
|
||||
paletteQuery = '';
|
||||
navOpen = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -124,7 +160,37 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const hydrated = $sessionHydrated;
|
||||
const token = $clientSession?.token ?? null;
|
||||
|
||||
if (!hydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
isRestoringSession = false;
|
||||
restoredToken = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoredToken === token) {
|
||||
return;
|
||||
}
|
||||
|
||||
restoredToken = token;
|
||||
isRestoringSession = true;
|
||||
|
||||
invalidateAll().finally(() => {
|
||||
if (restoredToken === token) {
|
||||
isRestoringSession = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
syncViewport();
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const isTypingField =
|
||||
@@ -141,11 +207,18 @@
|
||||
if (event.key === 'Escape') {
|
||||
paletteOpen = false;
|
||||
quickMenuOpen = false;
|
||||
userMenuOpen = false;
|
||||
navOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
return () => window.removeEventListener('keydown', handleKeydown);
|
||||
window.addEventListener('resize', syncViewport);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
window.removeEventListener('resize', syncViewport);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -154,65 +227,79 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand-row">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark">HP</span>
|
||||
<span>Hunter Premium Produce</span>
|
||||
</a>
|
||||
{#if showBottomNav && navOpen}
|
||||
<button aria-label="Close navigation" class="nav-backdrop" type="button" onclick={() => (navOpen = false)}></button>
|
||||
{/if}
|
||||
|
||||
<button class="nav-toggle" type="button" aria-label="Navigation options">
|
||||
<span></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="search-box" type="button" aria-label="Search the workspace" onclick={() => openPalette()}>
|
||||
<span class="search-icon"></span>
|
||||
<span class="search-placeholder">Search the workspace...</span>
|
||||
<kbd>/</kbd>
|
||||
</button>
|
||||
|
||||
<nav class="nav-list" aria-label="Client navigation">
|
||||
{#each navigation as item}
|
||||
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
|
||||
<span class="nav-icon">{item.shortLabel}</span>
|
||||
<span>{item.label}</span>
|
||||
{#if !showBottomNav}
|
||||
<aside class="sidebar">
|
||||
<div class="brand-row">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark">HP</span>
|
||||
<span>Hunter Premium Produce</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
{#each footerLinks as item}
|
||||
<a href={item.href}>
|
||||
<span class="nav-icon muted">{item.shortLabel}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
<div class="sidebar-body">
|
||||
<button class="search-box" type="button" aria-label="Search the workspace" onclick={() => openPalette()}>
|
||||
<span class="search-icon"></span>
|
||||
<span class="search-placeholder">Search the workspace...</span>
|
||||
<kbd>/</kbd>
|
||||
</button>
|
||||
|
||||
<div class="main-shell">
|
||||
<nav class="nav-list" aria-label="Client navigation">
|
||||
{#each navigation as item}
|
||||
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
|
||||
<span class="nav-icon">{item.shortLabel}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
{#each footerLinks as item}
|
||||
<a href={item.href}>
|
||||
<span class="nav-icon muted">{item.shortLabel}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="sidebar-meta">
|
||||
<span>{appVersion}</span>
|
||||
<small>© {currentYear} Hunter Premium Produce</small>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
|
||||
<header class="topbar">
|
||||
<div class="topbar-copy">
|
||||
<h1>{pageTitle(page.url.pathname)}</h1>
|
||||
<p>{pageDescription(page.url.pathname)}</p>
|
||||
<div class="topbar-start">
|
||||
<div class="topbar-copy">
|
||||
<h1>{pageTitle(page.url.pathname)}</h1>
|
||||
<p>{pageDescription(page.url.pathname)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-middle">
|
||||
<button class="search-box topbar-search" type="button" aria-label="Search the workspace" onclick={() => openPalette()}>
|
||||
<span class="search-icon"></span>
|
||||
<span class="search-placeholder">Search pages, mixes, products, and settings...</span>
|
||||
<kbd>/</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="topbar-actions">
|
||||
{#if $clientSession}
|
||||
<button class="workspace-chip session-chip" type="button" onclick={() => clientSession.clear()}>
|
||||
<span class="workspace-label">Signed in</span>
|
||||
<strong>{$clientSession.email}</strong>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="workspace-chip">
|
||||
<span class="workspace-label">Client</span>
|
||||
<strong>Sign in required</strong>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="menu-wrap">
|
||||
<button class="action-button" type="button" onclick={() => (quickMenuOpen = !quickMenuOpen)}>
|
||||
<button
|
||||
class="action-button"
|
||||
type="button"
|
||||
onclick={() => {
|
||||
quickMenuOpen = !quickMenuOpen;
|
||||
userMenuOpen = false;
|
||||
}}
|
||||
>
|
||||
Quick Actions
|
||||
<span class:open={quickMenuOpen} class="chevron"></span>
|
||||
</button>
|
||||
@@ -226,11 +313,65 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="menu-wrap user-menu-wrap">
|
||||
<button
|
||||
aria-expanded={userMenuOpen}
|
||||
class="user-trigger"
|
||||
type="button"
|
||||
onclick={() => {
|
||||
userMenuOpen = !userMenuOpen;
|
||||
quickMenuOpen = false;
|
||||
}}
|
||||
>
|
||||
<span class={`user-status-dot ${$clientSession ? 'live' : 'idle'}`}></span>
|
||||
<span class="user-trigger-copy">
|
||||
<span class="workspace-label">{$sessionHydrated ? ($clientSession ? 'Signed in' : 'Signed out') : 'Checking session'}</span>
|
||||
<strong>{$sessionHydrated ? ($clientSession ? $clientSession.email : 'Sign in required') : 'Restoring workspace access'}</strong>
|
||||
</span>
|
||||
<span class:open={userMenuOpen} class="chevron"></span>
|
||||
</button>
|
||||
|
||||
{#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 overview page to sign in.'
|
||||
: 'Waiting for the browser session check to complete.'}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onclick={openSettings}>Change settings</button>
|
||||
{#if $clientSession}
|
||||
<button type="button" onclick={() => clientSession.clear()}>Log out</button>
|
||||
{:else if !$sessionHydrated}
|
||||
<button type="button" disabled>Checking session...</button>
|
||||
{:else}
|
||||
<a href="/">Go to sign-in</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
{#if !isRootRoute && !$clientSession}
|
||||
{#if !isRootRoute && (!$sessionHydrated || isRestoringSession)}
|
||||
<section class="locked-card loading-card">
|
||||
<p class="workspace-label">Checking Session</p>
|
||||
<h2>Restoring your client workspace.</h2>
|
||||
<p>Refreshing the current page with the saved browser session before deciding whether sign-in is required.</p>
|
||||
</section>
|
||||
{:else if !isRootRoute && !$clientSession}
|
||||
<section class="locked-card">
|
||||
<p class="workspace-label">Client Sign-In Required</p>
|
||||
<h2>Sign in on the Hunter Premium Produce home page to unlock workspace data.</h2>
|
||||
@@ -244,6 +385,95 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showBottomNav}
|
||||
<nav class="bottom-nav" aria-label="Tablet navigation">
|
||||
{#each primaryBottomNavigation as item}
|
||||
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
|
||||
<span class="bottom-nav-icon">{item.shortLabel}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<button aria-expanded={navOpen} class:active={navOpen} type="button" onclick={() => (navOpen = !navOpen)}>
|
||||
<span class="bottom-nav-icon muted">+</span>
|
||||
<span>More</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{#if navOpen}
|
||||
<section
|
||||
aria-label="Tablet navigation drawer"
|
||||
class="bottom-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div class="drawer-handle"></div>
|
||||
|
||||
<div class="drawer-header">
|
||||
<div>
|
||||
<p class="workspace-label">Workspace Drawer</p>
|
||||
<strong>Hunter Premium Produce</strong>
|
||||
</div>
|
||||
<button aria-label="Close drawer" class="nav-toggle" type="button" onclick={() => (navOpen = false)}>
|
||||
<span></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="search-box drawer-search" type="button" aria-label="Search the workspace" onclick={() => openPalette()}>
|
||||
<span class="search-icon"></span>
|
||||
<span class="search-placeholder">Search the workspace...</span>
|
||||
<kbd>/</kbd>
|
||||
</button>
|
||||
|
||||
<div class="drawer-grid">
|
||||
<nav class="drawer-section" aria-label="All workspace pages">
|
||||
{#each navigation as item}
|
||||
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href} onclick={() => (navOpen = false)}>
|
||||
<span class="nav-icon">{item.shortLabel}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="drawer-section drawer-actions">
|
||||
<a href="/mixes/new" onclick={() => (navOpen = false)}>
|
||||
<span class="nav-icon">NW</span>
|
||||
<span>Create mix worksheet</span>
|
||||
</a>
|
||||
<button type="button" onclick={openSettings}>
|
||||
<span class="nav-icon muted">ST</span>
|
||||
<span>Change settings</span>
|
||||
</button>
|
||||
<a href="/products" onclick={() => (navOpen = false)}>
|
||||
<span class="nav-icon muted">DP</span>
|
||||
<span>Review delivered pricing</span>
|
||||
</a>
|
||||
<button type="button" onclick={() => openPalette('')}>
|
||||
<span class="nav-icon muted">SR</span>
|
||||
<span>Search the workspace</span>
|
||||
</button>
|
||||
{#if $clientSession}
|
||||
<button type="button" onclick={() => clientSession.clear()}>
|
||||
<span class="nav-icon muted">SO</span>
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-footer">
|
||||
{#each footerLinks as item}
|
||||
<a href={item.href} onclick={() => (navOpen = false)}>
|
||||
<span>{item.label}</span>
|
||||
<small>{item.shortLabel}</small>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if paletteOpen}
|
||||
<div class="palette-overlay" role="presentation" onclick={() => (paletteOpen = false)}>
|
||||
<div
|
||||
@@ -340,6 +570,10 @@
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.nav-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -349,6 +583,14 @@
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sidebar-body {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -401,6 +643,10 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.topbar-nav-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-toggle span,
|
||||
.search-icon,
|
||||
.chevron {
|
||||
@@ -521,6 +767,18 @@
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
|
||||
.sidebar-meta {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.85rem 0.3rem 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.sidebar-meta small {
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.main-shell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -528,15 +786,22 @@
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.95fr) minmax(20rem, 1.1fr) auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.9rem;
|
||||
padding: 0.86rem 1.34rem;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.topbar-start {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.82rem;
|
||||
}
|
||||
|
||||
.topbar-copy h1,
|
||||
.topbar-copy p {
|
||||
margin: 0;
|
||||
@@ -553,6 +818,15 @@
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.topbar-middle {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-search {
|
||||
min-height: 3rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -600,6 +874,73 @@
|
||||
color: #304038;
|
||||
}
|
||||
|
||||
.user-trigger {
|
||||
min-width: 14rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.72rem;
|
||||
padding: 0.64rem 0.84rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.96rem;
|
||||
background: var(--panel-soft);
|
||||
color: #304038;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-status-dot {
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
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);
|
||||
}
|
||||
|
||||
.user-status-dot.idle {
|
||||
background: #c08b3d;
|
||||
box-shadow: 0 0 0 0.24rem rgba(192, 139, 61, 0.14);
|
||||
}
|
||||
|
||||
.user-trigger-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-trigger-copy strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.user-menu-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-menu-panel {
|
||||
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;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 0.54rem;
|
||||
height: 0.54rem;
|
||||
@@ -658,6 +999,10 @@
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.locked-card h2,
|
||||
.locked-card p {
|
||||
margin: 0;
|
||||
@@ -775,29 +1120,294 @@
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.bottom-nav,
|
||||
.bottom-drawer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-shell.bottom-nav-layout .content {
|
||||
padding-bottom: 7.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
.nav-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 48;
|
||||
display: block;
|
||||
border: none;
|
||||
background: rgba(11, 18, 14, 0.28);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: 0;
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
left: max(0.8rem, env(safe-area-inset-left));
|
||||
right: max(0.8rem, env(safe-area-inset-right));
|
||||
bottom: max(0.8rem, env(safe-area-inset-bottom));
|
||||
z-index: 45;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem;
|
||||
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);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.bottom-nav a,
|
||||
.bottom-nav button {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.34rem;
|
||||
padding: 0.62rem 0.38rem;
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
background: transparent;
|
||||
color: #51635a;
|
||||
text-align: center;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bottom-nav a.active,
|
||||
.bottom-nav button.active {
|
||||
color: var(--green-deep);
|
||||
background: linear-gradient(180deg, #f4fbf7 0%, #e8f6ee 100%);
|
||||
}
|
||||
|
||||
.bottom-nav-icon {
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.78rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.bottom-nav-icon.muted {
|
||||
background: linear-gradient(135deg, #96a49c 0%, #718077 100%);
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem calc(6.6rem + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--line);
|
||||
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);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.drawer-handle {
|
||||
width: 3.5rem;
|
||||
height: 0.34rem;
|
||||
margin: 0 auto;
|
||||
border-radius: 999px;
|
||||
background: #c8d4ce;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.drawer-header strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.drawer-search {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.drawer-grid {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
}
|
||||
|
||||
.drawer-section {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.drawer-section a,
|
||||
.drawer-section button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.72rem;
|
||||
padding: 0.82rem 0.86rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.96rem;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
color: #304038;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drawer-section a.active {
|
||||
color: var(--green-deep);
|
||||
background: var(--green-soft);
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.drawer-footer a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.82rem 0.9rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.96rem;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
color: #304038;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.drawer-footer small {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 0.95rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.topbar-middle {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1181px) {
|
||||
.bottom-nav-layout .content {
|
||||
padding-bottom: 1.34rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1181px) {
|
||||
.nav-toggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.drawer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbar,
|
||||
.topbar-actions,
|
||||
.action-button {
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.topbar-start {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topbar-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-copy h1 {
|
||||
font-size: 1.34rem;
|
||||
}
|
||||
|
||||
.topbar-middle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
left: max(0.55rem, env(safe-area-inset-left));
|
||||
right: max(0.55rem, env(safe-area-inset-right));
|
||||
bottom: max(0.55rem, env(safe-area-inset-bottom));
|
||||
gap: 0.32rem;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.bottom-nav a,
|
||||
.bottom-nav button {
|
||||
padding: 0.55rem 0.2rem;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.bottom-nav-icon {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
padding: 0.75rem 0.8rem calc(6.3rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.topbar-actions,
|
||||
.workspace-chip,
|
||||
.menu-wrap,
|
||||
.action-button,
|
||||
.user-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.menu-panel {
|
||||
left: 0;
|
||||
right: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0.92rem;
|
||||
}
|
||||
|
||||
@@ -415,7 +415,7 @@
|
||||
<tbody>
|
||||
{#each draftRows as row, index}
|
||||
<tr>
|
||||
<td>
|
||||
<td data-label="Raw Material">
|
||||
<select
|
||||
value={row.raw_material_id ?? ''}
|
||||
onchange={(event) =>
|
||||
@@ -431,10 +431,10 @@
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td>{currency(row.marketValue)}</td>
|
||||
<td>{row.wastePercentage !== null ? `${(row.wastePercentage * 100).toFixed(1)}%` : 'N/A'}</td>
|
||||
<td>{currency(row.costPerKg, 4)}</td>
|
||||
<td>
|
||||
<td data-label="Market Value">{currency(row.marketValue)}</td>
|
||||
<td data-label="Waste %">{row.wastePercentage !== null ? `${(row.wastePercentage * 100).toFixed(1)}%` : 'N/A'}</td>
|
||||
<td data-label="Cost / Kg">{currency(row.costPerKg, 4)}</td>
|
||||
<td data-label="Qty Kg">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -445,8 +445,8 @@
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>{currency(row.lineCost)}</td>
|
||||
<td>
|
||||
<td data-label="Line Cost">{currency(row.lineCost)}</td>
|
||||
<td data-label="Notes">
|
||||
<input
|
||||
type="text"
|
||||
value={row.notes}
|
||||
@@ -454,7 +454,7 @@
|
||||
placeholder="Optional row note"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Row Action">
|
||||
<button class="icon-delete" type="button" onclick={() => removeIngredientRow(index)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -756,6 +756,7 @@
|
||||
|
||||
.sheet-table {
|
||||
width: 100%;
|
||||
min-width: 58rem;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 0.54rem;
|
||||
}
|
||||
@@ -860,12 +861,24 @@
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.editor-grid,
|
||||
.metric-row,
|
||||
.meta-grid {
|
||||
@media (max-width: 1240px) {
|
||||
.editor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar-stack {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.metric-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -877,8 +890,88 @@
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.intro-actions,
|
||||
.editor-actions,
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.meta-grid,
|
||||
.sidebar-stack {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.sheet-table,
|
||||
.sheet-table thead,
|
||||
.sheet-table tbody,
|
||||
.sheet-table tr,
|
||||
.sheet-table td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sheet-table {
|
||||
min-width: 0;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.sheet-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sheet-table tbody {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.sheet-table tbody tr {
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.sheet-table tbody td {
|
||||
padding: 0.78rem 0.8rem;
|
||||
white-space: normal;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sheet-table tbody td:first-child,
|
||||
.sheet-table tbody td:last-child {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.sheet-table tbody td + td {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sheet-table 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;
|
||||
}
|
||||
|
||||
.sheet-table input,
|
||||
.sheet-table select,
|
||||
.icon-delete {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -86,6 +86,8 @@ export const mockCosts: ProductCostBreakdown[] = [
|
||||
{
|
||||
product_id: 1,
|
||||
product_name: 'Hunter Orchard Blend 20kg',
|
||||
client_name: 'Hunter Premium Produce',
|
||||
mix_name: 'Hunter Orchard Blend',
|
||||
finished_product_delivered: 14.208,
|
||||
distributor_price: 18.3329,
|
||||
wholesale_price: 17.3268,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { writable } from 'svelte/store';
|
||||
import { readable, writable } from 'svelte/store';
|
||||
|
||||
export type AppSession = {
|
||||
name: string;
|
||||
@@ -74,5 +74,19 @@ export function hasStoredAdminSession() {
|
||||
return getStoredAdminSession() !== null;
|
||||
}
|
||||
|
||||
export const sessionHydrated = readable(false, (set) => {
|
||||
if (!browser) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
set(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
};
|
||||
});
|
||||
|
||||
export const clientSession = createSessionStore(CLIENT_STORAGE_KEY);
|
||||
export const adminSession = createSessionStore(ADMIN_STORAGE_KEY);
|
||||
|
||||
@@ -97,6 +97,8 @@ export type Product = {
|
||||
export type ProductCostBreakdown = {
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
client_name: string;
|
||||
mix_name: string;
|
||||
cleaned_product_cost?: number;
|
||||
grading_cost?: number;
|
||||
bagging_cost?: number;
|
||||
|
||||
Reference in New Issue
Block a user