lat and long

This commit is contained in:
2026-08-27 13:58:19 +05:30
parent 60efac7a7e
commit a214ea67da
15 changed files with 1816 additions and 445 deletions

View File

@@ -14,6 +14,7 @@ import {
import { useAuth } from '@/auth/AuthContext';
import { HOME_ROUTE } from '@/auth/roles';
import {
checkAccount,
MIN_PASSWORD_LENGTH,
PasswordSetupRequiredError,
setInitialPassword,
@@ -43,10 +44,24 @@ export function LoginPage() {
const [isBusy, setIsBusy] = useState(false);
/**
* The userid `applogin` returned for an account with no password, or null.
* Which of the three steps is on screen.
*
* 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,
* Email first, always. A tenant made by `createtenantuser` and every branch
* made by `createtenantlocation` is spawned with an EMPTY password, so their
* owner's first sign-in cannot succeed — and a form that asks for the
* password up front asks them for something that does not exist yet. They
* guess, it fails, and only then are they told to invent one.
*
* So the email is checked before a password field is ever shown, and the page
* goes straight to whichever step that account actually needs. This is what
* the old console does, and it is the right shape.
*/
const [step, setStep] = useState<'email' | 'password' | 'setup'>('email');
/**
* The userid the probe returned for an account with no password.
*
* Deliberately not a route: it exists only because a check 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.
*/
@@ -56,6 +71,28 @@ export function LoginPage() {
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
/** Step one: which door does this email need? */
async function handleEmail(event: FormEvent) {
event.preventDefault();
setError(null);
setIsBusy(true);
try {
const check = await checkAccount(email);
if (check.state === 'setup') {
setSetupUserid(check.userid);
setNewPassword('');
setConfirmPassword('');
setStep('setup');
} else {
setStep('password');
}
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'Could not check that email');
} finally {
setIsBusy(false);
}
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
@@ -64,12 +101,14 @@ export function LoginPage() {
const session = await signIn(email, password);
navigate(HOME_ROUTE[session.role], { replace: true });
} catch (cause) {
// Not a failure — the first step. The account exists and has no password,
// and the backend handed back the userid to set one against.
// Still handled, even though the probe should have caught it. Between the
// check and the submit an administrator could have cleared the password,
// and the account would otherwise dead-end on "Invalid Email".
if (cause instanceof PasswordSetupRequiredError) {
setSetupUserid(cause.userid);
setNewPassword('');
setConfirmPassword('');
setStep('setup');
} else {
setError(cause instanceof Error ? cause.message : 'Sign-in failed');
}
@@ -78,6 +117,16 @@ export function LoginPage() {
}
}
/** Back to the email field, from either of the two second steps. */
function restart() {
setStep('email');
setSetupUserid(null);
setPassword('');
setNewPassword('');
setConfirmPassword('');
setError(null);
}
/**
* Set the password, then sign in with it.
*
@@ -107,7 +156,8 @@ export function LoginPage() {
}
}
const canSubmit = email.trim() !== '' && password !== '' && !isBusy;
const canSubmit =
step === 'email' ? email.trim() !== '' && !isBusy : password !== '' && !isBusy;
const canSetup =
newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy;
@@ -129,8 +179,9 @@ export function LoginPage() {
}}
>
<BrandPanel />
{setupUserid === null ? (
{step !== 'setup' ? (
<FormPanel
step={step}
email={email}
password={password}
isPasswordVisible={isPasswordVisible}
@@ -140,7 +191,8 @@ export function LoginPage() {
onEmail={setEmail}
onPassword={setPassword}
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
onSubmit={handleSubmit}
onSubmit={step === 'email' ? handleEmail : handleSubmit}
onBack={restart}
/>
) : (
<SetupPanel
@@ -155,10 +207,7 @@ export function LoginPage() {
onConfirmPassword={setConfirmPassword}
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
onSubmit={handleSetup}
onBack={() => {
setSetupUserid(null);
setError(null);
}}
onBack={restart}
/>
)}
</div>
@@ -297,6 +346,8 @@ function BrandPanel() {
──────────────────────────────────────────────────────────────────────────── */
interface FormPanelProps {
step: 'email' | 'password';
onBack: () => void;
email: string;
password: string;
isPasswordVisible: boolean;
@@ -310,6 +361,8 @@ interface FormPanelProps {
}
function FormPanel({
step,
onBack,
email,
password,
isPasswordVisible,
@@ -339,7 +392,9 @@ function FormPanel({
Welcome back
</h1>
<p style={{ margin: '6px 0 0', fontSize: 13.5, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
Sign in to manage tenants, branches, catalogue and counter sales.
{step === 'email'
? 'Sign in to manage tenants, branches, catalogue and counter sales.'
: `Signing in as ${email}.`}
</p>
</div>
@@ -352,12 +407,19 @@ function FormPanel({
onChange={(event) => onEmail(event.target.value)}
placeholder="you@company.com"
autoComplete="username"
autoFocus
autoFocus={step === 'email'}
required
style={inputStyle}
readOnly={step === 'password'}
style={{
...inputStyle,
...(step === 'password'
? { color: 'var(--color-ink-3)', cursor: 'default' }
: {}),
}}
/>
</Field>
{step === 'password' ? (
<Field
label="Password"
htmlFor="password"
@@ -380,10 +442,12 @@ function FormPanel({
onChange={(event) => onPassword(event.target.value)}
placeholder="••••••••"
autoComplete="current-password"
autoFocus
required
style={{ ...inputStyle, paddingRight: 40 }}
/>
</Field>
) : null}
{/* Neither "Keep me signed in" nor "Forgot password?" is here any more.
@@ -399,11 +463,17 @@ function FormPanel({
<SubmitButton
canSubmit={canSubmit}
isBusy={isBusy}
busyLabel="Signing in…"
label="Continue"
busyLabel={step === 'email' ? 'Checking…' : 'Signing in…'}
label={step === 'email' ? 'Continue' : 'Sign in'}
/>
</form>
{step === 'password' ? (
<button type="button" onClick={onBack} style={backLinkStyle}>
Use a different account
</button>
) : null}
<p
style={{
margin: 0,
@@ -574,22 +644,7 @@ function SetupPanel({
/>
</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,
}}
>
<button type="button" onClick={onBack} style={backLinkStyle}>
Use a different account
</button>
</div>
@@ -684,6 +739,19 @@ function SubmitButton({
);
}
const backLinkStyle: React.CSSProperties = {
alignSelf: 'center',
border: 0,
background: 'transparent',
padding: 0,
fontSize: 12.5,
fontFamily: 'inherit',
color: 'var(--color-ink-3)',
cursor: 'pointer',
textDecoration: 'underline',
textUnderlineOffset: 3,
};
const eyeButtonStyle: React.CSSProperties = {
position: 'absolute',
right: 8,

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
@@ -8,7 +8,16 @@ import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet } from 'lucide-react';
import { importSheetProducts, type SheetImportResult, type SheetProductRow } from '@/api/products';
import type { SheetProductRow } from '@/api/products';
import {
isStorageFailure,
pollJob,
previewSheet,
submitIngest,
summarise,
type IngestJob,
type IngestPreview,
} from '@/api/ingest';
import { errorMessage } from '@/api/client';
import { SectionHeader } from '@/components/SectionHeader';
import { SheetDropzone } from '@/components/SheetDropzone';
@@ -23,10 +32,9 @@ interface PreviewRow extends Record<string, unknown> {
quantity: number;
}
export interface SheetImportPanelProps {
tenantid: number | undefined;
locationid: number | undefined;
}
/* No props. The ingest writes the global catalogue and takes no tenant and no
outlet — see the note in `GlobalCataloguePage`. They return with the
inventory step. */
/**
* The spreadsheet import path.
@@ -40,21 +48,33 @@ export interface SheetImportPanelProps {
* twice, so that is said out loud before the button rather than discovered
* afterwards.
*/
export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps) {
export function SheetImportPanel() {
const [file, setFile] = useState<File | null>(null);
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const [progress, setProgress] = useState<{ done: number; total: number } | null>(null);
const [result, setResult] = useState<SheetImportResult | null>(null);
/**
* Non-null while the ingest service is working.
*
* Not a percentage. The old importer ran N creates from the browser and could
* count them; this is one request to a service that scrapes, calls an LLM and
* fetches images before it answers, and it reports nothing along the way. A
* bar that invented a position would be lying, so it says what is happening
* and how many rows are in flight instead.
*/
/** The service's own dry run. Authoritative about what it will read. */
const [dryRun, setDryRun] = useState<IngestPreview | null>(null);
/** The job while it runs, and after it settles. */
const [job, setJob] = useState<IngestJob | null>(null);
const [isWorking, setIsWorking] = useState(false);
const hasTarget = Boolean(tenantid && locationid);
async function handleFile(next: File | File[] | null) {
const chosen = Array.isArray(next) ? (next[0] ?? null) : next;
setFile(chosen);
setParsed(null);
setParseError(null);
setResult(null);
setJob(null);
setDryRun(null);
if (!chosen) return;
try {
@@ -64,21 +84,46 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
}
}
async function handleImport() {
if (!parsed || !tenantid || !locationid) return;
setProgress({ done: 0, total: parsed.rows.length });
/**
* Ask the service what it would read, without writing anything.
*
* `/preview` parses the sheet and reports the column mapping and the first
* rows. Worth doing every time: a header the service does not recognise is
* dropped SILENTLY, so a price column it never saw looks identical to a
* successful import until somebody opens the catalogue.
*/
async function handlePreview() {
if (!file) return;
setParseError(null);
setIsWorking(true);
try {
const outcome = await importSheetProducts({
tenantid,
locationid,
rows: parsed.rows,
onProgress: (done, total) => setProgress({ done, total }),
});
setResult(outcome);
setDryRun(await previewSheet(file));
} catch (cause) {
setParseError(errorMessage(cause));
} finally {
setProgress(null);
setIsWorking(false);
}
}
/**
* Submit, then poll until it settles.
*
* The FILE goes up, not the parsed rows — the service does its own parsing
* and enrichment and can only do that from the original document. The local
* parse still runs, but only to fill the preview table.
*/
async function handleImport() {
if (!file) return;
setParseError(null);
setIsWorking(true);
try {
const submitted = await submitIngest({ file });
setJob(submitted);
setJob(await pollJob(submitted.job_id, setJob));
} catch (cause) {
setParseError(errorMessage(cause));
} finally {
setIsWorking(false);
}
}
@@ -145,46 +190,134 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
quantity: row.quantity,
}));
if (result) {
const isClean = result.failures.length === 0;
/* ── Finished ─────────────────────────────────────────────────────────── */
if (job && (job.status === 'done' || job.status === 'failed')) {
const outcome = job.result;
const broken = isStorageFailure(job);
return (
<Card padding={4} variant="transparent">
<VStack gap={3}>
<HStack align="center" gap={1.5}>
{isClean ? (
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
) : (
{broken ? (
<AlertTriangle size={22} style={{ color: 'var(--color-error, #d64545)' }} />
) : (outcome?.error_count ?? 0) + (outcome?.rejected ?? 0) > 0 ? (
<AlertTriangle size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
) : (
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
)}
<Text type="large" weight="semibold">
{result.created} product{result.created === 1 ? '' : 's'} imported
{summarise(job)}
</Text>
</HStack>
<Text type="body" color="secondary">
{result.linked} linked to the store, {result.stocked} given opening stock.
{isClean ? '' : ` ${result.failures.length} row(s) did not make it.`}
</Text>
{outcome ? (
<>
<Text type="body" color="secondary">
{outcome.rows_total} sheet row{outcome.rows_total === 1 ? '' : 's'} became{' '}
{outcome.products_built} product{outcome.products_built === 1 ? '' : 's'}
{outcome.products_built > outcome.rows_total
? ' — a cell listing several pack sizes becomes one product each.'
: '.'}
{outcome.brands.length > 0 ? ` Brands touched: ${outcome.brands.join(', ')}.` : ''}
</Text>
{!isClean ? (
<VStack gap={1}>
{result.failures.slice(0, 10).map((failure, index) => (
<HStack key={`${failure.row.productsku}-${index}`} gap={1} align="center">
<Badge variant="error" label={failure.row.productsku || '—'} />
<Text type="body" size="sm" color="secondary">
{failure.reason}
{/* Headers the service did not recognise.
High on the panel because they are dropped silently. A price
column it never read looks exactly like a clean import. */}
{outcome.unrecognised_columns.length > 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Ignored columns
</Text>
</HStack>
))}
</VStack>
) : null}
<Text type="body" size="sm" color="secondary">
{outcome.unrecognised_columns.join(', ')} — the service does not recognise these
headers, so nothing in them was read.
</Text>
</VStack>
) : null}
{outcome.errors.length > 0 ? (
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
{outcome.error_count} row{outcome.error_count === 1 ? '' : 's'} could not be
imported
</Text>
{outcome.errors.slice(0, 10).map((failure) => (
<HStack key={`e${failure.row}`} gap={1} align="center" wrap="wrap">
<Badge variant="error" label={`Row ${failure.row}`} />
<Text type="body" size="sm" color="secondary">
{failure.product_name ? `${failure.product_name} — ` : ''}
{failure.error}
</Text>
</HStack>
))}
{outcome.error_count > outcome.errors.length ? (
<Text type="body" size="xsm" color="secondary">
{outcome.error_count - outcome.errors.length} more not listed — the service
caps this list at 50.
</Text>
) : null}
</VStack>
) : null}
{outcome.rejections.length > 0 ? (
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
{outcome.rejected} built but rejected by validation
</Text>
{outcome.rejections.slice(0, 10).map((rejection, index) => (
<HStack key={`r${index}`} gap={1} align="center" wrap="wrap">
<Badge variant="error" label={rejection.size || '—'} />
<Text type="body" size="sm" color="secondary">
{rejection.product_name} — {rejection.reason}
</Text>
</HStack>
))}
</VStack>
) : null}
{outcome.warnings.length > 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Corrections applied
</Text>
{outcome.warnings.slice(0, 10).map((warning, index) => (
<Text key={`w${index}`} type="body" size="sm" color="secondary">
{warning}
</Text>
))}
</VStack>
) : null}
{/* The catalogue is not the shelf. Said here because the screen
asks for a merchant and an outlet, which makes it look as
though the upload put something in their shop. It did not. */}
{!broken ? (
<Text type="body" size="sm" style={{ color: 'var(--color-ink-4)', lineHeight: 1.6 }}>
These are in the global catalogue. They are not yet on this outlet&rsquo;s shelf —
putting them there with a price and opening stock is a separate step, and it is
not wired up yet.
</Text>
) : null}
</>
) : (
<Text type="body" color="secondary">
{job.detail ?? 'The service reported no detail.'}
</Text>
)}
<RawResponse payload={job} />
<HStack>
<Button
label="Import another file"
label="Send another file"
variant="secondary"
onClick={() => {
setResult(null);
setJob(null);
setDryRun(null);
setParsed(null);
setFile(null);
}}
@@ -215,7 +348,6 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
<SheetDropzone
file={file}
onFile={handleFile}
blockedReason={hasTarget ? undefined : 'Pick a merchant and outlet first'}
/>
<Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}>
@@ -286,31 +418,81 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
/>
) : null}
{progress ? (
<ProgressBar
label="Importing"
value={progress.done}
max={progress.total}
hasValueLabel
formatValueLabel={(value, max) => `${value} of ${max}`}
/>
{/* The service's dry run, once it has been asked for.
This is the authoritative answer to "what will it actually
read", and the ignored-columns line is the reason to run it: an
unrecognised header is dropped without a word. */}
{dryRun ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
What the service will read
</Text>
{Object.entries(dryRun.recognised_columns ?? {}).map(([field, header]) => (
<Text key={field} type="body" size="xsm" color="secondary">
{String(header)} → {field}
</Text>
))}
{(dryRun.unrecognised_columns ?? []).length > 0 ? (
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
Ignored: {dryRun.unrecognised_columns.join(', ')} — nothing in these columns is
read.
</Text>
) : null}
</VStack>
) : null}
{/* Real progress, from the job.
`stage_index` of `total_stages` and `rows_done` of `rows_total`
both come off each poll, so this is a position rather than a
decoration. It falls back to indeterminate for the moment
between submitting and the first reading. */}
{job && job.status !== 'done' && job.status !== 'failed' ? (
<VStack gap={0.5}>
{job.rows_total > 0 ? (
<ProgressBar
label={job.stage_name || 'Working'}
value={job.rows_done}
max={job.rows_total}
hasValueLabel
formatValueLabel={(value, max) => `${value} of ${max} rows`}
/>
) : (
<ProgressBar label={job.stage_name || 'Starting'} isIndeterminate />
)}
<Text type="body" size="xsm" color="secondary">
Stage {job.stage_index + 1} of {job.total_stages}
{job.detail ? ` · ${job.detail}` : ''} — keep this tab open.
</Text>
</VStack>
) : null}
<HStack justify="between" align="center" gap={2} wrap="wrap">
<HStack align="center" gap={1}>
<FileSpreadsheet size={15} style={{ color: 'var(--color-slate-400)' }} />
<Text type="body" size="xsm" color="secondary">
Nothing dedupes on SKU — importing this file twice creates every product twice.
Sent whole for parsing and enrichment. The table above is only what we could read
locally — check with a dry run first.
</Text>
</HStack>
<Button
label={progress ? 'Importing…' : `Import ${parsed.rows.length} products`}
variant="primary"
size="lg"
isLoading={Boolean(progress)}
isDisabled={!hasTarget || parsed.rows.length === 0}
onClick={handleImport}
/>
<HStack gap={1} align="center">
<Button
label="Dry run"
variant="secondary"
size="lg"
isDisabled={isWorking || !file}
onClick={handlePreview}
/>
<Button
label={isWorking ? 'Working…' : 'Send to catalogue'}
variant="primary"
size="lg"
isLoading={isWorking}
isDisabled={isWorking || !file}
onClick={handleImport}
/>
</HStack>
</HStack>
</VStack>
</Card>
@@ -318,3 +500,65 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
</VStack>
);
}
/**
* What the ingest service actually replied.
*
* Collapsed, so it is not in the way, and copyable in one click — the point is
* to get the real payload out of a browser and in front of someone who can turn
* it into types. Scaffolding: it goes when `api/ingest.ts` stops guessing.
*/
function RawResponse({ payload }: { payload: unknown }) {
const [isOpen, setIsOpen] = useState(false);
const text = useMemo(() => {
try {
return JSON.stringify(payload, null, 2);
} catch {
return String(payload);
}
}, [payload]);
return (
<VStack gap={1}>
<HStack gap={1} align="center" wrap="wrap">
<Button
label={isOpen ? 'Hide the raw response' : 'Show the raw response'}
variant="ghost"
size="sm"
onClick={() => setIsOpen((open) => !open)}
/>
{isOpen ? (
<Button
label="Copy"
variant="ghost"
size="sm"
onClick={() => void navigator.clipboard?.writeText(text)}
/>
) : null}
</HStack>
{isOpen ? (
<pre
style={{
margin: 0,
padding: 14,
borderRadius: 12,
border: '1px solid var(--color-line)',
background: 'var(--color-surface-subtle)',
fontFamily: 'var(--font-mono)',
fontSize: 12,
lineHeight: 1.6,
maxHeight: 320,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: 'var(--color-ink-2)',
}}
>
{text}
</pre>
) : null}
</VStack>
);
}

View File

@@ -124,30 +124,33 @@ export async function parseProductSheet(file: File): Promise<ParsedSheet> {
const categoryid = toNumber(picked.categoryid) ?? 0;
const subcategoryid = toNumber(picked.subcategoryid) ?? 0;
// Only the product name is required, and that is the ingest service's rule
// rather than ours: everything else is optional and enriched when blank,
// and even the brand is inferred from the name.
//
// This validation used to demand a SKU, a price, a cost and a numeric
// category id, because the console itself was creating the products and
// needed all four. It no longer creates anything — the service does — so
// enforcing them here would reject sheets the service handles perfectly
// well. A missing price is now a warning, not a refusal.
let isValid = true;
if (!productname) {
issues.push({ line, field: 'productname', message: 'Product name is missing' });
isValid = false;
}
if (!productsku) {
// Without a SKU the created product cannot be found again, and the
// create endpoint does not return its new id — so the row would import
// and then be impossible to link to the store.
issues.push({ line, field: 'productsku', message: 'SKU is missing — the row cannot be linked to a store without one' });
isValid = false;
if (productname && !String(picked.productbrand ?? '').trim() && productname.split(/\s+/).length < 2) {
// The service infers the brand from the name ("Britannia 50-50" →
// Britannia) and rejects the row when it cannot. A single bare word is
// the case where that is most likely to fail.
issues.push({
line,
field: 'productbrand',
message: 'No brand column and the name is one word — the service may not be able to infer a brand',
});
}
if (retailprice === null || retailprice <= 0) {
issues.push({ line, field: 'retailprice', message: 'Retail price is missing or not a number' });
isValid = false;
}
if (productcost === null) {
issues.push({ line, field: 'productcost', message: 'Cost is missing or not a number' });
isValid = false;
}
if (categoryid <= 0) {
issues.push({ line, field: 'categoryid', message: 'Category id is missing — the tenant has no mapping from a category name' });
isValid = false;
if (retailprice !== null && retailprice <= 0) {
issues.push({ line, field: 'retailprice', message: 'Price is not a positive number' });
}
if (!isValid) return;
@@ -168,53 +171,72 @@ export async function parseProductSheet(file: File): Promise<ParsedSheet> {
});
});
// Duplicate SKUs inside one file are the user's own error and will produce
// duplicate products, since nothing on the server dedupes.
const seen = new Set<string>();
for (const [index, row] of rows.entries()) {
if (seen.has(row.productsku)) {
issues.push({
line: index + 2,
field: 'productsku',
message: `SKU ${row.productsku} appears more than once in this file`,
});
}
seen.add(row.productsku);
}
// The duplicate-SKU check that used to be here is gone.
//
// It existed because nothing on the old server deduped, so two rows with one
// SKU made two products. The ingest service upserts on `image_id` — within
// the batch as well as against the table — so a repeated pack now collapses,
// last one winning. Flagging it as an error would be warning about behaviour
// that is now correct.
return { rows, issues, unmappedColumns, totalRows: raw.length };
}
/** The template handed to a tenant, so the columns arrive named correctly. */
/**
* The template handed to a merchant.
*
* The header names are chosen to survive the INGEST SERVICE's column matcher,
* not ours. It matches case-insensitively on keywords, and the old template
* collided with it in three places — one of them silently destructive:
*
* `quantity` → their `size_variants` keyword list. An opening stock of
* 40 would have been written into the global catalogue as a
* pack size of "40", for every merchant to see.
* `productcost` → "cost" is in their price list, so it competed with
* `retailprice` for the same field.
* `subcategoryid` → contains "category", competing with `categoryid`.
*
* And `taxpercent` and `unitvalue` matched nothing at all, so they were dropped
* without a word — a header that matches nothing is silently ignored, which
* looks exactly like a successful import until somebody opens the catalogue.
*
* So the columns are named in their vocabulary. `Opening Stock` no longer
* contains "quantity"; `Pack Size` says what their `size_variants` actually
* wants; the price columns are unambiguous.
*/
export async function downloadTemplate(): Promise<void> {
const XLSX = await loadXlsx();
const headers = [
'productname',
'productsku',
'categoryid',
'subcategoryid',
'retailprice',
'productcost',
'taxpercent',
'quantity',
'productunit',
'unitvalue',
'productbrand',
'productdesc',
'Product Name',
'Brand',
'Pack Size',
'Category',
'MRP',
'Product Description',
'HSN Code',
'Barcode (EAN)',
'SKU',
'Image URL',
'Opening Stock',
];
const example = {
productname: 'Aachi Chilli Powder 100g',
productsku: 'AACHI-CHILLI-100',
categoryid: 1,
subcategoryid: 0,
retailprice: 35,
productcost: 28,
taxpercent: 5,
quantity: 24,
productunit: 'g',
unitvalue: '100',
productbrand: 'Aachi',
productdesc: '',
'Product Name': 'Britannia Good Day Cashew Cookies',
Brand: 'Britannia',
// Their pack-size explosion is real and useful: three sizes in one cell
// become three catalogue products.
'Pack Size': '100g, 200g',
Category: 'Biscuits',
MRP: 50,
'Product Description': 'Crunchy cashew cookies',
'HSN Code': '1905',
'Barcode (EAN)': '8901063142107',
SKU: '',
// Supplying this skips their image-search stage entirely for the row, which
// is both faster and more reliable than letting it guess.
'Image URL': '',
// Read by nobody upstream — the ingest writes the catalogue, not stock.
// It is here so one sheet can also drive the outlet inventory step.
'Opening Stock': 24,
};
const sheet = XLSX.utils.json_to_sheet([example], { header: headers });

View File

@@ -1,15 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Card } from '@astryxdesign/core/Card';
import { useState } from 'react';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { Selector } from '@astryxdesign/core/Selector';
import { VStack } from '@astryxdesign/core/VStack';
import { PackageSearch, Upload } from 'lucide-react';
import type { TenantInfo } from '@/api/types';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
import { useTenantLocations, useTenants } from '@/queries/hooks';
import { SheetImportPanel } from '../import/SheetImportPanel';
/**
@@ -25,79 +19,18 @@ import { SheetImportPanel } from '../import/SheetImportPanel';
* 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.
* - **Upload sheet** is writing, and it writes the GLOBAL catalogue — not one
* merchant's shelf. The ingest service parses, enriches and stores rows in
* the per-brand tables every merchant reads from, and it takes no tenant and
* no outlet. This mode therefore asks for nothing but a file.
*
* 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 tenantid = Number(params.get('tenantid') ?? 0) || undefined;
const locationid = Number(params.get('locationid') ?? 0) || undefined;
const [mode, setMode] = useState<'catalogue' | 'sheet'>('catalogue');
const { data: tenants } = useTenants();
/**
* 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, string>();
for (const tenant of tenants as TenantInfo[]) {
if (!seen.has(tenant.tenantid)) seen.set(tenant.tenantid, tenant.tenantname);
}
return [...seen.entries()].map(([id, name]) => ({ value: String(id), label: name }));
}, [tenants]);
const locationOptions = useMemo(
() =>
(locations.data ?? []).map((outlet) => ({
value: String(outlet.locationid),
label: outlet.locationname || `Outlet ${outlet.locationid}`,
})),
[locations.data],
);
function chooseTenant(value: string) {
const next = new URLSearchParams(params);
next.set('tenantid', value);
// 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 });
}
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
@@ -117,46 +50,19 @@ export function GlobalCataloguePage() {
/>
{mode === 'sheet' ? (
<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} variant="transparent">
<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>
/* The merchant and outlet pickers used to sit above this panel, and
they described a flow that no longer exists.
The ingest endpoint writes the GLOBAL catalogue. It has no concept of
a tenant or an outlet — putting a product on one shop's shelf with a
price and opening stock is a separate call (`/api/upload/stores`,
joined on `image_id`) that is not wired up yet. Two selectors saying
the upload was "written against one merchant and one outlet" would
have had someone pick a shop, upload, and then go looking for stock
that was never going to arrive.
They come back with the inventory step, and mean something then. */
<SheetImportPanel />
) : (
<CatalogueBrowser
tenantid={undefined}

View File

@@ -1,5 +1,5 @@
import { Drawer } from './Drawer';
import { SheetImportPanel } from '@/features/nearle-admin/import/SheetImportPanel';
import { TenantSheetImportPanel } from './TenantSheetImportPanel';
/**
* Upload a product spreadsheet, from the Products tab.
@@ -30,7 +30,7 @@ export function SheetUploadDrawer({
width={560}
onClose={onClose}
>
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
<TenantSheetImportPanel tenantid={tenantid} locationid={locationid} />
</Drawer>
);
}

View File

@@ -0,0 +1,338 @@
import { useState } from 'react';
/**
* A merchant's OWN product list — not the global catalogue.
*
* This is the browser-side importer that used to serve both consoles. Nearle
* Admin's Upload sheet now hands the file to the ingest service instead, which
* writes the per-brand tables every merchant reads from. That is a
* platform-operator action, and it must not be what happens when a shopkeeper
* uploads their own price list: one merchant's spreadsheet would land in front
* of all of them.
*
* So this path stays for the Store Admin. It creates products under that
* tenant, lists them at that outlet and gives them opening stock — scoped to
* the shop, exactly as before.
*/
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 { ProgressBar } from '@astryxdesign/core/ProgressBar';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet } from 'lucide-react';
import { importSheetProducts, type SheetImportResult, type SheetProductRow } from '@/api/products';
import { errorMessage } from '@/api/client';
import { SectionHeader } from '@/components/SectionHeader';
import { SheetDropzone } from '@/components/SheetDropzone';
import {
downloadTemplate,
parseProductSheet,
type ParsedSheet,
} from '@/features/nearle-admin/import/parseProductSheet';
interface PreviewRow extends Record<string, unknown> {
productname: string;
productsku: string;
categoryid: number;
retailprice: number;
productcost: number;
quantity: number;
}
export interface TenantSheetImportPanelProps {
tenantid: number | undefined;
locationid: number | undefined;
}
/**
* The spreadsheet import path.
*
* Staged rather than one-shot — upload, parse, show what is wrong, then commit —
* because the underlying calls are N creates with no transaction and no batch,
* so a row that fails on the server fails alone and has to be recoverable.
*
* It is also not idempotent, and cannot be made so from the client: nothing on
* the server dedupes on SKU. Re-uploading the same file creates the products
* twice, so that is said out loud before the button rather than discovered
* afterwards.
*/
export function TenantSheetImportPanel({ tenantid, locationid }: TenantSheetImportPanelProps) {
const [file, setFile] = useState<File | null>(null);
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const [progress, setProgress] = useState<{ done: number; total: number } | null>(null);
const [result, setResult] = useState<SheetImportResult | null>(null);
const hasTarget = Boolean(tenantid && locationid);
async function handleFile(next: File | File[] | null) {
const chosen = Array.isArray(next) ? (next[0] ?? null) : next;
setFile(chosen);
setParsed(null);
setParseError(null);
setResult(null);
if (!chosen) return;
try {
setParsed(await parseProductSheet(chosen));
} catch (cause) {
setParseError(errorMessage(cause));
}
}
async function handleImport() {
if (!parsed || !tenantid || !locationid) return;
setProgress({ done: 0, total: parsed.rows.length });
try {
const outcome = await importSheetProducts({
tenantid,
locationid,
rows: parsed.rows,
onProgress: (done, total) => setProgress({ done, total }),
});
setResult(outcome);
} catch (cause) {
setParseError(errorMessage(cause));
} finally {
setProgress(null);
}
}
const previewColumns: TableColumn<PreviewRow>[] = [
{
key: 'productname',
header: 'Product',
width: { type: 'proportional', value: 3 },
renderCell: (row) => (
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.productname}
</Text>
<Text type="body" size="xsm" color="secondary" style={{ fontFamily: 'var(--font-mono)' }}>
{row.productsku}
</Text>
</VStack>
),
},
{
key: 'categoryid',
header: 'Category',
align: 'end',
width: { type: 'pixel', value: 100 },
renderCell: (row) => <Text type="body" size="sm" hasTabularNumbers>{row.categoryid}</Text>,
},
{
key: 'retailprice',
header: 'Retail',
align: 'end',
width: { type: 'pixel', value: 100 },
renderCell: (row) => (
<Text type="label" size="sm" hasTabularNumbers>
₹{row.retailprice}
</Text>
),
},
{
key: 'productcost',
header: 'Cost',
align: 'end',
width: { type: 'pixel', value: 100 },
renderCell: (row) => (
<Text type="body" size="sm" color="secondary" hasTabularNumbers>
₹{row.productcost}
</Text>
),
},
{
key: 'quantity',
header: 'Opening stock',
align: 'end',
width: { type: 'pixel', value: 130 },
renderCell: (row) => <Text type="body" size="sm" hasTabularNumbers>{row.quantity}</Text>,
},
];
const preview: PreviewRow[] = (parsed?.rows ?? []).slice(0, 25).map((row: SheetProductRow) => ({
productname: row.productname,
productsku: row.productsku,
categoryid: row.categoryid,
retailprice: row.retailprice,
productcost: row.productcost,
quantity: row.quantity,
}));
if (result) {
const isClean = result.failures.length === 0;
return (
<Card padding={4} variant="transparent">
<VStack gap={3}>
<HStack align="center" gap={1.5}>
{isClean ? (
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
) : (
<AlertTriangle size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
)}
<Text type="large" weight="semibold">
{result.created} product{result.created === 1 ? '' : 's'} imported
</Text>
</HStack>
<Text type="body" color="secondary">
{result.linked} linked to the store, {result.stocked} given opening stock.
{isClean ? '' : ` ${result.failures.length} row(s) did not make it.`}
</Text>
{!isClean ? (
<VStack gap={1}>
{result.failures.slice(0, 10).map((failure, index) => (
<HStack key={`${failure.row.productsku}-${index}`} gap={1} align="center">
<Badge variant="error" label={failure.row.productsku || '—'} />
<Text type="body" size="sm" color="secondary">
{failure.reason}
</Text>
</HStack>
))}
</VStack>
) : null}
<HStack>
<Button
label="Import another file"
variant="secondary"
onClick={() => {
setResult(null);
setParsed(null);
setFile(null);
}}
/>
</HStack>
</VStack>
</Card>
);
}
return (
<VStack gap={2}>
<Card padding={0} variant="transparent">
<VStack gap={2} padding={3}>
<SectionHeader
title="Upload the tenant's product list"
action={
<Button
label="Download template"
variant="secondary"
size="sm"
icon={<Download size={14} />}
onClick={() => void downloadTemplate()}
/>
}
/>
<SheetDropzone
file={file}
onFile={handleFile}
blockedReason={hasTarget ? undefined : 'Pick a merchant and outlet first'}
/>
<Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}>
Required columns: productname, productsku, categoryid, retailprice, productcost. The
catalogue's category names do not map to a tenant's own ids, so categoryid must be in
the file.
</Text>
{parseError ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{parseError}
</Text>
) : null}
</VStack>
</Card>
{parsed ? (
<Card padding={0} variant="transparent">
<VStack gap={2} padding={3}>
<SectionHeader
title="What is in the file"
note={`${parsed.totalRows} rows read · ${parsed.rows.length} ready · ${parsed.issues.length} to fix`}
/>
{parsed.unmappedColumns.length > 0 ? (
<HStack gap={1} wrap="wrap" align="center">
<Text type="body" size="sm" color="secondary">
Ignored columns:
</Text>
{parsed.unmappedColumns.map((column) => (
<Badge key={column} variant="neutral" label={column} />
))}
</HStack>
) : null}
{parsed.issues.length > 0 ? (
<VStack
gap={0.5}
padding={2}
style={{ background: 'var(--color-warning-muted, #fdf6e3)', borderRadius: 12 }}
>
<HStack align="center" gap={1}>
<AlertTriangle size={15} style={{ color: 'var(--color-warning, #b7860b)' }} />
<Text type="label" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
{parsed.issues.length} row{parsed.issues.length === 1 ? '' : 's'} will be skipped
</Text>
</HStack>
{parsed.issues.slice(0, 8).map((issue, index) => (
<Text key={index} type="body" size="xsm" color="secondary">
Row {issue.line} · {issue.field}: {issue.message}
</Text>
))}
{parsed.issues.length > 8 ? (
<Text type="body" size="xsm" color="secondary">
…and {parsed.issues.length - 8} more.
</Text>
) : null}
</VStack>
) : null}
{preview.length > 0 ? (
<Table<PreviewRow>
data={preview}
columns={previewColumns}
idKey="productsku"
density="compact"
dividers="rows"
/>
) : null}
{progress ? (
<ProgressBar
label="Importing"
value={progress.done}
max={progress.total}
hasValueLabel
formatValueLabel={(value, max) => `${value} of ${max}`}
/>
) : null}
<HStack justify="between" align="center" gap={2} wrap="wrap">
<HStack align="center" gap={1}>
<FileSpreadsheet size={15} style={{ color: 'var(--color-slate-400)' }} />
<Text type="body" size="xsm" color="secondary">
Nothing dedupes on SKU — importing this file twice creates every product twice.
</Text>
</HStack>
<Button
label={progress ? 'Importing…' : `Import ${parsed.rows.length} products`}
variant="primary"
size="lg"
isLoading={Boolean(progress)}
isDisabled={!hasTarget || parsed.rows.length === 0}
onClick={handleImport}
/>
</HStack>
</VStack>
</Card>
) : null}
</VStack>
);
}

View File

@@ -1,18 +1,31 @@
import { useMemo, useState } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, Check, ChevronDown, ChevronRight, EyeOff, Pencil } from 'lucide-react';
import {
AlertTriangle,
Check,
ChevronDown,
ChevronRight,
EyeOff,
Monitor,
Pencil,
Send,
} from 'lucide-react';
import type { DateRange } from '@/api/insights';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel } from '../format';
import { useCounterLabels, type CounterLabels } from '../counterLabels';
import { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
import { BUCKET_COLOR, BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
import { BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard';
/**
@@ -43,20 +56,22 @@ export function TerminalsPage() {
const board = useTerminalBoard({ branches: scoped, range, isHidden: labels.isHidden });
const needs = board.now.length + board.look.length;
/**
* The header line, now that the tiles carry the counts.
*
* It used to lead with "2 counters need attention. 41 sales haven't reached
* us yet" — which the KPI row above now states in larger type, so keeping it
* here printed the same two numbers twice, ten pixels apart. What is left is
* the one thing no tile can say: why every counter is called T4A9.
*/
const summary = useMemo(() => {
if (board.isLoading && board.total === 0) return 'Checking your counters…';
if (board.total === 0) return undefined;
if (needs === 0) {
return `All ${board.total} counter${board.total === 1 ? '' : 's'} are fine — everything they’ve sold has reached us. Each one is listed by the code printed on your receipts, until you rename it.`;
return 'Everything they’ve sold has reached us. Each counter is listed by the code printed on your receipts, until you rename it.';
}
const stuck = board.strandedBills;
const n = board.now.length > 0 ? board.now.length : needs;
const head = `${n} counter${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention.`;
const tail = ' Counters are listed by the code printed on your receipts, until you rename them.';
return stuck > 0
? `${head} ${stuck} sale${stuck === 1 ? '' : 's'} haven’t reached us yet.${tail}`
: `${head}${tail}`;
}, [board, needs]);
return 'Counters are listed by the code printed on your receipts, until you rename them.';
}, [board.isLoading, board.total, needs]);
return (
<VStack gap={3}>
@@ -77,11 +92,45 @@ export function TerminalsPage() {
}
/>
{/* The console's opening move on every operations page — Console, Sales,
Inventory, Reports all lead with this same grid. This page used to
open with a sentence instead, which is most of why it read as a
bolt-on rather than part of the product.
It also puts the stake where the eye lands. The sales waiting on an
unreachable till were previously a clause in a paragraph, and they are
the reason anybody opens this page. */}
{board.total > 0 ? (
<div className="kpi-grid">
<KpiCard
label="Not reached"
value={String(board.strandedBills)}
note={board.strandedBills > 0 ? 'sales we can’t see' : 'everything is in'}
tone={board.strandedBills > 0 ? 'error' : 'neutral'}
icon={<Send size={15} />}
/>
<KpiCard
label="Need someone"
value={String(board.needCounters)}
note={`of ${board.total}`}
tone={board.needCounters > 0 ? 'warning' : 'neutral'}
icon={<AlertTriangle size={15} />}
/>
<KpiCard
label="Selling fine"
value={String(board.fine.length)}
note={`${rupees(board.takenAmount)} taken`}
tone="accent"
icon={<Monitor size={15} />}
/>
</div>
) : null}
{/* A failed read is not an answer. Kept visually distinct from "this shop
has no counters", because only one of the two means nothing is wrong. */}
{board.failed.length > 0 ? (
<Card padding={0} elevation="low">
<HStack gap={1.5} align="start" padding={2}>
<Card padding={0} variant="transparent">
<HStack gap={1.5} align="start" style={{ ...panelStyle, padding: 16 }}>
<span style={{ color: 'var(--color-error, #d64545)', flex: 'none', marginTop: 2 }}>
<AlertTriangle size={16} />
</span>
@@ -122,9 +171,7 @@ export function TerminalsPage() {
isOpen={showFine}
onToggle={() => setShowFine((open) => !open)}
>
{board.fine.map((counter) => (
<FineRow key={counter.key} counter={counter} labels={labels} />
))}
<FineTable counters={board.fine} labels={labels} />
</Group>
) : null}
@@ -158,6 +205,20 @@ export function TerminalsPage() {
);
}
/**
* The page's one surface.
*
* A hairline and a radius, no fill. Every block here used to be a white
* `elevation="low"` card, which on a tinted page reads as a stack of raised
* slabs — and the console has been moving away from that everywhere else.
* Defined once so the edges cannot drift apart the next time one of these is
* edited.
*/
const panelStyle: React.CSSProperties = {
borderRadius: 14,
border: '1px solid var(--color-line)',
};
/* ── Groups ──────────────────────────────────────────────────────────────── */
function Group({
@@ -177,54 +238,29 @@ function Group({
}) {
return (
<VStack gap={1.5}>
<HStack align="center" gap={1}>
<span
aria-hidden
style={{
width: 8,
height: 8,
borderRadius: 999,
flex: 'none',
background: BUCKET_COLOR[bucket],
}}
/>
<Text
type="label"
size="sm"
weight="semibold"
style={{ textTransform: 'uppercase', letterSpacing: '.06em' }}
>
{BUCKET_LABEL[bucket]}
</Text>
<Text type="body" size="xsm" color="secondary" style={{ fontVariantNumeric: 'tabular-nums' }}>
{count}
</Text>
{isCollapsible ? (
<button
type="button"
onClick={onToggle}
aria-expanded={isOpen}
style={{
marginLeft: 'auto',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
height: 26,
padding: '0 10px',
borderRadius: 999,
border: '1px solid var(--color-line)',
background: 'transparent',
color: 'var(--color-ink-3)',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
}}
>
{isOpen ? 'Hide' : 'Show'}
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
) : null}
</HStack>
{/* `SectionHeader`, like every other section in the console. This used to
be a hand-built row — an 8px coloured dot, an uppercase micro-label and
a bare number — which is a heading style that exists nowhere else in
the product. The colour went with it: severity is already carried by
the wording and by the order the groups appear in, and saying it a
third time in red was the page shouting. */}
<SectionHeader
title={BUCKET_LABEL[bucket]}
note={String(count)}
{...(isCollapsible
? {
action: (
<Button
label={isOpen ? 'Hide' : 'Show'}
variant="ghost"
size="sm"
icon={isOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
onClick={onToggle ?? (() => {})}
/>
),
}
: {})}
/>
{!isCollapsible || isOpen ? <VStack gap={1.5}>{children}</VStack> : null}
</VStack>
);
@@ -243,13 +279,33 @@ function ProblemCard({ card, labels }: { card: BoardCard; labels: CounterLabels
const [isRenaming, setIsRenaming] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const { problem } = card;
const rail = BUCKET_COLOR[problem.bucket];
return (
<Card padding={0} elevation="low">
<div style={{ display: 'flex', minWidth: 0 }}>
<span aria-hidden style={{ width: 3, flex: 'none', background: rail }} />
<VStack gap={1} padding={2} style={{ minWidth: 0, flex: 1 }}>
<Card padding={0} variant="transparent">
<div
style={{ ...panelStyle, display: 'flex', gap: 14, minWidth: 0, padding: 16 }}
>
{/* The tinted icon tile `KpiCard` uses, in place of the 3px coloured
rail this card used to carry. The rail existed nowhere else in the
console; the tile is the shape the product already uses to mark
what a block is about. */}
<span
aria-hidden
style={{
display: 'grid',
placeItems: 'center',
width: 34,
height: 34,
flex: 'none',
borderRadius: 10,
background: 'color-mix(in oklab, var(--color-brand) 11%, transparent)',
color: 'var(--color-brand)',
}}
>
{problem.bucket === 'now' ? <AlertTriangle size={17} /> : <Monitor size={17} />}
</span>
<VStack gap={1} style={{ minWidth: 0, flex: 1 }}>
<CounterName card={card} labels={labels} isRenaming={isRenaming} setIsRenaming={setIsRenaming} />
<Text type="label" size="lg" weight="semibold">
@@ -290,15 +346,25 @@ function ProblemCard({ card, labels }: { card: BoardCard; labels: CounterLabels
) : null}
<HStack gap={1} align="center" wrap="wrap" style={{ paddingTop: 2 }}>
<SmallButton
<Button
label={showDetails ? 'Hide details' : 'Details'}
variant="secondary"
size="sm"
onClick={() => setShowDetails((open) => !open)}
/>
<SmallButton label="Rename" icon={<Pencil size={11} />} onClick={() => setIsRenaming(true)} />
<Button
label="Rename"
variant="ghost"
size="sm"
icon={<Pencil size={12} />}
onClick={() => setIsRenaming(true)}
/>
{problem.code === 'never-used' || problem.code === 'no-status' ? (
<SmallButton
<Button
label="Hide this counter"
icon={<EyeOff size={11} />}
variant="ghost"
size="sm"
icon={<EyeOff size={12} />}
onClick={() => labels.hide(card.terminalId)}
/>
) : null}
@@ -345,15 +411,22 @@ function CounterName({
placeholder="Counter 2, by the door…"
/>
</div>
<SmallButton
<Button
label="Save"
icon={<Check size={11} />}
variant="secondary"
size="sm"
icon={<Check size={12} />}
onClick={() => {
labels.rename(card.terminalId, draft);
setIsRenaming(false);
}}
/>
<SmallButton label="Cancel" onClick={() => setIsRenaming(false)} />
<Button
label="Cancel"
variant="ghost"
size="sm"
onClick={() => setIsRenaming(false)}
/>
<Text type="body" size="xsm" style={{ color: 'var(--color-ink-4)' }}>
{card.terminalId} is the code printed on your receipts. Saved on this browser only.
</Text>
@@ -454,31 +527,164 @@ function Details({ card }: { card: BoardCard }) {
/* ── The fine list ───────────────────────────────────────────────────────── */
/** A counter with nothing wrong gets one line. Anything more is noise. */
function FineRow({ counter, labels }: { counter: HealthyCounter; labels: CounterLabels }) {
const [isRenaming, setIsRenaming] = useState(false);
return (
<Card padding={0} elevation="low">
<HStack justify="between" align="center" gap={2} padding={2} wrap="wrap">
<CounterName
card={counter}
labels={labels}
isRenaming={isRenaming}
setIsRenaming={setIsRenaming}
/>
{!isRenaming ? (
<Text
type="body"
size="xsm"
color="secondary"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{counter.periodBills} sales · {rupees(counter.periodAmount)}
{counter.status.lastBillAt ? ` · last sale ${clockTime(counter.status.lastBillAt)}` : ''}
/**
* Counters with nothing wrong.
*
* A Table, like every other list in the console — Stores, Store detail, Users,
* Sales. This used to be a stack of hand-built rows, each one a bordered card
* carrying a name and a run-on line of `84 sales · ₹52,100 · last sale 14:22`.
* Four of those read as four more problems; in a table the same four read as
* what they are, which is a quiet list of shops doing their job.
*
* Renaming moves into the row's own control rather than being inline, because
* a text field cannot live inside a table cell without breaking the column.
*/
interface FineRowData extends Record<string, unknown> {
key: string;
name: string;
code: string;
branch: string;
bills: number;
amount: number;
lastSale: string;
terminalId: string;
}
function FineTable({
counters,
labels,
}: {
counters: HealthyCounter[];
labels: CounterLabels;
}) {
const [renaming, setRenaming] = useState<HealthyCounter | null>(null);
const rows: FineRowData[] = counters.map((counter) => ({
key: counter.key,
name: labels.nameFor(counter.terminalId),
// Blank rather than repeated when the counter has not been renamed: the
// name column already IS the code then, and printing it twice on one row
// is the kind of noise that makes a table look machine-generated.
code: labels.isNamed(counter.terminalId) ? counter.terminalId : '',
branch: branchLabel(counter.branchName),
bills: counter.periodBills,
amount: counter.periodAmount,
lastSale: counter.status.lastBillAt ? clockTime(counter.status.lastBillAt) : '—',
terminalId: counter.terminalId,
}));
const columns: TableColumn<FineRowData>[] = [
{
key: 'name',
header: 'Counter',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.name}
</Text>
) : null}
</HStack>
</Card>
{row.code ? (
<Text
type="body"
size="xsm"
style={{ color: 'var(--color-ink-4)', fontFamily: 'var(--font-mono)' }}
>
{row.code}
</Text>
) : null}
</VStack>
),
},
{
key: 'branch',
header: 'Branch',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="sm" color="secondary">
{row.branch}
</Text>
),
},
{
key: 'bills',
header: 'Sales',
align: 'end',
width: { type: 'pixel', value: 90 },
renderCell: (row) => (
<Text type="body" size="sm" hasTabularNumbers>
{row.bills}
</Text>
),
},
{
key: 'amount',
header: 'Taken',
align: 'end',
width: { type: 'pixel', value: 120 },
renderCell: (row) => (
<Text type="label" size="sm" hasTabularNumbers>
{rupees(row.amount)}
</Text>
),
},
{
key: 'lastSale',
header: 'Last sale',
align: 'end',
width: { type: 'pixel', value: 100 },
renderCell: (row) => (
<Text type="body" size="sm" color="secondary" hasTabularNumbers>
{row.lastSale}
</Text>
),
},
{
key: 'rename',
header: '',
align: 'end',
width: { type: 'pixel', value: 100 },
renderCell: (row) => (
<Button
label="Rename"
variant="ghost"
size="sm"
onClick={() => {
const found = counters.find((counter) => counter.key === row.key);
if (found) setRenaming(found);
}}
/>
),
},
];
return (
<VStack gap={1.5}>
{renaming ? (
<Card padding={0} variant="transparent">
<div style={{ ...panelStyle, padding: 14 }}>
<CounterName
card={renaming}
labels={labels}
isRenaming
setIsRenaming={() => setRenaming(null)}
/>
</div>
</Card>
) : null}
<Card padding={0} variant="transparent">
<div style={{ ...panelStyle, overflow: 'hidden' }}>
<Table<FineRowData>
data={rows}
columns={columns}
idKey="key"
density="balanced"
hasHover
dividers="rows"
/>
</div>
</Card>
</VStack>
);
}
@@ -486,14 +692,19 @@ function FineRow({ counter, labels }: { counter: HealthyCounter; labels: Counter
function HiddenList({ labels }: { labels: CounterLabels }) {
return (
<Card padding={0} elevation="low">
<VStack gap={1} padding={2}>
<Card padding={0} variant="transparent">
<VStack gap={1} style={{ ...panelStyle, padding: 16 }}>
{labels.hidden.map((terminalId) => (
<HStack key={terminalId} justify="between" align="center" gap={2}>
<Text type="body" size="xsm" style={{ fontFamily: 'var(--font-mono)' }}>
{labels.nameFor(terminalId)}
</Text>
<SmallButton label="Bring back" onClick={() => labels.unhide(terminalId)} />
<Button
label="Bring back"
variant="ghost"
size="sm"
onClick={() => labels.unhide(terminalId)}
/>
</HStack>
))}
<div style={{ height: 1, background: 'var(--color-line)' }} />
@@ -510,8 +721,8 @@ function HiddenList({ labels }: { labels: CounterLabels }) {
function Empty({ checked }: { checked: string[] }) {
return (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Card padding={0} variant="transparent">
<VStack gap={0.5} style={{ ...panelStyle, padding: 20 }}>
<Text type="label" size="sm" weight="semibold">
No counter has reported yet
</Text>
@@ -529,38 +740,3 @@ function Empty({ checked }: { checked: string[] }) {
);
}
/* ── Bits ────────────────────────────────────────────────────────────────── */
function SmallButton({
label,
icon,
onClick,
}: {
label: string;
icon?: React.ReactNode;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
height: 26,
padding: '0 10px',
borderRadius: 999,
border: '1px solid var(--color-line)',
background: 'transparent',
color: 'var(--color-ink-3)',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
}}
>
{icon}
{label}
</button>
);
}

View File

@@ -56,11 +56,15 @@ export const BUCKET_LABEL: Record<Bucket, string> = {
export const BUCKET_RANK: Record<Bucket, number> = { fine: 0, look: 1, now: 2 };
export const BUCKET_COLOR: Record<Bucket, string> = {
now: 'var(--color-error, #d64545)',
look: 'var(--color-warning, #b7860b)',
fine: 'var(--color-success, #10b981)',
};
/* `BUCKET_COLOR` was here — a red/amber/green ramp the board painted onto a
rail down each card and a dot beside each heading.
It is gone rather than moved. The console does not colour-code severity:
`KpiCard` maps every tone to `--color-brand`, deliberately, and a three-colour
ramp existed on this page and nowhere else in the product. Urgency is already
carried twice over — by the group's name, in plain words, and by the order the
groups appear in — so the colour was a third telling, in the loudest register
available, on the one page a supervisor opens when they are already worried. */
export interface ProblemContext {
/** Evaluation instant, so a whole board is judged against one clock. */

View File

@@ -64,6 +64,28 @@ export interface Board {
* headline that cries wolf is the one thing this page cannot afford.
*/
strandedBills: number;
/**
* What every counter in scope rang in the selected period.
*
* From the sales split, not from the heartbeats: a till reports its own
* running total, and two tills whose clocks disagree would otherwise be added
* together into a figure the books never saw.
*
* There is deliberately NO "amount stuck" twin. The health record carries
* `pending_bills` — a count — and no value, so the money sitting on an
* unreachable till is a number this backend cannot tell us. The page says how
* many sales are waiting and stops there rather than estimating.
*/
takenAmount: number;
takenBills: number;
/**
* How many COUNTERS need attention — not how many cards are on screen.
*
* A counter with a problem in both buckets produces two cards, so counting
* cards against `total` reads "5 of 4 counters". Distinct terminals is the
* only version of this number that can be shown beside a total.
*/
needCounters: number;
isLoading: boolean;
/** Branches whose health read FAILED — not branches with no counters. */
failed: string[];
@@ -243,12 +265,42 @@ export function useTerminalBoard({ branches, range, isHidden }: BoardOptions): B
const inBucket = (bucket: Bucket) => cards.filter((card) => card.problem.bucket === bucket);
// Summed per COUNTER, not per card. A counter with a problem in both
// buckets produces two cards carrying the same period figures, so adding
// the cards up would bill it twice — and it would be the counters in the
// worst shape that got double-counted, which is the wrong direction for a
// number the page leads with.
const takings = new Map<string, { bills: number; amount: number }>();
for (const card of cards) {
takings.set(`${card.branchId}:${card.terminalId}`, {
bills: card.periodBills,
amount: card.periodAmount,
});
}
for (const counter of fine) {
takings.set(`${counter.branchId}:${counter.terminalId}`, {
bills: counter.periodBills,
amount: counter.periodAmount,
});
}
const needing = new Set(cards.map((card) => `${card.branchId}:${card.terminalId}`));
let takenBills = 0;
let takenAmount = 0;
for (const entry of takings.values()) {
takenBills += entry.bills;
takenAmount += entry.amount;
}
return {
now: inBucket('now'),
look: inBucket('look'),
fine,
total,
strandedBills,
takenAmount,
takenBills,
needCounters: needing.size,
isLoading: health.some((query) => query.isLoading),
failed,
checked: branches.map((branch) => branch.locationname),