Initial commit
This commit is contained in:
18
src/features/nearle-admin/NearleAdminShell.tsx
Normal file
18
src/features/nearle-admin/NearleAdminShell.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { AppShell, type NavEntry } from '@/components/shell/AppShell';
|
||||
|
||||
/**
|
||||
* Nearle Admin — the platform workspace.
|
||||
*
|
||||
* No "Onboard branch" here on purpose. Opening an outlet is the merchant's
|
||||
* decision about their own business, so it sits in the Store Admin workspace.
|
||||
* We onboard the tenant; they onboard their branches.
|
||||
*/
|
||||
const NAV: readonly NavEntry[] = [
|
||||
{ to: '/nearle/stores', label: 'Stores' },
|
||||
{ to: '/nearle/onboard/tenant', label: 'Onboard tenant' },
|
||||
{ to: '/nearle/catalogue', label: 'Global catalogue' },
|
||||
];
|
||||
|
||||
export function NearleAdminShell() {
|
||||
return <AppShell nav={NAV} home="/nearle/stores" navLabel="Nearle Admin" />;
|
||||
}
|
||||
181
src/features/nearle-admin/import/ImportDrawer.tsx
Normal file
181
src/features/nearle-admin/import/ImportDrawer.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
|
||||
import { useProductCategories } from '@/queries/hooks';
|
||||
|
||||
export interface ImportDrawerProps {
|
||||
product: CatalogueProduct;
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
isSaving: boolean;
|
||||
error: unknown;
|
||||
onClose: () => void;
|
||||
onSubmit: (row: ImportCatalogueProductRequest) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five things the catalogue cannot supply.
|
||||
*
|
||||
* The global catalogue carries a price RANGE and a free-text category, and
|
||||
* neither maps onto a tenant's own pricing or category tree. So category,
|
||||
* subcategory, retail price, cost and tax are collected here before Import can
|
||||
* be enabled — an import with them missing produces a product a store cannot
|
||||
* legally sell.
|
||||
*/
|
||||
export function ImportDrawer({
|
||||
product,
|
||||
tenantid,
|
||||
locationid,
|
||||
isSaving,
|
||||
error,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: ImportDrawerProps) {
|
||||
const { data: categories } = useProductCategories(tenantid);
|
||||
|
||||
const [categoryid, setCategoryid] = useState('');
|
||||
const [subcategoryid, setSubcategoryid] = useState('');
|
||||
const [retailprice, setRetailprice] = useState<number | null>(null);
|
||||
const [productcost, setProductcost] = useState<number | null>(null);
|
||||
const [taxpercent, setTaxpercent] = useState<number | null>(0);
|
||||
const [quantity, setQuantity] = useState<number | null>(0);
|
||||
|
||||
const categoryOptions = (categories ?? []).map((entry) => ({
|
||||
value: String(entry.categoryid),
|
||||
label: entry.categoryname,
|
||||
}));
|
||||
|
||||
const isComplete =
|
||||
categoryid !== '' &&
|
||||
typeof retailprice === 'number' &&
|
||||
retailprice > 0 &&
|
||||
typeof productcost === 'number' &&
|
||||
typeof taxpercent === 'number';
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
className="dialog-panel"
|
||||
padding={0}
|
||||
>
|
||||
<DialogHeader title="Add to store" subtitle="Set what this store will sell it for" onOpenChange={(open) => (open ? undefined : onClose())} hasDivider />
|
||||
<VStack gap={3} padding={3}>
|
||||
<HStack gap={2} align="start">
|
||||
<div
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 12,
|
||||
background: 'var(--color-slate-50)',
|
||||
flex: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{product.images?.[0] ? (
|
||||
<img
|
||||
src={product.images[0]}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<VStack gap={0.5}>
|
||||
<Text type="large" weight="semibold">
|
||||
{product.product_name}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{product.brand} · id {product.id} · {product.size ?? '—'}
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Catalogue range {product.price_range ?? '—'} — a guide, not a price. What you set below
|
||||
is what the store sells at.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label={<span>Category <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
options={categoryOptions}
|
||||
value={categoryid}
|
||||
onChange={setCategoryid}
|
||||
placeholder="Select category…"
|
||||
description="The tenant's own category, not the catalogue's."
|
||||
hasSearch
|
||||
/>
|
||||
<Selector
|
||||
label="Subcategory"
|
||||
options={[{ value: '', label: 'None' }]}
|
||||
value={subcategoryid}
|
||||
onChange={setSubcategoryid}
|
||||
placeholder="Select subcategory…"
|
||||
isDisabled={categoryid === ''}
|
||||
/>
|
||||
<NumberInput label={<span>Retail price (₹) <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={retailprice} onChange={setRetailprice} min={0} />
|
||||
<NumberInput label={<span>Cost (₹) <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={productcost} onChange={setProductcost} min={0} />
|
||||
<NumberInput label="Tax (%)" value={taxpercent} onChange={setTaxpercent} min={0} max={100} />
|
||||
<NumberInput
|
||||
label="Opening stock"
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
min={0}
|
||||
description="Re-importing adds to this rather than replacing it."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<HStack
|
||||
align="center"
|
||||
gap={1}
|
||||
padding={2}
|
||||
style={{
|
||||
background: 'var(--color-error-muted, #fceeee)',
|
||||
borderRadius: 12,
|
||||
color: 'var(--color-error, #d64545)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} />
|
||||
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||
{errorMessage(error)}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack justify="end" gap={1.5}>
|
||||
<Button label="Cancel" variant="secondary" onClick={onClose} />
|
||||
<Button
|
||||
label={isSaving ? 'Adding…' : 'Add to store'}
|
||||
variant="primary"
|
||||
isLoading={isSaving}
|
||||
isDisabled={!isComplete}
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
tenantid,
|
||||
locationid,
|
||||
brand: product.brand,
|
||||
catalogueid: product.id,
|
||||
categoryid: Number(categoryid),
|
||||
subcategoryid: subcategoryid === '' ? 0 : Number(subcategoryid),
|
||||
quantity: quantity ?? 0,
|
||||
stocktype: 'in',
|
||||
status: 'available',
|
||||
retailprice: retailprice ?? 0,
|
||||
productcost: productcost ?? 0,
|
||||
taxpercent: taxpercent ?? 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
320
src/features/nearle-admin/import/SheetImportPanel.tsx
Normal file
320
src/features/nearle-admin/import/SheetImportPanel.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { FileInput } from '@astryxdesign/core/FileInput';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet } from 'lucide-react';
|
||||
import { importSheetProducts, type SheetImportResult, type SheetProductRow } from '@/api/products';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { downloadTemplate, parseProductSheet, type ParsedSheet } from './parseProductSheet';
|
||||
|
||||
interface PreviewRow extends Record<string, unknown> {
|
||||
productname: string;
|
||||
productsku: string;
|
||||
categoryid: number;
|
||||
retailprice: number;
|
||||
productcost: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface SheetImportPanelProps {
|
||||
tenantid: number | undefined;
|
||||
locationid: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The spreadsheet import path.
|
||||
*
|
||||
* Staged rather than one-shot — upload, parse, show what is wrong, then commit —
|
||||
* because the underlying calls are N creates with no transaction and no batch,
|
||||
* so a row that fails on the server fails alone and has to be recoverable.
|
||||
*
|
||||
* It is also not idempotent, and cannot be made so from the client: nothing on
|
||||
* the server dedupes on SKU. Re-uploading the same file creates the products
|
||||
* twice, so that is said out loud before the button rather than discovered
|
||||
* afterwards.
|
||||
*/
|
||||
export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
const [result, setResult] = useState<SheetImportResult | null>(null);
|
||||
|
||||
const hasTarget = Boolean(tenantid && locationid);
|
||||
|
||||
async function handleFile(next: File | File[] | null) {
|
||||
const chosen = Array.isArray(next) ? (next[0] ?? null) : next;
|
||||
setFile(chosen);
|
||||
setParsed(null);
|
||||
setParseError(null);
|
||||
setResult(null);
|
||||
if (!chosen) return;
|
||||
|
||||
try {
|
||||
setParsed(await parseProductSheet(chosen));
|
||||
} catch (cause) {
|
||||
setParseError(errorMessage(cause));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!parsed || !tenantid || !locationid) return;
|
||||
setProgress({ done: 0, total: parsed.rows.length });
|
||||
try {
|
||||
const outcome = await importSheetProducts({
|
||||
tenantid,
|
||||
locationid,
|
||||
rows: parsed.rows,
|
||||
onProgress: (done, total) => setProgress({ done, total }),
|
||||
});
|
||||
setResult(outcome);
|
||||
} catch (cause) {
|
||||
setParseError(errorMessage(cause));
|
||||
} finally {
|
||||
setProgress(null);
|
||||
}
|
||||
}
|
||||
|
||||
const previewColumns: TableColumn<PreviewRow>[] = [
|
||||
{
|
||||
key: 'productname',
|
||||
header: 'Product',
|
||||
width: { type: 'proportional', value: 3 },
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.productname}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{row.productsku}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'categoryid',
|
||||
header: 'Category',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => <Text type="body" size="sm" hasTabularNumbers>{row.categoryid}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'retailprice',
|
||||
header: 'Retail',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => (
|
||||
<Text type="label" size="sm" hasTabularNumbers>
|
||||
₹{row.retailprice}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'productcost',
|
||||
header: 'Cost',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" color="secondary" hasTabularNumbers>
|
||||
₹{row.productcost}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'quantity',
|
||||
header: 'Opening stock',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 130 },
|
||||
renderCell: (row) => <Text type="body" size="sm" hasTabularNumbers>{row.quantity}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
const preview: PreviewRow[] = (parsed?.rows ?? []).slice(0, 25).map((row: SheetProductRow) => ({
|
||||
productname: row.productname,
|
||||
productsku: row.productsku,
|
||||
categoryid: row.categoryid,
|
||||
retailprice: row.retailprice,
|
||||
productcost: row.productcost,
|
||||
quantity: row.quantity,
|
||||
}));
|
||||
|
||||
if (result) {
|
||||
const isClean = result.failures.length === 0;
|
||||
return (
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
{isClean ? (
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
) : (
|
||||
<AlertTriangle size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
||||
)}
|
||||
<Text type="large" weight="semibold">
|
||||
{result.created} product{result.created === 1 ? '' : 's'} imported
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<Text type="body" color="secondary">
|
||||
{result.linked} linked to the store, {result.stocked} given opening stock.
|
||||
{isClean ? '' : ` ${result.failures.length} row(s) did not make it.`}
|
||||
</Text>
|
||||
|
||||
{!isClean ? (
|
||||
<VStack gap={1}>
|
||||
{result.failures.slice(0, 10).map((failure, index) => (
|
||||
<HStack key={`${failure.row.productsku}-${index}`} gap={1} align="center">
|
||||
<Badge variant="error" label={failure.row.productsku || '—'} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{failure.reason}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<HStack>
|
||||
<Button
|
||||
label="Import another file"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
setParsed(null);
|
||||
setFile(null);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={2}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Upload the tenant's product list"
|
||||
note="parsed in the browser, then written row by row"
|
||||
action={
|
||||
<Button
|
||||
label="Download template"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Download size={14} />}
|
||||
onClick={() => void downloadTemplate()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
label="Product workbook"
|
||||
value={file}
|
||||
onChange={handleFile}
|
||||
accept=".xlsx,.xls,.csv"
|
||||
mode="dropzone"
|
||||
placeholder="Drop an .xlsx or .csv here"
|
||||
description="Required columns: productname, productsku, categoryid, retailprice, productcost. The catalogue's category names do not map to a tenant's own ids, so categoryid must be in the file."
|
||||
isDisabled={!hasTarget}
|
||||
/>
|
||||
|
||||
{parseError ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{parseError}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{parsed ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="What is in the file"
|
||||
note={`${parsed.totalRows} rows read · ${parsed.rows.length} ready · ${parsed.issues.length} to fix`}
|
||||
/>
|
||||
|
||||
{parsed.unmappedColumns.length > 0 ? (
|
||||
<HStack gap={1} wrap="wrap" align="center">
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Ignored columns:
|
||||
</Text>
|
||||
{parsed.unmappedColumns.map((column) => (
|
||||
<Badge key={column} variant="neutral" label={column} />
|
||||
))}
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
{parsed.issues.length > 0 ? (
|
||||
<VStack
|
||||
gap={0.5}
|
||||
padding={2}
|
||||
style={{ background: 'var(--color-warning-muted, #fdf6e3)', borderRadius: 12 }}
|
||||
>
|
||||
<HStack align="center" gap={1}>
|
||||
<AlertTriangle size={15} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
||||
<Text type="label" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
||||
{parsed.issues.length} row{parsed.issues.length === 1 ? '' : 's'} will be skipped
|
||||
</Text>
|
||||
</HStack>
|
||||
{parsed.issues.slice(0, 8).map((issue, index) => (
|
||||
<Text key={index} type="body" size="xsm" color="secondary">
|
||||
Row {issue.line} · {issue.field}: {issue.message}
|
||||
</Text>
|
||||
))}
|
||||
{parsed.issues.length > 8 ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
…and {parsed.issues.length - 8} more.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{preview.length > 0 ? (
|
||||
<Table<PreviewRow>
|
||||
data={preview}
|
||||
columns={previewColumns}
|
||||
idKey="productsku"
|
||||
density="compact"
|
||||
dividers="rows"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{progress ? (
|
||||
<ProgressBar
|
||||
label="Importing"
|
||||
value={progress.done}
|
||||
max={progress.total}
|
||||
hasValueLabel
|
||||
formatValueLabel={(value, max) => `${value} of ${max}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<HStack align="center" gap={1}>
|
||||
<FileSpreadsheet size={15} style={{ color: 'var(--color-slate-400)' }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Nothing dedupes on SKU — importing this file twice creates every product twice.
|
||||
</Text>
|
||||
</HStack>
|
||||
<Button
|
||||
label={progress ? 'Importing…' : `Import ${parsed.rows.length} products`}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={Boolean(progress)}
|
||||
isDisabled={!hasTarget || parsed.rows.length === 0}
|
||||
onClick={handleImport}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
224
src/features/nearle-admin/import/parseProductSheet.ts
Normal file
224
src/features/nearle-admin/import/parseProductSheet.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Parsing and validating an uploaded product workbook.
|
||||
*
|
||||
* All of this happens in the browser: the backend does no spreadsheet handling
|
||||
* at all, so a sheet becomes N create calls, and every row that is going to fail
|
||||
* should be caught here rather than halfway through those calls.
|
||||
*/
|
||||
|
||||
import type { SheetProductRow } from '@/api/products';
|
||||
|
||||
/**
|
||||
* `xlsx` is around 400kB and only the spreadsheet path ever touches it, so it is
|
||||
* imported on demand rather than shipped to everyone who opens the console.
|
||||
*/
|
||||
async function loadXlsx() {
|
||||
return import('xlsx');
|
||||
}
|
||||
|
||||
/** What a column may be called in the wild, lowercased and stripped. */
|
||||
const COLUMN_ALIASES: Record<keyof SheetProductRow, string[]> = {
|
||||
productname: ['productname', 'product', 'name', 'itemname', 'description'],
|
||||
productsku: ['productsku', 'sku', 'code', 'itemcode', 'barcode'],
|
||||
categoryid: ['categoryid', 'category'],
|
||||
subcategoryid: ['subcategoryid', 'subcategory'],
|
||||
retailprice: ['retailprice', 'price', 'mrp', 'sellingprice'],
|
||||
productcost: ['productcost', 'cost', 'purchaseprice', 'costprice'],
|
||||
taxpercent: ['taxpercent', 'tax', 'gst', 'gstpercent'],
|
||||
quantity: ['quantity', 'qty', 'stock', 'openingstock'],
|
||||
productunit: ['productunit', 'unit', 'uom'],
|
||||
unitvalue: ['unitvalue', 'size', 'packsize'],
|
||||
productbrand: ['productbrand', 'brand'],
|
||||
productdesc: ['productdesc', 'productdescription', 'details'],
|
||||
};
|
||||
|
||||
export interface RowIssue {
|
||||
/** 1-based, and counting the header — the number the user sees in Excel. */
|
||||
line: number;
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ParsedSheet {
|
||||
rows: SheetProductRow[];
|
||||
issues: RowIssue[];
|
||||
/** Header cells that matched nothing. Surfaced so a rename is obvious. */
|
||||
unmappedColumns: string[];
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
function normalise(header: string): string {
|
||||
return header.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const cleaned = value.replace(/[^0-9.-]/g, '');
|
||||
if (cleaned === '') return null;
|
||||
const parsed = Number(cleaned);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the first sheet of a workbook into validated rows.
|
||||
*
|
||||
* Every problem is collected rather than thrown on the first one: an importer
|
||||
* that stops at row 3 of 400 makes the user run it 400 times to find out what
|
||||
* is wrong with their file.
|
||||
*/
|
||||
export async function parseProductSheet(file: File): Promise<ParsedSheet> {
|
||||
const XLSX = await loadXlsx();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(buffer, { type: 'array' });
|
||||
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
if (!firstSheetName) {
|
||||
return { rows: [], issues: [], unmappedColumns: [], totalRows: 0 };
|
||||
}
|
||||
|
||||
const sheet = workbook.Sheets[firstSheetName];
|
||||
if (!sheet) return { rows: [], issues: [], unmappedColumns: [], totalRows: 0 };
|
||||
|
||||
const raw = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' });
|
||||
if (raw.length === 0) {
|
||||
return { rows: [], issues: [], unmappedColumns: [], totalRows: 0 };
|
||||
}
|
||||
|
||||
// Map the file's headers onto our field names once, not per row.
|
||||
const headers = Object.keys(raw[0] ?? {});
|
||||
const headerToField = new Map<string, keyof SheetProductRow>();
|
||||
const matchedHeaders = new Set<string>();
|
||||
|
||||
for (const header of headers) {
|
||||
const key = normalise(header);
|
||||
for (const [field, aliases] of Object.entries(COLUMN_ALIASES)) {
|
||||
if (aliases.includes(key)) {
|
||||
headerToField.set(header, field as keyof SheetProductRow);
|
||||
matchedHeaders.add(header);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unmappedColumns = headers.filter((header) => !matchedHeaders.has(header));
|
||||
const rows: SheetProductRow[] = [];
|
||||
const issues: RowIssue[] = [];
|
||||
|
||||
raw.forEach((record, index) => {
|
||||
const line = index + 2; // +1 for zero-index, +1 for the header row
|
||||
const picked: Partial<Record<keyof SheetProductRow, unknown>> = {};
|
||||
|
||||
for (const [header, field] of headerToField) {
|
||||
picked[field] = record[header];
|
||||
}
|
||||
|
||||
const productname = String(picked.productname ?? '').trim();
|
||||
const productsku = String(picked.productsku ?? '').trim();
|
||||
const retailprice = toNumber(picked.retailprice);
|
||||
const productcost = toNumber(picked.productcost);
|
||||
const taxpercent = toNumber(picked.taxpercent) ?? 0;
|
||||
const quantity = toNumber(picked.quantity) ?? 0;
|
||||
const categoryid = toNumber(picked.categoryid) ?? 0;
|
||||
const subcategoryid = toNumber(picked.subcategoryid) ?? 0;
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (!productname) {
|
||||
issues.push({ line, field: 'productname', message: 'Product name is missing' });
|
||||
isValid = false;
|
||||
}
|
||||
if (!productsku) {
|
||||
// Without a SKU the created product cannot be found again, and the
|
||||
// create endpoint does not return its new id — so the row would import
|
||||
// and then be impossible to link to the store.
|
||||
issues.push({ line, field: 'productsku', message: 'SKU is missing — the row cannot be linked to a store without one' });
|
||||
isValid = false;
|
||||
}
|
||||
if (retailprice === null || retailprice <= 0) {
|
||||
issues.push({ line, field: 'retailprice', message: 'Retail price is missing or not a number' });
|
||||
isValid = false;
|
||||
}
|
||||
if (productcost === null) {
|
||||
issues.push({ line, field: 'productcost', message: 'Cost is missing or not a number' });
|
||||
isValid = false;
|
||||
}
|
||||
if (categoryid <= 0) {
|
||||
issues.push({ line, field: 'categoryid', message: 'Category id is missing — the tenant has no mapping from a category name' });
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!isValid) return;
|
||||
|
||||
rows.push({
|
||||
productname,
|
||||
productsku,
|
||||
categoryid,
|
||||
subcategoryid,
|
||||
retailprice: retailprice as number,
|
||||
productcost: productcost as number,
|
||||
taxpercent,
|
||||
quantity,
|
||||
productunit: String(picked.productunit ?? '').trim() || undefined,
|
||||
unitvalue: String(picked.unitvalue ?? '').trim() || undefined,
|
||||
productbrand: String(picked.productbrand ?? '').trim() || undefined,
|
||||
productdesc: String(picked.productdesc ?? '').trim() || undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// Duplicate SKUs inside one file are the user's own error and will produce
|
||||
// duplicate products, since nothing on the server dedupes.
|
||||
const seen = new Set<string>();
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (seen.has(row.productsku)) {
|
||||
issues.push({
|
||||
line: index + 2,
|
||||
field: 'productsku',
|
||||
message: `SKU ${row.productsku} appears more than once in this file`,
|
||||
});
|
||||
}
|
||||
seen.add(row.productsku);
|
||||
}
|
||||
|
||||
return { rows, issues, unmappedColumns, totalRows: raw.length };
|
||||
}
|
||||
|
||||
/** The template handed to a tenant, so the columns arrive named correctly. */
|
||||
export async function downloadTemplate(): Promise<void> {
|
||||
const XLSX = await loadXlsx();
|
||||
const headers = [
|
||||
'productname',
|
||||
'productsku',
|
||||
'categoryid',
|
||||
'subcategoryid',
|
||||
'retailprice',
|
||||
'productcost',
|
||||
'taxpercent',
|
||||
'quantity',
|
||||
'productunit',
|
||||
'unitvalue',
|
||||
'productbrand',
|
||||
'productdesc',
|
||||
];
|
||||
const example = {
|
||||
productname: 'Aachi Chilli Powder 100g',
|
||||
productsku: 'AACHI-CHILLI-100',
|
||||
categoryid: 1,
|
||||
subcategoryid: 0,
|
||||
retailprice: 35,
|
||||
productcost: 28,
|
||||
taxpercent: 5,
|
||||
quantity: 24,
|
||||
productunit: 'g',
|
||||
unitvalue: '100',
|
||||
productbrand: 'Aachi',
|
||||
productdesc: '',
|
||||
};
|
||||
|
||||
const sheet = XLSX.utils.json_to_sheet([example], { header: headers });
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, 'Products');
|
||||
XLSX.writeFile(workbook, 'nearle-product-template.xlsx');
|
||||
}
|
||||
295
src/features/nearle-admin/pages/GlobalCataloguePage.tsx
Normal file
295
src/features/nearle-admin/pages/GlobalCataloguePage.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Check, PackagePlus, Upload } from 'lucide-react';
|
||||
import { catalogueKey } from '@/api/catalogue';
|
||||
import { productsApi } from '@/api/products';
|
||||
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import {
|
||||
useCatalogueBrands,
|
||||
useCatalogueProducts,
|
||||
useImportedRefs,
|
||||
useTenants,
|
||||
} from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { ImportDrawer } from '../import/ImportDrawer';
|
||||
import { SheetImportPanel } from '../import/SheetImportPanel';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
|
||||
/**
|
||||
* The global catalogue browser, and the place both import paths start.
|
||||
*
|
||||
* The list loads with NO brand filter — brand, category and keyword are filters
|
||||
* applied on top of an already-visible list, not a prerequisite to seeing one.
|
||||
* Products already imported by the selected tenant are badged rather than
|
||||
* hidden, because re-importing tops up stock and is a legitimate thing to do.
|
||||
*/
|
||||
export function GlobalCataloguePage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const tenantid = Number(params.get('tenantid') ?? 0) || undefined;
|
||||
const locationid = Number(params.get('locationid') ?? 0) || undefined;
|
||||
|
||||
const [brand, setBrand] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [selected, setSelected] = useState<CatalogueProduct | null>(null);
|
||||
const [mode, setMode] = useState<'catalogue' | 'sheet'>('catalogue');
|
||||
|
||||
const { data: tenants } = useTenants();
|
||||
const { data: brands } = useCatalogueBrands();
|
||||
const {
|
||||
data: products,
|
||||
isLoading,
|
||||
error,
|
||||
} = useCatalogueProducts({ brand: brand || undefined, keyword: keyword || undefined, pagesize: 48 });
|
||||
const { data: importedRefs } = useImportedRefs(tenantid);
|
||||
|
||||
/** `brand:catalogueid` — both halves, because a bare id repeats across brands. */
|
||||
const importedKeys = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const ref of importedRefs ?? []) set.add(catalogueKey(ref));
|
||||
return set;
|
||||
}, [importedRefs]);
|
||||
|
||||
const tenantOptions = useMemo(() => {
|
||||
if (!tenants) return [];
|
||||
const seen = new Map<number, { name: string; locationid: number }>();
|
||||
for (const tenant of tenants as TenantInfo[]) {
|
||||
if (!seen.has(tenant.tenantid)) {
|
||||
seen.set(tenant.tenantid, { name: tenant.tenantname, locationid: tenant.locationid });
|
||||
}
|
||||
}
|
||||
return [...seen.entries()].map(([id, value]) => ({ value: String(id), label: value.name }));
|
||||
}, [tenants]);
|
||||
|
||||
const brandOptions = useMemo(
|
||||
() => [
|
||||
{ value: '', label: 'All brands' },
|
||||
...(brands ?? []).map((entry) => ({
|
||||
value: entry.brand,
|
||||
label: `${entry.brand} (${entry.product_count})`,
|
||||
})),
|
||||
],
|
||||
[brands],
|
||||
);
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (rows: ImportCatalogueProductRequest[]) => productsApi.importFromCatalogue(rows),
|
||||
onSuccess: async () => {
|
||||
// Both the imported-refs lookup and the store catalogue go stale together.
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.catalogue.all }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.products.all }),
|
||||
]);
|
||||
setSelected(null);
|
||||
},
|
||||
});
|
||||
|
||||
function chooseTenant(value: string) {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('tenantid', value);
|
||||
const tenant = (tenants as TenantInfo[] | undefined)?.find(
|
||||
(entry) => String(entry.tenantid) === value,
|
||||
);
|
||||
if (tenant) next.set('locationid', String(tenant.locationid));
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
const hasTarget = Boolean(tenantid && locationid);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Global catalogue"
|
||||
count={products ? `${products.length} shown` : undefined}
|
||||
description="Browse the FMCG catalogue and stock a store from it — or upload the tenant's own product list instead."
|
||||
actions={
|
||||
<HStack gap={1}>
|
||||
<Button
|
||||
label="From catalogue"
|
||||
variant={mode === 'catalogue' ? 'primary' : 'secondary'}
|
||||
icon={<PackagePlus size={15} />}
|
||||
onClick={() => setMode('catalogue')}
|
||||
/>
|
||||
<Button
|
||||
label="From spreadsheet"
|
||||
variant={mode === 'sheet' ? 'primary' : 'secondary'}
|
||||
icon={<Upload size={15} />}
|
||||
onClick={() => setMode('sheet')}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Which store is being stocked. Every import call needs both ids, so this
|
||||
is asked once, up front, rather than per product. */}
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader title="Stocking" note="which store these products go to" />
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label="Tenant"
|
||||
options={tenantOptions}
|
||||
value={tenantid ? String(tenantid) : ''}
|
||||
onChange={chooseTenant}
|
||||
placeholder="Select tenant…"
|
||||
hasSearch
|
||||
/>
|
||||
<TextInput
|
||||
label="Location id"
|
||||
value={locationid ? String(locationid) : ''}
|
||||
onChange={(value) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('locationid', value);
|
||||
setParams(next, { replace: true });
|
||||
}}
|
||||
description="Defaults to the tenant's first outlet."
|
||||
/>
|
||||
</div>
|
||||
{!hasTarget ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
||||
Pick a tenant before importing — every import call is scoped to a tenant and a
|
||||
location, and the backend rejects one without both.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{mode === 'sheet' ? (
|
||||
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
|
||||
) : (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Products"
|
||||
note="filters narrow an already-visible list"
|
||||
action={
|
||||
<HStack gap={1} wrap="wrap">
|
||||
<div style={{ width: 190 }}>
|
||||
<Selector
|
||||
label="Brand"
|
||||
isLabelHidden
|
||||
options={brandOptions}
|
||||
value={brand}
|
||||
onChange={setBrand}
|
||||
placeholder="All brands"
|
||||
hasSearch
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 240 }}>
|
||||
<TextInput
|
||||
label="Search catalogue"
|
||||
isLabelHidden
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search catalogue…"
|
||||
hasClear
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataState
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
isEmpty={(products ?? []).length === 0}
|
||||
emptyTitle="Nothing matches those filters"
|
||||
emptyDescription="Clear the brand or keyword to see the full catalogue."
|
||||
>
|
||||
<div className="card-grid">
|
||||
{(products ?? []).map((product) => {
|
||||
const isImported = importedKeys.has(catalogueKey(product));
|
||||
return (
|
||||
<Card key={`${product.brand}:${product.id}`} padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<div
|
||||
style={{
|
||||
aspectRatio: '1',
|
||||
background: 'var(--color-slate-50)',
|
||||
borderRadius: 12,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{product.images?.[0] ? (
|
||||
<img
|
||||
src={product.images[0]}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<PackagePlus size={26} style={{ color: 'var(--color-slate-300)' }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold" maxLines={2}>
|
||||
{product.product_name}
|
||||
</Text>
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
{product.brand} · {product.size ?? '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<HStack justify="between" align="center">
|
||||
{/* A RANGE, not a price. The store sets the real one at import. */}
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{product.price_range ?? '—'}
|
||||
</Text>
|
||||
{isImported ? (
|
||||
<Badge variant="success" label="Imported" icon={<Check size={11} />} />
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
label={isImported ? 'Top up stock' : 'Add to store'}
|
||||
variant={isImported ? 'secondary' : 'primary'}
|
||||
size="sm"
|
||||
width="100%"
|
||||
isDisabled={!hasTarget}
|
||||
onClick={() => setSelected(product)}
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DataState>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{selected && hasTarget ? (
|
||||
<ImportDrawer
|
||||
product={selected}
|
||||
tenantid={tenantid as number}
|
||||
locationid={locationid as number}
|
||||
isSaving={importMutation.isPending}
|
||||
error={importMutation.error}
|
||||
onClose={() => setSelected(null)}
|
||||
onSubmit={(row) => importMutation.mutate([row])}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
279
src/features/nearle-admin/pages/OnboardTenantPage.tsx
Normal file
279
src/features/nearle-admin/pages/OnboardTenantPage.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertCircle, Building2, CheckCircle2, MapPin } from 'lucide-react';
|
||||
import { tenantsApi, type CreateTenantRequest } from '@/api/tenants';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { PageBody } from '@/components/PageBody';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
|
||||
const BUSINESS_CATEGORIES = [
|
||||
{ value: '1', label: 'Grocery & Daily' },
|
||||
{ value: '2', label: 'Fresh & Produce' },
|
||||
{ value: '3', label: 'Pharmacy' },
|
||||
{ value: '4', label: 'Bakery' },
|
||||
];
|
||||
|
||||
interface FormState {
|
||||
tenantname: string;
|
||||
companyname: string;
|
||||
primarycontact: string;
|
||||
primaryemail: string;
|
||||
locationname: string;
|
||||
categoryid: string;
|
||||
address: string;
|
||||
suburb: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
tenantname: '',
|
||||
companyname: '',
|
||||
primarycontact: '',
|
||||
primaryemail: '',
|
||||
locationname: '',
|
||||
categoryid: '1',
|
||||
address: '',
|
||||
suburb: '',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Provision a new merchant tenant.
|
||||
*
|
||||
* This registers the enterprise, its first outlet, and spawns the primary
|
||||
* Administrator account — account creation is a side effect of provisioning
|
||||
* here, because that is what the backend does. There is no separate invite.
|
||||
*
|
||||
* Products are NOT collected on this form. Once the tenant exists it has a
|
||||
* tenantid and a locationid, and only then can either import path run: both
|
||||
* `importcatalogueproduct` and `createproductlocation` require the pair. So the
|
||||
* form hands off to the catalogue on success rather than pretending the two
|
||||
* steps are one.
|
||||
*/
|
||||
export function OnboardTenantPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function set<K extends keyof FormState>(key: K) {
|
||||
return (value: string) => setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: CreateTenantRequest) => tenantsApi.createTenant(body),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all });
|
||||
},
|
||||
onError: (cause) => setError(errorMessage(cause)),
|
||||
});
|
||||
|
||||
const isComplete =
|
||||
form.tenantname.trim() !== '' &&
|
||||
form.companyname.trim() !== '' &&
|
||||
form.primarycontact.trim() !== '' &&
|
||||
form.primaryemail.trim() !== '' &&
|
||||
form.locationname.trim() !== '' &&
|
||||
form.address.trim() !== '' &&
|
||||
form.city.trim() !== '' &&
|
||||
form.postcode.trim() !== '';
|
||||
|
||||
function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
tenantname: form.tenantname.trim(),
|
||||
companyname: form.companyname.trim(),
|
||||
primarycontact: form.primarycontact.trim(),
|
||||
primaryemail: form.primaryemail.trim(),
|
||||
locationname: form.locationname.trim(),
|
||||
categoryid: Number(form.categoryid),
|
||||
address: form.address.trim(),
|
||||
suburb: form.suburb.trim(),
|
||||
city: form.city.trim(),
|
||||
state: form.state.trim(),
|
||||
postcode: form.postcode.trim(),
|
||||
status: 'Active',
|
||||
});
|
||||
}
|
||||
|
||||
if (mutation.isSuccess) {
|
||||
const created = mutation.data;
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Tenant provisioned"
|
||||
description="The enterprise is registered and its primary Administrator account has been created."
|
||||
/>
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
<Text type="large" weight="semibold">
|
||||
{form.tenantname} is live
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" color="secondary">
|
||||
Its first outlet, {form.locationname}, has been commissioned. The next step is
|
||||
stocking the catalogue — pick products from the global catalogue, or upload the
|
||||
tenant's own list as a spreadsheet.
|
||||
</Text>
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<Button
|
||||
label="Stock from global catalogue"
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/nearle/catalogue?tenantid=${created?.tenantid ?? ''}&locationid=${created?.locationid ?? ''}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
label="Back to stores"
|
||||
variant="secondary"
|
||||
onClick={() => navigate('/nearle/stores')}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageBody measure="reading">
|
||||
<PageHeader
|
||||
title="Onboard tenant"
|
||||
description="Register a merchant group, commission its first outlet, and create its Administrator account."
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<VStack gap={3}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Business"
|
||||
note="who the merchant is"
|
||||
action={<Building2 size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div className="form-grid">
|
||||
<TextInput
|
||||
label={<span>Merchant name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.tenantname}
|
||||
onChange={set('tenantname')}
|
||||
placeholder="e.g. Kaveri Groceries"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Company registered name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.companyname}
|
||||
onChange={set('companyname')}
|
||||
placeholder="e.g. Kaveri Retail Pvt. Ltd."
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Primary phone <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.primarycontact}
|
||||
onChange={set('primarycontact')}
|
||||
placeholder="9876543210"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Primary admin email <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
type="email"
|
||||
value={form.primaryemail}
|
||||
onChange={set('primaryemail')}
|
||||
placeholder="admin@kaveri.com"
|
||||
description="The Administrator account is created against this address."
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>First outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.locationname}
|
||||
onChange={set('locationname')}
|
||||
placeholder="e.g. Kaveri RS Puram"
|
||||
/>
|
||||
<Selector
|
||||
label="Business category"
|
||||
options={BUSINESS_CATEGORIES}
|
||||
value={form.categoryid}
|
||||
onChange={set('categoryid')}
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Head office"
|
||||
note="where the business is registered"
|
||||
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Street address <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.address}
|
||||
onChange={set('address')}
|
||||
placeholder="e.g. 12, Avinashi Road"
|
||||
/>
|
||||
<div className="form-grid-4">
|
||||
<TextInput label="Suburb" value={form.suburb} onChange={set('suburb')} placeholder="e.g. Peelamedu" />
|
||||
<TextInput label={<span>City <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.city} onChange={set('city')} />
|
||||
<TextInput label={<span>State <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.state} onChange={set('state')} />
|
||||
<TextInput
|
||||
label={<span>Postcode <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.postcode}
|
||||
onChange={set('postcode')}
|
||||
placeholder="641004"
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{error ? (
|
||||
<HStack
|
||||
align="center"
|
||||
gap={1}
|
||||
padding={2}
|
||||
style={{
|
||||
background: 'var(--color-error-muted, #fceeee)',
|
||||
borderRadius: 12,
|
||||
color: 'var(--color-error, #d64545)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={17} />
|
||||
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||
{error}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Products are added after the tenant exists — both import paths need its tenantid and
|
||||
locationid.
|
||||
</Text>
|
||||
<Button
|
||||
label={mutation.isPending ? 'Provisioning…' : 'Provision tenant'}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={mutation.isPending}
|
||||
isDisabled={!isComplete}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</form>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
273
src/features/nearle-admin/pages/StoreDetailPage.tsx
Normal file
273
src/features/nearle-admin/pages/StoreDetailPage.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { IndianRupee, PackageCheck, ShoppingCart, Store, TriangleAlert } from 'lucide-react';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { Freshness } from '@/components/Freshness';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useLocationSummary, useOrderSummary, useTenantLocations, useTenants } from '@/queries/hooks';
|
||||
import type { LocationOrderSummary, TenantInfo, TenantLocation } from '@/api/types';
|
||||
|
||||
interface BranchRow extends Record<string, unknown> {
|
||||
locationid: number;
|
||||
locationname: string;
|
||||
city: string;
|
||||
hours: string;
|
||||
radius: string;
|
||||
status: string;
|
||||
orders: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
function money(value: number | undefined): string {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return '₹0';
|
||||
return `₹${value.toLocaleString('en-IN')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One tenant: its branches, and how each is performing.
|
||||
*
|
||||
* Order figures come from `/orders/getlocationsummary`, which is scoped to the
|
||||
* tenant and returns one row per branch. Counter sales are NOT folded in here:
|
||||
* the POS endpoints take a single required locationid, so a tenant-wide till
|
||||
* figure would mean one request per branch, and a blended number would be
|
||||
* eventually consistent in a way this page cannot honestly caption. Branch-level
|
||||
* POS lives on the branch screen instead.
|
||||
*/
|
||||
export function StoreDetailPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const tenantid = Number(tenantId ?? 0) || undefined;
|
||||
|
||||
const { data: tenants } = useTenants();
|
||||
const { data: locations, isLoading, error } = useTenantLocations(tenantid);
|
||||
const summary = useLocationSummary(tenantid);
|
||||
const orders = useOrderSummary(tenantid);
|
||||
|
||||
const tenant = useMemo(
|
||||
() => (tenants as TenantInfo[] | undefined)?.find((entry) => entry.tenantid === tenantid),
|
||||
[tenants, tenantid],
|
||||
);
|
||||
|
||||
const rows = useMemo<BranchRow[]>(() => {
|
||||
const perLocation = new Map<number, LocationOrderSummary>();
|
||||
for (const entry of summary.data ?? []) {
|
||||
if (typeof entry.locationid === 'number') perLocation.set(entry.locationid, entry);
|
||||
}
|
||||
|
||||
return ((locations ?? []) as TenantLocation[]).map((branch) => {
|
||||
const stats = perLocation.get(branch.locationid);
|
||||
return {
|
||||
locationid: branch.locationid,
|
||||
locationname: branch.locationname,
|
||||
city: [branch.suburb, branch.city].filter(Boolean).join(', '),
|
||||
hours:
|
||||
branch.opentime && branch.closetime ? `${branch.opentime}–${branch.closetime}` : '—',
|
||||
radius: branch.deliveryradius ? `${(branch.deliveryradius / 1000).toFixed(1)} km` : '—',
|
||||
status: branch.status ?? 'Unknown',
|
||||
orders: Number(stats?.totalorders ?? 0),
|
||||
revenue: Number(stats?.revenue ?? 0),
|
||||
};
|
||||
});
|
||||
}, [locations, summary.data]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const branches = rows.length;
|
||||
const active = rows.filter((row) => row.status.toLowerCase() === 'active').length;
|
||||
const totalOrders = rows.reduce((sum, row) => sum + row.orders, 0);
|
||||
const totalRevenue = rows.reduce((sum, row) => sum + row.revenue, 0);
|
||||
const cancelled = Number(orders.data?.cancelled ?? 0);
|
||||
return { branches, active, totalOrders, totalRevenue, cancelled };
|
||||
}, [rows, orders.data]);
|
||||
|
||||
const columns: TableColumn<BranchRow>[] = [
|
||||
{
|
||||
key: 'locationname',
|
||||
header: 'Branch',
|
||||
width: { type: 'proportional', value: 3 },
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.locationname}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{row.city || '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'hours',
|
||||
header: 'Hours',
|
||||
width: { type: 'pixel', value: 130 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{row.hours}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'radius',
|
||||
header: 'Radius',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 90 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" hasTabularNumbers>
|
||||
{row.radius}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'orders',
|
||||
header: 'Orders',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => (
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
hasTabularNumbers
|
||||
color={row.orders === 0 ? 'disabled' : 'primary'}
|
||||
>
|
||||
{row.orders}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
header: 'Revenue',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 120 },
|
||||
renderCell: (row) => (
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
hasTabularNumbers
|
||||
color={row.revenue === 0 ? 'disabled' : 'primary'}
|
||||
>
|
||||
{money(row.revenue)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 110 },
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
|
||||
label={row.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title={tenant?.tenantname ?? 'Tenant'}
|
||||
count={tenant?.companyname}
|
||||
description={`${totals.branches} branch${totals.branches === 1 ? '' : 'es'} · ${
|
||||
tenant?.city ?? '—'
|
||||
}`}
|
||||
isLive
|
||||
actions={
|
||||
<HStack gap={1}>
|
||||
<Button
|
||||
label="Stock catalogue"
|
||||
variant="secondary"
|
||||
href={`/nearle/catalogue?tenantid=${tenantid ?? ''}`}
|
||||
as={Link}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Branches"
|
||||
value={String(totals.branches)}
|
||||
note={`${totals.active} active`}
|
||||
tone="accent"
|
||||
icon={<Store size={15} />}
|
||||
fill={totals.branches ? totals.active / totals.branches : 0}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Orders"
|
||||
value={String(totals.totalOrders)}
|
||||
note="all statuses"
|
||||
tone="neutral"
|
||||
icon={<ShoppingCart size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Revenue"
|
||||
value={money(totals.totalRevenue)}
|
||||
note="from order summary"
|
||||
tone={totals.totalRevenue > 0 ? 'success' : 'neutral'}
|
||||
icon={<IndianRupee size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Cancelled"
|
||||
value={String(totals.cancelled)}
|
||||
note={
|
||||
totals.totalOrders
|
||||
? `${Math.round((totals.cancelled / totals.totalOrders) * 100)}% of orders`
|
||||
: 'no orders yet'
|
||||
}
|
||||
tone={totals.cancelled > 0 ? 'error' : 'neutral'}
|
||||
icon={<TriangleAlert size={15} />}
|
||||
fill={totals.totalOrders ? totals.cancelled / totals.totalOrders : 0}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Catalogue"
|
||||
value={tenant ? 'Linked' : '—'}
|
||||
note="stocked per branch"
|
||||
tone="neutral"
|
||||
icon={<PackageCheck size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Branches"
|
||||
note="orders and revenue are online only — counter sales are per branch"
|
||||
action={<Freshness updatedAt={summary.dataUpdatedAt} isFetching={summary.isFetching} />}
|
||||
/>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<DataState
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
isEmpty={rows.length === 0}
|
||||
emptyTitle="No branches yet"
|
||||
/* No CTA here: this tenant's own Administrator commissions their
|
||||
outlets. Naming who acts is more useful than a button that would
|
||||
take an action out of their hands. */
|
||||
emptyDescription="This tenant's Administrator opens their outlets from their own workspace."
|
||||
>
|
||||
{/* Columns carry meaning, so the table scrolls sideways rather
|
||||
than dropping any of them. The page itself never scrolls wide. */}
|
||||
<div className="table-scroll">
|
||||
<Table<BranchRow>
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="locationid"
|
||||
density="balanced"
|
||||
hasHover
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
</DataState>
|
||||
</Card>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
252
src/features/nearle-admin/pages/StoresPage.tsx
Normal file
252
src/features/nearle-admin/pages/StoresPage.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Building2, Plus, Store, Users } from 'lucide-react';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useTenants } from '@/queries/hooks';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
|
||||
/** A tenant, with its branches folded in. */
|
||||
interface TenantRow extends Record<string, unknown> {
|
||||
tenantid: number;
|
||||
tenantname: string;
|
||||
companyname: string;
|
||||
city: string;
|
||||
branches: number;
|
||||
status: string;
|
||||
primaryemail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Nearle Admin's home: every tenant on the platform, and how many branches
|
||||
* sit under each.
|
||||
*
|
||||
* `getalltenants` returns one row per tenant-location pair, so the rows are
|
||||
* grouped by tenantid here rather than shown raw — otherwise a tenant with six
|
||||
* branches reads as six tenants.
|
||||
*/
|
||||
export function StoresPage() {
|
||||
const { data, isLoading, error } = useTenants();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const rows = useMemo<TenantRow[]>(() => {
|
||||
if (!data) return [];
|
||||
const grouped = new Map<number, TenantRow>();
|
||||
|
||||
for (const tenant of data as TenantInfo[]) {
|
||||
const existing = grouped.get(tenant.tenantid);
|
||||
if (existing) {
|
||||
existing.branches += 1;
|
||||
continue;
|
||||
}
|
||||
grouped.set(tenant.tenantid, {
|
||||
tenantid: tenant.tenantid,
|
||||
tenantname: tenant.tenantname,
|
||||
companyname: tenant.companyname ?? '',
|
||||
city: tenant.city ?? '',
|
||||
branches: 1,
|
||||
status: tenant.status ?? 'Unknown',
|
||||
primaryemail: tenant.primaryemail ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
const all = [...grouped.values()];
|
||||
const term = search.trim().toLowerCase();
|
||||
if (!term) return all;
|
||||
return all.filter(
|
||||
(row) =>
|
||||
row.tenantname.toLowerCase().includes(term) ||
|
||||
row.companyname.toLowerCase().includes(term) ||
|
||||
row.city.toLowerCase().includes(term),
|
||||
);
|
||||
}, [data, search]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const tenants = rows.length;
|
||||
const branches = rows.reduce((sum, row) => sum + row.branches, 0);
|
||||
const active = rows.filter((row) => row.status.toLowerCase() === 'active').length;
|
||||
return { tenants, branches, active };
|
||||
}, [rows]);
|
||||
|
||||
const columns: TableColumn<TenantRow>[] = [
|
||||
{
|
||||
key: 'tenantname',
|
||||
header: 'Tenant',
|
||||
width: { type: 'proportional', value: 3 },
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.tenantname}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{row.companyname || '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'city',
|
||||
header: 'City',
|
||||
width: { type: 'proportional', value: 1.5 },
|
||||
renderCell: (row) => <Text type="body" size="sm">{row.city || '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'branches',
|
||||
header: 'Branches',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 110 },
|
||||
renderCell: (row) => (
|
||||
<Text type="label" size="sm" hasTabularNumbers>
|
||||
{row.branches}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'primaryemail',
|
||||
header: 'Primary admin',
|
||||
width: { type: 'proportional', value: 2 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{row.primaryemail || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 120 },
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
|
||||
label={row.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 110 },
|
||||
renderCell: (row) => (
|
||||
<Link
|
||||
to={`/nearle/stores/${row.tenantid}`}
|
||||
style={{
|
||||
color: 'var(--color-brand)',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Open →
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Stores"
|
||||
count={totals.tenants ? `${totals.tenants} tenants` : undefined}
|
||||
description="Every merchant on the platform, the branches under them, and how each one is performing."
|
||||
isLive
|
||||
actions={
|
||||
<Button
|
||||
label="Onboard tenant"
|
||||
variant="primary"
|
||||
icon={<Plus size={15} />}
|
||||
href="/nearle/onboard/tenant"
|
||||
as={Link}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Tenants"
|
||||
value={String(totals.tenants)}
|
||||
note={`${totals.active} active`}
|
||||
tone="accent"
|
||||
icon={<Building2 size={15} />}
|
||||
fill={totals.tenants ? totals.active / totals.tenants : 0}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Branches"
|
||||
value={String(totals.branches)}
|
||||
note="across the network"
|
||||
tone="neutral"
|
||||
icon={<Store size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Avg branches"
|
||||
value={totals.tenants ? (totals.branches / totals.tenants).toFixed(1) : '0'}
|
||||
note="per tenant"
|
||||
tone="neutral"
|
||||
icon={<Users size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Tenant directory"
|
||||
note={`${rows.length} shown`}
|
||||
action={
|
||||
<div className="toolbar-stack" style={{ maxWidth: 260 }}>
|
||||
<TextInput
|
||||
label="Search tenants"
|
||||
isLabelHidden
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by name, company or city…"
|
||||
hasClear
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<DataState
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
isEmpty={rows.length === 0}
|
||||
emptyTitle={search ? 'No tenants match that search' : 'No tenants yet'}
|
||||
emptyDescription={
|
||||
search
|
||||
? 'Try a different name, company or city.'
|
||||
: 'Onboard the first merchant group to get started.'
|
||||
}
|
||||
emptyAction={
|
||||
search ? undefined : (
|
||||
<Button label="Onboard tenant" variant="primary" href="/nearle/onboard/tenant" as={Link} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Columns carry meaning, so the table scrolls sideways rather
|
||||
than dropping any of them. The page itself never scrolls wide. */}
|
||||
<div className="table-scroll">
|
||||
<Table<TenantRow>
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="tenantid"
|
||||
density="balanced"
|
||||
hasHover
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
</DataState>
|
||||
</Card>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user