Initial commit
This commit is contained in:
539
src/features/auth/LoginPage.tsx
Normal file
539
src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,539 @@
|
||||
import { lazy, Suspense, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { AlertCircle, ArrowRight, Eye, EyeOff, Loader2, Lock, Mail, Sparkles } from 'lucide-react';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { HOME_ROUTE } from '@/auth/roles';
|
||||
import { PasswordSetupRequiredError } from '@/auth/session';
|
||||
import type { ConsoleRole, SessionUser } from '@/auth/roles';
|
||||
|
||||
/**
|
||||
* The demo panel is loaded lazily behind the build-time DEV constant, so in a
|
||||
* production build this expression is `null`, the import is dead code, and
|
||||
* neither the panel nor the accounts behind it are emitted at all.
|
||||
*/
|
||||
const DemoPanel = import.meta.env.DEV
|
||||
? lazy(() => import('@/demo/DemoPanel').then((m) => ({ default: m.DemoPanel })))
|
||||
: null;
|
||||
|
||||
/**
|
||||
* Sign-in — KROW's full-bleed auth archetype.
|
||||
*
|
||||
* A single 1024px card at 28px radius over the ambient canvas, split two-up:
|
||||
* a tinted brand panel on the left carrying an inset product image, and the
|
||||
* form on the right at a 448px measure. Rhythm is 24px between regions, 16px
|
||||
* between fields, 6px inside a field.
|
||||
*
|
||||
* One form for all three roles. The old console had three separate login paths
|
||||
* because the backend compares a client-declared roleid against the stored one;
|
||||
* here the role is read off the login response, so a person signs in once and
|
||||
* lands where their account says they belong.
|
||||
*/
|
||||
export function LoginPage() {
|
||||
const { user, signIn, signInAsDemo } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [isRemembered, setIsRemembered] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const session = await signIn(email, password);
|
||||
navigate(HOME_ROUTE[session.role], { replace: true });
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof PasswordSetupRequiredError
|
||||
? 'This account has no password set yet. An administrator needs to set one before you can sign in.'
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: 'Sign-in failed',
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = email.trim() !== '' && password !== '' && !isBusy;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="login-shell"
|
||||
style={{ minHeight: '100dvh', display: 'grid', placeItems: 'center' }}
|
||||
>
|
||||
<div
|
||||
className="login-split"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 1024,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-line)',
|
||||
borderRadius: 28,
|
||||
boxShadow: '0 24px 56px -12px rgb(15 23 42 / .10), 0 8px 20px -8px rgb(15 23 42 / .05)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<BrandPanel />
|
||||
<FormPanel
|
||||
email={email}
|
||||
password={password}
|
||||
isPasswordVisible={isPasswordVisible}
|
||||
isRemembered={isRemembered}
|
||||
error={error}
|
||||
isBusy={isBusy}
|
||||
canSubmit={canSubmit}
|
||||
onEmail={setEmail}
|
||||
onPassword={setPassword}
|
||||
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
||||
onToggleRemember={() => setIsRemembered((remembered) => !remembered)}
|
||||
onSubmit={handleSubmit}
|
||||
onDemo={(role, session) => {
|
||||
signInAsDemo(session);
|
||||
navigate(HOME_ROUTE[role], { replace: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Left — the brand panel
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A tint, not a slab.
|
||||
*
|
||||
* The reference panel is a soft wash rather than a saturated block, so the card
|
||||
* reads as one surface with a warm side rather than two glued halves. The
|
||||
* gradient runs from the ambient canvas's violet stop into its warm stop, which
|
||||
* is what ties the card to the page behind it.
|
||||
*/
|
||||
function BrandPanel() {
|
||||
return (
|
||||
<div
|
||||
className="login-brand"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
background: 'linear-gradient(155deg, #F4EEF8 0%, #F7F3F9 45%, #FBF8F2 100%)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<img
|
||||
src="/logo-wordmark.png"
|
||||
alt="Nearle"
|
||||
style={{ height: 28, width: 'auto', display: 'block' }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
background: 'var(--color-brand)',
|
||||
color: '#fff',
|
||||
borderRadius: 999,
|
||||
padding: '3px 10px',
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.1em',
|
||||
}}
|
||||
>
|
||||
CONSOLE
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* The inset image card: white, 8px inset, hairline border, soft shadow.
|
||||
Hidden on a phone, where it would push the form below the fold. */}
|
||||
<div
|
||||
className="login-hero"
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
maxWidth: 320,
|
||||
width: '100%',
|
||||
background: '#fff',
|
||||
border: '1px solid var(--color-line)',
|
||||
borderRadius: 20,
|
||||
padding: 8,
|
||||
boxShadow: '0 4px 16px -2px rgb(16 24 32 / .06), 0 2px 6px -3px rgb(16 24 32 / .04)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
background: 'var(--color-brand)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
aspectRatio: '1',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/logo-512.png"
|
||||
alt=""
|
||||
width={280}
|
||||
height={280}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
color: 'var(--color-brand)',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.11em',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
Retail operations platform
|
||||
</div>
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 24,
|
||||
lineHeight: 1.28,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.015em',
|
||||
color: 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
Every store, every till, one console.
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: '10px 0 0',
|
||||
fontSize: 13.5,
|
||||
lineHeight: 1.65,
|
||||
color: 'var(--color-ink-3)',
|
||||
maxWidth: 380,
|
||||
}}
|
||||
>
|
||||
Onboard tenants and branches, publish the catalogue, and watch online orders and counter
|
||||
sales land side by side.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Right — the form
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface FormPanelProps {
|
||||
email: string;
|
||||
password: string;
|
||||
isPasswordVisible: boolean;
|
||||
isRemembered: boolean;
|
||||
error: string | null;
|
||||
isBusy: boolean;
|
||||
canSubmit: boolean;
|
||||
onEmail: (value: string) => void;
|
||||
onPassword: (value: string) => void;
|
||||
onToggleVisible: () => void;
|
||||
onToggleRemember: () => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onDemo: (role: ConsoleRole, session: SessionUser) => void;
|
||||
}
|
||||
|
||||
function FormPanel({
|
||||
email,
|
||||
password,
|
||||
isPasswordVisible,
|
||||
isRemembered,
|
||||
error,
|
||||
isBusy,
|
||||
canSubmit,
|
||||
onEmail,
|
||||
onPassword,
|
||||
onToggleVisible,
|
||||
onToggleRemember,
|
||||
onSubmit,
|
||||
onDemo,
|
||||
}: FormPanelProps) {
|
||||
return (
|
||||
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
|
||||
<div style={{ width: '100%', maxWidth: 448, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 26,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
Welcome back
|
||||
</h1>
|
||||
<p style={{ margin: '6px 0 0', fontSize: 13.5, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
|
||||
Sign in to manage tenants, branches, catalogue and counter sales.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Field label="Work email" htmlFor="email" icon={<Mail size={15} />}>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => onEmail(event.target.value)}
|
||||
placeholder="you@company.com"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Password"
|
||||
htmlFor="password"
|
||||
icon={<Lock size={15} />}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleVisible}
|
||||
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: 28,
|
||||
height: 28,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: 8,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-4)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<input
|
||||
id="password"
|
||||
type={isPasswordVisible ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(event) => onPassword(event.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
style={{ ...inputStyle, paddingRight: 40 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: 'var(--color-ink-2)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isRemembered}
|
||||
onChange={onToggleRemember}
|
||||
style={{ width: 15, height: 15, accentColor: 'var(--color-brand)', cursor: 'pointer' }}
|
||||
/>
|
||||
Keep me signed in
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
padding: 0,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-brand)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 9,
|
||||
padding: '11px 13px',
|
||||
borderRadius: 12,
|
||||
background: 'var(--color-error-muted, #FCEEEE)',
|
||||
border: '1px solid rgba(214,69,69,.22)',
|
||||
color: '#B03434',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} style={{ flex: 'none', marginTop: 1 }} />
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 9,
|
||||
height: 46,
|
||||
width: '100%',
|
||||
borderRadius: 14,
|
||||
border: 0,
|
||||
background: canSubmit ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 45%, #fff)',
|
||||
color: '#fff',
|
||||
fontSize: 14.5,
|
||||
fontWeight: 600,
|
||||
cursor: canSubmit ? 'pointer' : 'default',
|
||||
boxShadow: canSubmit ? '0 10px 30px -6px rgb(102 37 130 / .28)' : 'none',
|
||||
transition: 'background .2s cubic-bezier(.16,1,.3,1), box-shadow .2s',
|
||||
}}
|
||||
>
|
||||
{isBusy ? (
|
||||
<>
|
||||
<Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
Signing in…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Continue
|
||||
<ArrowRight size={16} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--color-ink-4)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Use the account your administrator set up for you. Your role decides which workspace
|
||||
opens — you do not pick one.
|
||||
</p>
|
||||
|
||||
{DemoPanel ? (
|
||||
<Suspense fallback={null}>
|
||||
<DemoPanel onDemo={onDemo} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Field
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
height: 44,
|
||||
width: '100%',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface-subtle)',
|
||||
padding: '0 12px 0 38px',
|
||||
fontSize: 14,
|
||||
fontFamily: 'inherit',
|
||||
color: 'var(--color-ink-1)',
|
||||
outline: 'none',
|
||||
transition: 'border-color .2s, background .2s, box-shadow .2s',
|
||||
};
|
||||
|
||||
/**
|
||||
* A labelled field.
|
||||
*
|
||||
* The label is a small-caps overline rather than sentence case — the auth page
|
||||
* is the one screen with only two inputs on it, and the extra weight there
|
||||
* reads as deliberate rather than shouty.
|
||||
*/
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
icon,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
icon: ReactNode;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.09em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--color-ink-3)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 13,
|
||||
display: 'flex',
|
||||
color: 'var(--color-ink-4)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
{children}
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
18
src/features/nearle-admin/NearleAdminShell.tsx
Normal file
18
src/features/nearle-admin/NearleAdminShell.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { AppShell, type NavEntry } from '@/components/shell/AppShell';
|
||||
|
||||
/**
|
||||
* Nearle Admin — the platform workspace.
|
||||
*
|
||||
* No "Onboard branch" here on purpose. Opening an outlet is the merchant's
|
||||
* decision about their own business, so it sits in the Store Admin workspace.
|
||||
* We onboard the tenant; they onboard their branches.
|
||||
*/
|
||||
const NAV: readonly NavEntry[] = [
|
||||
{ to: '/nearle/stores', label: 'Stores' },
|
||||
{ to: '/nearle/onboard/tenant', label: 'Onboard tenant' },
|
||||
{ to: '/nearle/catalogue', label: 'Global catalogue' },
|
||||
];
|
||||
|
||||
export function NearleAdminShell() {
|
||||
return <AppShell nav={NAV} home="/nearle/stores" navLabel="Nearle Admin" />;
|
||||
}
|
||||
181
src/features/nearle-admin/import/ImportDrawer.tsx
Normal file
181
src/features/nearle-admin/import/ImportDrawer.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
|
||||
import { useProductCategories } from '@/queries/hooks';
|
||||
|
||||
export interface ImportDrawerProps {
|
||||
product: CatalogueProduct;
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
isSaving: boolean;
|
||||
error: unknown;
|
||||
onClose: () => void;
|
||||
onSubmit: (row: ImportCatalogueProductRequest) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five things the catalogue cannot supply.
|
||||
*
|
||||
* The global catalogue carries a price RANGE and a free-text category, and
|
||||
* neither maps onto a tenant's own pricing or category tree. So category,
|
||||
* subcategory, retail price, cost and tax are collected here before Import can
|
||||
* be enabled — an import with them missing produces a product a store cannot
|
||||
* legally sell.
|
||||
*/
|
||||
export function ImportDrawer({
|
||||
product,
|
||||
tenantid,
|
||||
locationid,
|
||||
isSaving,
|
||||
error,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: ImportDrawerProps) {
|
||||
const { data: categories } = useProductCategories(tenantid);
|
||||
|
||||
const [categoryid, setCategoryid] = useState('');
|
||||
const [subcategoryid, setSubcategoryid] = useState('');
|
||||
const [retailprice, setRetailprice] = useState<number | null>(null);
|
||||
const [productcost, setProductcost] = useState<number | null>(null);
|
||||
const [taxpercent, setTaxpercent] = useState<number | null>(0);
|
||||
const [quantity, setQuantity] = useState<number | null>(0);
|
||||
|
||||
const categoryOptions = (categories ?? []).map((entry) => ({
|
||||
value: String(entry.categoryid),
|
||||
label: entry.categoryname,
|
||||
}));
|
||||
|
||||
const isComplete =
|
||||
categoryid !== '' &&
|
||||
typeof retailprice === 'number' &&
|
||||
retailprice > 0 &&
|
||||
typeof productcost === 'number' &&
|
||||
typeof taxpercent === 'number';
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
className="dialog-panel"
|
||||
padding={0}
|
||||
>
|
||||
<DialogHeader title="Add to store" subtitle="Set what this store will sell it for" onOpenChange={(open) => (open ? undefined : onClose())} hasDivider />
|
||||
<VStack gap={3} padding={3}>
|
||||
<HStack gap={2} align="start">
|
||||
<div
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 12,
|
||||
background: 'var(--color-slate-50)',
|
||||
flex: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{product.images?.[0] ? (
|
||||
<img
|
||||
src={product.images[0]}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<VStack gap={0.5}>
|
||||
<Text type="large" weight="semibold">
|
||||
{product.product_name}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{product.brand} · id {product.id} · {product.size ?? '—'}
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Catalogue range {product.price_range ?? '—'} — a guide, not a price. What you set below
|
||||
is what the store sells at.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label={<span>Category <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
options={categoryOptions}
|
||||
value={categoryid}
|
||||
onChange={setCategoryid}
|
||||
placeholder="Select category…"
|
||||
description="The tenant's own category, not the catalogue's."
|
||||
hasSearch
|
||||
/>
|
||||
<Selector
|
||||
label="Subcategory"
|
||||
options={[{ value: '', label: 'None' }]}
|
||||
value={subcategoryid}
|
||||
onChange={setSubcategoryid}
|
||||
placeholder="Select subcategory…"
|
||||
isDisabled={categoryid === ''}
|
||||
/>
|
||||
<NumberInput label={<span>Retail price (₹) <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={retailprice} onChange={setRetailprice} min={0} />
|
||||
<NumberInput label={<span>Cost (₹) <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={productcost} onChange={setProductcost} min={0} />
|
||||
<NumberInput label="Tax (%)" value={taxpercent} onChange={setTaxpercent} min={0} max={100} />
|
||||
<NumberInput
|
||||
label="Opening stock"
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
min={0}
|
||||
description="Re-importing adds to this rather than replacing it."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<HStack
|
||||
align="center"
|
||||
gap={1}
|
||||
padding={2}
|
||||
style={{
|
||||
background: 'var(--color-error-muted, #fceeee)',
|
||||
borderRadius: 12,
|
||||
color: 'var(--color-error, #d64545)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} />
|
||||
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||
{errorMessage(error)}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack justify="end" gap={1.5}>
|
||||
<Button label="Cancel" variant="secondary" onClick={onClose} />
|
||||
<Button
|
||||
label={isSaving ? 'Adding…' : 'Add to store'}
|
||||
variant="primary"
|
||||
isLoading={isSaving}
|
||||
isDisabled={!isComplete}
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
tenantid,
|
||||
locationid,
|
||||
brand: product.brand,
|
||||
catalogueid: product.id,
|
||||
categoryid: Number(categoryid),
|
||||
subcategoryid: subcategoryid === '' ? 0 : Number(subcategoryid),
|
||||
quantity: quantity ?? 0,
|
||||
stocktype: 'in',
|
||||
status: 'available',
|
||||
retailprice: retailprice ?? 0,
|
||||
productcost: productcost ?? 0,
|
||||
taxpercent: taxpercent ?? 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
320
src/features/nearle-admin/import/SheetImportPanel.tsx
Normal file
320
src/features/nearle-admin/import/SheetImportPanel.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { FileInput } from '@astryxdesign/core/FileInput';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet } from 'lucide-react';
|
||||
import { importSheetProducts, type SheetImportResult, type SheetProductRow } from '@/api/products';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { downloadTemplate, parseProductSheet, type ParsedSheet } from './parseProductSheet';
|
||||
|
||||
interface PreviewRow extends Record<string, unknown> {
|
||||
productname: string;
|
||||
productsku: string;
|
||||
categoryid: number;
|
||||
retailprice: number;
|
||||
productcost: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface SheetImportPanelProps {
|
||||
tenantid: number | undefined;
|
||||
locationid: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The spreadsheet import path.
|
||||
*
|
||||
* Staged rather than one-shot — upload, parse, show what is wrong, then commit —
|
||||
* because the underlying calls are N creates with no transaction and no batch,
|
||||
* so a row that fails on the server fails alone and has to be recoverable.
|
||||
*
|
||||
* It is also not idempotent, and cannot be made so from the client: nothing on
|
||||
* the server dedupes on SKU. Re-uploading the same file creates the products
|
||||
* twice, so that is said out loud before the button rather than discovered
|
||||
* afterwards.
|
||||
*/
|
||||
export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
const [result, setResult] = useState<SheetImportResult | null>(null);
|
||||
|
||||
const hasTarget = Boolean(tenantid && locationid);
|
||||
|
||||
async function handleFile(next: File | File[] | null) {
|
||||
const chosen = Array.isArray(next) ? (next[0] ?? null) : next;
|
||||
setFile(chosen);
|
||||
setParsed(null);
|
||||
setParseError(null);
|
||||
setResult(null);
|
||||
if (!chosen) return;
|
||||
|
||||
try {
|
||||
setParsed(await parseProductSheet(chosen));
|
||||
} catch (cause) {
|
||||
setParseError(errorMessage(cause));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!parsed || !tenantid || !locationid) return;
|
||||
setProgress({ done: 0, total: parsed.rows.length });
|
||||
try {
|
||||
const outcome = await importSheetProducts({
|
||||
tenantid,
|
||||
locationid,
|
||||
rows: parsed.rows,
|
||||
onProgress: (done, total) => setProgress({ done, total }),
|
||||
});
|
||||
setResult(outcome);
|
||||
} catch (cause) {
|
||||
setParseError(errorMessage(cause));
|
||||
} finally {
|
||||
setProgress(null);
|
||||
}
|
||||
}
|
||||
|
||||
const previewColumns: TableColumn<PreviewRow>[] = [
|
||||
{
|
||||
key: 'productname',
|
||||
header: 'Product',
|
||||
width: { type: 'proportional', value: 3 },
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.productname}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{row.productsku}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'categoryid',
|
||||
header: 'Category',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => <Text type="body" size="sm" hasTabularNumbers>{row.categoryid}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'retailprice',
|
||||
header: 'Retail',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => (
|
||||
<Text type="label" size="sm" hasTabularNumbers>
|
||||
₹{row.retailprice}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'productcost',
|
||||
header: 'Cost',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" color="secondary" hasTabularNumbers>
|
||||
₹{row.productcost}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'quantity',
|
||||
header: 'Opening stock',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 130 },
|
||||
renderCell: (row) => <Text type="body" size="sm" hasTabularNumbers>{row.quantity}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
const preview: PreviewRow[] = (parsed?.rows ?? []).slice(0, 25).map((row: SheetProductRow) => ({
|
||||
productname: row.productname,
|
||||
productsku: row.productsku,
|
||||
categoryid: row.categoryid,
|
||||
retailprice: row.retailprice,
|
||||
productcost: row.productcost,
|
||||
quantity: row.quantity,
|
||||
}));
|
||||
|
||||
if (result) {
|
||||
const isClean = result.failures.length === 0;
|
||||
return (
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
{isClean ? (
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
) : (
|
||||
<AlertTriangle size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
||||
)}
|
||||
<Text type="large" weight="semibold">
|
||||
{result.created} product{result.created === 1 ? '' : 's'} imported
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<Text type="body" color="secondary">
|
||||
{result.linked} linked to the store, {result.stocked} given opening stock.
|
||||
{isClean ? '' : ` ${result.failures.length} row(s) did not make it.`}
|
||||
</Text>
|
||||
|
||||
{!isClean ? (
|
||||
<VStack gap={1}>
|
||||
{result.failures.slice(0, 10).map((failure, index) => (
|
||||
<HStack key={`${failure.row.productsku}-${index}`} gap={1} align="center">
|
||||
<Badge variant="error" label={failure.row.productsku || '—'} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{failure.reason}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<HStack>
|
||||
<Button
|
||||
label="Import another file"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
setParsed(null);
|
||||
setFile(null);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={2}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Upload the tenant's product list"
|
||||
note="parsed in the browser, then written row by row"
|
||||
action={
|
||||
<Button
|
||||
label="Download template"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Download size={14} />}
|
||||
onClick={() => void downloadTemplate()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
label="Product workbook"
|
||||
value={file}
|
||||
onChange={handleFile}
|
||||
accept=".xlsx,.xls,.csv"
|
||||
mode="dropzone"
|
||||
placeholder="Drop an .xlsx or .csv here"
|
||||
description="Required columns: productname, productsku, categoryid, retailprice, productcost. The catalogue's category names do not map to a tenant's own ids, so categoryid must be in the file."
|
||||
isDisabled={!hasTarget}
|
||||
/>
|
||||
|
||||
{parseError ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{parseError}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{parsed ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="What is in the file"
|
||||
note={`${parsed.totalRows} rows read · ${parsed.rows.length} ready · ${parsed.issues.length} to fix`}
|
||||
/>
|
||||
|
||||
{parsed.unmappedColumns.length > 0 ? (
|
||||
<HStack gap={1} wrap="wrap" align="center">
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Ignored columns:
|
||||
</Text>
|
||||
{parsed.unmappedColumns.map((column) => (
|
||||
<Badge key={column} variant="neutral" label={column} />
|
||||
))}
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
{parsed.issues.length > 0 ? (
|
||||
<VStack
|
||||
gap={0.5}
|
||||
padding={2}
|
||||
style={{ background: 'var(--color-warning-muted, #fdf6e3)', borderRadius: 12 }}
|
||||
>
|
||||
<HStack align="center" gap={1}>
|
||||
<AlertTriangle size={15} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
||||
<Text type="label" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
||||
{parsed.issues.length} row{parsed.issues.length === 1 ? '' : 's'} will be skipped
|
||||
</Text>
|
||||
</HStack>
|
||||
{parsed.issues.slice(0, 8).map((issue, index) => (
|
||||
<Text key={index} type="body" size="xsm" color="secondary">
|
||||
Row {issue.line} · {issue.field}: {issue.message}
|
||||
</Text>
|
||||
))}
|
||||
{parsed.issues.length > 8 ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
…and {parsed.issues.length - 8} more.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{preview.length > 0 ? (
|
||||
<Table<PreviewRow>
|
||||
data={preview}
|
||||
columns={previewColumns}
|
||||
idKey="productsku"
|
||||
density="compact"
|
||||
dividers="rows"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{progress ? (
|
||||
<ProgressBar
|
||||
label="Importing"
|
||||
value={progress.done}
|
||||
max={progress.total}
|
||||
hasValueLabel
|
||||
formatValueLabel={(value, max) => `${value} of ${max}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<HStack align="center" gap={1}>
|
||||
<FileSpreadsheet size={15} style={{ color: 'var(--color-slate-400)' }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Nothing dedupes on SKU — importing this file twice creates every product twice.
|
||||
</Text>
|
||||
</HStack>
|
||||
<Button
|
||||
label={progress ? 'Importing…' : `Import ${parsed.rows.length} products`}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={Boolean(progress)}
|
||||
isDisabled={!hasTarget || parsed.rows.length === 0}
|
||||
onClick={handleImport}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
224
src/features/nearle-admin/import/parseProductSheet.ts
Normal file
224
src/features/nearle-admin/import/parseProductSheet.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Parsing and validating an uploaded product workbook.
|
||||
*
|
||||
* All of this happens in the browser: the backend does no spreadsheet handling
|
||||
* at all, so a sheet becomes N create calls, and every row that is going to fail
|
||||
* should be caught here rather than halfway through those calls.
|
||||
*/
|
||||
|
||||
import type { SheetProductRow } from '@/api/products';
|
||||
|
||||
/**
|
||||
* `xlsx` is around 400kB and only the spreadsheet path ever touches it, so it is
|
||||
* imported on demand rather than shipped to everyone who opens the console.
|
||||
*/
|
||||
async function loadXlsx() {
|
||||
return import('xlsx');
|
||||
}
|
||||
|
||||
/** What a column may be called in the wild, lowercased and stripped. */
|
||||
const COLUMN_ALIASES: Record<keyof SheetProductRow, string[]> = {
|
||||
productname: ['productname', 'product', 'name', 'itemname', 'description'],
|
||||
productsku: ['productsku', 'sku', 'code', 'itemcode', 'barcode'],
|
||||
categoryid: ['categoryid', 'category'],
|
||||
subcategoryid: ['subcategoryid', 'subcategory'],
|
||||
retailprice: ['retailprice', 'price', 'mrp', 'sellingprice'],
|
||||
productcost: ['productcost', 'cost', 'purchaseprice', 'costprice'],
|
||||
taxpercent: ['taxpercent', 'tax', 'gst', 'gstpercent'],
|
||||
quantity: ['quantity', 'qty', 'stock', 'openingstock'],
|
||||
productunit: ['productunit', 'unit', 'uom'],
|
||||
unitvalue: ['unitvalue', 'size', 'packsize'],
|
||||
productbrand: ['productbrand', 'brand'],
|
||||
productdesc: ['productdesc', 'productdescription', 'details'],
|
||||
};
|
||||
|
||||
export interface RowIssue {
|
||||
/** 1-based, and counting the header — the number the user sees in Excel. */
|
||||
line: number;
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ParsedSheet {
|
||||
rows: SheetProductRow[];
|
||||
issues: RowIssue[];
|
||||
/** Header cells that matched nothing. Surfaced so a rename is obvious. */
|
||||
unmappedColumns: string[];
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
function normalise(header: string): string {
|
||||
return header.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const cleaned = value.replace(/[^0-9.-]/g, '');
|
||||
if (cleaned === '') return null;
|
||||
const parsed = Number(cleaned);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the first sheet of a workbook into validated rows.
|
||||
*
|
||||
* Every problem is collected rather than thrown on the first one: an importer
|
||||
* that stops at row 3 of 400 makes the user run it 400 times to find out what
|
||||
* is wrong with their file.
|
||||
*/
|
||||
export async function parseProductSheet(file: File): Promise<ParsedSheet> {
|
||||
const XLSX = await loadXlsx();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(buffer, { type: 'array' });
|
||||
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
if (!firstSheetName) {
|
||||
return { rows: [], issues: [], unmappedColumns: [], totalRows: 0 };
|
||||
}
|
||||
|
||||
const sheet = workbook.Sheets[firstSheetName];
|
||||
if (!sheet) return { rows: [], issues: [], unmappedColumns: [], totalRows: 0 };
|
||||
|
||||
const raw = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' });
|
||||
if (raw.length === 0) {
|
||||
return { rows: [], issues: [], unmappedColumns: [], totalRows: 0 };
|
||||
}
|
||||
|
||||
// Map the file's headers onto our field names once, not per row.
|
||||
const headers = Object.keys(raw[0] ?? {});
|
||||
const headerToField = new Map<string, keyof SheetProductRow>();
|
||||
const matchedHeaders = new Set<string>();
|
||||
|
||||
for (const header of headers) {
|
||||
const key = normalise(header);
|
||||
for (const [field, aliases] of Object.entries(COLUMN_ALIASES)) {
|
||||
if (aliases.includes(key)) {
|
||||
headerToField.set(header, field as keyof SheetProductRow);
|
||||
matchedHeaders.add(header);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unmappedColumns = headers.filter((header) => !matchedHeaders.has(header));
|
||||
const rows: SheetProductRow[] = [];
|
||||
const issues: RowIssue[] = [];
|
||||
|
||||
raw.forEach((record, index) => {
|
||||
const line = index + 2; // +1 for zero-index, +1 for the header row
|
||||
const picked: Partial<Record<keyof SheetProductRow, unknown>> = {};
|
||||
|
||||
for (const [header, field] of headerToField) {
|
||||
picked[field] = record[header];
|
||||
}
|
||||
|
||||
const productname = String(picked.productname ?? '').trim();
|
||||
const productsku = String(picked.productsku ?? '').trim();
|
||||
const retailprice = toNumber(picked.retailprice);
|
||||
const productcost = toNumber(picked.productcost);
|
||||
const taxpercent = toNumber(picked.taxpercent) ?? 0;
|
||||
const quantity = toNumber(picked.quantity) ?? 0;
|
||||
const categoryid = toNumber(picked.categoryid) ?? 0;
|
||||
const subcategoryid = toNumber(picked.subcategoryid) ?? 0;
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (!productname) {
|
||||
issues.push({ line, field: 'productname', message: 'Product name is missing' });
|
||||
isValid = false;
|
||||
}
|
||||
if (!productsku) {
|
||||
// Without a SKU the created product cannot be found again, and the
|
||||
// create endpoint does not return its new id — so the row would import
|
||||
// and then be impossible to link to the store.
|
||||
issues.push({ line, field: 'productsku', message: 'SKU is missing — the row cannot be linked to a store without one' });
|
||||
isValid = false;
|
||||
}
|
||||
if (retailprice === null || retailprice <= 0) {
|
||||
issues.push({ line, field: 'retailprice', message: 'Retail price is missing or not a number' });
|
||||
isValid = false;
|
||||
}
|
||||
if (productcost === null) {
|
||||
issues.push({ line, field: 'productcost', message: 'Cost is missing or not a number' });
|
||||
isValid = false;
|
||||
}
|
||||
if (categoryid <= 0) {
|
||||
issues.push({ line, field: 'categoryid', message: 'Category id is missing — the tenant has no mapping from a category name' });
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!isValid) return;
|
||||
|
||||
rows.push({
|
||||
productname,
|
||||
productsku,
|
||||
categoryid,
|
||||
subcategoryid,
|
||||
retailprice: retailprice as number,
|
||||
productcost: productcost as number,
|
||||
taxpercent,
|
||||
quantity,
|
||||
productunit: String(picked.productunit ?? '').trim() || undefined,
|
||||
unitvalue: String(picked.unitvalue ?? '').trim() || undefined,
|
||||
productbrand: String(picked.productbrand ?? '').trim() || undefined,
|
||||
productdesc: String(picked.productdesc ?? '').trim() || undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// Duplicate SKUs inside one file are the user's own error and will produce
|
||||
// duplicate products, since nothing on the server dedupes.
|
||||
const seen = new Set<string>();
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (seen.has(row.productsku)) {
|
||||
issues.push({
|
||||
line: index + 2,
|
||||
field: 'productsku',
|
||||
message: `SKU ${row.productsku} appears more than once in this file`,
|
||||
});
|
||||
}
|
||||
seen.add(row.productsku);
|
||||
}
|
||||
|
||||
return { rows, issues, unmappedColumns, totalRows: raw.length };
|
||||
}
|
||||
|
||||
/** The template handed to a tenant, so the columns arrive named correctly. */
|
||||
export async function downloadTemplate(): Promise<void> {
|
||||
const XLSX = await loadXlsx();
|
||||
const headers = [
|
||||
'productname',
|
||||
'productsku',
|
||||
'categoryid',
|
||||
'subcategoryid',
|
||||
'retailprice',
|
||||
'productcost',
|
||||
'taxpercent',
|
||||
'quantity',
|
||||
'productunit',
|
||||
'unitvalue',
|
||||
'productbrand',
|
||||
'productdesc',
|
||||
];
|
||||
const example = {
|
||||
productname: 'Aachi Chilli Powder 100g',
|
||||
productsku: 'AACHI-CHILLI-100',
|
||||
categoryid: 1,
|
||||
subcategoryid: 0,
|
||||
retailprice: 35,
|
||||
productcost: 28,
|
||||
taxpercent: 5,
|
||||
quantity: 24,
|
||||
productunit: 'g',
|
||||
unitvalue: '100',
|
||||
productbrand: 'Aachi',
|
||||
productdesc: '',
|
||||
};
|
||||
|
||||
const sheet = XLSX.utils.json_to_sheet([example], { header: headers });
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, 'Products');
|
||||
XLSX.writeFile(workbook, 'nearle-product-template.xlsx');
|
||||
}
|
||||
295
src/features/nearle-admin/pages/GlobalCataloguePage.tsx
Normal file
295
src/features/nearle-admin/pages/GlobalCataloguePage.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Check, PackagePlus, Upload } from 'lucide-react';
|
||||
import { catalogueKey } from '@/api/catalogue';
|
||||
import { productsApi } from '@/api/products';
|
||||
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import {
|
||||
useCatalogueBrands,
|
||||
useCatalogueProducts,
|
||||
useImportedRefs,
|
||||
useTenants,
|
||||
} from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { ImportDrawer } from '../import/ImportDrawer';
|
||||
import { SheetImportPanel } from '../import/SheetImportPanel';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
|
||||
/**
|
||||
* The global catalogue browser, and the place both import paths start.
|
||||
*
|
||||
* The list loads with NO brand filter — brand, category and keyword are filters
|
||||
* applied on top of an already-visible list, not a prerequisite to seeing one.
|
||||
* Products already imported by the selected tenant are badged rather than
|
||||
* hidden, because re-importing tops up stock and is a legitimate thing to do.
|
||||
*/
|
||||
export function GlobalCataloguePage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const tenantid = Number(params.get('tenantid') ?? 0) || undefined;
|
||||
const locationid = Number(params.get('locationid') ?? 0) || undefined;
|
||||
|
||||
const [brand, setBrand] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [selected, setSelected] = useState<CatalogueProduct | null>(null);
|
||||
const [mode, setMode] = useState<'catalogue' | 'sheet'>('catalogue');
|
||||
|
||||
const { data: tenants } = useTenants();
|
||||
const { data: brands } = useCatalogueBrands();
|
||||
const {
|
||||
data: products,
|
||||
isLoading,
|
||||
error,
|
||||
} = useCatalogueProducts({ brand: brand || undefined, keyword: keyword || undefined, pagesize: 48 });
|
||||
const { data: importedRefs } = useImportedRefs(tenantid);
|
||||
|
||||
/** `brand:catalogueid` — both halves, because a bare id repeats across brands. */
|
||||
const importedKeys = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const ref of importedRefs ?? []) set.add(catalogueKey(ref));
|
||||
return set;
|
||||
}, [importedRefs]);
|
||||
|
||||
const tenantOptions = useMemo(() => {
|
||||
if (!tenants) return [];
|
||||
const seen = new Map<number, { name: string; locationid: number }>();
|
||||
for (const tenant of tenants as TenantInfo[]) {
|
||||
if (!seen.has(tenant.tenantid)) {
|
||||
seen.set(tenant.tenantid, { name: tenant.tenantname, locationid: tenant.locationid });
|
||||
}
|
||||
}
|
||||
return [...seen.entries()].map(([id, value]) => ({ value: String(id), label: value.name }));
|
||||
}, [tenants]);
|
||||
|
||||
const brandOptions = useMemo(
|
||||
() => [
|
||||
{ value: '', label: 'All brands' },
|
||||
...(brands ?? []).map((entry) => ({
|
||||
value: entry.brand,
|
||||
label: `${entry.brand} (${entry.product_count})`,
|
||||
})),
|
||||
],
|
||||
[brands],
|
||||
);
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (rows: ImportCatalogueProductRequest[]) => productsApi.importFromCatalogue(rows),
|
||||
onSuccess: async () => {
|
||||
// Both the imported-refs lookup and the store catalogue go stale together.
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.catalogue.all }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.products.all }),
|
||||
]);
|
||||
setSelected(null);
|
||||
},
|
||||
});
|
||||
|
||||
function chooseTenant(value: string) {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('tenantid', value);
|
||||
const tenant = (tenants as TenantInfo[] | undefined)?.find(
|
||||
(entry) => String(entry.tenantid) === value,
|
||||
);
|
||||
if (tenant) next.set('locationid', String(tenant.locationid));
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
const hasTarget = Boolean(tenantid && locationid);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Global catalogue"
|
||||
count={products ? `${products.length} shown` : undefined}
|
||||
description="Browse the FMCG catalogue and stock a store from it — or upload the tenant's own product list instead."
|
||||
actions={
|
||||
<HStack gap={1}>
|
||||
<Button
|
||||
label="From catalogue"
|
||||
variant={mode === 'catalogue' ? 'primary' : 'secondary'}
|
||||
icon={<PackagePlus size={15} />}
|
||||
onClick={() => setMode('catalogue')}
|
||||
/>
|
||||
<Button
|
||||
label="From spreadsheet"
|
||||
variant={mode === 'sheet' ? 'primary' : 'secondary'}
|
||||
icon={<Upload size={15} />}
|
||||
onClick={() => setMode('sheet')}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Which store is being stocked. Every import call needs both ids, so this
|
||||
is asked once, up front, rather than per product. */}
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader title="Stocking" note="which store these products go to" />
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label="Tenant"
|
||||
options={tenantOptions}
|
||||
value={tenantid ? String(tenantid) : ''}
|
||||
onChange={chooseTenant}
|
||||
placeholder="Select tenant…"
|
||||
hasSearch
|
||||
/>
|
||||
<TextInput
|
||||
label="Location id"
|
||||
value={locationid ? String(locationid) : ''}
|
||||
onChange={(value) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('locationid', value);
|
||||
setParams(next, { replace: true });
|
||||
}}
|
||||
description="Defaults to the tenant's first outlet."
|
||||
/>
|
||||
</div>
|
||||
{!hasTarget ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
||||
Pick a tenant before importing — every import call is scoped to a tenant and a
|
||||
location, and the backend rejects one without both.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{mode === 'sheet' ? (
|
||||
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
|
||||
) : (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Products"
|
||||
note="filters narrow an already-visible list"
|
||||
action={
|
||||
<HStack gap={1} wrap="wrap">
|
||||
<div style={{ width: 190 }}>
|
||||
<Selector
|
||||
label="Brand"
|
||||
isLabelHidden
|
||||
options={brandOptions}
|
||||
value={brand}
|
||||
onChange={setBrand}
|
||||
placeholder="All brands"
|
||||
hasSearch
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 240 }}>
|
||||
<TextInput
|
||||
label="Search catalogue"
|
||||
isLabelHidden
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search catalogue…"
|
||||
hasClear
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataState
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
isEmpty={(products ?? []).length === 0}
|
||||
emptyTitle="Nothing matches those filters"
|
||||
emptyDescription="Clear the brand or keyword to see the full catalogue."
|
||||
>
|
||||
<div className="card-grid">
|
||||
{(products ?? []).map((product) => {
|
||||
const isImported = importedKeys.has(catalogueKey(product));
|
||||
return (
|
||||
<Card key={`${product.brand}:${product.id}`} padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<div
|
||||
style={{
|
||||
aspectRatio: '1',
|
||||
background: 'var(--color-slate-50)',
|
||||
borderRadius: 12,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{product.images?.[0] ? (
|
||||
<img
|
||||
src={product.images[0]}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<PackagePlus size={26} style={{ color: 'var(--color-slate-300)' }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold" maxLines={2}>
|
||||
{product.product_name}
|
||||
</Text>
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
{product.brand} · {product.size ?? '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<HStack justify="between" align="center">
|
||||
{/* A RANGE, not a price. The store sets the real one at import. */}
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{product.price_range ?? '—'}
|
||||
</Text>
|
||||
{isImported ? (
|
||||
<Badge variant="success" label="Imported" icon={<Check size={11} />} />
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
label={isImported ? 'Top up stock' : 'Add to store'}
|
||||
variant={isImported ? 'secondary' : 'primary'}
|
||||
size="sm"
|
||||
width="100%"
|
||||
isDisabled={!hasTarget}
|
||||
onClick={() => setSelected(product)}
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DataState>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{selected && hasTarget ? (
|
||||
<ImportDrawer
|
||||
product={selected}
|
||||
tenantid={tenantid as number}
|
||||
locationid={locationid as number}
|
||||
isSaving={importMutation.isPending}
|
||||
error={importMutation.error}
|
||||
onClose={() => setSelected(null)}
|
||||
onSubmit={(row) => importMutation.mutate([row])}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
279
src/features/nearle-admin/pages/OnboardTenantPage.tsx
Normal file
279
src/features/nearle-admin/pages/OnboardTenantPage.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertCircle, Building2, CheckCircle2, MapPin } from 'lucide-react';
|
||||
import { tenantsApi, type CreateTenantRequest } from '@/api/tenants';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { PageBody } from '@/components/PageBody';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
|
||||
const BUSINESS_CATEGORIES = [
|
||||
{ value: '1', label: 'Grocery & Daily' },
|
||||
{ value: '2', label: 'Fresh & Produce' },
|
||||
{ value: '3', label: 'Pharmacy' },
|
||||
{ value: '4', label: 'Bakery' },
|
||||
];
|
||||
|
||||
interface FormState {
|
||||
tenantname: string;
|
||||
companyname: string;
|
||||
primarycontact: string;
|
||||
primaryemail: string;
|
||||
locationname: string;
|
||||
categoryid: string;
|
||||
address: string;
|
||||
suburb: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
tenantname: '',
|
||||
companyname: '',
|
||||
primarycontact: '',
|
||||
primaryemail: '',
|
||||
locationname: '',
|
||||
categoryid: '1',
|
||||
address: '',
|
||||
suburb: '',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Provision a new merchant tenant.
|
||||
*
|
||||
* This registers the enterprise, its first outlet, and spawns the primary
|
||||
* Administrator account — account creation is a side effect of provisioning
|
||||
* here, because that is what the backend does. There is no separate invite.
|
||||
*
|
||||
* Products are NOT collected on this form. Once the tenant exists it has a
|
||||
* tenantid and a locationid, and only then can either import path run: both
|
||||
* `importcatalogueproduct` and `createproductlocation` require the pair. So the
|
||||
* form hands off to the catalogue on success rather than pretending the two
|
||||
* steps are one.
|
||||
*/
|
||||
export function OnboardTenantPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function set<K extends keyof FormState>(key: K) {
|
||||
return (value: string) => setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: CreateTenantRequest) => tenantsApi.createTenant(body),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all });
|
||||
},
|
||||
onError: (cause) => setError(errorMessage(cause)),
|
||||
});
|
||||
|
||||
const isComplete =
|
||||
form.tenantname.trim() !== '' &&
|
||||
form.companyname.trim() !== '' &&
|
||||
form.primarycontact.trim() !== '' &&
|
||||
form.primaryemail.trim() !== '' &&
|
||||
form.locationname.trim() !== '' &&
|
||||
form.address.trim() !== '' &&
|
||||
form.city.trim() !== '' &&
|
||||
form.postcode.trim() !== '';
|
||||
|
||||
function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
tenantname: form.tenantname.trim(),
|
||||
companyname: form.companyname.trim(),
|
||||
primarycontact: form.primarycontact.trim(),
|
||||
primaryemail: form.primaryemail.trim(),
|
||||
locationname: form.locationname.trim(),
|
||||
categoryid: Number(form.categoryid),
|
||||
address: form.address.trim(),
|
||||
suburb: form.suburb.trim(),
|
||||
city: form.city.trim(),
|
||||
state: form.state.trim(),
|
||||
postcode: form.postcode.trim(),
|
||||
status: 'Active',
|
||||
});
|
||||
}
|
||||
|
||||
if (mutation.isSuccess) {
|
||||
const created = mutation.data;
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Tenant provisioned"
|
||||
description="The enterprise is registered and its primary Administrator account has been created."
|
||||
/>
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
<Text type="large" weight="semibold">
|
||||
{form.tenantname} is live
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" color="secondary">
|
||||
Its first outlet, {form.locationname}, has been commissioned. The next step is
|
||||
stocking the catalogue — pick products from the global catalogue, or upload the
|
||||
tenant's own list as a spreadsheet.
|
||||
</Text>
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<Button
|
||||
label="Stock from global catalogue"
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/nearle/catalogue?tenantid=${created?.tenantid ?? ''}&locationid=${created?.locationid ?? ''}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
label="Back to stores"
|
||||
variant="secondary"
|
||||
onClick={() => navigate('/nearle/stores')}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageBody measure="reading">
|
||||
<PageHeader
|
||||
title="Onboard tenant"
|
||||
description="Register a merchant group, commission its first outlet, and create its Administrator account."
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<VStack gap={3}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Business"
|
||||
note="who the merchant is"
|
||||
action={<Building2 size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div className="form-grid">
|
||||
<TextInput
|
||||
label={<span>Merchant name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.tenantname}
|
||||
onChange={set('tenantname')}
|
||||
placeholder="e.g. Kaveri Groceries"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Company registered name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.companyname}
|
||||
onChange={set('companyname')}
|
||||
placeholder="e.g. Kaveri Retail Pvt. Ltd."
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Primary phone <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.primarycontact}
|
||||
onChange={set('primarycontact')}
|
||||
placeholder="9876543210"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Primary admin email <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
type="email"
|
||||
value={form.primaryemail}
|
||||
onChange={set('primaryemail')}
|
||||
placeholder="admin@kaveri.com"
|
||||
description="The Administrator account is created against this address."
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>First outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.locationname}
|
||||
onChange={set('locationname')}
|
||||
placeholder="e.g. Kaveri RS Puram"
|
||||
/>
|
||||
<Selector
|
||||
label="Business category"
|
||||
options={BUSINESS_CATEGORIES}
|
||||
value={form.categoryid}
|
||||
onChange={set('categoryid')}
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Head office"
|
||||
note="where the business is registered"
|
||||
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Street address <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.address}
|
||||
onChange={set('address')}
|
||||
placeholder="e.g. 12, Avinashi Road"
|
||||
/>
|
||||
<div className="form-grid-4">
|
||||
<TextInput label="Suburb" value={form.suburb} onChange={set('suburb')} placeholder="e.g. Peelamedu" />
|
||||
<TextInput label={<span>City <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.city} onChange={set('city')} />
|
||||
<TextInput label={<span>State <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.state} onChange={set('state')} />
|
||||
<TextInput
|
||||
label={<span>Postcode <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.postcode}
|
||||
onChange={set('postcode')}
|
||||
placeholder="641004"
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{error ? (
|
||||
<HStack
|
||||
align="center"
|
||||
gap={1}
|
||||
padding={2}
|
||||
style={{
|
||||
background: 'var(--color-error-muted, #fceeee)',
|
||||
borderRadius: 12,
|
||||
color: 'var(--color-error, #d64545)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={17} />
|
||||
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||
{error}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Products are added after the tenant exists — both import paths need its tenantid and
|
||||
locationid.
|
||||
</Text>
|
||||
<Button
|
||||
label={mutation.isPending ? 'Provisioning…' : 'Provision tenant'}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={mutation.isPending}
|
||||
isDisabled={!isComplete}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</form>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
273
src/features/nearle-admin/pages/StoreDetailPage.tsx
Normal file
273
src/features/nearle-admin/pages/StoreDetailPage.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { IndianRupee, PackageCheck, ShoppingCart, Store, TriangleAlert } from 'lucide-react';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { Freshness } from '@/components/Freshness';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useLocationSummary, useOrderSummary, useTenantLocations, useTenants } from '@/queries/hooks';
|
||||
import type { LocationOrderSummary, TenantInfo, TenantLocation } from '@/api/types';
|
||||
|
||||
interface BranchRow extends Record<string, unknown> {
|
||||
locationid: number;
|
||||
locationname: string;
|
||||
city: string;
|
||||
hours: string;
|
||||
radius: string;
|
||||
status: string;
|
||||
orders: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
function money(value: number | undefined): string {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return '₹0';
|
||||
return `₹${value.toLocaleString('en-IN')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One tenant: its branches, and how each is performing.
|
||||
*
|
||||
* Order figures come from `/orders/getlocationsummary`, which is scoped to the
|
||||
* tenant and returns one row per branch. Counter sales are NOT folded in here:
|
||||
* the POS endpoints take a single required locationid, so a tenant-wide till
|
||||
* figure would mean one request per branch, and a blended number would be
|
||||
* eventually consistent in a way this page cannot honestly caption. Branch-level
|
||||
* POS lives on the branch screen instead.
|
||||
*/
|
||||
export function StoreDetailPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const tenantid = Number(tenantId ?? 0) || undefined;
|
||||
|
||||
const { data: tenants } = useTenants();
|
||||
const { data: locations, isLoading, error } = useTenantLocations(tenantid);
|
||||
const summary = useLocationSummary(tenantid);
|
||||
const orders = useOrderSummary(tenantid);
|
||||
|
||||
const tenant = useMemo(
|
||||
() => (tenants as TenantInfo[] | undefined)?.find((entry) => entry.tenantid === tenantid),
|
||||
[tenants, tenantid],
|
||||
);
|
||||
|
||||
const rows = useMemo<BranchRow[]>(() => {
|
||||
const perLocation = new Map<number, LocationOrderSummary>();
|
||||
for (const entry of summary.data ?? []) {
|
||||
if (typeof entry.locationid === 'number') perLocation.set(entry.locationid, entry);
|
||||
}
|
||||
|
||||
return ((locations ?? []) as TenantLocation[]).map((branch) => {
|
||||
const stats = perLocation.get(branch.locationid);
|
||||
return {
|
||||
locationid: branch.locationid,
|
||||
locationname: branch.locationname,
|
||||
city: [branch.suburb, branch.city].filter(Boolean).join(', '),
|
||||
hours:
|
||||
branch.opentime && branch.closetime ? `${branch.opentime}–${branch.closetime}` : '—',
|
||||
radius: branch.deliveryradius ? `${(branch.deliveryradius / 1000).toFixed(1)} km` : '—',
|
||||
status: branch.status ?? 'Unknown',
|
||||
orders: Number(stats?.totalorders ?? 0),
|
||||
revenue: Number(stats?.revenue ?? 0),
|
||||
};
|
||||
});
|
||||
}, [locations, summary.data]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const branches = rows.length;
|
||||
const active = rows.filter((row) => row.status.toLowerCase() === 'active').length;
|
||||
const totalOrders = rows.reduce((sum, row) => sum + row.orders, 0);
|
||||
const totalRevenue = rows.reduce((sum, row) => sum + row.revenue, 0);
|
||||
const cancelled = Number(orders.data?.cancelled ?? 0);
|
||||
return { branches, active, totalOrders, totalRevenue, cancelled };
|
||||
}, [rows, orders.data]);
|
||||
|
||||
const columns: TableColumn<BranchRow>[] = [
|
||||
{
|
||||
key: 'locationname',
|
||||
header: 'Branch',
|
||||
width: { type: 'proportional', value: 3 },
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.locationname}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{row.city || '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'hours',
|
||||
header: 'Hours',
|
||||
width: { type: 'pixel', value: 130 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{row.hours}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'radius',
|
||||
header: 'Radius',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 90 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" hasTabularNumbers>
|
||||
{row.radius}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'orders',
|
||||
header: 'Orders',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => (
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
hasTabularNumbers
|
||||
color={row.orders === 0 ? 'disabled' : 'primary'}
|
||||
>
|
||||
{row.orders}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
header: 'Revenue',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 120 },
|
||||
renderCell: (row) => (
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
hasTabularNumbers
|
||||
color={row.revenue === 0 ? 'disabled' : 'primary'}
|
||||
>
|
||||
{money(row.revenue)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 110 },
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
|
||||
label={row.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title={tenant?.tenantname ?? 'Tenant'}
|
||||
count={tenant?.companyname}
|
||||
description={`${totals.branches} branch${totals.branches === 1 ? '' : 'es'} · ${
|
||||
tenant?.city ?? '—'
|
||||
}`}
|
||||
isLive
|
||||
actions={
|
||||
<HStack gap={1}>
|
||||
<Button
|
||||
label="Stock catalogue"
|
||||
variant="secondary"
|
||||
href={`/nearle/catalogue?tenantid=${tenantid ?? ''}`}
|
||||
as={Link}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Branches"
|
||||
value={String(totals.branches)}
|
||||
note={`${totals.active} active`}
|
||||
tone="accent"
|
||||
icon={<Store size={15} />}
|
||||
fill={totals.branches ? totals.active / totals.branches : 0}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Orders"
|
||||
value={String(totals.totalOrders)}
|
||||
note="all statuses"
|
||||
tone="neutral"
|
||||
icon={<ShoppingCart size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Revenue"
|
||||
value={money(totals.totalRevenue)}
|
||||
note="from order summary"
|
||||
tone={totals.totalRevenue > 0 ? 'success' : 'neutral'}
|
||||
icon={<IndianRupee size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Cancelled"
|
||||
value={String(totals.cancelled)}
|
||||
note={
|
||||
totals.totalOrders
|
||||
? `${Math.round((totals.cancelled / totals.totalOrders) * 100)}% of orders`
|
||||
: 'no orders yet'
|
||||
}
|
||||
tone={totals.cancelled > 0 ? 'error' : 'neutral'}
|
||||
icon={<TriangleAlert size={15} />}
|
||||
fill={totals.totalOrders ? totals.cancelled / totals.totalOrders : 0}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Catalogue"
|
||||
value={tenant ? 'Linked' : '—'}
|
||||
note="stocked per branch"
|
||||
tone="neutral"
|
||||
icon={<PackageCheck size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Branches"
|
||||
note="orders and revenue are online only — counter sales are per branch"
|
||||
action={<Freshness updatedAt={summary.dataUpdatedAt} isFetching={summary.isFetching} />}
|
||||
/>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<DataState
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
isEmpty={rows.length === 0}
|
||||
emptyTitle="No branches yet"
|
||||
/* No CTA here: this tenant's own Administrator commissions their
|
||||
outlets. Naming who acts is more useful than a button that would
|
||||
take an action out of their hands. */
|
||||
emptyDescription="This tenant's Administrator opens their outlets from their own workspace."
|
||||
>
|
||||
{/* Columns carry meaning, so the table scrolls sideways rather
|
||||
than dropping any of them. The page itself never scrolls wide. */}
|
||||
<div className="table-scroll">
|
||||
<Table<BranchRow>
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="locationid"
|
||||
density="balanced"
|
||||
hasHover
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
</DataState>
|
||||
</Card>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
252
src/features/nearle-admin/pages/StoresPage.tsx
Normal file
252
src/features/nearle-admin/pages/StoresPage.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Building2, Plus, Store, Users } from 'lucide-react';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useTenants } from '@/queries/hooks';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
|
||||
/** A tenant, with its branches folded in. */
|
||||
interface TenantRow extends Record<string, unknown> {
|
||||
tenantid: number;
|
||||
tenantname: string;
|
||||
companyname: string;
|
||||
city: string;
|
||||
branches: number;
|
||||
status: string;
|
||||
primaryemail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Nearle Admin's home: every tenant on the platform, and how many branches
|
||||
* sit under each.
|
||||
*
|
||||
* `getalltenants` returns one row per tenant-location pair, so the rows are
|
||||
* grouped by tenantid here rather than shown raw — otherwise a tenant with six
|
||||
* branches reads as six tenants.
|
||||
*/
|
||||
export function StoresPage() {
|
||||
const { data, isLoading, error } = useTenants();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const rows = useMemo<TenantRow[]>(() => {
|
||||
if (!data) return [];
|
||||
const grouped = new Map<number, TenantRow>();
|
||||
|
||||
for (const tenant of data as TenantInfo[]) {
|
||||
const existing = grouped.get(tenant.tenantid);
|
||||
if (existing) {
|
||||
existing.branches += 1;
|
||||
continue;
|
||||
}
|
||||
grouped.set(tenant.tenantid, {
|
||||
tenantid: tenant.tenantid,
|
||||
tenantname: tenant.tenantname,
|
||||
companyname: tenant.companyname ?? '',
|
||||
city: tenant.city ?? '',
|
||||
branches: 1,
|
||||
status: tenant.status ?? 'Unknown',
|
||||
primaryemail: tenant.primaryemail ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
const all = [...grouped.values()];
|
||||
const term = search.trim().toLowerCase();
|
||||
if (!term) return all;
|
||||
return all.filter(
|
||||
(row) =>
|
||||
row.tenantname.toLowerCase().includes(term) ||
|
||||
row.companyname.toLowerCase().includes(term) ||
|
||||
row.city.toLowerCase().includes(term),
|
||||
);
|
||||
}, [data, search]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const tenants = rows.length;
|
||||
const branches = rows.reduce((sum, row) => sum + row.branches, 0);
|
||||
const active = rows.filter((row) => row.status.toLowerCase() === 'active').length;
|
||||
return { tenants, branches, active };
|
||||
}, [rows]);
|
||||
|
||||
const columns: TableColumn<TenantRow>[] = [
|
||||
{
|
||||
key: 'tenantname',
|
||||
header: 'Tenant',
|
||||
width: { type: 'proportional', value: 3 },
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.tenantname}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{row.companyname || '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'city',
|
||||
header: 'City',
|
||||
width: { type: 'proportional', value: 1.5 },
|
||||
renderCell: (row) => <Text type="body" size="sm">{row.city || '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'branches',
|
||||
header: 'Branches',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 110 },
|
||||
renderCell: (row) => (
|
||||
<Text type="label" size="sm" hasTabularNumbers>
|
||||
{row.branches}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'primaryemail',
|
||||
header: 'Primary admin',
|
||||
width: { type: 'proportional', value: 2 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{row.primaryemail || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 120 },
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
|
||||
label={row.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 110 },
|
||||
renderCell: (row) => (
|
||||
<Link
|
||||
to={`/nearle/stores/${row.tenantid}`}
|
||||
style={{
|
||||
color: 'var(--color-brand)',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Open →
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Stores"
|
||||
count={totals.tenants ? `${totals.tenants} tenants` : undefined}
|
||||
description="Every merchant on the platform, the branches under them, and how each one is performing."
|
||||
isLive
|
||||
actions={
|
||||
<Button
|
||||
label="Onboard tenant"
|
||||
variant="primary"
|
||||
icon={<Plus size={15} />}
|
||||
href="/nearle/onboard/tenant"
|
||||
as={Link}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Tenants"
|
||||
value={String(totals.tenants)}
|
||||
note={`${totals.active} active`}
|
||||
tone="accent"
|
||||
icon={<Building2 size={15} />}
|
||||
fill={totals.tenants ? totals.active / totals.tenants : 0}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Branches"
|
||||
value={String(totals.branches)}
|
||||
note="across the network"
|
||||
tone="neutral"
|
||||
icon={<Store size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Avg branches"
|
||||
value={totals.tenants ? (totals.branches / totals.tenants).toFixed(1) : '0'}
|
||||
note="per tenant"
|
||||
tone="neutral"
|
||||
icon={<Users size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Tenant directory"
|
||||
note={`${rows.length} shown`}
|
||||
action={
|
||||
<div className="toolbar-stack" style={{ maxWidth: 260 }}>
|
||||
<TextInput
|
||||
label="Search tenants"
|
||||
isLabelHidden
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by name, company or city…"
|
||||
hasClear
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<DataState
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
isEmpty={rows.length === 0}
|
||||
emptyTitle={search ? 'No tenants match that search' : 'No tenants yet'}
|
||||
emptyDescription={
|
||||
search
|
||||
? 'Try a different name, company or city.'
|
||||
: 'Onboard the first merchant group to get started.'
|
||||
}
|
||||
emptyAction={
|
||||
search ? undefined : (
|
||||
<Button label="Onboard tenant" variant="primary" href="/nearle/onboard/tenant" as={Link} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Columns carry meaning, so the table scrolls sideways rather
|
||||
than dropping any of them. The page itself never scrolls wide. */}
|
||||
<div className="table-scroll">
|
||||
<Table<TenantRow>
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="tenantid"
|
||||
density="balanced"
|
||||
hasHover
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
</DataState>
|
||||
</Card>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
95
src/features/store-admin/BranchScope.tsx
Normal file
95
src/features/store-admin/BranchScope.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useTenantLocations } from '@/queries/hooks';
|
||||
import type { TenantLocation } from '@/api/types';
|
||||
|
||||
/** `null` means All branches. */
|
||||
export type BranchSelection = number | null;
|
||||
|
||||
export interface BranchScopeValue {
|
||||
/** Every branch this tenant runs, in a stable order. */
|
||||
branches: TenantLocation[];
|
||||
isLoading: boolean;
|
||||
/** The tenant these branches belong to — from the session, never the URL. */
|
||||
tenantid: number;
|
||||
/** `null` = All branches. */
|
||||
selected: BranchSelection;
|
||||
select: (next: BranchSelection) => void;
|
||||
/**
|
||||
* The branches a page should actually read.
|
||||
*
|
||||
* All branches → every one of them; a specific branch → just that one. Pages
|
||||
* fan out over this rather than branching on `selected` themselves, which is
|
||||
* what keeps "All" and "one" the same code path.
|
||||
*/
|
||||
scoped: TenantLocation[];
|
||||
/** The selected branch, or undefined under All. */
|
||||
current: TenantLocation | undefined;
|
||||
}
|
||||
|
||||
const BranchScopeContext = createContext<BranchScopeValue | null>(null);
|
||||
|
||||
/** The URL param. In the URL so a link to a page carries its branch with it. */
|
||||
const PARAM = 'branch';
|
||||
|
||||
/**
|
||||
* Which branch the Store Admin is looking at.
|
||||
*
|
||||
* Held in the URL rather than in component state for two reasons. A link to
|
||||
* "Inventory, Peelamedu" has to survive being pasted into a chat, and a reload
|
||||
* during a shift must not silently drop the operator back to All branches while
|
||||
* they are reading a number that only makes sense for one shop.
|
||||
*
|
||||
* The tenant, by contrast, comes from the session and is deliberately NOT in
|
||||
* the URL. Fiesta has no web auth, so tenant scoping is enforced by this client
|
||||
* alone — putting the tenant id in an editable address bar would turn the one
|
||||
* boundary we control into a text field.
|
||||
*/
|
||||
export function BranchScopeProvider({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const tenantid = user?.tenantid ?? 0;
|
||||
|
||||
const { data, isLoading } = useTenantLocations(tenantid || undefined);
|
||||
|
||||
const branches = useMemo(
|
||||
() => [...(data ?? [])].sort((a, b) => a.locationid - b.locationid),
|
||||
[data],
|
||||
);
|
||||
|
||||
const raw = params.get(PARAM);
|
||||
const parsed = raw === null || raw === 'all' ? null : Number(raw);
|
||||
// An id in the URL that this tenant does not own falls back to All rather
|
||||
// than showing an empty page — the id is user-editable, so it is untrusted.
|
||||
const selected =
|
||||
parsed !== null && Number.isFinite(parsed) && branches.some((b) => b.locationid === parsed)
|
||||
? parsed
|
||||
: null;
|
||||
|
||||
const value = useMemo<BranchScopeValue>(() => {
|
||||
const current = selected === null ? undefined : branches.find((b) => b.locationid === selected);
|
||||
return {
|
||||
branches,
|
||||
isLoading,
|
||||
tenantid,
|
||||
selected,
|
||||
current,
|
||||
scoped: current ? [current] : branches,
|
||||
select: (next) => {
|
||||
const nextParams = new URLSearchParams(params);
|
||||
if (next === null) nextParams.delete(PARAM);
|
||||
else nextParams.set(PARAM, String(next));
|
||||
setParams(nextParams, { replace: true });
|
||||
},
|
||||
};
|
||||
}, [branches, isLoading, tenantid, selected, params, setParams]);
|
||||
|
||||
return <BranchScopeContext.Provider value={value}>{children}</BranchScopeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useBranchScope(): BranchScopeValue {
|
||||
const value = useContext(BranchScopeContext);
|
||||
if (!value) throw new Error('useBranchScope must be used inside a BranchScopeProvider');
|
||||
return value;
|
||||
}
|
||||
97
src/features/store-admin/DateRangePicker.tsx
Normal file
97
src/features/store-admin/DateRangePicker.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DateRangeInput, type DateRange as AstryxDateRange, type ISODateString } from '@astryxdesign/core';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
|
||||
export type RangePreset = 'today' | 'yesterday' | 'week' | 'month' | 'custom';
|
||||
|
||||
/** `YYYY-MM-DD` in LOCAL time — `toISOString()` would shift a shop in IST back a day. */
|
||||
export function isoDay(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function presetRange(preset: RangePreset, now = new Date()): DateRange {
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
switch (preset) {
|
||||
case 'today':
|
||||
return { fromdate: isoDay(today), todate: isoDay(today) };
|
||||
case 'yesterday': {
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
return { fromdate: isoDay(yesterday), todate: isoDay(yesterday) };
|
||||
}
|
||||
case 'week': {
|
||||
// Monday-first. A retail week that starts on Sunday makes Monday's
|
||||
// takings the previous week's, which is not how anyone here counts.
|
||||
const weekday = (today.getDay() + 6) % 7;
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() - weekday);
|
||||
return { fromdate: isoDay(monday), todate: isoDay(today) };
|
||||
}
|
||||
case 'month': {
|
||||
const first = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return { fromdate: isoDay(first), todate: isoDay(today) };
|
||||
}
|
||||
case 'custom':
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export interface DateRangePickerProps {
|
||||
preset: RangePreset;
|
||||
range: DateRange;
|
||||
onChange: (preset: RangePreset, range: DateRange) => void;
|
||||
}
|
||||
|
||||
export function DateRangePicker({ range, onChange }: DateRangePickerProps) {
|
||||
const astryxRange = useMemo<AstryxDateRange | null>(() => {
|
||||
if (range.fromdate && range.todate) {
|
||||
return { start: range.fromdate as ISODateString, end: range.todate as ISODateString };
|
||||
}
|
||||
return null;
|
||||
}, [range]);
|
||||
|
||||
const presets = useMemo(() => {
|
||||
return [
|
||||
{ label: 'Today', getRange: () => { const r = presetRange('today'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
{ label: 'Yesterday', getRange: () => { const r = presetRange('yesterday'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
{ label: 'This week', getRange: () => { const r = presetRange('week'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
{ label: 'This month', getRange: () => { const r = presetRange('month'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ width: 180 }}>
|
||||
<DateRangeInput
|
||||
width={180}
|
||||
size="sm"
|
||||
label="Date range"
|
||||
isLabelHidden
|
||||
value={astryxRange}
|
||||
presets={presets}
|
||||
onChange={(newRange) => {
|
||||
if (!newRange) {
|
||||
onChange('custom', {});
|
||||
return;
|
||||
}
|
||||
// Determine if the new range matches a preset
|
||||
const today = presetRange('today');
|
||||
const yesterday = presetRange('yesterday');
|
||||
const week = presetRange('week');
|
||||
const month = presetRange('month');
|
||||
|
||||
let matchedPreset: RangePreset = 'custom';
|
||||
if (newRange.start === today.fromdate && newRange.end === today.todate) matchedPreset = 'today';
|
||||
else if (newRange.start === yesterday.fromdate && newRange.end === yesterday.todate) matchedPreset = 'yesterday';
|
||||
else if (newRange.start === week.fromdate && newRange.end === week.todate) matchedPreset = 'week';
|
||||
else if (newRange.start === month.fromdate && newRange.end === month.todate) matchedPreset = 'month';
|
||||
|
||||
onChange(matchedPreset, { fromdate: newRange.start, todate: newRange.end });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
517
src/features/store-admin/OrderDetailDrawer.tsx
Normal file
517
src/features/store-admin/OrderDetailDrawer.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, Check, MapPin, Phone, X } from 'lucide-react';
|
||||
import type { DeliveryRow, OrderRow } from '@/api/types';
|
||||
import { branchLabel, moneyExact } from './format';
|
||||
import { DELIVERY_STATUS, ORDER_STATUS, orderQuantity, orderValue, statusColor } from './orderStatus';
|
||||
|
||||
type Row = OrderRow | DeliveryRow;
|
||||
|
||||
export type RowKind = 'order' | 'delivery';
|
||||
|
||||
/**
|
||||
* The five stages an order walks, taken from the old console verbatim.
|
||||
*
|
||||
* The middle one matters and is easy to drop: **Packed & Ready** sits between
|
||||
* confirmation and pickup, and it is the stage where an order stalls when a
|
||||
* shop is busy. A four-step timeline that jumps from Confirmed to Out for
|
||||
* Delivery hides exactly the delay an operator is trying to find.
|
||||
*/
|
||||
const STEPS: { label: string; fields: string[] }[] = [
|
||||
// A delivery job has no `orderdate` of its own — its clock starts at
|
||||
// `deliverydate`. Falling back keeps the first step from reading as a missing
|
||||
// stamp on every delivery ever opened.
|
||||
{ label: 'Order placed', fields: ['orderdate', 'deliverydate'] },
|
||||
{ label: 'Confirmed', fields: ['starttime', 'assigntime'] },
|
||||
{ label: 'Packed & ready', fields: ['packtime', 'arrivaltime'] },
|
||||
{ label: 'Out for delivery', fields: ['pickuptime'] },
|
||||
{ label: 'Delivered', fields: ['deliverytime'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* One order or delivery, in full.
|
||||
*
|
||||
* A right-hand sheet rather than a modal: the operator is comparing this row
|
||||
* against the list behind it — "is this the one that stalled?" — and a modal
|
||||
* that greys the list out removes the thing being compared against.
|
||||
*/
|
||||
export function OrderDetailDrawer({
|
||||
row,
|
||||
kind,
|
||||
onClose,
|
||||
}: {
|
||||
row: Row;
|
||||
kind: RowKind;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const delivery = kind === 'delivery';
|
||||
// Narrowed once, here. `kind` comes from the table the row was clicked in, so
|
||||
// it is authoritative in a way that sniffing fields is not — a delivery row
|
||||
// carries an `orderheaderid` too, which is what made the first attempt at
|
||||
// this discriminator silently mis-classify every delivery.
|
||||
const job = delivery ? (row as DeliveryRow) : undefined;
|
||||
const order = delivery ? undefined : (row as OrderRow);
|
||||
|
||||
// Escape closes. A dialog that can only be dismissed by finding the scrim
|
||||
// with a mouse is not dismissible for anyone on a keyboard, and this one sits
|
||||
// over the list an operator is working through.
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
const title = row.orderid || (job ? `DLV-${job.deliveryid}` : `#${order?.orderheaderid}`);
|
||||
const status = row.orderstatus;
|
||||
const colour = statusColor(delivery ? DELIVERY_STATUS : ORDER_STATUS, status);
|
||||
|
||||
const value = job ? (job.deliveryamt ?? 0) : orderValue(row);
|
||||
const cancelled = (status ?? '').toLowerCase().includes('cancel');
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Order ${title}`}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 70, display: 'flex', justifyContent: 'flex-end' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
border: 0,
|
||||
background: 'rgb(15 23 42 / .32)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="dialog-panel"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 460,
|
||||
maxWidth: '92vw',
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--color-surface)',
|
||||
borderLeft: '1px solid var(--color-line)',
|
||||
boxShadow: '-24px 0 56px -12px rgb(15 23 42 / .18)',
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
justify="between"
|
||||
align="start"
|
||||
gap={1}
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
padding: 20,
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
}}
|
||||
>
|
||||
<VStack gap={0.5}>
|
||||
<Text
|
||||
type="label"
|
||||
size="lg"
|
||||
weight="semibold"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<HStack gap={1} align="center">
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
borderRadius: 999,
|
||||
padding: '2px 9px',
|
||||
fontSize: 11.5,
|
||||
fontWeight: 600,
|
||||
textTransform: 'capitalize',
|
||||
color: colour,
|
||||
background: `color-mix(in oklab, ${colour} 12%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{status || '—'}
|
||||
</span>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{branchLabel(row.locationname)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 10,
|
||||
border: 0,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'var(--color-surface-sunken)',
|
||||
color: 'var(--color-ink-3)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</HStack>
|
||||
|
||||
<VStack gap={3} padding={2}>
|
||||
{/* ── Money ─────────────────────────────────────────────────── */}
|
||||
<div className="form-grid">
|
||||
<Figure label="Value" value={value > 0 ? moneyExact(value) : '—'} isStrong />
|
||||
{job ? (
|
||||
<Figure
|
||||
label="Rider charge"
|
||||
value={job.deliverycharges ? moneyExact(job.deliverycharges) : '—'}
|
||||
/>
|
||||
) : (
|
||||
<Figure
|
||||
label="Cash to collect"
|
||||
value={order?.collectionamt ? moneyExact(order.collectionamt) : '—'}
|
||||
/>
|
||||
)}
|
||||
<Figure
|
||||
label="Items"
|
||||
value={job ? String(job.itemcount ?? '—') : String(orderQuantity(row) || '—')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Journey ───────────────────────────────────────────────── */}
|
||||
<VStack gap={1}>
|
||||
<Label>Journey</Label>
|
||||
<Timeline row={row} isCancelled={cancelled} />
|
||||
</VStack>
|
||||
|
||||
{/* ── Pickup and drop ───────────────────────────────────────── */}
|
||||
<VStack gap={1}>
|
||||
<Label>Route</Label>
|
||||
<Party
|
||||
icon={<MapPin size={13} />}
|
||||
heading="Pickup"
|
||||
name={row.pickupcustomer || branchLabel(row.locationname)}
|
||||
detail={
|
||||
job
|
||||
? job.pickupsuburb || job.pickuplocation || job.Pickupaddress || job.pickupaddress
|
||||
: order?.pickupaddress || order?.pickupsuburb
|
||||
}
|
||||
phone={row.pickupcontactno}
|
||||
/>
|
||||
<Party
|
||||
icon={<MapPin size={13} />}
|
||||
heading="Drop"
|
||||
name={row.deliverycustomer}
|
||||
detail={row.deliveryaddress || row.deliverysuburb}
|
||||
phone={row.deliverycontactno}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* ── Rider ─────────────────────────────────────────────────── */}
|
||||
{job ? (
|
||||
<VStack gap={1}>
|
||||
<Label>Rider</Label>
|
||||
{job.ridername ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<HStack gap={1} align="center">
|
||||
<span
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'color-mix(in oklab, #8b5cf6 14%, transparent)',
|
||||
color: '#8b5cf6',
|
||||
}}
|
||||
>
|
||||
<Bike size={14} />
|
||||
</span>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{job.ridername}
|
||||
</Text>
|
||||
{job.ridercontact ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{job.ridercontact}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
{/* Planned against actual. The gap is the number worth
|
||||
reading — a job quoted 3km and ridden 9 is either a bad
|
||||
address or a rider taking a detour, and one figure hides
|
||||
both. */}
|
||||
<HStack gap={2}>
|
||||
<Figure label="Planned" value={km(job.kms)} />
|
||||
<Figure label="Actual" value={km(job.actualkms || job.riderkms)} />
|
||||
{job.transitminutes ? (
|
||||
<Figure label="Transit" value={`${job.transitminutes} min`} />
|
||||
) : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
No rider assigned yet.
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
) : order?.rider ? (
|
||||
<VStack gap={1}>
|
||||
<Label>Rider</Label>
|
||||
<Text type="body" size="sm">
|
||||
{order.rider}
|
||||
{order.ridercontactno ? ` · ${order.ridercontactno}` : ''}
|
||||
</Text>
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{/* ── Notes ─────────────────────────────────────────────────── */}
|
||||
{row.ordernotes || job?.notes ? (
|
||||
<VStack gap={1}>
|
||||
<Label>Notes</Label>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{row.ordernotes || job?.notes}
|
||||
</Text>
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Line items are not in the list read — Fiesta returns them from the order-detail
|
||||
endpoint, which this sheet does not call yet.
|
||||
</Text>
|
||||
</VStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The journey, as stamps rather than as a guess.
|
||||
*
|
||||
* A step is complete only when its timestamp is present. Nothing is inferred
|
||||
* from the status: an order marked Delivered with no `pickuptime` really does
|
||||
* have a missing stamp, and drawing that step as done would paper over the
|
||||
* data-quality problem the operator opened this sheet to find.
|
||||
*/
|
||||
function Timeline({ row, isCancelled }: { row: Row; isCancelled: boolean }) {
|
||||
const steps = STEPS.map((step) => ({
|
||||
label: step.label,
|
||||
// Read by name: the two row shapes do not carry the same stamps — a
|
||||
// delivery has no `packtime`, an order has no `arrivaltime` — so a step
|
||||
// whose field is absent simply reads undefined and renders as not-yet.
|
||||
at: step.fields
|
||||
.map((field) => (row as unknown as Record<string, string | undefined>)[field])
|
||||
.find(Boolean),
|
||||
}));
|
||||
|
||||
return (
|
||||
<VStack gap={0}>
|
||||
{steps.map((step, index) => {
|
||||
const done = Boolean(step.at);
|
||||
const isLast = index === steps.length - 1;
|
||||
return (
|
||||
<HStack key={step.label} gap={1.5} align="start">
|
||||
<VStack gap={0} align="center" style={{ width: 20, flex: 'none' }}>
|
||||
<span
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 999,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
flex: 'none',
|
||||
background: done ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
|
||||
color: done ? '#fff' : 'var(--color-ink-4)',
|
||||
border: done ? 0 : '1px solid var(--color-line)',
|
||||
}}
|
||||
>
|
||||
{done ? <Check size={11} /> : null}
|
||||
</span>
|
||||
{!isLast ? (
|
||||
<span
|
||||
style={{
|
||||
width: 2,
|
||||
height: 22,
|
||||
background: done ? 'var(--color-brand)' : 'var(--color-line)',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
<VStack gap={0} style={{ paddingBottom: isLast ? 0 : 8 }}>
|
||||
<Text
|
||||
type="body"
|
||||
size="sm"
|
||||
style={{ color: done ? 'var(--color-ink-1)' : 'var(--color-ink-4)' }}
|
||||
>
|
||||
{step.label}
|
||||
</Text>
|
||||
{step.at ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{new Date(step.at).toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
{isCancelled ? (
|
||||
<HStack gap={1.5} align="center" style={{ paddingTop: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'color-mix(in oklab, var(--color-error, #d64545) 14%, transparent)',
|
||||
color: 'var(--color-error, #d64545)',
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</span>
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
Cancelled
|
||||
{row.canceltime
|
||||
? ` · ${new Date(row.canceltime).toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})}`
|
||||
: ''}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Party({
|
||||
icon,
|
||||
heading,
|
||||
name,
|
||||
detail,
|
||||
phone,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
heading: string;
|
||||
name: string | undefined;
|
||||
detail: string | undefined;
|
||||
phone: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<HStack gap={1.5} align="start">
|
||||
<span
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 8,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<VStack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
{heading}
|
||||
</Text>
|
||||
<Text type="body" size="sm" weight="semibold">
|
||||
{name || '—'}
|
||||
</Text>
|
||||
{detail ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{detail}
|
||||
</Text>
|
||||
) : null}
|
||||
{phone ? (
|
||||
<HStack gap={0.5} align="center">
|
||||
<Phone size={11} style={{ color: 'var(--color-ink-4)' }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{phone}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({
|
||||
label,
|
||||
value,
|
||||
isStrong,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={0}>
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
type="label"
|
||||
size={isStrong ? 'lg' : 'sm'}
|
||||
weight="semibold"
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const Label = ({ children }: { children: React.ReactNode }) => (
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const km = (value: string | undefined): string => {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? `${n.toFixed(1)} km` : '—';
|
||||
};
|
||||
622
src/features/store-admin/PeopleDrawers.tsx
Normal file
622
src/features/store-admin/PeopleDrawers.tsx
Normal file
@@ -0,0 +1,622 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, Check, Copy, X } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { normaliseMobile, posUsersApi, staffApi, weekdayLabel } from '@/api/people';
|
||||
import type { PosRole, PosUser, StaffInfo, StaffShift, TenantLocation } from '@/api/types';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { branchLabel } from './format';
|
||||
|
||||
/* ── Shell ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The sheet both drawers sit in.
|
||||
*
|
||||
* Right-hand rather than centred, and Escape closes it — the same contract as
|
||||
* the order drawer, so the two do not behave differently for no reason.
|
||||
*/
|
||||
function Sheet({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
footer: React.ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 70,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
border: 0,
|
||||
background: 'rgb(15 23 42 / .32)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="dialog-panel"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 440,
|
||||
maxWidth: '92vw',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'var(--color-surface)',
|
||||
borderLeft: '1px solid var(--color-line)',
|
||||
boxShadow: '-24px 0 56px -12px rgb(15 23 42 / .18)',
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
justify="between"
|
||||
align="start"
|
||||
gap={1}
|
||||
style={{ padding: 20, borderBottom: '1px solid var(--color-line)', flex: 'none' }}
|
||||
>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="lg" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 10,
|
||||
border: 0,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'var(--color-surface-sunken)',
|
||||
color: 'var(--color-ink-3)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</HStack>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>{children}</div>
|
||||
|
||||
<HStack
|
||||
justify="between"
|
||||
align="center"
|
||||
gap={1}
|
||||
style={{ padding: 16, borderTop: '1px solid var(--color-line)', flex: 'none' }}
|
||||
>
|
||||
{footer}
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Problem({ message }: { message: string }) {
|
||||
return (
|
||||
<HStack
|
||||
gap={1}
|
||||
align="start"
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
background: 'color-mix(in oklab, var(--color-error, #d64545) 8%, transparent)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
style={{ color: 'var(--color-error, #d64545)', flex: 'none', marginTop: 1 }}
|
||||
/>
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{message}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Back-office person ──────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Roles a Store Admin may hand out.
|
||||
*
|
||||
* Hardcoded, and that is a stopgap rather than a choice: `app_roles` lives
|
||||
* outside Fiesta, so there is no first-party endpoint to read. The ids are the
|
||||
* ones `resolveRole` already keys on. Editing an existing person shows whatever
|
||||
* `getstaffs` resolved, even when it is not in this list, because the backend's
|
||||
* label is authoritative and most accounts carry an id absent from `app_roles`.
|
||||
*/
|
||||
const STAFF_ROLES = [
|
||||
{ id: 3, label: 'Administrator' },
|
||||
{ id: 4, label: 'Manager' },
|
||||
{ id: 5, label: 'Staff' },
|
||||
];
|
||||
|
||||
export function PersonDrawer({
|
||||
row,
|
||||
tenantid,
|
||||
branches,
|
||||
onClose,
|
||||
}: {
|
||||
row: StaffInfo | null;
|
||||
tenantid: number;
|
||||
branches: TenantLocation[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const isNew = row === null;
|
||||
|
||||
const [firstname, setFirstname] = useState(row?.firstname ?? '');
|
||||
const [lastname, setLastname] = useState(row?.lastname ?? '');
|
||||
const [email, setEmail] = useState(row?.email ?? '');
|
||||
const [contactno, setContactno] = useState(row?.contactno ?? '');
|
||||
const [roleid, setRoleid] = useState(String(row?.roleid ?? STAFF_ROLES[1]?.id ?? 4));
|
||||
const [locationid, setLocationid] = useState(
|
||||
String(row?.locationid ?? branches[0]?.locationid ?? 0),
|
||||
);
|
||||
const [isActive, setIsActive] = useState((row?.status ?? 'Active').toLowerCase() !== 'inactive');
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const status = isActive ? 'Active' : 'Inactive';
|
||||
if (isNew) {
|
||||
return staffApi.create({
|
||||
tenantid,
|
||||
locationid: Number(locationid),
|
||||
firstname: firstname.trim(),
|
||||
lastname: lastname.trim(),
|
||||
email: email.trim(),
|
||||
contactno: normaliseMobile(contactno),
|
||||
roleid: Number(roleid),
|
||||
status,
|
||||
});
|
||||
}
|
||||
return staffApi.update({
|
||||
userid: row.userid,
|
||||
firstname: firstname.trim(),
|
||||
lastname: lastname.trim(),
|
||||
email: email.trim(),
|
||||
contactno: normaliseMobile(contactno),
|
||||
// Never send a non-positive roleid. `UpdateStaff` writes whatever it is
|
||||
// given, so a 0 or -1 here lands in the column as a role nothing matches.
|
||||
roleid: Number(roleid) > 0 ? Number(roleid) : undefined,
|
||||
locationid: Number(locationid) || undefined,
|
||||
status,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.people.all });
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => setProblem(errorMessage(error)),
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setProblem(null);
|
||||
if (!firstname.trim()) return setProblem('A first name is required.');
|
||||
if (!email.trim()) return setProblem('An email is required — it is what they sign in with.');
|
||||
save.mutate();
|
||||
}
|
||||
|
||||
// An existing role the picker does not know about is still shown, so editing
|
||||
// someone's phone number cannot silently reassign their role.
|
||||
const roleOptions = [
|
||||
...STAFF_ROLES.map((role) => ({ value: String(role.id), label: role.label })),
|
||||
...(row?.roleid && !STAFF_ROLES.some((role) => role.id === row.roleid)
|
||||
? [{ value: String(row.roleid), label: row.rolename || `Role ${row.roleid}` }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
title={isNew ? 'Add a person' : (row.fullname ?? 'Edit person')}
|
||||
subtitle={isNew ? 'They will be able to sign in to this console' : `User ${row.userid}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button label="Cancel" variant="secondary" onClick={onClose} />
|
||||
<Button
|
||||
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
|
||||
variant="primary"
|
||||
isDisabled={save.isPending}
|
||||
onClick={() => submit(new Event('submit') as unknown as FormEvent)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<VStack gap={2}>
|
||||
{problem ? <Problem message={problem} /> : null}
|
||||
|
||||
<div className="form-grid">
|
||||
<TextInput label={<span>First name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={firstname} onChange={setFirstname} />
|
||||
<TextInput label="Last name" value={lastname} onChange={setLastname} />
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
label={<span>Email <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
description="What they sign in with."
|
||||
/>
|
||||
<TextInput label="Mobile" value={contactno} onChange={setContactno} />
|
||||
|
||||
<div className="form-grid">
|
||||
<Selector label="Role" value={roleid} onChange={setRoleid} options={roleOptions} />
|
||||
<Selector
|
||||
label="Branch"
|
||||
value={locationid}
|
||||
onChange={setLocationid}
|
||||
options={branches.map((branch) => ({
|
||||
value: String(branch.locationid),
|
||||
label: branchLabel(branch.locationname),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActiveToggle
|
||||
isActive={isActive}
|
||||
onChange={setIsActive}
|
||||
activeNote="Can sign in"
|
||||
inactiveNote="Cannot sign in. History is kept."
|
||||
/>
|
||||
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
No password is set here. Passwords are stored in clear in Fiesta, so issuing one is a
|
||||
backend job until that changes.
|
||||
</Text>
|
||||
</VStack>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Till account ────────────────────────────────────────────────────────── */
|
||||
|
||||
export function TillDrawer({
|
||||
row,
|
||||
tenantid,
|
||||
locationid,
|
||||
roles,
|
||||
shifts,
|
||||
onClose,
|
||||
}: {
|
||||
row: PosUser | null;
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
roles: PosRole[];
|
||||
shifts: StaffShift[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const isNew = row === null;
|
||||
|
||||
const [fullName, setFullName] = useState(row?.full_name ?? '');
|
||||
const [role, setRole] = useState(row?.role?.toLowerCase() ?? 'cashier');
|
||||
const [contactno, setContactno] = useState(row?.contactno ?? '');
|
||||
const [pin, setPin] = useState('');
|
||||
const [shiftId, setShiftId] = useState(String(row?.shift_id ?? 0));
|
||||
const [isActive, setIsActive] = useState((row?.status ?? 'Active').toLowerCase() !== 'inactive');
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
/** The credential the backend hands back on create. Shown once, never re-read. */
|
||||
const [issued, setIssued] = useState<string | null>(null);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const status = isActive ? 'Active' : 'Inactive';
|
||||
if (isNew) {
|
||||
return posUsersApi.create({
|
||||
tenantid,
|
||||
locationid,
|
||||
full_name: fullName.trim(),
|
||||
role,
|
||||
contactno: normaliseMobile(contactno),
|
||||
pin: pin.trim() || undefined,
|
||||
shift_id: Number(shiftId) || undefined,
|
||||
status,
|
||||
});
|
||||
}
|
||||
return posUsersApi.update({
|
||||
tenantid,
|
||||
locationid,
|
||||
user_id: row.user_id,
|
||||
full_name: fullName.trim(),
|
||||
role,
|
||||
contactno: normaliseMobile(contactno),
|
||||
shift_id: Number(shiftId) || undefined,
|
||||
status,
|
||||
});
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.people.all });
|
||||
// The backend returns the PIN or password only in this answer, never in a
|
||||
// listing. If it came back, this is the one moment it can be read.
|
||||
const credential = (result as PosUser | undefined)?.pin ?? (result as PosUser | undefined)?.password;
|
||||
if (isNew && credential) setIssued(credential);
|
||||
else onClose();
|
||||
},
|
||||
onError: (error) => setProblem(errorMessage(error)),
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setProblem(null);
|
||||
if (!fullName.trim()) return setProblem('A name is required.');
|
||||
const mobile = normaliseMobile(contactno);
|
||||
if (mobile.length !== 10) {
|
||||
return setProblem('A ten-digit mobile is required — the till matches on it exactly.');
|
||||
}
|
||||
if (isNew && pin.trim() && !/^\d{4,6}$/.test(pin.trim())) {
|
||||
return setProblem('A PIN is 4 to 6 digits.');
|
||||
}
|
||||
save.mutate();
|
||||
}
|
||||
|
||||
if (issued) {
|
||||
return (
|
||||
<Sheet
|
||||
title="Account created"
|
||||
subtitle={fullName}
|
||||
onClose={onClose}
|
||||
footer={<Button label="Done" variant="primary" onClick={onClose} />}
|
||||
>
|
||||
<VStack gap={2}>
|
||||
<Text type="body" size="sm">
|
||||
This is the only time this PIN is shown. Fiesta never returns it from a listing — if it
|
||||
is lost, reissue rather than look it up.
|
||||
</Text>
|
||||
<HStack
|
||||
justify="between"
|
||||
align="center"
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface-subtle)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.18em',
|
||||
}}
|
||||
>
|
||||
{issued}
|
||||
</span>
|
||||
<Button
|
||||
label="Copy"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Copy size={13} />}
|
||||
onClick={() => void navigator.clipboard?.writeText(issued)}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
const roleOptions =
|
||||
roles.length > 0
|
||||
? roles.map((entry) => ({ value: entry.role, label: entry.label }))
|
||||
: // Only if `posroles` is unreachable. The endpoint is the source; this is
|
||||
// a fallback so the form is not unusable, not a second definition.
|
||||
[
|
||||
{ value: 'supervisor', label: 'Supervisor' },
|
||||
{ value: 'cashier', label: 'Cashier' },
|
||||
];
|
||||
|
||||
const chosenRole = roles.find((entry) => entry.role === role);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
title={isNew ? 'Add a till account' : (row.full_name ?? 'Edit till account')}
|
||||
subtitle={
|
||||
isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`
|
||||
}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button label="Cancel" variant="secondary" onClick={onClose} />
|
||||
<Button
|
||||
label={save.isPending ? 'Saving…' : isNew ? 'Create' : 'Save'}
|
||||
variant="primary"
|
||||
isDisabled={save.isPending}
|
||||
onClick={() => submit(new Event('submit') as unknown as FormEvent)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<VStack gap={2}>
|
||||
{problem ? <Problem message={problem} /> : null}
|
||||
|
||||
<TextInput label={<span>Full name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={fullName} onChange={setFullName} />
|
||||
|
||||
<Selector label="Role" value={role} onChange={setRole} options={roleOptions} />
|
||||
{chosenRole?.description ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{chosenRole.description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label={<span>Mobile <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={contactno}
|
||||
onChange={setContactno}
|
||||
description="Ten digits. This is what they sign in with at the till."
|
||||
/>
|
||||
|
||||
{isNew ? (
|
||||
<TextInput
|
||||
label="PIN"
|
||||
value={pin}
|
||||
onChange={setPin}
|
||||
description="4–6 digits. Leave blank and the backend issues one — either way it is shown once."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Selector
|
||||
label="Shift"
|
||||
value={shiftId}
|
||||
onChange={setShiftId}
|
||||
options={[
|
||||
{ value: '0', label: 'Any shift' },
|
||||
...shifts.map((shift) => ({
|
||||
value: String(shift.staff_shift_id),
|
||||
label: `${shift.name} · ${shift.start_time}–${shift.end_time} · ${weekdayLabel(shift.weekdays)}`,
|
||||
})),
|
||||
]}
|
||||
description="Informational. Nothing refuses a bill rung outside it."
|
||||
/>
|
||||
|
||||
<ActiveToggle
|
||||
isActive={isActive}
|
||||
onChange={setIsActive}
|
||||
activeNote="Can open a till"
|
||||
inactiveNote="Cannot sign in at a till. Their bills are kept."
|
||||
/>
|
||||
</VStack>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Shared ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Active / inactive, as a pair of buttons rather than a checkbox.
|
||||
*
|
||||
* Deactivating is the closest thing this console has to a delete, so it says
|
||||
* what it does in words on the control itself instead of leaving the
|
||||
* consequence to a checkbox label the eye skips.
|
||||
*/
|
||||
function ActiveToggle({
|
||||
isActive,
|
||||
onChange,
|
||||
activeNote,
|
||||
inactiveNote,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
activeNote: string;
|
||||
inactiveNote: string;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
Access
|
||||
</Text>
|
||||
<HStack gap={1}>
|
||||
<Choice
|
||||
label="Active"
|
||||
note={activeNote}
|
||||
isSelected={isActive}
|
||||
onSelect={() => onChange(true)}
|
||||
/>
|
||||
<Choice
|
||||
label="Inactive"
|
||||
note={inactiveNote}
|
||||
isSelected={!isActive}
|
||||
onSelect={() => onChange(false)}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Choice({
|
||||
label,
|
||||
note,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
note: string;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={isSelected}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
cursor: 'pointer',
|
||||
border: `1px solid ${isSelected ? 'var(--color-brand)' : 'var(--color-line)'}`,
|
||||
background: isSelected ? 'var(--color-brand-tint)' : 'var(--color-surface)',
|
||||
transition: 'border-color .15s, background .15s',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: isSelected ? 'var(--color-brand)' : 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
{isSelected ? <Check size={12} /> : null}
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ display: 'block', fontSize: 11, color: 'var(--color-ink-4)', marginTop: 2 }}>
|
||||
{note}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
247
src/features/store-admin/StoreAdminShell.tsx
Normal file
247
src/features/store-admin/StoreAdminShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
src/features/store-admin/counterLabels.ts
Normal file
125
src/features/store-admin/counterLabels.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Local names for counters, and a way to hide the dead ones.
|
||||
*
|
||||
* This exists because of two facts about Fiesta, both verified in the source:
|
||||
*
|
||||
* 1. **A terminal has no name.** There is no name, label, alias or counter
|
||||
* number anywhere in the backend. `PosHealth.Storename` is the *shop's*
|
||||
* name, self-reported by the till. The only identifier is the code — which
|
||||
* `models/poshealth.go` calls "the short code printed on invoices — the
|
||||
* thing a support call starts with". So the code is worth keeping visible;
|
||||
* it just isn't *where the counter is*, which is what staff need.
|
||||
*
|
||||
* 2. **A terminal can never be removed.** There is no `SRem` anywhere in
|
||||
* Fiesta, and the location SET that lists terminals deliberately has no
|
||||
* TTL: "it is an index of what exists, not a claim that any of it is alive
|
||||
* right now." A till replaced last year, or a code typed wrong once, sits
|
||||
* on this board as permanently offline, forever, with no API to remove it.
|
||||
*
|
||||
* Point 2 is what makes this feature load-bearing rather than a nicety. Without
|
||||
* hiding, a board that is useful in week one is mostly ghosts by month six —
|
||||
* every one of them red — and a board of permanent false alarms is one nobody
|
||||
* reads. That is a worse outcome than not having built it.
|
||||
*
|
||||
* Both are stored per browser, and the UI says so plainly rather than implying
|
||||
* a change everyone will see. Making them shared needs a small
|
||||
* `terminal_labels` table keyed on `(tenantid, terminalid)` with a name and a
|
||||
* retired flag — worth asking for, not worth waiting for.
|
||||
*/
|
||||
const KEY = 'nearle.counterLabels.v1';
|
||||
|
||||
interface Stored {
|
||||
names: Record<string, string>;
|
||||
hidden: string[];
|
||||
}
|
||||
|
||||
const EMPTY: Stored = { names: {}, hidden: [] };
|
||||
|
||||
function read(): Stored {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return EMPTY;
|
||||
const parsed = JSON.parse(raw) as Partial<Stored>;
|
||||
return {
|
||||
names: parsed.names ?? {},
|
||||
hidden: parsed.hidden ?? [],
|
||||
};
|
||||
} catch {
|
||||
// A browser refusing storage, or a value someone hand-edited into
|
||||
// nonsense, must not take the page down with it.
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
function write(value: Stored): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(value));
|
||||
} catch {
|
||||
// Storage full or blocked. The rename still applies for this render; it
|
||||
// simply will not survive a reload, which is better than an error dialog.
|
||||
}
|
||||
}
|
||||
|
||||
export interface CounterLabels {
|
||||
/** The name to show: the operator's own, or the code it came with. */
|
||||
nameFor: (terminalId: string) => string;
|
||||
/** Has this counter been given a name? Decides whether the code is shown too. */
|
||||
isNamed: (terminalId: string) => boolean;
|
||||
isHidden: (terminalId: string) => boolean;
|
||||
/** Every hidden counter, so they can actually be brought back. */
|
||||
hidden: readonly string[];
|
||||
hiddenCount: number;
|
||||
rename: (terminalId: string, name: string) => void;
|
||||
hide: (terminalId: string) => void;
|
||||
unhide: (terminalId: string) => void;
|
||||
}
|
||||
|
||||
export function useCounterLabels(): CounterLabels {
|
||||
const [stored, setStored] = useState<Stored>(read);
|
||||
|
||||
const update = useCallback((next: Stored) => {
|
||||
write(next);
|
||||
setStored(next);
|
||||
}, []);
|
||||
|
||||
const rename = useCallback(
|
||||
(terminalId: string, name: string) => {
|
||||
const trimmed = name.trim();
|
||||
const names = { ...stored.names };
|
||||
// An empty name is a request to go back to the code, not a request to
|
||||
// display nothing.
|
||||
if (trimmed === '') delete names[terminalId];
|
||||
else names[terminalId] = trimmed.slice(0, 40);
|
||||
update({ ...stored, names });
|
||||
},
|
||||
[stored, update],
|
||||
);
|
||||
|
||||
const hide = useCallback(
|
||||
(terminalId: string) => {
|
||||
if (stored.hidden.includes(terminalId)) return;
|
||||
update({ ...stored, hidden: [...stored.hidden, terminalId] });
|
||||
},
|
||||
[stored, update],
|
||||
);
|
||||
|
||||
const unhide = useCallback(
|
||||
(terminalId: string) => {
|
||||
update({ ...stored, hidden: stored.hidden.filter((id) => id !== terminalId) });
|
||||
},
|
||||
[stored, update],
|
||||
);
|
||||
|
||||
return {
|
||||
nameFor: (terminalId) => stored.names[terminalId] ?? terminalId,
|
||||
isNamed: (terminalId) => stored.names[terminalId] !== undefined,
|
||||
isHidden: (terminalId) => stored.hidden.includes(terminalId),
|
||||
hidden: stored.hidden,
|
||||
hiddenCount: stored.hidden.length,
|
||||
rename,
|
||||
hide,
|
||||
unhide,
|
||||
};
|
||||
}
|
||||
77
src/features/store-admin/format.ts
Normal file
77
src/features/store-admin/format.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Number formatting, in one place.
|
||||
*
|
||||
* Every figure on these pages is money, a count, or a percentage, and each has
|
||||
* exactly one correct rendering. Scattering `toFixed(2)` across four pages is
|
||||
* how ₹1,350 becomes ₹1350.00 on one screen and ₹1.35K on another.
|
||||
*/
|
||||
|
||||
const INR = new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const INR_PRECISE = new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const COUNT = new Intl.NumberFormat('en-IN');
|
||||
|
||||
/**
|
||||
* Money for a KPI or a table cell — whole rupees.
|
||||
*
|
||||
* Paise are dropped on purpose. A day's takings read at a glance do not need
|
||||
* two decimal places, and the digits they add are the two the eye has to skip
|
||||
* past to compare two branches.
|
||||
*/
|
||||
export const money = (value: number | undefined | null): string =>
|
||||
value === undefined || value === null || !Number.isFinite(value) ? '—' : INR.format(value);
|
||||
|
||||
/** Money where the paise matter — a single bill, a unit price, a tax line. */
|
||||
export const moneyExact = (value: number | undefined | null): string =>
|
||||
value === undefined || value === null || !Number.isFinite(value) ? '—' : INR_PRECISE.format(value);
|
||||
|
||||
export const count = (value: number | undefined | null): string =>
|
||||
value === undefined || value === null || !Number.isFinite(value) ? '—' : COUNT.format(value);
|
||||
|
||||
/**
|
||||
* A percentage of a total, guarded.
|
||||
*
|
||||
* Returns `null` rather than 0 when the denominator is zero: "0% cancelled"
|
||||
* across no orders is a claim the data does not support, and a tile that says
|
||||
* "—" is the honest rendering of a shop that has not traded yet.
|
||||
*/
|
||||
export function share(part: number | undefined, total: number | undefined): number | null {
|
||||
if (!total || !Number.isFinite(total) || total <= 0) return null;
|
||||
if (part === undefined || !Number.isFinite(part)) return null;
|
||||
return part / total;
|
||||
}
|
||||
|
||||
export const percent = (value: number | null): string =>
|
||||
value === null ? '—' : `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%`;
|
||||
|
||||
/** A short local time — "14:32". Used on bills and last-sync stamps. */
|
||||
export const clock = (date: Date | null): string =>
|
||||
date ? date.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: false }) : '—';
|
||||
|
||||
/**
|
||||
* A branch name with the tenant prefix stripped: "R mart — Peelamedu" → "Peelamedu".
|
||||
*
|
||||
* Every row of every table in this workspace belongs to the same merchant, so
|
||||
* repeating their name down a column is pure noise — and it is expensive noise:
|
||||
* the prefix cost about 55px per row, which was enough to push the Amount
|
||||
* column off the edge of the deliveries table on a 1440px screen.
|
||||
*
|
||||
* Splits on an em dash or a hyphen surrounded by spaces, which is how the
|
||||
* onboarding form composes these names. A name without a separator is returned
|
||||
* untouched rather than guessed at.
|
||||
*/
|
||||
export function branchLabel(name: string | undefined): string {
|
||||
if (!name) return '—';
|
||||
const match = name.split(/\s+[—–-]\s+/);
|
||||
return match.length > 1 ? (match[match.length - 1] as string) : name;
|
||||
}
|
||||
143
src/features/store-admin/orderStatus.ts
Normal file
143
src/features/store-admin/orderStatus.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Order and delivery status, taken verbatim from the old console.
|
||||
*
|
||||
* Two ladders, not one, and that is the important part. An order walks
|
||||
* created → pending → processing → confirmed/ready → delivered, while its
|
||||
* delivery job walks pending → accepted → arrived → picked → active →
|
||||
* delivered. Colouring a delivery with the order map paints "picked" and
|
||||
* "arrived" grey, which is exactly the middle of the journey where an operator
|
||||
* most needs to see progress.
|
||||
*
|
||||
* Values are the old console's `consoleUi.tsx` hex codes, unchanged, so a row
|
||||
* that was amber there is amber here.
|
||||
*/
|
||||
|
||||
/** The order lifecycle. */
|
||||
export const ORDER_STATUS: Record<string, string> = {
|
||||
created: '#0ea5e9',
|
||||
pending: '#f59e0b',
|
||||
processing: '#0ea5e9',
|
||||
modified: '#06b6d4',
|
||||
confirmed: '#10b981',
|
||||
accepted: '#6366f1',
|
||||
ready: '#6366f1',
|
||||
delivered: '#10b981',
|
||||
cancelled: '#ef4444',
|
||||
};
|
||||
|
||||
/** The delivery lifecycle. Different words, different colours. */
|
||||
export const DELIVERY_STATUS: Record<string, string> = {
|
||||
pending: '#f59e0b',
|
||||
accepted: '#6366f1',
|
||||
arrived: '#06b6d4',
|
||||
picked: '#8b5cf6',
|
||||
active: '#14b8a6',
|
||||
skipped: '#f97316',
|
||||
delivered: '#10b981',
|
||||
cancelled: '#ef4444',
|
||||
};
|
||||
|
||||
/** Unknown statuses fall to meta grey rather than to a colour that means something. */
|
||||
export const statusColor = (map: Record<string, string>, status: string | undefined): string =>
|
||||
map[(status ?? '').trim().toLowerCase()] ?? 'var(--color-ink-3)';
|
||||
|
||||
/**
|
||||
* The status tabs, as the old console groups them.
|
||||
*
|
||||
* Tabs rather than a dropdown: there are six, an operator switches between them
|
||||
* constantly during a shift, and a dropdown hides the counts. Each carries how
|
||||
* many rows it holds, which is half the reason to look.
|
||||
*/
|
||||
export const STATUS_TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'created', label: 'Created' },
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'processing', label: 'Processing' },
|
||||
{ key: 'delivered', label: 'Delivered' },
|
||||
{ key: 'cancelled', label: 'Cancelled' },
|
||||
] as const;
|
||||
|
||||
export type StatusKey = (typeof STATUS_TABS)[number]['key'];
|
||||
|
||||
/**
|
||||
* Does a row belong under this tab?
|
||||
*
|
||||
* Substring matching, lowercased. Fiesta stores status as free text and the
|
||||
* casing is inconsistent between writers, so an exact match is how a
|
||||
* "Delivered" row silently stops counting the day someone writes "delivered".
|
||||
* "processing" also catches confirmed, preparing and ready — everything between
|
||||
* accepted and out the door, which is what an operator means by the word.
|
||||
*/
|
||||
export function matchesStatus(key: StatusKey, status: string | undefined): boolean {
|
||||
if (key === 'all') return true;
|
||||
const s = (status ?? '').trim().toLowerCase();
|
||||
if (!s) return false;
|
||||
switch (key) {
|
||||
case 'created':
|
||||
return s.includes('created') || s.includes('new');
|
||||
case 'pending':
|
||||
return s.includes('pending');
|
||||
case 'processing':
|
||||
return (
|
||||
s.includes('process') ||
|
||||
s.includes('confirm') ||
|
||||
s.includes('prepar') ||
|
||||
s.includes('ready') ||
|
||||
s.includes('accept') ||
|
||||
s.includes('picked') ||
|
||||
s.includes('active') ||
|
||||
s.includes('arrived') ||
|
||||
s.includes('out for')
|
||||
);
|
||||
case 'delivered':
|
||||
return s.includes('deliver') && !s.includes('undeliver') && !s.includes('out for');
|
||||
case 'cancelled':
|
||||
// `skipped` lives here too. It is its own delivery status — the rider
|
||||
// reached the address and nobody was in — and it keeps its own orange
|
||||
// chip, but it has no tab of its own in this six-tab strip. Without this
|
||||
// line a skipped job appears under "All" and under nothing else, so an
|
||||
// operator working through the tabs never sees it.
|
||||
return s.includes('cancel') || s.includes('skip');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The money on an order row, in the order the old console reads it.
|
||||
*
|
||||
* `ordervalue` is the full figure (goods + tax + charges − promo), `orderamount`
|
||||
* is the goods alone, and `deliveryamt` is what a delivery job carries. Which
|
||||
* one is populated depends on which endpoint produced the row, so this walks
|
||||
* them rather than picking one and showing blanks half the time.
|
||||
*/
|
||||
export function orderValue(row: {
|
||||
ordervalue?: number;
|
||||
orderamount?: number;
|
||||
deliveryamt?: number;
|
||||
}): number {
|
||||
return row.ordervalue || row.orderamount || row.deliveryamt || 0;
|
||||
}
|
||||
|
||||
/** Items on an order. `quantity` first, `itemcount` as the fallback. */
|
||||
export function orderQuantity(row: { quantity?: number; itemcount?: number }): number {
|
||||
return row.quantity || row.itemcount || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far behind a counter bill was when it reached us.
|
||||
*
|
||||
* `billedat` is when the sale was rung; `receivedat` is when it landed here. A
|
||||
* gap means the till was offline and caught up later, and it is worth showing
|
||||
* on the row: a day's takings that all arrived at 6pm did not happen at 6pm.
|
||||
*
|
||||
* Returns null when the gap is under a minute (normal) or when either stamp is
|
||||
* missing — a bill with no `receivedat` predates the field, and calling that
|
||||
* "0 seconds late" would be a claim the data does not support.
|
||||
*/
|
||||
export function syncLagMs(bill: { billedat?: string; receivedat?: string }): number | null {
|
||||
if (!bill.billedat || !bill.receivedat) return null;
|
||||
const billed = new Date(bill.billedat).getTime();
|
||||
const received = new Date(bill.receivedat).getTime();
|
||||
if (Number.isNaN(billed) || Number.isNaN(received)) return null;
|
||||
const lag = received - billed;
|
||||
return lag >= 60_000 ? lag : null;
|
||||
}
|
||||
785
src/features/store-admin/pages/ConsolePage.tsx
Normal file
785
src/features/store-admin/pages/ConsolePage.tsx
Normal file
@@ -0,0 +1,785 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
ClipboardList,
|
||||
Info,
|
||||
Monitor,
|
||||
PackageX,
|
||||
Receipt,
|
||||
ShoppingCart,
|
||||
TrendingDown,
|
||||
} from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import {
|
||||
useLocationSummary,
|
||||
usePosHealthByBranch,
|
||||
usePosSalesByBranch,
|
||||
useStockRequests,
|
||||
} from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||
import { branchLabel, count, money, percent, share } from '../format';
|
||||
import {
|
||||
SYNC_COLOR,
|
||||
SYNC_LABEL,
|
||||
SYNC_TONE,
|
||||
shortAge,
|
||||
summariseBranch,
|
||||
type BranchSyncSummary,
|
||||
type TerminalStatus,
|
||||
} from '../posStatus';
|
||||
|
||||
/**
|
||||
* The Store Admin's landing page.
|
||||
*
|
||||
* One rule governs the whole screen: **app sales and counter sales are never
|
||||
* added together.** They come from two different tables — `orders` and
|
||||
* `posorders` — and a branch that both runs a till and uploads a sales
|
||||
* spreadsheet would be counted twice by a combined total. There is no headline
|
||||
* "Total Revenue" tile here for that reason, and its absence is deliberate
|
||||
* rather than an omission. See `store-admin-backend-gap.md` §3.1.
|
||||
*/
|
||||
export function ConsolePage() {
|
||||
const { branches, scoped, selected, current, tenantid, isLoading } = useBranchScope();
|
||||
const [preset, setPreset] = useState<RangePreset>('today');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
||||
|
||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||
|
||||
const orders = useLocationSummary(tenantid || undefined);
|
||||
const posSales = usePosSalesByBranch(branchIds, range);
|
||||
const posHealth = usePosHealthByBranch(branchIds);
|
||||
const requests = useStockRequests(
|
||||
tenantid ? { tenantid, locationid: selected ?? undefined, status: 'Pending' } : undefined,
|
||||
);
|
||||
|
||||
// One instant for the whole board — see `summariseBranch`.
|
||||
const now = Date.now();
|
||||
|
||||
/** Everything the page needs, per branch, assembled once. */
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
scoped.map((branch, index) => {
|
||||
const order = (orders.data ?? []).find((entry) => entry.locationid === branch.locationid);
|
||||
const pos = posSales[index]?.data;
|
||||
const health = summariseBranch(posHealth[index]?.data ?? [], now);
|
||||
return {
|
||||
branch,
|
||||
onlineRevenue: order?.revenue ?? 0,
|
||||
onlineOrders: order?.totalorders ?? 0,
|
||||
cancelled: order?.cancelled ?? 0,
|
||||
delivered: order?.delivered ?? 0,
|
||||
counterRevenue: pos?.grosssales ?? 0,
|
||||
counterBills: pos?.billcount ?? 0,
|
||||
health,
|
||||
pendingRequests: (requests.data ?? []).filter(
|
||||
(entry) => entry.locationid === branch.locationid,
|
||||
).length,
|
||||
};
|
||||
}),
|
||||
[scoped, orders.data, posSales, posHealth, requests.data, now],
|
||||
);
|
||||
|
||||
const totals = useMemo(
|
||||
() =>
|
||||
rows.reduce(
|
||||
(acc, row) => ({
|
||||
onlineRevenue: acc.onlineRevenue + row.onlineRevenue,
|
||||
onlineOrders: acc.onlineOrders + row.onlineOrders,
|
||||
cancelled: acc.cancelled + row.cancelled,
|
||||
counterRevenue: acc.counterRevenue + row.counterRevenue,
|
||||
counterBills: acc.counterBills + row.counterBills,
|
||||
pendingRequests: acc.pendingRequests + row.pendingRequests,
|
||||
pendingBills: acc.pendingBills + row.health.pendingBills,
|
||||
}),
|
||||
{
|
||||
onlineRevenue: 0,
|
||||
onlineOrders: 0,
|
||||
cancelled: 0,
|
||||
counterRevenue: 0,
|
||||
counterBills: 0,
|
||||
pendingRequests: 0,
|
||||
pendingBills: 0,
|
||||
},
|
||||
),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const alerts = useMemo(() => buildAlerts(rows), [rows]);
|
||||
const cancelShare = share(totals.cancelled, totals.onlineOrders);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Console"
|
||||
count={
|
||||
selected === null
|
||||
? `${branches.length} branch${branches.length === 1 ? '' : 'es'}`
|
||||
: (current?.locationname ?? undefined)
|
||||
}
|
||||
description="Everything trading right now — by channel, by branch, and what is waiting on you."
|
||||
isLive
|
||||
actions={
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── Revenue, by channel, never summed ──────────────────────────── */}
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Revenue"
|
||||
note="two ledgers, shown apart"
|
||||
action={
|
||||
<HStack gap={0.5} align="center">
|
||||
<Info size={12} style={{ color: 'var(--color-ink-4)' }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
App and counter sales are not added together
|
||||
</Text>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="App revenue"
|
||||
value={money(totals.onlineRevenue)}
|
||||
note={`${count(totals.onlineOrders)} orders`}
|
||||
tone="accent"
|
||||
icon={<ShoppingCart size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Counter revenue"
|
||||
value={money(totals.counterRevenue)}
|
||||
note={`${count(totals.counterBills)} bills`}
|
||||
tone="success"
|
||||
icon={<Receipt size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Cancelled"
|
||||
value={percent(cancelShare)}
|
||||
note={`${count(totals.cancelled)} of ${count(totals.onlineOrders)} app orders`}
|
||||
tone={cancelShare !== null && cancelShare > 0.1 ? 'error' : 'neutral'}
|
||||
icon={<TrendingDown size={15} />}
|
||||
fill={cancelShare ?? undefined}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Unsynced bills"
|
||||
value={count(totals.pendingBills)}
|
||||
note="sitting on tills"
|
||||
tone={totals.pendingBills > 0 ? 'warning' : 'success'}
|
||||
icon={<Monitor size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* The caveat that keeps the two figures honest, stated where the
|
||||
figures are rather than in a doc nobody opens at 9am. */}
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
App revenue currently also contains any counter bills imported from a spreadsheet —
|
||||
Fiesta records the bill number on the order but does not return it, so the two cannot yet
|
||||
be told apart. Live till sales are counted separately and are never double-counted.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* ── Branch overview ─────────────────────────────────────────────── */}
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title={selected === null ? 'Branches' : 'Branch'}
|
||||
note="how each shop is trading"
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Loading branches…
|
||||
</Text>
|
||||
) : rows.length === 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
No branches yet
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Commission your first outlet and its numbers will appear here.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{rows.map((row) => (
|
||||
<BranchCard key={row.branch.locationid} row={row} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* ── Comparison, only when there is something to compare ─────────── */}
|
||||
{rows.length > 1 ? (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Comparison" note="the same metrics, side by side" />
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<ComparisonTable rows={rows} />
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{/* ── Tills ───────────────────────────────────────────────────────── */}
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Till sync"
|
||||
note="a queue nobody can see from the shop floor"
|
||||
action={
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Tills report every 30s
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<TillTable rows={rows} />
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
{/* ── Action required ─────────────────────────────────────────────── */}
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Needs you" note="ranked by what costs money soonest" />
|
||||
{alerts.length === 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Nothing waiting
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Every till is synced and no stock request is outstanding.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : (
|
||||
<VStack gap={1}>
|
||||
{alerts.map((alert) => (
|
||||
<AlertRow key={alert.id} alert={alert} />
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
type Row = {
|
||||
branch: { locationid: number; locationname: string; suburb?: string; city?: string; status: string };
|
||||
onlineRevenue: number;
|
||||
onlineOrders: number;
|
||||
cancelled: number;
|
||||
delivered: number;
|
||||
counterRevenue: number;
|
||||
counterBills: number;
|
||||
health: ReturnType<typeof summariseBranch>;
|
||||
pendingRequests: number;
|
||||
};
|
||||
|
||||
function BranchCard({ row }: { row: Row }) {
|
||||
const { branch, health } = row;
|
||||
const isActive = branch.status?.toLowerCase() === 'active';
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<HStack justify="between" align="start" gap={1}>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold" maxLines={1}>
|
||||
{branch.locationname}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{branch.suburb ?? branch.city ?? '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Badge
|
||||
variant={isActive ? 'success' : 'neutral'}
|
||||
label={isActive ? 'Active' : 'Inactive'}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* Two channels, two rows, never a sum. */}
|
||||
<VStack gap={0.5}>
|
||||
<ChannelRow label="App" value={money(row.onlineRevenue)} note={`${count(row.onlineOrders)} orders`} />
|
||||
<ChannelRow
|
||||
label="Counter"
|
||||
value={money(row.counterRevenue)}
|
||||
note={`${count(row.counterBills)} bills`}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--color-line)' }} />
|
||||
|
||||
<HStack justify="between" align="center" gap={1}>
|
||||
{/* Plain words, and a link. This line used to read "Stale · 2/3
|
||||
tills" — three pieces of jargon in four words, on the page a
|
||||
merchant lands on. It is now a sentence, and it goes somewhere. */}
|
||||
<Link
|
||||
to="/admin/terminals"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
background: SYNC_COLOR[health.state],
|
||||
}}
|
||||
/>
|
||||
<Text type="body" size="xsm" color="secondary" maxLines={1}>
|
||||
{tillLine(health)}
|
||||
</Text>
|
||||
</Link>
|
||||
{row.pendingRequests > 0 ? (
|
||||
<Badge variant="warning" label={`${row.pendingRequests} to approve`} />
|
||||
) : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "2 counters need a look", not "Stale · 2/3 tills".
|
||||
*
|
||||
* Counts what a person would count: how many counters we cannot currently see,
|
||||
* and whether any of them is holding sales. Everything else is a detail for the
|
||||
* Counters page, which this line links to.
|
||||
*/
|
||||
function tillLine(health: BranchSyncSummary): string {
|
||||
if (health.total === 0) return 'No counter reporting';
|
||||
const absent = health.terminals.filter((t) => t.presence !== 'online').length;
|
||||
if (absent === 0) {
|
||||
return health.pendingBills > 0
|
||||
? `All counters on · ${health.pendingBills} sales still sending`
|
||||
: `All ${health.total} counter${health.total === 1 ? '' : 's'} fine`;
|
||||
}
|
||||
const stuck = health.terminals
|
||||
.filter((t) => t.presence !== 'online')
|
||||
.reduce((sum, t) => sum + t.pendingBills, 0);
|
||||
if (stuck > 0) {
|
||||
return `${stuck} sale${stuck === 1 ? '' : 's'} stuck on ${absent} counter${absent === 1 ? '' : 's'}`;
|
||||
}
|
||||
return `${absent} counter${absent === 1 ? '' : 's'} not answering`;
|
||||
}
|
||||
|
||||
function ChannelRow({ label, value, note }: { label: string; value: string; note: string }) {
|
||||
return (
|
||||
<HStack justify="between" align="center" gap={1}>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{label}
|
||||
</Text>
|
||||
<HStack gap={0.5} align="center">
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
weight="semibold"
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{note}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The comparison table.
|
||||
*
|
||||
* Metrics run down and branches run across, which is the transpose of a normal
|
||||
* data table and the right way round here: the question is "which branch is
|
||||
* behind on revenue", and that is a comparison along a row.
|
||||
*/
|
||||
function ComparisonTable({ rows }: { rows: Row[] }) {
|
||||
const metrics: { label: string; get: (row: Row) => string; total: string }[] = [
|
||||
{
|
||||
label: 'App revenue',
|
||||
get: (row) => money(row.onlineRevenue),
|
||||
total: money(rows.reduce((sum, row) => sum + row.onlineRevenue, 0)),
|
||||
},
|
||||
{
|
||||
label: 'Counter revenue',
|
||||
get: (row) => money(row.counterRevenue),
|
||||
total: money(rows.reduce((sum, row) => sum + row.counterRevenue, 0)),
|
||||
},
|
||||
{
|
||||
label: 'App orders',
|
||||
get: (row) => count(row.onlineOrders),
|
||||
total: count(rows.reduce((sum, row) => sum + row.onlineOrders, 0)),
|
||||
},
|
||||
{
|
||||
label: 'Counter bills',
|
||||
get: (row) => count(row.counterBills),
|
||||
total: count(rows.reduce((sum, row) => sum + row.counterBills, 0)),
|
||||
},
|
||||
{
|
||||
label: 'Cancelled',
|
||||
get: (row) => percent(share(row.cancelled, row.onlineOrders)),
|
||||
total: percent(
|
||||
share(
|
||||
rows.reduce((sum, row) => sum + row.cancelled, 0),
|
||||
rows.reduce((sum, row) => sum + row.onlineOrders, 0),
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Awaiting approval',
|
||||
get: (row) => count(row.pendingRequests),
|
||||
total: count(rows.reduce((sum, row) => sum + row.pendingRequests, 0)),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Metric</Th>
|
||||
{rows.map((row) => (
|
||||
<Th key={row.branch.locationid} align="right">
|
||||
{branchLabel(row.branch.locationname)}
|
||||
</Th>
|
||||
))}
|
||||
<Th align="right">All</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metrics.map((metric) => (
|
||||
<tr key={metric.label}>
|
||||
<Td align="left" isMuted>
|
||||
{metric.label}
|
||||
</Td>
|
||||
{rows.map((row) => (
|
||||
<Td key={row.branch.locationid} align="right">
|
||||
{metric.get(row)}
|
||||
</Td>
|
||||
))}
|
||||
<Td align="right" isStrong>
|
||||
{metric.total}
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The device readings, in words rather than as flags.
|
||||
*
|
||||
* `posStatus` no longer pre-formats a `warnings` array, because the strings it
|
||||
* produced ("Storage low (140 MB)") were written for a status board and are
|
||||
* read here by whoever is scanning a table. Each reading is nullable on
|
||||
* purpose — `models/poshealth.go` uses pointers so that "not reported" stays
|
||||
* distinguishable from "reported as zero" — so absent must never render.
|
||||
*/
|
||||
function deviceNote(terminal: TerminalStatus): string {
|
||||
const notes: string[] = [];
|
||||
if (terminal.printerReachable === false) notes.push('Printer not responding');
|
||||
if (terminal.storageFreeMb !== null && terminal.storageFreeMb < 200) {
|
||||
notes.push(`Low space (${terminal.storageFreeMb} MB)`);
|
||||
}
|
||||
if (terminal.batteryLevel !== null && terminal.batteryLevel < 20 && !terminal.batteryCharging) {
|
||||
notes.push(`Battery ${terminal.batteryLevel}%`);
|
||||
}
|
||||
if (terminal.drawerStatus === 'open') notes.push('Drawer open');
|
||||
return notes.length > 0 ? notes.join(' · ') : '—';
|
||||
}
|
||||
|
||||
function TillTable({ rows }: { rows: Row[] }) {
|
||||
const terminals = rows.flatMap((row) =>
|
||||
row.health.terminals.map((terminal) => ({ branch: row.branch.locationname, terminal })),
|
||||
);
|
||||
|
||||
if (terminals.length === 0) {
|
||||
return (
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
No till is reporting
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
A terminal disappears from this board within seconds of going quiet, so an empty board
|
||||
means nothing is currently reaching us — not that nothing is selling.
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Till</Th>
|
||||
<Th align="left">Branch</Th>
|
||||
<Th align="left">Status</Th>
|
||||
<Th align="right">Queued</Th>
|
||||
<Th align="right">Oldest</Th>
|
||||
<Th align="right">Today</Th>
|
||||
<Th align="left">Notes</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{terminals.map(({ branch, terminal }) => (
|
||||
<tr key={`${branch}:${terminal.terminalId}`}>
|
||||
<Td align="left" isStrong>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
{terminal.terminalId}
|
||||
</span>
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
{branchLabel(branch)}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Badge variant={SYNC_TONE[terminal.state]} label={SYNC_LABEL[terminal.state]} />
|
||||
</Td>
|
||||
<Td align="right" isStrong={terminal.pendingBills > 0}>
|
||||
{terminal.pendingBills > 0 ? count(terminal.pendingBills) : '—'}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
{shortAge(terminal.oldestPendingMs)}
|
||||
</Td>
|
||||
<Td align="right">
|
||||
{count(terminal.todayBills)} · {money(terminal.todayAmount)}
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
{deviceNote(terminal)}
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Alerts ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface Alert {
|
||||
id: string;
|
||||
tone: 'error' | 'warning' | 'accent';
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
detail: string;
|
||||
to: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What needs the admin, ordered by what costs money soonest.
|
||||
*
|
||||
* Stranded takings first: an unsynced till is money already collected that the
|
||||
* business cannot see, and it is the only item here that gets worse purely by
|
||||
* being ignored.
|
||||
*/
|
||||
function buildAlerts(rows: Row[]): Alert[] {
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const stranded = row.health.terminals.filter((terminal) => terminal.pendingBills > 0);
|
||||
if (stranded.length > 0) {
|
||||
const bills = stranded.reduce((sum, terminal) => sum + terminal.pendingBills, 0);
|
||||
const oldest = Math.max(...stranded.map((terminal) => terminal.oldestPendingMs));
|
||||
// Name the till, not just the shop. "Something is wrong at Peelamedu"
|
||||
// makes someone go and look; "PLM-02 is holding 29 bills" tells them
|
||||
// which counter to walk to.
|
||||
const worst = stranded.reduce((acc, terminal) =>
|
||||
terminal.oldestPendingMs > acc.oldestPendingMs ? terminal : acc,
|
||||
);
|
||||
alerts.push({
|
||||
id: `sync:${row.branch.locationid}`,
|
||||
tone: oldest > 30 * 60 * 1000 ? 'error' : 'warning',
|
||||
icon: <Monitor size={15} />,
|
||||
title: `${count(bills)} bill${bills === 1 ? '' : 's'} stranded at ${row.branch.locationname}`,
|
||||
detail: `${stranded.length > 1 ? `${stranded.length} tills, oldest on ` : ''}${worst.terminalId}, waiting ${shortAge(oldest)}. Takings already collected that the business cannot see.`,
|
||||
to: '/admin/terminals',
|
||||
action: 'Open Terminals',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tills we cannot see.
|
||||
*
|
||||
* Presence, not the old `state === 'offline'` test — that check predates
|
||||
* `stale` and `no-heartbeat`, so a till whose heartbeat simply stopped would
|
||||
* have raised nothing here at all. Which was the bug in miniature.
|
||||
*/
|
||||
const unseen = rows.flatMap((row) =>
|
||||
row.health.terminals
|
||||
.filter((terminal) => terminal.presence !== 'online' && terminal.pendingBills === 0)
|
||||
.map((terminal) => ({ row, terminal })),
|
||||
);
|
||||
if (unseen.length > 0) {
|
||||
const branches = [...new Set(unseen.map(({ row }) => row.branch.locationname))];
|
||||
alerts.push({
|
||||
id: 'unseen',
|
||||
tone: 'warning',
|
||||
icon: <AlertTriangle size={15} />,
|
||||
title: `${unseen.length} till${unseen.length === 1 ? '' : 's'} not reporting`,
|
||||
detail: unseen
|
||||
.map(({ terminal }) => `${terminal.terminalId} (${SYNC_LABEL[terminal.state].toLowerCase()})`)
|
||||
.join(', ')
|
||||
.concat(branches.length > 1 ? ` across ${branches.length} branches` : ` at ${branches[0]}`),
|
||||
to: '/admin/terminals',
|
||||
action: 'See tills',
|
||||
});
|
||||
}
|
||||
|
||||
const waiting = rows.reduce((sum, row) => sum + row.pendingRequests, 0);
|
||||
if (waiting > 0) {
|
||||
alerts.push({
|
||||
id: 'requests',
|
||||
tone: 'warning',
|
||||
icon: <ClipboardList size={15} />,
|
||||
title: `${count(waiting)} stock request${waiting === 1 ? '' : 's'} awaiting approval`,
|
||||
detail: 'Nothing reaches a shelf until you approve it — a store cannot add its own stock.',
|
||||
to: '/admin/inventory',
|
||||
action: 'Review requests',
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const cancelRate = share(row.cancelled, row.onlineOrders);
|
||||
if (cancelRate !== null && cancelRate > 0.25 && row.onlineOrders >= 10) {
|
||||
alerts.push({
|
||||
id: `cancel:${row.branch.locationid}`,
|
||||
tone: 'error',
|
||||
icon: <PackageX size={15} />,
|
||||
title: `${percent(cancelRate)} of app orders cancelled at ${row.branch.locationname}`,
|
||||
detail: `${count(row.cancelled)} of ${count(row.onlineOrders)} orders. Usually stock the app thinks is on the shelf and is not.`,
|
||||
to: '/admin/sales',
|
||||
action: 'Open Sales',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return alerts;
|
||||
}
|
||||
|
||||
function AlertRow({ alert }: { alert: Alert }) {
|
||||
const color =
|
||||
alert.tone === 'error'
|
||||
? 'var(--color-error, #d64545)'
|
||||
: alert.tone === 'warning'
|
||||
? 'var(--color-warning, #b7860b)'
|
||||
: 'var(--color-brand)';
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack justify="between" align="center" gap={2} style={{ padding: 16 }}>
|
||||
<HStack gap={1.5} align="start" style={{ minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
flex: 'none',
|
||||
background: `color-mix(in oklab, ${color} 12%, transparent)`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{alert.icon}
|
||||
</span>
|
||||
<VStack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{alert.title}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{alert.detail}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Button
|
||||
label={alert.action}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
href={alert.to}
|
||||
as={Link}
|
||||
icon={<ArrowRight size={14} />}
|
||||
style={{ width: 160, flex: 'none', justifyContent: 'center' }}
|
||||
/>
|
||||
</HStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Table primitives ────────────────────────────────────────────────────── */
|
||||
|
||||
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '11px 14px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
fontWeight: isStrong ? 600 : 400,
|
||||
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
727
src/features/store-admin/pages/InventoryPage.tsx
Normal file
727
src/features/store-admin/pages/InventoryPage.tsx
Normal file
@@ -0,0 +1,727 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ClipboardList,
|
||||
Info,
|
||||
Layers,
|
||||
PackageSearch,
|
||||
Send,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { stockApi } from '@/api/stock';
|
||||
import type { StockRequest } from '@/api/types';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { useStockRequests, useStockStatement } from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { branchLabel, count, money } from '../format';
|
||||
|
||||
type Tab = 'catalogue' | 'stock' | 'requests';
|
||||
|
||||
/**
|
||||
* Inventory — catalogue, stock and the approval queue.
|
||||
*
|
||||
* The spec's three catalogue tiers are real in Fiesta, not an invention of the
|
||||
* UI. `productlocations.publishedat` is NULL while a product sits in the admin
|
||||
* catalogue and stamped once it is released to the shops, and the column is
|
||||
* deliberately read-only through ordinary writes so that setting a price cannot
|
||||
* accidentally publish. So "review, price, then publish" is the backend's own
|
||||
* model, and this page follows it rather than layering a second one on top.
|
||||
*
|
||||
* What is NOT real: per-store assignment. `PublishProduct` takes a tenant and a
|
||||
* product and releases to every outlet. The store checkboxes below are built to
|
||||
* the spec and marked pending, per the decision to have the UI lead and the
|
||||
* backend catch up. Nothing on screen claims the selection is in force.
|
||||
*/
|
||||
export function InventoryPage() {
|
||||
const { branches, scoped, selected, current, tenantid } = useBranchScope();
|
||||
const [tab, setTab] = useState<Tab>('requests');
|
||||
|
||||
const requests = useStockRequests(
|
||||
tenantid ? { tenantid, locationid: selected ?? undefined } : undefined,
|
||||
);
|
||||
|
||||
const pending = useMemo(
|
||||
() => (requests.data ?? []).filter((entry) => entry.status === 'Pending'),
|
||||
[requests.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Inventory"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
description="What you sell, where it is stocked, and what the shops are asking you for."
|
||||
actions={
|
||||
pending.length > 0 ? (
|
||||
<Badge variant="warning" label={`${pending.length} awaiting approval`} />
|
||||
) : undefined
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
||||
<TabButton
|
||||
label="Stock requests"
|
||||
icon={<ClipboardList size={14} />}
|
||||
isActive={tab === 'requests'}
|
||||
onClick={() => setTab('requests')}
|
||||
badge={pending.length || undefined}
|
||||
/>
|
||||
<TabButton
|
||||
label="Stock"
|
||||
icon={<Layers size={14} />}
|
||||
isActive={tab === 'stock'}
|
||||
onClick={() => setTab('stock')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Catalogue"
|
||||
icon={<PackageSearch size={14} />}
|
||||
isActive={tab === 'catalogue'}
|
||||
onClick={() => setTab('catalogue')}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{tab === 'requests' ? (
|
||||
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} />
|
||||
) : tab === 'stock' ? (
|
||||
<StockPanel />
|
||||
) : (
|
||||
<CataloguePanel branchCount={scoped.length} />
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Stock requests ──────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The approval queue.
|
||||
*
|
||||
* Approve means "mark Received", which posts the full requested quantity to
|
||||
* that branch's stock. There is no approve-for-less: the service reads the
|
||||
* quantity off the request row and the call carries only a status string. The
|
||||
* quantity field below is therefore shown read-only with the reason attached,
|
||||
* rather than as an input that silently discards what the admin types.
|
||||
*/
|
||||
function RequestsPanel({
|
||||
requests,
|
||||
isLoading,
|
||||
}: {
|
||||
requests: StockRequest[];
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [acting, setActing] = useState<number | null>(null);
|
||||
|
||||
const decide = useMutation({
|
||||
mutationFn: ({ requestid, approve }: { requestid: number; approve: boolean }) =>
|
||||
approve ? stockApi.approve(requestid) : stockApi.reject(requestid),
|
||||
onSettled: async () => {
|
||||
setActing(null);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.stock.all });
|
||||
// Approving moves stock, so anything counting stock is stale too.
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.products.all });
|
||||
},
|
||||
});
|
||||
|
||||
const pending = requests.filter((entry) => entry.status === 'Pending');
|
||||
const settled = requests.filter((entry) => entry.status !== 'Pending');
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Awaiting you"
|
||||
value={count(pending.length)}
|
||||
note="nothing reaches a shelf until approved"
|
||||
tone={pending.length > 0 ? 'warning' : 'success'}
|
||||
icon={<ClipboardList size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Units requested"
|
||||
value={count(pending.reduce((sum, entry) => sum + entry.qty, 0))}
|
||||
note="across open requests"
|
||||
tone="neutral"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Settled"
|
||||
value={count(settled.length)}
|
||||
note="approved or rejected"
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Open requests" note="a store cannot add its own stock" />
|
||||
{isLoading ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading the queue…
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : pending.length === 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Nothing waiting
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Every request has been decided. New ones appear here the moment a store raises them.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : (
|
||||
<VStack gap={1}>
|
||||
{pending.map((request) => (
|
||||
<Card key={request.requestid} padding={0} elevation="low">
|
||||
<HStack justify="between" align="center" gap={2} style={{ padding: 16 }}>
|
||||
<HStack gap={1.5} align="center" style={{ minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
flex: 'none',
|
||||
background: 'var(--color-slate-50)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{request.productimage ? (
|
||||
<img
|
||||
src={request.productimage}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<PackageSearch size={16} style={{ color: 'var(--color-slate-300)' }} />
|
||||
)}
|
||||
</span>
|
||||
<VStack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text type="label" size="sm" weight="semibold" maxLines={1}>
|
||||
{request.productname ?? `Product ${request.productid}`}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{branchLabel(request.locationname) || `Branch ${request.locationid}`} · asked for{' '}
|
||||
<strong>{count(request.qty)}</strong> units
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={1} align="center" style={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
label="Reject"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<X size={14} />}
|
||||
isDisabled={decide.isPending && acting === request.requestid}
|
||||
onClick={() => {
|
||||
setActing(request.requestid);
|
||||
decide.mutate({ requestid: request.requestid, approve: false });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label={`Approve ${count(request.qty)}`}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Check size={14} />}
|
||||
isDisabled={decide.isPending && acting === request.requestid}
|
||||
onClick={() => {
|
||||
setActing(request.requestid);
|
||||
decide.mutate({ requestid: request.requestid, approve: true });
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Stated once, under the queue, rather than on every row. */}
|
||||
<HStack gap={1} align="start">
|
||||
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Approving posts the <strong>full requested quantity</strong> to that branch. Fiesta
|
||||
reads the amount off the request row, so approving for less is not possible yet — and
|
||||
the request carries no reason, requester or remarks, because those columns do not exist.
|
||||
To give a shop a different amount, reject and ask them to re-raise it.
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{settled.length > 0 ? (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Decided" note="most recent first" />
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Product</Th>
|
||||
<Th align="left">Branch</Th>
|
||||
<Th align="right">Qty</Th>
|
||||
<Th align="left">Outcome</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{settled.slice(0, 25).map((request) => (
|
||||
<tr key={request.requestid}>
|
||||
<Td align="left" isStrong>
|
||||
{request.productname ?? `Product ${request.productid}`}
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
{branchLabel(request.locationname) || `Branch ${request.locationid}`}
|
||||
</Td>
|
||||
<Td align="right">{count(request.qty)}</Td>
|
||||
<Td align="left">
|
||||
<Badge
|
||||
variant={request.status === 'Received' ? 'success' : 'neutral'}
|
||||
label={request.status === 'Received' ? 'Stock added' : request.status}
|
||||
/>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Stock ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Stock levels and movement.
|
||||
*
|
||||
* Opening → in → out → closing is the whole vocabulary the ledger has.
|
||||
* `productstocks.stocktype` is `in`/`out` with no reason column, so a sale, a
|
||||
* branch transfer and a stock-take correction are indistinguishable once
|
||||
* written. The spec's seven movement types are not shown, because showing seven
|
||||
* columns sourced from two would be a fabrication.
|
||||
*/
|
||||
function StockPanel() {
|
||||
const { scoped, tenantid } = useBranchScope();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
// One branch at a time: the statement endpoint requires a single locationid.
|
||||
const branch = scoped[0];
|
||||
const statement = useStockStatement(tenantid || undefined, branch?.locationid, {
|
||||
keyword: keyword.trim() || undefined,
|
||||
pagesize: 100,
|
||||
});
|
||||
|
||||
const rows = statement.data ?? [];
|
||||
const value = rows.reduce(
|
||||
(sum, row) => sum + (row.closing ?? 0) * (row.retailprice ?? 0),
|
||||
0,
|
||||
);
|
||||
const low = rows.filter((row) => (row.closing ?? 0) > 0 && (row.closing ?? 0) <= 5).length;
|
||||
const out = rows.filter((row) => (row.closing ?? 0) <= 0).length;
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Stock value"
|
||||
value={money(value)}
|
||||
note={`${count(rows.length)} products`}
|
||||
tone="accent"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Low stock"
|
||||
value={count(low)}
|
||||
note="5 units or fewer"
|
||||
tone={low > 0 ? 'warning' : 'neutral'}
|
||||
icon={<AlertTriangle size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Out of stock"
|
||||
value={count(out)}
|
||||
note="nothing on the shelf"
|
||||
tone={out > 0 ? 'error' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title={branch ? `Stock at ${branch.locationname}` : 'Stock'}
|
||||
note="opening → in → out → closing"
|
||||
action={
|
||||
<div style={{ width: 240 }}>
|
||||
<TextInput
|
||||
label="Search stock"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search products…"
|
||||
hasClear
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{scoped.length > 1 ? (
|
||||
<HStack gap={1} align="start">
|
||||
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Showing <strong>{branch?.locationname}</strong>. The stock statement is scoped to one
|
||||
branch by the backend — pick a branch in the header to switch.
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
{statement.isLoading ? (
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading stock…
|
||||
</Text>
|
||||
</VStack>
|
||||
) : rows.length === 0 ? (
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
No stock recorded
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Products reach a shelf through an approved stock request, not by being in the
|
||||
catalogue.
|
||||
</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Product</Th>
|
||||
<Th align="right">Opening</Th>
|
||||
<Th align="right">In</Th>
|
||||
<Th align="right">Out</Th>
|
||||
<Th align="right">Closing</Th>
|
||||
<Th align="right">Value</Th>
|
||||
<Th align="left">Status</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const closing = row.closing ?? 0;
|
||||
return (
|
||||
<tr key={row.productid}>
|
||||
<Td align="left" isStrong>
|
||||
{row.productname ?? `Product ${row.productid}`}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
{count(row.opening)}
|
||||
</Td>
|
||||
<Td align="right">{count(row.credit)}</Td>
|
||||
<Td align="right">{count(row.debit)}</Td>
|
||||
<Td align="right" isStrong>
|
||||
{count(closing)}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
{money(closing * (row.retailprice ?? 0))}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Badge
|
||||
variant={closing <= 0 ? 'error' : closing <= 5 ? 'warning' : 'success'}
|
||||
label={closing <= 0 ? 'Out' : closing <= 5 ? 'Low' : 'In stock'}
|
||||
/>
|
||||
</Td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Catalogue ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The catalogue tiers and how a product moves between them.
|
||||
*
|
||||
* Deliberately an explanation plus the two entry points rather than a second
|
||||
* copy of the Nearle Admin's catalogue browser: the import UI already exists
|
||||
* and works, and duplicating it here is how two screens drift apart.
|
||||
*/
|
||||
function CataloguePanel({ branchCount }: { branchCount: number }) {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="How a product reaches a shelf" note="four steps, two entry points" />
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<div className="form-grid">
|
||||
<Step
|
||||
n={1}
|
||||
title="Into your catalogue"
|
||||
body="Import from the global catalogue, or upload your own spreadsheet. Only you can do either — a store manager cannot."
|
||||
/>
|
||||
<Step
|
||||
n={2}
|
||||
title="Price it"
|
||||
body="The global catalogue carries a price range, not a price. Yours is the one customers pay, and it is set per branch."
|
||||
/>
|
||||
<Step
|
||||
n={3}
|
||||
title="Publish"
|
||||
body={`Publishing releases the product to all ${branchCount} branch${branchCount === 1 ? '' : 'es'}. Until then it is yours alone and no store can see it.`}
|
||||
/>
|
||||
<Step
|
||||
n={4}
|
||||
title="Stock it"
|
||||
body="A published product is not stock. A store raises a request, you approve it, and only then does anything reach the shelf."
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Add products" note="both paths end in the same catalogue" />
|
||||
<div className="card-grid">
|
||||
<EntryCard
|
||||
icon={<PackageSearch size={18} />}
|
||||
title="From the global catalogue"
|
||||
body="1,267 FMCG products with images and pack sizes. Pick what you sell and set your own price."
|
||||
action="Browse catalogue"
|
||||
/>
|
||||
<EntryCard
|
||||
icon={<Send size={18} />}
|
||||
title="From a spreadsheet"
|
||||
body="Your own product list. Validated before anything is created — you see valid, duplicate and error rows, and can download the failures to fix and re-upload."
|
||||
action="Upload a sheet"
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack gap={1.5} align="start" style={{ padding: 16 }}>
|
||||
<Info size={16} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Two things the spec asks for that the backend cannot do yet
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
<strong>Per-branch assignment.</strong> Publishing releases a product to every
|
||||
branch — `PublishProduct` takes a tenant and a product, with no location list. The
|
||||
per-branch controls are built to your spec and will start working the day that
|
||||
parameter lands; nothing here pretends the selection is in force today.
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
<strong>Duplicate detection by SKU.</strong> Across the live catalogue 6,245 products
|
||||
share 93 distinct SKU values and 154 are blank, so a SKU cannot decide that two rows
|
||||
are the same product. Upload validation warns on repeats but will not merge on them,
|
||||
and there is no barcode column anywhere in Fiesta to fall back to.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ n, title, body }: { n: number; title: string; body: string }) {
|
||||
return (
|
||||
<VStack gap={0.5}>
|
||||
<HStack gap={1} align="center">
|
||||
<span
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{body}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryCard({
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
action,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
body: string;
|
||||
action: string;
|
||||
}) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<span
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{body}
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<Button label={action} variant="secondary" size="sm" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Primitives ──────────────────────────────────────────────────────────── */
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
onClick,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{badge ? (
|
||||
<span
|
||||
style={{
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-warning, #b7860b)',
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '11px 14px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
fontWeight: isStrong ? 600 : 400,
|
||||
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
321
src/features/store-admin/pages/OnboardBranchPage.tsx
Normal file
321
src/features/store-admin/pages/OnboardBranchPage.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import { useMemo, useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { TimeInput } from '@astryxdesign/core/TimeInput';
|
||||
import { createISOTimeString, type ISOTimeString } from '@astryxdesign/core/utils';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertCircle, CheckCircle2, Clock, MapPin, Store } from 'lucide-react';
|
||||
import { tenantsApi, type CreateBranchRequest } from '@/api/tenants';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { PageBody } from '@/components/PageBody';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { useTenants } from '@/queries/hooks';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
|
||||
interface FormState {
|
||||
tenantid: string;
|
||||
locationname: string;
|
||||
email: string;
|
||||
contactno: string;
|
||||
address: string;
|
||||
suburb: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
opentime: ISOTimeString;
|
||||
closetime: ISOTimeString;
|
||||
deliveryradius: number;
|
||||
deliverymins: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `createISOTimeString` returns null for anything it cannot parse. These two are
|
||||
* compile-time literals that are known-good, so a failure here is a typo in this
|
||||
* file rather than bad input — fail loudly instead of defaulting silently.
|
||||
*/
|
||||
function isoTime(value: string): ISOTimeString {
|
||||
const parsed = createISOTimeString(value);
|
||||
if (!parsed) throw new Error(`Invalid default time: ${value}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
tenantid: '',
|
||||
locationname: '',
|
||||
email: '',
|
||||
contactno: '',
|
||||
address: '',
|
||||
suburb: '',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '',
|
||||
opentime: isoTime('08:00'),
|
||||
closetime: isoTime('22:00'),
|
||||
deliveryradius: 5000,
|
||||
deliverymins: 30,
|
||||
};
|
||||
|
||||
/**
|
||||
* Commission a branch under an existing tenant.
|
||||
*
|
||||
* Sets the store's parameters and delivery thresholds, and spawns a placeholder
|
||||
* branch-manager account. The delivery radius and estimated speed are what the
|
||||
* customer app uses to decide whether an address is servable, so they are
|
||||
* required rather than optional-with-a-default-nobody-checks.
|
||||
*
|
||||
* ── PARKED, NOT DEAD ────────────────────────────────────────────────────────
|
||||
* Branch onboarding belongs to the Store Admin, not to us: a merchant opens
|
||||
* their own outlets. This file is therefore unrouted until the Store Admin
|
||||
* workspace exists, and it lives here rather than under `nearle-admin/` so its
|
||||
* eventual owner is obvious from the path.
|
||||
*
|
||||
* Two things must change when it is wired up:
|
||||
* 1. Drop the tenant Selector. A Store Admin has exactly one tenant and it
|
||||
* comes from the session, so asking them to pick it is asking them to
|
||||
* confirm something they cannot get wrong.
|
||||
* 2. Take `tenantid` from `useAuth()` instead of form state, and let the
|
||||
* backend reject a mismatch rather than trusting the client's number.
|
||||
*/
|
||||
export function OnboardBranchPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: tenants, isLoading: isLoadingTenants } = useTenants();
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/** One option per tenant — `getalltenants` returns a row per branch. */
|
||||
const tenantOptions = useMemo(() => {
|
||||
if (!tenants) return [];
|
||||
const seen = new Map<number, string>();
|
||||
for (const tenant of tenants as TenantInfo[]) {
|
||||
if (!seen.has(tenant.tenantid)) seen.set(tenant.tenantid, tenant.tenantname);
|
||||
}
|
||||
return [...seen.entries()].map(([id, name]) => ({ value: String(id), label: name }));
|
||||
}, [tenants]);
|
||||
|
||||
function set<K extends keyof FormState>(key: K) {
|
||||
return (value: FormState[K]) => setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: CreateBranchRequest) => tenantsApi.createBranch(body),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all });
|
||||
},
|
||||
onError: (cause) => setError(errorMessage(cause)),
|
||||
});
|
||||
|
||||
const isComplete =
|
||||
form.tenantid !== '' &&
|
||||
form.locationname.trim() !== '' &&
|
||||
form.address.trim() !== '' &&
|
||||
form.city.trim() !== '' &&
|
||||
form.postcode.trim() !== '';
|
||||
|
||||
function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
tenantid: Number(form.tenantid),
|
||||
locationname: form.locationname.trim(),
|
||||
email: form.email.trim(),
|
||||
contactno: form.contactno.trim(),
|
||||
address: form.address.trim(),
|
||||
suburb: form.suburb.trim(),
|
||||
city: form.city.trim(),
|
||||
state: form.state.trim(),
|
||||
postcode: form.postcode.trim(),
|
||||
opentime: form.opentime,
|
||||
closetime: form.closetime,
|
||||
deliveryradius: form.deliveryradius,
|
||||
deliverymins: form.deliverymins,
|
||||
status: 'Active',
|
||||
});
|
||||
}
|
||||
|
||||
if (mutation.isSuccess) {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader title="Branch commissioned" description="The outlet is live and can take orders." />
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
<Text type="large" weight="semibold">
|
||||
{form.locationname} is commissioned
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" color="secondary">
|
||||
A placeholder branch-manager account was created with it. The branch has no catalogue
|
||||
yet — products are published to it per store.
|
||||
</Text>
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<Button
|
||||
label="Commission another"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
mutation.reset();
|
||||
setForm({ ...EMPTY, tenantid: form.tenantid });
|
||||
}}
|
||||
/>
|
||||
<Button label="Back to stores" variant="primary" onClick={() => navigate('/nearle/stores')} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageBody measure="reading">
|
||||
<PageHeader
|
||||
title="Onboard branch"
|
||||
description="Commission a new outlet under an existing tenant and set its delivery thresholds."
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<VStack gap={3}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Owner and identity"
|
||||
note="which tenant this branch belongs to"
|
||||
action={<Store size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label={<span>Owner tenant <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
options={tenantOptions}
|
||||
value={form.tenantid}
|
||||
onChange={set('tenantid')}
|
||||
placeholder={isLoadingTenants ? 'Loading tenants…' : 'Select tenant…'}
|
||||
isLoading={isLoadingTenants}
|
||||
hasSearch
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.locationname}
|
||||
onChange={set('locationname')}
|
||||
placeholder="e.g. RS Puram Super Hub"
|
||||
/>
|
||||
<TextInput
|
||||
label="Outlet email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={set('email')}
|
||||
placeholder="rspuram@kaveri.com"
|
||||
/>
|
||||
<TextInput
|
||||
label="Outlet contact"
|
||||
value={form.contactno}
|
||||
onChange={set('contactno')}
|
||||
placeholder="9876543222"
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Location"
|
||||
note="where the outlet sits"
|
||||
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Street address <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.address}
|
||||
onChange={set('address')}
|
||||
placeholder="e.g. 240, DB Road"
|
||||
/>
|
||||
<div className="form-grid-4">
|
||||
<TextInput label="Suburb" value={form.suburb} onChange={set('suburb')} placeholder="e.g. RS Puram" />
|
||||
<TextInput label={<span>City <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.city} onChange={set('city')} />
|
||||
<TextInput label={<span>State <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.state} onChange={set('state')} />
|
||||
<TextInput label={<span>Postcode <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.postcode} onChange={set('postcode')} />
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Hours and delivery"
|
||||
note="what the customer app uses to decide if an address is servable"
|
||||
action={<Clock size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div className="form-grid-4">
|
||||
<TimeInput
|
||||
label="Open time"
|
||||
value={form.opentime}
|
||||
onChange={(value) =>
|
||||
setForm((prev) => ({ ...prev, opentime: value ?? prev.opentime }))
|
||||
}
|
||||
/>
|
||||
<TimeInput
|
||||
label="Close time"
|
||||
value={form.closetime}
|
||||
onChange={(value) =>
|
||||
setForm((prev) => ({ ...prev, closetime: value ?? prev.closetime }))
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Delivery radius (m)"
|
||||
value={form.deliveryradius}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, deliveryradius: value ?? 0 }))}
|
||||
min={0}
|
||||
step={500}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Est. delivery (mins)"
|
||||
value={form.deliverymins}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, deliverymins: value ?? 0 }))}
|
||||
min={0}
|
||||
step={5}
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{error ? (
|
||||
<HStack
|
||||
align="center"
|
||||
gap={1}
|
||||
padding={2}
|
||||
style={{
|
||||
background: 'var(--color-error-muted, #fceeee)',
|
||||
borderRadius: 12,
|
||||
color: 'var(--color-error, #d64545)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={17} />
|
||||
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||
{error}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack justify="end">
|
||||
<Button
|
||||
label={mutation.isPending ? 'Commissioning…' : 'Commission branch'}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={mutation.isPending}
|
||||
isDisabled={!isComplete}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</form>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
576
src/features/store-admin/pages/ReportsPage.tsx
Normal file
576
src/features/store-admin/pages/ReportsPage.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { BarChart3, Boxes, IndianRupee, Info } from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useLocationSummary, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||
import { branchLabel, count, money, percent, share } from '../format';
|
||||
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
|
||||
type Tab = 'revenue' | 'sales' | 'inventory';
|
||||
|
||||
/**
|
||||
* Reports — revenue, sales and stock.
|
||||
*
|
||||
* The same rule as the Console: the two channels are reported side by side and
|
||||
* never summed. Every comparison on this page is either within a channel or
|
||||
* between branches, both of which are sound; a figure that mixed `orders` and
|
||||
* `posorders` would not be.
|
||||
*/
|
||||
export function ReportsPage() {
|
||||
const { branches, scoped, selected, current, tenantid } = useBranchScope();
|
||||
const [tab, setTab] = useState<Tab>('revenue');
|
||||
const [preset, setPreset] = useState<RangePreset>('month');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('month'));
|
||||
|
||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||
const orders = useLocationSummary(tenantid || undefined);
|
||||
const posSales = usePosSalesByBranch(branchIds, range);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
scoped.map((branch, index) => {
|
||||
const order = (orders.data ?? []).find((entry) => entry.locationid === branch.locationid);
|
||||
const pos = posSales[index]?.data;
|
||||
return {
|
||||
branch,
|
||||
appRevenue: order?.revenue ?? 0,
|
||||
appOrders: order?.totalorders ?? 0,
|
||||
cancelled: order?.cancelled ?? 0,
|
||||
delivered: order?.delivered ?? 0,
|
||||
counterRevenue: pos?.grosssales ?? 0,
|
||||
counterBills: pos?.billcount ?? 0,
|
||||
tax: pos?.taxcollected ?? 0,
|
||||
discount: pos?.discountgiven ?? 0,
|
||||
byDay: pos?.byday ?? [],
|
||||
};
|
||||
}),
|
||||
[scoped, orders.data, posSales],
|
||||
);
|
||||
|
||||
const totals = rows.reduce(
|
||||
(acc, row) => ({
|
||||
appRevenue: acc.appRevenue + row.appRevenue,
|
||||
appOrders: acc.appOrders + row.appOrders,
|
||||
cancelled: acc.cancelled + row.cancelled,
|
||||
delivered: acc.delivered + row.delivered,
|
||||
counterRevenue: acc.counterRevenue + row.counterRevenue,
|
||||
counterBills: acc.counterBills + row.counterBills,
|
||||
tax: acc.tax + row.tax,
|
||||
discount: acc.discount + row.discount,
|
||||
}),
|
||||
{
|
||||
appRevenue: 0,
|
||||
appOrders: 0,
|
||||
cancelled: 0,
|
||||
delivered: 0,
|
||||
counterRevenue: 0,
|
||||
counterBills: 0,
|
||||
tax: 0,
|
||||
discount: 0,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Reports"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
description="Revenue and stock over a period you choose, compared where a comparison is sound."
|
||||
actions={
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
||||
<TabButton
|
||||
label="Revenue"
|
||||
icon={<IndianRupee size={14} />}
|
||||
isActive={tab === 'revenue'}
|
||||
onClick={() => setTab('revenue')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Sales"
|
||||
icon={<BarChart3 size={14} />}
|
||||
isActive={tab === 'sales'}
|
||||
onClick={() => setTab('sales')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Inventory"
|
||||
icon={<Boxes size={14} />}
|
||||
isActive={tab === 'inventory'}
|
||||
onClick={() => setTab('inventory')}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{tab === 'revenue' ? (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="App revenue"
|
||||
value={money(totals.appRevenue)}
|
||||
note={`${count(totals.appOrders)} orders`}
|
||||
tone="accent"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Counter revenue"
|
||||
value={money(totals.counterRevenue)}
|
||||
note={`${count(totals.counterBills)} bills`}
|
||||
tone="success"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Tax collected"
|
||||
value={money(totals.tax)}
|
||||
note="counter sales"
|
||||
tone="neutral"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Discount given"
|
||||
value={money(totals.discount)}
|
||||
note="counter sales"
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChannelSplit app={totals.appRevenue} counter={totals.counterRevenue} />
|
||||
|
||||
{rows.length > 1 ? (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="By branch" note="within a channel, so the comparison holds" />
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Branch</Th>
|
||||
<Th align="right">App revenue</Th>
|
||||
<Th align="right">Counter revenue</Th>
|
||||
<Th align="right">App orders</Th>
|
||||
<Th align="right">Counter bills</Th>
|
||||
<Th align="right">Avg bill</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.branch.locationid}>
|
||||
<Td align="left" isStrong>
|
||||
{branchLabel(row.branch.locationname)}
|
||||
</Td>
|
||||
<Td align="right">{money(row.appRevenue)}</Td>
|
||||
<Td align="right">{money(row.counterRevenue)}</Td>
|
||||
<Td align="right" isMuted>
|
||||
{count(row.appOrders)}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
{count(row.counterBills)}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
{row.counterBills > 0
|
||||
? money(row.counterRevenue / row.counterBills)
|
||||
: '—'}
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
) : tab === 'sales' ? (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Fulfilled"
|
||||
value={count(totals.delivered)}
|
||||
note={`of ${count(totals.appOrders)} app orders`}
|
||||
tone="success"
|
||||
fill={share(totals.delivered, totals.appOrders) ?? undefined}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Cancelled"
|
||||
value={percent(share(totals.cancelled, totals.appOrders))}
|
||||
note={`${count(totals.cancelled)} orders`}
|
||||
tone={
|
||||
(share(totals.cancelled, totals.appOrders) ?? 0) > 0.1 ? 'error' : 'neutral'
|
||||
}
|
||||
fill={share(totals.cancelled, totals.appOrders) ?? undefined}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Counter bills"
|
||||
value={count(totals.counterBills)}
|
||||
note="rung up at a till"
|
||||
tone="neutral"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Average bill"
|
||||
value={
|
||||
totals.counterBills > 0
|
||||
? money(totals.counterRevenue / totals.counterBills)
|
||||
: '—'
|
||||
}
|
||||
note="counter sales"
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DailyTrend rows={rows} />
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack gap={1.5} align="start" style={{ padding: 16 }}>
|
||||
<Info size={16} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Fast and slow movers need one more read
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Product-level performance comes from the stock statement, not from the sales
|
||||
summary — movement out of stock is what makes a product fast or slow. It is on the
|
||||
Inventory tab, per branch, because the statement endpoint is scoped to a single
|
||||
location.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
) : (
|
||||
<InventoryReport />
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The channel split, as a bar rather than a pie.
|
||||
*
|
||||
* Two quantities that must not be added still compare fine as lengths, and a
|
||||
* bar makes "counter is two thirds of the business" readable without implying a
|
||||
* whole they sum to.
|
||||
*/
|
||||
function ChannelSplit({ app, counter }: { app: number; counter: number }) {
|
||||
const max = Math.max(app, counter, 1);
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="App versus counter" note="compared, not combined" />
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<Bar label="App" value={app} max={max} color="var(--color-brand)" />
|
||||
<Bar
|
||||
label="Counter"
|
||||
value={counter}
|
||||
max={max}
|
||||
color="var(--color-success, #10b981)"
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Bar({
|
||||
label,
|
||||
value,
|
||||
max,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
max: number;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={0.5}>
|
||||
<HStack justify="between" align="center">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{label}
|
||||
</Text>
|
||||
<Text type="label" size="sm" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{money(value)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<div
|
||||
style={{
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-surface-sunken)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.max(2, (value / max) * 100)}%`,
|
||||
height: '100%',
|
||||
borderRadius: 999,
|
||||
background: color,
|
||||
transition: 'width .4s cubic-bezier(.16,1,.3,1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
/** Counter takings per trading day, straight from `PosSalesSummary.byday`. */
|
||||
function DailyTrend({ rows }: { rows: { byDay: { day?: string; amount?: number }[] }[] }) {
|
||||
const chartData = useMemo(() => {
|
||||
const totals = new Array(7).fill(0);
|
||||
let hasData = false;
|
||||
for (const row of rows) {
|
||||
for (const entry of row.byDay) {
|
||||
if (!entry.day) continue;
|
||||
const d = new Date(entry.day);
|
||||
if (!isNaN(d.getTime())) {
|
||||
const dayIndex = (d.getDay() + 6) % 7;
|
||||
totals[dayIndex] += (entry.amount ?? 0);
|
||||
hasData = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasData ? WEEKDAYS.map((name, index) => ({ name, amount: totals[index] })) : [];
|
||||
}, [rows]);
|
||||
|
||||
if (chartData.length === 0) return null;
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Counter takings by day" note="every till, combined" />
|
||||
<Card padding={0} elevation="low">
|
||||
<div style={{ padding: '24px 20px', height: 260, overflowX: 'auto' }}>
|
||||
<div style={{ minWidth: 400, height: '100%' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsBarChart data={chartData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 13, fill: 'var(--color-slate-500)', fontWeight: 500 }}
|
||||
dy={10}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'var(--color-surface-sunken)', radius: 6 }}
|
||||
formatter={(value: any) => [money(value as number), 'Takings']}
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-low)',
|
||||
background: 'var(--color-background-surface)',
|
||||
padding: '8px 12px'
|
||||
}}
|
||||
itemStyle={{ color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
<RechartsBar
|
||||
dataKey="amount"
|
||||
fill="var(--color-brand)"
|
||||
radius={[6, 6, 6, 6]}
|
||||
barSize={36}
|
||||
/>
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory reporting, per branch.
|
||||
*
|
||||
* Fast and slow movers come from the stock statement's `debit` — what actually
|
||||
* left the shelf — rather than from a sales table, because that is the one
|
||||
* figure that counts both channels without double-counting either.
|
||||
*/
|
||||
function InventoryReport() {
|
||||
const { scoped, tenantid } = useBranchScope();
|
||||
const branch = scoped[0];
|
||||
const statement = useStockStatement(tenantid || undefined, branch?.locationid, {
|
||||
pagesize: 200,
|
||||
});
|
||||
|
||||
const rows = statement.data ?? [];
|
||||
const moving = [...rows].sort((a, b) => (b.debit ?? 0) - (a.debit ?? 0));
|
||||
const fast = moving.slice(0, 5);
|
||||
const slow = moving.filter((row) => (row.closing ?? 0) > 0).slice(-5).reverse();
|
||||
const value = rows.reduce((sum, row) => sum + (row.closing ?? 0) * (row.retailprice ?? 0), 0);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Inventory value"
|
||||
value={money(value)}
|
||||
note={branch?.locationname ?? '—'}
|
||||
tone="accent"
|
||||
/>
|
||||
<KpiCard label="Products" value={count(rows.length)} note="with a stock record" tone="neutral" />
|
||||
<KpiCard
|
||||
label="Units moved"
|
||||
value={count(rows.reduce((sum, row) => sum + (row.debit ?? 0), 0))}
|
||||
note="off the shelf this period"
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<MoverList title="Fast moving" note="most units off the shelf" rows={fast} />
|
||||
<MoverList title="Slow moving" note="in stock, barely selling" rows={slow} />
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function MoverList({
|
||||
title,
|
||||
note,
|
||||
rows,
|
||||
}: {
|
||||
title: string;
|
||||
note: string;
|
||||
rows: { productid: number; productname?: string; debit?: number; closing?: number }[];
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title={title} note={note} />
|
||||
<Card padding={0} elevation="low">
|
||||
{rows.length === 0 ? (
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Not enough movement to rank yet.
|
||||
</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<VStack>
|
||||
{rows.map((row, index) => (
|
||||
<HStack
|
||||
key={row.productid}
|
||||
justify="between"
|
||||
align="center"
|
||||
gap={1}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: index === 0 ? undefined : '1px solid var(--color-line)',
|
||||
}}
|
||||
>
|
||||
<Text type="body" size="sm" maxLines={1}>
|
||||
{row.productname ?? `Product ${row.productid}`}
|
||||
</Text>
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
weight="semibold"
|
||||
style={{ fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}
|
||||
>
|
||||
{count(row.debit)} sold
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '11px 14px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
fontWeight: isStrong ? 600 : 400,
|
||||
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
1176
src/features/store-admin/pages/SalesPage.tsx
Normal file
1176
src/features/store-admin/pages/SalesPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
566
src/features/store-admin/pages/TerminalsPage.tsx
Normal file
566
src/features/store-admin/pages/TerminalsPage.tsx
Normal file
@@ -0,0 +1,566 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronRight, EyeOff, Pencil } from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||
import { branchLabel } from '../format';
|
||||
import { useCounterLabels, type CounterLabels } from '../counterLabels';
|
||||
import { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
|
||||
import { BUCKET_COLOR, BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
|
||||
import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard';
|
||||
|
||||
/**
|
||||
* Counters — the till board, written for the person who actually opens it.
|
||||
*
|
||||
* That person is a shop supervisor who knows the shop and nothing about
|
||||
* networks. The previous version of this page was accurate and unusable by
|
||||
* them: it led with a terminal code and a word like "Stale", put the money in
|
||||
* the smallest grey type at the bottom, and expected the reader to translate
|
||||
* severity into action.
|
||||
*
|
||||
* So: three plainly-named groups, one problem per card, the stake in the
|
||||
* headline, one instruction in bold, and every technical reading — version,
|
||||
* battery, storage, exact timestamps — behind a Details toggle, because that is
|
||||
* support information and support is not who is looking.
|
||||
*
|
||||
* A shop with nothing wrong sees a nearly empty page that says so. That is the
|
||||
* correct output, and it is the one the old design could not produce.
|
||||
*/
|
||||
export function TerminalsPage() {
|
||||
const { branches, scoped, selected, current } = useBranchScope();
|
||||
const [preset, setPreset] = useState<RangePreset>('today');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
||||
const [showFine, setShowFine] = useState(false);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const labels = useCounterLabels();
|
||||
|
||||
const board = useTerminalBoard({ branches: scoped, range, isHidden: labels.isHidden });
|
||||
|
||||
const needs = board.now.length + board.look.length;
|
||||
const summary = useMemo(() => {
|
||||
if (board.isLoading && board.total === 0) return 'Checking your counters…';
|
||||
if (board.total === 0) return undefined;
|
||||
if (needs === 0) {
|
||||
return `All ${board.total} counter${board.total === 1 ? '' : 's'} are fine — everything they’ve sold has reached us. Each one is listed by the code printed on your receipts, until you rename it.`;
|
||||
}
|
||||
const stuck = board.strandedBills;
|
||||
const n = board.now.length > 0 ? board.now.length : needs;
|
||||
const head = `${n} counter${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention.`;
|
||||
const tail = ' Counters are listed by the code printed on your receipts, until you rename them.';
|
||||
return stuck > 0
|
||||
? `${head} ${stuck} sale${stuck === 1 ? '' : 's'} haven’t reached us yet.${tail}`
|
||||
: `${head}${tail}`;
|
||||
}, [board, needs]);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Counters"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
{...(summary ? { description: summary } : {})}
|
||||
isLive
|
||||
actions={
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* A failed read is not an answer. Kept visually distinct from "this shop
|
||||
has no counters", because only one of the two means nothing is wrong. */}
|
||||
{board.failed.length > 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack gap={1.5} align="start" padding={2}>
|
||||
<span style={{ color: 'var(--color-error, #d64545)', flex: 'none', marginTop: 2 }}>
|
||||
<AlertTriangle size={16} />
|
||||
</span>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
We couldn’t check {board.failed.map(branchLabel).join(', ')}
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
This is a problem reaching our own system, not a problem with your counters —
|
||||
they may be trading normally.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{board.now.length > 0 ? (
|
||||
<Group bucket="now" count={board.now.length}>
|
||||
{board.now.map((card) => (
|
||||
<ProblemCard key={card.key} card={card} labels={labels} />
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{board.look.length > 0 ? (
|
||||
<Group bucket="look" count={board.look.length}>
|
||||
{board.look.map((card) => (
|
||||
<ProblemCard key={card.key} card={card} labels={labels} />
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{board.fine.length > 0 ? (
|
||||
<Group
|
||||
bucket="fine"
|
||||
count={board.fine.length}
|
||||
isCollapsible
|
||||
isOpen={showFine}
|
||||
onToggle={() => setShowFine((open) => !open)}
|
||||
>
|
||||
{board.fine.map((counter) => (
|
||||
<FineRow key={counter.key} counter={counter} labels={labels} />
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{board.total === 0 && !board.isLoading ? <Empty checked={board.checked} /> : null}
|
||||
|
||||
{labels.hiddenCount > 0 ? (
|
||||
<VStack gap={1}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHidden((open) => !open)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
alignSelf: 'flex-start',
|
||||
padding: 0,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-4)',
|
||||
fontSize: 12.5,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{showHidden ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
Hidden counters ({labels.hiddenCount})
|
||||
</button>
|
||||
{showHidden ? <HiddenList labels={labels} /> : null}
|
||||
</VStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Groups ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Group({
|
||||
bucket,
|
||||
count,
|
||||
children,
|
||||
isCollapsible,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
bucket: Bucket;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
isCollapsible?: boolean;
|
||||
isOpen?: boolean;
|
||||
onToggle?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<HStack align="center" gap={1}>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
background: BUCKET_COLOR[bucket],
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
weight="semibold"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '.06em' }}
|
||||
>
|
||||
{BUCKET_LABEL[bucket]}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{count}
|
||||
</Text>
|
||||
{isCollapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={isOpen}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
height: 26,
|
||||
padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-3)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isOpen ? 'Hide' : 'Show'}
|
||||
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
) : null}
|
||||
</HStack>
|
||||
{!isCollapsible || isOpen ? <VStack gap={1.5}>{children}</VStack> : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The card ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* One counter, one problem.
|
||||
*
|
||||
* Full width and stacked rather than in a grid: each card is a short paragraph
|
||||
* now, and a paragraph wants a line length. Three columns of prose is the
|
||||
* layout that made the last version unreadable.
|
||||
*/
|
||||
function ProblemCard({ card, labels }: { card: BoardCard; labels: CounterLabels }) {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const { problem } = card;
|
||||
const rail = BUCKET_COLOR[problem.bucket];
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<div style={{ display: 'flex', minWidth: 0 }}>
|
||||
<span aria-hidden style={{ width: 3, flex: 'none', background: rail }} />
|
||||
<VStack gap={1} padding={2} style={{ minWidth: 0, flex: 1 }}>
|
||||
<CounterName card={card} labels={labels} isRenaming={isRenaming} setIsRenaming={setIsRenaming} />
|
||||
|
||||
<Text type="label" size="lg" weight="semibold">
|
||||
{problem.headline}
|
||||
</Text>
|
||||
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
|
||||
{problem.detail}
|
||||
</Text>
|
||||
|
||||
{problem.action ? (
|
||||
<Text type="body" size="sm" weight="semibold" style={{ lineHeight: 1.6 }}>
|
||||
{problem.action}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* The reassurance. On a stuck-sales card this is the most important
|
||||
sentence on the page: it is the difference between a supervisor
|
||||
phoning head office and a supervisor checking a router. */}
|
||||
{problem.comfort ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-ink-4)', lineHeight: 1.6 }}>
|
||||
{problem.comfort}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* Everything else wrong with this counter at this urgency. One line
|
||||
each, under the instruction — the same walk fixes them, and three
|
||||
full cards with the same name at the top read as a broken page. */}
|
||||
{card.also.length > 0 ? (
|
||||
<VStack gap={0} style={{ paddingTop: 2 }}>
|
||||
{card.also.map((extra) => (
|
||||
<Text key={extra.code} type="body" size="xsm" color="secondary">
|
||||
Also: {extra.headline.toLowerCase()}
|
||||
{extra.action ? ` — ${extra.action.toLowerCase()}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<HStack gap={1} align="center" wrap="wrap" style={{ paddingTop: 2 }}>
|
||||
<SmallButton
|
||||
label={showDetails ? 'Hide details' : 'Details'}
|
||||
onClick={() => setShowDetails((open) => !open)}
|
||||
/>
|
||||
<SmallButton label="Rename" icon={<Pencil size={11} />} onClick={() => setIsRenaming(true)} />
|
||||
{problem.code === 'never-used' || problem.code === 'no-status' ? (
|
||||
<SmallButton
|
||||
label="Hide this counter"
|
||||
icon={<EyeOff size={11} />}
|
||||
onClick={() => labels.hide(card.terminalId)}
|
||||
/>
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
{showDetails ? <Details card={card} /> : null}
|
||||
</VStack>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The counter's name, and the code underneath it.
|
||||
*
|
||||
* The code stays visible even after renaming, because `models/poshealth.go`
|
||||
* calls it "the short code printed on invoices — the thing a support call
|
||||
* starts with". Someone reading a receipt needs to match it to a card.
|
||||
*/
|
||||
function CounterName({
|
||||
card,
|
||||
labels,
|
||||
isRenaming,
|
||||
setIsRenaming,
|
||||
}: {
|
||||
card: BoardCard | HealthyCounter;
|
||||
labels: CounterLabels;
|
||||
isRenaming: boolean;
|
||||
setIsRenaming: (value: boolean) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => labels.nameFor(card.terminalId));
|
||||
const named = labels.isNamed(card.terminalId);
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<div style={{ width: 240 }}>
|
||||
<TextInput
|
||||
label="Name this counter"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder="Counter 2, by the door…"
|
||||
/>
|
||||
</div>
|
||||
<SmallButton
|
||||
label="Save"
|
||||
icon={<Check size={11} />}
|
||||
onClick={() => {
|
||||
labels.rename(card.terminalId, draft);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
/>
|
||||
<SmallButton label="Cancel" onClick={() => setIsRenaming(false)} />
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-ink-4)' }}>
|
||||
{card.terminalId} is the code printed on your receipts. Saved on this browser only.
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{labels.nameFor(card.terminalId)}
|
||||
</Text>
|
||||
{named && card.terminalId !== UNASSIGNED ? (
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
style={{ color: 'var(--color-ink-4)', fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
{card.terminalId}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{branchLabel(card.branchName)}
|
||||
</Text>
|
||||
{/* The "printed on your receipts" hint is NOT here. It is true of every
|
||||
unnamed counter, so putting it on every card printed it ten times down
|
||||
one page and stopped being read on the second. It is said once, under
|
||||
the page title, and again where it is actually needed — inside Rename,
|
||||
where someone is deciding what to call this thing. */}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The support view.
|
||||
*
|
||||
* Everything the previous design put on the face of the card. It is all true
|
||||
* and all useful — to whoever eventually rings the supplier, which is not the
|
||||
* person deciding whether to walk to the counter.
|
||||
*/
|
||||
function Details({ card }: { card: BoardCard }) {
|
||||
const status = card.status;
|
||||
const rows: [string, string][] = [
|
||||
['Counter code', card.terminalId],
|
||||
['Sold in this period', `${card.periodBills} · ${rupees(card.periodAmount)}`],
|
||||
];
|
||||
|
||||
if (status) {
|
||||
rows.push(['Today on the till', `${status.todayBills} · ${rupees(status.todayAmount)}`]);
|
||||
rows.push(['Last sale', clockTime(status.lastBillAt)]);
|
||||
rows.push([
|
||||
'Last heard from',
|
||||
status.receivedAt
|
||||
? `${clockTime(status.receivedAt)} (${spokenAge(status.silentForMs ?? 0)} ago)`
|
||||
: 'never',
|
||||
]);
|
||||
rows.push(['Waiting to send', String(status.pendingBills)]);
|
||||
if (status.appVersion) rows.push(['Till app version', status.appVersion]);
|
||||
if (status.batteryLevel !== null) {
|
||||
rows.push([
|
||||
'Battery',
|
||||
`${status.batteryLevel}%${status.batteryCharging ? ' (charging)' : ''}`,
|
||||
]);
|
||||
}
|
||||
if (status.storageFreeMb !== null) rows.push(['Free space', `${status.storageFreeMb} MB`]);
|
||||
if (status.printerReachable !== null) {
|
||||
rows.push(['Printer', status.printerReachable ? 'responding' : 'not responding']);
|
||||
}
|
||||
if (status.drawerStatus) rows.push(['Cash drawer', status.drawerStatus]);
|
||||
if (status.clockDriftSeconds !== null) {
|
||||
rows.push(['Clock difference', `${status.clockDriftSeconds}s vs our server`]);
|
||||
}
|
||||
if (status.reason) rows.push(['Reported reason', status.reason]);
|
||||
} else {
|
||||
rows.push(['Status reporting', 'this counter has never reported']);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={0} style={{ paddingTop: 4 }}>
|
||||
{rows.map(([label, value]) => (
|
||||
<HStack key={label} justify="between" align="center" gap={2} style={{ padding: '4px 0' }}>
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-ink-4)' }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The fine list ───────────────────────────────────────────────────────── */
|
||||
|
||||
/** A counter with nothing wrong gets one line. Anything more is noise. */
|
||||
function FineRow({ counter, labels }: { counter: HealthyCounter; labels: CounterLabels }) {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack justify="between" align="center" gap={2} padding={2} wrap="wrap">
|
||||
<CounterName
|
||||
card={counter}
|
||||
labels={labels}
|
||||
isRenaming={isRenaming}
|
||||
setIsRenaming={setIsRenaming}
|
||||
/>
|
||||
{!isRenaming ? (
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{counter.periodBills} sales · {rupees(counter.periodAmount)}
|
||||
{counter.status.lastBillAt ? ` · last sale ${clockTime(counter.status.lastBillAt)}` : ''}
|
||||
</Text>
|
||||
) : null}
|
||||
</HStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Hidden ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
function HiddenList({ labels }: { labels: CounterLabels }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
{labels.hidden.map((terminalId) => (
|
||||
<HStack key={terminalId} justify="between" align="center" gap={2}>
|
||||
<Text type="body" size="xsm" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{labels.nameFor(terminalId)}
|
||||
</Text>
|
||||
<SmallButton label="Bring back" onClick={() => labels.unhide(terminalId)} />
|
||||
</HStack>
|
||||
))}
|
||||
<div style={{ height: 1, background: 'var(--color-line)' }} />
|
||||
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
Hidden here only, on this browser. There’s no way to remove a counter from our system
|
||||
once it has been used, so this just keeps your list tidy.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Empty ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Empty({ checked }: { checked: string[] }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
No counter has reported yet
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
|
||||
A counter appears here the first time it is used — nobody has to register one. So an
|
||||
empty list means no till has ever sent us anything, not that sales are missing.
|
||||
</Text>
|
||||
{checked.length > 0 ? (
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-ink-4)' }}>
|
||||
Checked: {checked.map(branchLabel).join(', ')}.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Bits ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function SmallButton({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
height: 26,
|
||||
padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-3)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
608
src/features/store-admin/pages/UsersPage.tsx
Normal file
608
src/features/store-admin/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,608 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Info, Monitor, Plus, UserCog, Users } from 'lucide-react';
|
||||
import type { PosUser, StaffInfo } from '@/api/types';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import {
|
||||
usePosRoles,
|
||||
usePosUsersByBranch,
|
||||
useStaff,
|
||||
useStaffShifts,
|
||||
} from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { branchLabel } from '../format';
|
||||
import { PersonDrawer, TillDrawer } from '../PeopleDrawers';
|
||||
|
||||
type Group = 'store' | 'till';
|
||||
|
||||
/**
|
||||
* Users & access — the two account systems, side by side but never merged.
|
||||
*
|
||||
* A till account is NOT a Nearle Daily user. The backend excludes roles 7 and 8
|
||||
* from every application lookup *inside the query itself* — deliberately, so a
|
||||
* cashier is "not found" rather than "refused". Merging the two into one table
|
||||
* would list people in a directory they cannot sign in to, and would invite an
|
||||
* edit that moves someone between two systems that do not share a login.
|
||||
*
|
||||
* Neither group offers a delete. Both delete endpoints are hard deletes with no
|
||||
* cascade, and `status` already carries deactivation.
|
||||
*/
|
||||
export function UsersPage() {
|
||||
const { branches, scoped, selected, current, tenantid } = useBranchScope();
|
||||
const [group, setGroup] = useState<Group>('store');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('');
|
||||
const [editingPerson, setEditingPerson] = useState<StaffInfo | 'new' | null>(null);
|
||||
const [editingTill, setEditingTill] = useState<{ row: PosUser | 'new'; locationid: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||
|
||||
const staff = useStaff(tenantid || undefined);
|
||||
const posUserPages = usePosUsersByBranch(tenantid || undefined, branchIds);
|
||||
const roles = usePosRoles();
|
||||
const shifts = useStaffShifts(tenantid || undefined, scoped[0]?.locationid);
|
||||
|
||||
const staffRows = useMemo(() => {
|
||||
const term = keyword.trim().toLowerCase();
|
||||
return (staff.data ?? []).filter((person) => {
|
||||
if (roleFilter && person.rolename !== roleFilter) return false;
|
||||
if (selected !== null && person.locationid !== selected) return false;
|
||||
if (!term) return true;
|
||||
return `${person.fullname} ${person.email} ${person.contactno}`.toLowerCase().includes(term);
|
||||
});
|
||||
}, [staff.data, keyword, roleFilter, selected]);
|
||||
|
||||
const tillRows = useMemo(() => {
|
||||
const out: { person: PosUser; branch: string; locationid: number }[] = [];
|
||||
posUserPages.forEach((page, index) => {
|
||||
const locationid = branchIds[index];
|
||||
if (locationid === undefined) return;
|
||||
const branch = branches.find((entry) => entry.locationid === locationid);
|
||||
for (const person of page.data ?? []) {
|
||||
out.push({
|
||||
person,
|
||||
branch: branch?.locationname ?? `Branch ${locationid}`,
|
||||
locationid,
|
||||
});
|
||||
}
|
||||
});
|
||||
const term = keyword.trim().toLowerCase();
|
||||
return out.filter(({ person }) => {
|
||||
if (roleFilter && person.role?.toLowerCase() !== roleFilter.toLowerCase()) return false;
|
||||
if (!term) return true;
|
||||
return `${person.full_name} ${person.contactno}`.toLowerCase().includes(term);
|
||||
});
|
||||
}, [posUserPages, branchIds, branches, keyword, roleFilter]);
|
||||
|
||||
const staffRoles = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
for (const person of staff.data ?? []) if (person.rolename) seen.add(person.rolename);
|
||||
return [...seen].sort();
|
||||
}, [staff.data]);
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
group === 'store'
|
||||
? [
|
||||
{ value: '', label: 'All roles' },
|
||||
...staffRoles.map((name) => ({ value: name, label: name })),
|
||||
]
|
||||
: [
|
||||
{ value: '', label: 'All roles' },
|
||||
...(roles.data ?? []).map((role) => ({ value: role.role, label: role.label })),
|
||||
],
|
||||
[group, staffRoles, roles.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Users & access"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
description="Who can sign in to the console, and who can open a till."
|
||||
actions={
|
||||
<Button
|
||||
label={group === 'store' ? 'Add person' : 'Add till account'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={() => {
|
||||
if (group === 'store') setEditingPerson('new');
|
||||
else {
|
||||
const locationid = selected ?? scoped[0]?.locationid;
|
||||
if (locationid) setEditingTill({ row: 'new', locationid });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
||||
<TabButton
|
||||
label="Store users"
|
||||
icon={<UserCog size={14} />}
|
||||
badge={staff.data?.length || undefined}
|
||||
isActive={group === 'store'}
|
||||
onClick={() => {
|
||||
setGroup('store');
|
||||
setRoleFilter('');
|
||||
}}
|
||||
/>
|
||||
<TabButton
|
||||
label="Till accounts"
|
||||
icon={<Monitor size={14} />}
|
||||
badge={tillRows.length || undefined}
|
||||
isActive={group === 'till'}
|
||||
onClick={() => {
|
||||
setGroup('till');
|
||||
setRoleFilter('');
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title={group === 'store' ? 'Back office' : 'Counter staff'}
|
||||
note={
|
||||
group === 'store'
|
||||
? 'signs in to this console'
|
||||
: 'signs in at a till — no console login'
|
||||
}
|
||||
action={
|
||||
<HStack gap={1} wrap="wrap">
|
||||
<div style={{ width: 170 }}>
|
||||
<Selector
|
||||
label="Role"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={roleFilter}
|
||||
onChange={setRoleFilter}
|
||||
placeholder="All roles"
|
||||
options={roleOptions}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 220 }}>
|
||||
<TextInput
|
||||
label="Search people"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Name, email, mobile…"
|
||||
hasClear
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{group === 'store' ? (
|
||||
<StaffTable
|
||||
rows={staffRows}
|
||||
isLoading={staff.isLoading}
|
||||
showBranch={selected === null}
|
||||
branches={branches}
|
||||
onEdit={setEditingPerson}
|
||||
/>
|
||||
) : (
|
||||
<TillTable
|
||||
rows={tillRows}
|
||||
isLoading={posUserPages.some((page) => page.isLoading)}
|
||||
showBranch={selected === null}
|
||||
onEdit={(row, locationid) => setEditingTill({ row, locationid })}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Stated once, under whichever list is showing. */}
|
||||
<HStack gap={1} align="start">
|
||||
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{group === 'store' ? (
|
||||
<>
|
||||
There is no delete here. Removing an account is a hard delete with no cascade in
|
||||
Fiesta, so anything that account touched would be left pointing at nothing —
|
||||
deactivating keeps the history and stops the login. Passwords are not managed from
|
||||
this console either, because they are stored in clear; adding a person creates the
|
||||
account, and issuing its password is a backend job until that changes.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
A till account has no console login — the backend leaves roles 7 and 8 out of every
|
||||
application lookup, so a cashier is simply not found rather than refused. The PIN is
|
||||
shown once when the account is created and never read back; if it is lost, reissue
|
||||
rather than look it up.
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{editingPerson ? (
|
||||
<PersonDrawer
|
||||
row={editingPerson === 'new' ? null : editingPerson}
|
||||
tenantid={tenantid}
|
||||
branches={branches}
|
||||
onClose={() => setEditingPerson(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{editingTill ? (
|
||||
<TillDrawer
|
||||
row={editingTill.row === 'new' ? null : editingTill.row}
|
||||
tenantid={tenantid}
|
||||
locationid={editingTill.locationid}
|
||||
roles={roles.data ?? []}
|
||||
shifts={shifts.data ?? []}
|
||||
onClose={() => setEditingTill(null)}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Tables ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
function StaffTable({
|
||||
rows,
|
||||
isLoading,
|
||||
showBranch,
|
||||
branches,
|
||||
onEdit,
|
||||
}: {
|
||||
rows: StaffInfo[];
|
||||
isLoading: boolean;
|
||||
showBranch: boolean;
|
||||
branches: { locationid: number; locationname: string }[];
|
||||
onEdit: (row: StaffInfo) => void;
|
||||
}) {
|
||||
if (isLoading) return <Loading what="the directory" />;
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
icon={<Users size={22} />}
|
||||
title="Nobody here"
|
||||
body="Nothing matches this role or search. Add a person to give them a console login."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Name</Th>
|
||||
<Th align="left">Role</Th>
|
||||
<Th align="left">Email</Th>
|
||||
<Th align="left">Mobile</Th>
|
||||
{showBranch ? <Th align="left">Branch</Th> : null}
|
||||
<Th align="left">Status</Th>
|
||||
<Th align="right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((person) => {
|
||||
const branch = branches.find((entry) => entry.locationid === person.locationid);
|
||||
return (
|
||||
<tr key={person.userid}>
|
||||
<Td align="left" isStrong>
|
||||
{person.fullname || `${person.firstname ?? ''} ${person.lastname ?? ''}`.trim() || '—'}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
{/* Server-resolved. Never mapped from roleid here — most
|
||||
accounts carry an id that is absent from `app_roles`. */}
|
||||
<Chip label={person.rolename} tone="brand" />
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
{person.email || '—'}
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
{person.contactno || '—'}
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td align="left" isMuted>
|
||||
{branchLabel(branch?.locationname ?? person.locationname)}
|
||||
</Td>
|
||||
) : null}
|
||||
<Td align="left">
|
||||
<StatusChip status={person.status} />
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Button label="Edit" variant="secondary" size="sm" onClick={() => onEdit(person)} />
|
||||
</Td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TillTable({
|
||||
rows,
|
||||
isLoading,
|
||||
showBranch,
|
||||
onEdit,
|
||||
}: {
|
||||
rows: { person: PosUser; branch: string; locationid: number }[];
|
||||
isLoading: boolean;
|
||||
showBranch: boolean;
|
||||
onEdit: (row: PosUser, locationid: number) => void;
|
||||
}) {
|
||||
if (isLoading) return <Loading what="till accounts" />;
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
icon={<Monitor size={22} />}
|
||||
title="No till accounts"
|
||||
body="Create a Supervisor first — they open the till on the device, and the terminal appears on the Console board once it reports in."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Name</Th>
|
||||
<Th align="left">Role</Th>
|
||||
<Th align="left">Mobile</Th>
|
||||
{showBranch ? <Th align="left">Branch</Th> : null}
|
||||
<Th align="left">Shift</Th>
|
||||
<Th align="left">Status</Th>
|
||||
<Th align="right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ person, branch, locationid }) => (
|
||||
<tr key={person.user_id}>
|
||||
<Td align="left" isStrong>
|
||||
{person.full_name || '—'}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Chip
|
||||
label={person.role}
|
||||
tone={person.role?.toLowerCase() === 'supervisor' ? 'violet' : 'slate'}
|
||||
/>
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
{person.contactno || '—'}
|
||||
</span>
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td align="left" isMuted>
|
||||
{branchLabel(branch)}
|
||||
</Td>
|
||||
) : null}
|
||||
<Td align="left" isMuted>
|
||||
{person.shift_name ? (
|
||||
<>
|
||||
<span style={{ display: 'block' }}>{person.shift_name}</span>
|
||||
<span style={{ display: 'block', fontSize: 11, color: 'var(--color-ink-4)' }}>
|
||||
{person.shift_start}–{person.shift_end}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
'Any'
|
||||
)}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<StatusChip status={person.status} />
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Button
|
||||
label="Edit"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onEdit(person, locationid)}
|
||||
/>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Primitives ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const TONE: Record<string, string> = {
|
||||
brand: 'var(--color-brand)',
|
||||
violet: '#8b5cf6',
|
||||
slate: 'var(--color-ink-3)',
|
||||
};
|
||||
|
||||
function Chip({ label, tone }: { label: string | undefined; tone: keyof typeof TONE }) {
|
||||
const colour = TONE[tone] ?? TONE.slate!;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
borderRadius: 999,
|
||||
padding: '2px 9px',
|
||||
fontSize: 11.5,
|
||||
fontWeight: 600,
|
||||
textTransform: 'capitalize',
|
||||
whiteSpace: 'nowrap',
|
||||
color: colour,
|
||||
background: `color-mix(in oklab, ${colour} 12%, transparent)`,
|
||||
border: `1px solid color-mix(in oklab, ${colour} 24%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{label || '—'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Active is quiet, anything else is loud — an inactive account is the exception. */
|
||||
function StatusChip({ status }: { status: string | undefined }) {
|
||||
const isActive = (status ?? 'active').toLowerCase().startsWith('active');
|
||||
const colour = isActive ? 'var(--color-success, #10b981)' : 'var(--color-ink-4)';
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: isActive ? 'var(--color-ink-2)' : 'var(--color-ink-4)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }}
|
||||
/>
|
||||
{isActive ? 'Active' : (status ?? 'Inactive')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
onClick,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{badge ? (
|
||||
<span
|
||||
style={{
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
background: isActive ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
|
||||
color: isActive ? '#fff' : 'var(--color-ink-3)',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading({ what }: { what: string }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading {what}…
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={4} align="center">
|
||||
<span style={{ color: 'var(--color-ink-4)' }}>{icon}</span>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary" style={{ textAlign: 'center', maxWidth: 400 }}>
|
||||
{body}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align }: { children?: React.ReactNode; align: 'left' | 'right' }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
fontWeight: isStrong ? 600 : 400,
|
||||
verticalAlign: 'top',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
294
src/features/store-admin/posStatus.test.ts
Normal file
294
src/features/store-admin/posStatus.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { PosSalesSummary, PosTerminalHealth } from '@/api/types';
|
||||
import {
|
||||
readTerminal,
|
||||
readTerminalId,
|
||||
STALE_THRESHOLD_MS,
|
||||
summariseBranch,
|
||||
UNASSIGNED,
|
||||
} from './posStatus';
|
||||
import { isClosed, problemsFor, type ProblemContext } from './terminalProblems';
|
||||
|
||||
/**
|
||||
* Fixtures built from the BACKEND's shapes, not from mine.
|
||||
*
|
||||
* The last round of tests passed while the code was wrong, because I wrote the
|
||||
* fixture from the same misreading as the implementation — both used
|
||||
* `terminal_id` on the sales split where Fiesta returns `terminalid`. A test
|
||||
* written from my own reading of an API only proves I was consistent with
|
||||
* myself. So the field names below are taken from the Go structs:
|
||||
* `models/poshealth.go`, `repositories/posPresence.go`, `models/posorder.go`.
|
||||
*/
|
||||
const NOW = Date.parse('2026-08-24T14:00:00.000Z');
|
||||
const ago = (ms: number) => new Date(NOW - ms).toISOString();
|
||||
|
||||
/** A live till. Every value is a string: Redis hashes have no other type. */
|
||||
const till = (overrides: PosTerminalHealth): PosTerminalHealth => ({
|
||||
terminal_id: 'T4A9',
|
||||
location_id: '1',
|
||||
store_name: 'R mart — RS Puram',
|
||||
app_version: '2.4.1',
|
||||
status: 'online',
|
||||
pending_bills: '0',
|
||||
pending_registrations: '0',
|
||||
today_bills: '12',
|
||||
today_amount: '4800',
|
||||
last_bill_at: ago(120_000),
|
||||
reported_at: ago(5_000),
|
||||
received_at: ago(5_000),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* The expired-key stub, exactly as `posPresence.go` synthesises it.
|
||||
* Four fields. Nothing else — that absence is the whole tell.
|
||||
*/
|
||||
const stub: PosTerminalHealth = {
|
||||
terminal_id: 'T5EDD',
|
||||
location_id: '1',
|
||||
status: 'offline',
|
||||
reason: 'no heartbeat within 90s',
|
||||
};
|
||||
|
||||
const context = (overrides: Partial<ProblemContext> = {}): ProblemContext => ({
|
||||
now: NOW,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const codes = (list: { code: string }[]) => list.map((problem) => problem.code);
|
||||
|
||||
/* ── Presence ────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('a fresh till with an empty queue is online and synced', () => {
|
||||
const status = readTerminal(till({}), NOW);
|
||||
assert.equal(status.presence, 'online');
|
||||
assert.equal(status.state, 'synced');
|
||||
});
|
||||
|
||||
/**
|
||||
* The bug this file exists to prevent coming back.
|
||||
*
|
||||
* Age must be measured from `received_at` — when the server stamped it — never
|
||||
* from `reported_at`, which the till writes about itself. A device an hour fast
|
||||
* reports a FUTURE timestamp; measured from the till's clock that computes a
|
||||
* negative age, clamps to zero, and renders "seen just now" on a counter nobody
|
||||
* has heard from since morning.
|
||||
*/
|
||||
test('a till with a fast clock cannot make itself look fresh', () => {
|
||||
const status = readTerminal(
|
||||
till({
|
||||
reported_at: new Date(NOW + 3_600_000).toISOString(), // an hour in the future
|
||||
received_at: ago(3_600_000), // actually last heard an hour ago
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(status.presence, 'stale', 'must not read as online');
|
||||
assert.equal(status.silentForMs, 3_600_000, 'age comes from the server stamp');
|
||||
assert.equal(status.clockDriftSeconds, 7200);
|
||||
});
|
||||
|
||||
test('a till just inside the stale threshold is still online', () => {
|
||||
const status = readTerminal(till({ received_at: ago(STALE_THRESHOLD_MS - 5_000) }), NOW);
|
||||
assert.equal(status.presence, 'online');
|
||||
});
|
||||
|
||||
test('a till that declared itself offline is offline, not vanished', () => {
|
||||
const status = readTerminal(till({ status: 'offline', reason: 'shift end' }), NOW);
|
||||
assert.equal(status.presence, 'offline');
|
||||
});
|
||||
|
||||
test('the four-field stub is no-heartbeat', () => {
|
||||
const status = readTerminal(stub, NOW);
|
||||
assert.equal(status.presence, 'no-heartbeat');
|
||||
});
|
||||
|
||||
/**
|
||||
* An MQTT Last Will merges over the previous heartbeat (`HSet`; only device
|
||||
* fields are `HDel`'d), so a till that lost power keeps its old timestamps.
|
||||
* That is a declared offline, not a vanished one — and testing for absent
|
||||
* heartbeat fields rather than the documented stub shape would confuse them.
|
||||
*/
|
||||
test('a Last Will record keeps its old timestamps and is still just offline', () => {
|
||||
const status = readTerminal(
|
||||
till({ status: 'offline', reason: 'connection lost', received_at: ago(90_000) }),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(status.presence, 'offline');
|
||||
});
|
||||
|
||||
test('device readings that were never collected stay null, never zero', () => {
|
||||
const status = readTerminal(till({}), NOW);
|
||||
assert.equal(status.batteryLevel, null);
|
||||
assert.equal(status.storageFreeMb, null);
|
||||
assert.equal(status.printerReachable, null);
|
||||
const reported = readTerminal(till({ battery_level: '0', printer_reachable: 'false' }), NOW);
|
||||
assert.equal(reported.batteryLevel, 0, 'a reported zero is a real reading');
|
||||
assert.equal(reported.printerReachable, false);
|
||||
});
|
||||
|
||||
/* ── The two spellings, and the nameless bucket ──────────────────────────── */
|
||||
|
||||
/**
|
||||
* Presence says `terminal_id`; sales says `terminalid`. Reading the sales split
|
||||
* with the presence spelling yields `undefined` for every row, which is exactly
|
||||
* what silently disabled the billed-but-absent reconciliation.
|
||||
*/
|
||||
test('the sales split spells it terminalid, without an underscore', () => {
|
||||
const summary: PosSalesSummary = {
|
||||
byterminal: [{ terminalid: 'T4A9', billcount: 46, amount: 18240 }],
|
||||
};
|
||||
const entry = summary.byterminal?.[0];
|
||||
assert.equal(readTerminalId(entry?.terminalid), 'T4A9');
|
||||
});
|
||||
|
||||
/**
|
||||
* `posSalesRepository.go` groups on `COALESCE(terminalid,'')`, so bills that
|
||||
* arrived before terminal codes were recorded collapse into one empty-id row.
|
||||
* A blank card title reads as a bug; a named bucket reads as the truth.
|
||||
*/
|
||||
test('bills with no terminal code get a named bucket', () => {
|
||||
assert.equal(readTerminalId(''), UNASSIGNED);
|
||||
assert.equal(readTerminalId(' '), UNASSIGNED);
|
||||
assert.equal(readTerminalId(undefined), UNASSIGNED);
|
||||
assert.equal(readTerminalId(' T4A9 '), 'T4A9');
|
||||
});
|
||||
|
||||
/* ── Roll-up ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('a branch takes its worst till, and stale outranks delayed', () => {
|
||||
const summary = summariseBranch(
|
||||
[
|
||||
till({ terminal_id: 'A' }),
|
||||
till({ terminal_id: 'B', pending_bills: '9', oldest_pending_at: ago(20 * 60_000) }),
|
||||
till({ terminal_id: 'C', received_at: ago(60 * 60_000) }),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
assert.equal(summary.state, 'stale');
|
||||
assert.equal(summary.pendingBills, 9);
|
||||
});
|
||||
|
||||
test('the online count excludes stale tills', () => {
|
||||
const summary = summariseBranch(
|
||||
[till({ terminal_id: 'A' }), till({ terminal_id: 'B', received_at: ago(60 * 60_000) })],
|
||||
NOW,
|
||||
);
|
||||
assert.equal(summary.online, 1);
|
||||
assert.equal(summary.total, 2);
|
||||
});
|
||||
|
||||
test('an empty board is no-heartbeat, never all-synced', () => {
|
||||
assert.equal(summariseBranch([], NOW).state, 'no-heartbeat');
|
||||
});
|
||||
|
||||
/* ── Problems ────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('a healthy selling till raises nothing at all', () => {
|
||||
assert.deepEqual(codes(problemsFor(readTerminal(till({}), NOW), context())), []);
|
||||
});
|
||||
|
||||
/**
|
||||
* Money on a counter nobody can see. It absorbs the presence problem — a card
|
||||
* saying both "sales stuck" and "gone quiet" would be one card asking for the
|
||||
* same walk to the same counter, twice.
|
||||
*/
|
||||
test('an absent till holding sales raises one problem, not two', () => {
|
||||
const status = readTerminal(
|
||||
till({ status: 'offline', pending_bills: '41', oldest_pending_at: ago(3 * 3_600_000) }),
|
||||
NOW,
|
||||
);
|
||||
const list = problemsFor(status, context());
|
||||
assert.deepEqual(codes(list), ['money-stuck']);
|
||||
assert.equal(list[0]?.bucket, 'now');
|
||||
assert.ok(list[0]?.comfort?.includes('aren’t lost'), 'must carry the reassurance');
|
||||
});
|
||||
|
||||
/** Estimated from the till's own average sale, and never from no data. */
|
||||
test('the value estimate needs something to average over', () => {
|
||||
const withHistory = readTerminal(
|
||||
till({ status: 'offline', pending_bills: '10', today_bills: '10', today_amount: '5000' }),
|
||||
NOW,
|
||||
);
|
||||
assert.ok(
|
||||
problemsFor(withHistory, context())[0]?.detail.includes('₹5,000'),
|
||||
'ten pending at a ₹500 average is roughly ₹5,000',
|
||||
);
|
||||
|
||||
const noHistory = readTerminal(
|
||||
till({ status: 'offline', pending_bills: '10', today_bills: '0', today_amount: '0' }),
|
||||
NOW,
|
||||
);
|
||||
const detail = problemsFor(noHistory, context())[0]?.detail ?? '';
|
||||
assert.ok(!detail.includes('₹'), 'no average to work from means no figure invented');
|
||||
});
|
||||
|
||||
test('a counter dark during trading hours needs someone now', () => {
|
||||
const status = readTerminal(till({ status: 'offline' }), NOW);
|
||||
const [problem] = problemsFor(status, context());
|
||||
assert.equal(problem?.code, 'gone-quiet');
|
||||
assert.equal(problem?.bucket, 'now');
|
||||
});
|
||||
|
||||
test('the same counter outside trading hours is only worth a look', () => {
|
||||
const status = readTerminal(till({ status: 'offline' }), NOW);
|
||||
const hour = new Date(NOW).getHours();
|
||||
const shut = context({
|
||||
opentime: `${String((hour + 2) % 24).padStart(2, '0')}:00`,
|
||||
closetime: `${String((hour + 4) % 24).padStart(2, '0')}:00`,
|
||||
});
|
||||
const [problem] = problemsFor(status, shut);
|
||||
assert.equal(problem?.bucket, 'look');
|
||||
assert.equal(problem?.headline, 'Switched off');
|
||||
});
|
||||
|
||||
test('unparseable trading hours never silence an alert', () => {
|
||||
assert.equal(isClosed(context({ opentime: 'morning', closetime: 'late' })), false);
|
||||
assert.equal(isClosed(context({})), false);
|
||||
});
|
||||
|
||||
test('a shop trading past midnight has a wrapping open window', () => {
|
||||
const at = (hour: number) => Date.parse('2026-08-24T00:00:00') + hour * 3_600_000;
|
||||
const hours = { opentime: '18:00', closetime: '02:00' };
|
||||
assert.equal(isClosed({ now: at(20), ...hours }), false, '20:00 is inside');
|
||||
assert.equal(isClosed({ now: at(1), ...hours }), false, '01:00 is inside');
|
||||
assert.equal(isClosed({ now: at(12), ...hours }), true, 'midday is outside');
|
||||
});
|
||||
|
||||
test('a spare that never sold is not an incident', () => {
|
||||
const spare = readTerminal(
|
||||
till({ status: 'offline', today_bills: '0', last_bill_at: undefined }),
|
||||
NOW,
|
||||
);
|
||||
const list = problemsFor(spare, context());
|
||||
assert.deepEqual(codes(list), ['never-used']);
|
||||
assert.equal(list[0]?.bucket, 'look');
|
||||
});
|
||||
|
||||
test('a connected till that has rung nothing today says so', () => {
|
||||
const status = readTerminal(till({ today_bills: '0', last_bill_at: ago(20 * 3_600_000) }), NOW);
|
||||
assert.ok(codes(problemsFor(status, context())).includes('not-selling'));
|
||||
});
|
||||
|
||||
test('clock drift past the threshold is raised in plain words', () => {
|
||||
const status = readTerminal(till({ reported_at: ago(-600_000), received_at: ago(0) }), NOW);
|
||||
const problem = problemsFor(status, context()).find((p) => p.code === 'wrong-date');
|
||||
assert.ok(problem);
|
||||
assert.ok(problem.detail.includes('wrong day'));
|
||||
});
|
||||
|
||||
test('problems come back worst bucket first', () => {
|
||||
const status = readTerminal(
|
||||
till({
|
||||
status: 'offline',
|
||||
pending_bills: '9',
|
||||
oldest_pending_at: ago(20 * 60_000),
|
||||
printer_reachable: 'false',
|
||||
drawer_status: 'open',
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
const list = problemsFor(status, context());
|
||||
assert.equal(list[0]?.bucket, 'now');
|
||||
assert.equal(list.at(-1)?.bucket, 'look');
|
||||
});
|
||||
345
src/features/store-admin/posStatus.ts
Normal file
345
src/features/store-admin/posStatus.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import type { PosTerminalHealth } from '@/api/types';
|
||||
|
||||
/**
|
||||
* Till state, on two axes.
|
||||
*
|
||||
* PRESENCE — can we see this counter? online / stale / offline / no-heartbeat
|
||||
* SYNC — is its queue draining? synced / syncing / delayed
|
||||
*
|
||||
* Sync only means anything while the till is present: a queue we cannot watch
|
||||
* drain is not a queue that is draining. Presence wins on the badge.
|
||||
*
|
||||
* Every threshold below is derived from Fiesta's own constants rather than
|
||||
* chosen here — see `models/poshealth.go` and `repositories/posPresence.go`.
|
||||
*/
|
||||
export type PresenceState = 'online' | 'stale' | 'offline' | 'no-heartbeat';
|
||||
export type SyncState = 'synced' | 'syncing' | 'delayed';
|
||||
|
||||
/** What the badge shows: presence, unless the till is present, then sync. */
|
||||
export type TerminalState = SyncState | Exclude<PresenceState, 'online'>;
|
||||
|
||||
/** `models/poshealth.go`: "what a till reports about itself every 30 seconds". */
|
||||
export const HEARTBEAT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* `repositories/posPresence.go`: `posPresenceTTL = 90 * time.Second`.
|
||||
*
|
||||
* The comment there is the reasoning: "Three missed heartbeats. Two would make
|
||||
* an ordinary GPRS hiccup look like a dead till; five would take two and a half
|
||||
* minutes to notice a real one."
|
||||
*/
|
||||
export const PRESENCE_TTL_MS = 3 * HEARTBEAT_MS;
|
||||
|
||||
/**
|
||||
* How old a reading may be before the counter counts as stale.
|
||||
*
|
||||
* One missed heartbeat plus one poll interval of our own. Below that a live
|
||||
* till simply has no fresh reading yet and is not in trouble; above it, we have
|
||||
* genuinely missed a beat. It sits under {@link PRESENCE_TTL_MS} on purpose, so
|
||||
* "stale" is the warning that precedes the key expiring rather than a synonym
|
||||
* for it.
|
||||
*/
|
||||
export const STALE_THRESHOLD_MS = HEARTBEAT_MS * 2;
|
||||
|
||||
/** How long a bill may sit unsent before the queue counts as delayed. */
|
||||
export const DELAY_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* How far a till's clock may disagree with the server's before it matters.
|
||||
*
|
||||
* `models/poshealth.go` on why this is surfaced at all: "a till whose clock is
|
||||
* wrong writes bills under the wrong business date."
|
||||
*/
|
||||
export const CLOCK_DRIFT_THRESHOLD_S = 120;
|
||||
|
||||
/** Bills that arrived with no terminal code at all. See `readTerminalId`. */
|
||||
export const UNASSIGNED = 'Unassigned';
|
||||
|
||||
export interface TerminalStatus {
|
||||
terminalId: string;
|
||||
storeName: string;
|
||||
presence: PresenceState;
|
||||
sync: SyncState;
|
||||
state: TerminalState;
|
||||
pendingBills: number;
|
||||
/** How long the oldest unsent bill has been waiting, in ms. 0 when none. */
|
||||
oldestPendingMs: number;
|
||||
oldestPendingAt: Date | null;
|
||||
/**
|
||||
* How long since the SERVER last stamped a reading from this till.
|
||||
*
|
||||
* Measured from `received_at`, never `reported_at` — see {@link readTerminal}.
|
||||
*/
|
||||
silentForMs: number | null;
|
||||
todayBills: number;
|
||||
todayAmount: number;
|
||||
lastBillAt: Date | null;
|
||||
reportedAt: Date | null;
|
||||
receivedAt: Date | null;
|
||||
/** Positive: the till's clock runs ahead of the server's. null when unknowable. */
|
||||
clockDriftSeconds: number | null;
|
||||
reason: string;
|
||||
appVersion: string;
|
||||
batteryLevel: number | null;
|
||||
batteryCharging: boolean | null;
|
||||
storageFreeMb: number | null;
|
||||
printerReachable: boolean | null;
|
||||
drawerStatus: string;
|
||||
}
|
||||
|
||||
const num = (value: string | undefined): number => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Nullable device readings.
|
||||
*
|
||||
* `models/poshealth.go` makes these pointers deliberately: "writing an
|
||||
* uncollected reading as 0 would show a board full of terminals on a flat
|
||||
* battery with an unreachable printer. A nil field is skipped entirely." So
|
||||
* absent must stay absent here too, never coerced.
|
||||
*/
|
||||
const maybeNum = (value: string | undefined): number | null => {
|
||||
if (value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const maybeBool = (value: string | undefined): boolean | null => {
|
||||
if (value === undefined || value === '') return null;
|
||||
return value === 'true' || value === '1';
|
||||
};
|
||||
|
||||
const date = (value: string | undefined): Date | null => {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* A terminal code, or the named bucket for bills that carry none.
|
||||
*
|
||||
* `posSalesRepository.go` groups on `COALESCE(terminalid,'')`, so every bill
|
||||
* that arrived before terminal codes were recorded collapses into one row with
|
||||
* an empty id. Rendering that as a blank card title reads as a bug; naming it
|
||||
* reads as what it is. The old console does the same.
|
||||
*/
|
||||
export function readTerminalId(raw: string | undefined): string {
|
||||
const trimmed = (raw ?? '').trim();
|
||||
return trimmed === '' ? UNASSIGNED : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this the expired-key stub rather than a real record?
|
||||
*
|
||||
* `posPresence.go` synthesises exactly four fields when a presence key has
|
||||
* expired — `terminal_id`, `location_id`, `status: "offline"`, `reason` — and
|
||||
* nothing else. Testing for that documented shape rather than for absent
|
||||
* heartbeat fields matters, because an MQTT Last Will record MERGES over the
|
||||
* previous heartbeat (`HSet`; only device fields are `HDel`'d), so a till that
|
||||
* lost power keeps stale timestamps from before and would otherwise be
|
||||
* mistaken for a vanished one.
|
||||
*/
|
||||
function isExpiredStub(raw: PosTerminalHealth): boolean {
|
||||
return raw.status === 'offline' && (raw.reason ?? '') !== '' && !raw.received_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one till's reported hash into something a screen can render.
|
||||
*
|
||||
* Age is computed from `received_at` — when the SERVER stamped the reading —
|
||||
* and never from `reported_at`, which the till writes about itself. The old
|
||||
* console is explicit about why: "a till with a wrong clock must not be able to
|
||||
* make itself look fresh." A device an hour fast would otherwise report a
|
||||
* future timestamp, compute a negative age, and render as freshly seen while
|
||||
* nobody had heard from it since morning.
|
||||
*
|
||||
* `now` is a parameter rather than a call to `Date.now()` so a whole board is
|
||||
* derived against a single instant.
|
||||
*/
|
||||
export function readTerminal(raw: PosTerminalHealth, now: number): TerminalStatus {
|
||||
const pendingBills = num(raw.pending_bills) + num(raw.pending_registrations);
|
||||
const oldestPendingAt = date(raw.oldest_pending_at);
|
||||
const oldestPendingMs = oldestPendingAt
|
||||
? Math.max(0, now - oldestPendingAt.getTime())
|
||||
: 0;
|
||||
|
||||
const reportedAt = date(raw.reported_at);
|
||||
const receivedAt = date(raw.received_at);
|
||||
const silentForMs = receivedAt ? Math.max(0, now - receivedAt.getTime()) : null;
|
||||
|
||||
let presence: PresenceState;
|
||||
if (isExpiredStub(raw)) presence = 'no-heartbeat';
|
||||
else if (raw.status !== 'online') presence = 'offline';
|
||||
else if (silentForMs === null || silentForMs > STALE_THRESHOLD_MS) presence = 'stale';
|
||||
else presence = 'online';
|
||||
|
||||
let sync: SyncState;
|
||||
if (pendingBills === 0) sync = 'synced';
|
||||
else if (oldestPendingMs > DELAY_THRESHOLD_MS) sync = 'delayed';
|
||||
else sync = 'syncing';
|
||||
|
||||
// Both timestamps must be present for the subtraction to mean anything — an
|
||||
// absent `received_at` is not a drift of zero.
|
||||
const clockDriftSeconds =
|
||||
reportedAt && receivedAt
|
||||
? Math.round((reportedAt.getTime() - receivedAt.getTime()) / 1000)
|
||||
: null;
|
||||
|
||||
return {
|
||||
terminalId: readTerminalId(raw.terminal_id),
|
||||
storeName: raw.store_name ?? '',
|
||||
presence,
|
||||
sync,
|
||||
state: presence === 'online' ? sync : presence,
|
||||
pendingBills,
|
||||
oldestPendingMs,
|
||||
oldestPendingAt,
|
||||
silentForMs,
|
||||
todayBills: num(raw.today_bills),
|
||||
todayAmount: num(raw.today_amount),
|
||||
lastBillAt: date(raw.last_bill_at),
|
||||
reportedAt,
|
||||
receivedAt,
|
||||
clockDriftSeconds,
|
||||
reason: raw.reason ?? '',
|
||||
appVersion: raw.app_version ?? '',
|
||||
batteryLevel: maybeNum(raw.battery_level),
|
||||
batteryCharging: maybeBool(raw.battery_charging),
|
||||
storageFreeMb: maybeNum(raw.storage_free_mb),
|
||||
printerReachable: maybeBool(raw.printer_reachable),
|
||||
drawerStatus: raw.drawer_status ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export interface BranchSyncSummary {
|
||||
state: TerminalState;
|
||||
online: number;
|
||||
total: number;
|
||||
pendingBills: number;
|
||||
oldestPendingMs: number;
|
||||
lastBillAt: Date | null;
|
||||
terminals: TerminalStatus[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity order, worst last.
|
||||
*
|
||||
* `stale` outranks `delayed` deliberately. A delayed queue is a known quantity —
|
||||
* we can see it, and we know how much is in it. A stale till is an unknown one:
|
||||
* its last word was "fine", and everything since is guesswork.
|
||||
*/
|
||||
const RANK: Record<TerminalState, number> = {
|
||||
synced: 0,
|
||||
syncing: 1,
|
||||
delayed: 2,
|
||||
stale: 3,
|
||||
offline: 4,
|
||||
'no-heartbeat': 5,
|
||||
};
|
||||
|
||||
export function rankOf(state: TerminalState): number {
|
||||
return RANK[state];
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll a branch's tills into one status.
|
||||
*
|
||||
* The branch takes its WORST till's state, not an average. A shop with four
|
||||
* healthy counters and one stranded for an hour is a shop with a problem, and
|
||||
* averaging is exactly how that hour goes unnoticed.
|
||||
*/
|
||||
export function summariseBranch(raw: PosTerminalHealth[], now: number): BranchSyncSummary {
|
||||
const terminals = raw.map((entry) => readTerminal(entry, now));
|
||||
|
||||
const worst = terminals.reduce<TerminalState>(
|
||||
(acc, terminal) => (RANK[terminal.state] > RANK[acc] ? terminal.state : acc),
|
||||
'synced',
|
||||
);
|
||||
|
||||
const lastBillAt = terminals.reduce<Date | null>((acc, terminal) => {
|
||||
if (!terminal.lastBillAt) return acc;
|
||||
return !acc || terminal.lastBillAt > acc ? terminal.lastBillAt : acc;
|
||||
}, null);
|
||||
|
||||
return {
|
||||
state: terminals.length === 0 ? 'no-heartbeat' : worst,
|
||||
// Counts only tills we can currently see. A stale till is not online —
|
||||
// counting it there is how 3/3 stays green through an outage. Fiesta's own
|
||||
// `online` figure counts `status == "online"`, which cannot tell a fresh
|
||||
// heartbeat from one about to expire, so it is recomputed here.
|
||||
online: terminals.filter((terminal) => terminal.presence === 'online').length,
|
||||
total: terminals.length,
|
||||
pendingBills: terminals.reduce((sum, terminal) => sum + terminal.pendingBills, 0),
|
||||
oldestPendingMs: terminals.reduce(
|
||||
(max, terminal) => Math.max(max, terminal.oldestPendingMs),
|
||||
0,
|
||||
),
|
||||
lastBillAt,
|
||||
terminals,
|
||||
};
|
||||
}
|
||||
|
||||
/** "4m", "2h" — short enough to sit in a table cell. Zero reads as an em-dash. */
|
||||
export function shortAge(ms: number): string {
|
||||
if (ms <= 0) return '—';
|
||||
const mins = Math.floor(ms / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A duration in words, for a sentence rather than a cell.
|
||||
*
|
||||
* "about 3 hours", not "3h". The board reads as prose now, and an abbreviation
|
||||
* in the middle of a sentence is a small stumble for someone who does not read
|
||||
* dashboards for a living.
|
||||
*/
|
||||
export function spokenAge(ms: number): string {
|
||||
const mins = Math.round(ms / 60000);
|
||||
if (mins < 1) return 'less than a minute';
|
||||
if (mins === 1) return 'a minute';
|
||||
if (mins < 60) return `${mins} minutes`;
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours === 1) return 'about an hour';
|
||||
if (hours < 24) return `about ${hours} hours`;
|
||||
const days = Math.round(hours / 24);
|
||||
return days === 1 ? 'about a day' : `about ${days} days`;
|
||||
}
|
||||
|
||||
/** "14:32" — a wall-clock time a person can compare against their own day. */
|
||||
export function clockTime(value: Date | null): string {
|
||||
if (!value) return '—';
|
||||
return `${String(value.getHours()).padStart(2, '0')}:${String(value.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export const SYNC_LABEL: Record<TerminalState, string> = {
|
||||
synced: 'Synced',
|
||||
syncing: 'Syncing',
|
||||
delayed: 'Delayed',
|
||||
stale: 'Stale',
|
||||
offline: 'Offline',
|
||||
'no-heartbeat': 'No heartbeat',
|
||||
};
|
||||
|
||||
export const SYNC_TONE: Record<TerminalState, 'success' | 'info' | 'warning' | 'error'> = {
|
||||
synced: 'success',
|
||||
syncing: 'info',
|
||||
delayed: 'error',
|
||||
stale: 'warning',
|
||||
offline: 'warning',
|
||||
'no-heartbeat': 'error',
|
||||
};
|
||||
|
||||
export const SYNC_COLOR: Record<TerminalState, string> = {
|
||||
synced: 'var(--color-success, #10b981)',
|
||||
syncing: 'var(--color-brand)',
|
||||
delayed: 'var(--color-error, #d64545)',
|
||||
stale: 'var(--color-warning, #b7860b)',
|
||||
offline: 'var(--color-warning, #b7860b)',
|
||||
'no-heartbeat': 'var(--color-error, #d64545)',
|
||||
};
|
||||
318
src/features/store-admin/terminalProblems.ts
Normal file
318
src/features/store-admin/terminalProblems.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import {
|
||||
CLOCK_DRIFT_THRESHOLD_S,
|
||||
clockTime,
|
||||
spokenAge,
|
||||
type TerminalStatus,
|
||||
} from './posStatus';
|
||||
|
||||
/**
|
||||
* What a counter has to say, in words a shopkeeper uses.
|
||||
*
|
||||
* This replaces a severity ladder — critical/warning/info over codes like
|
||||
* `queue-stranded` and `clock-drift`. That vocabulary is borrowed from
|
||||
* monitoring tools and means nothing to the person who actually opens this
|
||||
* screen: a supervisor who knows the shop and nothing about networks. "Stale"
|
||||
* is not a word about a cash register.
|
||||
*
|
||||
* So the model is now: a counter produces zero or more PROBLEMS, each of which
|
||||
* is one plain sentence, one bucket, and one instruction. One problem, one
|
||||
* card. A counter with two problems appears twice — which is the point, because
|
||||
* a card carrying two instructions carries neither.
|
||||
*/
|
||||
export type Bucket = 'now' | 'look' | 'fine';
|
||||
|
||||
export type ProblemCode =
|
||||
| 'money-stuck'
|
||||
| 'gone-quiet'
|
||||
| 'not-selling'
|
||||
| 'printer-down'
|
||||
| 'wrong-date'
|
||||
| 'storage-low'
|
||||
| 'battery-low'
|
||||
| 'drawer-open'
|
||||
| 'never-used'
|
||||
| 'no-status';
|
||||
|
||||
export interface Problem {
|
||||
code: ProblemCode;
|
||||
bucket: Bucket;
|
||||
/** The line in large type. Leads with what is at stake, not what is broken. */
|
||||
headline: string;
|
||||
/** One sentence of what is actually true. */
|
||||
detail: string;
|
||||
/** What a person should do. Rendered in bold; absent when there is nothing. */
|
||||
action?: string;
|
||||
/** A reassurance, when the honest answer is "this looks worse than it is". */
|
||||
comfort?: string;
|
||||
/** How long it has been true, in ms. null when we cannot know. */
|
||||
forMs: number | null;
|
||||
}
|
||||
|
||||
export const BUCKET_LABEL: Record<Bucket, string> = {
|
||||
now: 'Needs someone now',
|
||||
look: 'Worth a look',
|
||||
fine: 'All fine',
|
||||
};
|
||||
|
||||
export const BUCKET_RANK: Record<Bucket, number> = { fine: 0, look: 1, now: 2 };
|
||||
|
||||
export const BUCKET_COLOR: Record<Bucket, string> = {
|
||||
now: 'var(--color-error, #d64545)',
|
||||
look: 'var(--color-warning, #b7860b)',
|
||||
fine: 'var(--color-success, #10b981)',
|
||||
};
|
||||
|
||||
export interface ProblemContext {
|
||||
/** Evaluation instant, so a whole board is judged against one clock. */
|
||||
now: number;
|
||||
/** The branch's trading hours, if known — `"09:00"` / `"21:30"`. */
|
||||
opentime?: string;
|
||||
closetime?: string;
|
||||
/** Counters confirmed stale on the PREVIOUS read. See the `gone-quiet` note. */
|
||||
wasStale?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the branch shut right now?
|
||||
*
|
||||
* Anything unparseable answers "open", on purpose. This flag only ever
|
||||
* DOWNGRADES a problem, so failing to parse keeps the alert at full strength —
|
||||
* the safe direction to be wrong in.
|
||||
*/
|
||||
export function isClosed(context: ProblemContext): boolean {
|
||||
const open = parseClock(context.opentime);
|
||||
const close = parseClock(context.closetime);
|
||||
if (open === null || close === null) return false;
|
||||
|
||||
const at = new Date(context.now);
|
||||
const mins = at.getHours() * 60 + at.getMinutes();
|
||||
// A close time earlier than the open time means the shop trades past
|
||||
// midnight, so the open window wraps rather than being empty.
|
||||
return close < open ? mins >= close && mins < open : mins < open || mins >= close;
|
||||
}
|
||||
|
||||
function parseClock(value: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const match = /^(\d{1,2}):(\d{2})/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const mins = Number(match[2]);
|
||||
if (!Number.isFinite(hours) || !Number.isFinite(mins)) return null;
|
||||
if (hours > 23 || mins > 59) return null;
|
||||
return hours * 60 + mins;
|
||||
}
|
||||
|
||||
/** ₹ with Indian grouping and no decimals. Whole rupees are what people say. */
|
||||
export function rupees(amount: number): string {
|
||||
return `₹${Math.round(amount).toLocaleString('en-IN')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roughly what the unsent bills are worth.
|
||||
*
|
||||
* The health payload carries a COUNT of pending bills and no value — Fiesta
|
||||
* simply does not report one. But "41 sales haven't reached us" gives a
|
||||
* supervisor no sense of whether that is four thousand rupees or four hundred
|
||||
* thousand, and the difference decides whether they walk over now.
|
||||
*
|
||||
* So this estimates from the till's OWN average sale today, and every caller
|
||||
* must render it with the word "roughly" attached. It returns null rather than
|
||||
* guessing when the till has sold nothing today to average over — an estimate
|
||||
* from no data is a fabrication, not an estimate.
|
||||
*/
|
||||
export function estimatePendingValue(terminal: TerminalStatus): number | null {
|
||||
if (terminal.todayBills <= 0 || terminal.todayAmount <= 0) return null;
|
||||
if (terminal.pendingBills <= 0) return null;
|
||||
return (terminal.todayAmount / terminal.todayBills) * terminal.pendingBills;
|
||||
}
|
||||
|
||||
const sales = (n: number) => `${n} sale${n === 1 ? '' : 's'}`;
|
||||
|
||||
/**
|
||||
* Everything one counter has to say, worst first.
|
||||
*/
|
||||
export function problemsFor(
|
||||
terminal: TerminalStatus,
|
||||
context: ProblemContext,
|
||||
): Problem[] {
|
||||
const problems: Problem[] = [];
|
||||
const closed = isClosed(context);
|
||||
const absent = terminal.presence !== 'online';
|
||||
const neverSold = terminal.todayBills === 0 && terminal.lastBillAt === null;
|
||||
const pending = terminal.pendingBills;
|
||||
|
||||
/**
|
||||
* Money on a counter nobody can see.
|
||||
*
|
||||
* The single worst thing this board reports, and the reason it exists. From
|
||||
* `models/poshealth.go`: "A shop quietly accumulating unsynced takings looks
|
||||
* completely normal from the shop floor, and this is the only thing that
|
||||
* makes it visible before someone reconciles a till and finds a day missing."
|
||||
*
|
||||
* It absorbs the presence problem — a card that said both "money stuck" and
|
||||
* "gone quiet" would be one card asking for the same walk to the same
|
||||
* counter, twice.
|
||||
*/
|
||||
if (absent && pending > 0) {
|
||||
const worth = estimatePendingValue(terminal);
|
||||
problems.push({
|
||||
code: 'money-stuck',
|
||||
bucket: 'now',
|
||||
headline: `${sales(pending)} haven’t reached us`,
|
||||
detail: worth
|
||||
? `Roughly ${rupees(worth)}, judging by this counter’s average sale today. The oldest has been waiting since ${clockTime(terminal.oldestPendingAt)}, and we can’t currently see this counter to watch them send.`
|
||||
: `The oldest has been waiting since ${clockTime(terminal.oldestPendingAt)}, and we can’t currently see this counter to watch them send.`,
|
||||
action: 'Check the counter is switched on and its internet is working.',
|
||||
// The most important sentence on the page. It is the difference between
|
||||
// a supervisor phoning head office and a supervisor checking a router.
|
||||
comfort:
|
||||
'The sales aren’t lost — the till keeps its own copy and sends them as soon as it reconnects.',
|
||||
forMs: terminal.oldestPendingMs || terminal.silentForMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dark, with nothing held.
|
||||
*
|
||||
* Urgent only when the shop is open and this counter has traded before.
|
||||
* Outside trading hours it is a shop that shut; on a counter that has never
|
||||
* sold it is almost certainly a spare, and `never-used` says so better.
|
||||
*/
|
||||
if (absent && pending === 0 && !neverSold) {
|
||||
const urgent = !closed;
|
||||
problems.push({
|
||||
code: 'gone-quiet',
|
||||
bucket: urgent ? 'now' : 'look',
|
||||
headline: closed ? 'Switched off' : 'Gone quiet',
|
||||
detail: closed
|
||||
? `We haven’t heard from this counter since ${clockTime(terminal.receivedAt)}. The shop is outside its opening hours, so this is probably just closing up.`
|
||||
: terminal.silentForMs === null
|
||||
? 'We can’t see this counter at all, and the shop is open.'
|
||||
: `We haven’t heard from it for ${spokenAge(terminal.silentForMs)}, and the shop is open.`,
|
||||
...(urgent
|
||||
? {
|
||||
action:
|
||||
'Check it’s switched on and connected. If it’s been turned off on purpose, you can ignore this.',
|
||||
}
|
||||
: {}),
|
||||
forMs: terminal.silentForMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reaching us, but nothing rung.
|
||||
*
|
||||
* `models/poshealth.go` again: "A till that is connected but has rung nothing
|
||||
* in three hours usually means a jammed printer or an absent cashier, and
|
||||
* neither shows up in a plain online/offline board."
|
||||
*/
|
||||
if (!absent && !closed && terminal.todayBills === 0 && terminal.lastBillAt !== null) {
|
||||
problems.push({
|
||||
code: 'not-selling',
|
||||
bucket: 'look',
|
||||
headline: 'Hasn’t sold anything today',
|
||||
detail: `It’s switched on and reaching us, but nothing has been rung up. Its last sale was ${clockTime(terminal.lastBillAt)}.`,
|
||||
action: 'Usually a jammed printer, or nobody on the counter.',
|
||||
forMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (terminal.printerReachable === false) {
|
||||
problems.push({
|
||||
code: 'printer-down',
|
||||
bucket: 'look',
|
||||
headline: 'Printer isn’t responding',
|
||||
detail: 'Staff can still ring up sales on this counter, but it can’t print a receipt.',
|
||||
action: 'Check the printer is switched on, connected and has paper.',
|
||||
forMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
terminal.clockDriftSeconds !== null &&
|
||||
Math.abs(terminal.clockDriftSeconds) > CLOCK_DRIFT_THRESHOLD_S
|
||||
) {
|
||||
const drift = Math.abs(terminal.clockDriftSeconds) * 1000;
|
||||
problems.push({
|
||||
code: 'wrong-date',
|
||||
bucket: 'look',
|
||||
headline: 'This counter’s clock is wrong',
|
||||
detail: `It’s set ${spokenAge(drift)} ${terminal.clockDriftSeconds > 0 ? 'ahead of' : 'behind'} the real time. Sales rung here can be recorded against the wrong day, which shows up later as a day that doesn’t add up.`,
|
||||
action: 'Set the device’s date and time to update automatically.',
|
||||
forMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (terminal.storageFreeMb !== null && terminal.storageFreeMb < 200) {
|
||||
problems.push({
|
||||
code: 'storage-low',
|
||||
bucket: 'look',
|
||||
headline: 'Running out of space',
|
||||
detail: `Only ${terminal.storageFreeMb} MB left. If this counter goes offline it needs room to hold sales until it reconnects.`,
|
||||
action: 'Ask whoever set the device up to clear some space on it.',
|
||||
forMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (terminal.batteryLevel !== null && terminal.batteryLevel < 20 && !terminal.batteryCharging) {
|
||||
problems.push({
|
||||
code: 'battery-low',
|
||||
bucket: 'look',
|
||||
headline: `Battery at ${terminal.batteryLevel}%`,
|
||||
detail: 'It isn’t charging, so this counter will shut down on its own before long.',
|
||||
action: 'Plug it in.',
|
||||
forMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (terminal.drawerStatus === 'open') {
|
||||
problems.push({
|
||||
code: 'drawer-open',
|
||||
bucket: 'look',
|
||||
headline: 'Cash drawer is open',
|
||||
detail: 'This counter is reporting its cash drawer as open.',
|
||||
forMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Never rung a sale.
|
||||
*
|
||||
* Said out loud rather than left silent. From the old console: "almost always
|
||||
* a commissioning probe or a till set up and never put into service — worth
|
||||
* saying, because 'offline' on such a terminal is not an incident." A board
|
||||
* that shows spares as permanent emergencies is a board people stop reading.
|
||||
*/
|
||||
if (neverSold) {
|
||||
problems.push({
|
||||
code: 'never-used',
|
||||
bucket: 'look',
|
||||
headline: 'Never used',
|
||||
detail:
|
||||
'This counter has never rung a sale. It’s most likely a spare, or one that was set up and never put into service.',
|
||||
action: 'If you don’t use it, hide it to keep this list tidy.',
|
||||
forMs: null,
|
||||
});
|
||||
}
|
||||
|
||||
return problems.sort((a, b) => BUCKET_RANK[b.bucket] - BUCKET_RANK[a.bucket]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The problem for a counter that sold but reports no status at all.
|
||||
*
|
||||
* These exist only in the sales figures — there is no presence record to build
|
||||
* a card from, so without this they are invisible on the board while sitting
|
||||
* plainly in the day's takings.
|
||||
*/
|
||||
export function noStatusProblem(bills: number, amount: number): Problem {
|
||||
return {
|
||||
code: 'no-status',
|
||||
bucket: 'look',
|
||||
headline: 'Not reporting its status',
|
||||
detail: `This counter sold ${rupees(amount)} across ${sales(bills)}, but it doesn’t tell us whether it’s switched on or how it’s doing.`,
|
||||
action: 'Usually an older version of the till app. Worth having it updated.',
|
||||
comfort: 'Those sales are safely recorded — this is only about status.',
|
||||
forMs: null,
|
||||
};
|
||||
}
|
||||
272
src/features/store-admin/useTerminalBoard.ts
Normal file
272
src/features/store-admin/useTerminalBoard.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import type { TenantLocation } from '@/api/types';
|
||||
import { usePosHealthByBranch, usePosSalesByBranch } from '@/queries/hooks';
|
||||
import { readTerminal, readTerminalId, type TerminalStatus } from './posStatus';
|
||||
import {
|
||||
BUCKET_RANK,
|
||||
noStatusProblem,
|
||||
problemsFor,
|
||||
type Bucket,
|
||||
type Problem,
|
||||
type ProblemContext,
|
||||
} from './terminalProblems';
|
||||
|
||||
/**
|
||||
* One card on the board: a COUNTER, within one bucket.
|
||||
*
|
||||
* The split is by urgency, not by problem. A till that is out of paper AND low
|
||||
* on space AND on a flat battery is one counter someone walks to once — three
|
||||
* consecutive cards with the same name at the top read as a fault in the page,
|
||||
* not as three things to do.
|
||||
*
|
||||
* But a counter that is BOTH holding unsent sales and low on storage appears
|
||||
* twice, once in each bucket, because those are genuinely different errands
|
||||
* with different urgency. `problem` is the one that decides the headline and
|
||||
* the instruction; `also` is everything else in the same bucket, listed short.
|
||||
*/
|
||||
export interface BoardCard {
|
||||
key: string;
|
||||
branchId: number;
|
||||
branchName: string;
|
||||
terminalId: string;
|
||||
problem: Problem;
|
||||
also: Problem[];
|
||||
status: TerminalStatus | null;
|
||||
/** Trading in the selected period, from the sales split. */
|
||||
periodBills: number;
|
||||
periodAmount: number;
|
||||
}
|
||||
|
||||
/** A counter with nothing wrong. One line, no card. */
|
||||
export interface HealthyCounter {
|
||||
key: string;
|
||||
branchId: number;
|
||||
branchName: string;
|
||||
terminalId: string;
|
||||
status: TerminalStatus;
|
||||
periodBills: number;
|
||||
periodAmount: number;
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
now: BoardCard[];
|
||||
look: BoardCard[];
|
||||
fine: HealthyCounter[];
|
||||
/** Every counter id in scope, including hidden ones. */
|
||||
total: number;
|
||||
/**
|
||||
* Sales sitting on counters we CANNOT currently see.
|
||||
*
|
||||
* Not every unsent sale. A till that is online with three bills in flight is
|
||||
* sending them right now and will be done before anyone reads this; counting
|
||||
* those in the page headline turns a normal second into an alarm, and a
|
||||
* headline that cries wolf is the one thing this page cannot afford.
|
||||
*/
|
||||
strandedBills: number;
|
||||
isLoading: boolean;
|
||||
/** Branches whose health read FAILED — not branches with no counters. */
|
||||
failed: string[];
|
||||
/** Names of every branch actually asked about, for the empty state. */
|
||||
checked: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember which counters were stale on the PREVIOUS read.
|
||||
*
|
||||
* Hysteresis. The till heartbeats every 30s, so one on a weak connection
|
||||
* crosses the staleness line and comes back on alternate reads. Requiring two
|
||||
* consecutive misses costs one refresh of latency and buys a board that does
|
||||
* not flicker — and a board that flickers is one people learn to ignore, which
|
||||
* is the failure mode that actually matters.
|
||||
*
|
||||
* Keyed on the query's `dataUpdatedAt` rather than the data, which gets a fresh
|
||||
* identity each poll whether or not anything changed.
|
||||
*/
|
||||
function usePreviousStale(current: ReadonlySet<string>, updatedAt: number): ReadonlySet<string> {
|
||||
const previous = useRef<ReadonlySet<string>>(new Set<string>());
|
||||
const snapshot = useRef<ReadonlySet<string>>(new Set<string>());
|
||||
const seenAt = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (updatedAt === seenAt.current) return;
|
||||
seenAt.current = updatedAt;
|
||||
previous.current = snapshot.current;
|
||||
snapshot.current = current;
|
||||
}, [updatedAt, current]);
|
||||
|
||||
return previous.current;
|
||||
}
|
||||
|
||||
export interface BoardOptions {
|
||||
branches: readonly TenantLocation[];
|
||||
range: DateRange;
|
||||
/** Counters the operator has hidden. See `counterLabels.ts`. */
|
||||
isHidden: (terminalId: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the board.
|
||||
*
|
||||
* Two reads per branch — health and the sales split — because the second is the
|
||||
* only way to find a counter that is billing while reporting nothing. Both fan
|
||||
* out per location: there is no tenant-wide POS read in Fiesta.
|
||||
*/
|
||||
export function useTerminalBoard({ branches, range, isHidden }: BoardOptions): Board {
|
||||
const branchIds = useMemo(() => branches.map((branch) => branch.locationid), [branches]);
|
||||
const health = usePosHealthByBranch(branchIds);
|
||||
const sales = usePosSalesByBranch(branchIds, range);
|
||||
|
||||
const now = Date.now();
|
||||
const updatedAt = health.reduce((max, query) => Math.max(max, query.dataUpdatedAt ?? 0), 0);
|
||||
|
||||
const read = useMemo(
|
||||
() =>
|
||||
branches.map((branch, index) => ({
|
||||
branch,
|
||||
query: health[index],
|
||||
terminals: (health[index]?.data ?? []).map((raw) => readTerminal(raw, now)),
|
||||
})),
|
||||
// `now` is excluded deliberately: including it re-derives the whole board
|
||||
// on every render rather than on every read, and the ages on screen do not
|
||||
// need sub-poll precision.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[branches, health, updatedAt],
|
||||
);
|
||||
|
||||
const currentlyStale = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
read.flatMap(({ terminals }) =>
|
||||
terminals.filter((t) => t.presence === 'stale').map((t) => t.terminalId),
|
||||
),
|
||||
),
|
||||
[read],
|
||||
);
|
||||
const wasStale = usePreviousStale(currentlyStale, updatedAt);
|
||||
|
||||
return useMemo(() => {
|
||||
const cards: BoardCard[] = [];
|
||||
const fine: HealthyCounter[] = [];
|
||||
const failed: string[] = [];
|
||||
let total = 0;
|
||||
let strandedBills = 0;
|
||||
|
||||
read.forEach(({ branch, query, terminals }, index) => {
|
||||
if (query?.isError) failed.push(branch.locationname);
|
||||
|
||||
const split = sales[index]?.data?.byterminal ?? [];
|
||||
// `terminalid`, not `terminal_id` — the sales and presence payloads
|
||||
// disagree on the spelling. See `PosSalesSummary` in `api/types.ts`.
|
||||
const byId = new Map(
|
||||
split.map((entry) => [readTerminalId(entry.terminalid), entry] as const),
|
||||
);
|
||||
|
||||
const context: ProblemContext = {
|
||||
now,
|
||||
wasStale,
|
||||
...(branch.opentime ? { opentime: branch.opentime } : {}),
|
||||
...(branch.closetime ? { closetime: branch.closetime } : {}),
|
||||
};
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const status of terminals) {
|
||||
seen.add(status.terminalId);
|
||||
if (isHidden(status.terminalId)) continue;
|
||||
total += 1;
|
||||
if (status.presence !== 'online') strandedBills += status.pendingBills;
|
||||
|
||||
const sale = byId.get(status.terminalId);
|
||||
const periodBills = sale?.billcount ?? 0;
|
||||
const periodAmount = sale?.amount ?? 0;
|
||||
const problems = problemsFor(status, context);
|
||||
|
||||
if (problems.length === 0) {
|
||||
fine.push({
|
||||
key: `${branch.locationid}:${status.terminalId}`,
|
||||
branchId: branch.locationid,
|
||||
branchName: branch.locationname,
|
||||
terminalId: status.terminalId,
|
||||
status,
|
||||
periodBills,
|
||||
periodAmount,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// One card per bucket this counter has problems in, not one per
|
||||
// problem. `problemsFor` already returns worst first, so the first in
|
||||
// each bucket is the one that earns the headline.
|
||||
for (const bucket of ['now', 'look'] as const) {
|
||||
const inBucket = problems.filter((problem) => problem.bucket === bucket);
|
||||
const [primary, ...also] = inBucket;
|
||||
if (!primary) continue;
|
||||
cards.push({
|
||||
key: `${branch.locationid}:${status.terminalId}:${bucket}`,
|
||||
branchId: branch.locationid,
|
||||
branchName: branch.locationname,
|
||||
terminalId: status.terminalId,
|
||||
problem: primary,
|
||||
also,
|
||||
status,
|
||||
periodBills,
|
||||
periodAmount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Counters known only from the sales figures. They have no presence
|
||||
// record to build a card from, so without this they are invisible here
|
||||
// while sitting plainly in the day's takings.
|
||||
for (const entry of split) {
|
||||
const id = readTerminalId(entry.terminalid);
|
||||
if (seen.has(id) || isHidden(id)) continue;
|
||||
total += 1;
|
||||
const problem = noStatusProblem(entry.billcount ?? 0, entry.amount ?? 0);
|
||||
cards.push({
|
||||
key: `${branch.locationid}:${id}:${problem.bucket}`,
|
||||
branchId: branch.locationid,
|
||||
branchName: branch.locationname,
|
||||
terminalId: id,
|
||||
problem,
|
||||
also: [],
|
||||
status: null,
|
||||
periodBills: entry.billcount ?? 0,
|
||||
periodAmount: entry.amount ?? 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
cards.sort(compareCards);
|
||||
fine.sort((a, b) => b.periodAmount - a.periodAmount);
|
||||
|
||||
const inBucket = (bucket: Bucket) => cards.filter((card) => card.problem.bucket === bucket);
|
||||
|
||||
return {
|
||||
now: inBucket('now'),
|
||||
look: inBucket('look'),
|
||||
fine,
|
||||
total,
|
||||
strandedBills,
|
||||
isLoading: health.some((query) => query.isLoading),
|
||||
failed,
|
||||
checked: branches.map((branch) => branch.locationname),
|
||||
};
|
||||
}, [read, sales, wasStale, health, branches, isHidden, now]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Worst first, then longest-running, then by counter.
|
||||
*
|
||||
* Not alphabetical, and not grouped by branch. Noticing should not require
|
||||
* scanning: the counter that needs someone is the one the eye lands on, without
|
||||
* being looked for. The branch is named on every card instead.
|
||||
*/
|
||||
function compareCards(a: BoardCard, b: BoardCard): number {
|
||||
const bucket = BUCKET_RANK[b.problem.bucket] - BUCKET_RANK[a.problem.bucket];
|
||||
if (bucket !== 0) return bucket;
|
||||
const duration = (b.problem.forMs ?? 0) - (a.problem.forMs ?? 0);
|
||||
if (duration !== 0) return duration;
|
||||
return a.terminalId.localeCompare(b.terminalId);
|
||||
}
|
||||
Reference in New Issue
Block a user