|
|
|
@@ -39,7 +39,7 @@
|
|
|
|
|
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const searchItems: SearchItem[] = [
|
|
|
|
|
const baseSearchItems: SearchItem[] = [
|
|
|
|
|
{
|
|
|
|
|
href: '/',
|
|
|
|
|
label: 'Open Dashboard',
|
|
|
|
@@ -104,10 +104,12 @@
|
|
|
|
|
let quickMenuOpen = $state(false);
|
|
|
|
|
let userMenuOpen = $state(false);
|
|
|
|
|
let navOpen = $state(false);
|
|
|
|
|
let workingDocumentsExpanded = $state(true);
|
|
|
|
|
let workingDocumentsExpanded = $state(false);
|
|
|
|
|
let showBottomNav = $state(false);
|
|
|
|
|
let isRestoringSession = $state(false);
|
|
|
|
|
let restoredToken = $state<string | null>(null);
|
|
|
|
|
let seededSearchItems = $state<SearchItem[]>([]);
|
|
|
|
|
let seededSearchToken = $state<string | null>(null);
|
|
|
|
|
let paletteInput: HTMLInputElement | null = $state(null);
|
|
|
|
|
const appVersion = `v${packageInfo.version}`;
|
|
|
|
|
const releaseStage = 'Alpha';
|
|
|
|
@@ -138,6 +140,10 @@
|
|
|
|
|
...visibleWorkingDocumentItems.slice(0, 2)
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
const workingDocumentsActive = $derived(
|
|
|
|
|
visibleWorkingDocumentItems.some((item) => matchesRoute(item.href, page.url.pathname))
|
|
|
|
|
);
|
|
|
|
|
const searchItems = $derived([...baseSearchItems, ...seededSearchItems]);
|
|
|
|
|
|
|
|
|
|
function matchesRoute(href: string, pathname: string) {
|
|
|
|
|
return href === '/' ? pathname === '/' : pathname.startsWith(href);
|
|
|
|
@@ -255,6 +261,61 @@
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
const hydrated = $sessionHydrated;
|
|
|
|
|
const session = $clientSession;
|
|
|
|
|
const token = session?.token ?? null;
|
|
|
|
|
|
|
|
|
|
if (!hydrated || !session || !token) {
|
|
|
|
|
seededSearchItems = [];
|
|
|
|
|
seededSearchToken = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (seededSearchToken === token) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
seededSearchToken = token;
|
|
|
|
|
|
|
|
|
|
Promise.all([
|
|
|
|
|
hasModuleAccess(session, 'products') ? api.products() : Promise.resolve([]),
|
|
|
|
|
hasModuleAccess(session, 'mix_master') ? api.mixes() : Promise.resolve([]),
|
|
|
|
|
hasModuleAccess(session, 'mix_calculator') ? api.mixCalculatorSessions() : Promise.resolve([])
|
|
|
|
|
])
|
|
|
|
|
.then(([products, mixes, sessions]) => {
|
|
|
|
|
if (seededSearchToken !== token) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
seededSearchItems = [
|
|
|
|
|
...products.map((product) => ({
|
|
|
|
|
href: '/products',
|
|
|
|
|
label: product.name,
|
|
|
|
|
description: `Product · ${product.client_name} · ${product.mix_name}`,
|
|
|
|
|
keywords: `product ${product.name} ${product.client_name} ${product.mix_name} ${product.unit_of_measure}`
|
|
|
|
|
})),
|
|
|
|
|
...mixes.map((mix) => ({
|
|
|
|
|
href: `/mixes/${mix.id}`,
|
|
|
|
|
label: mix.name,
|
|
|
|
|
description: `Mix · ${mix.client_name} · ${mix.total_mix_kg}kg`,
|
|
|
|
|
keywords: `mix ${mix.name} ${mix.client_name} ${mix.notes ?? ''} ${mix.ingredients.map((ingredient) => ingredient.raw_material_name).join(' ')}`
|
|
|
|
|
})),
|
|
|
|
|
...sessions.map((savedSession) => ({
|
|
|
|
|
href: `/mix-calculator/${savedSession.id}`,
|
|
|
|
|
label: `${savedSession.session_number} · ${savedSession.product_name}`,
|
|
|
|
|
description: `Mix Session · ${savedSession.prepared_by_name} · ${savedSession.mix_date}`,
|
|
|
|
|
keywords: `mix calculator session ${savedSession.session_number} ${savedSession.product_name} ${savedSession.mix_name} ${savedSession.client_name} ${savedSession.prepared_by_name} ${savedSession.notes ?? ''}`
|
|
|
|
|
}))
|
|
|
|
|
];
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
if (seededSearchToken === token) {
|
|
|
|
|
seededSearchItems = [];
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if ($sessionHydrated && !$clientSession && !isRootRoute) {
|
|
|
|
|
goto('/', { replaceState: true });
|
|
|
|
@@ -370,11 +431,15 @@
|
|
|
|
|
<button
|
|
|
|
|
aria-controls="working-documents-nav"
|
|
|
|
|
aria-expanded={workingDocumentsExpanded}
|
|
|
|
|
class:active={workingDocumentsActive}
|
|
|
|
|
class="nav-group-toggle"
|
|
|
|
|
type="button"
|
|
|
|
|
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
|
|
|
|
|
>
|
|
|
|
|
<span class="nav-group-label">Working Documents</span>
|
|
|
|
|
<span class="nav-group-toggle-copy">
|
|
|
|
|
<span class="nav-icon muted">WD</span>
|
|
|
|
|
<span>Working Docs</span>
|
|
|
|
|
</span>
|
|
|
|
|
<span class:open={workingDocumentsExpanded} class="chevron"></span>
|
|
|
|
|
</button>
|
|
|
|
|
{#if workingDocumentsExpanded}
|
|
|
|
@@ -421,37 +486,12 @@
|
|
|
|
|
<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>
|
|
|
|
|
<span class="search-placeholder">Search products, mixes, sessions, and pages...</span>
|
|
|
|
|
<kbd>/</kbd>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="topbar-actions">
|
|
|
|
|
<div class="menu-wrap">
|
|
|
|
|
<button
|
|
|
|
|
class="action-button"
|
|
|
|
|
type="button"
|
|
|
|
|
onclick={() => {
|
|
|
|
|
quickMenuOpen = !quickMenuOpen;
|
|
|
|
|
userMenuOpen = false;
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Quick Actions
|
|
|
|
|
<span class:open={quickMenuOpen} class="chevron"></span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{#if quickMenuOpen}
|
|
|
|
|
<div class="menu-panel">
|
|
|
|
|
<a href="/mixes">Open mix costing</a>
|
|
|
|
|
<a href="/mixes/new">Create mix worksheet</a>
|
|
|
|
|
<a href="/mix-calculator">Open mix calculator</a>
|
|
|
|
|
<a href="/mix-calculator/new">Create mix session</a>
|
|
|
|
|
<a href="/products">Review delivered pricing</a>
|
|
|
|
|
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="menu-wrap user-menu-wrap">
|
|
|
|
|
<button
|
|
|
|
|
aria-expanded={userMenuOpen}
|
|
|
|
@@ -465,7 +505,7 @@
|
|
|
|
|
<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>
|
|
|
|
|
<strong>{$sessionHydrated ? ($clientSession ? $clientSession.name || 'Client account' : 'Sign in required') : 'Restoring workspace access'}</strong>
|
|
|
|
|
</span>
|
|
|
|
|
<span class:open={userMenuOpen} class="chevron"></span>
|
|
|
|
|
</button>
|
|
|
|
@@ -514,6 +554,33 @@
|
|
|
|
|
{/if}
|
|
|
|
|
</main>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="quick-fab-wrap">
|
|
|
|
|
{#if quickMenuOpen}
|
|
|
|
|
<div class="menu-panel quick-fab-panel">
|
|
|
|
|
<a href="/mixes">Open mix costing</a>
|
|
|
|
|
<a href="/mixes/new">Create mix worksheet</a>
|
|
|
|
|
<a href="/mix-calculator">Open mix calculator</a>
|
|
|
|
|
<a href="/mix-calculator/new">Create mix session</a>
|
|
|
|
|
<a href="/products">Review delivered pricing</a>
|
|
|
|
|
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
aria-expanded={quickMenuOpen}
|
|
|
|
|
aria-label="Open quick access menu"
|
|
|
|
|
class="quick-fab"
|
|
|
|
|
type="button"
|
|
|
|
|
onclick={() => {
|
|
|
|
|
quickMenuOpen = !quickMenuOpen;
|
|
|
|
|
userMenuOpen = false;
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span class={`quick-fab-plus ${quickMenuOpen ? 'open' : ''}`}></span>
|
|
|
|
|
<span>Quick Access</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if showBottomNav}
|
|
|
|
@@ -629,11 +696,15 @@
|
|
|
|
|
<button
|
|
|
|
|
aria-controls="drawer-working-documents-nav"
|
|
|
|
|
aria-expanded={workingDocumentsExpanded}
|
|
|
|
|
class:active={workingDocumentsActive}
|
|
|
|
|
class="nav-group-toggle drawer-group-toggle"
|
|
|
|
|
type="button"
|
|
|
|
|
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
|
|
|
|
|
>
|
|
|
|
|
<span class="drawer-group-label">Working Documents</span>
|
|
|
|
|
<span class="nav-group-toggle-copy">
|
|
|
|
|
<span class="nav-icon muted">WD</span>
|
|
|
|
|
<span>Working Documents</span>
|
|
|
|
|
</span>
|
|
|
|
|
<span class:open={workingDocumentsExpanded} class="chevron"></span>
|
|
|
|
|
</button>
|
|
|
|
|
{#if workingDocumentsExpanded}
|
|
|
|
@@ -710,7 +781,7 @@
|
|
|
|
|
>
|
|
|
|
|
<div class="palette-input-row">
|
|
|
|
|
<span class="search-icon"></span>
|
|
|
|
|
<input bind:this={paletteInput} bind:value={paletteQuery} placeholder="Search pages, workflows, and pricing views..." />
|
|
|
|
|
<input bind:this={paletteInput} bind:value={paletteQuery} placeholder="Search products, mixes, sessions, and pages..." />
|
|
|
|
|
<kbd>Esc</kbd>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
@@ -785,7 +856,7 @@
|
|
|
|
|
|
|
|
|
|
.app-shell {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 228px minmax(0, 1fr);
|
|
|
|
|
grid-template-columns: 244px minmax(0, 1fr);
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1003,25 +1074,31 @@
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
width: 100%;
|
|
|
|
|
padding: 0 0.68rem;
|
|
|
|
|
padding: 0.72rem 0.68rem;
|
|
|
|
|
border: none;
|
|
|
|
|
border-radius: 0.82rem;
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: inherit;
|
|
|
|
|
color: #304038;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: background-color 160ms ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.nav-group-label,
|
|
|
|
|
.drawer-group-label {
|
|
|
|
|
margin: 0;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
letter-spacing: 0.08em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
.nav-group-toggle:hover,
|
|
|
|
|
.nav-group-toggle.active {
|
|
|
|
|
background: var(--green-soft);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.nav-group-label {
|
|
|
|
|
padding: 0;
|
|
|
|
|
.nav-group-toggle.active {
|
|
|
|
|
color: var(--green-deep);
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.nav-group-toggle-copy {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.68rem;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.nav-sublist {
|
|
|
|
@@ -1141,9 +1218,12 @@
|
|
|
|
|
|
|
|
|
|
.topbar-middle {
|
|
|
|
|
min-width: 0;
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.topbar-search {
|
|
|
|
|
width: min(100%, 36rem);
|
|
|
|
|
min-height: 3rem;
|
|
|
|
|
background: #fff;
|
|
|
|
|
}
|
|
|
|
@@ -1186,15 +1266,6 @@
|
|
|
|
|
position: relative;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.action-button {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.62rem;
|
|
|
|
|
border-radius: 0.88rem;
|
|
|
|
|
padding: 0.68rem 0.84rem;
|
|
|
|
|
color: #304038;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.user-trigger {
|
|
|
|
|
min-width: 14rem;
|
|
|
|
|
display: inline-flex;
|
|
|
|
@@ -1291,6 +1362,71 @@
|
|
|
|
|
backdrop-filter: blur(10px);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.quick-fab-wrap {
|
|
|
|
|
position: fixed;
|
|
|
|
|
right: max(1rem, env(safe-area-inset-right));
|
|
|
|
|
bottom: max(1rem, env(safe-area-inset-bottom));
|
|
|
|
|
z-index: 46;
|
|
|
|
|
display: grid;
|
|
|
|
|
justify-items: end;
|
|
|
|
|
gap: 0.6rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.quick-fab {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.72rem;
|
|
|
|
|
padding: 0.88rem 1.05rem;
|
|
|
|
|
border: none;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%);
|
|
|
|
|
color: #fff;
|
|
|
|
|
box-shadow: 0 18px 36px rgba(23, 75, 45, 0.26);
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
letter-spacing: 0.01em;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.quick-fab-panel {
|
|
|
|
|
position: static;
|
|
|
|
|
min-width: 15rem;
|
|
|
|
|
padding: 0.45rem;
|
|
|
|
|
border-radius: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.quick-fab-plus {
|
|
|
|
|
position: relative;
|
|
|
|
|
width: 0.92rem;
|
|
|
|
|
height: 0.92rem;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.quick-fab-plus::before,
|
|
|
|
|
.quick-fab-plus::after {
|
|
|
|
|
content: '';
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 50%;
|
|
|
|
|
left: 50%;
|
|
|
|
|
width: 0.92rem;
|
|
|
|
|
height: 2px;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
background: currentColor;
|
|
|
|
|
transform: translate(-50%, -50%);
|
|
|
|
|
transition: transform 140ms ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.quick-fab-plus::after {
|
|
|
|
|
transform: translate(-50%, -50%) rotate(90deg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.quick-fab-plus.open::before {
|
|
|
|
|
transform: translate(-50%, -50%) rotate(45deg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.quick-fab-plus.open::after {
|
|
|
|
|
transform: translate(-50%, -50%) rotate(-45deg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.menu-panel a,
|
|
|
|
|
.menu-panel button {
|
|
|
|
|
padding: 0.72rem 0.78rem;
|
|
|
|
@@ -1482,6 +1618,10 @@
|
|
|
|
|
backdrop-filter: blur(16px);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.quick-fab-wrap {
|
|
|
|
|
bottom: calc(max(0.8rem, env(safe-area-inset-bottom)) + 5.9rem);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.bottom-nav a,
|
|
|
|
|
.bottom-nav button {
|
|
|
|
|
min-width: 0;
|
|
|
|
@@ -1599,11 +1739,7 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.drawer-group-toggle {
|
|
|
|
|
padding: 0 0.2rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.drawer-group-label {
|
|
|
|
|
padding: 0;
|
|
|
|
|
padding-inline: 0.2rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.drawer-sublist {
|
|
|
|
@@ -1649,6 +1785,7 @@
|
|
|
|
|
|
|
|
|
|
.topbar-middle {
|
|
|
|
|
order: 3;
|
|
|
|
|
width: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.topbar-actions {
|
|
|
|
|