backend integration started

This commit is contained in:
2026-08-26 18:37:28 +05:30
parent 517a99d577
commit 3545819be8
50 changed files with 3461 additions and 3241 deletions

View File

@@ -1,19 +1,23 @@
import { lazy, Suspense, useState, type FormEvent, type ReactNode } from 'react';
import { 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 {
AlertCircle,
ArrowRight,
Eye,
EyeOff,
Loader2,
Lock,
Mail,
ShieldCheck,
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;
import {
MIN_PASSWORD_LENGTH,
PasswordSetupRequiredError,
setInitialPassword,
} from '@/auth/session';
/**
* Sign-in — KROW's full-bleed auth archetype.
@@ -29,16 +33,27 @@ const DemoPanel = import.meta.env.DEV
* lands where their account says they belong.
*/
export function LoginPage() {
const { user, signIn, signInAsDemo } = useAuth();
const { user, signIn } = 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);
/**
* The userid `applogin` returned for an account with no password, or null.
*
* Non-null flips this page into its second state. It is deliberately not a
* route: the userid only exists because a sign-in attempt just produced it,
* and a `/set-password` URL that could be opened cold would be a way to set
* any account's password from nothing.
*/
const [setupUserid, setSetupUserid] = useState<number | null>(null);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
async function handleSubmit(event: FormEvent) {
@@ -49,19 +64,52 @@ export function LoginPage() {
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',
);
// Not a failure — the first step. The account exists and has no password,
// and the backend handed back the userid to set one against.
if (cause instanceof PasswordSetupRequiredError) {
setSetupUserid(cause.userid);
setNewPassword('');
setConfirmPassword('');
} else {
setError(cause instanceof Error ? cause.message : 'Sign-in failed');
}
} finally {
setIsBusy(false);
}
}
/**
* Set the password, then sign in with it.
*
* Signing in afterwards rather than sending the person back to the form: they
* have just typed the password twice, and `applogin` is the only proof the
* write actually took.
*/
async function handleSetup(event: FormEvent) {
event.preventDefault();
if (setupUserid === null) return;
setError(null);
if (newPassword !== confirmPassword) {
setError('Those two passwords do not match.');
return;
}
setIsBusy(true);
try {
await setInitialPassword(setupUserid, newPassword);
const session = await signIn(email, newPassword);
navigate(HOME_ROUTE[session.role], { replace: true });
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'Could not set the password');
} finally {
setIsBusy(false);
}
}
const canSubmit = email.trim() !== '' && password !== '' && !isBusy;
const canSetup =
newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy;
return (
<div
@@ -81,24 +129,38 @@ export function LoginPage() {
}}
>
<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 });
}}
/>
{setupUserid === null ? (
<FormPanel
email={email}
password={password}
isPasswordVisible={isPasswordVisible}
error={error}
isBusy={isBusy}
canSubmit={canSubmit}
onEmail={setEmail}
onPassword={setPassword}
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
onSubmit={handleSubmit}
/>
) : (
<SetupPanel
email={email}
newPassword={newPassword}
confirmPassword={confirmPassword}
isPasswordVisible={isPasswordVisible}
error={error}
isBusy={isBusy}
canSubmit={canSetup}
onNewPassword={setNewPassword}
onConfirmPassword={setConfirmPassword}
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
onSubmit={handleSetup}
onBack={() => {
setSetupUserid(null);
setError(null);
}}
/>
)}
</div>
</div>
);
@@ -238,32 +300,26 @@ 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' }}>
@@ -311,21 +367,7 @@ function FormPanel({
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',
}}
style={eyeButtonStyle}
>
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
@@ -343,104 +385,23 @@ function FormPanel({
/>
</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>
{/* Neither "Keep me signed in" nor "Forgot password?" is here any more.
The checkbox was initialised true, toggled, rendered — and never
read: `handleSubmit` and `signIn(email, password)` never received
it, so ticking or clearing it changed nothing about how long the
session lasted. The link had no handler at all, and there is no
password-reset endpoint in Fiesta to give it one. Both promised
something the system does not do. */}
<button
type="button"
style={{
border: 0,
background: 'transparent',
padding: 0,
fontSize: 13,
fontWeight: 600,
color: 'var(--color-brand)',
cursor: 'pointer',
}}
>
Forgot password?
</button>
</div>
<ErrorNote message={error} />
{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>
<SubmitButton
canSubmit={canSubmit}
isBusy={isBusy}
busyLabel="Signing in…"
label="Continue"
/>
</form>
<p
@@ -456,16 +417,289 @@ function FormPanel({
opens — you do not pick one.
</p>
{DemoPanel ? (
<Suspense fallback={null}>
<DemoPanel onDemo={onDemo} />
</Suspense>
) : null}
</div>
</div>
);
}
/* ────────────────────────────────────────────────────────────────────────────
Right — first sign-in, setting the password
──────────────────────────────────────────────────────────────────────────── */
interface SetupPanelProps {
email: string;
newPassword: string;
confirmPassword: string;
isPasswordVisible: boolean;
error: string | null;
isBusy: boolean;
canSubmit: boolean;
onNewPassword: (value: string) => void;
onConfirmPassword: (value: string) => void;
onToggleVisible: () => void;
onSubmit: (event: FormEvent) => void;
onBack: () => void;
}
/**
* The second state of this page, not a second page.
*
* An account created by `createtenantuser` or `createtenantlocation` is spawned
* with an empty password, so its owner's first sign-in cannot succeed and there
* is no reset email to fall back on. Before this existed the page detected the
* condition and then told the person to go and find an administrator — for an
* account that was working as designed.
*/
function SetupPanel({
email,
newPassword,
confirmPassword,
isPasswordVisible,
error,
isBusy,
canSubmit,
onNewPassword,
onConfirmPassword,
onToggleVisible,
onSubmit,
onBack,
}: SetupPanelProps) {
const isTooShort = newPassword !== '' && newPassword.length < MIN_PASSWORD_LENGTH;
const isMismatched = confirmPassword !== '' && newPassword !== confirmPassword;
return (
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
<div style={{ width: '100%', maxWidth: 448, display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 7,
marginBottom: 12,
padding: '4px 10px',
borderRadius: 999,
background: 'var(--color-surface-subtle)',
border: '1px solid var(--color-line)',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.09em',
textTransform: 'uppercase',
color: 'var(--color-brand)',
}}
>
<ShieldCheck size={13} />
First sign-in
</div>
<h1
style={{
margin: 0,
fontFamily: 'var(--font-display)',
fontSize: 26,
lineHeight: 1.2,
fontWeight: 700,
letterSpacing: '-0.02em',
color: 'var(--color-ink-1)',
}}
>
Choose a password
</h1>
<p style={{ margin: '6px 0 0', fontSize: 13.5, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
{email} has no password yet. Set one now and we will sign you straight in.
</p>
</div>
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Field
label="New password"
htmlFor="new-password"
icon={<Lock size={15} />}
action={
<button
type="button"
onClick={onToggleVisible}
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
style={eyeButtonStyle}
>
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
}
>
<input
id="new-password"
type={isPasswordVisible ? 'text' : 'password'}
value={newPassword}
onChange={(event) => onNewPassword(event.target.value)}
placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`}
autoComplete="new-password"
autoFocus
required
aria-invalid={isTooShort}
style={{ ...inputStyle, paddingRight: 40 }}
/>
</Field>
<Field label="Confirm password" htmlFor="confirm-password" icon={<Lock size={15} />}>
<input
id="confirm-password"
type={isPasswordVisible ? 'text' : 'password'}
value={confirmPassword}
onChange={(event) => onConfirmPassword(event.target.value)}
placeholder="Type it again"
autoComplete="new-password"
required
aria-invalid={isMismatched}
style={{
...inputStyle,
borderColor: isMismatched ? 'rgba(214,69,69,.45)' : 'var(--color-line)',
}}
/>
</Field>
<Hint>
{isTooShort
? `A few more characters — ${MIN_PASSWORD_LENGTH} is the minimum.`
: isMismatched
? 'Those two do not match yet.'
: 'Passwords on this backend are stored as typed. Do not reuse one from elsewhere.'}
</Hint>
<ErrorNote message={error} />
<SubmitButton
canSubmit={canSubmit}
isBusy={isBusy}
busyLabel="Setting it…"
label="Set password and sign in"
/>
</form>
<button
type="button"
onClick={onBack}
style={{
alignSelf: 'center',
border: 0,
background: 'transparent',
padding: 0,
fontSize: 12.5,
fontFamily: 'inherit',
color: 'var(--color-ink-3)',
cursor: 'pointer',
textDecoration: 'underline',
textUnderlineOffset: 3,
}}
>
Use a different account
</button>
</div>
</div>
);
}
/** A quiet line under the fields — advisory, never an error. */
function Hint({ children }: { children: ReactNode }) {
return (
<p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: 'var(--color-ink-4)' }}>
{children}
</p>
);
}
/* ────────────────────────────────────────────────────────────────────────────
Shared form furniture
──────────────────────────────────────────────────────────────────────────── */
function ErrorNote({ message }: { message: string | null }) {
if (!message) return null;
return (
<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 }} />
{message}
</div>
);
}
function SubmitButton({
canSubmit,
isBusy,
label,
busyLabel,
}: {
canSubmit: boolean;
isBusy: boolean;
label: string;
busyLabel: string;
}) {
return (
<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' }} />
{busyLabel}
</>
) : (
<>
{label}
<ArrowRight size={16} />
</>
)}
</button>
);
}
const eyeButtonStyle: React.CSSProperties = {
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',
};
/* ────────────────────────────────────────────────────────────────────────────
Field
──────────────────────────────────────────────────────────────────────────── */

View File

@@ -0,0 +1,420 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { HStack } from '@astryxdesign/core/HStack';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Pagination } from '@astryxdesign/core/Pagination';
import { Skeleton } from '@astryxdesign/core/Skeleton';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Token } from '@astryxdesign/core/Token';
import { VStack } from '@astryxdesign/core/VStack';
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
import { catalogueKey } from '@/api/catalogue';
import { productsApi } from '@/api/products';
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import {
useCatalogueBrands,
useCatalogueCategories,
useCatalogueProducts,
useImportedRefs,
} from '@/queries/hooks';
import { CatalogueCard } from './CatalogueCard';
import { CatalogueSidebar } from './CatalogueSidebar';
import { CatalogueDetailDrawer } from './CatalogueDetailDrawer';
const PAGE_SIZE = 24;
export interface CatalogueBrowserProps {
/** The merchant being stocked. Without one nothing can be imported. */
tenantid: number | undefined;
/** The outlet the import is written against. Required by the backend. */
locationid: number | undefined;
/** The tenant's own categories, for the one field the catalogue cannot supply. */
categoryOptions: { value: string; label: string }[];
/** Wording on the card and drawer buttons. */
actionLabel: string;
/**
* Import through the caller instead of writing here.
*
* The Nearle Admin collects a price, a cost and a tax rate first, so its
* import is a form rather than a click; the Store Admin's is one call. The
* grid is the same either way, which is the point of this component.
*/
onImport?: (product: CatalogueProduct) => void;
/** Shown above the filters — the tenant picker, the mode toggle, a banner. */
scope?: ReactNode;
/**
* Browsing only — no Add on the cards, none in the drawer.
*
* The Nearle Admin uses this: a platform operator reads the catalogue to
* check what is in it, and adds products through the spreadsheet upload
* rather than one at a time into somebody else's shop.
*/
isReadOnly?: boolean;
/**
* Lift the search row onto the page's tab row.
*
* Only true where a tab row actually exists — the Store Admin's Inventory.
* On a page without one the offset drags the row up over the page header's
* own actions and swallows their clicks, which is exactly what it did to the
* platform catalogue's mode toggle.
*/
alignWithTabs?: boolean;
/** Why importing is unavailable, if it is. */
blockedReason?: string;
}
/**
* The global catalogue browser, shared by both consoles.
*
* There were two of these — a 920px drawer in the Store Admin and a page in the
* Nearle Admin — with different cards, different filters, different empty
* states and different words for the same button. Same data, same job, two
* designs that had already drifted apart in a month. This is the one.
*
* What differs between the two workspaces is genuinely different and stays a
* prop: who is being stocked (`scope`), and what happens on import. Everything
* a person looks at is shared.
*
* Built on Astryx primitives throughout — `ClickableCard`, `Token`,
* `EmptyState`, `Skeleton` — rather than the inline styles that grew here
* first, so the catalogue reads as part of the console instead of a page that
* happens to sit inside it.
*/
export function CatalogueBrowser({
tenantid,
locationid,
categoryOptions,
actionLabel,
onImport,
scope,
blockedReason,
isReadOnly,
alignWithTabs,
}: CatalogueBrowserProps) {
const client = useQueryClient();
const [brand, setBrand] = useState('');
const [keyword, setKeyword] = useState('');
const [debounced, setDebounced] = useState('');
const [importInto, setImportInto] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [justImported, setJustImported] = useState<Set<string>>(new Set());
const [open, setOpen] = useState<CatalogueProduct | null>(null);
const [category, setCategory] = useState('');
const [page, setPage] = useState(1);
/**
* Whether the brand rail is showing.
*
* Open by default: the rail is how you get anywhere in a catalogue of
* thousands, and starting it closed would hide the navigation behind an icon
* on a page whose whole job is browsing. The toggle is for the moment you
* have chosen a brand and want the width back for the photographs.
*/
const [isFiltersOpen, setIsFiltersOpen] = useState(true);
/**
* 400ms, and it costs more than it looks to get wrong.
*
* The all-brands search path pulls every brand's filtered set into Go memory
* and slices there (`catalogueRepository.go:321-374`), so a request per
* keystroke is expensive on the server, not merely chatty.
*/
useEffect(() => {
const id = setTimeout(() => setDebounced(keyword.trim()), 400);
return () => clearTimeout(id);
}, [keyword]);
// Any change to what is being asked for starts at page one. Staying on page
// 7 of a search that now has two results shows an empty grid and reads as a
// broken filter.
useEffect(() => {
setPage(1);
}, [brand, category, debounced]);
// A category belongs to one brand's table; carrying it across brands filters
// on a name the new brand has never heard of and quietly returns nothing.
useEffect(() => {
setCategory('');
}, [brand]);
const brands = useCatalogueBrands();
const categories = useCatalogueCategories(brand || undefined);
const imported = useImportedRefs(tenantid);
const search = useCatalogueProducts({
...(brand ? { brand } : {}),
...(category ? { category } : {}),
...(debounced ? { keyword: debounced } : {}),
// 1-based, and not optional: `pageno <= 0` is clamped to 1 server-side
// (`catalogueRepository.go:272`), so a 0-based page would fetch page one
// twice and show it as page two.
pageno: page,
pagesize: PAGE_SIZE,
});
const rows = search.data ?? [];
/**
* How many pages there are, when we can know.
*
* `getbrands` reports a count per brand, so a brand-filtered view has a real
* total. Everything else — all brands, or any keyword — has none, because
* `api.list` returns the rows and drops the envelope's `total`. There,
* `hasMore` from a full page is the honest answer, and Pagination renders
* prev/next instead of inventing a last page.
*/
const brandTotal = brand
? (brands.data ?? []).find((entry) => entry.brand === brand)?.product_count
: undefined;
const knownTotal = !category && !debounced ? brandTotal : undefined;
const importedKeys = useMemo(() => {
const set = new Set((imported.data ?? []).map(catalogueKey));
for (const key of justImported) set.add(key);
return set;
}, [imported.data, justImported]);
const importOne = useMutation({
mutationFn: (row: ImportCatalogueProductRequest) => productsApi.importFromCatalogue([row]),
onSuccess: async () => {
await Promise.all([
client.invalidateQueries({ queryKey: queryKeys.catalogue.all }),
client.invalidateQueries({ queryKey: queryKeys.products.all }),
]);
},
});
async function importDirect(product: CatalogueProduct) {
if (!tenantid || !locationid) return;
const key = catalogueKey(product);
setBusy(key);
try {
await importOne.mutateAsync({
tenantid,
locationid,
brand: product.brand,
catalogueid: product.id,
// Uncategorised beats wrongly categorised — the backend leaves this
// optional for exactly that reason. An unclassified product is visibly
// unfinished; a wrongly classified one looks done and is only found by
// somebody browsing the wrong aisle.
categoryid: Number(importInto) || 0,
subcategoryid: 0,
quantity: 0,
stocktype: 'in',
status: 'Draft',
// Zero on purpose. Import is not pricing.
retailprice: 0,
productcost: 0,
taxpercent: 0,
});
setJustImported((set) => new Set(set).add(key));
} finally {
setBusy(null);
}
}
const run = onImport ?? ((product: CatalogueProduct) => void importDirect(product));
const canImport = Boolean(!isReadOnly && tenantid && locationid && !blockedReason);
/**
* "Import into" lives in the drawer, at the moment of the decision.
*
* It used to sit in the filter row as well, which put a question about where
* ONE product should be filed next to three controls that change what the
* whole grid shows — and it read as a fourth filter. It is only offered when
* this component performs the import itself: when the caller takes over
* (`onImport`), its own form collects the category along with the price, and
* asking twice on one screen gets two answers that can disagree.
*/
const showCategoryPicker = !isReadOnly && !onImport && categoryOptions.length > 0;
function clearFilters() {
setBrand('');
setCategory('');
setKeyword('');
}
const activeFilters = [
...(brand ? [{ key: 'brand', label: brand.replace('brand_', ''), clear: () => setBrand('') }] : []),
...(category ? [{ key: 'category', label: category, clear: () => setCategory('') }] : []),
...(debounced
? [{ key: 'keyword', label: `“${debounced}”`, clear: () => setKeyword('') }]
: []),
];
return (
<VStack gap={2}>
{scope}
{/* Pulled up onto the tab row's line and right-aligned, so search sits
level with the page selector rather than on a line of its own — the
tab row is mostly empty to the right and was reserving a whole row
below it for four controls.
The pull-up is a CSS offset rather than the controls being rendered
inside the tab row, because the tab row belongs to the page
(`InventoryPage`) and these controls belong to the browser's own
state. Rendering them there would mean lifting `keyword` and the rail
toggle into two different pages to keep one row tidy. It reverts to
its own line below 900px, where the tabs need the width.
It stays above both columns rather than inside the rail: the search
narrows the whole catalogue, and the rail only lists brands. */}
<HStack
gap={1}
align="center"
wrap="wrap"
justify="end"
{...(alignWithTabs ? { className: 'catalogue-controls' } : {})}
>
{activeFilters.map((filter) => (
<Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} />
))}
{activeFilters.length > 1 ? (
<Button label="Clear all" variant="ghost" size="sm" onClick={clearFilters} />
) : null}
<IconButton
label={isFiltersOpen ? 'Hide filters' : 'Show filters'}
icon={<Funnel size={15} />}
variant={isFiltersOpen ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setIsFiltersOpen((open) => !open)}
/>
<TextInput
label="Search the catalogue"
isLabelHidden
size="sm"
width={220}
value={keyword}
onChange={setKeyword}
placeholder="Search products…"
startIcon={<Search size={14} />}
hasClear
/>
</HStack>
<div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}>
{isFiltersOpen ? (
<CatalogueSidebar
brands={brands.data ?? []}
isLoading={brands.isLoading}
brand={brand}
category={category}
categories={(categories.data ?? []).filter(Boolean)}
isLoadingCategories={categories.isLoading}
onBrand={setBrand}
onCategory={setCategory}
/>
) : null}
<VStack gap={2}>
{/* No result-count line. It said "24 products on this page", which is
the page size restated — a number that never changes and answers a
question nobody asked. Where the page belongs in the whole is what
the pagination at the foot is for, and it says it there. */}
{blockedReason ? (
<Text type="body" size="xsm" style={{ color: 'var(--color-warning, #b7860b)' }}>
{blockedReason}
</Text>
) : null}
{search.isLoading ? (
<div className="product-grid">
{Array.from({ length: 8 }, (_, index) => (
<Skeleton key={index} height={320} radius={3} index={index} />
))}
</div>
) : search.isError ? (
<EmptyState
icon={<SearchX size={28} />}
title="The catalogue did not answer"
description="The request failed rather than came back empty. Try again, and if it keeps failing the catalogue service is the thing to look at."
actions={
<Button label="Try again" variant="secondary" onClick={() => void search.refetch()} />
}
/>
) : rows.length === 0 ? (
<EmptyState
icon={<PackageSearch size={28} />}
title="Nothing matches those filters"
description={
activeFilters.length > 0
? 'Clear a filter to widen the search — a brand, a category and a keyword together often narrow the catalogue to nothing.'
: 'The catalogue returned no products at all, which usually means the brand tables have not been populated.'
}
{...(activeFilters.length > 0
? {
actions: (
<Button label="Clear filters" variant="secondary" onClick={clearFilters} />
),
}
: {})}
/>
) : (
<>
<div className="product-grid">
{rows.map((product) => {
const key = catalogueKey(product);
return (
<CatalogueCard
key={key}
product={product}
isImported={importedKeys.has(key)}
isBusy={busy === key}
isDisabled={!canImport}
actionLabel={actionLabel}
onOpen={() => setOpen(product)}
{...(isReadOnly ? {} : { onImport: () => run(product) })}
/>
);
})}
</div>
{/* With a known total this renders numbered pages; without one it
falls back to prev/next off `hasMore`, which is all a response
that carries no count can honestly support. */}
<HStack justify="center">
<Pagination
page={page}
onChange={setPage}
pageSize={PAGE_SIZE}
{...(knownTotal
? { totalItems: knownTotal }
: { hasMore: rows.length === PAGE_SIZE })}
/>
</HStack>
</>
)}
</VStack>
</div>
{open ? (
<CatalogueDetailDrawer
product={open}
isImported={importedKeys.has(catalogueKey(open))}
isBusy={busy === catalogueKey(open)}
actionLabel={actionLabel}
{...(showCategoryPicker
? { categoryOptions, categoryid: importInto, onCategoryChange: setImportInto }
: {})}
{...(canImport && !isReadOnly
? {
onImport: () => {
const product = open;
if (onImport) setOpen(null);
run(product);
},
}
: {})}
{...(blockedReason ? { blockedReason } : {})}
onClose={() => setOpen(null)}
/>
) : null}
</VStack>
);
}

View File

@@ -0,0 +1,226 @@
import { useState, type MouseEvent } from 'react';
import { Check, ChevronLeft, ChevronRight, Eye, ImageOff, Plus } from 'lucide-react';
import type { CatalogueProduct } from '@/api/types';
/**
* One catalogue product, as a card.
*
* The proportions are taken from the reference storefront: a tall white media
* panel, a badge pinned top-left, an action rail that fades in on the right,
* then name, meta and one full-width button. Three things in that reference are
* deliberately NOT reproduced, because we have no data behind them and a card
* that states a figure it invented is worse than a card that says less:
*
* - **The discount flash** ("10% / 20%"). The catalogue carries a price RANGE
* across outlets, not a was-and-now. There is no discount to show. The brand
* takes that corner instead, which is the fact a person actually sorts by.
* - **The star rating.** No review data exists anywhere in Fiesta.
* - **The struck-through original price.** Same reason as the flash.
*
* And the quantity stepper is gone with them: importing does not move stock.
* It puts an unpriced product in the admin catalogue, so there is no quantity
* to choose at this moment — that happens at the stock request.
*/
export function CatalogueCard({
product,
isImported,
isBusy,
isDisabled,
actionLabel,
onOpen,
onImport,
}: {
product: CatalogueProduct;
isImported: boolean;
isBusy?: boolean;
isDisabled?: boolean;
actionLabel: string;
onOpen: () => void;
onImport?: () => void;
}) {
const images = product.images ?? [];
const [index, setIndex] = useState(0);
/**
* Which photos failed to load, by position.
*
* A single flag for the whole card was wrong: one dead URL blanked the card
* even when the product had four working photos behind it, and the flag
* only cleared if somebody happened to press an arrow. Catalogue images are
* scraped, and a retired or renamed object in the bucket is ordinary — a
* card should fall through to the next photo, not give up.
*/
const [failed, setFailed] = useState<ReadonlySet<number>>(new Set());
const photos = images.length;
/** The first photo from `index` onwards that has not failed, wrapping. */
const shownAt = (() => {
for (let hop = 0; hop < photos; hop += 1) {
const at = (index + hop) % photos;
if (!failed.has(at)) return at;
}
return -1;
})();
const image = shownAt >= 0 ? images[shownAt] : undefined;
const hasMany = photos > 1;
const markFailed = (at: number) =>
setFailed((current) => {
const next = new Set(current);
next.add(at);
return next;
});
/** Wrap in both directions, and never let the card open behind the arrow. */
const step = (event: MouseEvent<HTMLElement>, by: number) => {
event.stopPropagation();
setIndex((current) => (current + by + photos) % photos);
};
return (
<article className="pcard" data-imported={isImported || undefined}>
{/*
A div with an overlay button inside it, not a button around everything.
The arrows and dots are real buttons and a button cannot legally contain
another — nesting them produces a tree browsers repair by hoisting the
inner one out, which is how a photo arrow ends up opening the drawer.
So the click target is its own transparent layer underneath the
controls.
*/}
<div className="pcard-media">
{image ? (
<img
// Keyed by the URL so a fall-through to the next photo actually
// remounts the element — React would otherwise keep the failed
// image node and never re-attempt the load.
key={image}
src={image}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => markFailed(shownAt)}
/>
) : (
<span className="pcard-noimage" title={photos > 0 ? `${photos} photos, none loaded` : 'No photo'}>
<ImageOff size={30} />
</span>
)}
<button
type="button"
className="pcard-open"
onClick={onOpen}
aria-label={`View ${product.product_name}`}
/>
{/* No brand flag. The reference puts a discount there and we have no
discount to show; a brand chip in its place restated what the rail
on the left already says, what the SKU under the name repeats, and
what the packaging itself carries in larger type than we could. */}
{isImported ? <span className="pcard-owned">In your list</span> : null}
{/* The action rail. One icon, because one is all we have a use for —
the reference's wishlist and compare have nothing behind them. */}
<span className="pcard-rail">
<span className="pcard-railbtn" aria-hidden="true">
<Eye size={14} />
</span>
</span>
{/* The photo switcher. Only when there is more than one to switch to —
an arrow that does nothing is worse than no arrow, and most of a
real catalogue's rows carry several shots that the browse card
otherwise never shows. */}
{hasMany ? (
<>
<button
type="button"
className="pcard-arrow is-left"
aria-label="Previous photo"
onClick={(event) => step(event, -1)}
>
<ChevronLeft size={15} />
</button>
<button
type="button"
className="pcard-arrow is-right"
aria-label="Next photo"
onClick={(event) => step(event, 1)}
>
<ChevronRight size={15} />
</button>
<span className="pcard-dots">
{images.slice(0, 8).map((src, position) => (
<button
type="button"
// Position, not the URL: a catalogue row can list the same
// photo twice, and React drops a duplicate key — which reads
// as a dot row shorter than the count it stands for.
key={`${position}-${src}`}
className="pcard-dot"
data-active={position === shownAt || undefined}
aria-label={`Photo ${position + 1}`}
onClick={(event) => {
event.stopPropagation();
setIndex(position);
}}
/>
))}
</span>
</>
) : null}
</div>
<div className="pcard-body">
{/* The reference puts a star rating here. There is no review data
anywhere in Fiesta, so the line carries what this catalogue does
know about the pack — its code and its size. */}
<p className="pcard-meta">
<span className="pcard-sku">{product.product_sku ?? `id ${product.id}`}</span>
{product.size ? <span className="pcard-size">{product.size}</span> : null}
{photos > 1 ? <span className="pcard-photos">{photos} photos</span> : null}
</p>
<button type="button" className="pcard-name" onClick={onOpen} title={product.product_name}>
{product.product_name}
</button>
{/* Price and action on ONE row, as in the reference. A full-width button
under every card made the action the loudest thing in a grid of
twenty-four; beside the price it is available without insisting. */}
<div className="pcard-foot">
{/* A RANGE — what other outlets list it at, not a price this store is
bound to. The store sets its own after the import. */}
<span className="pcard-price">{product.price_range || '—'}</span>
{onImport ? (
isImported ? (
<span className="pcard-action is-added">
<Check size={12} /> Added
</span>
) : (
/* "Add", not the full instruction.
The reference says "Add to cart" — three short words beside a
price. "Add to my products" is eighteen characters and wrapped
the price onto two lines at this card width. The full wording
stays as the accessible name, and the drawer says it in full at
the point where somebody is deciding rather than scanning. */
<button
type="button"
className="pcard-action"
title={actionLabel}
aria-label={actionLabel}
disabled={isDisabled || isBusy}
onClick={onImport}
>
<Plus size={12} /> {isBusy ? 'Adding…' : 'Add'}
</button>
)
) : null}
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,337 @@
import { useEffect, useState } from 'react';
import { Banner } from '@astryxdesign/core/Banner';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { Divider } from '@astryxdesign/core/Divider';
import { HStack } from '@astryxdesign/core/HStack';
import { Lightbox } from '@astryxdesign/core/Lightbox';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { Token } from '@astryxdesign/core/Token';
import { VStack } from '@astryxdesign/core/VStack';
import { Check, DownloadCloud, ImageOff, Info } from 'lucide-react';
import type { CatalogueProduct } from '@/api/types';
import { Drawer } from '@/features/store-admin/Drawer';
/**
* One global-catalogue product, in full.
*
* Ported from the old console's `ImportProductModal`, which was the only screen
* in the platform that showed what the catalogue actually holds. The API
* returns nineteen fields per row and a browse card renders five; the rest —
* description, highlights, nutrition, the FSSAI licence, the provider list,
* every photo beyond the first — arrived in every response and had nowhere to
* be read.
*
* ONE THING FROM THE OLD SCREEN IS DELIBERATELY NOT HERE: its "Retail Packaging
* Info" panel. That block is `FMCGHoverOverlay`, which derives its contents from
* `simpleHash(productId)` and a keyword match on the category — the shelf life,
* the storage advice and the packaging notes it prints are generated from the
* product's id, not read from anywhere. It reads as compliance information and
* is invented, so it stays out.
*/
export interface CatalogueDetailDrawerProps {
product: CatalogueProduct;
/** Already in this tenant's catalogue — the import action becomes a note. */
isImported: boolean;
isBusy?: boolean;
actionLabel?: string;
/** The tenant's own categories. Omit to hide the picker. */
categoryOptions?: { value: string; label: string }[];
categoryid?: string;
onCategoryChange?: (value: string) => void;
/** Absent when the caller has nowhere to import to yet. */
onImport?: () => void;
/** Shown in place of the action when importing is unavailable. */
blockedReason?: string;
onClose: () => void;
}
export function CatalogueDetailDrawer({
product,
isImported,
isBusy,
actionLabel = 'Add to my products',
categoryOptions,
categoryid = '',
onCategoryChange,
onImport,
blockedReason,
onClose,
}: CatalogueDetailDrawerProps) {
const images = product.images ?? [];
const [heroAt, setHeroAt] = useState(0);
const [isZoomed, setIsZoomed] = useState(false);
const [failed, setFailed] = useState(false);
// A different product in the same drawer starts at its own first photo.
useEffect(() => {
setHeroAt(0);
setFailed(false);
}, [product.brand, product.id]);
const hero = images[Math.min(heroAt, Math.max(images.length - 1, 0))];
const brand = product.brand.replace('brand_', '').toUpperCase();
const facts: { label: string; value: string; isMono?: boolean }[] = [];
if (product.category) facts.push({ label: 'Catalogue category', value: product.category });
if (product.product_sku) facts.push({ label: 'SKU', value: product.product_sku, isMono: true });
if (product.fssai_license) {
facts.push({ label: 'FSSAI licence', value: product.fssai_license, isMono: true });
}
if (product.providers?.length) {
facts.push({ label: 'Sold on', value: product.providers.join(', ') });
}
return (
<Drawer
title={product.product_name}
subtitle={`${brand} · catalogue id ${product.id}`}
titleAside={isImported ? <Token label="In your list" size="sm" color="green" /> : undefined}
width={540}
onClose={onClose}
>
{/* The photograph, at the size a label can be read at. Click to zoom —
at card size the ingredients and the net weight are not legible. */}
<Card padding={0} elevation="none">
<button
type="button"
aria-label="Open photo full size"
onClick={() => setIsZoomed(true)}
disabled={!hero || failed}
style={{
display: 'block',
width: '100%',
height: 280,
padding: 0,
border: '1px solid var(--color-line)',
borderRadius: 16,
background: 'var(--color-surface)',
cursor: hero && !failed ? 'zoom-in' : 'default',
overflow: 'hidden',
}}
>
{hero && !failed ? (
<img
src={hero}
alt={product.product_name}
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
mixBlendMode: 'multiply',
}}
/>
) : (
<ImageOff size={40} style={{ color: 'var(--color-line)' }} />
)}
</button>
</Card>
{images.length > 1 ? (
<VStack gap={0.5}>
<HStack gap={0.5} align="center">
<span style={{ color: 'var(--color-ink-4)', display: 'flex' }}>
<Info size={12} />
</span>
<Text type="body" size="xsm" color="secondary">
{images.length} photos — only the first is imported
</Text>
</HStack>
<div style={{ display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 4 }}>
{images.map((src, index) => (
<button
type="button"
// Index, not the URL: a catalogue row can list the same photo
// twice, and a duplicate key makes React drop one thumbnail and
// mis-track the rest as you page through them.
key={`${index}-${src}`}
aria-label={`Show photo ${index + 1}`}
onClick={() => (index === heroAt ? setIsZoomed(true) : setHeroAt(index))}
style={{
width: 58,
height: 58,
flex: 'none',
padding: 2,
borderRadius: 10,
background: 'var(--color-surface)',
border:
index === heroAt
? '2px solid var(--color-brand)'
: '1px solid var(--color-line)',
opacity: index === heroAt ? 1 : 0.6,
cursor: 'pointer',
}}
>
<img
src={src}
alt=""
referrerPolicy="no-referrer"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</button>
))}
</div>
</VStack>
) : null}
{/* A RANGE, not a price. What the shop charges is set after the import,
and conflating the two is how a catalogue figure ends up on a shelf. */}
<Card padding={0} elevation="low">
<HStack justify="between" align="center" gap={2} padding={2}>
<VStack gap={0}>
<Text type="label" size="xsm" color="secondary">
MARKET PRICE RANGE
</Text>
<Text type="large" weight="semibold" hasTabularNumbers>
{product.price_range ?? '—'}
</Text>
</VStack>
{product.size ? <Token label={product.size} size="md" /> : null}
</HStack>
</Card>
{product.description ? (
<VStack gap={0.5}>
<Text type="label" size="xsm" color="secondary">
DESCRIPTION
</Text>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{product.description}
</Text>
</VStack>
) : null}
{product.highlights?.length || product.nutrients?.length ? (
<div className="form-grid">
{product.highlights?.length ? (
<VStack gap={0.5}>
<Text type="label" size="xsm" color="secondary">
HIGHLIGHTS
</Text>
<VStack gap={0}>
{product.highlights.map((line, index) => (
<Text
key={`${index}-${line}`}
type="body"
size="xsm"
color="secondary"
style={{ lineHeight: 1.6 }}
>
• {line}
</Text>
))}
</VStack>
</VStack>
) : null}
{product.nutrients?.length ? (
<VStack gap={0.5}>
<Text type="label" size="xsm" color="secondary">
NUTRITION
</Text>
<VStack gap={0}>
{product.nutrients.map((line, index) => (
<Text
key={`${index}-${line}`}
type="body"
size="xsm"
color="secondary"
style={{ lineHeight: 1.6 }}
>
• {line}
</Text>
))}
</VStack>
</VStack>
) : null}
</div>
) : null}
{facts.length > 0 ? (
<>
<Divider />
<VStack gap={1}>
{facts.map((fact) => (
<HStack key={fact.label} justify="between" align="start" gap={2}>
<Text type="body" size="xsm" color="secondary" style={{ flex: 'none' }}>
{fact.label}
</Text>
<Text
type="body"
size="xsm"
style={{
textAlign: 'right',
...(fact.isMono ? { fontFamily: 'var(--font-mono)' } : {}),
}}
>
{fact.value}
</Text>
</HStack>
))}
</VStack>
</>
) : null}
{/* The action, last, because everything above is what the decision is
made on. */}
{isImported ? (
<Banner
status="success"
title="Already in your products"
description="Set its price under Not ready in Products — that is the step that releases it to your shops."
icon={<Check size={16} />}
/>
) : blockedReason ? (
<Banner status="warning" title="Cannot import yet" description={blockedReason} />
) : onImport ? (
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
{categoryOptions ? (
<Selector
label="Import into"
size="sm"
options={categoryOptions}
value={categoryid}
onChange={(value) => onCategoryChange?.(value)}
placeholder="Leave uncategorised"
description="Your own category, not the catalogue's. Uncategorised beats wrongly categorised."
/>
) : null}
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
Adds this product with no price. It reaches no shop and cannot be sold until you
price and publish it.
</Text>
<Button
label={isBusy ? 'Adding…' : actionLabel}
variant="primary"
icon={<DownloadCloud size={14} />}
width="full"
isDisabled={Boolean(isBusy)}
onClick={onImport}
/>
</VStack>
</Card>
) : null}
{/* Gallery mode, driven by the same index the thumbnails set — so
opening the third photo and paging on from it leaves the drawer
showing the third photo when it closes. */}
{images.length > 0 ? (
<Lightbox
isOpen={isZoomed}
onOpenChange={setIsZoomed}
media={images.map((src, index) => ({
src,
alt: `${product.product_name} — photo ${index + 1}`,
}))}
index={Math.min(heroAt, images.length - 1)}
onIndexChange={setHeroAt}
hasZoom
/>
) : null}
</Drawer>
);
}

View File

@@ -0,0 +1,137 @@
import { useState } from 'react';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { ChevronDown } from 'lucide-react';
import type { CatalogueBrand } from '@/api/types';
const COLLAPSED_COUNT = 8;
/**
* The brand rail.
*
* The reference this is built from lists shop departments — Groceries with
* Dairy, Bakery, Fruits nested underneath. Our catalogue has no departments: it
* is partitioned by BRAND, one Postgres table each, discovered from
* `information_schema` (`getbrands`), and categories exist only inside a brand
* because the endpoint that lists them refuses to answer without one
* (`catalogueController.go:38-45`).
*
* So the two levels are brand, then that brand's own categories — the same
* shape as the reference, filled with the structure the data actually has. A
* department tree over this data would be invented, and inventing a taxonomy is
* how products end up filed where nobody looks for them.
*/
export function CatalogueSidebar({
brands,
isLoading,
brand,
category,
categories,
isLoadingCategories,
onBrand,
onCategory,
}: {
brands: CatalogueBrand[];
isLoading: boolean;
brand: string;
category: string;
categories: string[];
isLoadingCategories: boolean;
onBrand: (brand: string) => void;
onCategory: (category: string) => void;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const visible = isExpanded ? brands : brands.slice(0, COLLAPSED_COUNT);
const hidden = brands.length - visible.length;
return (
<aside className="catalogue-rail">
{/* The scroll lives on an inner element so the rounded corners can clip
it. With `overflow-y: auto` on the rounded box itself, the scrollbar
is painted inside the border box and squares off the two corners it
touches — one edge curved, the other cut. */}
<div className="rail-scroll">
<VStack gap={0}>
<button type="button" className="rail-all" data-active={!brand} onClick={() => onBrand('')}>
All brands
<span className="rail-count">
{isLoading ? '' : brands.reduce((sum, entry) => sum + (entry.product_count ?? 0), 0)}
</span>
</button>
{isLoading ? (
<VStack gap={1} style={{ padding: '12px 4px' }}>
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="rail-skeleton" />
))}
</VStack>
) : null}
{visible.map((entry) => {
const isOpen = entry.brand === brand;
return (
<div key={entry.brand} className="rail-group" data-open={isOpen}>
<button
type="button"
className="rail-brand"
data-active={isOpen}
aria-expanded={isOpen}
onClick={() => onBrand(isOpen ? '' : entry.brand)}
>
<span className="rail-brand-name">{entry.brand.replace('brand_', '')}</span>
<span className="rail-count">{entry.product_count}</span>
</button>
{/* The categories sit under the brand they belong to, which is
also the only place they can be asked for. */}
{isOpen ? (
<VStack gap={0} style={{ paddingBottom: 6 }}>
{isLoadingCategories ? (
<Text type="body" size="xsm" color="secondary" style={{ padding: '4px 12px' }}>
Loading categories…
</Text>
) : categories.length === 0 ? (
<Text type="body" size="xsm" color="secondary" style={{ padding: '4px 12px' }}>
No categories for this brand
</Text>
) : (
<>
<button
type="button"
className="rail-category"
data-active={!category}
onClick={() => onCategory('')}
>
All categories
</button>
{categories.map((name) => (
<button
key={name}
type="button"
className="rail-category"
data-active={category === name}
onClick={() => onCategory(category === name ? '' : name)}
>
{name}
</button>
))}
</>
)}
</VStack>
) : null}
</div>
);
})}
{hidden > 0 || isExpanded ? (
<button type="button" className="rail-more" onClick={() => setIsExpanded((on) => !on)}>
{isExpanded ? 'See less' : `See ${hidden} more`}
<ChevronDown size={13} style={{ transform: isExpanded ? 'rotate(180deg)' : undefined }} />
</button>
) : null}
</VStack>
</div>
</aside>
);
}

View File

@@ -1,256 +0,0 @@
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('');
/**
* Seeded from the bottom of the catalogue's price range.
*
* A guess, and a better starting point than an empty box — the operator
* changes it or accepts it, but does not have to go and look the product up.
* The range is a display string ("₹33-37"), so the number is parsed out of
* it rather than read from a field.
*/
const [retailprice, setRetailprice] = useState<number | null>(() => priceFloor(product.price_range));
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>
<CatalogueFacts product={product} />
<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>
);
}
/**
* The lower bound of a catalogue price range.
*
* "₹33-37" → 33. It is a display string with no fixed shape, so the first
* number in it is taken and anything unparseable gives null rather than a
* fabricated price.
*/
function priceFloor(range: string | undefined): number | null {
if (!range) return null;
const match = range.replace(/,/g, '').match(/\d+(\.\d+)?/);
if (!match) return null;
const value = Number(match[0]);
return Number.isFinite(value) && value > 0 ? value : null;
}
/**
* What the catalogue knows and the import does not carry.
*
* The import copies seven of nineteen fields. These are among the twelve it
* leaves behind, and they are the ones that decide whether this is the right
* product: the licence, who sells it, and what is in it. Shown here because
* after the import they are only reachable by going back to the catalogue.
*/
function CatalogueFacts({ product }: { product: CatalogueProduct }) {
const facts: [string, string][] = [];
if (product.category) facts.push(['Catalogue category', product.category]);
if (product.product_sku) facts.push(['SKU', product.product_sku]);
if (product.fssai_license) facts.push(['FSSAI licence', product.fssai_license]);
if (product.providers?.length) facts.push(['Sold on', product.providers.join(', ')]);
if ((product.images?.length ?? 0) > 1) {
facts.push(['Photos', `${product.images?.length} — only the first is imported`]);
}
if (facts.length === 0 && !product.highlights?.length) return null;
return (
<VStack gap={1}>
{facts.length > 0 ? (
<VStack gap={0.5}>
{facts.map(([label, value]) => (
<HStack key={label} justify="between" align="start" gap={2}>
<Text type="body" size="xsm" color="secondary" style={{ flex: 'none' }}>
{label}
</Text>
<Text type="body" size="xsm" style={{ textAlign: 'right' }}>
{value}
</Text>
</HStack>
))}
</VStack>
) : null}
{product.highlights?.length ? (
<VStack gap={0}>
{product.highlights.slice(0, 4).map((line) => (
<Text key={line} type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
• {line}
</Text>
))}
</VStack>
) : null}
</VStack>
);
}

View File

@@ -1,294 +1,171 @@
import { useMemo, useState } from 'react';
import { useEffect, 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 { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
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 { PackageSearch, Upload } from 'lucide-react';
import type { TenantInfo } from '@/api/types';
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 { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
import { useTenantLocations, useTenants } from '@/queries/hooks';
import { SheetImportPanel } from '../import/SheetImportPanel';
import type { TenantInfo } from '@/api/types';
/**
* The global catalogue browser, and the place both import paths start.
* The platform operator's catalogue.
*
* 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.
* TWO MODES, AND THEY ASK FOR DIFFERENT THINGS.
*
* - **Browse** is reading. The operator looks at what the FMCG catalogue holds
* — the photographs, the pack sizes, the FSSAI licences, what is stocked
* where — and nothing is written. There is no merchant to choose because
* nothing lands anywhere, so the page is the rail, the search and the grid
* and nothing else. The per-product Add is gone from here: stocking one
* shop at a time is the merchant's own job, in Store Admin ▸ Inventory ▸
* Catalogue.
*
* - **From spreadsheet** is writing, and it cannot work without a target —
* the import endpoint rejects a row with no `tenantid` or `locationid`. So
* the merchant and outlet pickers live in that mode, where the question is
* actually being asked, rather than sitting above a grid that never uses
* them.
*
* The grid, the filters, the cards and the detail drawer are the shared
* `CatalogueBrowser` — the same ones the Store Admin sees, because it is the
* same catalogue.
*/
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]);
/**
* The outlets of the chosen merchant, read separately and deliberately.
*
* `getalltenants` is `SELECT * FROM tenants` with no join to
* `tenantlocations` (`tenantRepository.go:79-133`), so its `locationid` is the
* struct's zero value on every row. Seeding the destination from it — which
* this page used to do — meant the id was always 0 and every upload stayed
* blocked behind "pick a merchant" for a merchant already picked.
*/
const locations = useTenantLocations(tenantid);
const tenantOptions = useMemo(() => {
if (!tenants) return [];
const seen = new Map<number, { name: string; locationid: number }>();
const seen = new Map<number, string>();
for (const tenant of tenants as TenantInfo[]) {
if (!seen.has(tenant.tenantid)) {
seen.set(tenant.tenantid, { name: tenant.tenantname, locationid: tenant.locationid });
}
if (!seen.has(tenant.tenantid)) seen.set(tenant.tenantid, tenant.tenantname);
}
return [...seen.entries()].map(([id, value]) => ({ value: String(id), label: value.name }));
return [...seen.entries()].map(([id, name]) => ({ value: String(id), label: name }));
}, [tenants]);
const brandOptions = useMemo(
() => [
{ value: '', label: 'All brands' },
...(brands ?? []).map((entry) => ({
value: entry.brand,
label: `${entry.brand} (${entry.product_count})`,
const locationOptions = useMemo(
() =>
(locations.data ?? []).map((outlet) => ({
value: String(outlet.locationid),
label: outlet.locationname || `Outlet ${outlet.locationid}`,
})),
],
[brands],
[locations.data],
);
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));
// Cleared rather than carried: the outlets for this merchant have not
// loaded yet, and keeping the previous one's would aim an upload at another
// merchant's shop.
next.delete('locationid');
setParams(next, { replace: true });
}
const hasTarget = Boolean(tenantid && locationid);
function chooseLocation(value: string) {
const next = new URLSearchParams(params);
next.set('locationid', value);
setParams(next, { replace: true });
}
/** Most merchants have one outlet; picking it from a list of one teaches nothing. */
useEffect(() => {
if (!tenantid || locationid) return;
const first = locations.data?.[0];
if (first) chooseLocation(String(first.locationid));
// `params` is read through the setter, so it is not a dependency here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tenantid, locationid, locations.data]);
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."
description="Read the FMCG catalogue, or upload a merchant's own product list."
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>
<SegmentedControl
label="What to do"
value={mode}
onChange={(value) => setMode(value as 'catalogue' | 'sheet')}
size="sm"
>
<SegmentedControlItem value="catalogue" label="Browse catalogue" icon={<PackageSearch size={14} />} />
<SegmentedControlItem value="sheet" label="Upload sheet" icon={<Upload size={14} />} />
</SegmentedControl>
}
/>
{/* 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" />
<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"
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 gap={2}>
{/* Only here. An upload is written against one merchant and one
outlet, and the endpoint refuses a row without both. */}
<Card padding={0} elevation="low">
<VStack gap={2} padding={2}>
<SectionHeader
title="Uploading for"
note="Every row is written against one merchant and one outlet."
/>
<div className="form-grid">
<Selector
label="Merchant"
options={tenantOptions}
value={tenantid ? String(tenantid) : ''}
onChange={chooseTenant}
placeholder="Select merchant…"
hasSearch
/>
<Selector
label="Outlet"
options={locationOptions}
value={locationid ? String(locationid) : ''}
onChange={chooseLocation}
placeholder={
!tenantid
? 'Choose a merchant first…'
: locations.isLoading
? 'Loading outlets…'
: locationOptions.length === 0
? 'This merchant has no outlets'
: 'Select outlet…'
}
isDisabled={!tenantid || locationOptions.length === 0}
hasSearch={locationOptions.length > 6}
/>
</div>
</VStack>
</Card>
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
</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])}
) : (
<CatalogueBrowser
tenantid={undefined}
locationid={undefined}
categoryOptions={[]}
actionLabel="Add to store"
isReadOnly
/>
) : null}
)}
</VStack>
);
}

View File

@@ -37,11 +37,20 @@ const EMPTY: FormState = {
primarycontact: '',
primaryemail: '',
locationname: '',
categoryid: '1',
// No default category. `utils/getappcategories` supplies the real list and
// this used to pre-select the first id regardless of what the merchant sells.
categoryid: '',
address: '',
suburb: '',
city: 'Coimbatore',
state: 'Tamil Nadu',
// Empty, not pre-filled.
//
// These carried 'Coimbatore' and 'Tamil Nadu' as VALUES, not placeholders —
// so a merchant anywhere else was submitted with the wrong city and state
// unless somebody noticed and cleared them. A default that is right most of
// the time is worse than a blank field, because it is only checked when it
// looks wrong.
city: '',
state: '',
postcode: '',
};
@@ -90,6 +99,8 @@ export function OnboardTenantPage() {
form.locationname.trim() !== '' &&
form.address.trim() !== '' &&
form.city.trim() !== '' &&
form.state.trim() !== '' &&
form.categoryid !== '' &&
form.postcode.trim() !== '';
function handleSubmit(event: FormEvent) {
@@ -101,7 +112,9 @@ export function OnboardTenantPage() {
primarycontact: form.primarycontact.trim(),
primaryemail: form.primaryemail.trim(),
locationname: form.locationname.trim(),
categoryid: Number(form.categoryid),
// `Number('')` is NaN, which serialises to null and is not what the
// backend means by "uncategorised" — 0 is.
categoryid: Number(form.categoryid) || 0,
address: form.address.trim(),
suburb: form.suburb.trim(),
city: form.city.trim(),

View File

@@ -7,7 +7,7 @@ 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 { IndianRupee, ShoppingCart, Store, TriangleAlert } from 'lucide-react';
import { DataState } from '@/components/DataState';
import { Freshness } from '@/components/Freshness';
import { KpiCard } from '@/components/KpiCard';
@@ -226,13 +226,13 @@ export function StoreDetailPage() {
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} />}
/>
{/* No "Catalogue — Linked" tile.
It read `tenant ? 'Linked' : '—'`, so it said Linked whenever a
tenant row came back at all — it described the tenant existing, not
the catalogue. `products/getimportedcatalogueproducts` could give a
real imported count here; until something calls it, a tile that is
always green is worse than no tile. */}
</div>
<VStack gap={1.5}>

View File

@@ -180,11 +180,15 @@ export function StoresPage() {
return (
<VStack gap={3}>
{/* No `isLive` here. Both of this page's reads — `useTenants` and
`useTenantsByApproval` — use the `stable` query options: a 5-minute
staleTime and no refetchInterval. The pill claimed a freshness the page
does not have. The pages that keep it (Console, Sales, Counters, store
detail) poll on a real interval. */}
<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"

View File

@@ -1,280 +0,0 @@
import { useEffect, useMemo, useState } 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 { Check, DownloadCloud, Info } from 'lucide-react';
import { catalogueKey } from '@/api/catalogue';
import { productsApi } from '@/api/products';
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import {
useCatalogueBrands,
useCatalogueProducts,
useImportedRefs,
useTenantCategories,
} from '@/queries/hooks';
import { Drawer } from './Drawer';
import { MediaPill, ProductCard } from './ProductCard';
/**
* Import from the global catalogue, without leaving Inventory.
*
* A drawer rather than a page because importing is one move inside the pricing
* job, not a destination: you come here because a product is missing from the
* list behind, and you want to go straight back to it.
*
* Two things follow the old console because it had them right, and one
* deliberately does not:
*
* - **One click imports.** No confirmation. Import is reversible in the sense
* that matters — the product lands unpriced and unreleased, so nothing
* reaches a till until someone prices it.
* - **The "Imported" badge is keyed on `brand:catalogueid`.** The old console
* compared a catalogue id against tenant PRODUCT ids, which are different
* number spaces, so its badge was effectively random. `getimportedcatalogueproducts`
* returns exactly the right pairs and it fetched them two lines above without
* ever reading them.
*/
export function CatalogueImportDrawer({
tenantid,
locationid,
onClose,
}: {
tenantid: number;
locationid: number;
onClose: () => void;
}) {
const client = useQueryClient();
const [brand, setBrand] = useState('');
const [keyword, setKeyword] = useState('');
const [debounced, setDebounced] = useState('');
const [categoryid, setCategoryid] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [justImported, setJustImported] = useState<Set<string>>(new Set());
useEffect(() => {
const id = setTimeout(() => setDebounced(keyword.trim()), 400);
return () => clearTimeout(id);
}, [keyword]);
const brands = useCatalogueBrands();
const categories = useTenantCategories(tenantid);
const imported = useImportedRefs(tenantid);
const products = useCatalogueProducts({
...(brand ? { brand } : {}),
...(debounced ? { keyword: debounced } : {}),
pagesize: 48,
});
const importedKeys = useMemo(() => {
const set = new Set((imported.data ?? []).map(catalogueKey));
for (const key of justImported) set.add(key);
return set;
}, [imported.data, justImported]);
const importOne = useMutation({
mutationFn: (row: ImportCatalogueProductRequest) => productsApi.importFromCatalogue([row]),
onSuccess: async () => {
await Promise.all([
client.invalidateQueries({ queryKey: queryKeys.catalogue.all }),
client.invalidateQueries({ queryKey: queryKeys.products.all }),
]);
},
});
async function run(product: CatalogueProduct) {
const key = catalogueKey(product);
setBusy(key);
try {
await importOne.mutateAsync({
tenantid,
locationid,
brand: product.brand,
catalogueid: product.id,
// Uncategorised beats wrongly categorised. The backend makes this field
// optional for exactly that reason: "an unclassified product is visibly
// unfinished, while a wrongly classified one looks done and is only
// found by someone browsing the wrong aisle."
categoryid: Number(categoryid) || 0,
subcategoryid: 0,
quantity: 0,
stocktype: 'in',
status: 'Draft',
// Zero on purpose. Import is not pricing — the product lands in the
// "Not ready" list behind this drawer, which is where a price is set.
retailprice: 0,
productcost: 0,
taxpercent: 0,
});
setJustImported((set) => new Set(set).add(key));
} finally {
setBusy(null);
}
}
const brandOptions = useMemo(
() => [
{ value: '', label: 'All brands' },
...(brands.data ?? []).map((entry) => ({
value: entry.brand,
label: `${entry.brand.replace('brand_', '').toUpperCase()} (${entry.product_count})`,
})),
],
[brands.data],
);
const categoryOptions = useMemo(
() => [
{ value: '', label: 'Leave uncategorised' },
...(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
})),
],
[categories.data],
);
const rows = products.data ?? [];
return (
<Drawer
title="Add products"
subtitle="Pick what you sell from the global catalogue. Prices are set afterwards."
width={920}
isBare
onClose={onClose}
>
<VStack gap={1.5} padding={2} style={{ flex: 'none' }}>
<HStack gap={1} wrap="wrap">
<div style={{ width: 200 }}>
<Selector
label="Brand"
isLabelHidden
size="sm"
value={brand}
onChange={setBrand}
placeholder="All brands"
options={brandOptions}
/>
</div>
<div style={{ width: 220 }}>
<TextInput
label="Search the catalogue"
isLabelHidden
size="sm"
value={keyword}
onChange={setKeyword}
placeholder="Search the catalogue…"
hasClear
/>
</div>
<div style={{ width: 210 }}>
<Selector
label="Category to import into"
isLabelHidden
size="sm"
value={categoryid}
onChange={setCategoryid}
placeholder="Leave uncategorised"
options={categoryOptions}
/>
</div>
</HStack>
<HStack gap={1} align="start">
<span style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 1 }}>
<Info size={13} />
</span>
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
Imported products arrive with no price and are not released to your shops. They
appear under <strong>Not ready</strong> behind this panel, where you set the price.
</Text>
</HStack>
</VStack>
<div className="import-scroll" style={{ padding: 16, paddingTop: 0 }}>
{products.isLoading ? (
<Text type="body" size="sm" color="secondary">
Loading the catalogue…
</Text>
) : rows.length === 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
No products match your selection
</Text>
<Text type="body" size="sm" color="secondary">
Try a different brand, or clear the search.
</Text>
</VStack>
) : (
<div className="product-grid">
{rows.map((product) => {
const key = catalogueKey(product);
const isImported = importedKeys.has(key);
const images = product.images ?? [];
return (
<ProductCard
key={key}
images={images}
title={product.product_name}
identifier={product.product_sku ?? `ID: ${product.id}`}
left={product.size || '—'}
right={product.price_range || '—'}
mediaBadge={
<MediaPill label={product.brand.replace('brand_', '')} color="var(--color-brand)" />
}
{...(isImported
? {
mediaStatus: (
<MediaPill label="In your list" color="#10b981" isSolid />
),
}
: {})}
action={
isImported ? (
<Button
label="Added"
variant="secondary"
size="sm"
icon={<Check size={12} />}
isDisabled
width="full"
/>
) : (
<Button
label={busy === key ? 'Adding…' : 'Add to my products'}
variant="primary"
size="sm"
icon={<DownloadCloud size={12} />}
isDisabled={busy !== null}
width="full"
onClick={() => void run(product)}
/>
)
}
/>
);
})}
</div>
)}
{/* The photo count the import silently drops. 90 of Nestlé's 123
products carry more than one image and only the first survives. */}
{rows.some((product) => (product.images?.length ?? 0) > 1) ? (
<Text
type="body"
size="xsm"
style={{ color: 'var(--color-ink-4)', display: 'block', paddingTop: 12 }}
>
Products with several photos import only the first one.
</Text>
) : null}
</div>
</Drawer>
);
}

View File

@@ -0,0 +1,53 @@
import { useMemo } from 'react';
import { useTenantCategories } from '@/queries/hooks';
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
import { useBranchScope } from './BranchScope';
/**
* The Store Admin's half of the catalogue: browse it, add to your own products.
*
* A thin wrapper over the shared browser, and deliberately thin. What is
* particular to this workspace is only the scope — the merchant is whoever is
* signed in — and one click imports, because a merchant adding a product to
* their own list has no pricing decision to make yet: the product lands under
* "Not ready" and is priced there.
*/
export function CataloguePanel() {
const { tenantid, current, branches } = useBranchScope();
/**
* Import writes against one outlet, which the backend requires.
*
* With "All branches" selected there is no single destination, so the first
* is used — and that is not a fudge: `PublishProduct` releases tenant-wide,
* so the outlet on the import row decides where the product row is filed,
* not who can sell it.
*/
const locationid = current?.locationid ?? branches[0]?.locationid;
const categories = useTenantCategories(tenantid);
const categoryOptions = useMemo(
() => [
{ value: '', label: 'Leave uncategorised' },
...(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
})),
],
[categories.data],
);
return (
<CatalogueBrowser
tenantid={tenantid || undefined}
locationid={locationid}
categoryOptions={categoryOptions}
actionLabel="Add to my products"
alignWithTabs
{...(!tenantid || !locationid
? { blockedReason: 'No outlet is selected, so there is nowhere to file an import.' }
: {})}
/>
);
}

View File

@@ -1,369 +0,0 @@
import { useState } from 'react';
import { ChevronLeft, ChevronRight, ImageOff } from 'lucide-react';
/**
* The product card, matched to the old console's.
*
* The proportions and treatment are lifted deliberately rather than reinvented,
* because staff already read them: a **160px white media band** with the image
* inset 12px and `object-contain`, a two-line bold name, the SKU in mono
* underneath, a hairline, then one line of meta with a figure right-aligned,
* then a single full-width action.
*
* `mix-blend-multiply` is the detail that makes it look right and is easy to
* miss. Catalogue photography is shot on white, so without it every card
* carries a visible grey rectangle where the photo's background meets the card.
*/
export interface ProductCardProps {
images: string[];
title: string;
/** The SKU or catalogue id — mono, under the title. */
identifier?: string;
/** Left-hand meta, e.g. "Unit: 500g". */
left?: React.ReactNode;
/** Right-hand figure, e.g. a price. Mono and bold. */
right?: React.ReactNode;
/**
* One short line under the SKU — why this product cannot be sold.
*
* A pill can say "Not ready"; only a sentence can say which of the two
* things is missing, and that is the whole difference between a card the
* merchant can act on and one that just worries them.
*/
note?: string;
noteColor?: string;
/** A badge pinned to the top-left of the image. */
mediaBadge?: React.ReactNode;
/** A badge pinned to the top-right of the image. */
mediaStatus?: React.ReactNode;
action?: React.ReactNode;
onClick?: () => void;
}
export function ProductCard({
images,
title,
identifier,
note,
noteColor,
left,
right,
mediaBadge,
mediaStatus,
action,
onClick,
}: ProductCardProps) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
display: 'flex',
flexDirection: 'column',
minWidth: 0,
borderRadius: 16,
border: `1px solid ${isHovered ? 'color-mix(in oklab, var(--color-brand) 40%, transparent)' : 'var(--color-line)'}`,
background: 'var(--color-surface)',
overflow: 'hidden',
cursor: onClick ? 'pointer' : 'default',
boxShadow: isHovered
? '0 8px 20px -6px rgb(16 24 40 / .12)'
: '0 1px 2px rgb(16 24 40 / .05)',
transition: 'box-shadow .2s, border-color .2s',
}}
>
<Media images={images} alt={title} isHovered={isHovered} badge={mediaBadge} status={mediaStatus} />
<div
style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
gap: 8,
padding: 12,
minWidth: 0,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<h4
title={title}
style={{
margin: 0,
fontSize: 14,
fontWeight: 700,
lineHeight: 1.35,
color: isHovered ? 'var(--color-brand)' : 'var(--color-ink-1)',
transition: 'color .2s',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{title}
</h4>
{identifier ? (
<p
style={{
margin: '2px 0 0',
fontSize: 12,
fontWeight: 600,
fontFamily: 'var(--font-mono)',
letterSpacing: '-.01em',
color: 'var(--color-ink-4)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{identifier}
</p>
) : null}
{note ? (
<p
style={{
margin: '4px 0 0',
fontSize: 11.5,
lineHeight: 1.45,
color: noteColor ?? 'var(--color-ink-4)',
}}
>
{note}
</p>
) : null}
</div>
<div
style={{
marginTop: 'auto',
paddingTop: 8,
borderTop: '1px solid var(--color-line)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{left !== undefined || right !== undefined ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
fontSize: 12,
color: 'var(--color-ink-3)',
}}
>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{left}
</span>
<span
style={{
flex: 'none',
fontWeight: 700,
fontFamily: 'var(--font-mono)',
color: 'var(--color-ink-1)',
}}
>
{right}
</span>
</div>
) : null}
{action}
</div>
</div>
</div>
);
}
/**
* The 160px media band.
*
* Arrows and dots appear only when there is more than one image, and only on
* hover — and every handler stops propagation, or paging through photos would
* open the card instead.
*/
function Media({
images,
alt,
isHovered,
badge,
status,
}: {
images: string[];
alt: string;
isHovered: boolean;
badge?: React.ReactNode;
status?: React.ReactNode;
}) {
const [index, setIndex] = useState(0);
const [failed, setFailed] = useState(false);
const current = images[Math.min(index, images.length - 1)];
const hasMany = images.length > 1;
return (
<div
style={{
position: 'relative',
width: '100%',
height: 160,
flex: 'none',
padding: 12,
display: 'grid',
placeItems: 'center',
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-line)',
overflow: 'hidden',
}}
>
{current && !failed ? (
<img
src={current}
alt={alt}
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
// Catalogue shots are on white; without this the photo's ground
// shows as a grey rectangle inside the card.
mixBlendMode: 'multiply',
transform: isHovered ? 'scale(1.08)' : 'scale(1)',
transition: 'transform .7s ease-in-out',
}}
/>
) : (
<span style={{ color: 'var(--color-line)' }}>
<ImageOff size={44} />
</span>
)}
{badge ? <span style={{ position: 'absolute', top: 8, left: 8, zIndex: 2 }}>{badge}</span> : null}
{status ? <span style={{ position: 'absolute', top: 8, right: 8, zIndex: 2 }}>{status}</span> : null}
{hasMany ? (
<>
<Arrow
side="left"
isVisible={isHovered}
onClick={(event) => {
event.stopPropagation();
setFailed(false);
setIndex((value) => (value - 1 + images.length) % images.length);
}}
/>
<Arrow
side="right"
isVisible={isHovered}
onClick={(event) => {
event.stopPropagation();
setFailed(false);
setIndex((value) => (value + 1) % images.length);
}}
/>
<span
style={{
position: 'absolute',
bottom: 6,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: 3,
zIndex: 2,
}}
>
{images.slice(0, 8).map((url, position) => (
<span
key={url}
style={{
width: position === index ? 12 : 5,
height: 5,
borderRadius: 999,
background:
position === index ? 'var(--color-brand)' : 'var(--color-line)',
transition: 'width .2s',
}}
/>
))}
</span>
</>
) : null}
</div>
);
}
function Arrow({
side,
isVisible,
onClick,
}: {
side: 'left' | 'right';
isVisible: boolean;
onClick: (event: React.MouseEvent) => void;
}) {
return (
<button
type="button"
aria-label={side === 'left' ? 'Previous image' : 'Next image'}
onClick={onClick}
style={{
position: 'absolute',
[side]: 4,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 2,
width: 26,
height: 26,
display: 'grid',
placeItems: 'center',
borderRadius: 999,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
color: 'var(--color-ink-3)',
cursor: 'pointer',
opacity: isVisible ? 1 : 0,
transition: 'opacity .2s',
}}
>
{side === 'left' ? <ChevronLeft size={14} /> : <ChevronRight size={14} />}
</button>
);
}
/** The small uppercase pill the old console pins over a product image. */
export function MediaPill({
label,
color,
isSolid,
}: {
label: string;
color: string;
isSolid?: boolean;
}) {
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '2px 8px',
borderRadius: 999,
fontSize: 10.5,
fontWeight: 800,
letterSpacing: '.06em',
textTransform: 'uppercase',
whiteSpace: 'nowrap',
background: isSolid ? color : `color-mix(in srgb, ${color} 12%, var(--color-surface))`,
color: isSolid ? '#fff' : color,
border: isSolid ? 'none' : `1px solid color-mix(in srgb, ${color} 35%, transparent)`,
}}
>
{label}
</span>
);
}

View File

@@ -10,7 +10,6 @@ import type { Product } from '@/api/types';
import { SectionHeader } from '@/components/SectionHeader';
import { useLocationProducts, useTenantCategories } from '@/queries/hooks';
import { useBranchScope } from './BranchScope';
import { CatalogueImportDrawer } from './CatalogueImportDrawer';
import { count, money } from './format';
import { ProductDrawer } from './ProductDrawer';
import { PublishDialog } from './PublishDialog';
@@ -40,14 +39,10 @@ import {
* the truth on its own. They are shown per row, not as a filter.
*/
export function ProductsPanel({
isImporting,
onCloseImport,
isUploading,
onCloseUpload,
}: {
/** Owned by InventoryPage, so its buttons can sit in the tab row. */
isImporting: boolean;
onCloseImport: () => void;
/** Owned by InventoryPage, so its button can sit in the tab row. */
isUploading: boolean;
onCloseUpload: () => void;
}) {
@@ -132,13 +127,6 @@ export function ProductsPanel({
onClose={onCloseUpload}
/>
) : null}
{isImporting && tenantid && branch ? (
<CatalogueImportDrawer
tenantid={tenantid}
locationid={branch.locationid}
onClose={onCloseImport}
/>
) : null}
{publishing && tenantid ? (
<PublishDialog
products={publishing}

View File

@@ -30,10 +30,11 @@ import { SectionHeader } from '@/components/SectionHeader';
import { queryKeys } from '@/queries/keys';
import { useStockRequests, useStockStatement } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { CataloguePanel } from '../CataloguePanel';
import { ProductsPanel } from '../ProductsPanel';
import { branchLabel, count, money } from '../format';
type Tab = 'products' | 'stock' | 'requests';
type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
/**
* Inventory — catalogue, stock and the approval queue.
@@ -55,7 +56,6 @@ export function InventoryPage() {
const [tab, setTab] = useState<Tab>('products');
// Owned here so the button can live in the tab row while the drawer it opens
// stays inside the panel that uses it.
const [isImporting, setImporting] = useState(false);
const [isUploading, setUploading] = useState(false);
const requests = useStockRequests(
@@ -73,9 +73,14 @@ export function InventoryPage() {
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."
/* The two product actions live here, not in the tab row, so their
position is fixed — they no longer appear and disappear as the tab
changes, and nothing below shifts sideways when they do.
/* Upload sheet lives here, not in the tab row, so its position is
fixed — it no longer appears and disappears as the tab changes, and
nothing below shifts sideways when it does.
"Import products" used to sit beside it and is gone: once browsing
the catalogue became a tab, the button was a second door to a room
already on the wall, and it read as a different action than the tab
it opened.
The "N awaiting approval" badge that used to sit here is gone: the
Stock requests tab already carries the same count, and a number
@@ -89,12 +94,6 @@ export function InventoryPage() {
isDisabled={!tenantid}
onClick={() => setUploading(true)}
/>
<BarAction
label="Import products"
icon={<DownloadCloud size={14} />}
isDisabled={!tenantid}
onClick={() => setImporting(true)}
/>
</HStack>
}
tabs={
@@ -102,13 +101,19 @@ export function InventoryPage() {
giving it a row of its own below. It only appears on that tab —
an action for a panel you are not looking at is a misfire waiting
to happen. */
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Products"
icon={<PackageSearch size={14} />}
isActive={tab === 'products'}
onClick={() => setTab('products')}
/>
<TabButton
label="Catalogue"
icon={<DownloadCloud size={14} />}
isActive={tab === 'catalogue'}
onClick={() => setTab('catalogue')}
/>
<TabButton
label="Stock requests"
icon={<ClipboardList size={14} />}
@@ -128,11 +133,11 @@ export function InventoryPage() {
{tab === 'products' ? (
<ProductsPanel
isImporting={isImporting}
onCloseImport={() => setImporting(false)}
isUploading={isUploading}
onCloseUpload={() => setUploading(false)}
/>
) : tab === 'catalogue' ? (
<CataloguePanel />
) : tab === 'requests' ? (
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} />
) : (

View File

@@ -55,8 +55,15 @@ const EMPTY: FormState = {
contactno: '',
address: '',
suburb: '',
city: 'Coimbatore',
state: 'Tamil Nadu',
// Empty, not pre-filled.
//
// These carried 'Coimbatore' and 'Tamil Nadu' as VALUES, not placeholders —
// so a merchant anywhere else was submitted with the wrong city and state
// unless somebody noticed and cleared them. A default that is right most of
// the time is worse than a blank field, because it is only checked when it
// looks wrong.
city: '',
state: '',
postcode: '',
opentime: isoTime('08:00'),
closetime: isoTime('22:00'),

View File

@@ -110,7 +110,7 @@ export function ReportsPage() {
/>
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Revenue"
icon={<IndianRupee size={14} />}

View File

@@ -184,7 +184,7 @@ export function SalesPage() {
</HStack>
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Online Orders"
icon={<ShoppingCart size={14} />}

View File

@@ -125,7 +125,7 @@ export function UsersPage() {
/>
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Store users"
icon={<UserCog size={14} />}
@@ -426,8 +426,16 @@ function Chip({ label, tone }: { label: string | undefined; tone: keyof typeof T
/** 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)';
// A missing status is UNKNOWN, not active. Defaulting it to active painted a
// green dot and the word "Active" beside an account whose state the API never
// reported — which is the one case where somebody most needs to look.
const isActive = (status ?? '').toLowerCase().startsWith('active');
const isUnknown = !status;
const colour = isUnknown
? 'var(--color-ink-4)'
: isActive
? 'var(--color-success, #10b981)'
: 'var(--color-ink-4)';
return (
<span
style={{
@@ -442,7 +450,7 @@ function StatusChip({ status }: { status: string | undefined }) {
<span
style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }}
/>
{isActive ? 'Active' : (status ?? 'Inactive')}
{isUnknown ? 'Unknown' : isActive ? 'Active' : status}
</span>
);
}

View File

@@ -292,3 +292,19 @@ test('problems come back worst bucket first', () => {
assert.equal(list[0]?.bucket, 'now');
assert.equal(list.at(-1)?.bucket, 'look');
});
/*
* The regression this file exists to prevent repeating.
*
* `GET /pos/health/location` answers `{location_id, total, online, terminals}`.
* It was typed as a bare array, so the first real sign-in hit `raw.map is not a
* function` — with no error boundary in the app at the time, that rendered as a
* blank page on the Store Admin console.
*/
test('a non-array from the health endpoint reads as no heartbeat, not a crash', () => {
const wrapper = { location_id: '1', total: 2, online: 1, terminals: [till({}), till({})] };
const summary = summariseBranch(wrapper as unknown as PosTerminalHealth[], NOW);
assert.equal(summary.state, 'no-heartbeat');
assert.equal(summary.total, 0);
assert.equal(summary.terminals.length, 0);
});

View File

@@ -251,7 +251,10 @@ export function rankOf(state: TerminalState): number {
* averaging is exactly how that hour goes unnoticed.
*/
export function summariseBranch(raw: PosTerminalHealth[], now: number): BranchSyncSummary {
const terminals = raw.map((entry) => readTerminal(entry, now));
// Defensive because this is a board on the landing page: an unexpected shape
// from the health endpoint should read as "no heartbeat", not take the whole
// console down with it. It already did once — see `PosLocationHealth`.
const terminals = (Array.isArray(raw) ? raw : []).map((entry) => readTerminal(entry, now));
const worst = terminals.reduce<TerminalState>(
(acc, terminal) => (RANK[terminal.state] > RANK[acc] ? terminal.state : acc),

View File

@@ -156,7 +156,7 @@ export function StoreProductsPage() {
)
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Catalogue"
icon={<PackageSearch size={14} />}

View File

@@ -209,15 +209,26 @@ function RoleChip({ label }: { label: string | undefined }) {
border: `1px solid color-mix(in oklab, ${colour} 24%, transparent)`,
}}
>
{label || 'cashier'}
{/* Not defaulted to 'cashier' — a till account whose role the API did
not report is unknown, and guessing the lower-privilege one is still a
guess printed in a Role column. */}
{label || '—'}
</span>
);
}
/** A dot and a word, not a pill — the merchant's page reads the same way. */
function StatusChip({ status }: { status: string | undefined }) {
const isActive = (status ?? 'active').toLowerCase().startsWith('active');
const colour = isActive ? 'var(--color-success, #10b981)' : 'var(--color-ink-4)';
// A missing status is UNKNOWN, not active. Defaulting it to active painted a
// green dot and the word "Active" beside an account whose state the API never
// reported — which is the one case where somebody most needs to look.
const isActive = (status ?? '').toLowerCase().startsWith('active');
const isUnknown = !status;
const colour = isUnknown
? 'var(--color-ink-4)'
: isActive
? 'var(--color-success, #10b981)'
: 'var(--color-ink-4)';
return (
<span
style={{
@@ -230,7 +241,7 @@ function StatusChip({ status }: { status: string | undefined }) {
}}
>
<span style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }} />
{isActive ? 'Active' : (status ?? 'Inactive')}
{isUnknown ? 'Unknown' : isActive ? 'Active' : status}
</span>
);
}