Files
daily_console_web/src/features/nearle-admin/pages/StoreDetailPage.tsx
2026-09-09 11:19:05 +05:30

314 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}