update api and login

This commit is contained in:
2026-09-29 00:44:48 +05:30
parent b36a30385c
commit 2d8739e9a4
50 changed files with 2044 additions and 986 deletions

View File

@@ -1,4 +1,5 @@
import type {NextRequest} from 'next/server';
import {UpstreamError} from '@/services/api/apiClient';
import {engagementApi} from '@/services/api/engagementApi';
import {toReportWindow, toSiteParam} from '@/services/api/range';
import {serveUpstream} from '@/shared/services/bff';
@@ -17,10 +18,17 @@ export async function GET(req: NextRequest) {
return serveUpstream(req, async (token, query) => {
const window = toReportWindow(query.range, new Date(query.nowMs));
const site = toSiteParam(query.storeId);
const [activities, impact] = await Promise.all([
engagementApi.activities(token, window, site),
engagementApi.impact(token, window, site),
]);
return toActivityRows(activities, impact);
try {
const [activities, impact] = await Promise.all([
engagementApi.activities(token, window, site),
engagementApi.impact(token, window, site),
]);
return toActivityRows(activities, impact);
} catch (err) {
// The deployed platform predates this route: an empty panel, not a red
// error. Any other failure, a real 404 included, still surfaces.
if (err instanceof UpstreamError && err.isRouteMissing) return [];
throw err;
}
});
}

View File

@@ -1,10 +1,24 @@
import type {NextRequest} from 'next/server';
import {adminApi} from '@/services/api/adminApi';
import {proxyUpstream} from '@/shared/services/bff';
import {toCompany} from '@/features/admin/services/mapCompany';
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
import {toCompany, toCompanyDetail} from '@/features/admin/services/mapCompany';
export const dynamic = 'force-dynamic';
/**
* GET /api/admin/clients/{id} — one company, with its owner.
*
* Suspended companies still resolve: suspension is exactly when an operator
* opens the page. A non-uuid or unknown id is the platform's 404.
*/
export async function GET(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return serveUpstream(req, (token) => adminApi.getClient(token, id), toCompanyDetail);
}
/**
* PATCH /api/admin/clients/{id} — suspend or reinstate a company.
*

View File

@@ -0,0 +1,22 @@
import type {NextRequest} from 'next/server';
import {adminApi} from '@/services/api/adminApi';
import {serveUpstream} from '@/shared/services/bff';
import {toAdminCamera} from '@/features/admin/services/mapMonitoring';
export const dynamic = 'force-dynamic';
/**
* GET /api/admin/clients/{id}/sites/{site}/cameras/{camera} — one camera. The
* platform checks company → shop → camera in one query.
*/
export async function GET(
req: NextRequest,
{params}: {params: Promise<{id: string; site: string; camera: string}>},
) {
const {id, site, camera} = await params;
return serveUpstream(
req,
(token) => adminApi.getSiteCamera(token, id, site, camera),
toAdminCamera,
);
}

View File

@@ -0,0 +1,22 @@
import type {NextRequest} from 'next/server';
import {adminApi} from '@/services/api/adminApi';
import {serveUpstream} from '@/shared/services/bff';
import {toAdminCamera} from '@/features/admin/services/mapMonitoring';
export const dynamic = 'force-dynamic';
/**
* GET /api/admin/clients/{id}/sites/{site}/cameras — the shop's cameras,
* redacted upstream: no host, port, path or credentials reach this console.
*/
export async function GET(
req: NextRequest,
{params}: {params: Promise<{id: string; site: string}>},
) {
const {id, site} = await params;
return serveUpstream(
req,
(token) => adminApi.listSiteCameras(token, id, site),
(rows) => rows.map(toAdminCamera),
);
}

View File

@@ -0,0 +1,24 @@
import type {NextRequest} from 'next/server';
import {adminApi} from '@/services/api/adminApi';
import {serveUpstream} from '@/shared/services/bff';
import {toAdminStore} from '@/features/admin/services/mapMonitoring';
export const dynamic = 'force-dynamic';
/**
* GET /api/admin/clients/{id}/sites/{site} — one shop of one company.
*
* The platform checks the pair: a shop that belongs to another company is a
* 404, never that company's row.
*/
export async function GET(
req: NextRequest,
{params}: {params: Promise<{id: string; site: string}>},
) {
const {id, site} = await params;
return serveUpstream(
req,
(token) => adminApi.getClientSite(token, id, site),
toAdminStore,
);
}

View File

@@ -0,0 +1,22 @@
import type {NextRequest} from 'next/server';
import {adminApi} from '@/services/api/adminApi';
import {serveUpstream} from '@/shared/services/bff';
import {toAdminStore} from '@/features/admin/services/mapMonitoring';
export const dynamic = 'force-dynamic';
/**
* GET /api/admin/clients/{id}/sites — the company's active shops, with the
* shop PC's liveness and camera counts. Read-only.
*/
export async function GET(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return serveUpstream(
req,
(token) => adminApi.listClientSites(token, id),
(rows) => rows.map(toAdminStore),
);
}

View File

@@ -0,0 +1,15 @@
import type {NextRequest} from 'next/server';
import {adminApi} from '@/services/api/adminApi';
import {serveUpstream} from '@/shared/services/bff';
import {toPlatformMonitoring} from '@/features/admin/services/mapMonitoring';
export const dynamic = 'force-dynamic';
/** GET /api/admin/monitoring/summary — estate-wide counts for the Overview. */
export async function GET(req: NextRequest) {
return serveUpstream(
req,
(token) => adminApi.monitoringSummary(token),
toPlatformMonitoring,
);
}

View File

@@ -5,6 +5,7 @@ import {UpstreamError} from '@/services/api/apiClient';
import {ConfigError} from '@/shared/errors/configError';
import {
LOGIN_ERROR_PARAM,
NOT_PLATFORM_ADMIN_MESSAGE,
type LoginErrorCode,
} from '@/features/auth/services/loginErrorCodes';
import {resolveRedirectTargetFor} from '@/features/auth/services/redirectTarget';
@@ -234,6 +235,35 @@ export async function POST(req: NextRequest) {
* a session it is unable to verify on the next request.
*/
const user = toAuthUser(bundle.user);
/**
* This is the Platform Admin console: it admits platform admins and nobody else.
*
* Correct merchant or staff credentials are still not Platform Admin
* credentials. Refused HERE, before `storeTokens` and before any cookie, so
* no session of any kind exists on this domain for them — not a merchant
* session that the proxy then has to route away from. The platform session
* `authApi.login` just minted is revoked for the same reason the
* misconfigured branch below revokes it: an unused refresh token is a
* credential left lying around. No redirect to the merchant app either; the
* form stays put and says why.
*/
if (!user.isPlatformAdmin) {
try {
await authApi.logout(bundle.access_token);
} catch {
/* best-effort: the refusal must not depend on the revoke succeeding */
}
const code: LoginErrorCode = 'not_platform_admin';
if (isForm) {
return NextResponse.redirect(
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
303,
);
}
return failJson(code, NOT_PLATFORM_ADMIN_MESSAGE, 403);
}
/**
* Two different lifetimes, and conflating them was the bug.
*

View File

@@ -34,6 +34,13 @@ export async function GET() {
try {
const user = await withUpstream((token) => authApi.me(token));
// This console knows only platform admins: a merchant or staff session is
// answered as nobody signed in, and its cookies are cleared, exactly like a
// session the platform rejected.
if (!toAuthUser(user).isPlatformAdmin) {
return anonymous();
}
const session: AuthSession = {
user: toAuthUser(user),
// The cookie's own expiry is the browser-side lifetime; the platform's
@@ -64,41 +71,46 @@ export async function GET() {
);
}
const res = NextResponse.json(
{data: null, meta: {generatedAt: new Date().toISOString()}},
{headers: {'cache-control': 'no-store'}},
);
/**
* Clear THIS TAB's cookies, by their tab-scoped names.
*
* This used to clear `loyaly_session` and `loyaly_tokens` — the unscoped
* names from before sessions were per-tab. Those cookies do not exist any
* more, so the clear silently did nothing and a confirmed 401 left the
* tab's real `loyaly_session_<tabId>` in place. The result was the
* half-authenticated state upstreamSession warns about, with a twist: the
* client set itself unauthenticated and went to /login, the proxy saw a
* still-valid session cookie and sent it straight back, and the two flapped.
*
* Same two helpers the logout route uses, so there is one naming scheme and
* the two paths cannot drift. Scoped to the resolved tab and no other: a
* dead session in one tab says nothing about the others, and clearing more
* than asked would sign out a tab that is working fine.
*
* A request with no resolvable tab clears nothing. There is no cookie to
* name, and guessing would reach into somebody else's session.
*/
const tabId = await resolveTabId();
if (tabId) {
res.cookies.set(sessionCookieFor(tabId), '', sessionCookieOptions(0));
res.cookies.set(tokenCookieFor(tabId), '', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 0,
});
}
return res;
return anonymous();
}
}
/** Nobody signed in: `data: null`, and this tab's session cookies cleared. */
async function anonymous() {
const res = NextResponse.json(
{data: null, meta: {generatedAt: new Date().toISOString()}},
{headers: {'cache-control': 'no-store'}},
);
/**
* Clear THIS TAB's cookies, by their tab-scoped names.
*
* This used to clear `loyaly_session` and `loyaly_tokens` — the unscoped
* names from before sessions were per-tab. Those cookies do not exist any
* more, so the clear silently did nothing and a confirmed 401 left the
* tab's real `loyaly_session_<tabId>` in place. The result was the
* half-authenticated state upstreamSession warns about, with a twist: the
* client set itself unauthenticated and went to /login, the proxy saw a
* still-valid session cookie and sent it straight back, and the two flapped.
*
* Same two helpers the logout route uses, so there is one naming scheme and
* the two paths cannot drift. Scoped to the resolved tab and no other: a
* dead session in one tab says nothing about the others, and clearing more
* than asked would sign out a tab that is working fine.
*
* A request with no resolvable tab clears nothing. There is no cookie to
* name, and guessing would reach into somebody else's session.
*/
const tabId = await resolveTabId();
if (tabId) {
res.cookies.set(sessionCookieFor(tabId), '', sessionCookieOptions(0));
res.cookies.set(tokenCookieFor(tabId), '', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 0,
});
}
return res;
}

View File

@@ -1,5 +1,6 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {refuseOffConsole} from '@/features/auth/services/serverSession';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
@@ -15,6 +16,8 @@ export async function DELETE(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const refused = await refuseOffConsole();
if (refused) return refused;
const {id} = await params;
return proxyUpstream(req, (token) => authApi.revokeSession(token, id));
}

View File

@@ -1,5 +1,6 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {refuseOffConsole} from '@/features/auth/services/serverSession';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
@@ -12,5 +13,7 @@ export const dynamic = 'force-dynamic';
* screen they are doing it from.
*/
export async function POST(req: NextRequest) {
const refused = await refuseOffConsole();
if (refused) return refused;
return proxyUpstream(req, (token) => authApi.revokeOtherSessions(token));
}

View File

@@ -1,5 +1,6 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {refuseOffConsole} from '@/features/auth/services/serverSession';
import {serveUpstream} from '@/shared/services/bff';
import {toDeviceSession} from '@/features/settings/services/mapSession';
@@ -14,6 +15,8 @@ export const dynamic = 'force-dynamic';
* from the browser they are reading the page in.
*/
export async function GET(req: NextRequest) {
const refused = await refuseOffConsole();
if (refused) return refused;
return serveUpstream(req, (token) => authApi.sessions(token), (list) =>
list.map(toDeviceSession),
);

View File

@@ -1,4 +1,5 @@
import type {NextRequest} from 'next/server';
import {UpstreamError} from '@/services/api/apiClient';
import {engagementApi} from '@/services/api/engagementApi';
import {toReportWindow, toSiteParam} from '@/services/api/range';
import {serveUpstream} from '@/shared/services/bff';
@@ -10,7 +11,14 @@ export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
return serveUpstream(req, async (token, query) => {
const window = toReportWindow(query.range, new Date(query.nowMs));
const list = await engagementApi.campaigns(token, window, toSiteParam(query.storeId));
return (list ?? []).map(toCampaign);
try {
const list = await engagementApi.campaigns(token, window, toSiteParam(query.storeId));
return (list ?? []).map(toCampaign);
} catch (err) {
// The deployed platform predates this route: an empty panel, not a red
// error. Any other failure, a real 404 included, still surfaces.
if (err instanceof UpstreamError && err.isRouteMissing) return [];
throw err;
}
});
}

View File

