329 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|