Files
daily_console_web/src/features/nearle-admin/pages/StoreDetailPage.tsx
2026-09-16 17:12:30 +05:30

342 lines
12 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, Truck } 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 { useOrderSummary, useOrders, useTenantLocations, useTenants } from '@/queries/hooks';
import { branchOrderStats, NO_ORDERS } from '@/features/store-admin/branchStats';
import type { 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';
import { PartnerAssignDrawer } from '../PartnerAssignDrawer';
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 are totalled from the tenant's own order rows and grouped by
* branch. They used to come from `/orders/getlocationsummary`, which does return
* one row per branch but carries no money on it at all — so every Revenue cell
* in this table read ₹0 for as long as the page has existed.
*
* 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);
/* Per-branch takings come from the order rows. `getlocationsummary`, which
used to feed this table, sends counts and no money at all — see
`branchStats.ts`. No date range here on purpose: this is the platform's
all-time view of a merchant, not a trading-day board. */
const summary = useOrders(tenantid ? { tenantid, pagesize: 500 } : undefined);
const orders = useOrderSummary(tenantid);
const tenant = useMemo(
() => (tenants as TenantInfo[] | undefined)?.find((entry) => entry.tenantid === tenantid),
[tenants, tenantid],
);
const rows = useMemo<BranchRow[]>(() => {
const perLocation = branchOrderStats(summary.data ?? []);
return ((locations ?? []) as TenantLocation[]).map((branch) => {
const stats = perLocation.get(branch.locationid) ?? NO_ORDERS;
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',
// Both channels, because this page answers "how is this merchant
// doing" rather than "through which door". `branchOrderStats` splits
// app orders from imported counter bills — a split the Store Admin
// console needs and this page does not — so they are added back here
// rather than left half-counted.
orders: stats.orders + stats.counterOrders,
revenue: stats.revenue + stats.counterRevenue,
};
});
}, [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);
/** Open while the merchant's delivery partner is being changed. */
const [isPartnerOpen, setPartnerOpen] = useState(false);
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="Delivery partner"
variant="secondary"
icon={<Truck size={14} />}
onClick={() => setPartnerOpen(true)}
/>
<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="across every branch"
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>
{isPartnerOpen && tenantid ? (
<PartnerAssignDrawer
tenantid={tenantid}
tenantname={tenant?.tenantname ?? 'this merchant'}
currentPartnerId={Number((tenant as unknown as Record<string, number>)?.['partnerid'] ?? 0)}
onClose={() => setPartnerOpen(false)}
/>
) : null}
{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>
);
}