Files
daily_console_web/src/features/store-user/StoreUserShell.tsx
2026-08-25 18:00:16 +05:30

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