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 { HStack } from '@astryxdesign/core/HStack'; 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, useTenantsByApproval } from '@/queries/hooks'; import type { TenantInfo } from '@/api/types'; /** A tenant, with its branches folded in. */ interface TenantRow extends Record { 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. * * ── The branch count comes from the server ────────────────────────────────── * * This file used to say `getalltenants` returns one row per tenant-location * pair, and counted repeated tenantids to get the number of branches. That was * never true: the query is `SELECT … FROM tenants`, with no join to * tenantlocations anywhere in it, so a tenantid has never appeared twice — and * the count was therefore 1 for every merchant on the platform, however many * shops they ran. The "Branches" and "Avg branches" tiles above this list were * the tenant count under two other names, and a tenant's own detail page * contradicted the row that opened it. * * The endpoint now returns `branchcount` and this reads it. The grouping by * tenantid is kept: it costs nothing, and it is the one thing standing between * a future join on that query and six rows for a six-branch merchant. */ type Tab = 'directory' | 'pending'; /** Rows per page. One more than this is fetched, to know whether there is a next. */ const PAGE_SIZE = 50; export function StoresPage() { const [tab, setTab] = useState('directory'); const [search, setSearch] = useState(''); const [page, setPage] = useState(1); /** * A page at a time, newest first — `getalltenants` orders by `tenantid DESC` * and has no total, so paging is "ask for one more than we show and see if it * comes back". A platform list read whole is fine at twenty tenants and not * at two thousand. */ const { data, isLoading, error } = useTenants({ pageno: page, pagesize: PAGE_SIZE + 1 }); /** * The queue of merchants awaiting approval. * * A separate endpoint, not a filter: `approved = 0` rows do not appear in * `getalltenants` at all, so without this they are invisible. Nothing here * can approve one — `approved` is writable only at creation — so this lists * and says so. */ const pending = useTenantsByApproval('pending'); const rows = useMemo(() => { if (!data) return []; const grouped = new Map(); for (const tenant of (data as TenantInfo[]).slice(0, PAGE_SIZE)) { const existing = grouped.get(tenant.tenantid); if (existing) { // Only reachable if the endpoint ever starts joining locations. Then // the rows ARE per-pair and counting them is right again. existing.branches += 1; continue; } grouped.set(tenant.tenantid, { tenantid: tenant.tenantid, tenantname: tenant.tenantname, companyname: tenant.companyname ?? '', city: tenant.city ?? '', // `?? 1` rather than `?? 0`: against a backend that does not send the // field yet, one branch is the safer guess — every merchant has at // least the outlet they were onboarded with, and showing 0 next to a // shop that plainly exists reads as broken rather than as unknown. branches: tenant.branchcount ?? 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[] = [ { key: 'tenantname', header: 'Tenant', width: { type: 'proportional', value: 3 }, renderCell: (row) => ( {row.tenantname} {row.companyname || '—'} ), }, { key: 'city', header: 'City', width: { type: 'proportional', value: 1.5 }, renderCell: (row) => {row.city || '—'}, }, { key: 'branches', header: 'Branches', align: 'end', width: { type: 'pixel', value: 110 }, renderCell: (row) => ( {row.branches} ), }, { key: 'primaryemail', header: 'Primary admin', width: { type: 'proportional', value: 2 }, renderCell: (row) => ( {row.primaryemail || '—'} ), }, { key: 'status', header: 'Status', align: 'end', width: { type: 'pixel', value: 120 }, renderCell: (row) => ( ), }, { key: 'actions', header: '', align: 'end', width: { type: 'pixel', value: 110 }, renderCell: (row) => ( Open → ), }, ]; return ( {/* No `isLive` here. Both of this page's reads — `useTenants` and `useTenantsByApproval` — use the `stable` query options: a 5-minute staleTime and no refetchInterval. The pill claimed a freshness the page does not have. The pages that keep it (Console, Sales, Counters, store detail) poll on a real interval. */} } href="/nearle/onboard/tenant" as={Link} /> } />
} fill={totals.tenants ? totals.active / totals.tenants : 0} /> } /> } />
setTab('directory')} /> setTab('pending')} /> {tab === 'pending' ? ( ) : ( } /> ) } > {/* Columns carry meaning, so the table scrolls sideways rather than dropping any of them. The page itself never scrolls wide. */}
data={rows} columns={columns} idKey="tenantid" density="balanced" hasHover dividers="rows" />
Page {page} {search ? ` · filtered from ${rows.length} on this page` : ''} ); } function Th({ children }: { children?: React.ReactNode }) { return ( {children} ); } function Td({ children, isMuted, isStrong, }: { children: React.ReactNode; isMuted?: boolean; isStrong?: boolean; }) { return ( {children} ); }