314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
import { useMemo, useState } 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, QrCode, 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';
|
||
import { TablePager } from '@/components/TablePager';
|
||
import { usePaged } from '@/components/usePaged';
|
||
import { Drawer } from '@/features/store-admin/Drawer';
|
||
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
|
||
|
||
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 paged = usePaged(rows);
|
||
|
||
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]);
|
||
|
||
/** The branch whose code is on screen, if any. */
|
||
const [qrFor, setQrFor] = useState<BranchRow | null>(null);
|
||
|
||
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}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
/* The storefront code, per branch, after the day it was commissioned.
|
||
Shown at creation too — but a poster gets lost, a shop reopens, a
|
||
merchant asks for it again a month later, and the person they ask is
|
||
whoever is looking at this page. The code is derived from the two ids
|
||
on this row, so there is nothing to look up and nothing to reissue. */
|
||
key: 'qr',
|
||
header: 'QR',
|
||
align: 'end',
|
||
width: { type: 'pixel', value: 84 },
|
||
renderCell: (row) => (
|
||
<Button
|
||
label="QR"
|
||
variant="ghost"
|
||
size="sm"
|
||
icon={<QrCode size={14} />}
|
||
onClick={() => setQrFor(row)}
|
||
/>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<VStack gap={3}>
|
||
<PageHeader
|
||
title={tenant?.tenantname ?? 'Tenant'}
|
||
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}
|
||
/>
|
||
{/* No "Catalogue — Linked" tile.
|
||
|
||
It read `tenant ? 'Linked' : '—'`, so it said Linked whenever a
|
||
tenant row came back at all — it described the tenant existing, not
|
||
the catalogue. `products/getimportedcatalogueproducts` could give a
|
||
real imported count here; until something calls it, a tile that is
|
||
always green is worse than no tile. */}
|
||
</div>
|
||
|
||
<VStack gap={1.5}>
|
||
<SectionHeader
|
||
title="Branches"
|
||
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={paged.rows}
|
||
columns={columns}
|
||
idKey="locationid"
|
||
density="balanced"
|
||
hasHover
|
||
dividers="rows"
|
||
/>
|
||
</div>
|
||
<TablePager paged={paged} label="branches" />
|
||
</DataState>
|
||
</Card>
|
||
</VStack>
|
||
|
||
{qrFor && tenantid ? (
|
||
<Drawer
|
||
title="Store QR code"
|
||
subtitle={qrFor.locationname}
|
||
width={400}
|
||
onClose={() => setQrFor(null)}
|
||
>
|
||
<StoreQrPanel
|
||
tenantid={tenantid}
|
||
locationid={qrFor.locationid}
|
||
locationname={qrFor.locationname}
|
||
{...(qrFor.city ? { where: String(qrFor.city) } : {})}
|
||
/>
|
||
</Drawer>
|
||
) : null}
|
||
</VStack>
|
||
);
|
||
}
|