lat and long
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
338
src/features/store-admin/TenantSheetImportPanel.tsx
Normal file
338
src/features/store-admin/TenantSheetImportPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user