507 lines
15 KiB
TypeScript
507 lines
15 KiB
TypeScript
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<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.
|
|
*
|
|
* ── 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<Tab>('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<TenantRow[]>(() => {
|
|
if (!data) return [];
|
|
const grouped = new Map<number, TenantRow>();
|
|
|
|
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<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}>
|
|
{/* 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. */}
|
|
<PageHeader
|
|
title="Stores"
|
|
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>
|
|
|
|
<HStack gap={0.5} wrap="wrap">
|
|
<TabButton
|
|
label="Directory"
|
|
isActive={tab === 'directory'}
|
|
onClick={() => setTab('directory')}
|
|
/>
|
|
<TabButton
|
|
label="Awaiting approval"
|
|
isActive={tab === 'pending'}
|
|
badge={pending.data?.length || undefined}
|
|
onClick={() => setTab('pending')}
|
|
/>
|
|
</HStack>
|
|
|
|
{tab === 'pending' ? (
|
|
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
|
|
) : (
|
|
<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>
|
|
|
|
<HStack justify="between" align="center" gap={1} wrap="wrap">
|
|
<Text type="body" size="sm" color="secondary">
|
|
Page {page}
|
|
{search ? ` · filtered from ${rows.length} on this page` : ''}
|
|
</Text>
|
|
<HStack gap={1}>
|
|
<Button
|
|
label="Previous"
|
|
variant="secondary"
|
|
size="sm"
|
|
isDisabled={page === 1}
|
|
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
|
/>
|
|
<Button
|
|
label="Next"
|
|
variant="secondary"
|
|
size="sm"
|
|
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
|
|
onClick={() => setPage((current) => current + 1)}
|
|
/>
|
|
</HStack>
|
|
</HStack>
|
|
</VStack>
|
|
)}
|
|
</VStack>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Merchants that have been created but never approved.
|
|
*
|
|
* Read-only, and it says why on the panel rather than offering a button that
|
|
* cannot work: `tenants.approved` is written once, at creation, and there is no
|
|
* update or approve endpoint anywhere in the backend. Until there is, this is
|
|
* the only place these rows are visible at all — `getalltenants` cannot see
|
|
* them.
|
|
*/
|
|
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
|
|
if (isLoading) {
|
|
return (
|
|
<Card padding={0} elevation="low">
|
|
<VStack padding={3}>
|
|
<Text type="body" size="sm" color="secondary">
|
|
Reading the approval queue…
|
|
</Text>
|
|
</VStack>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (rows.length === 0) {
|
|
return (
|
|
<Card padding={0} elevation="low">
|
|
<VStack gap={0.5} padding={3}>
|
|
<Text type="label" size="sm" weight="semibold">
|
|
Nothing waiting
|
|
</Text>
|
|
<Text type="body" size="sm" color="secondary">
|
|
Every merchant on the platform has been approved.
|
|
</Text>
|
|
</VStack>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<VStack gap={1.5}>
|
|
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
|
These merchants exist but are not approved, so they do not appear in the directory.
|
|
Approving is a database change today — the backend has no endpoint for it.
|
|
</Text>
|
|
<Card padding={0} elevation="low">
|
|
<div className="table-scroll">
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
|
<thead>
|
|
<tr>
|
|
<Th>Merchant</Th>
|
|
<Th>Company</Th>
|
|
<Th>Contact</Th>
|
|
<Th>City</Th>
|
|
<Th>Tenant id</Th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row) => (
|
|
<tr key={`${row.tenantid}-${row.locationid}`}>
|
|
<Td isStrong>{row.tenantname}</Td>
|
|
<Td isMuted>{row.companyname || '—'}</Td>
|
|
<Td isMuted>{row.primaryemail || row.primarycontact || '—'}</Td>
|
|
<Td isMuted>{row.city || '—'}</Td>
|
|
<Td isMuted>{row.tenantid}</Td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</VStack>
|
|
);
|
|
}
|
|
|
|
function TabButton({
|
|
label,
|
|
isActive,
|
|
onClick,
|
|
badge,
|
|
}: {
|
|
label: string;
|
|
isActive: boolean;
|
|
onClick: () => void;
|
|
badge?: number;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-pressed={isActive}
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
height: 32,
|
|
padding: '0 12px',
|
|
borderRadius: 12,
|
|
border: 0,
|
|
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
|
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
|
fontSize: 13,
|
|
fontWeight: isActive ? 600 : 500,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{label}
|
|
{badge ? (
|
|
<span
|
|
style={{
|
|
minWidth: 18,
|
|
height: 18,
|
|
padding: '0 5px',
|
|
borderRadius: 999,
|
|
background: 'var(--color-warning, #b7860b)',
|
|
color: '#fff',
|
|
fontSize: 11,
|
|
fontWeight: 700,
|
|
display: 'grid',
|
|
placeItems: 'center',
|
|
}}
|
|
>
|
|
{badge}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function Th({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<th
|
|
style={{
|
|
textAlign: 'center',
|
|
padding: '10px',
|
|
borderBottom: '1px solid var(--color-line)',
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
color: 'var(--color-ink-3)',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{children}
|
|
</th>
|
|
);
|
|
}
|
|
|
|
function Td({
|
|
children,
|
|
isMuted,
|
|
isStrong,
|
|
}: {
|
|
children: React.ReactNode;
|
|
isMuted?: boolean;
|
|
isStrong?: boolean;
|
|
}) {
|
|
return (
|
|
<td
|
|
style={{
|
|
textAlign: 'center',
|
|
padding: '10px',
|
|
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
|
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
|
fontWeight: isStrong ? 600 : 400,
|
|
}}
|
|
>
|
|
{children}
|
|
</td>
|
|
);
|
|
}
|