206 lines
7.0 KiB
TypeScript
206 lines
7.0 KiB
TypeScript
import { useState } from 'react';
|
|
import { Monitor, QrCode, Store, UserCog, UserRound, Users } from 'lucide-react';
|
|
import { StoreQrDrawer } from './StoreQrDrawer';
|
|
import { AppShell, IconButton, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
|
|
import { useAuth } from '@/auth/AuthContext';
|
|
import { useTenantLocations } from '@/queries/hooks';
|
|
import { BranchScopeProvider, useBranchScope } from '@/features/store-admin/BranchScope';
|
|
|
|
/**
|
|
* Store user — one branch, and only that branch.
|
|
*
|
|
* The same four destinations as the Store Admin, for the same reason: the
|
|
* questions a shop asks in a day are what did we take, what do we sell, what is
|
|
* on the shelf, and how did the period go. What changes is the scope, not the
|
|
* shape — so the pages are the merchant's pages with the branch fixed, rather
|
|
* than a second set that drifts.
|
|
*
|
|
* The old console gave this role five nav entries over a six-tab hub, and one
|
|
* of them (Store Console) rendered another one (Products) inside itself. Four
|
|
* pages, each owning one question, replaces it.
|
|
*/
|
|
const NAV: readonly NavEntry[] = [
|
|
{ to: '/store/console', label: 'Console' },
|
|
{ to: '/store/products', label: 'Products' },
|
|
{ to: '/store/sales', label: 'Sales' },
|
|
{ to: '/store/reports', label: 'Reports' },
|
|
];
|
|
|
|
/**
|
|
* The account menu.
|
|
*
|
|
* Everything here is either about this person, or about the shop's fittings —
|
|
* reached when something needs checking, not worked from hour to hour. The
|
|
* merchant's "New branch" entry is deliberately absent: a shop does not
|
|
* commission outlets, and `createtenantlocation` also spawns a login.
|
|
*
|
|
* Customers sits here rather than in the nav because the four nav slots each
|
|
* answer a question about the trading day, and "who orders from us" is a
|
|
* lookup. Move it up if the shop uses it hourly.
|
|
*
|
|
* The store QR is NOT here — it is an icon in the header, because it opens in
|
|
* place and closes again. A destination is for somewhere you work; this is
|
|
* something you glance at and print.
|
|
*/
|
|
const MANAGE: readonly MenuEntry[] = [
|
|
{
|
|
to: '/store/customers',
|
|
label: 'Customers',
|
|
icon: <Users size={13} />,
|
|
note: 'Who orders from this shop',
|
|
},
|
|
{
|
|
to: '/store/terminals',
|
|
label: 'Terminals',
|
|
icon: <Monitor size={13} />,
|
|
note: 'Till health at this counter',
|
|
},
|
|
{
|
|
to: '/store/staff',
|
|
label: 'Counter staff',
|
|
icon: <UserCog size={13} />,
|
|
note: 'Who can open the till',
|
|
},
|
|
{
|
|
to: '/store/account',
|
|
label: 'My account',
|
|
icon: <UserRound size={13} />,
|
|
note: 'Profile and store binding',
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Resolve the outlet this account is bound to.
|
|
*
|
|
* Two steps, and no third. `app_users.locationid` is the binding; a tenant with
|
|
* exactly one outlet needs no id at all. The old console had four more
|
|
* fallbacks below these — including matching the email's local part against a
|
|
* store's address, and falling back to `applocationid` — and those are guesses
|
|
* at an authorisation boundary. `applocationid` is a CITY id: passing one to a
|
|
* POS read returns an empty board, or another shop's takings if the numbers
|
|
* happen to collide.
|
|
*/
|
|
function useResolvedOutlet(): { locationid: number | null; isLoading: boolean } {
|
|
const { user } = useAuth();
|
|
const { data, isLoading } = useTenantLocations(user?.tenantid || undefined);
|
|
const branches = data ?? [];
|
|
|
|
if (user?.locationid && user.locationid > 0) return { locationid: user.locationid, isLoading };
|
|
if (isLoading) return { locationid: null, isLoading: true };
|
|
if (branches.length === 1 && branches[0]) return { locationid: branches[0].locationid, isLoading };
|
|
return { locationid: null, isLoading: false };
|
|
}
|
|
|
|
export function StoreUserShell() {
|
|
const { locationid, isLoading } = useResolvedOutlet();
|
|
const [isQrOpen, setQrOpen] = useState(false);
|
|
|
|
if (isLoading) return <Resolving />;
|
|
if (locationid === null) return <NoStore />;
|
|
|
|
return (
|
|
<BranchScopeProvider pin={locationid}>
|
|
<AppShell
|
|
nav={NAV}
|
|
home="/store/console"
|
|
navLabel="Store"
|
|
scopeControl={<BranchPill />}
|
|
manageItems={MANAGE}
|
|
headerActions={
|
|
<IconButton label="Store QR code" onClick={() => setQrOpen(true)}>
|
|
<QrCode size={16} />
|
|
</IconButton>
|
|
}
|
|
/>
|
|
{isQrOpen ? <StoreQrDrawer onClose={() => setQrOpen(false)} /> : null}
|
|
</BranchScopeProvider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Where the merchant has a branch selector, the shop has a label.
|
|
*
|
|
* Same slot, same size, same position — so the two workspaces read as one
|
|
* product — but not a control, because there is nothing to choose. A disabled
|
|
* dropdown would say "you may not switch"; a label says "this is your shop",
|
|
* which is the true statement.
|
|
*/
|
|
function BranchPill() {
|
|
const { current, isLoading } = useBranchScope();
|
|
const label = isLoading ? 'Loading…' : (current?.locationname ?? 'Your store');
|
|
|
|
return (
|
|
<span
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 7,
|
|
height: 32,
|
|
maxWidth: 230,
|
|
padding: '0 12px',
|
|
borderRadius: 12,
|
|
border: '1px solid var(--color-line)',
|
|
background: 'var(--color-surface-subtle)',
|
|
color: 'var(--color-ink-1)',
|
|
fontSize: 13,
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
<Store size={14} style={{ color: 'var(--color-brand)', flex: 'none' }} />
|
|
<span
|
|
style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
|
>
|
|
{label}
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function Resolving() {
|
|
return (
|
|
<div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', padding: 24 }}>
|
|
<p style={{ color: 'var(--color-slate-500)', fontSize: 14 }}>Finding your store…</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The honest refusal.
|
|
*
|
|
* Every read on every page below is authorised by `locationid` alone, so a page
|
|
* that guessed one would not fail loudly — it would quietly show another shop's
|
|
* takings, or an empty board that reads as a quiet day.
|
|
*/
|
|
function NoStore() {
|
|
const { signOut } = useAuth();
|
|
return (
|
|
<div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', padding: 24 }}>
|
|
<div style={{ maxWidth: 440, textAlign: 'center' }}>
|
|
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22, marginBottom: 8 }}>
|
|
No store assigned
|
|
</h1>
|
|
<p style={{ color: 'var(--color-slate-500)', fontSize: 14, lineHeight: 1.65 }}>
|
|
Your account is not linked to a branch yet, so there is nothing to show. Ask your store
|
|
administrator to allocate you to one.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={signOut}
|
|
style={{
|
|
marginTop: 20,
|
|
height: 32,
|
|
padding: '0 14px',
|
|
borderRadius: 12,
|
|
border: '1px solid var(--color-line)',
|
|
background: 'var(--color-surface)',
|
|
fontSize: 13,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|