update the adminpage ui
This commit is contained in:
139
src/features/admin/components/AdminAccount.tsx
Normal file
139
src/features/admin/components/AdminAccount.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {Token} from '@astryxdesign/core/Token';
|
||||
import {Link} from '@astryxdesign/core/Link';
|
||||
import {List, ListItem} from '@astryxdesign/core/List';
|
||||
import {
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
} from '@astryxdesign/core/SegmentedControl';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
import {useThemeMode} from '@/shared/providers/ThemeModeProvider';
|
||||
import type {ThemeMode} from '@/shared/theme/themeMode';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {ActiveSessionsPanel} from '@/features/settings/components/ActiveSessionsPanel';
|
||||
import {AdminPageHeader} from './common/AdminPageHeader';
|
||||
import {PendingIntegration} from './common/PendingIntegration';
|
||||
|
||||
/**
|
||||
* The platform admin's own account — Profile and Settings.
|
||||
*
|
||||
* ── Only what the platform can answer for an admin ───────────────────────
|
||||
* The profile is the signed-in session: id, name, email, role. Nothing is
|
||||
* editable, because the only profile write (`PATCH /api/settings/profile`) is
|
||||
* tenant-scoped and an admin has no tenant.
|
||||
*
|
||||
* Settings has two live parts: appearance (a cookie this app owns) and active
|
||||
* sessions (`/api/auth/sessions`, scoped to the person, not a tenant). Password
|
||||
* change and two-factor have no platform endpoint, so they say so rather than
|
||||
* render a form that saves nowhere — the merchant Security page's password
|
||||
* form and 2FA switch are exactly that, and are not reused here.
|
||||
*/
|
||||
|
||||
export function AdminProfile() {
|
||||
const {user} = useSession();
|
||||
if (!user) return null;
|
||||
const name = user.name || user.email;
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader
|
||||
title="Profile"
|
||||
subtitle="Your platform admin account."
|
||||
actions={<Link href="/admin/settings">Settings</Link>}
|
||||
/>
|
||||
|
||||
<Card padding={6}>
|
||||
<HStack gap={4} vAlign="center" className="min-w-0">
|
||||
<Avatar name={name} size="xl" tooltip={false} />
|
||||
<VStack gap={1} className="min-w-0">
|
||||
<Text size="lg" weight="semibold" className="truncate">
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary" className="truncate">
|
||||
{user.email}
|
||||
</Text>
|
||||
<HStack>
|
||||
<Token size="sm" label="Platform admin" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
|
||||
<StaticPanel
|
||||
title="Account details"
|
||||
subtitle="As the platform returned them at sign-in."
|
||||
>
|
||||
<List>
|
||||
<ListItem label="Name" endContent={<Text size="sm">{user.name || '—'}</Text>} />
|
||||
<ListItem label="Email" endContent={<Text size="sm">{user.email}</Text>} />
|
||||
<ListItem label="Role" endContent={<Text size="sm">Platform admin</Text>} />
|
||||
<ListItem
|
||||
label="Scope"
|
||||
description="A platform admin belongs to no merchant and administers all of them."
|
||||
endContent={<Text size="sm">All merchants</Text>}
|
||||
/>
|
||||
<ListItem
|
||||
label="Account ID"
|
||||
endContent={
|
||||
<Text size="sm" color="secondary">
|
||||
{user.id}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</List>
|
||||
</StaticPanel>
|
||||
|
||||
<StaticPanel title="Edit profile">
|
||||
<PendingIntegration
|
||||
icon="settings"
|
||||
title="Name and email cannot be changed here yet"
|
||||
description="The platform's only profile update is scoped to a merchant, and a platform admin has none. An admin endpoint for the signed-in account is needed."
|
||||
/>
|
||||
</StaticPanel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSettings() {
|
||||
const {mode, setMode} = useThemeMode();
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader
|
||||
title="Settings"
|
||||
subtitle="Appearance and sign-in security for your platform admin account."
|
||||
actions={<Link href="/admin/profile">Profile</Link>}
|
||||
/>
|
||||
|
||||
<StaticPanel
|
||||
title="Appearance"
|
||||
subtitle="Saved in this browser. Applies to both consoles."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as ThemeMode)}
|
||||
label="Theme"
|
||||
>
|
||||
<SegmentedControlItem value="light" label="Light" />
|
||||
<SegmentedControlItem value="dark" label="Dark" />
|
||||
<SegmentedControlItem value="system" label="System" />
|
||||
</SegmentedControl>
|
||||
</StaticPanel>
|
||||
|
||||
<ActiveSessionsPanel />
|
||||
|
||||
<StaticPanel title="Password and two-factor">
|
||||
<PendingIntegration
|
||||
icon="security"
|
||||
title="Password change and two-factor are not available yet"
|
||||
description="The platform has no endpoint to change your own password or enrol two-factor. Until it does, a platform admin password can only be changed on the platform itself."
|
||||
/>
|
||||
</StaticPanel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {EmptyState} from '@astryxdesign/core/EmptyState';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {AuthGuard} from '@/features/auth/guards/AuthGuard';
|
||||
import {BrandMark} from '@/shared/components/brand/BrandLogo';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {AdminShell} from './shell/AdminShell';
|
||||
|
||||
/**
|
||||
* The platform console's frame.
|
||||
*
|
||||
* ── Same guard, different shell ──────────────────────────────────────────
|
||||
* `AuthGuard` is the one the workspace uses, reading the session minted by the
|
||||
* same login route. What is deliberately absent is `WorkspaceShell`: no site
|
||||
* switcher, no tenant navigation, no Loyaly AI rail. Every one of those is
|
||||
* scoped to a company, and a platform operator has none — a store picker with
|
||||
* nothing in it is worse than no store picker.
|
||||
*
|
||||
* ── Why it says "Platform" so loudly ─────────────────────────────────────
|
||||
* This console can suspend a company and delete one irreversibly. Somebody who
|
||||
* has both consoles open needs to know which tab they are typing into before
|
||||
* they click, not after. The badge is the cheapest possible version of that.
|
||||
* same login route. The shell is NOT WorkspaceShell: that one carries a store
|
||||
* switcher, tenant navigation and a tenant account menu, every one of which is
|
||||
* scoped to a company a platform operator does not have. AdminShell keeps the
|
||||
* shape — rail, top bar, content, Loyaly AI — and drops the tenancy.
|
||||
*/
|
||||
export function AdminLayout({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<VStack gap={0} width="100%" minHeight="100vh">
|
||||
<AdminTopBar />
|
||||
<VStack gap={0} width="100%" paddingInline={8} paddingBlock={8}>
|
||||
{children}
|
||||
</VStack>
|
||||
</VStack>
|
||||
<OperatorsOnly>
|
||||
<AdminShell>{children}</AdminShell>
|
||||
</OperatorsOnly>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminTopBar() {
|
||||
const {user, logout} = useSession();
|
||||
/**
|
||||
* What a merchant session sees if it reaches the platform console.
|
||||
*
|
||||
* The proxy normally redirects a merchant away before this renders, but it
|
||||
* resolves the session from the tab POINTER cookie, while this tab's client
|
||||
* session comes from its own tab id (see tabSession.ts). With an operator
|
||||
* signed in in one tab and a merchant in another, the server can render this
|
||||
* shell for the operator's pointer while the tab itself holds the merchant.
|
||||
*
|
||||
* An automatic redirect to /dashboard was tried and does not work: the proxy,
|
||||
* reading the same operator pointer, sends /dashboard straight back to /admin,
|
||||
* and the tab settles on a blank page. So this says what happened and offers
|
||||
* the two ways out, instead of guessing.
|
||||
*
|
||||
* Routing only, like the proxy. Nothing leaks either way: every admin read
|
||||
* carries this tab's id and the platform answers 404 to a merchant.
|
||||
*/
|
||||
function OperatorsOnly({children}: {children: React.ReactNode}) {
|
||||
const {isAuthenticated, user, logout} = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
if (user?.isPlatformAdmin === true) return <>{children}</>;
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
async function signOut() {
|
||||
await logout();
|
||||
router.replace('/login');
|
||||
router.replace('/login?next=%2Fadmin');
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack
|
||||
gap={4}
|
||||
width="100%"
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
paddingInline={8}
|
||||
paddingBlock={4}
|
||||
className="border-b border-border bg-surface"
|
||||
>
|
||||
<HStack gap={3} vAlign="center">
|
||||
<BrandMark size={24} />
|
||||
<Text size="sm" weight="medium">
|
||||
Platform
|
||||
</Text>
|
||||
<Badge variant="neutral" label="Admin" />
|
||||
</HStack>
|
||||
|
||||
<HStack gap={3} vAlign="center">
|
||||
{/* The address, not the display name: on a console that can delete a
|
||||
company, which ACCOUNT is signed in matters more than whose it is. */}
|
||||
<Text size="xsm" color="secondary">
|
||||
{user?.email}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void signOut()}
|
||||
label="Sign out"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<VStack width="100%" minHeight="100vh" hAlign="center" vAlign="center" padding={6}>
|
||||
<EmptyState
|
||||
title="This is the platform admin console"
|
||||
description={`This tab is signed in as ${user?.email ?? 'a merchant account'}, which is not a platform admin. Sign out and sign in with a platform admin account, or go back to your dashboard.`}
|
||||
actions={
|
||||
<HStack gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
label="Go to dashboard"
|
||||
href="/dashboard"
|
||||
/>
|
||||
<Button size="sm" label="Sign out" onClick={() => void signOut()} />
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
206
src/features/admin/components/CameraDetail.tsx
Normal file
206
src/features/admin/components/CameraDetail.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {List, ListItem} from '@astryxdesign/core/List';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {
|
||||
useAlerts,
|
||||
useCamera,
|
||||
useCompany,
|
||||
useEvents,
|
||||
useStore,
|
||||
} from '@/features/admin/hooks/useMonitoring';
|
||||
import {useAiContext} from '@/features/admin/hooks/useAiContext';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
import {AdminPageHeader} from './common/AdminPageHeader';
|
||||
import {AdminSection} from './common/AdminSection';
|
||||
import {PendingIntegration} from './common/PendingIntegration';
|
||||
import {CompanyNotFound} from './CompanyDetail';
|
||||
import {
|
||||
AlertTable,
|
||||
CameraStatus,
|
||||
EventTable,
|
||||
adminHref,
|
||||
} from './MonitoringTables';
|
||||
|
||||
/**
|
||||
* One camera, four levels deep: company / store / camera.
|
||||
*
|
||||
* ── No fake "LIVE" ───────────────────────────────────────────────────────
|
||||
* The live feed panel is always the integration-required state today, even
|
||||
* once camera details arrive. The tenant console streams a camera through its
|
||||
* own authenticated route; there is no admin equivalent, and a looping
|
||||
* placeholder with a red LIVE badge would be the single most misleading thing
|
||||
* this console could draw.
|
||||
*/
|
||||
export function CameraDetail({
|
||||
companyId,
|
||||
storeId,
|
||||
cameraId,
|
||||
}: {
|
||||
companyId: string;
|
||||
storeId: string;
|
||||
cameraId: string;
|
||||
}) {
|
||||
const {resource, company} = useCompany(companyId);
|
||||
|
||||
return (
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={<CompanyNotFound />}
|
||||
>
|
||||
{() =>
|
||||
company ? (
|
||||
<CameraBody company={company} storeId={storeId} cameraId={cameraId} />
|
||||
) : (
|
||||
<CompanyNotFound />
|
||||
)
|
||||
}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function CameraBody({
|
||||
company,
|
||||
storeId,
|
||||
cameraId,
|
||||
}: {
|
||||
company: Company;
|
||||
storeId: string;
|
||||
cameraId: string;
|
||||
}) {
|
||||
const bp = useBreakpoint();
|
||||
const store = useStore(company.id, storeId);
|
||||
const camera = useCamera(company.id, storeId, cameraId);
|
||||
const events = useEvents(company.id, storeId, cameraId);
|
||||
const alerts = useAlerts(company.id, storeId, cameraId);
|
||||
|
||||
const storeName =
|
||||
store.isAvailable && store.resource.status === 'success'
|
||||
? store.resource.data.name
|
||||
: 'Shop';
|
||||
const cameraName =
|
||||
camera.isAvailable && camera.resource.status === 'success'
|
||||
? camera.resource.data.name
|
||||
: 'Camera';
|
||||
|
||||
useAiContext({
|
||||
level: 'camera',
|
||||
label: `${cameraName} · ${storeName} · ${company.name}`,
|
||||
companyId: company.id,
|
||||
storeId,
|
||||
cameraId,
|
||||
});
|
||||
|
||||
const twoUp = bp === 'desktop' || bp === 'ultrawide';
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader
|
||||
title={cameraName}
|
||||
subtitle={`${storeName} · ${company.name}`}
|
||||
back={{label: storeName, href: adminHref.store(company.id, storeId)}}
|
||||
crumbs={[
|
||||
{label: 'Merchants', href: adminHref.merchants},
|
||||
{label: company.name, href: adminHref.company(company.id)},
|
||||
{label: storeName, href: adminHref.store(company.id, storeId)},
|
||||
{label: cameraName},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Grid columns={twoUp ? 2 : 1} gap={4}>
|
||||
<AdminSection
|
||||
title="Camera"
|
||||
data={camera}
|
||||
pending={{
|
||||
icon: 'camera',
|
||||
title: 'Camera data unavailable',
|
||||
description:
|
||||
'Status, location and last-seen time need the platform admin camera-detail API.',
|
||||
}}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
>
|
||||
{(c) => (
|
||||
<List>
|
||||
<ListItem label="Status" endContent={<CameraStatus status={c.status} />} />
|
||||
<ListItem
|
||||
label="Location"
|
||||
endContent={<Text size="sm">{c.zone ?? '—'}</Text>}
|
||||
/>
|
||||
<ListItem
|
||||
label="Last seen"
|
||||
endContent={
|
||||
<Text size="sm">
|
||||
{c.lastSeenAt ? new Date(c.lastSeenAt).toLocaleString() : '—'}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</List>
|
||||
)}
|
||||
</AdminSection>
|
||||
|
||||
<Card>
|
||||
<VStack gap={4}>
|
||||
<SectionHeader title="Live feed" />
|
||||
<PendingIntegration
|
||||
icon="camera"
|
||||
title="Live feed unavailable"
|
||||
description="There is no platform admin stream endpoint. No feed is shown rather than a placeholder that looks live."
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid columns={twoUp ? 2 : 1} gap={4}>
|
||||
<AdminSection
|
||||
title="Alerts"
|
||||
data={alerts}
|
||||
pending={{
|
||||
icon: 'notifications',
|
||||
title: 'No alerts available',
|
||||
description:
|
||||
'Alert monitoring is not currently available for Platform Admin.',
|
||||
}}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="notifications"
|
||||
title="No alerts"
|
||||
description="This camera has raised no alerts."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => <AlertTable alerts={rows} />}
|
||||
</AdminSection>
|
||||
|
||||
<AdminSection
|
||||
title="Events and AI detections"
|
||||
data={events}
|
||||
pending={{
|
||||
icon: 'events',
|
||||
title: 'No event data available',
|
||||
description: 'Platform Admin event API integration is required.',
|
||||
}}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="events"
|
||||
title="No events"
|
||||
description="This camera has not reported any events yet."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => <EventTable events={rows} />}
|
||||
</AdminSection>
|
||||
</Grid>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
} from '@astryxdesign/core/SegmentedControl';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {
|
||||
useCompanies,
|
||||
useCompanyFilters,
|
||||
} from '@/features/admin/hooks/useCompanies';
|
||||
import {CreateCompanyDialog} from './CreateCompanyDialog';
|
||||
import {SuspendCompanyDialog} from './SuspendCompanyDialog';
|
||||
import {ResetOwnerPasswordDialog} from './ResetOwnerPasswordDialog';
|
||||
import {DeleteCompanyDialog} from './DeleteCompanyDialog';
|
||||
import type {Company, CompanyFilter} from '@/features/admin/types/company';
|
||||
|
||||
/**
|
||||
* Every company on the platform, and the four things an operator can do to one.
|
||||
*
|
||||
* ── The columns are exactly what the API returns ─────────────────────────
|
||||
* Name, slug, shops, accounts, status, created. `GET /api/admin/clients`
|
||||
* returns those seven fields and nothing else, so there is no owner column, no
|
||||
* plan, no revenue and no health. A column this list cannot fill would have to
|
||||
* be faked, and a faked column on the console that can delete a company is the
|
||||
* worst place in the product to put one.
|
||||
*
|
||||
* ── Why suspended rows are not hidden ────────────────────────────────────
|
||||
* A suspended company is the one an operator is most likely to be looking for
|
||||
* — it is the one somebody is on the phone about. It stays in the default list
|
||||
* with its status shown, and the filter is there to narrow deliberately.
|
||||
*/
|
||||
|
||||
interface CompanyRow extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
sites: number;
|
||||
users: number;
|
||||
status: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
function toRow(c: Company): CompanyRow {
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
sites: c.sites,
|
||||
users: c.users,
|
||||
status: c.isActive ? 'Active' : 'Suspended',
|
||||
// Date only. A platform operator cares which week a tenant was onboarded,
|
||||
// never which minute.
|
||||
created: new Date(c.createdAt).toLocaleDateString(),
|
||||
};
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumn<CompanyRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Company',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
{/* The slug is shown on every row because it is what the delete
|
||||
confirmation asks for, and hunting for it afterwards is how
|
||||
somebody ends up copying the wrong one. */}
|
||||
<Text size="xsm" color="secondary">
|
||||
{row.slug}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{key: 'sites', header: 'Shops', width: proportional(1), align: 'end'},
|
||||
{key: 'users', header: 'Accounts', width: proportional(1), align: 'end'},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={row.status === 'Active' ? 'success' : 'error'}
|
||||
label={row.status}
|
||||
/>
|
||||
<Text size="sm" color="secondary">
|
||||
{row.status}
|
||||
</Text>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{key: 'created', header: 'Created', width: proportional(1), align: 'end'},
|
||||
];
|
||||
|
||||
type Action =
|
||||
| {kind: 'create'}
|
||||
| {kind: 'suspend'; company: Company}
|
||||
| {kind: 'reset'; company: Company}
|
||||
| {kind: 'delete'; company: Company}
|
||||
| null;
|
||||
|
||||
export function CompaniesPanel() {
|
||||
const companies = useCompanies();
|
||||
const {query, setQuery, filter, setFilter, visible} = useCompanyFilters(
|
||||
companies.data,
|
||||
);
|
||||
const [action, setAction] = useState<Action>(null);
|
||||
|
||||
/**
|
||||
* Nothing optimistic. Every mutation refetches and the table redraws from
|
||||
* what the server says — a row that claimed to be suspended because a PATCH
|
||||
* was sent, while the platform refused it, is exactly the lie this console
|
||||
* cannot afford.
|
||||
*/
|
||||
function refresh() {
|
||||
companies.refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<PanelCard
|
||||
title="Companies"
|
||||
subtitle="Every merchant company on this platform."
|
||||
resource={companies}
|
||||
loading={<SkeletonRows count={5} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title="No companies yet"
|
||||
description="Create your first merchant company to get started."
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAction({kind: 'create'})}
|
||||
label="Create company"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAction({kind: 'create'})}
|
||||
label="Create company"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(all) => (
|
||||
<VStack gap={4} width="100%">
|
||||
<HStack gap={3} vAlign="center" hAlign="between">
|
||||
<TextInput
|
||||
label="Search companies"
|
||||
isLabelHidden
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search by name or slug…"
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={filter}
|
||||
onChange={(v) => setFilter(v as CompanyFilter)}
|
||||
label="Filter by status"
|
||||
>
|
||||
<SegmentedControlItem value="all" label="All" />
|
||||
<SegmentedControlItem value="active" label="Active" />
|
||||
<SegmentedControlItem value="suspended" label="Suspended" />
|
||||
</SegmentedControl>
|
||||
</HStack>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
/* A search that matches nothing is NOT the same as a platform
|
||||
with no companies — saying "no companies yet" here would tell
|
||||
an operator their tenants had vanished. */
|
||||
<Text size="sm" color="secondary">
|
||||
No companies match that search. {all.length}{' '}
|
||||
{all.length === 1 ? 'company' : 'companies'} in total.
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveTable
|
||||
data={visible.map(toRow)}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
summaryKeys={['status', 'sites']}
|
||||
columns={COLUMNS}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VStack gap={2} width="100%">
|
||||
{visible.map((c) => (
|
||||
<HStack
|
||||
key={c.id}
|
||||
gap={2}
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
width="100%"
|
||||
>
|
||||
<Text size="xsm" color="secondary">
|
||||
{c.name}
|
||||
</Text>
|
||||
<HStack gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setAction({kind: 'reset', company: c})}
|
||||
label="Reset owner password"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setAction({kind: 'suspend', company: c})}
|
||||
label={c.isActive ? 'Suspend' : 'Reinstate'}
|
||||
/>
|
||||
{/* Delete is offered only on a suspended company, because
|
||||
the platform refuses it otherwise. The dialog still
|
||||
sends an active one and shows the real 409 if this is
|
||||
ever reached another way — the guard is a courtesy,
|
||||
never the rule. */}
|
||||
{c.isActive ? null : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setAction({kind: 'delete', company: c})}
|
||||
label="Delete"
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
)}
|
||||
</PanelCard>
|
||||
|
||||
{action?.kind === 'create' ? (
|
||||
<CreateCompanyDialog
|
||||
onClose={() => setAction(null)}
|
||||
onCreated={refresh}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{action?.kind === 'suspend' ? (
|
||||
<SuspendCompanyDialog
|
||||
company={action.company}
|
||||
onClose={() => setAction(null)}
|
||||
onDone={refresh}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{action?.kind === 'reset' ? (
|
||||
<ResetOwnerPasswordDialog
|
||||
company={action.company}
|
||||
onClose={() => setAction(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{action?.kind === 'delete' ? (
|
||||
<DeleteCompanyDialog
|
||||
company={action.company}
|
||||
onClose={() => setAction(null)}
|
||||
onDeleted={refresh}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
148
src/features/admin/components/CompanyActions.tsx
Normal file
148
src/features/admin/components/CompanyActions.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {SuspendCompanyDialog} from './SuspendCompanyDialog';
|
||||
import {ResetOwnerPasswordDialog} from './ResetOwnerPasswordDialog';
|
||||
import {DeleteCompanyDialog} from './DeleteCompanyDialog';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
|
||||
export type CompanyDialog = 'suspend' | 'reset' | 'delete';
|
||||
|
||||
/**
|
||||
* The things an operator can do to an existing merchant, in one menu.
|
||||
*
|
||||
* Shared by the merchant cards and the merchant page so the two can never
|
||||
* offer different actions for the same merchant.
|
||||
*
|
||||
* There is no "Edit": `PATCH /api/admin/clients/{id}` accepts `active` and
|
||||
* nothing else, so a rename would be a form that saves nowhere
|
||||
* (ADMIN_CAPABILITIES.merchantEdit).
|
||||
*
|
||||
* Delete is offered only on a suspended merchant, because the platform refuses
|
||||
* it otherwise. The dialog still sends the request and shows the real 409 if
|
||||
* it is ever reached another way — the guard here is a courtesy, never the
|
||||
* rule.
|
||||
*
|
||||
* The menu and its dialogs are split so a card can hold the menu while the
|
||||
* dialogs render outside it — a dialog nested in a clickable card would route
|
||||
* its clicks through the card.
|
||||
*/
|
||||
export function CompanyActionsMenu({
|
||||
company,
|
||||
onSelect,
|
||||
viewHref,
|
||||
isIconOnly = false,
|
||||
}: {
|
||||
company: Company;
|
||||
onSelect: (dialog: CompanyDialog) => void;
|
||||
/** Adds a "View details" row — for surfaces that are not the detail page. */
|
||||
viewHref?: string;
|
||||
isIconOnly?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<DropdownMenu
|
||||
button={
|
||||
isIconOnly
|
||||
? {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
label: `Actions for ${company.name}`,
|
||||
isIconOnly: true,
|
||||
icon: <Icon icon="moreHorizontal" size="sm" />,
|
||||
}
|
||||
: {
|
||||
variant: 'secondary',
|
||||
size: 'sm',
|
||||
label: 'Manage',
|
||||
icon: <Icon icon="moreHorizontal" size="sm" />,
|
||||
}
|
||||
}
|
||||
hasChevron={!isIconOnly}
|
||||
menuWidth={220}
|
||||
items={[
|
||||
...(viewHref
|
||||
? [
|
||||
{label: 'View details', onClick: () => router.push(viewHref)},
|
||||
{type: 'divider' as const},
|
||||
]
|
||||
: []),
|
||||
{label: 'Reset owner password', onClick: () => onSelect('reset')},
|
||||
{
|
||||
label: company.isActive ? 'Deactivate merchant' : 'Reinstate merchant',
|
||||
onClick: () => onSelect('suspend'),
|
||||
},
|
||||
...(company.isActive
|
||||
? []
|
||||
: [
|
||||
{type: 'divider' as const},
|
||||
{label: 'Remove merchant', onClick: () => onSelect('delete')},
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompanyActionDialog({
|
||||
company,
|
||||
dialog,
|
||||
onClose,
|
||||
onChanged,
|
||||
onDeleted,
|
||||
}: {
|
||||
company: Company;
|
||||
dialog: CompanyDialog;
|
||||
onClose: () => void;
|
||||
/** After a suspend/reinstate — refetch, never patch optimistically. */
|
||||
onChanged: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
if (dialog === 'suspend') {
|
||||
return (
|
||||
<SuspendCompanyDialog company={company} onClose={onClose} onDone={onChanged} />
|
||||
);
|
||||
}
|
||||
if (dialog === 'reset') {
|
||||
return <ResetOwnerPasswordDialog company={company} onClose={onClose} />;
|
||||
}
|
||||
return (
|
||||
<DeleteCompanyDialog company={company} onClose={onClose} onDeleted={onDeleted} />
|
||||
);
|
||||
}
|
||||
|
||||
/** Menu and dialogs together, for a surface with no clickable container. */
|
||||
export function CompanyActions({
|
||||
company,
|
||||
onChanged,
|
||||
onDeleted,
|
||||
isIconOnly = false,
|
||||
}: {
|
||||
company: Company;
|
||||
onChanged: () => void;
|
||||
onDeleted: () => void;
|
||||
isIconOnly?: boolean;
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<CompanyDialog | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CompanyActionsMenu
|
||||
company={company}
|
||||
onSelect={setDialog}
|
||||
isIconOnly={isIconOnly}
|
||||
/>
|
||||
{dialog ? (
|
||||
<CompanyActionDialog
|
||||
company={company}
|
||||
dialog={dialog}
|
||||
onClose={() => setDialog(null)}
|
||||
onChanged={onChanged}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
402
src/features/admin/components/CompanyDetail.tsx
Normal file
402
src/features/admin/components/CompanyDetail.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Token} from '@astryxdesign/core/Token';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {TabList, Tab} from '@astryxdesign/core/TabList';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {
|
||||
SkeletonMetricGrid,
|
||||
SkeletonRows,
|
||||
} from '@/shared/components/patterns/LoadingState';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {
|
||||
useCompany,
|
||||
useCompanyStores,
|
||||
} from '@/features/admin/hooks/useMonitoring';
|
||||
import {useAiContext} from '@/features/admin/hooks/useAiContext';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
import {AdminPageHeader, BackButton} from './common/AdminPageHeader';
|
||||
import {AdminSection} from './common/AdminSection';
|
||||
import {CompanyActions} from './CompanyActions';
|
||||
import {StoreTable, adminHref} from './MonitoringTables';
|
||||
import {
|
||||
ADMIN_CAPABILITIES,
|
||||
MERCHANT_AREAS,
|
||||
} from '@/features/admin/config/capabilities';
|
||||
import type {MerchantArea} from '@/features/admin/config/capabilities';
|
||||
|
||||
/**
|
||||
* One merchant: what the platform knows about it, and the way down to its
|
||||
* shops.
|
||||
*
|
||||
* ── Only the fields the platform returns ─────────────────────────────────
|
||||
* Name, slug, status, shop count, account count, created. There is no owner
|
||||
* name, plan or contact on the admin surface, so there is no owner field
|
||||
* here. The shop and account COUNTS are real — they come on the merchant row
|
||||
* — even though the shops themselves cannot be listed yet, and the Shops tab
|
||||
* says exactly that rather than rendering an empty table that would read as
|
||||
* "this merchant has no shops".
|
||||
*
|
||||
* ── Tabs only for what can be answered ───────────────────────────────────
|
||||
* General and Shops. Sales persons, cameras, customers, sales, analytics,
|
||||
* footfall and device logs are listed on General with their backend state
|
||||
* (MERCHANT_AREAS) instead of as tabs that could only say "unavailable".
|
||||
*/
|
||||
export function CompanyDetail({companyId}: {companyId: string}) {
|
||||
const router = useRouter();
|
||||
const {resource, company} = useCompany(companyId);
|
||||
|
||||
useAiContext(
|
||||
company
|
||||
? {level: 'company', label: company.name, companyId: company.id}
|
||||
: null,
|
||||
);
|
||||
|
||||
return (
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={
|
||||
<VStack gap={6} width="100%">
|
||||
<SkeletonRows count={2} />
|
||||
<SkeletonMetricGrid columns={4} />
|
||||
</VStack>
|
||||
}
|
||||
empty={<CompanyNotFound />}
|
||||
>
|
||||
{() =>
|
||||
company ? (
|
||||
<CompanyBody
|
||||
company={company}
|
||||
onChanged={resource.refetch}
|
||||
onDeleted={() => router.replace(adminHref.merchants)}
|
||||
/>
|
||||
) : (
|
||||
<CompanyNotFound />
|
||||
)
|
||||
}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompanyNotFound() {
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader
|
||||
title="Merchant not found"
|
||||
crumbs={[
|
||||
{label: 'Merchants', href: adminHref.merchants},
|
||||
{label: 'Not found'},
|
||||
]}
|
||||
/>
|
||||
<Card>
|
||||
<EmptyPanel
|
||||
icon="companies"
|
||||
title="No merchant with this id"
|
||||
description="It may have been deleted, or the link is wrong. The merchant list shows every merchant on the platform."
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
href={adminHref.merchants}
|
||||
label="Back to merchants"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function CompanyBody({
|
||||
company,
|
||||
onChanged,
|
||||
onDeleted,
|
||||
}: {
|
||||
company: Company;
|
||||
onChanged: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<MerchantTab>('general');
|
||||
const status = company.isActive ? 'Active' : 'Suspended';
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<HStack>
|
||||
<BackButton label="Merchants" href={adminHref.merchants} />
|
||||
</HStack>
|
||||
|
||||
{/* Identity: who this is, whether they can sign in, and what can be
|
||||
done to them — in one place, above everything that describes them. */}
|
||||
<Card padding={6}>
|
||||
<HStack
|
||||
gap={4}
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
width="100%"
|
||||
className="flex-wrap gap-y-4"
|
||||
>
|
||||
<HStack gap={4} vAlign="center" className="min-w-0">
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-14 rounded-xl bg-muted shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS.companies} size="lg" color="secondary" />
|
||||
</HStack>
|
||||
<VStack gap={1} className="min-w-0">
|
||||
<Text type="display-3" weight="medium" className="break-words">
|
||||
{company.name}
|
||||
</Text>
|
||||
<HStack gap={3} vAlign="center" className="flex-wrap gap-y-1">
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={company.isActive ? 'success' : 'error'}
|
||||
label={status}
|
||||
/>
|
||||
<Text size="sm" color="secondary">
|
||||
{status}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="sm" color="secondary">
|
||||
@{company.slug}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
Created {fmtDate(company.createdAt)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<CompanyActions
|
||||
company={company}
|
||||
onChanged={onChanged}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
</HStack>
|
||||
</Card>
|
||||
|
||||
<TabList
|
||||
value={tab}
|
||||
onChange={(v) => setTab(v as MerchantTab)}
|
||||
hasDivider
|
||||
>
|
||||
<Tab value="general" label="General" />
|
||||
<Tab
|
||||
value="shops"
|
||||
label="Shops"
|
||||
endContent={<Badge label={company.sites.toLocaleString()} />}
|
||||
/>
|
||||
</TabList>
|
||||
|
||||
{tab === 'general' ? (
|
||||
<MerchantGeneral company={company} />
|
||||
) : (
|
||||
<MerchantShops company={company} />
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
type MerchantTab = 'general' | 'shops';
|
||||
|
||||
const fmtDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function MerchantGeneral({company}: {company: Company}) {
|
||||
const bp = useBreakpoint();
|
||||
// Read once per mount: rendering must stay pure, and a day count does not
|
||||
// need to tick while the page is open.
|
||||
const [now] = useState(() => Date.now());
|
||||
// Shops has its own tab; everything else is listed with its state.
|
||||
const areas = MERCHANT_AREAS.filter((a) => a.key !== 'shops');
|
||||
const live = areas.filter((a) => ADMIN_CAPABILITIES[a.capability]).length;
|
||||
// Derived from created_at, which the row carries — not an estimate.
|
||||
const days = Math.max(
|
||||
0,
|
||||
Math.floor((now - new Date(company.createdAt).getTime()) / DAY_MS),
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<Grid columns={bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 4} gap={4}>
|
||||
<Fact
|
||||
icon="stores"
|
||||
label="Shops"
|
||||
value={company.sites.toLocaleString()}
|
||||
caption="Registered to this merchant"
|
||||
/>
|
||||
<Fact
|
||||
icon="staff"
|
||||
label="Merchant accounts"
|
||||
value={company.users.toLocaleString()}
|
||||
caption="Logins on this merchant"
|
||||
/>
|
||||
<Fact
|
||||
icon="expiry"
|
||||
label="Created"
|
||||
value={fmtDate(company.createdAt)}
|
||||
caption={`${days.toLocaleString()} ${days === 1 ? 'day' : 'days'} on the platform`}
|
||||
/>
|
||||
<Fact
|
||||
icon={company.isActive ? 'present' : 'absent'}
|
||||
label="Status"
|
||||
value={company.isActive ? 'Active' : 'Suspended'}
|
||||
caption={
|
||||
company.isActive ? 'Can sign in' : 'Sign-in blocked for everyone'
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Card padding={6}>
|
||||
<VStack gap={5} width="100%">
|
||||
<SectionHeader
|
||||
title="Merchant data"
|
||||
subtitle={`What this console can read for ${company.name}. Every area is scoped to this merchant and its shops only.`}
|
||||
actions={
|
||||
<Token size="sm" label={`${live} of ${areas.length} live`} />
|
||||
}
|
||||
/>
|
||||
<Grid columns={bp === 'desktop' || bp === 'ultrawide' ? 2 : 1} gap={3}>
|
||||
{areas.map((a) => (
|
||||
<AreaTile
|
||||
key={a.key}
|
||||
area={a}
|
||||
isLive={ADMIN_CAPABILITIES[a.capability]}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One merchant-level area and whether the platform can answer it. A tile, not
|
||||
* a list row with a repeated badge: nine identical "Backend integration
|
||||
* required" tokens read as noise, so the state is a quiet dot + label and the
|
||||
* summary token above carries the count.
|
||||
*/
|
||||
function AreaTile({area, isLive}: {area: MerchantArea; isLive: boolean}) {
|
||||
return (
|
||||
<HStack
|
||||
gap={3}
|
||||
vAlign="center"
|
||||
width="100%"
|
||||
className="rounded-lg bg-muted p-4 min-w-0"
|
||||
>
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-9 rounded-md bg-surface shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS[area.icon]} size="sm" color="secondary" />
|
||||
</HStack>
|
||||
<VStack gap={0} className="min-w-0 flex-1">
|
||||
<Text size="sm" weight="medium">
|
||||
{area.label}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{area.description}
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack gap={1.5} vAlign="center" className="shrink-0">
|
||||
<StatusDot
|
||||
variant={isLive ? 'success' : 'neutral'}
|
||||
label={isLive ? 'Live' : 'Backend integration required'}
|
||||
/>
|
||||
<Text size="xsm" color="secondary">
|
||||
{isLive ? 'Live' : 'Needs API'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
function MerchantShops({company}: {company: Company}) {
|
||||
const stores = useCompanyStores(company.id);
|
||||
const n = company.sites;
|
||||
const shops = `${n} ${n === 1 ? 'shop' : 'shops'}`;
|
||||
|
||||
return (
|
||||
<AdminSection
|
||||
title="Shops"
|
||||
subtitle={`${shops} registered to ${company.name}.`}
|
||||
data={stores}
|
||||
pending={{
|
||||
icon: 'stores',
|
||||
title: 'Shops cannot be listed yet',
|
||||
description:
|
||||
n === 0
|
||||
? `${company.name} has not opened a shop. Once it does, its shops will be listed here when the platform admin shops API is connected.`
|
||||
: `The platform reports ${shops} for ${company.name}, but has no platform admin endpoint to list them. Connect the merchant-shops API to open each shop and its cameras from here.`,
|
||||
}}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title="No shops"
|
||||
description={`${company.name} has not opened a shop yet.`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => <StoreTable companyId={company.id} stores={rows} />}
|
||||
</AdminSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** A labelled fact with its icon. Not a KPI card: these do not trend. */
|
||||
function Fact({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
caption,
|
||||
}: {
|
||||
icon: IconKey;
|
||||
label: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
}) {
|
||||
return (
|
||||
<Card padding={4} className="h-full">
|
||||
<VStack gap={3} width="100%">
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<Text size="sm" color="secondary" weight="medium">
|
||||
{label}
|
||||
</Text>
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-7 rounded-md bg-muted shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS[icon]} size="sm" color="secondary" />
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Text size="xl" weight="semibold" className="tracking-tight">
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{caption}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -71,11 +71,11 @@ export function CreateCompanyDialog({
|
||||
// The platform's own wording. It names the rule that was hit — a
|
||||
// duplicate address, a slug already taken — and "Something went wrong"
|
||||
// would throw that away.
|
||||
setError(res.message ?? 'Could not create that company.');
|
||||
setError(res.message ?? 'Could not create that merchant.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreated({email: res.data.ownerEmail, password: res.data.password});
|
||||
setCreated({email: res.data.owner_email, password: res.data.password});
|
||||
// The company exists now, whether or not they close this dialog politely.
|
||||
onCreated();
|
||||
}
|
||||
@@ -99,11 +99,11 @@ export function CreateCompanyDialog({
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
purpose="info"
|
||||
width={460}
|
||||
aria-label="Create company"
|
||||
aria-label="Create merchant"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={created ? 'Company created' : 'Create company'}
|
||||
title={created ? 'Merchant created' : 'Create merchant'}
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
/>
|
||||
|
||||
@@ -128,24 +128,24 @@ export function CreateCompanyDialog({
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<TextInput
|
||||
label="Company name"
|
||||
label="Merchant name"
|
||||
value={companyName}
|
||||
onChange={setCompanyName}
|
||||
placeholder="TeNext Retail"
|
||||
placeholder="Business name"
|
||||
/>
|
||||
<TextInput
|
||||
label="Slug (optional)"
|
||||
value={slug}
|
||||
onChange={setSlug}
|
||||
placeholder="Derived from the name"
|
||||
description="Permanent once set — it becomes part of the company's broker topic."
|
||||
description="Permanent once set — it becomes part of the merchant's broker topic."
|
||||
/>
|
||||
<TextInput
|
||||
label="Owner email"
|
||||
type="email"
|
||||
value={ownerEmail}
|
||||
onChange={setOwnerEmail}
|
||||
placeholder="owner@company.com"
|
||||
placeholder="owner@merchant.com"
|
||||
/>
|
||||
<TextInput
|
||||
label="Owner name"
|
||||
@@ -165,7 +165,7 @@ export function CreateCompanyDialog({
|
||||
onClick={() => void submit()}
|
||||
isDisabled={!canSubmit}
|
||||
isLoading={busy}
|
||||
label="Create company"
|
||||
label="Create merchant"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
@@ -59,7 +59,7 @@ export function DeleteCompanyDialog({
|
||||
if (!res.ok) {
|
||||
// `still_active` and `storage_error` both name a condition the operator
|
||||
// can act on — suspend it first, or retry once storage is healthy.
|
||||
setError(res.message ?? 'Could not delete that company.');
|
||||
setError(res.message ?? 'Could not delete that merchant.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,11 +73,11 @@ export function DeleteCompanyDialog({
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="required"
|
||||
width={460}
|
||||
aria-label="Delete company"
|
||||
aria-label="Delete merchant"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title="Delete company?"
|
||||
title="Delete merchant?"
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
@@ -97,12 +97,12 @@ export function DeleteCompanyDialog({
|
||||
<Banner
|
||||
status="warning"
|
||||
title="Still active"
|
||||
description="The platform will refuse this until the company is suspended. Suspend it first, then come back."
|
||||
description="The platform will refuse this until the merchant is suspended. Suspend it first, then come back."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label="Type the company slug to confirm"
|
||||
label="Type the merchant slug to confirm"
|
||||
value={confirm}
|
||||
onChange={setConfirm}
|
||||
placeholder={company.slug}
|
||||
@@ -116,7 +116,7 @@ export function DeleteCompanyDialog({
|
||||
onClick={() => void submit()}
|
||||
isDisabled={!matches || busy}
|
||||
isLoading={busy}
|
||||
label="Delete company"
|
||||
label="Delete merchant"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
261
src/features/admin/components/MerchantsPanel.tsx
Normal file
261
src/features/admin/components/MerchantsPanel.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import {
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
} from '@astryxdesign/core/SegmentedControl';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import type {Breakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import {
|
||||
useCompanies,
|
||||
useCompanyFilters,
|
||||
} from '@/features/admin/hooks/useCompanies';
|
||||
import type {
|
||||
Company,
|
||||
CompanyFilter,
|
||||
CompanySort,
|
||||
} from '@/features/admin/types/company';
|
||||
import {CreateCompanyDialog} from './CreateCompanyDialog';
|
||||
import {
|
||||
CompanyActionDialog,
|
||||
CompanyActionsMenu,
|
||||
type CompanyDialog,
|
||||
} from './CompanyActions';
|
||||
import {adminHref} from './MonitoringTables';
|
||||
|
||||
/**
|
||||
* Every merchant on the platform, one card each.
|
||||
*
|
||||
* ── A card shows exactly what the API returns ────────────────────────────
|
||||
* Name, slug, status, shop count, account count, created — the whole
|
||||
* `GET /api/admin/clients` row. No owner, plan, revenue or health: a field the
|
||||
* row cannot fill would have to be faked.
|
||||
*
|
||||
* ── The card is the way in ───────────────────────────────────────────────
|
||||
* The whole card opens /admin/merchants/{id}. Its menu carries the row
|
||||
* actions; the dialogs those open are rendered at panel level, outside the
|
||||
* card, so a click inside a dialog can never also navigate.
|
||||
*
|
||||
* ── Suspended merchants stay in the default list ─────────────────────────
|
||||
* A suspended merchant is the one somebody is on the phone about. The filter
|
||||
* is there to narrow deliberately.
|
||||
*/
|
||||
|
||||
const SORTS: {value: CompanySort; label: string}[] = [
|
||||
{value: 'newest', label: 'Newest first'},
|
||||
{value: 'oldest', label: 'Oldest first'},
|
||||
{value: 'name', label: 'Name A–Z'},
|
||||
{value: 'shops', label: 'Most shops'},
|
||||
];
|
||||
|
||||
function cardColumns(bp: Breakpoint, isPanelOpen: boolean): number {
|
||||
if (bp === 'mobile') return 1;
|
||||
if (bp === 'tablet') return 2;
|
||||
if (bp === 'laptop') return isPanelOpen ? 2 : 3;
|
||||
if (bp === 'desktop') return 3;
|
||||
return isPanelOpen ? 3 : 4;
|
||||
}
|
||||
|
||||
const plural = (n: number, one: string, many: string) =>
|
||||
`${n.toLocaleString()} ${n === 1 ? one : many}`;
|
||||
|
||||
export function MerchantsPanel() {
|
||||
const companies = useCompanies();
|
||||
const {query, setQuery, filter, setFilter, sort, setSort, visible} =
|
||||
useCompanyFilters(companies.data);
|
||||
const [isCreating, setCreating] = useState(false);
|
||||
const [action, setAction] = useState<{
|
||||
company: Company;
|
||||
dialog: CompanyDialog;
|
||||
} | null>(null);
|
||||
const bp = useBreakpoint();
|
||||
const {isOpen} = useLoyalyAi();
|
||||
const columns = cardColumns(bp, isOpen);
|
||||
|
||||
/**
|
||||
* Nothing optimistic. Every mutation refetches and the cards redraw from
|
||||
* what the server says.
|
||||
*/
|
||||
function refresh() {
|
||||
companies.refetch();
|
||||
}
|
||||
|
||||
const create = (
|
||||
<Button size="sm" onClick={() => setCreating(true)} label="Create merchant" />
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={4} width="100%">
|
||||
<HStack
|
||||
gap={3}
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
width="100%"
|
||||
className="flex-wrap gap-y-2"
|
||||
>
|
||||
<HStack gap={3} vAlign="center" className="flex-wrap gap-y-2">
|
||||
<TextInput
|
||||
label="Search merchants"
|
||||
isLabelHidden
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search by name or slug…"
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={filter}
|
||||
onChange={(v) => setFilter(v as CompanyFilter)}
|
||||
label="Filter by status"
|
||||
>
|
||||
<SegmentedControlItem value="all" label="All" />
|
||||
<SegmentedControlItem value="active" label="Active" />
|
||||
<SegmentedControlItem value="suspended" label="Suspended" />
|
||||
</SegmentedControl>
|
||||
</HStack>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Selector
|
||||
label="Sort merchants"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
options={SORTS}
|
||||
value={sort}
|
||||
onChange={(v) => setSort(v as CompanySort)}
|
||||
/>
|
||||
{create}
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={companies}
|
||||
loading={<SkeletonMetricGrid columns={columns} count={columns * 2} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="companies"
|
||||
title="No merchants yet"
|
||||
description="Create your first merchant to get started."
|
||||
actions={create}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(all) =>
|
||||
visible.length === 0 ? (
|
||||
/* A search that matches nothing is NOT a platform with no
|
||||
merchants — saying "no merchants yet" here would tell an
|
||||
operator their tenants had vanished. */
|
||||
<Text size="sm" color="secondary">
|
||||
No merchants match these filters.{' '}
|
||||
{plural(all.length, 'merchant', 'merchants')} in total.
|
||||
</Text>
|
||||
) : (
|
||||
<Grid columns={columns} gap={4}>
|
||||
{visible.map((c) => (
|
||||
<MerchantCard
|
||||
key={c.id}
|
||||
company={c}
|
||||
onAction={(dialog) => setAction({company: c, dialog})}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
</AsyncBoundary>
|
||||
|
||||
{isCreating ? (
|
||||
<CreateCompanyDialog
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={refresh}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{action ? (
|
||||
<CompanyActionDialog
|
||||
company={action.company}
|
||||
dialog={action.dialog}
|
||||
onClose={() => setAction(null)}
|
||||
onChanged={refresh}
|
||||
onDeleted={refresh}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function MerchantCard({
|
||||
company,
|
||||
onAction,
|
||||
}: {
|
||||
company: Company;
|
||||
onAction: (dialog: CompanyDialog) => void;
|
||||
}) {
|
||||
const href = adminHref.company(company.id);
|
||||
const status = company.isActive ? 'Active' : 'Suspended';
|
||||
|
||||
return (
|
||||
<ClickableCard
|
||||
label={`Open ${company.name}`}
|
||||
href={href}
|
||||
padding={4}
|
||||
className="h-full"
|
||||
>
|
||||
<VStack gap={4} width="100%" height="100%" vAlign="between">
|
||||
<HStack gap={3} vAlign="start" hAlign="between" width="100%">
|
||||
<HStack gap={3} vAlign="center" className="min-w-0">
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-9 rounded-md bg-muted shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS.companies} size="sm" color="secondary" />
|
||||
</HStack>
|
||||
<VStack gap={0} className="min-w-0">
|
||||
<Text weight="medium" className="truncate">
|
||||
{company.name}
|
||||
</Text>
|
||||
{/* The slug is what the remove confirmation asks for. */}
|
||||
<Text size="xsm" color="secondary" className="truncate">
|
||||
{company.slug}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<CompanyActionsMenu
|
||||
company={company}
|
||||
onSelect={onAction}
|
||||
viewHref={href}
|
||||
isIconOnly
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<VStack gap={2} width="100%">
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={company.isActive ? 'success' : 'error'}
|
||||
label={status}
|
||||
/>
|
||||
<Text size="sm" color="secondary">
|
||||
{status}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
{plural(company.sites, 'shop', 'shops')} ·{' '}
|
||||
{plural(company.users, 'account', 'accounts')} · created{' '}
|
||||
{new Date(company.createdAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</ClickableCard>
|
||||
);
|
||||
}
|
||||
299
src/features/admin/components/MonitoringTables.tsx
Normal file
299
src/features/admin/components/MonitoringTables.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
'use client';
|
||||
|
||||
import {proportional} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Link} from '@astryxdesign/core/Link';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import type {
|
||||
AdminAlert,
|
||||
AdminCamera,
|
||||
AdminCameraStatus,
|
||||
AdminEvent,
|
||||
AdminSeverity,
|
||||
AdminStore,
|
||||
} from '@/features/admin/types/monitoring';
|
||||
|
||||
/**
|
||||
* The loaded branch of every drill-down section.
|
||||
*
|
||||
* None of these render today — every capability behind them is off, so their
|
||||
* sections show PendingIntegration instead. They exist so that turning a level
|
||||
* on is a flag flip: the rows are already written against the shapes in
|
||||
* types/monitoring.ts, and a missing field prints a dash, never a guess.
|
||||
*/
|
||||
|
||||
const DASH = '—';
|
||||
|
||||
function when(iso?: string): string {
|
||||
return iso ? new Date(iso).toLocaleString() : DASH;
|
||||
}
|
||||
|
||||
export const adminHref = {
|
||||
merchants: '/admin/merchants',
|
||||
company: (companyId: string) =>
|
||||
`/admin/merchants/${encodeURIComponent(companyId)}`,
|
||||
store: (companyId: string, storeId: string) =>
|
||||
`${adminHref.company(companyId)}/shops/${encodeURIComponent(storeId)}`,
|
||||
camera: (companyId: string, storeId: string, cameraId: string) =>
|
||||
`${adminHref.store(companyId, storeId)}/cameras/${encodeURIComponent(cameraId)}`,
|
||||
};
|
||||
|
||||
/* ── Stores ──────────────────────────────────────────────────────────── */
|
||||
|
||||
type StoreRow = Record<string, unknown> & {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
isOnline?: boolean;
|
||||
timezone: string;
|
||||
cameras: string;
|
||||
online: string;
|
||||
};
|
||||
|
||||
export function StoreTable({
|
||||
companyId,
|
||||
stores,
|
||||
}: {
|
||||
companyId: string;
|
||||
stores: AdminStore[];
|
||||
}) {
|
||||
const columns: TableColumn<StoreRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Shop',
|
||||
width: proportional(3),
|
||||
renderCell: (row) => (
|
||||
<Link href={adminHref.store(companyId, row.id)}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'isOnline',
|
||||
header: 'Status',
|
||||
width: proportional(1),
|
||||
renderCell: (row) =>
|
||||
row.isOnline === undefined ? (
|
||||
<Text size="sm">{DASH}</Text>
|
||||
) : (
|
||||
<CameraStatus status={row.isOnline ? 'online' : 'offline'} />
|
||||
),
|
||||
},
|
||||
{key: 'timezone', header: 'Timezone', width: proportional(2)},
|
||||
{key: 'cameras', header: 'Cameras', width: proportional(1), align: 'end'},
|
||||
{key: 'online', header: 'Online', width: proportional(1), align: 'end'},
|
||||
];
|
||||
const rows: StoreRow[] = stores.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name || s.slug,
|
||||
slug: s.slug,
|
||||
isOnline: s.isOnline,
|
||||
timezone: s.timezone ?? DASH,
|
||||
cameras: s.cameras === undefined ? DASH : String(s.cameras),
|
||||
online: s.camerasOnline === undefined ? DASH : String(s.camerasOnline),
|
||||
}));
|
||||
return (
|
||||
<ResponsiveTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
density="balanced"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Cameras ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const CAMERA_STATUS: Record<
|
||||
AdminCameraStatus,
|
||||
{label: string; dot: 'success' | 'error' | 'neutral'}
|
||||
> = {
|
||||
online: {label: 'Online', dot: 'success'},
|
||||
offline: {label: 'Offline', dot: 'error'},
|
||||
unknown: {label: 'Unknown', dot: 'neutral'},
|
||||
};
|
||||
|
||||
export function CameraStatus({status}: {status: AdminCameraStatus}) {
|
||||
const s = CAMERA_STATUS[status];
|
||||
return (
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot variant={s.dot} label={s.label} />
|
||||
<Text size="sm" color="secondary">
|
||||
{s.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
type CameraRow = Record<string, unknown> & {
|
||||
id: string;
|
||||
name: string;
|
||||
zone: string;
|
||||
status: AdminCameraStatus;
|
||||
lastSeen: string;
|
||||
};
|
||||
|
||||
export function CameraTable({
|
||||
companyId,
|
||||
storeId,
|
||||
cameras,
|
||||
}: {
|
||||
companyId: string;
|
||||
storeId: string;
|
||||
cameras: AdminCamera[];
|
||||
}) {
|
||||
const columns: TableColumn<CameraRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Camera',
|
||||
width: proportional(3),
|
||||
renderCell: (row) => (
|
||||
<Link href={adminHref.camera(companyId, storeId, row.id)}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{key: 'zone', header: 'Location', width: proportional(2)},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => <CameraStatus status={row.status} />,
|
||||
},
|
||||
{key: 'lastSeen', header: 'Last seen', width: proportional(2), align: 'end'},
|
||||
];
|
||||
const rows: CameraRow[] = cameras.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
zone: c.zone ?? DASH,
|
||||
status: c.status,
|
||||
lastSeen: when(c.lastSeenAt),
|
||||
}));
|
||||
return (
|
||||
<ResponsiveTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
summaryKeys={['status', 'lastSeen']}
|
||||
density="balanced"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Events and alerts ───────────────────────────────────────────────── */
|
||||
|
||||
const SEVERITY: Record<
|
||||
AdminSeverity,
|
||||
{label: string; dot: 'error' | 'warning' | 'neutral'}
|
||||
> = {
|
||||
critical: {label: 'Critical', dot: 'error'},
|
||||
warning: {label: 'Warning', dot: 'warning'},
|
||||
info: {label: 'Info', dot: 'neutral'},
|
||||
};
|
||||
|
||||
function Severity({severity}: {severity?: AdminSeverity}) {
|
||||
if (!severity) return <Text size="sm">{DASH}</Text>;
|
||||
const s = SEVERITY[severity];
|
||||
return (
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot variant={s.dot} label={s.label} />
|
||||
<Text size="sm" color="secondary">
|
||||
{s.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
type EventRow = Record<string, unknown> & {
|
||||
id: string;
|
||||
at: string;
|
||||
type: string;
|
||||
camera: string;
|
||||
severity?: AdminSeverity;
|
||||
};
|
||||
|
||||
export function EventTable({events}: {events: AdminEvent[]}) {
|
||||
const columns: TableColumn<EventRow>[] = [
|
||||
{key: 'at', header: 'Time', width: proportional(2)},
|
||||
{key: 'type', header: 'Event', width: proportional(2)},
|
||||
{key: 'camera', header: 'Camera', width: proportional(2)},
|
||||
{
|
||||
key: 'severity',
|
||||
header: 'Severity',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => <Severity severity={row.severity} />,
|
||||
},
|
||||
];
|
||||
const rows: EventRow[] = events.map((e) => ({
|
||||
id: e.id,
|
||||
at: when(e.at),
|
||||
type: e.type,
|
||||
camera: e.cameraName ?? DASH,
|
||||
severity: e.severity,
|
||||
}));
|
||||
return (
|
||||
<ResponsiveTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="id"
|
||||
primaryKey="type"
|
||||
density="compact"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type AlertRow = Record<string, unknown> & {
|
||||
id: string;
|
||||
at: string;
|
||||
title: string;
|
||||
camera: string;
|
||||
severity: AdminSeverity;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const ALERT_STATUS: Record<AdminAlert['status'], string> = {
|
||||
open: 'Open',
|
||||
acknowledged: 'Acknowledged',
|
||||
resolved: 'Resolved',
|
||||
};
|
||||
|
||||
export function AlertTable({alerts}: {alerts: AdminAlert[]}) {
|
||||
const columns: TableColumn<AlertRow>[] = [
|
||||
{
|
||||
key: 'severity',
|
||||
header: 'Severity',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => <Severity severity={row.severity} />,
|
||||
},
|
||||
{key: 'title', header: 'Alert', width: proportional(3)},
|
||||
{key: 'camera', header: 'Camera', width: proportional(2)},
|
||||
{key: 'at', header: 'Time', width: proportional(2)},
|
||||
{key: 'status', header: 'Status', width: proportional(1)},
|
||||
];
|
||||
const rows: AlertRow[] = alerts.map((a) => ({
|
||||
id: a.id,
|
||||
at: when(a.at),
|
||||
title: a.title,
|
||||
camera: a.cameraName ?? DASH,
|
||||
severity: a.severity,
|
||||
status: ALERT_STATUS[a.status],
|
||||
}));
|
||||
return (
|
||||
<ResponsiveTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="id"
|
||||
primaryKey="title"
|
||||
density="compact"
|
||||
/>
|
||||
);
|
||||
}
|
||||
821
src/features/admin/components/PlatformOverview.tsx
Normal file
821
src/features/admin/components/PlatformOverview.tsx
Normal file
@@ -0,0 +1,821 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Link} from '@astryxdesign/core/Link';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Token} from '@astryxdesign/core/Token';
|
||||
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {HoverLift} from '@/shared/components/motion/HoverLift';
|
||||
import {AnimatedNumber} from '@/shared/components/motion/AnimatedNumber';
|
||||
import {
|
||||
SkeletonMetricGrid,
|
||||
SkeletonRows,
|
||||
} from '@/shared/components/patterns/LoadingState';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {useCompanies} from '@/features/admin/hooks/useCompanies';
|
||||
import {summarise} from '@/features/admin/hooks/useMonitoring';
|
||||
import {ADMIN_CAPABILITIES} from '@/features/admin/config/capabilities';
|
||||
import type {AdminCapability} from '@/features/admin/config/capabilities';
|
||||
import {AdminPageHeader} from './common/AdminPageHeader';
|
||||
import {CreateCompanyDialog} from './CreateCompanyDialog';
|
||||
import {adminHref} from './MonitoringTables';
|
||||
|
||||
/**
|
||||
* The landing page for a platform operator: Admin → Merchant → Shop.
|
||||
*
|
||||
* ── Every number here is a sum over `GET /api/admin/clients` ─────────────
|
||||
* Merchants, active, shops and accounts are the list's own `active`, `sites`
|
||||
* and `users` fields, added up. Nothing is estimated, and no metric the
|
||||
* platform cannot report (cameras, alerts, events) is shown at all.
|
||||
*
|
||||
* The two lists below the KPIs are also derived from the same rows: active
|
||||
* merchants with no shop (onboarded but never set up — the likeliest support
|
||||
* call) and the newest merchants.
|
||||
*/
|
||||
// Rounded: AnimatedNumber springs between values and hands this every
|
||||
// in-between frame — unrounded, 6 → 4 shops paints as "3.108".
|
||||
const fmt = (v: number) => Math.round(v).toLocaleString();
|
||||
|
||||
const RECENT_COUNT = 5;
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function PlatformOverview() {
|
||||
const companies = useCompanies();
|
||||
const bp = useBreakpoint();
|
||||
const [isCreating, setCreating] = useState(false);
|
||||
|
||||
// Responsive column budget: 1 on mobile, 2 on tablet, 4 on desktop
|
||||
const columns = bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 4;
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader
|
||||
title="Platform overview"
|
||||
subtitle="Merchants, their shops and accounts across the Loyaly platform."
|
||||
meta={
|
||||
<HStack gap={2} vAlign="center" className="flex-wrap">
|
||||
<HStack
|
||||
gap={1.5}
|
||||
vAlign="center"
|
||||
className="px-2.5 py-1 rounded-full bg-muted border border-border"
|
||||
>
|
||||
<StatusDot variant="success" label="Platform operational" />
|
||||
<Text size="xsm" weight="medium">
|
||||
Platform Operational
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
·
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
Central Multi-tenant Console
|
||||
</Text>
|
||||
</HStack>
|
||||
}
|
||||
actions={
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
label="View directory"
|
||||
href={adminHref.merchants}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
label="Create merchant"
|
||||
icon={<Icon icon={ICONS.plus} size="sm" />}
|
||||
onClick={() => setCreating(true)}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={companies}
|
||||
loading={<SkeletonMetricGrid columns={columns} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="companies"
|
||||
title="No merchants yet"
|
||||
description="Totals appear here once the first merchant is created."
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
label="Create merchant"
|
||||
icon={<Icon icon={ICONS.plus} size="sm" />}
|
||||
onClick={() => setCreating(true)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => {
|
||||
const s = summarise(rows);
|
||||
const activePercent =
|
||||
s.companies > 0 ? Math.round((s.active / s.companies) * 100) : 100;
|
||||
const avgStores = (s.stores / (s.active || 1)).toFixed(1);
|
||||
const avgUsers = (s.accounts / (s.companies || 1)).toFixed(1);
|
||||
|
||||
return (
|
||||
<Grid columns={columns} gap={4}>
|
||||
{/* Card 1: Registered Merchants */}
|
||||
<HoverLift>
|
||||
<Card padding={4} elevation="low" className="h-full">
|
||||
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
||||
<VStack gap={2} width="100%">
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<Text size="sm" color="secondary" weight="medium">
|
||||
Merchants
|
||||
</Text>
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-8 rounded-lg bg-muted text-primary shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS.companies} size="sm" />
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} vAlign="end">
|
||||
<Text
|
||||
size="2xl"
|
||||
weight="semibold"
|
||||
className="tracking-tight"
|
||||
>
|
||||
<AnimatedNumber value={s.companies} format={fmt} />
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
tenants
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<VStack gap={2} width="100%">
|
||||
<ProgressBar
|
||||
value={activePercent}
|
||||
label="Active rate"
|
||||
isLabelHidden
|
||||
variant="success"
|
||||
/>
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot variant="success" label="Active" />
|
||||
<Text size="xsm" color="secondary">
|
||||
{s.active} active
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
{s.suspended} suspended
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
|
||||
{/* Card 2: Active Merchants */}
|
||||
<HoverLift>
|
||||
<Card padding={4} elevation="low" className="h-full">
|
||||
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
||||
<VStack gap={2} width="100%">
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<Text size="sm" color="secondary" weight="medium">
|
||||
Active merchants
|
||||
</Text>
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-8 rounded-lg bg-muted text-primary shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS.present} size="sm" />
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} vAlign="end">
|
||||
<Text
|
||||
size="2xl"
|
||||
weight="semibold"
|
||||
className="tracking-tight"
|
||||
>
|
||||
<AnimatedNumber value={s.active} format={fmt} />
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
active
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<HStack
|
||||
hAlign="between"
|
||||
vAlign="center"
|
||||
width="100%"
|
||||
className="pt-2 border-t border-border"
|
||||
>
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={
|
||||
s.active === s.companies ? 'success' : 'warning'
|
||||
}
|
||||
label="Health"
|
||||
/>
|
||||
<Text size="xsm" weight="medium">
|
||||
{activePercent}% active rate
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
{s.companies - s.active === 0
|
||||
? 'All nominal'
|
||||
: `${s.companies - s.active} inactive`}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
|
||||
{/* Card 3: Shops */}
|
||||
<HoverLift>
|
||||
<Card padding={4} elevation="low" className="h-full">
|
||||
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
||||
<VStack gap={2} width="100%">
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<Text size="sm" color="secondary" weight="medium">
|
||||
Shops
|
||||
</Text>
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-8 rounded-lg bg-muted text-primary shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS.stores} size="sm" />
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} vAlign="end">
|
||||
<Text
|
||||
size="2xl"
|
||||
weight="semibold"
|
||||
className="tracking-tight"
|
||||
>
|
||||
<AnimatedNumber value={s.stores} format={fmt} />
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
locations
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<HStack
|
||||
hAlign="between"
|
||||
vAlign="center"
|
||||
width="100%"
|
||||
className="pt-2 border-t border-border"
|
||||
>
|
||||
<Text size="xsm" color="secondary" className="truncate">
|
||||
{s.active} active {s.active === 1 ? 'client' : 'clients'}
|
||||
</Text>
|
||||
<Token size="sm" label={`${avgStores} avg / tenant`} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
|
||||
{/* Card 4: Accounts */}
|
||||
<HoverLift>
|
||||
<Card padding={4} elevation="low" className="h-full">
|
||||
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
||||
<VStack gap={2} width="100%">
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<Text size="sm" color="secondary" weight="medium">
|
||||
Merchant accounts
|
||||
</Text>
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-8 rounded-lg bg-muted text-primary shrink-0"
|
||||
>
|
||||
<Icon icon={ICONS.staff} size="sm" />
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} vAlign="end">
|
||||
<Text
|
||||
size="2xl"
|
||||
weight="semibold"
|
||||
className="tracking-tight"
|
||||
>
|
||||
<AnimatedNumber value={s.accounts} format={fmt} />
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
credentials
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<HStack
|
||||
hAlign="between"
|
||||
vAlign="center"
|
||||
width="100%"
|
||||
className="pt-2 border-t border-border"
|
||||
>
|
||||
<Text size="xsm" color="secondary" className="truncate">
|
||||
Across {s.companies}{' '}
|
||||
{s.companies === 1 ? 'merchant' : 'merchants'}
|
||||
</Text>
|
||||
<Token size="sm" label={`${avgUsers} users / tenant`} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
</Grid>
|
||||
);
|
||||
}}
|
||||
</AsyncBoundary>
|
||||
|
||||
<Grid columns={bp === 'mobile' || bp === 'tablet' ? 1 : 2} gap={4}>
|
||||
{/* Left Panel: Merchant Setup Status */}
|
||||
<Card padding={5} elevation="low">
|
||||
<VStack gap={4} width="100%">
|
||||
<HStack hAlign="between" vAlign="start" width="100%">
|
||||
<VStack gap={0.5}>
|
||||
<Text size="lg" weight="semibold">
|
||||
Merchant setup status
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
Active merchants that have not opened a shop yet.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={companies}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
>
|
||||
{(rows) => {
|
||||
const s = summarise(rows);
|
||||
const pending = s.withoutStores;
|
||||
|
||||
if (pending.length === 0) {
|
||||
return (
|
||||
<VStack gap={4} width="100%">
|
||||
<HStack
|
||||
gap={3}
|
||||
vAlign="center"
|
||||
className="p-4 rounded-xl bg-muted/60 border border-border"
|
||||
>
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-10 rounded-full bg-surface text-primary border border-border shrink-0 shadow-sm"
|
||||
>
|
||||
<Icon icon={ICONS.roles} size="md" />
|
||||
</HStack>
|
||||
<VStack gap={0.5} className="min-w-0">
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text weight="semibold">
|
||||
All merchants operational
|
||||
</Text>
|
||||
<Token size="sm" label="100% Configured" />
|
||||
</HStack>
|
||||
<Text size="sm" color="secondary">
|
||||
Every active merchant has at least one shop
|
||||
configured and provisioned for commerce.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<Grid columns={3} gap={2}>
|
||||
<Card padding={3} variant="muted">
|
||||
<VStack gap={0.5}>
|
||||
<Text size="xsm" color="secondary">
|
||||
Setup complete
|
||||
</Text>
|
||||
<Text size="base" weight="semibold">
|
||||
{s.active} / {s.companies}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
100% active rate
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
<Card padding={3} variant="muted">
|
||||
<VStack gap={0.5}>
|
||||
<Text size="xsm" color="secondary">
|
||||
Live storefronts
|
||||
</Text>
|
||||
<Text size="base" weight="semibold">
|
||||
{s.stores}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
retail locations
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
<Card padding={3} variant="muted">
|
||||
<VStack gap={0.5}>
|
||||
<Text size="xsm" color="secondary">
|
||||
Pending setup
|
||||
</Text>
|
||||
<Text size="base" weight="semibold">
|
||||
0
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
no backlog
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<HStack hAlign="between" vAlign="center" className="pt-1">
|
||||
<Text size="xsm" color="secondary">
|
||||
Unconfigured accounts will be highlighted here.
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label="+ Add merchant"
|
||||
onClick={() => setCreating(true)}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={3} width="100%">
|
||||
<HStack
|
||||
gap={2}
|
||||
vAlign="center"
|
||||
className="p-3 rounded-lg bg-muted border border-border"
|
||||
>
|
||||
<StatusDot variant="warning" label="Pending" />
|
||||
<Text size="sm" weight="medium">
|
||||
{pending.length}{' '}
|
||||
{pending.length === 1
|
||||
? 'merchant requires'
|
||||
: 'merchants require'}{' '}
|
||||
shop setup.
|
||||
</Text>
|
||||
</HStack>
|
||||
<VStack gap={2} width="100%">
|
||||
{pending.map((c) => (
|
||||
<ClickableCard
|
||||
key={c.id}
|
||||
href={adminHref.company(c.id)}
|
||||
label={`Set up ${c.name}`}
|
||||
padding={3}
|
||||
elevation="none"
|
||||
className="hover:border-primary transition-all"
|
||||
>
|
||||
<HStack
|
||||
hAlign="between"
|
||||
vAlign="center"
|
||||
width="100%"
|
||||
gap={3}
|
||||
>
|
||||
<HStack gap={3} vAlign="center" className="min-w-0">
|
||||
<Avatar name={c.name} size="sm" tooltip={false} />
|
||||
<VStack gap={0.5} className="min-w-0">
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text weight="semibold" className="truncate">
|
||||
{c.name}
|
||||
</Text>
|
||||
<Token size="sm" label={`@${c.slug}`} />
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
0 shops · {c.users}{' '}
|
||||
{c.users === 1 ? 'account' : 'accounts'} ·
|
||||
Added {formatDate(c.createdAt)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack
|
||||
gap={2}
|
||||
vAlign="center"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Token size="sm" label="Needs shop" />
|
||||
<Icon
|
||||
icon={ICONS.arrowRight}
|
||||
size="sm"
|
||||
color="secondary"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</ClickableCard>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}}
|
||||
</AsyncBoundary>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* Right Panel: Recently Added Merchants */}
|
||||
<Card padding={5} elevation="low">
|
||||
<VStack gap={4} width="100%">
|
||||
<HStack hAlign="between" vAlign="start" width="100%">
|
||||
<VStack gap={0.5}>
|
||||
<Text size="lg" weight="semibold">
|
||||
Recently added merchants
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
The newest merchants on the platform.
|
||||
</Text>
|
||||
</VStack>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label="View all"
|
||||
href={adminHref.merchants}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={companies}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
>
|
||||
{(rows) => {
|
||||
const recent = summarise(rows).recent.slice(0, RECENT_COUNT);
|
||||
return (
|
||||
<VStack gap={2} width="100%">
|
||||
{recent.map((c) => (
|
||||
<ClickableCard
|
||||
key={c.id}
|
||||
href={adminHref.company(c.id)}
|
||||
label={`View ${c.name}`}
|
||||
padding={3}
|
||||
elevation="none"
|
||||
className="hover:border-primary transition-all"
|
||||
>
|
||||
<HStack
|
||||
hAlign="between"
|
||||
vAlign="center"
|
||||
width="100%"
|
||||
gap={3}
|
||||
>
|
||||
<HStack gap={3} vAlign="center" className="min-w-0">
|
||||
<Avatar name={c.name} size="sm" tooltip={false} />
|
||||
<VStack gap={0.5} className="min-w-0">
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text weight="semibold" className="truncate">
|
||||
{c.name}
|
||||
</Text>
|
||||
<Token size="sm" label={`@${c.slug}`} />
|
||||
</HStack>
|
||||
<HStack
|
||||
gap={2}
|
||||
vAlign="center"
|
||||
className="flex-wrap"
|
||||
>
|
||||
<HStack gap={1} vAlign="center">
|
||||
<Icon
|
||||
icon={ICONS.stores}
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
/>
|
||||
<Text size="xsm" color="secondary">
|
||||
{c.sites} {c.sites === 1 ? 'shop' : 'shops'}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
·
|
||||
</Text>
|
||||
<HStack gap={1} vAlign="center">
|
||||
<Icon
|
||||
icon={ICONS.staff}
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
/>
|
||||
<Text size="xsm" color="secondary">
|
||||
{c.users}{' '}
|
||||
{c.users === 1 ? 'account' : 'accounts'}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
·
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
Added {formatDate(c.createdAt)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<HStack
|
||||
gap={2}
|
||||
vAlign="center"
|
||||
className="shrink-0"
|
||||
>
|
||||
<StatusDot
|
||||
variant={c.isActive ? 'success' : 'error'}
|
||||
label={c.isActive ? 'Active' : 'Suspended'}
|
||||
/>
|
||||
<Icon
|
||||
icon={ICONS.arrowRight}
|
||||
size="sm"
|
||||
color="secondary"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</ClickableCard>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}}
|
||||
</AsyncBoundary>
|
||||
</VStack>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<MonitoringCoverage />
|
||||
|
||||
{isCreating ? (
|
||||
<CreateCompanyDialog
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={() => companies.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which rungs of the drill-down the platform can answer today, read straight
|
||||
* from ADMIN_CAPABILITIES — so this panel cannot drift from what the pages
|
||||
* actually do.
|
||||
*/
|
||||
interface CoverageItem {
|
||||
label: string;
|
||||
capability: AdminCapability;
|
||||
description: string;
|
||||
icon: typeof ICONS.companies;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const COVERAGE: CoverageItem[] = [
|
||||
{
|
||||
label: 'Merchants',
|
||||
capability: 'companies',
|
||||
description:
|
||||
'Tenant isolation, company lifecycle, and operator credential provisioning.',
|
||||
icon: ICONS.companies,
|
||||
href: adminHref.merchants,
|
||||
},
|
||||
{
|
||||
label: 'Merchant shops',
|
||||
capability: 'companyStores',
|
||||
description:
|
||||
'Storefront network topology, physical address mapping, and device linkages.',
|
||||
icon: ICONS.stores,
|
||||
},
|
||||
{
|
||||
label: 'Footfall',
|
||||
capability: 'footfall',
|
||||
description:
|
||||
'Walk-in visitor velocity, camera detection streams, and dwell-time analytics.',
|
||||
icon: ICONS.visitors,
|
||||
href: '/admin/footfall',
|
||||
},
|
||||
{
|
||||
label: 'Commerce',
|
||||
capability: 'commerce',
|
||||
description:
|
||||
'Point-of-sale transactions, daily revenue tracking, and basket size metrics.',
|
||||
icon: ICONS.commerce,
|
||||
href: '/admin/commerce',
|
||||
},
|
||||
{
|
||||
label: 'Platform Loyaly AI',
|
||||
capability: 'platformAssistant',
|
||||
description:
|
||||
'Cross-merchant operational copilot, anomaly diagnosis, and assistant rail.',
|
||||
icon: ICONS.ai,
|
||||
},
|
||||
];
|
||||
|
||||
function MonitoringCoverage() {
|
||||
const bp = useBreakpoint();
|
||||
const liveCount = COVERAGE.filter(
|
||||
(c) => ADMIN_CAPABILITIES[c.capability],
|
||||
).length;
|
||||
|
||||
return (
|
||||
<Card padding={5} elevation="low">
|
||||
<VStack gap={5} width="100%">
|
||||
<HStack
|
||||
hAlign="between"
|
||||
vAlign="center"
|
||||
width="100%"
|
||||
className="flex-wrap gap-y-2"
|
||||
>
|
||||
<VStack gap={0.5}>
|
||||
<Text size="lg" weight="semibold">
|
||||
Monitoring coverage
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
What this console can read from the platform today.
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack
|
||||
gap={1.5}
|
||||
vAlign="center"
|
||||
className="px-3 py-1 rounded-full bg-muted border border-border"
|
||||
>
|
||||
<StatusDot variant="success" label="Live systems" />
|
||||
<Text size="xsm" weight="medium">
|
||||
{liveCount} of {COVERAGE.length} Subsystems Live
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Grid columns={bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 3} gap={3}>
|
||||
{COVERAGE.map(({label, capability, description, icon, href}) => {
|
||||
const live = ADMIN_CAPABILITIES[capability];
|
||||
return (
|
||||
<Card
|
||||
key={capability}
|
||||
padding={4}
|
||||
variant="muted"
|
||||
className="h-full border border-border"
|
||||
>
|
||||
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
||||
<VStack gap={2} width="100%">
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<HStack
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="size-8 rounded-lg bg-surface text-primary border border-border shrink-0 shadow-sm"
|
||||
>
|
||||
<Icon icon={icon} size="sm" />
|
||||
</HStack>
|
||||
{live ? (
|
||||
<HStack
|
||||
gap={1.5}
|
||||
vAlign="center"
|
||||
className="px-2 py-0.5 rounded-full bg-surface border border-border"
|
||||
>
|
||||
<StatusDot variant="success" label="Live" />
|
||||
<Text size="xsm" weight="medium">
|
||||
Live
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Token
|
||||
size="sm"
|
||||
label="Backend integration required"
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<VStack gap={0.5} width="100%">
|
||||
<Text weight="semibold">{label}</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{description}
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{href && live ? (
|
||||
<HStack
|
||||
width="100%"
|
||||
className="pt-2 border-t border-border"
|
||||
>
|
||||
<Link href={href} className="text-xs">
|
||||
Open {label.toLowerCase()} →
|
||||
</Link>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
336
src/features/admin/components/PlatformPages.tsx
Normal file
336
src/features/admin/components/PlatformPages.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import type {SelectorOptionType} from '@astryxdesign/core/Selector';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {MetricCard} from '@/shared/components/patterns/MetricCard';
|
||||
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {useCompanies} from '@/features/admin/hooks/useCompanies';
|
||||
import {useAdminData} from '@/features/admin/hooks/useMonitoring';
|
||||
import {useAiContext} from '@/features/admin/hooks/useAiContext';
|
||||
import {storeRepository} from '@/features/admin/repositories/monitoringRepository';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
import type {AnalyticsFilters, DateRange} from '@/features/admin/types/analytics';
|
||||
import {AdminPageHeader} from './common/AdminPageHeader';
|
||||
import {FootfallDashboard} from './analytics/FootfallDashboard';
|
||||
import {CommerceDashboard} from './analytics/CommerceDashboard';
|
||||
|
||||
/**
|
||||
* Footfall and Commerce — platform analytics over per-shop data.
|
||||
*
|
||||
* ── The hierarchy is the filter bar ──────────────────────────────────────
|
||||
* Footfall Merchant → Area → Shop → Date range
|
||||
* Commerce Merchant → Shop → Date range
|
||||
* Each control narrows the next: changing the merchant clears the area and the
|
||||
* shop, and a shop is only ever offered as a shop of the selected merchant.
|
||||
* The filters flow into analyticsRepository as query parameters, so every
|
||||
* chart refetches under the new scope and none can show another merchant's
|
||||
* rows (docs/ADMIN-MONITORING-API.md §2.2 Ownership).
|
||||
*
|
||||
* ── What is real today ───────────────────────────────────────────────────
|
||||
* The merchant list and the scope cards (sums over `GET /api/admin/clients`),
|
||||
* and the date range. Every chart and table is built and wired; each renders
|
||||
* its "backend integration required" frame until its endpoint ships, then
|
||||
* fills in with no component change (config/capabilities.ts).
|
||||
*/
|
||||
|
||||
const ALL = 'all';
|
||||
|
||||
type Kind = 'footfall' | 'commerce';
|
||||
|
||||
const COPY: Record<Kind, {title: string; subtitle: string}> = {
|
||||
footfall: {
|
||||
title: 'Footfall',
|
||||
subtitle: 'Walk-ins by merchant, area and shop, day by day.',
|
||||
},
|
||||
commerce: {
|
||||
title: 'Commerce',
|
||||
subtitle: 'Sales and transactions by merchant and shop, day by day.',
|
||||
},
|
||||
};
|
||||
|
||||
type RangeKey = '7d' | '30d' | '90d';
|
||||
|
||||
const RANGES: {value: RangeKey; label: string; days: number}[] = [
|
||||
{value: '7d', label: 'Last 7 days', days: 7},
|
||||
{value: '30d', label: 'Last 30 days', days: 30},
|
||||
{value: '90d', label: 'Last 90 days', days: 90},
|
||||
];
|
||||
|
||||
const isoDay = (t: number) => new Date(t).toISOString().slice(0, 10);
|
||||
|
||||
function rangeFor(key: RangeKey, now: number): DateRange {
|
||||
const days = RANGES.find((r) => r.value === key)?.days ?? 30;
|
||||
return {from: isoDay(now - (days - 1) * 86_400_000), to: isoDay(now)};
|
||||
}
|
||||
|
||||
export function PlatformFootfall() {
|
||||
useAiContext({level: 'platform', label: 'Footfall'});
|
||||
return <ScopedArea kind="footfall" />;
|
||||
}
|
||||
|
||||
export function PlatformCommerce() {
|
||||
useAiContext({level: 'platform', label: 'Commerce'});
|
||||
return <ScopedArea kind="commerce" />;
|
||||
}
|
||||
|
||||
function ScopedArea({kind}: {kind: Kind}) {
|
||||
const companies = useCompanies();
|
||||
const {title, subtitle} = COPY[kind];
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader title={title} subtitle={subtitle} />
|
||||
|
||||
<AsyncBoundary
|
||||
resource={companies}
|
||||
loading={<SkeletonMetricGrid columns={3} count={3} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="companies"
|
||||
title="No merchants yet"
|
||||
description={`${title} belongs to a merchant's shops. Create a merchant first.`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => <ScopedBody merchants={rows} kind={kind} />}
|
||||
</AsyncBoundary>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopedBody({merchants, kind}: {merchants: Company[]; kind: Kind}) {
|
||||
const [merchantId, setMerchantId] = useState<string>(ALL);
|
||||
const [area, setArea] = useState<string | undefined>();
|
||||
const [shopId, setShopId] = useState<string | undefined>();
|
||||
const [rangeKey, setRangeKey] = useState<RangeKey>('30d');
|
||||
// "Today" is read once per mount — rendering stays pure, and the range does
|
||||
// not drift while the page is open.
|
||||
const [now] = useState(() => Date.now());
|
||||
|
||||
// A merchant that vanished from the list (deleted in another tab) falls
|
||||
// back to the platform scope rather than to a stale name.
|
||||
const merchant = merchants.find((m) => m.id === merchantId) ?? null;
|
||||
|
||||
const filters: AnalyticsFilters = useMemo(
|
||||
() => ({
|
||||
merchantId: merchant?.id,
|
||||
area,
|
||||
shopId,
|
||||
range: rangeFor(rangeKey, now),
|
||||
}),
|
||||
[merchant?.id, area, shopId, rangeKey, now],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<FilterBar
|
||||
kind={kind}
|
||||
merchants={merchants}
|
||||
merchant={merchant}
|
||||
onMerchantChange={(v) => {
|
||||
setMerchantId(v || ALL);
|
||||
// Area and shop belong to the previous merchant — never carried over.
|
||||
setArea(undefined);
|
||||
setShopId(undefined);
|
||||
}}
|
||||
shopId={shopId}
|
||||
onShopChange={setShopId}
|
||||
rangeKey={rangeKey}
|
||||
onRangeChange={setRangeKey}
|
||||
/>
|
||||
|
||||
<ScopeMetrics merchants={merchants} merchant={merchant} />
|
||||
|
||||
{kind === 'footfall' ? (
|
||||
<FootfallDashboard filters={filters} />
|
||||
) : (
|
||||
<CommerceDashboard filters={filters} />
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected merchant's shops, for the Shop filter — from the real
|
||||
* merchant-shops endpoint when the platform has it, nothing otherwise.
|
||||
*/
|
||||
function useShopOptions(merchantId: string | undefined) {
|
||||
const shops = useAdminData(
|
||||
merchantId ? storeRepository.listByCompany(merchantId) : null,
|
||||
);
|
||||
const options =
|
||||
shops.isAvailable && shops.resource.status === 'success'
|
||||
? shops.resource.data.map((s) => ({value: s.id, label: s.name}))
|
||||
: [];
|
||||
return {isAvailable: shops.isAvailable, options};
|
||||
}
|
||||
|
||||
const NEEDS_MERCHANT = 'Choose a merchant first.';
|
||||
|
||||
function FilterBar({
|
||||
kind,
|
||||
merchants,
|
||||
merchant,
|
||||
onMerchantChange,
|
||||
shopId,
|
||||
onShopChange,
|
||||
rangeKey,
|
||||
onRangeChange,
|
||||
}: {
|
||||
kind: Kind;
|
||||
merchants: Company[];
|
||||
merchant: Company | null;
|
||||
onMerchantChange: (v: string) => void;
|
||||
shopId: string | undefined;
|
||||
onShopChange: (v: string | undefined) => void;
|
||||
rangeKey: RangeKey;
|
||||
onRangeChange: (v: RangeKey) => void;
|
||||
}) {
|
||||
const shops = useShopOptions(merchant?.id);
|
||||
|
||||
const merchantOptions: SelectorOptionType[] = [
|
||||
{value: ALL, label: 'All merchants'},
|
||||
{type: 'divider'},
|
||||
...[...merchants]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.isActive ? m.name : `${m.name} (suspended)`,
|
||||
})),
|
||||
];
|
||||
|
||||
const shopDisabledReason = !merchant
|
||||
? NEEDS_MERCHANT
|
||||
: !shops.isAvailable
|
||||
? 'Backend integration required — the platform has no admin endpoint that lists a merchant’s shops.'
|
||||
: shops.options.length === 0
|
||||
? 'This merchant has no shops.'
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<HStack gap={3} vAlign="end" className="flex-wrap gap-y-2">
|
||||
<Selector
|
||||
label="Merchant"
|
||||
options={merchantOptions}
|
||||
value={merchant ? merchant.id : ALL}
|
||||
onChange={onMerchantChange}
|
||||
hasSearch
|
||||
width={240}
|
||||
/>
|
||||
{kind === 'footfall' ? (
|
||||
<Selector
|
||||
label="Area / location"
|
||||
options={[]}
|
||||
placeholder="All areas"
|
||||
isDisabled
|
||||
disabledMessage={
|
||||
merchant
|
||||
? 'Backend integration required — shops carry no area or location field yet.'
|
||||
: NEEDS_MERCHANT
|
||||
}
|
||||
width={200}
|
||||
/>
|
||||
) : null}
|
||||
<Selector
|
||||
label="Shop"
|
||||
options={[{value: ALL, label: 'All shops'}, ...shops.options]}
|
||||
value={shopId ?? ALL}
|
||||
onChange={(v) => onShopChange(v && v !== ALL ? v : undefined)}
|
||||
placeholder="All shops"
|
||||
isDisabled={shopDisabledReason !== undefined}
|
||||
disabledMessage={shopDisabledReason}
|
||||
width={200}
|
||||
/>
|
||||
<Selector
|
||||
label="Date range"
|
||||
options={RANGES.map(({value, label}) => ({value, label}))}
|
||||
value={rangeKey}
|
||||
onChange={(v) => onRangeChange(v as RangeKey)}
|
||||
width={180}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
// Rounded: AnimatedNumber springs between values and hands this every
|
||||
// in-between frame — unrounded, 6 → 4 shops paints as "3.108".
|
||||
const fmt = (v: number) => Math.round(v).toLocaleString();
|
||||
|
||||
/**
|
||||
* What the merchant list can say about the current scope — nothing more.
|
||||
* No footfall, sales or transaction number appears here: none is returned.
|
||||
*/
|
||||
function ScopeMetrics({
|
||||
merchants,
|
||||
merchant,
|
||||
}: {
|
||||
merchants: Company[];
|
||||
merchant: Company | null;
|
||||
}) {
|
||||
const bp = useBreakpoint();
|
||||
const caption = (t: string) => (
|
||||
<Text size="xsm" color="secondary">
|
||||
{t}
|
||||
</Text>
|
||||
);
|
||||
|
||||
// Keyed per scope: a different merchant is a different entity, not a value
|
||||
// that moved, so the cards remount and paint the new numbers directly rather
|
||||
// than springing through counts that belong to neither merchant.
|
||||
if (merchant) {
|
||||
return (
|
||||
<Grid key={merchant.id} columns={bp === 'mobile' ? 1 : 2} gap={4}>
|
||||
<MetricCard
|
||||
label="Shops"
|
||||
value={merchant.sites}
|
||||
format={fmt}
|
||||
icon={ICONS.stores}
|
||||
footer={caption(`Registered to ${merchant.name}`)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Merchant accounts"
|
||||
value={merchant.users}
|
||||
format={fmt}
|
||||
icon={ICONS.staff}
|
||||
footer={caption(merchant.isActive ? 'Active merchant' : 'Suspended merchant')}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
const active = merchants.filter((m) => m.isActive);
|
||||
|
||||
return (
|
||||
<Grid key="all" columns={bp === 'mobile' ? 1 : 3} gap={4}>
|
||||
<MetricCard
|
||||
label="Active merchants"
|
||||
value={active.length}
|
||||
format={fmt}
|
||||
icon={ICONS.companies}
|
||||
footer={caption(`${fmt(merchants.length)} on the platform`)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Shops"
|
||||
value={active.reduce((n, m) => n + m.sites, 0)}
|
||||
format={fmt}
|
||||
icon={ICONS.stores}
|
||||
footer={caption('Registered to active merchants')}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Merchants without a shop"
|
||||
value={active.filter((m) => m.sites === 0).length}
|
||||
format={fmt}
|
||||
icon={ICONS.stores}
|
||||
footer={caption('Active, with nothing to report yet')}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -126,8 +126,8 @@ export function ResetOwnerPasswordDialog({
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="owner@company.com"
|
||||
description="This company has more than one owner — name the one to reset."
|
||||
placeholder="owner@merchant.com"
|
||||
description="This merchant has more than one owner — name the one to reset."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
205
src/features/admin/components/StoreDetail.tsx
Normal file
205
src/features/admin/components/StoreDetail.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {List, ListItem} from '@astryxdesign/core/List';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {
|
||||
useAlerts,
|
||||
useCompany,
|
||||
useEvents,
|
||||
useStore,
|
||||
useStoreCameras,
|
||||
} from '@/features/admin/hooks/useMonitoring';
|
||||
import {useAiContext} from '@/features/admin/hooks/useAiContext';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
import {AdminPageHeader} from './common/AdminPageHeader';
|
||||
import {AdminSection} from './common/AdminSection';
|
||||
import {CompanyNotFound} from './CompanyDetail';
|
||||
import {
|
||||
AlertTable,
|
||||
CameraTable,
|
||||
EventTable,
|
||||
adminHref,
|
||||
} from './MonitoringTables';
|
||||
|
||||
/**
|
||||
* One store of one company.
|
||||
*
|
||||
* ── The pair, never the store alone ──────────────────────────────────────
|
||||
* The route is /admin/merchants/{merchantId}/shops/{shopId}, and every
|
||||
* request this page makes nests the same way. The company is resolved first
|
||||
* from the real list — a store under a company that does not exist is a
|
||||
* not-found page, not a request. Whether the store really belongs to that
|
||||
* company is the PLATFORM's check (docs/ADMIN-MONITORING-API.md §Ownership);
|
||||
* the URL is a claim, and this page never treats it as proof.
|
||||
*
|
||||
* Today no store-level endpoint exists, so the page is reachable (the route is
|
||||
* real and the breadcrumbs work) and every section says what is missing.
|
||||
*/
|
||||
export function StoreDetail({
|
||||
companyId,
|
||||
storeId,
|
||||
}: {
|
||||
companyId: string;
|
||||
storeId: string;
|
||||
}) {
|
||||
const {resource, company} = useCompany(companyId);
|
||||
|
||||
return (
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={<CompanyNotFound />}
|
||||
>
|
||||
{() =>
|
||||
company ? (
|
||||
<StoreBody company={company} storeId={storeId} />
|
||||
) : (
|
||||
<CompanyNotFound />
|
||||
)
|
||||
}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function StoreBody({company, storeId}: {company: Company; storeId: string}) {
|
||||
const bp = useBreakpoint();
|
||||
const store = useStore(company.id, storeId);
|
||||
const cameras = useStoreCameras(company.id, storeId);
|
||||
const events = useEvents(company.id, storeId);
|
||||
const alerts = useAlerts(company.id, storeId);
|
||||
|
||||
// The store's NAME only exists once the store endpoint answers. Until then
|
||||
// the page says "Store" rather than printing a raw id as if it were a name.
|
||||
const name =
|
||||
store.isAvailable && store.resource.status === 'success'
|
||||
? store.resource.data.name
|
||||
: 'Shop';
|
||||
|
||||
useAiContext({
|
||||
level: 'store',
|
||||
label: `${name} · ${company.name}`,
|
||||
companyId: company.id,
|
||||
storeId,
|
||||
});
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<AdminPageHeader
|
||||
title={name}
|
||||
subtitle={`A shop of ${company.name}.`}
|
||||
back={{label: company.name, href: adminHref.company(company.id)}}
|
||||
crumbs={[
|
||||
{label: 'Merchants', href: adminHref.merchants},
|
||||
{label: company.name, href: adminHref.company(company.id)},
|
||||
{label: name},
|
||||
]}
|
||||
/>
|
||||
|
||||
<AdminSection
|
||||
title="Shop overview"
|
||||
data={store}
|
||||
pending={{
|
||||
icon: 'stores',
|
||||
title: 'Shop details are not available yet',
|
||||
description:
|
||||
'Status, location and camera counts for this shop need the platform admin shop-detail API.',
|
||||
}}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
>
|
||||
{(s) => (
|
||||
<List>
|
||||
<ListItem label="Timezone" endContent={<Text size="sm">{s.timezone ?? '—'}</Text>} />
|
||||
<ListItem
|
||||
label="Cameras"
|
||||
endContent={<Text size="sm">{s.cameras ?? '—'}</Text>}
|
||||
/>
|
||||
<ListItem
|
||||
label="Cameras online"
|
||||
endContent={<Text size="sm">{s.camerasOnline ?? '—'}</Text>}
|
||||
/>
|
||||
<ListItem
|
||||
label="Created"
|
||||
endContent={
|
||||
<Text size="sm">
|
||||
{s.createdAt ? new Date(s.createdAt).toLocaleDateString() : '—'}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</List>
|
||||
)}
|
||||
</AdminSection>
|
||||
|
||||
<AdminSection
|
||||
title="Cameras"
|
||||
data={cameras}
|
||||
pending={{
|
||||
icon: 'camera',
|
||||
title: 'Camera data unavailable',
|
||||
description:
|
||||
'Connect the platform admin store-cameras API to list this shop’s cameras with their status and last-seen time.',
|
||||
}}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="camera"
|
||||
title="No cameras"
|
||||
description="No camera is registered to this shop."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<CameraTable companyId={company.id} storeId={storeId} cameras={rows} />
|
||||
)}
|
||||
</AdminSection>
|
||||
|
||||
<Grid columns={bp === 'desktop' || bp === 'ultrawide' ? 2 : 1} gap={4}>
|
||||
<AdminSection
|
||||
title="Active alerts"
|
||||
data={alerts}
|
||||
pending={{
|
||||
icon: 'notifications',
|
||||
title: 'No alerts available',
|
||||
description:
|
||||
'Alert monitoring is not currently available for Platform Admin.',
|
||||
}}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="notifications"
|
||||
title="No open alerts"
|
||||
description="Nothing needs attention at this shop."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => <AlertTable alerts={rows} />}
|
||||
</AdminSection>
|
||||
|
||||
<AdminSection
|
||||
title="Recent events"
|
||||
data={events}
|
||||
pending={{
|
||||
icon: 'events',
|
||||
title: 'No event data available',
|
||||
description: 'Platform Admin event API integration is required.',
|
||||
}}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="events"
|
||||
title="No events"
|
||||
description="This shop has not reported any events yet."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => <EventTable events={rows} />}
|
||||
</AdminSection>
|
||||
</Grid>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -53,8 +53,8 @@ export function SuspendCompanyDialog({
|
||||
setError(
|
||||
res.message ??
|
||||
(suspending
|
||||
? 'Could not suspend that company.'
|
||||
: 'Could not reinstate that company.'),
|
||||
? 'Could not suspend that merchant.'
|
||||
: 'Could not reinstate that merchant.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -69,11 +69,11 @@ export function SuspendCompanyDialog({
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose={suspending ? 'required' : 'info'}
|
||||
width={440}
|
||||
aria-label={suspending ? 'Suspend company' : 'Reinstate company'}
|
||||
aria-label={suspending ? 'Suspend merchant' : 'Reinstate merchant'}
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={suspending ? 'Suspend company?' : 'Reinstate company?'}
|
||||
title={suspending ? 'Suspend merchant?' : 'Reinstate merchant?'}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function SuspendCompanyDialog({
|
||||
variant={suspending ? 'destructive' : 'primary'}
|
||||
onClick={() => void submit()}
|
||||
isLoading={busy}
|
||||
label={suspending ? 'Suspend company' : 'Reinstate company'}
|
||||
label={suspending ? 'Suspend merchant' : 'Reinstate merchant'}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
341
src/features/admin/components/analytics/AnalyticsPanel.tsx
Normal file
341
src/features/admin/components/analytics/AnalyticsPanel.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Token} from '@astryxdesign/core/Token';
|
||||
import {EmptyState} from '@astryxdesign/core/EmptyState';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import {Pagination} from '@astryxdesign/core/Pagination';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {
|
||||
SkeletonChart,
|
||||
SkeletonRows,
|
||||
} from '@/shared/components/patterns/LoadingState';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {CHART, dashFor, defaultEncoding} from '@/shared/components/charts/palette';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
import type {AdminData} from '@/features/admin/hooks/useMonitoring';
|
||||
|
||||
/**
|
||||
* The analytics layer's frame. Every chart and table on Footfall and Commerce
|
||||
* goes through AnalyticsPanel, which is what keeps the four states distinct:
|
||||
*
|
||||
* capability off → ChartEmpty "pending": the chart's own frame, its icon,
|
||||
* and "Backend integration required". No request is sent,
|
||||
* so this is never shown while something is loading.
|
||||
* loading → a skeleton at the chart's exact height (no layout jump)
|
||||
* request failed → AsyncBoundary's error banner ("Couldn't load this" + the
|
||||
* server's message) — an outage, not a missing feature
|
||||
* loaded, empty → ChartEmpty "empty": "No data for the selected filters"
|
||||
* loaded → children(data)
|
||||
*
|
||||
* Nothing in this file produces a value. A chart with no data draws no bars.
|
||||
*/
|
||||
|
||||
export function AnalyticsPanel<T>({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
data,
|
||||
pendingTitle,
|
||||
height = 260,
|
||||
variant = 'chart',
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: IconKey;
|
||||
data: AdminData<T>;
|
||||
/** e.g. "No area footfall data yet". */
|
||||
pendingTitle: string;
|
||||
height?: number;
|
||||
variant?: 'chart' | 'table';
|
||||
actions?: React.ReactNode;
|
||||
children: (data: T) => React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card padding={5} className="h-full">
|
||||
<VStack gap={4} width="100%">
|
||||
<SectionHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
data.isAvailable ? actions : (
|
||||
<Token size="sm" label="Backend integration required" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{data.isAvailable ? (
|
||||
<AsyncBoundary
|
||||
resource={data.resource}
|
||||
loading={
|
||||
variant === 'table' ? (
|
||||
<SkeletonRows count={5} />
|
||||
) : (
|
||||
<SkeletonChart height={height} />
|
||||
)
|
||||
}
|
||||
empty={
|
||||
<ChartEmpty
|
||||
icon={icon}
|
||||
height={height}
|
||||
title="No data for the selected filters"
|
||||
description="Try a wider date range, or a different merchant, area or shop."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AsyncBoundary>
|
||||
) : (
|
||||
<ChartEmpty
|
||||
icon={icon}
|
||||
height={height}
|
||||
title={pendingTitle}
|
||||
description="The chart is ready. It fills in as soon as the platform exposes this admin endpoint."
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chart's footprint, held open. A dashed frame at the plot's own height
|
||||
* says "a chart lives here" without drawing a single fake mark.
|
||||
*/
|
||||
export function ChartEmpty({
|
||||
icon,
|
||||
height,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: IconKey;
|
||||
height: number;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<VStack
|
||||
width="100%"
|
||||
minHeight={height}
|
||||
hAlign="center"
|
||||
vAlign="center"
|
||||
className="rounded-lg border border-dashed border-border bg-muted"
|
||||
>
|
||||
<EmptyState
|
||||
icon={<Icon icon={ICONS[icon]} size="lg" color="secondary" />}
|
||||
title={title}
|
||||
description={description}
|
||||
isCompact
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Series identity for charts with 2+ lines. Each swatch is the series' actual
|
||||
* stroke — same grey, same dash — so identity is never colour alone.
|
||||
*/
|
||||
export function ChartLegend({labels}: {labels: string[]}) {
|
||||
return (
|
||||
<HStack gap={4} vAlign="center" className="flex-wrap gap-y-1">
|
||||
{labels.map((label, i) => (
|
||||
<HStack key={label} gap={1.5} vAlign="center">
|
||||
<svg width="20" height="8" aria-hidden="true">
|
||||
<line
|
||||
x1="0"
|
||||
y1="4"
|
||||
x2="20"
|
||||
y2="4"
|
||||
stroke={CHART.seriesAt(i)}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={dashFor(defaultEncoding(i))}
|
||||
/>
|
||||
</svg>
|
||||
<Text size="xsm" color="secondary">
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Overview stat tiles ──────────────────────────────────────────────── */
|
||||
|
||||
export interface Stat {
|
||||
label: string;
|
||||
icon: IconKey;
|
||||
/** `undefined` renders "—" with "Data unavailable" — never a zero. */
|
||||
value?: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A KPI row whose values can be absent. When the endpoint is off every tile
|
||||
* shows "—" / "Data unavailable": the row keeps its shape, and a zero never
|
||||
* stands in for "not measured".
|
||||
*/
|
||||
export function StatPanel<T>({
|
||||
title,
|
||||
subtitle,
|
||||
data,
|
||||
toStats,
|
||||
placeholders,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
data: AdminData<T>;
|
||||
toStats: (d: T) => Stat[];
|
||||
/** The same tiles with no values — shown while the endpoint is off. */
|
||||
placeholders: Stat[];
|
||||
}) {
|
||||
return (
|
||||
<Card padding={5}>
|
||||
<VStack gap={4} width="100%">
|
||||
<SectionHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
data.isAvailable ? undefined : (
|
||||
<Token size="sm" label="Backend integration required" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{data.isAvailable ? (
|
||||
<AsyncBoundary
|
||||
resource={data.resource}
|
||||
loading={<StatGrid stats={placeholders} isLoading />}
|
||||
>
|
||||
{(d) => <StatGrid stats={toStats(d)} />}
|
||||
</AsyncBoundary>
|
||||
) : (
|
||||
<StatGrid stats={placeholders} />
|
||||
)}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatGrid({stats, isLoading}: {stats: Stat[]; isLoading?: boolean}) {
|
||||
const bp = useBreakpoint();
|
||||
const columns =
|
||||
bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : Math.min(stats.length, 5);
|
||||
return (
|
||||
<Grid columns={columns} gap={3}>
|
||||
{stats.map((s) => (
|
||||
<VStack key={s.label} gap={2} className="rounded-lg bg-muted p-4 min-w-0">
|
||||
<HStack hAlign="between" vAlign="center" width="100%">
|
||||
<Text size="xsm" color="secondary" weight="medium" className="truncate">
|
||||
{s.label}
|
||||
</Text>
|
||||
<Icon icon={ICONS[s.icon]} size="sm" color="secondary" />
|
||||
</HStack>
|
||||
<Text size="xl" weight="semibold" className="tracking-tight">
|
||||
{isLoading ? '…' : (s.value ?? '—')}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary" className="truncate">
|
||||
{isLoading
|
||||
? 'Loading'
|
||||
: s.value === undefined
|
||||
? 'Data unavailable'
|
||||
: (s.caption ?? '')}
|
||||
</Text>
|
||||
</VStack>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Details table ────────────────────────────────────────────────────── */
|
||||
|
||||
export interface SortOption<R> {
|
||||
value: string;
|
||||
label: string;
|
||||
compare: (a: R, b: R) => number;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* Sorted and paged on the client, over exactly the rows the API returned.
|
||||
* Columns whose field is absent from every row are dropped by the caller —
|
||||
* a table never shows an "Entries" column the platform does not send.
|
||||
*/
|
||||
export function DetailsTable<R extends Record<string, unknown>>({
|
||||
rows,
|
||||
columns,
|
||||
idKey,
|
||||
primaryKey,
|
||||
sorts,
|
||||
}: {
|
||||
rows: R[];
|
||||
columns: TableColumn<R>[];
|
||||
idKey: keyof R & string;
|
||||
primaryKey: string;
|
||||
sorts: SortOption<R>[];
|
||||
}) {
|
||||
const [sort, setSort] = useState(sorts[0].value);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const cmp = sorts.find((s) => s.value === sort)?.compare;
|
||||
return cmp ? [...rows].sort(cmp) : rows;
|
||||
}, [rows, sorts, sort]);
|
||||
|
||||
const pageRows = sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<VStack gap={3} width="100%">
|
||||
<HStack hAlign="between" vAlign="center" className="flex-wrap gap-y-2">
|
||||
<Text size="sm" color="secondary">
|
||||
{rows.length.toLocaleString()} {rows.length === 1 ? 'row' : 'rows'}
|
||||
</Text>
|
||||
<Selector
|
||||
label="Sort rows"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
options={sorts.map(({value, label}) => ({value, label}))}
|
||||
value={sort}
|
||||
onChange={(v) => {
|
||||
setSort(v);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<ResponsiveTable
|
||||
data={pageRows}
|
||||
columns={columns}
|
||||
idKey={idKey}
|
||||
primaryKey={primaryKey}
|
||||
density="balanced"
|
||||
/>
|
||||
{rows.length > PAGE_SIZE ? (
|
||||
<HStack hAlign="end">
|
||||
<Pagination
|
||||
page={page}
|
||||
onChange={setPage}
|
||||
totalItems={rows.length}
|
||||
pageSize={PAGE_SIZE}
|
||||
variant="count"
|
||||
size="sm"
|
||||
/>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/** True when at least one row carries the field — the column earns its place. */
|
||||
export function hasField<R>(rows: R[], key: keyof R): boolean {
|
||||
return rows.some((r) => r[key] !== undefined && r[key] !== null);
|
||||
}
|
||||
153
src/features/admin/components/analytics/CommerceDashboard.tsx
Normal file
153
src/features/admin/components/analytics/CommerceDashboard.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {
|
||||
formatCount,
|
||||
formatDayLabel,
|
||||
formatInr,
|
||||
formatInrCompact,
|
||||
} from '@/shared/utils/format';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
import type {AnalyticsFilters, CommerceDetail} from '@/features/admin/types/analytics';
|
||||
import {useCommerceAnalytics} from '@/features/admin/hooks/useAnalytics';
|
||||
import {
|
||||
AnalyticsPanel,
|
||||
DetailsTable,
|
||||
StatPanel,
|
||||
hasField,
|
||||
type SortOption,
|
||||
} from './AnalyticsPanel';
|
||||
|
||||
/**
|
||||
* Commerce: overview → merchant comparison + sales trend → transactions.
|
||||
*
|
||||
* One measure per chart. Sales (₹) and transactions (a count) never share an
|
||||
* axis: the trend plots sales, and the transaction count lives in the overview
|
||||
* and the table rather than as a second y-scale.
|
||||
*/
|
||||
export function CommerceDashboard({filters}: {filters: AnalyticsFilters}) {
|
||||
const bp = useBreakpoint();
|
||||
const data = useCommerceAnalytics(filters);
|
||||
const twoUp = bp === 'desktop' || bp === 'ultrawide';
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<StatPanel
|
||||
title="Commerce overview"
|
||||
subtitle="Sales for the selected merchant, shop and period."
|
||||
data={data.overview}
|
||||
placeholders={OVERVIEW_TILES}
|
||||
toStats={(o) => [
|
||||
{...OVERVIEW_TILES[0], value: formatInrCompact(o.salesInr), caption: formatInr(o.salesInr)},
|
||||
{...OVERVIEW_TILES[1], value: formatCount(o.transactions), caption: 'Recorded sales'},
|
||||
{...OVERVIEW_TILES[2], value: formatInr(o.averageTransactionInr), caption: 'Per transaction'},
|
||||
{...OVERVIEW_TILES[3], value: formatCount(o.activeMerchants), caption: 'With sales in the period'},
|
||||
{...OVERVIEW_TILES[4], value: formatCount(o.activeShops), caption: 'With sales in the period'},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Grid columns={twoUp ? 2 : 1} gap={6}>
|
||||
<AnalyticsPanel
|
||||
title="Sales by merchant"
|
||||
subtitle="Compare merchants for the selected period."
|
||||
icon="analytics"
|
||||
data={data.byMerchant}
|
||||
pendingTitle="No merchant sales data yet"
|
||||
>
|
||||
{(rows) => (
|
||||
<BarChartView
|
||||
data={[...rows].sort((a, b) => b.salesInr - a.salesInr)}
|
||||
xKey="merchantName"
|
||||
series={[{key: 'salesInr', label: 'Sales', format: formatInr}]}
|
||||
yFormat={formatInrCompact}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsPanel>
|
||||
|
||||
<AnalyticsPanel
|
||||
title="Sales over time"
|
||||
subtitle="Daily sales across the selected range."
|
||||
icon="revenue"
|
||||
data={data.daily}
|
||||
pendingTitle="No daily sales data yet"
|
||||
>
|
||||
{(rows) => (
|
||||
<LineChartView
|
||||
data={rows}
|
||||
xKey="date"
|
||||
series={[{key: 'salesInr', label: 'Sales', format: formatInr}]}
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatInrCompact}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsPanel>
|
||||
</Grid>
|
||||
|
||||
<AnalyticsPanel
|
||||
title="Transactions"
|
||||
subtitle="Sales by shop and day, with payment details where the platform records them."
|
||||
icon="purchases"
|
||||
data={data.details}
|
||||
pendingTitle="No transaction rows yet"
|
||||
variant="table"
|
||||
height={220}
|
||||
>
|
||||
{(rows) => <CommerceTable rows={rows} />}
|
||||
</AnalyticsPanel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const OVERVIEW_TILES = [
|
||||
{label: 'Total sales', icon: 'revenue'},
|
||||
{label: 'Transactions', icon: 'purchases'},
|
||||
{label: 'Avg transaction', icon: 'conversion'},
|
||||
{label: 'Active merchants', icon: 'companies'},
|
||||
{label: 'Active shops', icon: 'stores'},
|
||||
] as const satisfies {label: string; icon: IconKey}[];
|
||||
|
||||
type CommerceRow = CommerceDetail & Record<string, unknown> & {rowId: string};
|
||||
|
||||
const COMMERCE_SORTS: SortOption<CommerceRow>[] = [
|
||||
{value: 'date-desc', label: 'Newest first', compare: (a, b) => b.date.localeCompare(a.date)},
|
||||
{value: 'date-asc', label: 'Oldest first', compare: (a, b) => a.date.localeCompare(b.date)},
|
||||
{value: 'sales-desc', label: 'Highest sales', compare: (a, b) => b.salesInr - a.salesInr},
|
||||
{value: 'merchant', label: 'Merchant A–Z', compare: (a, b) => a.merchantName.localeCompare(b.merchantName)},
|
||||
];
|
||||
|
||||
function CommerceTable({rows}: {rows: CommerceDetail[]}) {
|
||||
const data: CommerceRow[] = rows.map((r) => ({
|
||||
...r,
|
||||
rowId: `${r.date}|${r.shopId}|${r.paymentMethod ?? ''}|${r.category ?? ''}`,
|
||||
}));
|
||||
|
||||
const columns: TableColumn<CommerceRow>[] = [
|
||||
{key: 'date', header: 'Date', renderCell: (r) => <Text size="sm">{formatDayLabel(r.date)}</Text>},
|
||||
{key: 'merchantName', header: 'Merchant'},
|
||||
{key: 'shopName', header: 'Shop'},
|
||||
{key: 'salesInr', header: 'Sales', align: 'end', renderCell: (r) => <Text size="sm">{formatInr(r.salesInr)}</Text>},
|
||||
{key: 'transactions', header: 'Transactions', align: 'end', renderCell: (r) => <Text size="sm">{formatCount(r.transactions)}</Text>},
|
||||
...(hasField(rows, 'paymentMethod')
|
||||
? [{key: 'paymentMethod', header: 'Payment'} as TableColumn<CommerceRow>]
|
||||
: []),
|
||||
...(hasField(rows, 'category')
|
||||
? [{key: 'category', header: 'Category'} as TableColumn<CommerceRow>]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<DetailsTable
|
||||
rows={data}
|
||||
columns={columns}
|
||||
idKey="rowId"
|
||||
primaryKey="shopName"
|
||||
sorts={COMMERCE_SORTS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
215
src/features/admin/components/analytics/FootfallDashboard.tsx
Normal file
215
src/features/admin/components/analytics/FootfallDashboard.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
||||
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {formatCount, formatDayLabel} from '@/shared/utils/format';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
import type {AnalyticsFilters, AreaDateFootfall, FootfallDetail} from '@/features/admin/types/analytics';
|
||||
import {useFootfallAnalytics} from '@/features/admin/hooks/useAnalytics';
|
||||
import {
|
||||
AnalyticsPanel,
|
||||
ChartLegend,
|
||||
DetailsTable,
|
||||
StatPanel,
|
||||
hasField,
|
||||
type SortOption,
|
||||
} from './AnalyticsPanel';
|
||||
|
||||
/**
|
||||
* Footfall: overview → area comparison + daily trend → area × date → details.
|
||||
*
|
||||
* Every panel reads its own endpoint through footfallRepository with the same
|
||||
* filters, so a merchant / area / shop / range change refetches all of them
|
||||
* and none can show another scope's rows.
|
||||
*/
|
||||
export function FootfallDashboard({filters}: {filters: AnalyticsFilters}) {
|
||||
const bp = useBreakpoint();
|
||||
const data = useFootfallAnalytics(filters);
|
||||
const twoUp = bp === 'desktop' || bp === 'ultrawide';
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<StatPanel
|
||||
title="Footfall overview"
|
||||
subtitle="Walk-ins for the selected merchant, area, shop and period."
|
||||
data={data.overview}
|
||||
placeholders={OVERVIEW_TILES}
|
||||
toStats={(o) => [
|
||||
{...OVERVIEW_TILES[0], value: formatCount(o.totalFootfall), caption: 'Walk-ins in the period'},
|
||||
{...OVERVIEW_TILES[1], value: formatCount(Math.round(o.averageDaily)), caption: 'Per day'},
|
||||
{
|
||||
...OVERVIEW_TILES[2],
|
||||
value: o.peakDay ? formatCount(o.peakDay.footfall) : undefined,
|
||||
caption: o.peakDay ? formatDayLabel(o.peakDay.date) : undefined,
|
||||
},
|
||||
{...OVERVIEW_TILES[3], value: formatCount(o.activeLocations), caption: 'Areas with walk-ins'},
|
||||
{...OVERVIEW_TILES[4], value: formatCount(o.reportingShops), caption: 'Shops that sent data'},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Grid columns={twoUp ? 2 : 1} gap={6}>
|
||||
<AnalyticsPanel
|
||||
title="Footfall by area"
|
||||
subtitle="Compare footfall across locations for the selected period."
|
||||
icon="analytics"
|
||||
data={data.byArea}
|
||||
pendingTitle="No area footfall data yet"
|
||||
>
|
||||
{(rows) => (
|
||||
<BarChartView
|
||||
data={[...rows].sort((a, b) => b.footfall - a.footfall)}
|
||||
xKey="area"
|
||||
series={[{key: 'footfall', label: 'Footfall', format: formatCount}]}
|
||||
yFormat={formatCount}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsPanel>
|
||||
|
||||
<AnalyticsPanel
|
||||
title="Daily footfall trend"
|
||||
subtitle="Walk-ins per day across the selected range."
|
||||
icon="visitors"
|
||||
data={data.daily}
|
||||
pendingTitle="No daily footfall data yet"
|
||||
>
|
||||
{(rows) => (
|
||||
<AreaChartView
|
||||
data={rows}
|
||||
xKey="date"
|
||||
series={[{key: 'footfall', label: 'Footfall', format: formatCount}]}
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCount}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsPanel>
|
||||
</Grid>
|
||||
|
||||
<AnalyticsPanel
|
||||
title="Area × date"
|
||||
subtitle="Every area across the same dates. The four busiest areas are drawn; the rest fold into Other."
|
||||
icon="compare"
|
||||
data={data.areaDate}
|
||||
pendingTitle="No area trend data yet"
|
||||
height={300}
|
||||
>
|
||||
{(rows) => <AreaDateChart rows={rows} />}
|
||||
</AnalyticsPanel>
|
||||
|
||||
<AnalyticsPanel
|
||||
title="Footfall details"
|
||||
subtitle="One row per shop per day, as the platform reports it."
|
||||
icon="events"
|
||||
data={data.details}
|
||||
pendingTitle="No footfall rows yet"
|
||||
variant="table"
|
||||
height={220}
|
||||
>
|
||||
{(rows) => <FootfallTable rows={rows} />}
|
||||
</AnalyticsPanel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const OVERVIEW_TILES = [
|
||||
{label: 'Total footfall', icon: 'visitors'},
|
||||
{label: 'Average daily', icon: 'analytics'},
|
||||
{label: 'Peak day', icon: 'up'},
|
||||
{label: 'Active locations', icon: 'stores'},
|
||||
{label: 'Reporting shops', icon: 'present'},
|
||||
] as const satisfies {label: string; icon: IconKey}[];
|
||||
|
||||
/* ── Area × date ──────────────────────────────────────────────────────── */
|
||||
|
||||
const MAX_SERIES = 4;
|
||||
const OTHER = 'Other';
|
||||
|
||||
/**
|
||||
* Pivots the long rows into one object per date with a key per area. Series
|
||||
* follow the AREA, ranked by total — never cycled: past four, the rest sum
|
||||
* into "Other" rather than reusing a grey another area already owns.
|
||||
*/
|
||||
function pivot(rows: AreaDateFootfall[]) {
|
||||
const totals = new Map<string, number>();
|
||||
for (const r of rows) totals.set(r.area, (totals.get(r.area) ?? 0) + r.footfall);
|
||||
const ranked = [...totals.entries()].sort((a, b) => b[1] - a[1]).map(([a]) => a);
|
||||
const shown = ranked.length > MAX_SERIES + 1 ? ranked.slice(0, MAX_SERIES) : ranked;
|
||||
const folds = ranked.length > shown.length;
|
||||
|
||||
const byDate = new Map<string, Record<string, number | string>>();
|
||||
for (const r of rows) {
|
||||
const row = byDate.get(r.date) ?? {date: r.date};
|
||||
const key = shown.includes(r.area) ? r.area : OTHER;
|
||||
row[key] = ((row[key] as number | undefined) ?? 0) + r.footfall;
|
||||
byDate.set(r.date, row);
|
||||
}
|
||||
const data = [...byDate.values()].sort((a, b) =>
|
||||
String(a.date).localeCompare(String(b.date)),
|
||||
);
|
||||
return {data, series: folds ? [...shown, OTHER] : shown};
|
||||
}
|
||||
|
||||
function AreaDateChart({rows}: {rows: AreaDateFootfall[]}) {
|
||||
const {data, series} = useMemo(() => pivot(rows), [rows]);
|
||||
return (
|
||||
<VStack gap={3} width="100%">
|
||||
<ChartLegend labels={series} />
|
||||
<LineChartView
|
||||
data={data}
|
||||
xKey="date"
|
||||
series={series.map((s) => ({key: s, label: s, format: formatCount}))}
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCount}
|
||||
height={300}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Details table ────────────────────────────────────────────────────── */
|
||||
|
||||
type FootfallRow = FootfallDetail & Record<string, unknown> & {rowId: string};
|
||||
|
||||
const FOOTFALL_SORTS: SortOption<FootfallRow>[] = [
|
||||
{value: 'date-desc', label: 'Newest first', compare: (a, b) => b.date.localeCompare(a.date)},
|
||||
{value: 'date-asc', label: 'Oldest first', compare: (a, b) => a.date.localeCompare(b.date)},
|
||||
{value: 'footfall-desc', label: 'Most footfall', compare: (a, b) => b.footfall - a.footfall},
|
||||
{value: 'shop', label: 'Shop A–Z', compare: (a, b) => a.shopName.localeCompare(b.shopName)},
|
||||
];
|
||||
|
||||
function FootfallTable({rows}: {rows: FootfallDetail[]}) {
|
||||
const data: FootfallRow[] = rows.map((r) => ({
|
||||
...r,
|
||||
rowId: `${r.date}|${r.shopId}`,
|
||||
}));
|
||||
|
||||
const columns: TableColumn<FootfallRow>[] = [
|
||||
{key: 'date', header: 'Date', renderCell: (r) => <Text size="sm">{formatDayLabel(r.date)}</Text>},
|
||||
{key: 'merchantName', header: 'Merchant'},
|
||||
...(hasField(rows, 'area') ? [{key: 'area', header: 'Area'} as TableColumn<FootfallRow>] : []),
|
||||
{key: 'shopName', header: 'Shop'},
|
||||
{key: 'footfall', header: 'Footfall', align: 'end', renderCell: (r) => <Text size="sm">{formatCount(r.footfall)}</Text>},
|
||||
...(hasField(rows, 'entries')
|
||||
? [{key: 'entries', header: 'Entries', align: 'end'} as TableColumn<FootfallRow>]
|
||||
: []),
|
||||
...(hasField(rows, 'exits')
|
||||
? [{key: 'exits', header: 'Exits', align: 'end'} as TableColumn<FootfallRow>]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<DetailsTable
|
||||
rows={data}
|
||||
columns={columns}
|
||||
idKey="rowId"
|
||||
primaryKey="shopName"
|
||||
sorts={FOOTFALL_SORTS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
106
src/features/admin/components/common/AdminPageHeader.tsx
Normal file
106
src/features/admin/components/common/AdminPageHeader.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { Breadcrumbs, BreadcrumbItem } from "@astryxdesign/core/Breadcrumbs";
|
||||
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
||||
import { Text } from "@astryxdesign/core/Text";
|
||||
import { Button } from "@astryxdesign/core/Button";
|
||||
import { Icon } from "@astryxdesign/core/Icon";
|
||||
|
||||
export interface Crumb {
|
||||
label: string;
|
||||
/** Omit on the last crumb — it is the current page. */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the operator is, what this page is, and what they can do here.
|
||||
*
|
||||
* Breadcrumbs appear only when there is a parent: a top-level page with a
|
||||
* one-item trail is clutter. Four levels deep (company / store / camera) the
|
||||
* trail is the only thing telling an operator which tenant they are looking
|
||||
* at, which on a console that can suspend it is not a nicety.
|
||||
*/
|
||||
export function AdminPageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
back,
|
||||
crumbs,
|
||||
meta,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** One level up. A drill-down page needs a way back that is not the
|
||||
* browser's — the operator may have arrived from a link. */
|
||||
back?: { label: string; href: string };
|
||||
crumbs?: Crumb[];
|
||||
/** A row under the title — status, slug, dates. */
|
||||
meta?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={3} width="100%">
|
||||
{back || (crumbs && crumbs.length > 0) ? (
|
||||
<HStack gap={3} vAlign="center" className="flex-wrap gap-y-1">
|
||||
{back ? <BackButton {...back} /> : null}
|
||||
{crumbs && crumbs.length > 0 ? (
|
||||
<Breadcrumbs variant="supporting">
|
||||
{crumbs.map((c, i) =>
|
||||
i === crumbs.length - 1 ? (
|
||||
<BreadcrumbItem key={i} isCurrent>
|
||||
{c.label}
|
||||
</BreadcrumbItem>
|
||||
) : (
|
||||
<BreadcrumbItem key={i} href={c.href}>
|
||||
{c.label}
|
||||
</BreadcrumbItem>
|
||||
),
|
||||
)}
|
||||
</Breadcrumbs>
|
||||
) : null}
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack
|
||||
gap={4}
|
||||
vAlign="start"
|
||||
hAlign="between"
|
||||
width="100%"
|
||||
className="flex-wrap gap-y-3"
|
||||
>
|
||||
<VStack gap={1} className="min-w-0">
|
||||
<Text type="display-3" weight="medium" className="break-words">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text size="sm" color="secondary">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
{meta ? (
|
||||
<HStack gap={3} vAlign="center" className="flex-wrap gap-y-1">
|
||||
{meta}
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
{actions ? (
|
||||
<HStack gap={2} vAlign="center">
|
||||
{actions}
|
||||
</HStack>
|
||||
) : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackButton({ label, href }: { label: string; href: string }) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href={href}
|
||||
label={label}
|
||||
icon={<Icon icon="chevronLeft" size="sm" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
59
src/features/admin/components/common/AdminSection.tsx
Normal file
59
src/features/admin/components/common/AdminSection.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import type {AdminData} from '@/features/admin/hooks/useMonitoring';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
import {PendingIntegration} from './PendingIntegration';
|
||||
|
||||
/**
|
||||
* A panel over one level of the drill-down, in whichever of its three states
|
||||
* it is in:
|
||||
*
|
||||
* unavailable no admin endpoint → PendingIntegration, no request sent
|
||||
* loading / error / empty → AsyncBoundary, as everywhere else
|
||||
* loaded → `children(data)`
|
||||
*
|
||||
* This is what makes "turn the level on" a flag flip: the section already
|
||||
* renders the loaded branch; only the hook's answer changes.
|
||||
*/
|
||||
export function AdminSection<T>({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
data,
|
||||
pending,
|
||||
loading,
|
||||
empty,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: React.ReactNode;
|
||||
data: AdminData<T>;
|
||||
pending: {icon: IconKey; title: string; description: string};
|
||||
loading: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
children: (data: T) => React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<VStack gap={4}>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
actions={data.isAvailable ? actions : undefined}
|
||||
/>
|
||||
{data.isAvailable ? (
|
||||
<AsyncBoundary resource={data.resource} loading={loading} empty={empty}>
|
||||
{children}
|
||||
</AsyncBoundary>
|
||||
) : (
|
||||
<PendingIntegration {...pending} />
|
||||
)}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
41
src/features/admin/components/common/PendingIntegration.tsx
Normal file
41
src/features/admin/components/common/PendingIntegration.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import {EmptyState} from '@astryxdesign/core/EmptyState';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Token} from '@astryxdesign/core/Token';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
|
||||
/**
|
||||
* "The platform cannot answer this yet" — the one treatment for every level
|
||||
* of the drill-down that has no admin endpoint behind it.
|
||||
*
|
||||
* Calm, not alarming: no red, no Banner. Nothing is broken; a capability is
|
||||
* missing, and the token says exactly which kind of missing it is so nobody
|
||||
* mistakes it for "this company has no stores". That distinction is the whole
|
||||
* point — an empty list and an unanswerable question look identical if you
|
||||
* render them the same way, and an operator would act on the wrong one.
|
||||
*
|
||||
* Unlike the merchant console's FeatureUnavailable, this names the gap in
|
||||
* engineering terms. The audience here is the platform team, who are the
|
||||
* people who can close it.
|
||||
*/
|
||||
export function PendingIntegration({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: IconKey;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Icon icon={ICONS[icon]} size="lg" color="secondary" />}
|
||||
title={title}
|
||||
description={description}
|
||||
actions={<Token label="Backend integration required" size="sm" />}
|
||||
isCompact
|
||||
/>
|
||||
);
|
||||
}
|
||||
274
src/features/admin/components/shell/AdminShell.tsx
Normal file
274
src/features/admin/components/shell/AdminShell.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect} from 'react';
|
||||
import {usePathname, useRouter} from 'next/navigation';
|
||||
import {AppShell} from '@astryxdesign/core/AppShell';
|
||||
import {
|
||||
Layout,
|
||||
LayoutContent,
|
||||
LayoutPanel,
|
||||
HStack,
|
||||
VStack,
|
||||
} from '@astryxdesign/core/Layout';
|
||||
import {TopNav, TopNavItem} from '@astryxdesign/core/TopNav';
|
||||
import {MobileNav} from '@astryxdesign/core/MobileNav';
|
||||
import {SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
|
||||
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {Token} from '@astryxdesign/core/Token';
|
||||
import {Link} from '@astryxdesign/core/Link';
|
||||
import {BrandLogo, BrandMark} from '@/shared/components/brand/BrandLogo';
|
||||
import {NavMenuButton} from '@/shared/layouts/workspace/NavMenuButton';
|
||||
import {
|
||||
NAV_DRAWER_ID,
|
||||
SidebarProvider,
|
||||
useSidebar,
|
||||
} from '@/shared/layouts/workspace/SidebarProvider';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {LoyalyAiPanel} from '@/features/loyaly-ai/components/LoyalyAiPanel';
|
||||
import {LoyalyAiSlideOver} from '@/features/loyaly-ai/components/LoyalyAiSlideOver';
|
||||
import {LoyalyAiToggle} from '@/features/loyaly-ai/components/LoyalyAiToggle';
|
||||
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
import {
|
||||
useBreakpoint,
|
||||
isPanelInline,
|
||||
assistantWidth,
|
||||
contentMaxWidth,
|
||||
} from '@/shared/hooks/useBreakpoint';
|
||||
import {ADMIN_NAV, isAdminNavActive} from './admin-nav';
|
||||
|
||||
/**
|
||||
* The platform console's frame: one top bar, content, Loyaly AI.
|
||||
*
|
||||
* No sidebar. The console has three destinations, which fit a horizontal bar
|
||||
* and leave the full width to the tables — the thing an operator is actually
|
||||
* scanning. Loyaly AI keeps its column on the right, as in the workspace, via
|
||||
* a nested Layout's `end` panel (AppShell has no slot for it).
|
||||
*
|
||||
* Responsive behaviour, by breakpoint:
|
||||
* mobile tabs → drawer behind the menu button (TopNav's mobile-bar mode
|
||||
* drops `startContent`, so the tabs cannot simply shrink),
|
||||
* Loyaly AI → full-height sheet
|
||||
* tablet tabs inline, Loyaly AI → slide-over
|
||||
* laptop+ tabs inline, Loyaly AI inline panel
|
||||
*
|
||||
* SidebarProvider is kept only for the drawer's open state — the same state
|
||||
* NavMenuButton drives — so the mobile menu behaves exactly like the
|
||||
* workspace's.
|
||||
*/
|
||||
export function AdminShell({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<ShellFrame>{children}</ShellFrame>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ShellFrame({children}: {children: React.ReactNode}) {
|
||||
const bp = useBreakpoint();
|
||||
const {isOpen, setIsOpen, panelMode, panelWidth} = useLoyalyAi();
|
||||
const {isDrawerOpen, setDrawerOpen} = useSidebar();
|
||||
|
||||
const inline = isPanelInline(bp);
|
||||
const showInlinePanel = inline && isOpen;
|
||||
const isFullscreen = showInlinePanel && panelMode === 'fullscreen';
|
||||
|
||||
let width: number | string = assistantWidth(bp);
|
||||
if (showInlinePanel) {
|
||||
if (isFullscreen) width = '100%';
|
||||
else if (panelMode === 'expanded') width = Math.max(panelWidth, 800);
|
||||
else width = panelWidth;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
// `wash`, not `elevated`: elevated paints the content column white
|
||||
// (--color-background-surface), which hid the ambient canvas everywhere
|
||||
// except the top bar. Wash gives the bar and content the same ground, so
|
||||
// the gradient in globals.css runs across the full page — as loyaly-staff.
|
||||
variant="wash"
|
||||
height="fill"
|
||||
contentPadding={0}
|
||||
topNav={<AdminTopNav />}
|
||||
mobileNav={{
|
||||
breakpoint: 'sm',
|
||||
isOpen: isDrawerOpen,
|
||||
onOpenChange: setDrawerOpen,
|
||||
hasToggle: false,
|
||||
content: <AdminMobileMenu />,
|
||||
}}
|
||||
>
|
||||
<Layout
|
||||
height="fill"
|
||||
content={
|
||||
!isFullscreen ? (
|
||||
<LayoutContent padding={5}>
|
||||
<VStack
|
||||
width="100%"
|
||||
maxWidth={contentMaxWidth(bp)}
|
||||
className={bp === 'ultrawide' ? 'mx-auto' : undefined}
|
||||
>
|
||||
{children}
|
||||
</VStack>
|
||||
</LayoutContent>
|
||||
) : undefined
|
||||
}
|
||||
end={
|
||||
showInlinePanel ? (
|
||||
<LayoutPanel
|
||||
hasDivider
|
||||
width={width as number}
|
||||
padding={0}
|
||||
role="complementary"
|
||||
label="Loyaly AI"
|
||||
className="transition-[width] duration-300 ease-out"
|
||||
>
|
||||
<LoyalyAiPanel onClose={() => setIsOpen(false)} />
|
||||
</LayoutPanel>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{!inline ? <LoyalyAiSlideOver /> : null}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected tab reads as an underline, not a filled pill: a 2px bar in the
|
||||
* primary text colour along the bottom edge. Token-backed utilities only —
|
||||
* `border-b-2` is the spacing scale's 2px and `border-primary` the text token,
|
||||
* so the bar stays monochrome in both themes.
|
||||
*/
|
||||
const TAB = 'rounded-none border-b-2 border-transparent';
|
||||
const TAB_SELECTED = 'rounded-none border-b-2 border-primary bg-transparent';
|
||||
|
||||
function AdminTopNav() {
|
||||
const pathname = usePathname();
|
||||
const {isDrawer} = useSidebar();
|
||||
|
||||
return (
|
||||
<TopNav
|
||||
label="Platform admin"
|
||||
heading={
|
||||
<HStack gap={3} vAlign="center">
|
||||
{isDrawer ? <NavMenuButton /> : null}
|
||||
<Link href="/admin">
|
||||
{isDrawer ? <BrandMark size={26} /> : <BrandLogo height={26} />}
|
||||
</Link>
|
||||
{/* Said loudly on purpose: this console can suspend and delete a
|
||||
company, and somebody with both consoles open needs to know
|
||||
which tab they are in before they click. */}
|
||||
<Token label="Admin" size="sm" />
|
||||
</HStack>
|
||||
}
|
||||
startContent={
|
||||
<HStack gap={1} vAlign="center" className="self-stretch">
|
||||
{ADMIN_NAV.map((item) => {
|
||||
const selected = isAdminNavActive(pathname, item.href);
|
||||
return (
|
||||
<TopNavItem
|
||||
key={item.href}
|
||||
label={item.label}
|
||||
href={item.href}
|
||||
isSelected={selected}
|
||||
className={selected ? TAB_SELECTED : TAB}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
}
|
||||
endContent={
|
||||
<HStack gap={2} vAlign="center">
|
||||
<LoyalyAiToggle />
|
||||
<AccountMenu />
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is signed in, and the way out.
|
||||
*
|
||||
* Not the workspace AccountMenu: every row in that one routes into a tenant's
|
||||
* settings, and this account has no tenant. The EMAIL heads the menu rather
|
||||
* than the display name — on a console that can delete a company, which
|
||||
* account is signed in matters more than whose it is.
|
||||
*/
|
||||
function AccountMenu() {
|
||||
const {user, logout} = useSession();
|
||||
const router = useRouter();
|
||||
const who = user?.email ?? 'Platform admin';
|
||||
|
||||
async function signOut() {
|
||||
await logout();
|
||||
router.replace('/login');
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
button={{
|
||||
variant: 'ghost',
|
||||
size: 'md',
|
||||
label: `Account: ${who}`,
|
||||
isIconOnly: true,
|
||||
icon: <Avatar name={user?.name || who} size="sm" tooltip={false} />,
|
||||
}}
|
||||
hasChevron={false}
|
||||
menuWidth={260}
|
||||
items={[
|
||||
{
|
||||
type: 'section',
|
||||
title: who,
|
||||
items: [{label: 'Platform admin', isDisabled: true}],
|
||||
},
|
||||
{type: 'divider'},
|
||||
{label: 'Profile', onClick: () => router.push('/admin/profile')},
|
||||
{label: 'Settings', onClick: () => router.push('/admin/settings')},
|
||||
{type: 'divider'},
|
||||
{label: 'Sign out', onClick: () => void signOut()},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tabs again, as a drawer, for the width where TopNav drops them. */
|
||||
const DRAWER = [
|
||||
'[&>div]:w-4/5',
|
||||
'[&>div>*:first-child>button]:size-11',
|
||||
'[&_.astryx-side-nav-item]:h-12',
|
||||
].join(' ');
|
||||
|
||||
function AdminMobileMenu() {
|
||||
const pathname = usePathname();
|
||||
const {isDrawerOpen, setDrawerOpen, closeDrawer} = useSidebar();
|
||||
|
||||
useEffect(() => {
|
||||
closeDrawer();
|
||||
}, [pathname, closeDrawer]);
|
||||
|
||||
return (
|
||||
<MobileNav
|
||||
id={NAV_DRAWER_ID}
|
||||
label="Navigation"
|
||||
side="start"
|
||||
width={320}
|
||||
isOpen={isDrawerOpen}
|
||||
onOpenChange={setDrawerOpen}
|
||||
className={DRAWER}
|
||||
header={<BrandLogo height={26} />}
|
||||
>
|
||||
<SideNavSection title="Platform admin" isHeaderHidden>
|
||||
{ADMIN_NAV.map((item) => (
|
||||
<SideNavItem
|
||||
key={item.href}
|
||||
label={item.label}
|
||||
href={item.href}
|
||||
isSelected={isAdminNavActive(pathname, item.href)}
|
||||
onClick={closeDrawer}
|
||||
/>
|
||||
))}
|
||||
</SideNavSection>
|
||||
</MobileNav>
|
||||
);
|
||||
}
|
||||
36
src/features/admin/components/shell/admin-nav.ts
Normal file
36
src/features/admin/components/shell/admin-nav.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* The platform console's top navigation.
|
||||
*
|
||||
* Platform-level destinations only. Everything that belongs to one merchant —
|
||||
* shops, sales persons, cameras, customers, sales, analytics, device logs —
|
||||
* lives inside that merchant (merchants → merchant → shop → camera), never
|
||||
* here, so no global tab can mix two merchants' data.
|
||||
*
|
||||
* Footfall and Commerce are platform views that still resolve Merchant → Shop
|
||||
* before they ask for anything; see PlatformPages.
|
||||
*
|
||||
* Add a tab here, with its route, when the platform can answer it — TopNav
|
||||
* reads best at five or fewer.
|
||||
*/
|
||||
export interface AdminNavEntry {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export const ADMIN_NAV: AdminNavEntry[] = [
|
||||
{label: 'Overview', href: '/admin'},
|
||||
{label: 'Merchants', href: '/admin/merchants'},
|
||||
{label: 'Footfall', href: '/admin/footfall'},
|
||||
{label: 'Commerce', href: '/admin/commerce'},
|
||||
];
|
||||
|
||||
/**
|
||||
* `/admin` is the overview and a prefix of every other entry, so it matches
|
||||
* exactly; everything else lights up for its own detail pages too — a shop
|
||||
* page under /admin/merchants/… keeps "Merchants" selected, because that is
|
||||
* the path the operator took to get there.
|
||||
*/
|
||||
export function isAdminNavActive(pathname: string, href: string): boolean {
|
||||
if (href === '/admin') return pathname === '/admin';
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
Reference in New Issue
Block a user