Initial commit
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user