@@ -49,7 +49,7 @@ export async function GET(req: NextRequest) {
// If the upstream platform has not deployed /api/floor/visits yet,
// answer with an empty list so the floor screen renders its clean empty state
// rather than failing with 404.
if (err instanceof UpstreamError && err.status === 404) {
if (err instanceof UpstreamError && err.isRouteMissing) {
return {items: []};
}
throw err;

View File

@@ -1,4 +1,5 @@
import type {NextRequest} from 'next/server';
import {UpstreamError} from '@/services/api/apiClient';
import {engagementApi} from '@/services/api/engagementApi';
import {toReportWindow, toSiteParam} from '@/services/api/range';
import {serveUpstream} from '@/shared/services/bff';
@@ -14,8 +15,17 @@ export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
return serveUpstream(req, async (token, query) => {
const window = toReportWindow(query.range, new Date(query.nowMs));
return toJourney(
await engagementApi.journey(token, window, toSiteParam(query.storeId)),
);
try {
return toJourney(
await engagementApi.journey(token, window, toSiteParam(query.storeId)),
);
} catch (err) {
// The deployed platform predates this route: an empty panel, not a red
// error. Any other failure, a real 404 included, still surfaces.
if (err instanceof UpstreamError && err.isRouteMissing) {
return {stages: [], attribution: 'observed'};
}
throw err;
}
});
}

View File

@@ -57,8 +57,9 @@ export async function GET(req: NextRequest) {
} catch (err) {
// If the upstream platform has not deployed /api/sales yet,
// answer with an empty list so the sales screen renders cleanly
// rather than failing with 404.
if (err instanceof UpstreamError && err.status === 404) {
// rather than failing. Only a missing ROUTE — a 404 for a shop that
// does not exist is a real answer and still surfaces.
if (err instanceof UpstreamError && err.isRouteMissing) {
return {items: []};
}
throw err;
@@ -87,6 +88,7 @@ export async function GET(req: NextRequest) {
export async function POST(req: NextRequest) {
let body: {
idempotencyKey?: unknown;
clientCreatedAt?: unknown;
visitId?: unknown;
visitorId?: unknown;
invoiceNo?: unknown;
@@ -125,7 +127,7 @@ export async function POST(req: NextRequest) {
site: typeof body.site === 'string' ? body.site : undefined,
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
visitor_id: typeof body.visitorId === 'string' ? body.visitorId : undefined,
client_created_at: new Date().toISOString(),
client_created_at: draftTime(body.clientCreatedAt),
lines,
}),
);
@@ -139,10 +141,16 @@ export async function POST(req: NextRequest) {
sale: result.sale ? toSale(result.sale) : null,
},
},
{status: 201, headers: {'cache-control': 'no-store'}},
// The platform's own status: 201 for a new sale, 200 for a replay.
{
status: result.status === 'already_processed' ? 200 : 201,
headers: {'cache-control': 'no-store'},
},
);
} catch (err) {
if (err instanceof UpstreamError && err.status === 404) {
// 404 "No such endpoint." or 405 — the deployed platform has no sale
// writer yet. Any other 404 (a visit that is not this shop's) is real.
if (err instanceof UpstreamError && err.isRouteMissing) {
return Response.json(
{
error: {
@@ -161,3 +169,17 @@ export async function POST(req: NextRequest) {
);
}
}
/**
* When the sale was drafted on the device, as the dialog stamped it. Falls
* back to now for a missing or unparseable value, and for one in the future —
* a device clock ahead of the server must not date a sale tomorrow.
*/
function draftTime(value: unknown): string {
const now = Date.now();
if (typeof value !== 'string') return new Date(now).toISOString();
const t = Date.parse(value);
return Number.isFinite(t) && t <= now
? new Date(t).toISOString()
: new Date(now).toISOString();
}

View File

@@ -0,0 +1,23 @@
import {fail} from '@/shared/services/apiRoute';
export const dynamic = 'force-dynamic';
/**
* GET / PATCH /api/settings/profile — the merchant's business profile.
*
* The platform has no profile endpoint yet (docs/API-STATUS.md), so there is
* nothing to forward to. Without this file Next.js answered its own HTML 404
* page, which the settings screen could only report as "Unexpected response".
* This says what is actually true, in the envelope the screen reads. Once the
* platform ships the route, replace both handlers with `serveUpstream` /
* `proxyUpstream` calls against it.
*/
const NOT_YET = 'Business profile is not available on the platform yet.';
export function GET() {
return fail('not_deployed', NOT_YET, 501);
}
export function PATCH() {
return fail('not_deployed', NOT_YET, 501);
}

View File

@@ -15,7 +15,14 @@ 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 {StatusDot} from '@astryxdesign/core/StatusDot';
import {Icon} from '@astryxdesign/core/Icon';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {ICONS} from '@/shared/utils/icons';
import {useCompanies} from '@/features/admin/hooks/useCompanies';
import {summarise} from '@/features/admin/hooks/useMonitoring';
import {adminHref} from './MonitoringTables';
import {AdminPageHeader} from './common/AdminPageHeader';
import {PendingIntegration} from './common/PendingIntegration';
@@ -27,8 +34,8 @@ import {PendingIntegration} from './common/PendingIntegration';
* 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
* Settings has two live parts: appearance (a cookie this app owns) and the
* merchant activity status list (`GET /api/admin/clients`). 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.
@@ -106,7 +113,7 @@ export function AdminSettings() {
<VStack gap={6} width="100%">
<AdminPageHeader
title="Settings"
subtitle="Appearance and sign-in security for your platform admin account."
subtitle="Appearance, merchant activity and sign-in security for your platform admin account."
actions={<Link href="/admin/profile">Profile</Link>}
/>
@@ -125,7 +132,7 @@ export function AdminSettings() {
</SegmentedControl>
</StaticPanel>
<ActiveSessionsPanel />
<MerchantActivityStatus />
<StaticPanel title="Password and two-factor">
<PendingIntegration
@@ -137,3 +144,70 @@ export function AdminSettings() {
</VStack>
);
}
/**
* Every merchant and whether it is active or suspended.
*
* Only what `GET /api/admin/clients` returns: `isActive`, `sites`, `users`,
* `createdAt`. The platform has no last-seen or last-activity field, so none
* is shown. Suspended merchants sort first — they are the rows an admin opens
* this list to find.
*/
function MerchantActivityStatus() {
const companies = useCompanies();
return (
<AsyncBoundary resource={companies} loading={<SkeletonRows count={3} />}>
{(rows) => {
const s = summarise(rows);
const sorted = [...rows].sort(
(a, b) =>
Number(a.isActive) - Number(b.isActive) ||
a.name.localeCompare(b.name),
);
return (
<StaticPanel
title="Merchant activity status"
subtitle={`${s.active} active · ${s.suspended} suspended`}
actions={<Link href={adminHref.merchants}>View all</Link>}
>
<List>
{sorted.map((c) => (
<ListItem
key={c.id}
href={adminHref.company(c.id)}
label={c.name}
description={`${c.sites} ${c.sites === 1 ? 'store' : 'stores'} · ${c.users} ${c.users === 1 ? 'account' : 'accounts'} · Added ${formatDate(c.createdAt)}`}
startContent={<Avatar name={c.name} size="sm" tooltip={false} />}
endContent={
<HStack gap={3} vAlign="center">
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={c.isActive ? 'success' : 'error'}
label={c.isActive ? 'Active' : 'Suspended'}
/>
<Text size="sm">{c.isActive ? 'Active' : 'Suspended'}</Text>
</HStack>
<Icon icon={ICONS.arrowRight} size="sm" color="secondary" />
</HStack>
}
/>
))}
</List>
</StaticPanel>
);
}}
</AsyncBoundary>
);
}
function formatDate(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime())
? iso
: d.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}

View File

@@ -1,11 +1,11 @@
'use client';
import {useRouter} from 'next/navigation';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {EmptyState} from '@astryxdesign/core/EmptyState';
import {Button} from '@astryxdesign/core/Button';
import {useEffect} from 'react';
import {Center} from '@astryxdesign/core/Center';
import {Spinner} from '@astryxdesign/core/Spinner';
import {AuthGuard} from '@/features/auth/guards/AuthGuard';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {destinationForUser} from '@/features/auth/services/roleDestination';
import {AdminShell} from './shell/AdminShell';
/**
@@ -29,51 +29,40 @@ export function AdminLayout({children}: {children: React.ReactNode}) {
}
/**
* What a merchant session sees if it reaches the platform console.
* Platform admins only. A merchant or staff session never sees this 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.
* The proxy redirects them before this renders, but it resolves a DOCUMENT
* request 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 /admin for the
* operator's pointer while this tab 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.
* This used to show an explanation with a "Go to dashboard" link. Now it sends
* the tab straight to its own home — /dashboard for a merchant, /floor for
* staff — and renders nothing of the admin console in between.
*
* 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.
* A full navigation rather than `router.replace`: the tab session script claims
* the pointer on `beforeunload`, so the next document request is gated as THIS
* tab. A client-side RSC fetch could be bounced back to /admin by the
* operator's pointer — the loop that made the old version give up and explain.
*
* Routing only, like the proxy. Every admin read carries this tab's id, and the
* proxy and platform answer 404 to a non-admin either way.
*/
function OperatorsOnly({children}: {children: React.ReactNode}) {
const {isAuthenticated, user, logout} = useSession();
const router = useRouter();
const {user} = useSession();
const isOperator = user?.isPlatformAdmin === true;
const home = user && !isOperator ? destinationForUser(user) : null;
if (user?.isPlatformAdmin === true) return <>{children}</>;
if (!isAuthenticated) return null;
useEffect(() => {
if (home) window.location.replace(home);
}, [home]);
async function signOut() {
await logout();
router.replace('/login?next=%2Fadmin');
}
if (isOperator) return <>{children}</>;
return (
<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>
<Center height="100vh" role="status" aria-label="Leaving the admin console">
<Spinner size="lg" />
</Center>
);
}

View File

@@ -110,7 +110,7 @@ function CameraBody({
back={{label: storeName, href: adminHref.store(company.id, storeId)}}
crumbs={[
{label: 'Merchants', href: adminHref.merchants},
{label: company.name, href: adminHref.company(company.id)},
{label: company.name, href: adminHref.shops(company.id)},
{label: storeName, href: adminHref.store(company.id, storeId)},
{label: cameraName},
]}
@@ -124,7 +124,7 @@ function CameraBody({
icon: 'camera',
title: 'Camera data unavailable',
description:
'Status, location and last-seen time need the platform admin camera-detail API.',
'Status, last-seen and snapshot time need the platform admin camera-detail API.',
}}
loading={<SkeletonRows count={3} />}
>
@@ -132,8 +132,13 @@ function CameraBody({
<List>
<ListItem label="Status" endContent={<CameraStatus status={c.status} />} />
<ListItem
label="Location"
endContent={<Text size="sm">{c.zone ?? '—'}</Text>}
label="Camera ID"
description="What the engine knows it by."
endContent={<Text size="sm">{c.cameraId}</Text>}
/>
<ListItem
label="Enabled"
endContent={<Text size="sm">{c.isEnabled ? 'Yes' : 'No'}</Text>}
/>
<ListItem
label="Last seen"
@@ -143,6 +148,14 @@ function CameraBody({
</Text>
}
/>
<ListItem
label="Last snapshot"
endContent={
<Text size="sm">
{c.snapshotAt ? new Date(c.snapshotAt).toLocaleString() : '—'}
</Text>
}
/>
</List>
)}
</AdminSection>

View File

@@ -1,14 +1,14 @@
'use client';
import {useState} from 'react';
import {useRouter} from 'next/navigation';
import {useRouter, useSearchParams} from 'next/navigation';
import {Card} from '@astryxdesign/core/Card';
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
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';
@@ -24,31 +24,33 @@ import {
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {
useCompany,
useCompanyOwner,
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 {adminHref} from './MonitoringTables';
import {ShopCard, ShopList} from './ShopCards';
import {PendingIntegration} from './common/PendingIntegration';
import {
ADMIN_CAPABILITIES,
MERCHANT_AREAS,
} from '@/features/admin/config/capabilities';
import type {MerchantArea} from '@/features/admin/config/capabilities';
import type {AdminData} from '@/features/admin/hooks/useMonitoring';
import type {AdminStore} from '@/features/admin/types/monitoring';
/**
* 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".
* Name, slug, status, shop count, account count, created — from the merchant
* row — plus the owner's name and email from the detail read, when the
* platform serves it. There is no plan or contact beyond that. If the shops
* cannot be listed, the Shops tab says so 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,
@@ -129,8 +131,30 @@ function CompanyBody({
onChanged: () => void;
onDeleted: () => void;
}) {
const [tab, setTab] = useState<MerchantTab>('general');
const router = useRouter();
const searchParams = useSearchParams();
// The tab lives in the URL, so "back" from a shop lands on Shops, and a
// Shops link can be shared.
const tab: MerchantTab =
searchParams.get('tab') === 'shops' ? 'shops' : 'general';
const setTab = (t: MerchantTab) =>
router.replace(
t === 'shops'
? adminHref.shops(company.id)
: adminHref.company(company.id),
{scroll: false},
);
const status = company.isActive ? 'Active' : 'Suspended';
// One request feeds the tab badge, the General preview and the Shops tab,
// so the count shown and the shops listed can never disagree. Until the
// list exists, the merchant row's own `sites` is the only count there is.
const stores = useCompanyStores(company.id);
const listed =
stores.isAvailable &&
(stores.resource.status === 'success' || stores.resource.status === 'empty')
? stores.resource.data
: undefined;
const shopCount = listed ? listed.length : company.sites;
return (
<VStack gap={6} width="100%">
@@ -196,14 +220,23 @@ function CompanyBody({
<Tab
value="shops"
label="Shops"
endContent={<Badge label={company.sites.toLocaleString()} />}
endContent={<Badge label={shopCount.toLocaleString()} />}
/>
</TabList>
{tab === 'general' ? (
<MerchantGeneral company={company} />
<MerchantGeneral
company={company}
stores={stores}
shopCount={shopCount}
onOpenShops={() => setTab('shops')}
/>
) : (
<MerchantShops company={company} />
<MerchantShops
company={company}
stores={stores}
shopCount={shopCount}
/>
)}
</VStack>
);
@@ -220,19 +253,34 @@ const fmtDate = (iso: string) =>
const DAY_MS = 24 * 60 * 60 * 1000;
function MerchantGeneral({company}: {company: Company}) {
function MerchantGeneral({
company,
stores,
shopCount,
onOpenShops,
}: {
company: Company;
stores: AdminData<AdminStore[]>;
shopCount: number;
onOpenShops: () => void;
}) {
const bp = useBreakpoint();
const owner = useCompanyOwner(company.id);
// 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),
);
// Merchant-level areas the platform cannot answer yet. Cameras and their
// heartbeat live inside a shop, so they are not listed here.
const upcoming = MERCHANT_AREAS.filter(
(a) =>
!['shops', 'cameras', 'heartbeat'].includes(a.key) &&
!ADMIN_CAPABILITIES[a.capability],
).map((a) => a.label);
return (
<VStack gap={6} width="100%">
@@ -240,14 +288,19 @@ function MerchantGeneral({company}: {company: Company}) {
<Fact
icon="stores"
label="Shops"
value={company.sites.toLocaleString()}
value={shopCount.toLocaleString()}
caption="Registered to this merchant"
action={{label: 'View shops →', onClick: onOpenShops}}
/>
<Fact
icon="staff"
label="Merchant accounts"
value={company.users.toLocaleString()}
caption="Logins on this merchant"
caption={
owner
? `Owner: ${[owner.name, owner.email].filter(Boolean).join(' · ')}`
: 'Logins on this merchant'
}
/>
<Fact
icon="expiry"
@@ -268,98 +321,114 @@ function MerchantGeneral({company}: {company: Company}) {
<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.`}
title="Shops"
subtitle={`The shops and businesses ${company.name} runs. Open one to see its cameras, footfall and sales.`}
actions={
<Token size="sm" label={`${live} of ${areas.length} live`} />
shopCount > 0 ? (
<Button
size="sm"
variant="secondary"
label="View all shops"
onClick={onOpenShops}
/>
) : undefined
}
/>
<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>
{stores.isAvailable ? (
<AsyncBoundary
resource={stores.resource}
loading={<SkeletonRows count={2} />}
empty={
<Text size="sm" color="secondary">
{company.name} has not opened a shop yet.
</Text>
}
>
{(rows) => (
<Grid
columns={bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 3}
gap={4}
>
{[...rows]
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, PREVIEW)
.map((s) => (
<ShopCard key={s.id} companyId={company.id} shop={s} />
))}
</Grid>
)}
</AsyncBoundary>
) : (
<ShopsPending company={company} />
)}
</VStack>
</Card>
{upcoming.length > 0 ? (
<Text size="xsm" color="secondary">
Also coming to this merchant as the platform exposes them:{' '}
{upcoming.join(' · ')}.
</Text>
) : null}
</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}) {
const PREVIEW = 3;
/** Why the shops cannot be opened yet — said once, in engineering terms. */
function ShopsPending({company}: {company: Company}) {
const n = company.sites;
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>
<PendingIntegration
icon="stores"
title="Shops cannot be listed yet"
description={
n === 0
? `${company.name} has not opened a shop.`
: `The platform reports ${n} ${n === 1 ? 'shop' : 'shops'} for ${company.name} but has no platform-admin endpoint to list them. Once GET /api/admin/clients/{id}/sites ships, every shop opens from here.`
}
/>
);
}
function MerchantShops({company}: {company: Company}) {
const stores = useCompanyStores(company.id);
const n = company.sites;
const shops = `${n} ${n === 1 ? 'shop' : 'shops'}`;
function MerchantShops({
company,
stores,
shopCount,
}: {
company: Company;
stores: AdminData<AdminStore[]>;
shopCount: number;
}) {
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.`}
<Card padding={6}>
<VStack gap={5} width="100%">
<SectionHeader
title="Shops"
subtitle={`${shopCount.toLocaleString()} ${
shopCount === 1 ? 'shop' : 'shops'
} registered to ${company.name}.`}
/>
}
>
{(rows) => <StoreTable companyId={company.id} stores={rows} />}
</AdminSection>
{stores.isAvailable ? (
<AsyncBoundary
resource={stores.resource}
loading={<SkeletonRows count={4} />}
empty={
<EmptyPanel
icon="stores"
title="No shops"
description={`${company.name} has not opened a shop yet.`}
/>
}
>
{(rows) => <ShopList companyId={company.id} shops={rows} />}
</AsyncBoundary>
) : (
<ShopsPending company={company} />
)}
</VStack>
</Card>
);
}
@@ -369,34 +438,74 @@ function Fact({
label,
value,
caption,
action,
}: {
icon: IconKey;
label: string;
value: string;
caption: string;
/** Makes the whole card a way in — e.g. Shops → the Shops tab. */
action?: {label: string; onClick: () => void};
}) {
return (
const body = (
<FactBody icon={icon} label={label} value={value} caption={caption}>
{action ? (
<Text size="sm" weight="medium">
{action.label}
</Text>
) : null}
</FactBody>
);
return action ? (
<ClickableCard
label={`${label}: ${action.label}`}
onClick={action.onClick}
padding={4}
className="h-full"
>
{body}
</ClickableCard>
) : (
<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>
{body}
</Card>
);
}
function FactBody({
icon,
label,
value,
caption,
children,
}: {
icon: IconKey;
label: string;
value: string;
caption: string;
children?: React.ReactNode;
}) {
return (
<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>
{children}
</VStack>
);
}

View File

@@ -4,16 +4,13 @@ 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';
/**
@@ -35,79 +32,14 @@ export const adminHref = {
merchants: '/admin/merchants',
company: (companyId: string) =>
`/admin/merchants/${encodeURIComponent(companyId)}`,
/** The merchant page, opened on its Shops tab — where "back" from a shop goes. */
shops: (companyId: string) => `${adminHref.company(companyId)}?tab=shops`,
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<
@@ -131,66 +63,6 @@ export function CameraStatus({status}: {status: AdminCameraStatus}) {
);
}
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'}

View File

@@ -14,20 +14,24 @@ 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 {ACCENT} from '@/shared/utils/accent';
import type {BrandAccent} from '@/shared/utils/accent';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {useCompanies} from '@/features/admin/hooks/useCompanies';
import {summarise} from '@/features/admin/hooks/useMonitoring';
import {
summarise,
usePlatformMonitoring,
} 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 {AdminKpiCard, KpiCaption} from './common/AdminKpiCard';
import {CreateCompanyDialog} from './CreateCompanyDialog';
import {adminHref} from './MonitoringTables';
@@ -43,10 +47,6 @@ import {adminHref} from './MonitoringTables';
* 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 {
@@ -68,7 +68,7 @@ export function PlatformOverview() {
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;
const columns = bp === 'mobile' ? 1 : 3;
return (
<VStack gap={6} width="100%">
@@ -137,223 +137,83 @@ export function PlatformOverview() {
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"
>
<AdminKpiCard
label="Merchants"
value={s.companies}
unit="tenants"
icon={ICONS.companies}
tone="warm"
isFooterDivided={false}
footer={
<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={
s.active === s.companies ? 'success' : 'warning'
}
label="Health"
/>
<Text size="xsm" weight="medium">
{activePercent}% active rate
</Text>
<StatusDot variant="success" label="Active" />
<KpiCaption>{s.active} active</KpiCaption>
</HStack>
<Text size="xsm" color="secondary">
{s.companies - s.active === 0
? 'All nominal'
: `${s.companies - s.active} inactive`}
</Text>
<KpiCaption>{s.suspended} suspended</KpiCaption>
</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'}
}
/>
<AdminKpiCard
label="Active merchants"
value={s.active}
unit="active"
icon={ICONS.present}
tone="success"
footer={
<>
<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>
<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>
<KpiCaption>
{s.companies - s.active === 0
? 'All nominal'
: `${s.companies - s.active} inactive`}
</KpiCaption>
</>
}
/>
<AdminKpiCard
label="Stores"
value={s.stores}
unit="locations"
icon={ICONS.stores}
tone="warm"
footer={
<>
<KpiCaption>
{s.active} active {s.active === 1 ? 'client' : 'clients'}
</KpiCaption>
<Token size="sm" label={`${avgStores} avg / tenant`} />
</>
}
/>
</Grid>
);
}}
</AsyncBoundary>
<EstateKpis columns={columns} />
<Grid columns={bp === 'mobile' || bp === 'tablet' ? 1 : 2} gap={4}>
{/* Left Panel: Merchant Setup Status */}
<Card padding={5} elevation="low">
@@ -388,7 +248,7 @@ export function PlatformOverview() {
<HStack
hAlign="center"
vAlign="center"
className="size-10 rounded-full bg-surface text-primary border border-border shrink-0 shadow-sm"
className="size-10 rounded-full bg-success-muted text-success shrink-0"
>
<Icon icon={ICONS.roles} size="md" />
</HStack>
@@ -595,7 +455,7 @@ export function PlatformOverview() {
<Icon
icon={ICONS.stores}
size="xsm"
color="secondary"
className={ACCENT.warm.ink}
/>
<Text size="xsm" color="secondary">
{c.sites} {c.sites === 1 ? 'shop' : 'shops'}
@@ -608,7 +468,7 @@ export function PlatformOverview() {
<Icon
icon={ICONS.staff}
size="xsm"
color="secondary"
className={ACCENT.cool.ink}
/>
<Text size="xsm" color="secondary">
{c.users}{' '}
@@ -625,11 +485,7 @@ export function PlatformOverview() {
</VStack>
</HStack>
<HStack
gap={2}
vAlign="center"
className="shrink-0"
>
<HStack gap={2} vAlign="center" className="shrink-0">
<StatusDot
variant={c.isActive ? 'success' : 'error'}
label={c.isActive ? 'Active' : 'Suspended'}
@@ -673,6 +529,8 @@ interface CoverageItem {
capability: AdminCapability;
description: string;
icon: typeof ICONS.companies;
/** warm = what a merchant runs; cool = analytics and AI. */
accent: BrandAccent;
href?: string;
}
@@ -683,6 +541,7 @@ const COVERAGE: CoverageItem[] = [
description:
'Tenant isolation, company lifecycle, and operator credential provisioning.',
icon: ICONS.companies,
accent: 'warm',
href: adminHref.merchants,
},
{
@@ -691,6 +550,7 @@ const COVERAGE: CoverageItem[] = [
description:
'Storefront network topology, physical address mapping, and device linkages.',
icon: ICONS.stores,
accent: 'warm',
},
{
label: 'Footfall',
@@ -698,6 +558,7 @@ const COVERAGE: CoverageItem[] = [
description:
'Walk-in visitor velocity, camera detection streams, and dwell-time analytics.',
icon: ICONS.visitors,
accent: 'cool',
href: '/admin/footfall',
},
{
@@ -706,6 +567,7 @@ const COVERAGE: CoverageItem[] = [
description:
'Point-of-sale transactions, daily revenue tracking, and basket size metrics.',
icon: ICONS.commerce,
accent: 'warm',
href: '/admin/commerce',
},
{
@@ -714,9 +576,67 @@ const COVERAGE: CoverageItem[] = [
description:
'Cross-merchant operational copilot, anomaly diagnosis, and assistant rail.',
icon: ICONS.ai,
accent: 'cool',
},
];
/**
* Camera and visit totals across every merchant, from the platform's
* monitoring summary. Rendered only once it has real numbers: while loading,
* on a platform that predates the route, or on a failure, the row is simply
* absent — the merchant row above is the page, this is an extra, and a
* skeleton or error here would read as the overview being broken.
*/
function EstateKpis({columns}: {columns: number}) {
const monitoring = usePlatformMonitoring();
if (!monitoring.isAvailable || monitoring.resource.status !== 'success') {
return null;
}
const m = monitoring.resource.data;
const offline = m.camerasTotal - m.camerasOnline;
const onlinePercent =
m.camerasTotal > 0 ? Math.round((m.camerasOnline / m.camerasTotal) * 100) : 100;
return (
<Grid columns={columns} gap={4}>
<AdminKpiCard
label="Cameras online"
value={m.camerasOnline}
unit={`of ${m.camerasTotal.toLocaleString()}`}
icon={ICONS.camera}
tone={offline === 0 ? 'success' : 'warning'}
isFooterDivided={false}
footer={
<VStack gap={2} width="100%">
<ProgressBar
value={onlinePercent}
label="Cameras online"
isLabelHidden
variant={offline === 0 ? 'success' : 'warning'}
/>
<KpiCaption>
{offline === 0 ? 'Every camera reporting' : `${offline} not reporting`}
</KpiCaption>
</VStack>
}
/>
<AdminKpiCard
label="Visits today"
value={m.eventsToday}
unit="walk-ins"
icon={ICONS.visitors}
tone="cool"
footer={
<KpiCaption>
Across {m.storesTotal.toLocaleString()}{' '}
{m.storesTotal === 1 ? 'shop' : 'shops'}
</KpiCaption>
}
/>
</Grid>
);
}
function MonitoringCoverage() {
const bp = useBreakpoint();
const liveCount = COVERAGE.filter(
@@ -753,69 +673,70 @@ function MonitoringCoverage() {
</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 ? (
{COVERAGE.map(
({label, capability, description, icon, accent, 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
gap={1.5}
hAlign="center"
vAlign="center"
className="px-2 py-0.5 rounded-full bg-surface border border-border"
className={`size-8 rounded-lg shrink-0 ${ACCENT[accent].chip}`}
>
<StatusDot variant="success" label="Live" />
<Text size="xsm" weight="medium">
Live
</Text>
<Icon icon={icon} size="sm" />
</HStack>
) : (
<Token
size="sm"
label="Backend integration required"
/>
)}
</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 gap={0.5} width="100%">
<Text weight="semibold">{label}</Text>
<Text size="xsm" color="secondary">
{description}
</Text>
</VStack>
</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>
);
})}
{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>
);
}

View File

@@ -3,11 +3,11 @@
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 {Token} from '@astryxdesign/core/Token';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
@@ -17,8 +17,12 @@ 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 type {
AnalyticsFilters,
DateRange,
} from '@/features/admin/types/analytics';
import {AdminPageHeader} from './common/AdminPageHeader';
import {AdminKpiCard, KpiCaption} from './common/AdminKpiCard';
import {FootfallDashboard} from './analytics/FootfallDashboard';
import {CommerceDashboard} from './analytics/CommerceDashboard';
@@ -260,10 +264,6 @@ function FilterBar({
);
}
// 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.
@@ -276,61 +276,122 @@ function ScopeMetrics({
merchant: Company | null;
}) {
const bp = useBreakpoint();
const caption = (t: string) => (
<Text size="xsm" color="secondary">
{t}
</Text>
);
// Same budget as the Overview row, so a card is the same width everywhere.
const columns = bp === 'mobile' ? 1 : 3;
// 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"
<Grid key={merchant.id} columns={columns} gap={4}>
<AdminKpiCard
label="Stores"
value={merchant.sites}
format={fmt}
unit={merchant.sites === 1 ? 'location' : 'locations'}
icon={ICONS.stores}
footer={caption(`Registered to ${merchant.name}`)}
tone="warm"
footer={<KpiCaption>Registered to {merchant.name}</KpiCaption>}
/>
<MetricCard
label="Merchant accounts"
value={merchant.users}
format={fmt}
icon={ICONS.staff}
footer={caption(merchant.isActive ? 'Active merchant' : 'Suspended merchant')}
<AdminKpiCard
label="Status"
value={merchant.isActive ? 'Active' : 'Suspended'}
icon={ICONS.present}
tone={merchant.isActive ? 'success' : 'error'}
footer={
<HStack gap={1.5} vAlign="center" className="min-w-0">
<StatusDot
variant={merchant.isActive ? 'success' : 'error'}
label={merchant.isActive ? 'Active' : 'Suspended'}
/>
<KpiCaption>
{merchant.isActive
? 'Accounts can sign in'
: 'Sign-in is switched off'}
</KpiCaption>
</HStack>
}
/>
<AdminKpiCard
label="On the platform since"
value={formatDate(merchant.createdAt)}
icon={ICONS.companies}
tone="warm"
footer={<KpiCaption>@{merchant.slug}</KpiCaption>}
/>
</Grid>
);
}
const active = merchants.filter((m) => m.isActive);
const shops = active.reduce((n, m) => n + m.sites, 0);
const withoutShop = active.filter((m) => m.sites === 0).length;
return (
<Grid key="all" columns={bp === 'mobile' ? 1 : 3} gap={4}>
<MetricCard
<Grid key="all" columns={columns} gap={4}>
<AdminKpiCard
label="Active merchants"
value={active.length}
format={fmt}
icon={ICONS.companies}
footer={caption(`${fmt(merchants.length)} on the platform`)}
unit="active"
icon={ICONS.present}
tone="success"
footer={
<>
<KpiCaption>{merchants.length} on the platform</KpiCaption>
<Token
size="sm"
label={`${merchants.length - active.length} suspended`}
/>
</>
}
/>
<MetricCard
label="Shops"
value={active.reduce((n, m) => n + m.sites, 0)}
format={fmt}
<AdminKpiCard
label="Stores"
value={shops}
unit="locations"
icon={ICONS.stores}
footer={caption('Registered to active merchants')}
tone="warm"
footer={
<>
<KpiCaption>Registered to active merchants</KpiCaption>
<Token
size="sm"
label={`${(shops / (active.length || 1)).toFixed(1)} avg`}
/>
</>
}
/>
<MetricCard
label="Merchants without a shop"
value={active.filter((m) => m.sites === 0).length}
format={fmt}
<AdminKpiCard
label="Merchants without a store"
value={withoutShop}
unit="pending"
icon={ICONS.stores}
footer={caption('Active, with nothing to report yet')}
tone={withoutShop > 0 ? 'warning' : 'success'}
footer={
<HStack gap={1.5} vAlign="center" className="min-w-0">
<StatusDot
variant={withoutShop > 0 ? 'warning' : 'success'}
label={withoutShop > 0 ? 'Needs setup' : 'All set up'}
/>
<KpiCaption>
{withoutShop > 0
? 'Active, with nothing to report yet'
: 'Every active merchant has a store'}
</KpiCaption>
</HStack>
}
/>
</Grid>
);
}
function formatDate(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime())
? iso
: d.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}

View File

@@ -0,0 +1,236 @@
'use client';
import {useMemo, useState} from 'react';
import {Grid} from '@astryxdesign/core/Grid';
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Pagination} from '@astryxdesign/core/Pagination';
import {
SegmentedControl,
SegmentedControlItem,
} from '@astryxdesign/core/SegmentedControl';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {ICONS} from '@/shared/utils/icons';
import type {AdminCamera, AdminStore} from '@/features/admin/types/monitoring';
import {CameraStatus, adminHref} from './MonitoringTables';
/**
* A merchant's shops, and a shop's cameras, as cards that open.
*
* Both render exactly the rows the platform returned for THAT merchant /
* shop — the list endpoint nests under the merchant's id, so another
* merchant's shops cannot arrive here, and nothing is filtered client-side for
* isolation (search and the status filter only narrow what was returned).
*
* Every field shown is on the platform's own row: name, slug, timezone, shop
* PC online, last heartbeat, cameras up/total. There is no location or
* created date on a site, so no card claims one.
*/
const when = (iso?: string) => (iso ? new Date(iso).toLocaleString() : '—');
function columnsFor(bp: ReturnType<typeof useBreakpoint>) {
return bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 3;
}
const PAGE_SIZE = 12;
type StatusFilter = 'all' | 'online' | 'offline';
export function ShopList({
companyId,
shops,
}: {
companyId: string;
shops: AdminStore[];
}) {
const bp = useBreakpoint();
const [query, setQuery] = useState('');
const [status, setStatus] = useState<StatusFilter>('all');
const [page, setPage] = useState(1);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return shops
.filter((s) =>
status === 'all' ? true : status === 'online' ? s.isOnline : !s.isOnline,
)
.filter(
(s) =>
!q ||
s.name.toLowerCase().includes(q) ||
s.slug.toLowerCase().includes(q),
)
.sort((a, b) => a.name.localeCompare(b.name));
}, [shops, query, status]);
const pageRows = visible.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<VStack gap={4} width="100%">
<HStack gap={3} vAlign="center" className="flex-wrap gap-y-2">
<TextInput
label="Search shops"
isLabelHidden
value={query}
onChange={(v) => {
setQuery(v);
setPage(1);
}}
placeholder="Search shops…"
/>
<SegmentedControl
value={status}
onChange={(v) => {
setStatus(v as StatusFilter);
setPage(1);
}}
label="Filter by shop status"
>
<SegmentedControlItem value="all" label="All" />
<SegmentedControlItem value="online" label="Online" />
<SegmentedControlItem value="offline" label="Offline" />
</SegmentedControl>
<Text size="sm" color="secondary">
{visible.length.toLocaleString()} of {shops.length.toLocaleString()}
</Text>
</HStack>
{visible.length === 0 ? (
<Text size="sm" color="secondary">
No shops match these filters.
</Text>
) : (
<Grid columns={columnsFor(bp)} gap={4}>
{pageRows.map((s) => (
<ShopCard key={s.id} companyId={companyId} shop={s} />
))}
</Grid>
)}
{visible.length > PAGE_SIZE ? (
<HStack hAlign="end">
<Pagination
page={page}
onChange={setPage}
totalItems={visible.length}
pageSize={PAGE_SIZE}
variant="count"
size="sm"
/>
</HStack>
) : null}
</VStack>
);
}
export function ShopCard({companyId, shop}: {companyId: string; shop: AdminStore}) {
return (
<ClickableCard
label={`Open ${shop.name}`}
href={adminHref.store(companyId, shop.id)}
padding={4}
className="h-full"
>
<VStack gap={4} width="100%" height="100%" vAlign="between">
<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.stores} size="sm" color="secondary" />
</HStack>
<VStack gap={0} className="min-w-0">
<Text weight="medium" className="truncate">
{shop.name}
</Text>
<Text size="xsm" color="secondary" className="truncate">
{shop.slug} · {shop.timezone}
</Text>
</VStack>
</HStack>
<VStack gap={2} width="100%">
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={shop.isOnline ? 'success' : 'error'}
label={shop.isOnline ? 'Shop PC online' : 'Shop PC offline'}
/>
<Text size="sm" color="secondary">
{shop.isOnline ? 'Online' : 'Offline'}
</Text>
</HStack>
<Text size="xsm" color="secondary">
{shop.camerasUp.toLocaleString()} of{' '}
{shop.camerasTotal.toLocaleString()}{' '}
{shop.camerasTotal === 1 ? 'camera' : 'cameras'} up · last heartbeat{' '}
{when(shop.lastHeartbeatAt)}
</Text>
<Text size="sm" weight="medium">
View shop →
</Text>
</VStack>
</VStack>
</ClickableCard>
);
}
export function CameraGrid({
companyId,
storeId,
cameras,
}: {
companyId: string;
storeId: string;
cameras: AdminCamera[];
}) {
const bp = useBreakpoint();
const sorted = [...cameras].sort((a, b) => a.name.localeCompare(b.name));
return (
<Grid columns={columnsFor(bp)} gap={4}>
{sorted.map((c) => (
<ClickableCard
key={c.id}
label={`Open camera ${c.name}`}
href={adminHref.camera(companyId, storeId, c.id)}
padding={4}
className="h-full"
>
<VStack gap={3} 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.camera} size="sm" color="secondary" />
</HStack>
<VStack gap={0} className="min-w-0">
<Text weight="medium" className="truncate">
{c.name}
</Text>
<Text size="xsm" color="secondary" className="truncate">
{c.cameraId}
{c.isEnabled ? '' : ' · disabled'}
</Text>
</VStack>
</HStack>
<CameraStatus status={c.status} />
<Text size="xsm" color="secondary">
Last seen {when(c.lastSeenAt)}
</Text>
<Text size="sm" weight="medium">
View camera →
</Text>
</VStack>
</ClickableCard>
))}
</Grid>
);
}

View File

@@ -1,45 +1,64 @@
'use client';
import {useMemo, useState} from 'react';
import {Grid} from '@astryxdesign/core/Grid';
import {VStack} from '@astryxdesign/core/Layout';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {List, ListItem} from '@astryxdesign/core/List';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Button} from '@astryxdesign/core/Button';
import {Card} from '@astryxdesign/core/Card';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
import {LineChartView} from '@/shared/components/charts/LineChartView';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {
formatCount,
formatDayLabel,
formatInr,
formatInrCompact,
} from '@/shared/utils/format';
import {
useAlerts,
useCompany,
useCompanyStores,
useEvents,
useStore,
useStoreCameras,
} from '@/features/admin/hooks/useMonitoring';
import type {AdminData} from '@/features/admin/hooks/useMonitoring';
import {
useCommerceAnalytics,
useFootfallAnalytics,
} from '@/features/admin/hooks/useAnalytics';
import {useAiContext} from '@/features/admin/hooks/useAiContext';
import type {Company} from '@/features/admin/types/company';
import type {AdminStore} from '@/features/admin/types/monitoring';
import type {AnalyticsFilters} from '@/features/admin/types/analytics';
import type {IconKey} from '@/shared/utils/icons';
import {AdminPageHeader} from './common/AdminPageHeader';
import {AdminSection} from './common/AdminSection';
import {CompanyNotFound} from './CompanyDetail';
import {
AlertTable,
CameraTable,
EventTable,
adminHref,
} from './MonitoringTables';
import {AlertTable, EventTable, adminHref} from './MonitoringTables';
import {CameraGrid} from './ShopCards';
import {AnalyticsPanel, StatPanel} from './analytics/AnalyticsPanel';
import {FootfallTable} from './analytics/FootfallDashboard';
/**
* One store of one company.
* One shop of one merchant, and everything the platform can say about it.
*
* ── The pair, never the store alone ──────────────────────────────────────
* ── The pair, never the shop 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.
* request this page makes nests the same way — shop detail, cameras, events,
* alerts, and the footfall / commerce reads (merchant + shop as filters). The
* merchant is resolved first from the real list; whether the shop belongs to
* it is the PLATFORM's check (docs/ADMIN-MONITORING-API.md §2.2).
*
* 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.
* ── Only what the platform returns ───────────────────────────────────────
* A site carries name, slug, timezone, shop-PC liveness, heartbeat and
* camera counts — no address, area or created date — so none is shown. Each
* section renders real rows when its endpoint exists and its own honest
* empty state until then.
*/
export function StoreDetail({
companyId,
@@ -67,19 +86,53 @@ export function StoreDetail({
);
}
const DAY_MS = 86_400_000;
const isoDay = (t: number) => new Date(t).toISOString().slice(0, 10);
const when = (iso?: string) => (iso ? new Date(iso).toLocaleString() : '—');
/** Nothing for this area exists on the platform yet — no repository at all. */
const NOT_BUILT = {isAvailable: false} as const satisfies AdminData<never>;
function StoreBody({company, storeId}: {company: Company; storeId: string}) {
const bp = useBreakpoint();
const twoUp = bp === 'desktop' || bp === 'ultrawide';
const store = useStore(company.id, storeId);
// The merchant's shop list, when it exists, also names this shop — and is
// how a shop id that is not this merchant's is caught before anything else.
const list = useCompanyStores(company.id);
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 =
const [now] = useState(() => Date.now());
const filters: AnalyticsFilters = useMemo(
() => ({
merchantId: company.id,
shopId: storeId,
range: {from: isoDay(now - 29 * DAY_MS), to: isoDay(now)},
}),
[company.id, storeId, now],
);
const footfall = useFootfallAnalytics(filters);
const commerce = useCommerceAnalytics(filters);
const fromDetail =
store.isAvailable && store.resource.status === 'success'
? store.resource.data.name
: 'Shop';
? store.resource.data
: undefined;
const listRows =
list.isAvailable &&
(list.resource.status === 'success' || list.resource.status === 'empty')
? list.resource.data
: undefined;
const listLoaded = listRows !== undefined;
const fromList = listRows?.find((s) => s.id === storeId);
const shop: AdminStore | undefined = fromDetail ?? fromList;
// The name exists only once an endpoint answers. Until then "Shop", never a
// raw id printed as if it were a name.
const name = shop?.name ?? 'Shop';
useAiContext({
level: 'store',
@@ -88,118 +141,262 @@ function StoreBody({company, storeId}: {company: Company; storeId: string}) {
storeId,
});
if (listLoaded && !fromList && !fromDetail) {
return <ShopNotFound company={company} />;
}
return (
<VStack gap={6} width="100%">
<AdminPageHeader
title={name}
subtitle={`A shop of ${company.name}.`}
back={{label: company.name, href: adminHref.company(company.id)}}
back={{label: company.name, href: adminHref.shops(company.id)}}
crumbs={[
{label: 'Merchants', href: adminHref.merchants},
{label: company.name, href: adminHref.company(company.id)},
{label: company.name, href: adminHref.shops(company.id)},
{label: name},
]}
meta={
shop ? (
<>
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={shop.isOnline ? 'success' : 'error'}
label={shop.isOnline ? 'Shop PC online' : 'Shop PC offline'}
/>
<Text size="sm" color="secondary">
{shop.isOnline ? 'Online' : 'Offline'}
</Text>
</HStack>
<Text size="sm" color="secondary">
@{shop.slug}
</Text>
<Text size="sm" color="secondary">
{shop.timezone}
</Text>
</>
) : undefined
}
/>
<StatPanel
title="Shop status"
subtitle="Cameras and the shop PC, as the platform last heard from them."
data={store}
placeholders={STATUS_TILES}
toStats={(s) => [
{...STATUS_TILES[0], value: formatCount(s.camerasTotal), caption: 'Registered'},
{...STATUS_TILES[1], value: formatCount(s.camerasUp), caption: 'Reporting now'},
{
...STATUS_TILES[2],
value: formatCount(Math.max(0, s.camerasTotal - s.camerasUp)),
caption: 'Not reporting',
},
{...STATUS_TILES[3], value: s.isOnline ? 'Online' : 'Offline', caption: s.agentVersion ? `Agent ${s.agentVersion}` : 'Shop PC'},
{...STATUS_TILES[4], value: when(s.lastHeartbeatAt), caption: 'Last heartbeat'},
]}
/>
<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
<AnalyticsPanel
title="Cameras"
subtitle="Every camera registered to this shop. Open one for its status and activity."
icon="camera"
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."
/>
}
variant="table"
height={200}
pendingTitle="Cameras cannot be listed yet"
pendingDescription="Needs GET /api/admin/clients/{id}/sites/{siteId}/cameras on the platform."
emptyTitle="No cameras"
emptyDescription="No camera is registered to this shop."
>
{(rows) => (
<CameraTable companyId={company.id} storeId={storeId} cameras={rows} />
<CameraGrid companyId={company.id} storeId={storeId} cameras={rows} />
)}
</AdminSection>
</AnalyticsPanel>
<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."
/>
}
<Grid columns={twoUp ? 2 : 1} gap={6}>
<StatPanel
title="Footfall · last 30 days"
data={footfall.overview}
placeholders={FOOTFALL_TILES}
toStats={(o) => [
{...FOOTFALL_TILES[0], value: formatCount(o.totalFootfall), caption: 'Walk-ins'},
{...FOOTFALL_TILES[1], value: formatCount(Math.round(o.averageDaily)), caption: 'Per day'},
{
...FOOTFALL_TILES[2],
value: o.peakDay ? formatCount(o.peakDay.footfall) : undefined,
caption: o.peakDay ? formatDayLabel(o.peakDay.date) : undefined,
},
]}
/>
<StatPanel
title="Sales · last 30 days"
data={commerce.overview}
placeholders={SALES_TILES}
toStats={(o) => [
{...SALES_TILES[0], value: formatInrCompact(o.salesInr), caption: formatInr(o.salesInr)},
{...SALES_TILES[1], value: formatCount(o.transactions), caption: 'Recorded sales'},
{...SALES_TILES[2], value: formatInr(o.averageTransactionInr), caption: 'Per transaction'},
]}
/>
</Grid>
<Grid columns={twoUp ? 2 : 1} gap={6}>
<AnalyticsPanel
title="Daily footfall"
subtitle="Walk-ins per day at this shop."
icon="visitors"
data={footfall.daily}
pendingTitle="No footfall data yet"
emptyDescription="This shop recorded no walk-ins in the last 30 days."
>
{(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) => (
<AreaChartView
data={rows}
xKey="date"
series={[{key: 'footfall', label: 'Footfall', format: formatCount}]}
xFormat={formatDayLabel}
yFormat={formatCount}
/>
}
)}
</AnalyticsPanel>
<AnalyticsPanel
title="Sales over time"
subtitle="Daily sales at this shop."
icon="revenue"
data={commerce.daily}
pendingTitle="No sales data yet"
emptyDescription="This shop recorded no sales in the last 30 days."
>
{(rows) => (
<LineChartView
data={rows}
xKey="date"
series={[{key: 'salesInr', label: 'Sales', format: formatInr}]}
xFormat={formatDayLabel}
yFormat={formatInrCompact}
/>
)}
</AnalyticsPanel>
</Grid>
<AnalyticsPanel
title="Recent footfall"
subtitle="Day-by-day rows for this shop, as the platform reports them."
icon="events"
data={footfall.details}
variant="table"
height={180}
pendingTitle="No footfall rows yet"
pendingDescription="Fills in when the platform exposes admin footfall details."
emptyDescription="No footfall was recorded at this shop in the last 30 days."
>
{(rows) => <FootfallTable rows={rows} />}
</AnalyticsPanel>
<Grid columns={twoUp ? 2 : 1} gap={6}>
<AnalyticsPanel
title="Recent events"
icon="events"
data={events}
variant="table"
height={180}
pendingTitle="Event monitoring unavailable"
pendingDescription="The platform has no event endpoint for platform admin."
emptyTitle="No events"
emptyDescription="This shop has not reported any events yet."
>
{(rows) => <EventTable events={rows} />}
</AdminSection>
</AnalyticsPanel>
<AnalyticsPanel
title="Active alerts"
icon="notifications"
data={alerts}
variant="table"
height={180}
pendingTitle="Alert monitoring unavailable"
pendingDescription="The platform has no alert endpoint for platform admin."
emptyTitle="No open alerts"
emptyDescription="Nothing needs attention at this shop."
>
{(rows) => <AlertTable alerts={rows} />}
</AnalyticsPanel>
</Grid>
<Grid columns={twoUp ? 2 : 1} gap={6}>
<AnalyticsPanel
title="Staff"
icon="staff"
data={NOT_BUILT}
variant="table"
height={140}
pendingTitle="Staff unavailable"
pendingDescription="Sales persons for a merchant's shop need a platform-admin team endpoint."
>
{() => null}
</AnalyticsPanel>
<AnalyticsPanel
title="Customers"
icon="profile"
data={NOT_BUILT}
variant="table"
height={140}
pendingTitle="Customers unavailable"
pendingDescription="A shop's customers need a platform-admin customers endpoint."
>
{() => null}
</AnalyticsPanel>
</Grid>
</VStack>
);
}
const STATUS_TILES = [
{label: 'Cameras', icon: 'camera'},
{label: 'Online', icon: 'present'},
{label: 'Offline', icon: 'absent'},
{label: 'Shop PC', icon: 'stores'},
{label: 'Last heartbeat', icon: 'sessions'},
] as const satisfies {label: string; icon: IconKey}[];
const FOOTFALL_TILES = [
{label: 'Total footfall', icon: 'visitors'},
{label: 'Average daily', icon: 'analytics'},
{label: 'Peak day', icon: 'up'},
] as const satisfies {label: string; icon: IconKey}[];
const SALES_TILES = [
{label: 'Sales', icon: 'revenue'},
{label: 'Transactions', icon: 'purchases'},
{label: 'Avg transaction', icon: 'conversion'},
] as const satisfies {label: string; icon: IconKey}[];
/**
* The merchant's shop list loaded, and this id is not in it: another
* merchant's shop, or a mistyped link. Said plainly, with no data from either.
*/
function ShopNotFound({company}: {company: Company}) {
return (
<VStack gap={6} width="100%">
<AdminPageHeader
title="Shop not found"
back={{label: company.name, href: adminHref.shops(company.id)}}
/>
<Card>
<EmptyPanel
icon="stores"
title={`No shop with this id at ${company.name}`}
description="It may belong to another merchant, have been removed, or the link is wrong."
actions={
<Button
size="sm"
variant="secondary"
href={adminHref.shops(company.id)}
label={`${company.name}'s shops`}
/>
}
/>
</Card>
</VStack>
);
}

View File

@@ -46,6 +46,9 @@ export function AnalyticsPanel<T>({
icon,
data,
pendingTitle,
pendingDescription = 'The chart is ready. It fills in as soon as the platform exposes this admin endpoint.',
emptyTitle = 'No data for the selected filters',
emptyDescription = 'Try a wider date range, or a different merchant, area or shop.',
height = 260,
variant = 'chart',
actions,
@@ -57,6 +60,9 @@ export function AnalyticsPanel<T>({
data: AdminData<T>;
/** e.g. "No area footfall data yet". */
pendingTitle: string;
pendingDescription?: string;
emptyTitle?: string;
emptyDescription?: string;
height?: number;
variant?: 'chart' | 'table';
actions?: React.ReactNode;
@@ -88,8 +94,8 @@ export function AnalyticsPanel<T>({
<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."
title={emptyTitle}
description={emptyDescription}
/>
}
>
@@ -100,7 +106,7 @@ export function AnalyticsPanel<T>({
icon={icon}
height={height}
title={pendingTitle}
description="The chart is ready. It fills in as soon as the platform exposes this admin endpoint."
description={pendingDescription}
/>
)}
</VStack>

View File

@@ -183,7 +183,7 @@ const FOOTFALL_SORTS: SortOption<FootfallRow>[] = [
{value: 'shop', label: 'Shop A–Z', compare: (a, b) => a.shopName.localeCompare(b.shopName)},
];
function FootfallTable({rows}: {rows: FootfallDetail[]}) {
export function FootfallTable({rows}: {rows: FootfallDetail[]}) {
const data: FootfallRow[] = rows.map((r) => ({
...r,
rowId: `${r.date}|${r.shopId}`,

View File

@@ -0,0 +1,114 @@
'use client';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Card} from '@astryxdesign/core/Card';
import {Icon} from '@astryxdesign/core/Icon';
import {HoverLift} from '@/shared/components/motion/HoverLift';
import {AnimatedNumber} from '@/shared/components/motion/AnimatedNumber';
import {ACCENT} from '@/shared/utils/accent';
import type {BrandAccent} from '@/shared/utils/accent';
import type {ICONS} from '@/shared/utils/icons';
/**
* The platform console's KPI card — one shape for Overview, Footfall and
* Commerce, so the three rows read as the same system.
*
* Tone is the icon chip only, never the card: `warm`/`cool` are the two brand
* accents (see accent.ts), `success`/`warning`/`error` are semantic. The card
* stays white.
*/
export type KpiTone = BrandAccent | 'success' | 'warning' | 'error';
const TONE_CHIP: Record<KpiTone, string> = {
warm: ACCENT.warm.chip,
cool: ACCENT.cool.chip,
success: 'bg-success-muted text-success',
warning: 'bg-warning-muted text-warning',
error: 'bg-error-muted text-error',
};
// Rounded: AnimatedNumber hands every in-between frame to `format`.
const fmt = (v: number) => Math.round(v).toLocaleString();
export function AdminKpiCard({
label,
value,
unit,
icon,
tone,
footer,
isFooterDivided = true,
}: {
label: string;
/** A number animates; a string (a date, a status) is shown as is. */
value: number | string;
/** The small word beside the figure: "tenants", "locations". */
unit?: string;
icon: typeof ICONS.companies;
tone: KpiTone;
footer: React.ReactNode;
/** false when the footer draws its own separation, e.g. a ProgressBar. */
isFooterDivided?: boolean;
}) {
return (
<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">
{label}
</Text>
<HStack
hAlign="center"
vAlign="center"
className={`size-8 rounded-lg shrink-0 ${TONE_CHIP[tone]}`}
>
<Icon icon={icon} size="sm" />
</HStack>
</HStack>
<HStack gap={2} vAlign="end">
<Text size="2xl" weight="semibold" className="tracking-tight">
{typeof value === 'number' ? (
<AnimatedNumber value={value} format={fmt} />
) : (
value
)}
</Text>
{unit ? (
<Text size="xsm" color="secondary">
{unit}
</Text>
) : null}
</HStack>
</VStack>
{isFooterDivided ? (
<HStack
hAlign="between"
vAlign="center"
width="100%"
gap={2}
className="pt-2 border-t border-border"
>
{footer}
</HStack>
) : (
footer
)}
</VStack>
</Card>
</HoverLift>
);
}
/** The footer's left-hand caption, truncating rather than wrapping. */
export function KpiCaption({children}: {children: React.ReactNode}) {
return (
<Text size="xsm" color="secondary" className="truncate">
{children}
</Text>
);
}

View File

@@ -1,10 +1,10 @@
"use client";
'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";
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;
@@ -32,7 +32,7 @@ export function AdminPageHeader({
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 };
back?: {label: string; href: string};
crumbs?: Crumb[];
/** A row under the title — status, slug, dates. */
meta?: React.ReactNode;
@@ -93,7 +93,7 @@ export function AdminPageHeader({
);
}
export function BackButton({ label, href }: { label: string; href: string }) {
export function BackButton({label, href}: {label: string; href: string}) {
return (
<Button
variant="ghost"

View File

@@ -7,13 +7,16 @@ import type {IconKey} from '@/shared/utils/icons';
*
* companies → company → stores → store → cameras → camera → events / alerts
*
* — but the platform's admin surface (`/api/admin/*`) currently stops at the
* first rung. Every tenant endpoint below it (`/api/sites`, cameras, visits)
* takes its company from the signed-in account, and a platform operator's
* account has none. There is no way to ask "Company B's stores" without an
* explicit admin endpoint, and this console will not fake one: no reusing a
* tenant route with a swapped id, no listing every store under every company,
* no placeholder rows.
* — and the platform's admin surface (`/api/admin/*`) answers down to the
* camera, plus the platform totals. Events, alerts and everything else below
* are not served yet. Tenant endpoints (`/api/sites`, cameras, visits) take
* their company from the signed-in account, and a platform operator's account
* has none, so this console will not fake a level: no reusing a tenant route
* with a swapped id, no placeholder rows.
*
* A flag that is on against a deployed platform that predates its route gets
* `not_deployed` back (bff.ts), and AdminSection renders the same "pending"
* state as a flag that is off.
*
* So each level is a flag. `false` means the repository hands back no
* endpoint, the hook reports `unavailable`, and the section renders the
@@ -30,23 +33,23 @@ import type {IconKey} from '@/shared/utils/icons';
export const ADMIN_CAPABILITIES = {
/** `GET /api/admin/clients` — live. */
companies: true,
/** `GET /api/admin/clients/{id}` — not upstream yet; the detail page reads
* the company out of the list instead, which carries the same row. */
companyDetail: false,
/** `GET /api/admin/clients/{id}` — adds the owner to the merchant page.
* The rest of the page still reads the company out of the list. */
companyDetail: true,
/** `GET /api/admin/clients/{id}/sites` */
companyStores: false,
companyStores: true,
/** `GET /api/admin/clients/{id}/sites/{siteId}` */
storeDetail: false,
/** `GET /api/admin/clients/{id}/sites/{siteId}/cameras` */
storeCameras: false,
storeDetail: true,
/** `GET /api/admin/clients/{id}/sites/{siteId}/cameras` — read-only. */
storeCameras: true,
/** `GET /api/admin/clients/{id}/sites/{siteId}/cameras/{cameraId}` */
cameraDetail: false,
cameraDetail: true,
/** `GET /api/admin/clients/{id}/sites/{siteId}/events` (optionally ?camera=) */
events: false,
/** `GET /api/admin/clients/{id}/sites/{siteId}/alerts` (optionally ?camera=) */
alerts: false,
/** `GET /api/admin/monitoring/summary` — platform-wide camera / alert / event totals. */
platformMonitoring: false,
platformMonitoring: true,
/** `PATCH /api/admin/clients/{id}` with name / owner fields. Today the
* PATCH accepts `active` only, so a merchant cannot be renamed. */
merchantEdit: false,
@@ -97,7 +100,7 @@ export interface MerchantArea {
export const MERCHANT_AREAS: MerchantArea[] = [
{key: 'shops', icon: 'stores', label: 'Shops', capability: 'companyStores', description: 'Every shop or business the merchant runs.'},
{key: 'sales-persons', icon: 'staff', label: 'Sales persons', capability: 'salesPersons', description: 'Create, edit and remove the staff who sign in on the mobile app.'},
{key: 'cameras', icon: 'camera', label: 'Cameras', capability: 'storeCameras', description: 'Add, edit and remove cameras per shop, with online / offline state.'},
{key: 'cameras', icon: 'camera', label: 'Cameras', capability: 'storeCameras', description: 'Cameras per shop, with online / offline state. Read-only.'},
{key: 'heartbeat', icon: 'events', label: 'Camera heartbeat', capability: 'cameraHeartbeat', description: 'Last-seen time for every camera.'},
{key: 'customers', icon: 'profile', label: 'Customers', capability: 'customers', description: 'Customers who belong to this merchant.'},
{key: 'sales', icon: 'revenue', label: 'Sales', capability: 'sales', description: 'Sales recorded at the merchant’s shops.'},

View File

@@ -9,14 +9,17 @@ import {
alertRepository,
cameraRepository,
eventRepository,
platformMonitoringRepository,
storeRepository,
} from '@/features/admin/repositories/monitoringRepository';
import {companyRepository} from '@/features/admin/repositories/companyRepository';
import type {Company} from '@/features/admin/types/company';
import type {
AdminAlert,
AdminCamera,
AdminEvent,
AdminStore,
PlatformMonitoring,
} from '@/features/admin/types/monitoring';
/**
@@ -35,7 +38,14 @@ export function useAdminData<T>(endpoint: Endpoint<T> | null): AdminData<T> {
// Called unconditionally — `null` holds the request, which is what keeps the
// hook order stable while a capability is off.
const resource = useResource(endpoint);
return endpoint ? {isAvailable: true, resource} : {isAvailable: false};
// Flag on, but the deployed platform predates the route (bff.ts answers
// `not_deployed`): the same "not yet" as a flag that is off, on every page,
// rather than an error each page would have to special-case.
const notDeployed =
resource.status === 'error' && resource.error.code === 'not_deployed';
return endpoint && !notDeployed
? {isAvailable: true, resource}
: {isAvailable: false};
}
export function useCompanyStores(companyId: string): AdminData<AdminStore[]> {
@@ -83,10 +93,11 @@ export function useAlerts(
/**
* One company, read out of the list.
*
* The platform has no `GET /api/admin/clients/{id}`, but the list row IS the
* whole company record — there is nothing more a detail endpoint could add
* today. So the detail page reuses the list request (one fetch, and the same
* row the table showed) rather than inventing a route.
* The list row carries everything the drill-down pages need, and reusing it
* means one fetch and the same row the table showed. The one thing only
* `GET /api/admin/clients/{id}` adds is the owner — see {@link useCompanyOwner},
* kept separate so a platform that predates the detail read still renders
* every page.
*
* `company` is `undefined` until the list lands, and `null` once it has landed
* without this id: a deleted company, or a mistyped URL. The page needs to
@@ -106,6 +117,25 @@ export function useCompany(companyId: string): {
return {resource, company};
}
/**
* The company's owner, from the detail read. `undefined` until it lands, and
* also when the read is off, not deployed, or failed — the owner is an extra
* on the page, never a reason to fail it.
*/
export function useCompanyOwner(
companyId: string,
): {name?: string; email?: string} | undefined {
const resource = useResource(companyRepository.get(companyId));
if (resource.status !== 'success') return undefined;
const {ownerName, ownerEmail} = resource.data;
return ownerName || ownerEmail ? {name: ownerName, email: ownerEmail} : undefined;
}
/** Estate-wide camera / shop / visit counts from the platform. */
export function usePlatformMonitoring(): AdminData<PlatformMonitoring> {
return useAdminData(platformMonitoringRepository.summary());
}
/** Platform totals, computed from the company list and nothing else. */
export interface PlatformSummary {
companies: number;

View File

@@ -4,6 +4,7 @@ import {
postJson,
type Endpoint,
} from '@/shared/services/httpClient';
import {ADMIN_CAPABILITIES} from '@/features/admin/config/capabilities';
import type {
Company,
CompanyActiveResult,
@@ -26,6 +27,12 @@ import type {
export const companyRepository = {
list: (): Endpoint<Company[]> => ({path: '/api/admin/clients', params: {}}),
/** One company with its owner. `null` while the capability is off. */
get: (id: string): Endpoint<Company> | null =>
ADMIN_CAPABILITIES.companyDetail
? {path: `/api/admin/clients/${encodeURIComponent(id)}`, params: {}}
: null,
/**
* Creates the company and its owner together, and hands back the owner's
* generated password ONCE.

View File

@@ -5,6 +5,7 @@ import type {
AdminCamera,
AdminEvent,
AdminStore,
PlatformMonitoring,
} from '@/features/admin/types/monitoring';
/**
@@ -101,3 +102,10 @@ export const alertRepository = {
}
: null,
};
export const platformMonitoringRepository = {
summary: (): Endpoint<PlatformMonitoring> | null =>
ADMIN_CAPABILITIES.platformMonitoring
? {path: '/api/admin/monitoring/summary', params: {}}
: null,
};

View File

@@ -1,4 +1,4 @@
import type {ApiClientRow} from '@/services/api/types';
import type {ApiClientDetail, ApiClientRow} from '@/services/api/types';
import type {Company} from '@/features/admin/types/company';
/**
@@ -25,3 +25,12 @@ export function toCompany(c: ApiClientRow): Company {
createdAt: c.created_at,
};
}
/** The detail read: the same row, plus the owner when there is one. */
export function toCompanyDetail(c: ApiClientDetail): Company {
return {
...toCompany(c),
ownerEmail: c.owner_email || undefined,
ownerName: c.owner_name || undefined,
};
}

View File

@@ -0,0 +1,59 @@
import type {
ApiAdminCamera,
ApiPlatformMonitoring,
ApiSite,
} from '@/services/api/types';
import type {
AdminCamera,
AdminStore,
PlatformMonitoring,
} from '@/features/admin/types/monitoring';
/**
* Platform `SiteHealth` / `AdminCamera` / `PlatformSummary` → the admin
* console's shapes, for the `/api/admin/clients/{id}/sites…` and
* `/api/admin/monitoring/summary` BFF routes. A shop is the same `SiteHealth`
* row the tenant `/api/sites` returns; a camera is the platform's redacted
* admin type, not the tenant `Camera`.
*/
export function toAdminStore(s: ApiSite): AdminStore {
return {
id: s.site_id,
slug: s.slug,
// Falls back to the slug: a shop with no name is a data fault worth seeing.
name: s.name || s.slug,
timezone: s.timezone,
isOnline: s.online,
lastHeartbeatAt: s.last_heartbeat_at,
lastEventAt: s.last_event_at,
agentVersion: s.agent_version,
camerasTotal: s.cameras_total,
camerasUp: s.cameras_up,
};
}
export function toAdminCamera(c: ApiAdminCamera): AdminCamera {
return {
id: c.id,
siteId: c.site_id,
cameraId: c.camera_id,
name: c.label || c.camera_id,
isEnabled: c.enabled,
// `connected` null = never reported, which is not the same as down.
status:
c.connected === true ? 'online' : c.connected === false ? 'offline' : 'unknown',
lastSeenAt: c.last_seen_at,
snapshotAt: c.snapshot_at,
};
}
export function toPlatformMonitoring(m: ApiPlatformMonitoring): PlatformMonitoring {
return {
camerasTotal: m.cameras_total,
camerasOnline: m.cameras_online,
merchantsActive: m.merchants_active,
storesTotal: m.sites_total,
eventsToday: m.events_today,
asOf: m.as_of,
};
}

View File

@@ -6,10 +6,10 @@
* five components that would each rename them slightly differently.
*
* Nothing is invented here. Every field comes back from
* `GET /api/admin/clients`; the admin surface upstream is company
* administration and nothing else, so there is no owner name, no contact, no
* revenue and no health. A column this list cannot fill is a column that would
* have to be faked.
* `GET /api/admin/clients`, except the owner, which only the detail read
* (`GET /api/admin/clients/{id}`) carries — so it is optional and absent on
* list rows. There is no revenue and no health: a column the platform cannot
* fill is a column that would have to be faked.
*/
export interface Company {
id: string;
@@ -27,6 +27,9 @@ export interface Company {
users: number;
/** ISO-8601, from the server's clock. */
createdAt: string;
/** Detail read only. Absent when the company has no owner. */
ownerEmail?: string;
ownerName?: string;
}
/** What a suspend/reinstate hands back — the row, and what it cost. */

View File

@@ -1,9 +1,8 @@
/**
* What the platform console will render below the company level.
*
* ── None of these endpoints exist yet ────────────────────────────────────
* These are the shapes the console is written against, so that wiring a real
* endpoint is a mapper and a flag rather than a redesign (see
* Shops, cameras and the platform totals are served by the platform's
* `/api/admin/*` drill-down; events and alerts are not yet (see
* config/capabilities.ts). They are deliberately the SAME fields the tenant
* console already reads for its own stores and cameras (features/stores/types)
* — an admin view of a store should not invent columns a merchant's own view
@@ -13,34 +12,51 @@
* The UI prints a dash for a missing value, never a guess.
*/
/**
* A shop, as the admin console consumes it — the platform's `SiteHealth`
* (Behavision server/internal/api/types.go), the same row `GET /api/sites`
* returns to a merchant. An admin endpoint would reuse `Store.SiteHealth`,
* which already takes the client id, so this is the exact shape it will send.
*
* There is no location, area, address or created date on a site — so the
* console shows none. `timezone` is the nearest thing to a place it has.
*/
export interface AdminStore {
id: string;
/** Immutable upstream, like a company slug. */
/** Immutable upstream, like a merchant slug. */
slug: string;
name: string;
timezone?: string;
/** The platform's own store liveness (`online` on SiteHealth). */
isOnline?: boolean;
/** Cameras registered to the store. */
cameras?: number;
/** Cameras that reported within the platform's liveness window. */
camerasOnline?: number;
createdAt?: string;
timezone: string;
/** The shop PC's liveness, from its heartbeat. */
isOnline: boolean;
lastHeartbeatAt?: string;
lastEventAt?: string;
agentVersion?: string;
/** Cameras registered to the shop. */
camerasTotal: number;
/** Cameras reporting within the platform's liveness window. */
camerasUp: number;
}
/** The platform's own camera states. No invented `maintenance`. */
/**
* The platform's own camera states. `unknown` is "no agent has reported on
* it yet" — the platform sends `connected` absent, and a bare false would
* claim the camera is down when nobody has looked.
*/
export type AdminCameraStatus = 'online' | 'offline' | 'unknown';
/** The platform's `Camera`, minus connection details an admin view never shows. */
export interface AdminCamera {
id: string;
siteId: string;
/** What the engine knows it by — stable for the camera's life. */
cameraId: string;
name: string;
isEnabled: boolean;
status: AdminCameraStatus;
/** ISO-8601, from the server's clock. */
lastSeenAt?: string;
/** Where in the store it points, if the merchant named it. */
zone?: string;
/** Whether the platform can serve a live stream for it right now. */
hasLiveStream?: boolean;
snapshotAt?: string;
}
export type AdminSeverity = 'critical' | 'warning' | 'info';
@@ -65,3 +81,15 @@ export interface AdminAlert {
cameraId?: string;
cameraName?: string;
}
/** Estate-wide counts from `GET /api/admin/monitoring/summary`. */
export interface PlatformMonitoring {
camerasTotal: number;
camerasOnline: number;
merchantsActive: number;
storesTotal: number;
/** Visits recorded today, on the platform database's day (likely UTC). */
eventsToday: number;
/** ISO-8601, the server's clock. */
asOf: string;
}

View File

@@ -19,6 +19,10 @@ import type {LoginError} from '@/features/auth/types/auth';
export const LOGIN_ERROR_PARAM = 'error';
/** Shown to a valid merchant or staff account: this is the Platform Admin console. */
export const NOT_PLATFORM_ADMIN_MESSAGE =
'This account does not have Platform Admin access.';
/** Code → the field it belongs to and what the user reads. */
const LOGIN_ERRORS: Record<string, LoginError> = {
email_required: {field: 'email', message: 'Invalid email or password.'},
@@ -51,6 +55,11 @@ const LOGIN_ERRORS: Record<string, LoginError> = {
field: 'form',
message: 'Sign-in is unavailable right now. Please contact support.',
},
// Valid credentials for an account that is not a platform admin.
not_platform_admin: {
field: 'form',
message: NOT_PLATFORM_ADMIN_MESSAGE,
},
/*
* `platform_account` used to live here: correct credentials for a platform
* admin, refused because every surface in this console was tenant-scoped.

View File

@@ -44,6 +44,13 @@ export async function getServerSession(): Promise<AuthSession | null> {
const payload = verifySessionToken(store.get(sessionCookieFor(tabId))?.value);
if (!payload) return null;
// This console knows only platform admins — the same rule the proxy and
// /api/auth/session apply — so a merchant cookie minted before that rule
// existed never seeds the shell as a signed-in merchant.
if (payload.isPlatformAdmin !== true) {
return null;
}
return {
user: {
id: payload.sub,
@@ -59,6 +66,19 @@ export async function getServerSession(): Promise<AuthSession | null> {
};
}
/**
* For the /api/auth/* routes that act on the CURRENT session (list or revoke
* devices). The proxy matcher skips /api/auth entirely, so the rule it applies
* to every other API is restated here: a non-admin session gets the same 401.
*/
export async function refuseOffConsole(): Promise<Response | null> {
if (await getServerSession()) return null;
return Response.json(
{error: {code: 'unauthorized', message: 'Sign in to continue.'}},
{status: 401, headers: {'cache-control': 'no-store'}},
);
}
/**
* Guard for route handlers that must not answer an anonymous request.
*

View File

@@ -76,6 +76,9 @@ export function SaleEntryDialog({
* A ref rather than state: it must survive every re-render unchanged.
*/
const idempotencyKey = useRef<string | null>(null);
/** When the sale was first submitted — kept with the key, so a retry
* reports the original moment rather than the moment it got through. */
const clientCreatedAt = useRef<string | null>(null);
const [lines, setLines] = useState<DraftLine[]>([]);
const [name, setName] = useState('');
@@ -147,10 +150,12 @@ export function SaleEntryDialog({
* fresh key.
*/
idempotencyKey.current ??= newIdempotencyKey();
clientCreatedAt.current ??= new Date().toISOString();
// Through httpClient, so the request carries this tab's session header.
const res = await saleRepository.create({
idempotencyKey: idempotencyKey.current,
clientCreatedAt: clientCreatedAt.current,
// Taken from the floor context, never typed. §4: do not ask for a
// visit id the flow already knows. Staff identity is not sent at
// all — the platform derives it from the session.

View File

@@ -1,131 +0,0 @@
'use client';
import {proportional, pixel} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {useToast} from '@astryxdesign/core/Toast';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {useResource} from '@/shared/hooks/useResource';
import {securityRepository} from '@/features/settings/repositories/securityRepository';
import type {DeviceSession} from '@/features/settings/services/mapSession';
/**
* Every device signed in as the current person, from GET /api/auth/sessions.
*
* Shared by the merchant Security settings and the platform console's
* Settings: `/api/auth/*` is scoped to whoever is signed in, not to a tenant,
* so it answers for a platform admin exactly as it does for a merchant. One
* component so the two consoles cannot drift on what "sign out" means.
*
* Nothing optimistic: after a revoke the list is re-read, because the platform
* decides what is still live.
*/
export function ActiveSessionsPanel() {
const toast = useToast();
const sessions = useResource(securityRepository.sessions());
const handleRevokeSession = async (id: string) => {
const res = await securityRepository.revoke(id);
if (!res.ok) {
toast({type: 'error', body: res.message ?? 'Could not sign that device out.'});
return;
}
sessions.refetch();
toast({body: 'That device has been signed out.'});
};
const handleRevokeAllOther = async () => {
const res = await securityRepository.revokeOthers();
if (!res.ok) {
toast({type: 'error', body: res.message ?? 'Could not sign the other devices out.'});
return;
}
sessions.refetch();
toast({body: 'Every other device has been signed out.'});
};
const columns: TableColumn<DeviceSession & Record<string, unknown>>[] = [
{
key: 'device',
header: 'Device',
width: proportional(2),
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Text size="sm" weight="medium">
{row.device}
</Text>
{row.isCurrent ? <Badge variant="success" label="This browser" /> : null}
</HStack>
),
},
{
key: 'lastUsedAt',
header: 'Last used',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{new Date(row.lastUsedAt).toLocaleString()}
</Text>
),
},
{
key: 'actions',
header: 'Action',
align: 'center',
width: pixel(120),
// The current session CAN be revoked upstream, but doing it from this
// table signs you out mid-page with no warning. Signing out is what the
// account menu is for.
renderCell: (row) =>
row.isCurrent ? null : (
<HStack hAlign="center">
<Button
size="sm"
variant="ghost"
label="Sign out"
onClick={() => void handleRevokeSession(row.id)}
/>
</HStack>
),
},
];
return (
<PanelCard
title="Active sessions"
subtitle="Every device signed in as you."
resource={sessions}
loading={<SkeletonRows count={3} />}
empty={
<EmptyPanel
icon="security"
title="No other devices"
description="Only this browser is signed in."
/>
}
actions={
<Button
variant="secondary"
size="sm"
label="Sign out everywhere else"
onClick={() => void handleRevokeAllOther()}
/>
}
>
{(rows) => (
<ResponsiveTable
columns={columns}
data={rows as (DeviceSession & Record<string, unknown>)[]}
idKey="id"
primaryKey="device"
/>
)}
</PanelCard>
);
}

View File

@@ -12,16 +12,6 @@ import {Switch} from '@astryxdesign/core/Switch';
import {TextInput} from '@astryxdesign/core/TextInput';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
import {ActiveSessionsPanel} from './ActiveSessionsPanel';
export interface ActiveSessionItem extends Record<string, unknown> {
id: string;
device: string;
location: string;
ip: string;
lastActive: string;
isCurrent: boolean;
}
export interface LoginAuditItem extends Record<string, unknown> {
id: string;
@@ -190,11 +180,6 @@ export function SecurityManager() {
</VStack>
</StaticPanel>
{/* Real devices, from GET /api/auth/sessions — shared with the platform
console. The 2FA switch, password form and audit trail above and
below are still local-only: those have no endpoint on the platform. */}
<ActiveSessionsPanel />
<StaticPanel title="Login Audit Trail" subtitle="Recent account authentication events and security attempts.">
<ResponsiveTable
columns={auditColumns}

View File

@@ -189,10 +189,22 @@ export function proxy(request: NextRequest): NextResponse {
? pointer
: null;
const session = tabId
const verified = tabId
? verifySessionToken(request.cookies.get(sessionCookieFor(tabId))?.value)
: null;
/**
* This is the Platform Admin console, and it knows only platform admins.
*
* A merchant or staff session is read as NO session — not routed to
* /dashboard, not routed anywhere in the merchant app. Every branch below
* then treats the request as anonymous: pages go to the sign-in page, APIs
* answer 401. So no merchant route can render, whatever cookie a browser
* presents. The login route already refuses to mint such a session; this
* covers one minted before that rule existed.
*/
const session = verified?.isPlatformAdmin === true ? verified : null;
/**
* THIS TAB's session, and no other. This is the line that was wrong.
*
@@ -252,6 +264,12 @@ export function proxy(request: NextRequest): NextResponse {
* /login the terminal state of every failed-auth path rather than one more
* hop in it.
*/
// Redeeming an invitation creates a merchant account — nothing the Platform
// Admin console offers. Its sign-in page is the only public page.
if (pathname === JOIN_PATH) {
return NextResponse.redirect(new URL(LOGIN_PATH, request.url));
}
if (PUBLIC_PATHS.has(pathname)) {
return NextResponse.next();
}
@@ -277,7 +295,8 @@ export function proxy(request: NextRequest): NextResponse {
// Where they were headed, so the login form can finish the journey rather
// than dumping every user on the dashboard. Path only — never an absolute
// URL, which would make this an open redirect.
if (pathname !== HOME_PATH) {
// Only an /admin page is worth coming back to.
if (isAdminArea(pathname)) {
target.searchParams.set('next', `${pathname}${search}`);
}
return NextResponse.redirect(target);

View File

@@ -1,12 +1,16 @@
import 'server-only';
import {upstreamRequest} from './apiClient';
import type {
ApiAdminCamera,
ApiClientActiveResult,
ApiClientDetail,
ApiClientDeleteResult,
ApiClientRow,
ApiNewClientInput,
ApiNewClientResult,
ApiOwnerPasswordResult,
ApiPlatformMonitoring,
ApiSite,
} from './types';
/**
@@ -28,10 +32,13 @@ import type {
* draw. If that answer were ever wrong, these calls would return 404 and the
* screen would be empty — not populated with somebody else's tenants.
*
* Scope is exactly the five operations the platform documents as the admin
* surface. There is no endpoint to look inside a company, and none is invented
* here: seeing a tenant's data means signing in as that tenant's owner.
* Scope is exactly what the platform serves under `/api/admin/*`: the five
* company operations, and the read-only drill-down merchant → shop → camera
* plus the platform totals. Every read nests under the company in the URL, and
* the platform checks the whole chain — a shop of another company is a 404.
*/
const seg = encodeURIComponent;
export const adminApi = {
listClients: (accessToken: string) =>
upstreamRequest<ApiClientRow[]>({path: '/api/admin/clients', accessToken}),
@@ -97,4 +104,52 @@ export const adminApi = {
body: {confirm},
accessToken,
}),
// ── Read-only drill-down ─────────────────────────────────────────────────
/** One company, with its owner. */
getClient: (accessToken: string, id: string) =>
upstreamRequest<ApiClientDetail>({
path: `/api/admin/clients/${seg(id)}`,
accessToken,
}),
/** The company's active shops — the same `SiteHealth` rows a merchant sees. */
listClientSites: (accessToken: string, id: string) =>
upstreamRequest<ApiSite[]>({
path: `/api/admin/clients/${seg(id)}/sites`,
accessToken,
}),
/** `site` may be the shop's slug or its uuid. */
getClientSite: (accessToken: string, id: string, site: string) =>
upstreamRequest<ApiSite>({
path: `/api/admin/clients/${seg(id)}/sites/${seg(site)}`,
accessToken,
}),
listSiteCameras: (accessToken: string, id: string, site: string) =>
upstreamRequest<ApiAdminCamera[]>({
path: `/api/admin/clients/${seg(id)}/sites/${seg(site)}/cameras`,
accessToken,
}),
/** `camera` may be the engine's `camera_id` or the row uuid. */
getSiteCamera: (
accessToken: string,
id: string,
site: string,
camera: string,
) =>
upstreamRequest<ApiAdminCamera>({
path: `/api/admin/clients/${seg(id)}/sites/${seg(site)}/cameras/${seg(camera)}`,
accessToken,
}),
/** Estate-wide camera, merchant, shop and visit counts. */
monitoringSummary: (accessToken: string) =>
upstreamRequest<ApiPlatformMonitoring>({
path: '/api/admin/monitoring/summary',
accessToken,
}),
};

View File

@@ -71,8 +71,30 @@ export class UpstreamError extends Error {
get isUnsupported(): boolean {
return this.status === 501;
}
/**
* The platform running here does not have this route at all — it is newer
* than the deployed server. Not "this record does not exist".
*
* Two ways that shows up, and neither carries a code of its own:
* - no pattern matches the path: the server's catch-all answers 404
* `not_found` with exactly {@link MISSING_ROUTE_MESSAGE}
* (Behavision cmd/behavision-server/main.go). A missing merchant, shop
* or sale is also 404 `not_found`, but worded differently — so this is
* the one place that matches on prose, and it matches the whole string.
* - the path exists for another method only: Go's mux answers 405.
*/
get isRouteMissing(): boolean {
return (
this.status === 405 ||
(this.status === 404 && this.message === MISSING_ROUTE_MESSAGE)
);
}
}
/** The platform catch-all's wording for a path it has no route for. */
export const MISSING_ROUTE_MESSAGE = 'No such endpoint.';
export type QueryValue = string | number | boolean | undefined | null;
export interface UpstreamRequest {

View File

@@ -634,6 +634,46 @@ export interface ApiClientRow {
created_at: string;
}
/**
* `GET /api/admin/clients/{id}` — `ClientDetail` upstream: the list row,
* flattened, plus the owner. Both owner fields are omitted when the company
* has no owner, which is a data fault worth showing as a dash.
*/
export interface ApiClientDetail extends ApiClientRow {
owner_email?: string;
owner_name?: string;
}
/**
* A camera as a PLATFORM ADMIN sees it — `AdminCamera` upstream, a separate
* type from `ApiCamera` on purpose: it carries no host, port, path, username
* or password flag, so an admin view cannot leak a live path into somebody
* else's camera. `connected` is always present here, null when unknown.
*/
export interface ApiAdminCamera {
id: string;
site_id: string;
site?: string;
camera_id: string;
label: string;
enabled: boolean;
connected: boolean | null;
last_seen_at?: string;
snapshot_at?: string;
check: ApiCameraCheck;
}
/** `GET /api/admin/monitoring/summary` — `PlatformSummary` upstream. Counts only. */
export interface ApiPlatformMonitoring {
cameras_total: number;
cameras_online: number;
merchants_active: number;
sites_total: number;
/** Visits since midnight on the platform database's clock (likely UTC). */
events_today: number;
as_of: string;
}
/**
* Creating a company also creates its owner, in one transaction — a company
* with no owner is a tenant nobody can sign into and it looks normal in every

View File

@@ -1,6 +1,11 @@
'use client';
import {useEffect} from 'react';
import {Center} from '@astryxdesign/core/Center';
import {Spinner} from '@astryxdesign/core/Spinner';
import {AuthGuard} from '@/features/auth/guards/AuthGuard';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {ADMIN_DESTINATION} from '@/features/auth/services/roleDestination';
import {WorkspaceShell} from '@/shared/layouts/workspace/WorkspaceShell';
/**
@@ -20,7 +25,42 @@ import {WorkspaceShell} from '@/shared/layouts/workspace/WorkspaceShell';
export function ProtectedLayout({children}: {children: React.ReactNode}) {
return (
<AuthGuard>
<WorkspaceShell>{children}</WorkspaceShell>
<TenantsOnly>
<WorkspaceShell>{children}</WorkspaceShell>
</TenantsOnly>
</AuthGuard>
);
}
/**
* A platform operator never sees the merchant workspace.
*
* The mirror of `OperatorsOnly` in AdminLayout. The proxy redirects an operator
* to /admin, but it resolves a DOCUMENT request from the tab pointer cookie —
* so with a merchant signed in in another tab, the operator's tab can be served
* /dashboard, and every data call (which carries this tab's own id) then 403s
* with "This account has no company". That page is useless to an operator.
*
* A full navigation rather than `router.replace`: the tab session script claims
* the pointer on `beforeunload`, so the /admin document request is gated as
* THIS tab. A client-side RSC fetch has no such hook and could be bounced back
* to /dashboard by the other tab's pointer.
*/
function TenantsOnly({children}: {children: React.ReactNode}) {
const {user} = useSession();
const isOperator = user?.isPlatformAdmin === true;
useEffect(() => {
if (isOperator) window.location.replace(ADMIN_DESTINATION);
}, [isOperator]);
if (isOperator) {
return (
<Center height="100vh" role="status" aria-label="Opening the platform console">
<Spinner size="lg" />
</Center>
);
}
return <>{children}</>;
}

View File

@@ -81,6 +81,17 @@ export function failureFrom(err: unknown): UpstreamFailure {
reason: 'not_supported',
};
}
// Checked before the generic 404 below: a route the deployed platform
// does not have yet is a version gap, not a missing record, and a panel
// must not tell somebody their data is gone when the server is just old.
if (err.isRouteMissing) {
return {
status: 501,
code: 'not_deployed',
message: 'This needs a newer platform version than the one running.',
reason: 'not_deployed',
};
}
// A 404 is also what another tenant's data returns, always — so it must
// never be phrased as "deleted".
return {

View File

@@ -30,7 +30,10 @@ export type ApiErrorCode =
| 'internal'
| 'not_found'
| 'bad_request'
| 'unauthorized';
| 'unauthorized'
/** The deployed platform is older than this console and has no route for
* the request. Not a fault — render "not available yet", never red. */
| 'not_deployed';
export interface ApiFailure {
error: {code: ApiErrorCode; message: string};