Initial commit

This commit is contained in:
2026-08-24 20:35:18 +05:30
commit 1dc582ba07
85 changed files with 21081 additions and 0 deletions

View 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>
);
}