v1.4 - Login fixes, etc
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>© {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>
|
||||
Reference in New Issue
Block a user