Initial commit

This commit is contained in:
2026-08-24 20:35:18 +05:30
commit 1dc582ba07
85 changed files with 21081 additions and 0 deletions

View 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>
);
}