Initial commit
This commit is contained in:
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