update api and login
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
22
src/app/api/admin/clients/[id]/sites/[site]/cameras/route.ts
Normal file
22
src/app/api/admin/clients/[id]/sites/[site]/cameras/route.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
24
src/app/api/admin/clients/[id]/sites/[site]/route.ts
Normal file
24
src/app/api/admin/clients/[id]/sites/[site]/route.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
22
src/app/api/admin/clients/[id]/sites/route.ts
Normal file
22
src/app/api/admin/clients/[id]/sites/route.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
15
src/app/api/admin/monitoring/summary/route.ts
Normal file
15
src/app/api/admin/monitoring/summary/route.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
23
src/app/api/settings/profile/route.ts
Normal file
23
src/app/api/settings/profile/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
236
src/features/admin/components/ShopCards.tsx
Normal file
236
src/features/admin/components/ShopCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
114
src/features/admin/components/common/AdminKpiCard.tsx
Normal file
114
src/features/admin/components/common/AdminKpiCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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.'},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
59
src/features/admin/services/mapMonitoring.ts
Normal file
59
src/features/admin/services/mapMonitoring.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
23
src/proxy.ts
23
src/proxy.ts
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user