Initial commit
This commit is contained in:
247
src/features/store-admin/StoreAdminShell.tsx
Normal file
247
src/features/store-admin/StoreAdminShell.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Check, ChevronDown, Monitor, Store, Users } from 'lucide-react';
|
||||
import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
|
||||
import { BranchScopeProvider, useBranchScope } from './BranchScope';
|
||||
|
||||
/**
|
||||
* Store Admin — the merchant workspace.
|
||||
*
|
||||
* Four destinations, deliberately. The old console gave this role five
|
||||
* (Dashboard, Products, Reports, Store Console, POS) and split the same
|
||||
* question across them: counter sales lived in POS, online sales in Dashboard,
|
||||
* and neither page could answer "how did this branch do today". Console, Sales,
|
||||
* Inventory and Reports each own one question instead.
|
||||
*/
|
||||
const NAV: readonly NavEntry[] = [
|
||||
{ to: '/admin/console', label: 'Console' },
|
||||
{ to: '/admin/sales', label: 'Sales' },
|
||||
{ to: '/admin/inventory', label: 'Inventory' },
|
||||
{ to: '/admin/reports', label: 'Reports' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Setup destinations, in the account menu rather than the nav.
|
||||
*
|
||||
* Three entries, not a Settings page. Opening an outlet, deciding who can sign
|
||||
* in, and checking on the tills are the things a merchant does outside the flow
|
||||
* of a trading day — reached often enough to deserve naming, none of them worth
|
||||
* a nav slot beside Console and Sales.
|
||||
*
|
||||
* Terminals belongs here rather than under Sales for the same reason: it is
|
||||
* where you go when the Console has already told you something is wrong, not a
|
||||
* place anyone works from.
|
||||
*/
|
||||
const MANAGE: readonly MenuEntry[] = [
|
||||
{
|
||||
to: '/admin/branches/new',
|
||||
label: 'New branch',
|
||||
icon: <Store size={13} />,
|
||||
note: 'Commission an outlet',
|
||||
},
|
||||
{
|
||||
to: '/admin/users',
|
||||
label: 'Users & access',
|
||||
icon: <Users size={13} />,
|
||||
note: 'Store and till accounts',
|
||||
},
|
||||
{
|
||||
to: '/admin/terminals',
|
||||
label: 'Terminals',
|
||||
icon: <Monitor size={13} />,
|
||||
note: 'Till health, per counter',
|
||||
},
|
||||
];
|
||||
|
||||
export function StoreAdminShell() {
|
||||
return (
|
||||
<BranchScopeProvider>
|
||||
<AppShell
|
||||
nav={NAV}
|
||||
home="/admin/console"
|
||||
navLabel="Store Admin"
|
||||
scopeControl={<BranchSelector />}
|
||||
manageItems={MANAGE}
|
||||
/>
|
||||
</BranchScopeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch selector, in the header.
|
||||
*
|
||||
* It lives in the chrome rather than on each page because it scopes all four
|
||||
* pages: a control that reappears in a different place on every screen reads as
|
||||
* a different control each time, and the operator loses track of what is
|
||||
* filtered.
|
||||
*
|
||||
* "All branches" is a real option rather than a cleared filter, and it is the
|
||||
* default. A merchant with three shops wants the group view first; the single
|
||||
* shop is the drill-down.
|
||||
*/
|
||||
function BranchSelector() {
|
||||
const { branches, selected, select, current, isLoading } = useBranchScope();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function handle(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, [isOpen]);
|
||||
|
||||
const label = isLoading
|
||||
? 'Loading…'
|
||||
: (current?.locationname ?? `All branches (${branches.length})`);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={`Branch: ${label}. Filters every page.`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
height: 32,
|
||||
maxWidth: 210,
|
||||
padding: '0 8px 0 10px',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isOpen ? 'var(--color-brand)' : 'var(--color-line)'}`,
|
||||
background: isOpen ? 'var(--color-surface)' : 'var(--color-surface-subtle)',
|
||||
color: 'var(--color-ink-1)',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color .2s, background .2s',
|
||||
}}
|
||||
>
|
||||
<Store size={14} style={{ color: 'var(--color-brand)', flex: 'none' }} />
|
||||
<span
|
||||
style={{
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<ChevronDown size={12} style={{ color: 'var(--color-ink-3)', flex: 'none' }} />
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<>
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Branch"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 38,
|
||||
zIndex: 50,
|
||||
width: 260,
|
||||
padding: 6,
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface)',
|
||||
boxShadow: '0 12px 28px -6px rgb(16 24 40 / .12)',
|
||||
}}
|
||||
>
|
||||
<BranchOption
|
||||
label={`All branches (${branches.length})`}
|
||||
note="Everything, consolidated"
|
||||
isSelected={selected === null}
|
||||
onSelect={() => {
|
||||
select(null);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div style={{ height: 1, background: 'var(--color-line)', margin: '4px 0' }} />
|
||||
{branches.map((branch) => (
|
||||
<BranchOption
|
||||
key={branch.locationid}
|
||||
label={branch.locationname}
|
||||
note={branch.suburb ?? branch.city ?? ''}
|
||||
isSelected={selected === branch.locationid}
|
||||
onSelect={() => {
|
||||
select(branch.locationid);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchOption({
|
||||
label,
|
||||
note,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
note: string;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
background: isSelected
|
||||
? 'var(--color-brand-tint)'
|
||||
: isHovered
|
||||
? 'var(--color-surface-sunken)'
|
||||
: 'transparent',
|
||||
color: isSelected ? 'var(--color-brand)' : 'var(--color-ink-2)',
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 0, flex: 1 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: isSelected ? 600 : 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{note ? (
|
||||
<span style={{ display: 'block', fontSize: 11.5, color: 'var(--color-ink-4)' }}>
|
||||
{note}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSelected ? <Check size={14} style={{ flex: 'none' }} /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user