store user login
This commit is contained in:
205
src/features/store-user/StoreUserShell.tsx
Normal file
205
src/features/store-user/StoreUserShell.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user