Files
daily_console_web/src/features/store-admin/StoreAdminShell.tsx
2026-09-09 11:19:05 +05:30

329 lines
11 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import { Check, ChevronDown, FileSpreadsheet, Monitor, Store, Users } from 'lucide-react';
import { QrCode } from 'lucide-react';
import { StoreQrDrawer } from '@/features/store-user/StoreQrDrawer';
import { AppShell, IconButton, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
import { OnboardingGate } from '@/features/onboarding/OnboardingGate';
import { AssistantScope } from '@/features/console/AssistantScope';
import { BranchScopeProvider, useBranchScope } from './BranchScope';
import { useLiveEvents } from '@/queries/useLiveEvents';
import { DateScopeProvider } from '@/components/shell/DateScope';
/**
* 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/dispatch', label: 'Dispatch' },
{ 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[] = [
/* First, because it is the first thing a new merchant should do and the one
nobody has done: across 200 shops, 18 had a photograph and none had a
licence number. It was not neglect — until now nothing in the platform
could write the `tenants` table at all. */
{
to: '/admin/profile',
label: 'Shop profile',
icon: <Store size={13} />,
note: 'What shoppers see about you',
},
/* Before "New branch", because that is now the order: hire the person, then
open the shop and say who runs it. */
{
to: '/admin/users',
label: 'Users & access',
icon: <Users size={13} />,
note: 'Store and terminal accounts',
},
{
to: '/admin/branches/new',
label: 'New branch',
icon: <Store size={13} />,
note: 'Commission an outlet',
},
{
to: '/admin/terminals',
label: 'Terminals',
icon: <Monitor size={13} />,
note: 'Till health, per counter',
},
/* Here rather than in the nav for the reason stated above: the four nav slots
each answer a question about the trading day, and "did my spreadsheet
land?" is not one of them. It is checked in the days AFTER an upload,
while the catalogue service's admin decides whether to run it. */
{
to: '/admin/uploads',
label: 'Uploads',
icon: <FileSpreadsheet size={13} />,
note: 'Spreadsheets sent to the catalogue',
},
];
export function StoreAdminShell() {
return (
<DateScopeProvider>
<BranchScopeProvider>
<LiveWatch />
<OnboardingGate />
<AssistantScope>
<AdminShell />
</AssistantScope>
</BranchScopeProvider>
</DateScopeProvider>
);
}
/**
* The shell itself, inside the providers so it can read the branch in scope.
*
* Split out for the QR button: it needs open/closed state and it needs to know
* which outlet is selected, and both of those have to sit under
* `BranchScopeProvider`.
*/
function AdminShell() {
const [isQrOpen, setQrOpen] = useState(false);
return (
<>
<AppShell
nav={NAV}
home="/admin/console"
navLabel="Store Admin"
scopeControl={<BranchSelector />}
manageItems={MANAGE}
/* The storefront code, in the same place the branch login has it.
A merchant runs the outlets — they are the one who prints the poster
for a new shop and reprints it when it is lost — and until now the
code existed only on the branch user's header, so an owner with five
shops could not reach any of them. It follows the branch selector, so
the code on screen is always the one for the outlet in scope. */
headerActions={
<IconButton label="Store QR code" onClick={() => setQrOpen(true)}>
<QrCode size={16} />
</IconButton>
}
/>
{isQrOpen ? <StoreQrDrawer onClose={() => setQrOpen(false)} /> : null}
</>
);
}
/**
* Opens the live stream for the branch currently in scope.
*
* A component rather than a hook call in the shell body, because it has to sit
* INSIDE `BranchScopeProvider` to read the selection — and it renders nothing,
* so where it sits in the tree costs nothing either.
*
* Under "All branches" `current` is undefined and no stream opens. That is
* deliberate: the stream is per-outlet, and a six-branch tenant would otherwise
* hold six connections open for a screen that is already polling.
*/
function LiveWatch() {
const { tenantid, current } = useBranchScope();
useLiveEvents(tenantid, current?.locationid);
return null;
}
/**
* 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>
);
}