update api and login
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
|
import {UpstreamError} from '@/services/api/apiClient';
|
||||||
import {engagementApi} from '@/services/api/engagementApi';
|
import {engagementApi} from '@/services/api/engagementApi';
|
||||||
import {toReportWindow, toSiteParam} from '@/services/api/range';
|
import {toReportWindow, toSiteParam} from '@/services/api/range';
|
||||||
import {serveUpstream} from '@/shared/services/bff';
|
import {serveUpstream} from '@/shared/services/bff';
|
||||||
@@ -17,10 +18,17 @@ export async function GET(req: NextRequest) {
|
|||||||
return serveUpstream(req, async (token, query) => {
|
return serveUpstream(req, async (token, query) => {
|
||||||
const window = toReportWindow(query.range, new Date(query.nowMs));
|
const window = toReportWindow(query.range, new Date(query.nowMs));
|
||||||
const site = toSiteParam(query.storeId);
|
const site = toSiteParam(query.storeId);
|
||||||
const [activities, impact] = await Promise.all([
|
try {
|
||||||
engagementApi.activities(token, window, site),
|
const [activities, impact] = await Promise.all([
|
||||||
engagementApi.impact(token, window, site),
|
engagementApi.activities(token, window, site),
|
||||||
]);
|
engagementApi.impact(token, window, site),
|
||||||
return toActivityRows(activities, impact);
|
]);
|
||||||
|
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 type {NextRequest} from 'next/server';
|
||||||
import {adminApi} from '@/services/api/adminApi';
|
import {adminApi} from '@/services/api/adminApi';
|
||||||
import {proxyUpstream} from '@/shared/services/bff';
|
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
|
||||||
import {toCompany} from '@/features/admin/services/mapCompany';
|
import {toCompany, toCompanyDetail} from '@/features/admin/services/mapCompany';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
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.
|
* 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 {ConfigError} from '@/shared/errors/configError';
|
||||||
import {
|
import {
|
||||||
LOGIN_ERROR_PARAM,
|
LOGIN_ERROR_PARAM,
|
||||||
|
NOT_PLATFORM_ADMIN_MESSAGE,
|
||||||
type LoginErrorCode,
|
type LoginErrorCode,
|
||||||
} from '@/features/auth/services/loginErrorCodes';
|
} from '@/features/auth/services/loginErrorCodes';
|
||||||
import {resolveRedirectTargetFor} from '@/features/auth/services/redirectTarget';
|
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.
|
* a session it is unable to verify on the next request.
|
||||||
*/
|
*/
|
||||||
const user = toAuthUser(bundle.user);
|
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.
|
* Two different lifetimes, and conflating them was the bug.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const user = await withUpstream((token) => authApi.me(token));
|
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 = {
|
const session: AuthSession = {
|
||||||
user: toAuthUser(user),
|
user: toAuthUser(user),
|
||||||
// The cookie's own expiry is the browser-side lifetime; the platform's
|
// 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(
|
return anonymous();
|
||||||
{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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 type {NextRequest} from 'next/server';
|
||||||
import {authApi} from '@/services/api/authApi';
|
import {authApi} from '@/services/api/authApi';
|
||||||
|
import {refuseOffConsole} from '@/features/auth/services/serverSession';
|
||||||
import {proxyUpstream} from '@/shared/services/bff';
|
import {proxyUpstream} from '@/shared/services/bff';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -15,6 +16,8 @@ export async function DELETE(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{params}: {params: Promise<{id: string}>},
|
{params}: {params: Promise<{id: string}>},
|
||||||
) {
|
) {
|
||||||
|
const refused = await refuseOffConsole();
|
||||||
|
if (refused) return refused;
|
||||||
const {id} = await params;
|
const {id} = await params;
|
||||||
return proxyUpstream(req, (token) => authApi.revokeSession(token, id));
|
return proxyUpstream(req, (token) => authApi.revokeSession(token, id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {authApi} from '@/services/api/authApi';
|
import {authApi} from '@/services/api/authApi';
|
||||||
|
import {refuseOffConsole} from '@/features/auth/services/serverSession';
|
||||||
import {proxyUpstream} from '@/shared/services/bff';
|
import {proxyUpstream} from '@/shared/services/bff';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -12,5 +13,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
* screen they are doing it from.
|
* screen they are doing it from.
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const refused = await refuseOffConsole();
|
||||||
|
if (refused) return refused;
|
||||||
return proxyUpstream(req, (token) => authApi.revokeOtherSessions(token));
|
return proxyUpstream(req, (token) => authApi.revokeOtherSessions(token));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {authApi} from '@/services/api/authApi';
|
import {authApi} from '@/services/api/authApi';
|
||||||
|
import {refuseOffConsole} from '@/features/auth/services/serverSession';
|
||||||
import {serveUpstream} from '@/shared/services/bff';
|
import {serveUpstream} from '@/shared/services/bff';
|
||||||
import {toDeviceSession} from '@/features/settings/services/mapSession';
|
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.
|
* from the browser they are reading the page in.
|
||||||
*/
|
*/
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const refused = await refuseOffConsole();
|
||||||
|
if (refused) return refused;
|
||||||
return serveUpstream(req, (token) => authApi.sessions(token), (list) =>
|
return serveUpstream(req, (token) => authApi.sessions(token), (list) =>
|
||||||
list.map(toDeviceSession),
|
list.map(toDeviceSession),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
|
import {UpstreamError} from '@/services/api/apiClient';
|
||||||
import {engagementApi} from '@/services/api/engagementApi';
|
import {engagementApi} from '@/services/api/engagementApi';
|
||||||
import {toReportWindow, toSiteParam} from '@/services/api/range';
|
import {toReportWindow, toSiteParam} from '@/services/api/range';
|
||||||
import {serveUpstream} from '@/shared/services/bff';
|
import {serveUpstream} from '@/shared/services/bff';
|
||||||
@@ -10,7 +11,14 @@ export const dynamic = 'force-dynamic';
|
|||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
return serveUpstream(req, async (token, query) => {
|
return serveUpstream(req, async (token, query) => {
|
||||||
const window = toReportWindow(query.range, new Date(query.nowMs));
|
const window = toReportWindow(query.range, new Date(query.nowMs));
|
||||||
const list = await engagementApi.campaigns(token, window, toSiteParam(query.storeId));
|
try {
|
||||||
return (list ?? []).map(toCampaign);
|
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,
|
// 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
|
// answer with an empty list so the floor screen renders its clean empty state
|
||||||
// rather than failing with 404.
|
// rather than failing with 404.
|
||||||
if (err instanceof UpstreamError && err.status === 404) {
|
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||||
return {items: []};
|
return {items: []};
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
|
import {UpstreamError} from '@/services/api/apiClient';
|
||||||
import {engagementApi} from '@/services/api/engagementApi';
|
import {engagementApi} from '@/services/api/engagementApi';
|
||||||
import {toReportWindow, toSiteParam} from '@/services/api/range';
|
import {toReportWindow, toSiteParam} from '@/services/api/range';
|
||||||
import {serveUpstream} from '@/shared/services/bff';
|
import {serveUpstream} from '@/shared/services/bff';
|
||||||
@@ -14,8 +15,17 @@ export const dynamic = 'force-dynamic';
|
|||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
return serveUpstream(req, async (token, query) => {
|
return serveUpstream(req, async (token, query) => {
|
||||||
const window = toReportWindow(query.range, new Date(query.nowMs));
|
const window = toReportWindow(query.range, new Date(query.nowMs));
|
||||||
return toJourney(
|
try {
|
||||||
await engagementApi.journey(token, window, toSiteParam(query.storeId)),
|
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) {
|
} catch (err) {
|
||||||
// If the upstream platform has not deployed /api/sales yet,
|
// If the upstream platform has not deployed /api/sales yet,
|
||||||
// answer with an empty list so the sales screen renders cleanly
|
// answer with an empty list so the sales screen renders cleanly
|
||||||
// rather than failing with 404.
|
// rather than failing. Only a missing ROUTE — a 404 for a shop that
|
||||||
if (err instanceof UpstreamError && err.status === 404) {
|
// does not exist is a real answer and still surfaces.
|
||||||
|
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||||
return {items: []};
|
return {items: []};
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -87,6 +88,7 @@ export async function GET(req: NextRequest) {
|
|||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
let body: {
|
let body: {
|
||||||
idempotencyKey?: unknown;
|
idempotencyKey?: unknown;
|
||||||
|
clientCreatedAt?: unknown;
|
||||||
visitId?: unknown;
|
visitId?: unknown;
|
||||||
visitorId?: unknown;
|
visitorId?: unknown;
|
||||||
invoiceNo?: unknown;
|
invoiceNo?: unknown;
|
||||||
@@ -125,7 +127,7 @@ export async function POST(req: NextRequest) {
|
|||||||
site: typeof body.site === 'string' ? body.site : undefined,
|
site: typeof body.site === 'string' ? body.site : undefined,
|
||||||
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
|
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
|
||||||
visitor_id: typeof body.visitorId === 'string' ? body.visitorId : undefined,
|
visitor_id: typeof body.visitorId === 'string' ? body.visitorId : undefined,
|
||||||
client_created_at: new Date().toISOString(),
|
client_created_at: draftTime(body.clientCreatedAt),
|
||||||
lines,
|
lines,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -139,10 +141,16 @@ export async function POST(req: NextRequest) {
|
|||||||
sale: result.sale ? toSale(result.sale) : null,
|
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) {
|
} 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(
|
return Response.json(
|
||||||
{
|
{
|
||||||
error: {
|
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 {useThemeMode} from '@/shared/providers/ThemeModeProvider';
|
||||||
import type {ThemeMode} from '@/shared/theme/themeMode';
|
import type {ThemeMode} from '@/shared/theme/themeMode';
|
||||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
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 {AdminPageHeader} from './common/AdminPageHeader';
|
||||||
import {PendingIntegration} from './common/PendingIntegration';
|
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
|
* editable, because the only profile write (`PATCH /api/settings/profile`) is
|
||||||
* tenant-scoped and an admin has no tenant.
|
* tenant-scoped and an admin has no tenant.
|
||||||
*
|
*
|
||||||
* Settings has two live parts: appearance (a cookie this app owns) and active
|
* Settings has two live parts: appearance (a cookie this app owns) and the
|
||||||
* sessions (`/api/auth/sessions`, scoped to the person, not a tenant). Password
|
* merchant activity status list (`GET /api/admin/clients`). Password
|
||||||
* change and two-factor have no platform endpoint, so they say so rather than
|
* 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
|
* render a form that saves nowhere — the merchant Security page's password
|
||||||
* form and 2FA switch are exactly that, and are not reused here.
|
* form and 2FA switch are exactly that, and are not reused here.
|
||||||
@@ -106,7 +113,7 @@ export function AdminSettings() {
|
|||||||
<VStack gap={6} width="100%">
|
<VStack gap={6} width="100%">
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
title="Settings"
|
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>}
|
actions={<Link href="/admin/profile">Profile</Link>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -125,7 +132,7 @@ export function AdminSettings() {
|
|||||||
</SegmentedControl>
|
</SegmentedControl>
|
||||||
</StaticPanel>
|
</StaticPanel>
|
||||||
|
|
||||||
<ActiveSessionsPanel />
|
<MerchantActivityStatus />
|
||||||
|
|
||||||
<StaticPanel title="Password and two-factor">
|
<StaticPanel title="Password and two-factor">
|
||||||
<PendingIntegration
|
<PendingIntegration
|
||||||
@@ -137,3 +144,70 @@ export function AdminSettings() {
|
|||||||
</VStack>
|
</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';
|
'use client';
|
||||||
|
|
||||||
import {useRouter} from 'next/navigation';
|
import {useEffect} from 'react';
|
||||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
import {Center} from '@astryxdesign/core/Center';
|
||||||
import {EmptyState} from '@astryxdesign/core/EmptyState';
|
import {Spinner} from '@astryxdesign/core/Spinner';
|
||||||
import {Button} from '@astryxdesign/core/Button';
|
|
||||||
import {AuthGuard} from '@/features/auth/guards/AuthGuard';
|
import {AuthGuard} from '@/features/auth/guards/AuthGuard';
|
||||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||||
|
import {destinationForUser} from '@/features/auth/services/roleDestination';
|
||||||
import {AdminShell} from './shell/AdminShell';
|
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
|
* The proxy redirects them before this renders, but it resolves a DOCUMENT
|
||||||
* resolves the session from the tab POINTER cookie, while this tab's client
|
* request from the tab POINTER cookie, while this tab's client session comes
|
||||||
* session comes from its own tab id (see tabSession.ts). With an operator
|
* from its own tab id (see tabSession.ts). With an operator signed in in one
|
||||||
* signed in in one tab and a merchant in another, the server can render this
|
* tab and a merchant in another, the server can render /admin for the
|
||||||
* shell for the operator's pointer while the tab itself holds the merchant.
|
* operator's pointer while this tab holds the merchant.
|
||||||
*
|
*
|
||||||
* An automatic redirect to /dashboard was tried and does not work: the proxy,
|
* This used to show an explanation with a "Go to dashboard" link. Now it sends
|
||||||
* reading the same operator pointer, sends /dashboard straight back to /admin,
|
* the tab straight to its own home — /dashboard for a merchant, /floor for
|
||||||
* and the tab settles on a blank page. So this says what happened and offers
|
* staff — and renders nothing of the admin console in between.
|
||||||
* the two ways out, instead of guessing.
|
|
||||||
*
|
*
|
||||||
* Routing only, like the proxy. Nothing leaks either way: every admin read
|
* A full navigation rather than `router.replace`: the tab session script claims
|
||||||
* carries this tab's id and the platform answers 404 to a merchant.
|
* 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}) {
|
function OperatorsOnly({children}: {children: React.ReactNode}) {
|
||||||
const {isAuthenticated, user, logout} = useSession();
|
const {user} = useSession();
|
||||||
const router = useRouter();
|
const isOperator = user?.isPlatformAdmin === true;
|
||||||
|
const home = user && !isOperator ? destinationForUser(user) : null;
|
||||||
|
|
||||||
if (user?.isPlatformAdmin === true) return <>{children}</>;
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return null;
|
if (home) window.location.replace(home);
|
||||||
|
}, [home]);
|
||||||
|
|
||||||
async function signOut() {
|
if (isOperator) return <>{children}</>;
|
||||||
await logout();
|
|
||||||
router.replace('/login?next=%2Fadmin');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack width="100%" minHeight="100vh" hAlign="center" vAlign="center" padding={6}>
|
<Center height="100vh" role="status" aria-label="Leaving the admin console">
|
||||||
<EmptyState
|
<Spinner size="lg" />
|
||||||
title="This is the platform admin console"
|
</Center>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function CameraBody({
|
|||||||
back={{label: storeName, href: adminHref.store(company.id, storeId)}}
|
back={{label: storeName, href: adminHref.store(company.id, storeId)}}
|
||||||
crumbs={[
|
crumbs={[
|
||||||
{label: 'Merchants', href: adminHref.merchants},
|
{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: storeName, href: adminHref.store(company.id, storeId)},
|
||||||
{label: cameraName},
|
{label: cameraName},
|
||||||
]}
|
]}
|
||||||
@@ -124,7 +124,7 @@ function CameraBody({
|
|||||||
icon: 'camera',
|
icon: 'camera',
|
||||||
title: 'Camera data unavailable',
|
title: 'Camera data unavailable',
|
||||||
description:
|
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} />}
|
loading={<SkeletonRows count={3} />}
|
||||||
>
|
>
|
||||||
@@ -132,8 +132,13 @@ function CameraBody({
|
|||||||
<List>
|
<List>
|
||||||
<ListItem label="Status" endContent={<CameraStatus status={c.status} />} />
|
<ListItem label="Status" endContent={<CameraStatus status={c.status} />} />
|
||||||
<ListItem
|
<ListItem
|
||||||
label="Location"
|
label="Camera ID"
|
||||||
endContent={<Text size="sm">{c.zone ?? '—'}</Text>}
|
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
|
<ListItem
|
||||||
label="Last seen"
|
label="Last seen"
|
||||||
@@ -143,6 +148,14 @@ function CameraBody({
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
label="Last snapshot"
|
||||||
|
endContent={
|
||||||
|
<Text size="sm">
|
||||||
|
{c.snapshotAt ? new Date(c.snapshotAt).toLocaleString() : '—'}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</List>
|
</List>
|
||||||
)}
|
)}
|
||||||
</AdminSection>
|
</AdminSection>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {useRouter} from 'next/navigation';
|
import {useRouter, useSearchParams} from 'next/navigation';
|
||||||
import {Card} from '@astryxdesign/core/Card';
|
import {Card} from '@astryxdesign/core/Card';
|
||||||
|
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
|
||||||
import {Grid} from '@astryxdesign/core/Grid';
|
import {Grid} from '@astryxdesign/core/Grid';
|
||||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||||
import {Text} from '@astryxdesign/core/Text';
|
import {Text} from '@astryxdesign/core/Text';
|
||||||
import {Button} from '@astryxdesign/core/Button';
|
import {Button} from '@astryxdesign/core/Button';
|
||||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||||
import {Token} from '@astryxdesign/core/Token';
|
|
||||||
import {Badge} from '@astryxdesign/core/Badge';
|
import {Badge} from '@astryxdesign/core/Badge';
|
||||||
import {Icon} from '@astryxdesign/core/Icon';
|
import {Icon} from '@astryxdesign/core/Icon';
|
||||||
import {TabList, Tab} from '@astryxdesign/core/TabList';
|
import {TabList, Tab} from '@astryxdesign/core/TabList';
|
||||||
@@ -24,31 +24,33 @@ import {
|
|||||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||||
import {
|
import {
|
||||||
useCompany,
|
useCompany,
|
||||||
|
useCompanyOwner,
|
||||||
useCompanyStores,
|
useCompanyStores,
|
||||||
} from '@/features/admin/hooks/useMonitoring';
|
} from '@/features/admin/hooks/useMonitoring';
|
||||||
import {useAiContext} from '@/features/admin/hooks/useAiContext';
|
import {useAiContext} from '@/features/admin/hooks/useAiContext';
|
||||||
import type {Company} from '@/features/admin/types/company';
|
import type {Company} from '@/features/admin/types/company';
|
||||||
import {AdminPageHeader, BackButton} from './common/AdminPageHeader';
|
import {AdminPageHeader, BackButton} from './common/AdminPageHeader';
|
||||||
import {AdminSection} from './common/AdminSection';
|
|
||||||
import {CompanyActions} from './CompanyActions';
|
import {CompanyActions} from './CompanyActions';
|
||||||
import {StoreTable, adminHref} from './MonitoringTables';
|
import {adminHref} from './MonitoringTables';
|
||||||
|
import {ShopCard, ShopList} from './ShopCards';
|
||||||
|
import {PendingIntegration} from './common/PendingIntegration';
|
||||||
import {
|
import {
|
||||||
ADMIN_CAPABILITIES,
|
ADMIN_CAPABILITIES,
|
||||||
MERCHANT_AREAS,
|
MERCHANT_AREAS,
|
||||||
} from '@/features/admin/config/capabilities';
|
} 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
|
* One merchant: what the platform knows about it, and the way down to its
|
||||||
* shops.
|
* shops.
|
||||||
*
|
*
|
||||||
* ── Only the fields the platform returns ─────────────────────────────────
|
* ── Only the fields the platform returns ─────────────────────────────────
|
||||||
* Name, slug, status, shop count, account count, created. There is no owner
|
* Name, slug, status, shop count, account count, created — from the merchant
|
||||||
* name, plan or contact on the admin surface, so there is no owner field
|
* row — plus the owner's name and email from the detail read, when the
|
||||||
* here. The shop and account COUNTS are real — they come on the merchant row
|
* platform serves it. There is no plan or contact beyond that. If the shops
|
||||||
* — even though the shops themselves cannot be listed yet, and the Shops tab
|
* cannot be listed, the Shops tab says so rather than rendering an empty
|
||||||
* says exactly that rather than rendering an empty table that would read as
|
* table that would read as "this merchant has no shops".
|
||||||
* "this merchant has no shops".
|
|
||||||
*
|
*
|
||||||
* ── Tabs only for what can be answered ───────────────────────────────────
|
* ── Tabs only for what can be answered ───────────────────────────────────
|
||||||
* General and Shops. Sales persons, cameras, customers, sales, analytics,
|
* General and Shops. Sales persons, cameras, customers, sales, analytics,
|
||||||
@@ -129,8 +131,30 @@ function CompanyBody({
|
|||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
onDeleted: () => 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';
|
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 (
|
return (
|
||||||
<VStack gap={6} width="100%">
|
<VStack gap={6} width="100%">
|
||||||
@@ -196,14 +220,23 @@ function CompanyBody({
|
|||||||
<Tab
|
<Tab
|
||||||
value="shops"
|
value="shops"
|
||||||
label="Shops"
|
label="Shops"
|
||||||
endContent={<Badge label={company.sites.toLocaleString()} />}
|
endContent={<Badge label={shopCount.toLocaleString()} />}
|
||||||
/>
|
/>
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
{tab === 'general' ? (
|
{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>
|
</VStack>
|
||||||
);
|
);
|
||||||
@@ -220,19 +253,34 @@ const fmtDate = (iso: string) =>
|
|||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
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 bp = useBreakpoint();
|
||||||
|
const owner = useCompanyOwner(company.id);
|
||||||
// Read once per mount: rendering must stay pure, and a day count does not
|
// Read once per mount: rendering must stay pure, and a day count does not
|
||||||
// need to tick while the page is open.
|
// need to tick while the page is open.
|
||||||
const [now] = useState(() => Date.now());
|
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.
|
// Derived from created_at, which the row carries — not an estimate.
|
||||||
const days = Math.max(
|
const days = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.floor((now - new Date(company.createdAt).getTime()) / DAY_MS),
|
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 (
|
return (
|
||||||
<VStack gap={6} width="100%">
|
<VStack gap={6} width="100%">
|
||||||
@@ -240,14 +288,19 @@ function MerchantGeneral({company}: {company: Company}) {
|
|||||||
<Fact
|
<Fact
|
||||||
icon="stores"
|
icon="stores"
|
||||||
label="Shops"
|
label="Shops"
|
||||||
value={company.sites.toLocaleString()}
|
value={shopCount.toLocaleString()}
|
||||||
caption="Registered to this merchant"
|
caption="Registered to this merchant"
|
||||||
|
action={{label: 'View shops →', onClick: onOpenShops}}
|
||||||
/>
|
/>
|
||||||
<Fact
|
<Fact
|
||||||
icon="staff"
|
icon="staff"
|
||||||
label="Merchant accounts"
|
label="Merchant accounts"
|
||||||
value={company.users.toLocaleString()}
|
value={company.users.toLocaleString()}
|
||||||
caption="Logins on this merchant"
|
caption={
|
||||||
|
owner
|
||||||
|
? `Owner: ${[owner.name, owner.email].filter(Boolean).join(' · ')}`
|
||||||
|
: 'Logins on this merchant'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Fact
|
<Fact
|
||||||
icon="expiry"
|
icon="expiry"
|
||||||
@@ -268,98 +321,114 @@ function MerchantGeneral({company}: {company: Company}) {
|
|||||||
<Card padding={6}>
|
<Card padding={6}>
|
||||||
<VStack gap={5} width="100%">
|
<VStack gap={5} width="100%">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Merchant data"
|
title="Shops"
|
||||||
subtitle={`What this console can read for ${company.name}. Every area is scoped to this merchant and its shops only.`}
|
subtitle={`The shops and businesses ${company.name} runs. Open one to see its cameras, footfall and sales.`}
|
||||||
actions={
|
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}>
|
{stores.isAvailable ? (
|
||||||
{areas.map((a) => (
|
<AsyncBoundary
|
||||||
<AreaTile
|
resource={stores.resource}
|
||||||
key={a.key}
|
loading={<SkeletonRows count={2} />}
|
||||||
area={a}
|
empty={
|
||||||
isLive={ADMIN_CAPABILITIES[a.capability]}
|
<Text size="sm" color="secondary">
|
||||||
/>
|
{company.name} has not opened a shop yet.
|
||||||
))}
|
</Text>
|
||||||
</Grid>
|
}
|
||||||
|
>
|
||||||
|
{(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>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{upcoming.length > 0 ? (
|
||||||
|
<Text size="xsm" color="secondary">
|
||||||
|
Also coming to this merchant as the platform exposes them:{' '}
|
||||||
|
{upcoming.join(' · ')}.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const PREVIEW = 3;
|
||||||
* 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
|
/** Why the shops cannot be opened yet — said once, in engineering terms. */
|
||||||
* required" tokens read as noise, so the state is a quiet dot + label and the
|
function ShopsPending({company}: {company: Company}) {
|
||||||
* summary token above carries the count.
|
const n = company.sites;
|
||||||
*/
|
|
||||||
function AreaTile({area, isLive}: {area: MerchantArea; isLive: boolean}) {
|
|
||||||
return (
|
return (
|
||||||
<HStack
|
<PendingIntegration
|
||||||
gap={3}
|
icon="stores"
|
||||||
vAlign="center"
|
title="Shops cannot be listed yet"
|
||||||
width="100%"
|
description={
|
||||||
className="rounded-lg bg-muted p-4 min-w-0"
|
n === 0
|
||||||
>
|
? `${company.name} has not opened a shop.`
|
||||||
<HStack
|
: `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.`
|
||||||
hAlign="center"
|
}
|
||||||
vAlign="center"
|
/>
|
||||||
className="size-9 rounded-md bg-surface shrink-0"
|
|
||||||
>
|
|
||||||
<Icon icon={ICONS[area.icon]} size="sm" color="secondary" />
|
|
||||||
</HStack>
|
|
||||||
<VStack gap={0} className="min-w-0 flex-1">
|
|
||||||
<Text size="sm" weight="medium">
|
|
||||||
{area.label}
|
|
||||||
</Text>
|
|
||||||
<Text size="xsm" color="secondary">
|
|
||||||
{area.description}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
<HStack gap={1.5} vAlign="center" className="shrink-0">
|
|
||||||
<StatusDot
|
|
||||||
variant={isLive ? 'success' : 'neutral'}
|
|
||||||
label={isLive ? 'Live' : 'Backend integration required'}
|
|
||||||
/>
|
|
||||||
<Text size="xsm" color="secondary">
|
|
||||||
{isLive ? 'Live' : 'Needs API'}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MerchantShops({company}: {company: Company}) {
|
function MerchantShops({
|
||||||
const stores = useCompanyStores(company.id);
|
company,
|
||||||
const n = company.sites;
|
stores,
|
||||||
const shops = `${n} ${n === 1 ? 'shop' : 'shops'}`;
|
shopCount,
|
||||||
|
}: {
|
||||||
|
company: Company;
|
||||||
|
stores: AdminData<AdminStore[]>;
|
||||||
|
shopCount: number;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<AdminSection
|
<Card padding={6}>
|
||||||
title="Shops"
|
<VStack gap={5} width="100%">
|
||||||
subtitle={`${shops} registered to ${company.name}.`}
|
<SectionHeader
|
||||||
data={stores}
|
title="Shops"
|
||||||
pending={{
|
subtitle={`${shopCount.toLocaleString()} ${
|
||||||
icon: 'stores',
|
shopCount === 1 ? 'shop' : 'shops'
|
||||||
title: 'Shops cannot be listed yet',
|
} registered to ${company.name}.`}
|
||||||
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.`}
|
|
||||||
/>
|
/>
|
||||||
}
|
{stores.isAvailable ? (
|
||||||
>
|
<AsyncBoundary
|
||||||
{(rows) => <StoreTable companyId={company.id} stores={rows} />}
|
resource={stores.resource}
|
||||||
</AdminSection>
|
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,
|
label,
|
||||||
value,
|
value,
|
||||||
caption,
|
caption,
|
||||||
|
action,
|
||||||
}: {
|
}: {
|
||||||
icon: IconKey;
|
icon: IconKey;
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
caption: 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">
|
<Card padding={4} className="h-full">
|
||||||
<VStack gap={3} width="100%">
|
{body}
|
||||||
<HStack hAlign="between" vAlign="center" width="100%">
|
|
||||||
<Text size="sm" color="secondary" weight="medium">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<HStack
|
|
||||||
hAlign="center"
|
|
||||||
vAlign="center"
|
|
||||||
className="size-7 rounded-md bg-muted shrink-0"
|
|
||||||
>
|
|
||||||
<Icon icon={ICONS[icon]} size="sm" color="secondary" />
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
<Text size="xl" weight="semibold" className="tracking-tight">
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
<Text size="xsm" color="secondary">
|
|
||||||
{caption}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</Card>
|
</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 type {TableColumn} from '@astryxdesign/core/Table';
|
||||||
import {HStack} from '@astryxdesign/core/Layout';
|
import {HStack} from '@astryxdesign/core/Layout';
|
||||||
import {Text} from '@astryxdesign/core/Text';
|
import {Text} from '@astryxdesign/core/Text';
|
||||||
import {Link} from '@astryxdesign/core/Link';
|
|
||||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||||
import type {
|
import type {
|
||||||
AdminAlert,
|
AdminAlert,
|
||||||
AdminCamera,
|
|
||||||
AdminCameraStatus,
|
AdminCameraStatus,
|
||||||
AdminEvent,
|
AdminEvent,
|
||||||
AdminSeverity,
|
AdminSeverity,
|
||||||
AdminStore,
|
|
||||||
} from '@/features/admin/types/monitoring';
|
} from '@/features/admin/types/monitoring';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,79 +32,14 @@ export const adminHref = {
|
|||||||
merchants: '/admin/merchants',
|
merchants: '/admin/merchants',
|
||||||
company: (companyId: string) =>
|
company: (companyId: string) =>
|
||||||
`/admin/merchants/${encodeURIComponent(companyId)}`,
|
`/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) =>
|
store: (companyId: string, storeId: string) =>
|
||||||
`${adminHref.company(companyId)}/shops/${encodeURIComponent(storeId)}`,
|
`${adminHref.company(companyId)}/shops/${encodeURIComponent(storeId)}`,
|
||||||
camera: (companyId: string, storeId: string, cameraId: string) =>
|
camera: (companyId: string, storeId: string, cameraId: string) =>
|
||||||
`${adminHref.store(companyId, storeId)}/cameras/${encodeURIComponent(cameraId)}`,
|
`${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 ─────────────────────────────────────────────────────────── */
|
/* ── Cameras ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const CAMERA_STATUS: Record<
|
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<
|
const SEVERITY: Record<
|
||||||
AdminSeverity,
|
AdminSeverity,
|
||||||
{label: string; dot: 'error' | 'warning' | 'neutral'}
|
{label: string; dot: 'error' | 'warning' | 'neutral'}
|
||||||
|
|||||||
@@ -14,20 +14,24 @@ import {StatusDot} from '@astryxdesign/core/StatusDot';
|
|||||||
import {Token} from '@astryxdesign/core/Token';
|
import {Token} from '@astryxdesign/core/Token';
|
||||||
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
||||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||||
import {HoverLift} from '@/shared/components/motion/HoverLift';
|
|
||||||
import {AnimatedNumber} from '@/shared/components/motion/AnimatedNumber';
|
|
||||||
import {
|
import {
|
||||||
SkeletonMetricGrid,
|
SkeletonMetricGrid,
|
||||||
SkeletonRows,
|
SkeletonRows,
|
||||||
} from '@/shared/components/patterns/LoadingState';
|
} from '@/shared/components/patterns/LoadingState';
|
||||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||||
import {ICONS} from '@/shared/utils/icons';
|
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 {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||||
import {useCompanies} from '@/features/admin/hooks/useCompanies';
|
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 {ADMIN_CAPABILITIES} from '@/features/admin/config/capabilities';
|
||||||
import type {AdminCapability} from '@/features/admin/config/capabilities';
|
import type {AdminCapability} from '@/features/admin/config/capabilities';
|
||||||
import {AdminPageHeader} from './common/AdminPageHeader';
|
import {AdminPageHeader} from './common/AdminPageHeader';
|
||||||
|
import {AdminKpiCard, KpiCaption} from './common/AdminKpiCard';
|
||||||
import {CreateCompanyDialog} from './CreateCompanyDialog';
|
import {CreateCompanyDialog} from './CreateCompanyDialog';
|
||||||
import {adminHref} from './MonitoringTables';
|
import {adminHref} from './MonitoringTables';
|
||||||
|
|
||||||
@@ -43,10 +47,6 @@ import {adminHref} from './MonitoringTables';
|
|||||||
* merchants with no shop (onboarded but never set up — the likeliest support
|
* merchants with no shop (onboarded but never set up — the likeliest support
|
||||||
* call) and the newest merchants.
|
* 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;
|
const RECENT_COUNT = 5;
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
@@ -68,7 +68,7 @@ export function PlatformOverview() {
|
|||||||
const [isCreating, setCreating] = useState(false);
|
const [isCreating, setCreating] = useState(false);
|
||||||
|
|
||||||
// Responsive column budget: 1 on mobile, 2 on tablet, 4 on desktop
|
// 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 (
|
return (
|
||||||
<VStack gap={6} width="100%">
|
<VStack gap={6} width="100%">
|
||||||
@@ -137,223 +137,83 @@ export function PlatformOverview() {
|
|||||||
const activePercent =
|
const activePercent =
|
||||||
s.companies > 0 ? Math.round((s.active / s.companies) * 100) : 100;
|
s.companies > 0 ? Math.round((s.active / s.companies) * 100) : 100;
|
||||||
const avgStores = (s.stores / (s.active || 1)).toFixed(1);
|
const avgStores = (s.stores / (s.active || 1)).toFixed(1);
|
||||||
const avgUsers = (s.accounts / (s.companies || 1)).toFixed(1);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid columns={columns} gap={4}>
|
<Grid columns={columns} gap={4}>
|
||||||
{/* Card 1: Registered Merchants */}
|
<AdminKpiCard
|
||||||
<HoverLift>
|
label="Merchants"
|
||||||
<Card padding={4} elevation="low" className="h-full">
|
value={s.companies}
|
||||||
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
unit="tenants"
|
||||||
<VStack gap={2} width="100%">
|
icon={ICONS.companies}
|
||||||
<HStack hAlign="between" vAlign="center" width="100%">
|
tone="warm"
|
||||||
<Text size="sm" color="secondary" weight="medium">
|
isFooterDivided={false}
|
||||||
Merchants
|
footer={
|
||||||
</Text>
|
<VStack gap={2} width="100%">
|
||||||
<HStack
|
<ProgressBar
|
||||||
hAlign="center"
|
value={activePercent}
|
||||||
vAlign="center"
|
label="Active rate"
|
||||||
className="size-8 rounded-lg bg-muted text-primary shrink-0"
|
isLabelHidden
|
||||||
>
|
variant="success"
|
||||||
<Icon icon={ICONS.companies} size="sm" />
|
/>
|
||||||
</HStack>
|
<HStack hAlign="between" vAlign="center" width="100%">
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<HStack gap={2} vAlign="end">
|
|
||||||
<Text
|
|
||||||
size="2xl"
|
|
||||||
weight="semibold"
|
|
||||||
className="tracking-tight"
|
|
||||||
>
|
|
||||||
<AnimatedNumber value={s.companies} format={fmt} />
|
|
||||||
</Text>
|
|
||||||
<Text size="xsm" color="secondary">
|
|
||||||
tenants
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack gap={2} width="100%">
|
|
||||||
<ProgressBar
|
|
||||||
value={activePercent}
|
|
||||||
label="Active rate"
|
|
||||||
isLabelHidden
|
|
||||||
variant="success"
|
|
||||||
/>
|
|
||||||
<HStack hAlign="between" vAlign="center" width="100%">
|
|
||||||
<HStack gap={1.5} vAlign="center">
|
|
||||||
<StatusDot variant="success" label="Active" />
|
|
||||||
<Text size="xsm" color="secondary">
|
|
||||||
{s.active} active
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<Text size="xsm" color="secondary">
|
|
||||||
{s.suspended} suspended
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
</VStack>
|
|
||||||
</Card>
|
|
||||||
</HoverLift>
|
|
||||||
|
|
||||||
{/* Card 2: Active Merchants */}
|
|
||||||
<HoverLift>
|
|
||||||
<Card padding={4} elevation="low" className="h-full">
|
|
||||||
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
|
||||||
<VStack gap={2} width="100%">
|
|
||||||
<HStack hAlign="between" vAlign="center" width="100%">
|
|
||||||
<Text size="sm" color="secondary" weight="medium">
|
|
||||||
Active merchants
|
|
||||||
</Text>
|
|
||||||
<HStack
|
|
||||||
hAlign="center"
|
|
||||||
vAlign="center"
|
|
||||||
className="size-8 rounded-lg bg-muted text-primary shrink-0"
|
|
||||||
>
|
|
||||||
<Icon icon={ICONS.present} size="sm" />
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<HStack gap={2} vAlign="end">
|
|
||||||
<Text
|
|
||||||
size="2xl"
|
|
||||||
weight="semibold"
|
|
||||||
className="tracking-tight"
|
|
||||||
>
|
|
||||||
<AnimatedNumber value={s.active} format={fmt} />
|
|
||||||
</Text>
|
|
||||||
<Text size="xsm" color="secondary">
|
|
||||||
active
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<HStack
|
|
||||||
hAlign="between"
|
|
||||||
vAlign="center"
|
|
||||||
width="100%"
|
|
||||||
className="pt-2 border-t border-border"
|
|
||||||
>
|
|
||||||
<HStack gap={1.5} vAlign="center">
|
<HStack gap={1.5} vAlign="center">
|
||||||
<StatusDot
|
<StatusDot variant="success" label="Active" />
|
||||||
variant={
|
<KpiCaption>{s.active} active</KpiCaption>
|
||||||
s.active === s.companies ? 'success' : 'warning'
|
|
||||||
}
|
|
||||||
label="Health"
|
|
||||||
/>
|
|
||||||
<Text size="xsm" weight="medium">
|
|
||||||
{activePercent}% active rate
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
<Text size="xsm" color="secondary">
|
<KpiCaption>{s.suspended} suspended</KpiCaption>
|
||||||
{s.companies - s.active === 0
|
|
||||||
? 'All nominal'
|
|
||||||
: `${s.companies - s.active} inactive`}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
}
|
||||||
</HoverLift>
|
/>
|
||||||
|
<AdminKpiCard
|
||||||
{/* Card 3: Shops */}
|
label="Active merchants"
|
||||||
<HoverLift>
|
value={s.active}
|
||||||
<Card padding={4} elevation="low" className="h-full">
|
unit="active"
|
||||||
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
icon={ICONS.present}
|
||||||
<VStack gap={2} width="100%">
|
tone="success"
|
||||||
<HStack hAlign="between" vAlign="center" width="100%">
|
footer={
|
||||||
<Text size="sm" color="secondary" weight="medium">
|
<>
|
||||||
Shops
|
<HStack gap={1.5} vAlign="center">
|
||||||
</Text>
|
<StatusDot
|
||||||
<HStack
|
variant={
|
||||||
hAlign="center"
|
s.active === s.companies ? 'success' : 'warning'
|
||||||
vAlign="center"
|
}
|
||||||
className="size-8 rounded-lg bg-muted text-primary shrink-0"
|
label="Health"
|
||||||
>
|
/>
|
||||||
<Icon icon={ICONS.stores} size="sm" />
|
<Text size="xsm" weight="medium">
|
||||||
</HStack>
|
{activePercent}% active rate
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<HStack gap={2} vAlign="end">
|
|
||||||
<Text
|
|
||||||
size="2xl"
|
|
||||||
weight="semibold"
|
|
||||||
className="tracking-tight"
|
|
||||||
>
|
|
||||||
<AnimatedNumber value={s.stores} format={fmt} />
|
|
||||||
</Text>
|
|
||||||
<Text size="xsm" color="secondary">
|
|
||||||
locations
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<HStack
|
|
||||||
hAlign="between"
|
|
||||||
vAlign="center"
|
|
||||||
width="100%"
|
|
||||||
className="pt-2 border-t border-border"
|
|
||||||
>
|
|
||||||
<Text size="xsm" color="secondary" className="truncate">
|
|
||||||
{s.active} active {s.active === 1 ? 'client' : 'clients'}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Token size="sm" label={`${avgStores} avg / tenant`} />
|
|
||||||
</HStack>
|
</HStack>
|
||||||
</VStack>
|
<KpiCaption>
|
||||||
</Card>
|
{s.companies - s.active === 0
|
||||||
</HoverLift>
|
? 'All nominal'
|
||||||
|
: `${s.companies - s.active} inactive`}
|
||||||
{/* Card 4: Accounts */}
|
</KpiCaption>
|
||||||
<HoverLift>
|
</>
|
||||||
<Card padding={4} elevation="low" className="h-full">
|
}
|
||||||
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
/>
|
||||||
<VStack gap={2} width="100%">
|
<AdminKpiCard
|
||||||
<HStack hAlign="between" vAlign="center" width="100%">
|
label="Stores"
|
||||||
<Text size="sm" color="secondary" weight="medium">
|
value={s.stores}
|
||||||
Merchant accounts
|
unit="locations"
|
||||||
</Text>
|
icon={ICONS.stores}
|
||||||
<HStack
|
tone="warm"
|
||||||
hAlign="center"
|
footer={
|
||||||
vAlign="center"
|
<>
|
||||||
className="size-8 rounded-lg bg-muted text-primary shrink-0"
|
<KpiCaption>
|
||||||
>
|
{s.active} active {s.active === 1 ? 'client' : 'clients'}
|
||||||
<Icon icon={ICONS.staff} size="sm" />
|
</KpiCaption>
|
||||||
</HStack>
|
<Token size="sm" label={`${avgStores} avg / tenant`} />
|
||||||
</HStack>
|
</>
|
||||||
|
}
|
||||||
<HStack gap={2} vAlign="end">
|
/>
|
||||||
<Text
|
|
||||||
size="2xl"
|
|
||||||
weight="semibold"
|
|
||||||
className="tracking-tight"
|
|
||||||
>
|
|
||||||
<AnimatedNumber value={s.accounts} format={fmt} />
|
|
||||||
</Text>
|
|
||||||
<Text size="xsm" color="secondary">
|
|
||||||
credentials
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<HStack
|
|
||||||
hAlign="between"
|
|
||||||
vAlign="center"
|
|
||||||
width="100%"
|
|
||||||
className="pt-2 border-t border-border"
|
|
||||||
>
|
|
||||||
<Text size="xsm" color="secondary" className="truncate">
|
|
||||||
Across {s.companies}{' '}
|
|
||||||
{s.companies === 1 ? 'merchant' : 'merchants'}
|
|
||||||
</Text>
|
|
||||||
<Token size="sm" label={`${avgUsers} users / tenant`} />
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
</Card>
|
|
||||||
</HoverLift>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</AsyncBoundary>
|
</AsyncBoundary>
|
||||||
|
|
||||||
|
<EstateKpis columns={columns} />
|
||||||
|
|
||||||
<Grid columns={bp === 'mobile' || bp === 'tablet' ? 1 : 2} gap={4}>
|
<Grid columns={bp === 'mobile' || bp === 'tablet' ? 1 : 2} gap={4}>
|
||||||
{/* Left Panel: Merchant Setup Status */}
|
{/* Left Panel: Merchant Setup Status */}
|
||||||
<Card padding={5} elevation="low">
|
<Card padding={5} elevation="low">
|
||||||
@@ -388,7 +248,7 @@ export function PlatformOverview() {
|
|||||||
<HStack
|
<HStack
|
||||||
hAlign="center"
|
hAlign="center"
|
||||||
vAlign="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" />
|
<Icon icon={ICONS.roles} size="md" />
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -595,7 +455,7 @@ export function PlatformOverview() {
|
|||||||
<Icon
|
<Icon
|
||||||
icon={ICONS.stores}
|
icon={ICONS.stores}
|
||||||
size="xsm"
|
size="xsm"
|
||||||
color="secondary"
|
className={ACCENT.warm.ink}
|
||||||
/>
|
/>
|
||||||
<Text size="xsm" color="secondary">
|
<Text size="xsm" color="secondary">
|
||||||
{c.sites} {c.sites === 1 ? 'shop' : 'shops'}
|
{c.sites} {c.sites === 1 ? 'shop' : 'shops'}
|
||||||
@@ -608,7 +468,7 @@ export function PlatformOverview() {
|
|||||||
<Icon
|
<Icon
|
||||||
icon={ICONS.staff}
|
icon={ICONS.staff}
|
||||||
size="xsm"
|
size="xsm"
|
||||||
color="secondary"
|
className={ACCENT.cool.ink}
|
||||||
/>
|
/>
|
||||||
<Text size="xsm" color="secondary">
|
<Text size="xsm" color="secondary">
|
||||||
{c.users}{' '}
|
{c.users}{' '}
|
||||||
@@ -625,11 +485,7 @@ export function PlatformOverview() {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<HStack
|
<HStack gap={2} vAlign="center" className="shrink-0">
|
||||||
gap={2}
|
|
||||||
vAlign="center"
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
<StatusDot
|
<StatusDot
|
||||||
variant={c.isActive ? 'success' : 'error'}
|
variant={c.isActive ? 'success' : 'error'}
|
||||||
label={c.isActive ? 'Active' : 'Suspended'}
|
label={c.isActive ? 'Active' : 'Suspended'}
|
||||||
@@ -673,6 +529,8 @@ interface CoverageItem {
|
|||||||
capability: AdminCapability;
|
capability: AdminCapability;
|
||||||
description: string;
|
description: string;
|
||||||
icon: typeof ICONS.companies;
|
icon: typeof ICONS.companies;
|
||||||
|
/** warm = what a merchant runs; cool = analytics and AI. */
|
||||||
|
accent: BrandAccent;
|
||||||
href?: string;
|
href?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,6 +541,7 @@ const COVERAGE: CoverageItem[] = [
|
|||||||
description:
|
description:
|
||||||
'Tenant isolation, company lifecycle, and operator credential provisioning.',
|
'Tenant isolation, company lifecycle, and operator credential provisioning.',
|
||||||
icon: ICONS.companies,
|
icon: ICONS.companies,
|
||||||
|
accent: 'warm',
|
||||||
href: adminHref.merchants,
|
href: adminHref.merchants,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -691,6 +550,7 @@ const COVERAGE: CoverageItem[] = [
|
|||||||
description:
|
description:
|
||||||
'Storefront network topology, physical address mapping, and device linkages.',
|
'Storefront network topology, physical address mapping, and device linkages.',
|
||||||
icon: ICONS.stores,
|
icon: ICONS.stores,
|
||||||
|
accent: 'warm',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Footfall',
|
label: 'Footfall',
|
||||||
@@ -698,6 +558,7 @@ const COVERAGE: CoverageItem[] = [
|
|||||||
description:
|
description:
|
||||||
'Walk-in visitor velocity, camera detection streams, and dwell-time analytics.',
|
'Walk-in visitor velocity, camera detection streams, and dwell-time analytics.',
|
||||||
icon: ICONS.visitors,
|
icon: ICONS.visitors,
|
||||||
|
accent: 'cool',
|
||||||
href: '/admin/footfall',
|
href: '/admin/footfall',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -706,6 +567,7 @@ const COVERAGE: CoverageItem[] = [
|
|||||||
description:
|
description:
|
||||||
'Point-of-sale transactions, daily revenue tracking, and basket size metrics.',
|
'Point-of-sale transactions, daily revenue tracking, and basket size metrics.',
|
||||||
icon: ICONS.commerce,
|
icon: ICONS.commerce,
|
||||||
|
accent: 'warm',
|
||||||
href: '/admin/commerce',
|
href: '/admin/commerce',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -714,9 +576,67 @@ const COVERAGE: CoverageItem[] = [
|
|||||||
description:
|
description:
|
||||||
'Cross-merchant operational copilot, anomaly diagnosis, and assistant rail.',
|
'Cross-merchant operational copilot, anomaly diagnosis, and assistant rail.',
|
||||||
icon: ICONS.ai,
|
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() {
|
function MonitoringCoverage() {
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
const liveCount = COVERAGE.filter(
|
const liveCount = COVERAGE.filter(
|
||||||
@@ -753,69 +673,70 @@ function MonitoringCoverage() {
|
|||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<Grid columns={bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 3} gap={3}>
|
<Grid columns={bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 3} gap={3}>
|
||||||
{COVERAGE.map(({label, capability, description, icon, href}) => {
|
{COVERAGE.map(
|
||||||
const live = ADMIN_CAPABILITIES[capability];
|
({label, capability, description, icon, accent, href}) => {
|
||||||
return (
|
const live = ADMIN_CAPABILITIES[capability];
|
||||||
<Card
|
return (
|
||||||
key={capability}
|
<Card
|
||||||
padding={4}
|
key={capability}
|
||||||
variant="muted"
|
padding={4}
|
||||||
className="h-full border border-border"
|
variant="muted"
|
||||||
>
|
className="h-full border border-border"
|
||||||
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
>
|
||||||
<VStack gap={2} width="100%">
|
<VStack gap={3} width="100%" height="100%" vAlign="between">
|
||||||
<HStack hAlign="between" vAlign="center" width="100%">
|
<VStack gap={2} width="100%">
|
||||||
<HStack
|
<HStack hAlign="between" vAlign="center" width="100%">
|
||||||
hAlign="center"
|
|
||||||
vAlign="center"
|
|
||||||
className="size-8 rounded-lg bg-surface text-primary border border-border shrink-0 shadow-sm"
|
|
||||||
>
|
|
||||||
<Icon icon={icon} size="sm" />
|
|
||||||
</HStack>
|
|
||||||
{live ? (
|
|
||||||
<HStack
|
<HStack
|
||||||
gap={1.5}
|
hAlign="center"
|
||||||
vAlign="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" />
|
<Icon icon={icon} size="sm" />
|
||||||
<Text size="xsm" weight="medium">
|
|
||||||
Live
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
) : (
|
{live ? (
|
||||||
<Token
|
<HStack
|
||||||
size="sm"
|
gap={1.5}
|
||||||
label="Backend integration required"
|
vAlign="center"
|
||||||
/>
|
className="px-2 py-0.5 rounded-full bg-surface border border-border"
|
||||||
)}
|
>
|
||||||
</HStack>
|
<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%">
|
<VStack gap={0.5} width="100%">
|
||||||
<Text weight="semibold">{label}</Text>
|
<Text weight="semibold">{label}</Text>
|
||||||
<Text size="xsm" color="secondary">
|
<Text size="xsm" color="secondary">
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
|
</VStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
</VStack>
|
|
||||||
|
|
||||||
{href && live ? (
|
{href && live ? (
|
||||||
<HStack
|
<HStack
|
||||||
width="100%"
|
width="100%"
|
||||||
className="pt-2 border-t border-border"
|
className="pt-2 border-t border-border"
|
||||||
>
|
>
|
||||||
<Link href={href} className="text-xs">
|
<Link href={href} className="text-xs">
|
||||||
Open {label.toLowerCase()} →
|
Open {label.toLowerCase()} →
|
||||||
</Link>
|
</Link>
|
||||||
</HStack>
|
</HStack>
|
||||||
) : null}
|
) : null}
|
||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import {useMemo, useState} from 'react';
|
import {useMemo, useState} from 'react';
|
||||||
import {Grid} from '@astryxdesign/core/Grid';
|
import {Grid} from '@astryxdesign/core/Grid';
|
||||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||||
import {Text} from '@astryxdesign/core/Text';
|
|
||||||
import {Selector} from '@astryxdesign/core/Selector';
|
import {Selector} from '@astryxdesign/core/Selector';
|
||||||
import type {SelectorOptionType} from '@astryxdesign/core/Selector';
|
import type {SelectorOptionType} from '@astryxdesign/core/Selector';
|
||||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
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 {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
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 {useAiContext} from '@/features/admin/hooks/useAiContext';
|
||||||
import {storeRepository} from '@/features/admin/repositories/monitoringRepository';
|
import {storeRepository} from '@/features/admin/repositories/monitoringRepository';
|
||||||
import type {Company} from '@/features/admin/types/company';
|
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 {AdminPageHeader} from './common/AdminPageHeader';
|
||||||
|
import {AdminKpiCard, KpiCaption} from './common/AdminKpiCard';
|
||||||
import {FootfallDashboard} from './analytics/FootfallDashboard';
|
import {FootfallDashboard} from './analytics/FootfallDashboard';
|
||||||
import {CommerceDashboard} from './analytics/CommerceDashboard';
|
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.
|
* What the merchant list can say about the current scope — nothing more.
|
||||||
* No footfall, sales or transaction number appears here: none is returned.
|
* No footfall, sales or transaction number appears here: none is returned.
|
||||||
@@ -276,61 +276,122 @@ function ScopeMetrics({
|
|||||||
merchant: Company | null;
|
merchant: Company | null;
|
||||||
}) {
|
}) {
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
const caption = (t: string) => (
|
// Same budget as the Overview row, so a card is the same width everywhere.
|
||||||
<Text size="xsm" color="secondary">
|
const columns = bp === 'mobile' ? 1 : 3;
|
||||||
{t}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Keyed per scope: a different merchant is a different entity, not a value
|
// 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
|
// that moved, so the cards remount and paint the new numbers directly rather
|
||||||
// than springing through counts that belong to neither merchant.
|
// than springing through counts that belong to neither merchant.
|
||||||
if (merchant) {
|
if (merchant) {
|
||||||
return (
|
return (
|
||||||
<Grid key={merchant.id} columns={bp === 'mobile' ? 1 : 2} gap={4}>
|
<Grid key={merchant.id} columns={columns} gap={4}>
|
||||||
<MetricCard
|
<AdminKpiCard
|
||||||
label="Shops"
|
label="Stores"
|
||||||
value={merchant.sites}
|
value={merchant.sites}
|
||||||
format={fmt}
|
unit={merchant.sites === 1 ? 'location' : 'locations'}
|
||||||
icon={ICONS.stores}
|
icon={ICONS.stores}
|
||||||
footer={caption(`Registered to ${merchant.name}`)}
|
tone="warm"
|
||||||
|
footer={<KpiCaption>Registered to {merchant.name}</KpiCaption>}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<AdminKpiCard
|
||||||
label="Merchant accounts"
|
label="Status"
|
||||||
value={merchant.users}
|
value={merchant.isActive ? 'Active' : 'Suspended'}
|
||||||
format={fmt}
|
icon={ICONS.present}
|
||||||
icon={ICONS.staff}
|
tone={merchant.isActive ? 'success' : 'error'}
|
||||||
footer={caption(merchant.isActive ? 'Active merchant' : 'Suspended merchant')}
|
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>
|
</Grid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = merchants.filter((m) => m.isActive);
|
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 (
|
return (
|
||||||
<Grid key="all" columns={bp === 'mobile' ? 1 : 3} gap={4}>
|
<Grid key="all" columns={columns} gap={4}>
|
||||||
<MetricCard
|
<AdminKpiCard
|
||||||
label="Active merchants"
|
label="Active merchants"
|
||||||
value={active.length}
|
value={active.length}
|
||||||
format={fmt}
|
unit="active"
|
||||||
icon={ICONS.companies}
|
icon={ICONS.present}
|
||||||
footer={caption(`${fmt(merchants.length)} on the platform`)}
|
tone="success"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<KpiCaption>{merchants.length} on the platform</KpiCaption>
|
||||||
|
<Token
|
||||||
|
size="sm"
|
||||||
|
label={`${merchants.length - active.length} suspended`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<AdminKpiCard
|
||||||
label="Shops"
|
label="Stores"
|
||||||
value={active.reduce((n, m) => n + m.sites, 0)}
|
value={shops}
|
||||||
format={fmt}
|
unit="locations"
|
||||||
icon={ICONS.stores}
|
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
|
<AdminKpiCard
|
||||||
label="Merchants without a shop"
|
label="Merchants without a store"
|
||||||
value={active.filter((m) => m.sites === 0).length}
|
value={withoutShop}
|
||||||
format={fmt}
|
unit="pending"
|
||||||
icon={ICONS.stores}
|
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>
|
</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';
|
'use client';
|
||||||
|
|
||||||
|
import {useMemo, useState} from 'react';
|
||||||
import {Grid} from '@astryxdesign/core/Grid';
|
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 {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 {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
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 {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||||
|
import {
|
||||||
|
formatCount,
|
||||||
|
formatDayLabel,
|
||||||
|
formatInr,
|
||||||
|
formatInrCompact,
|
||||||
|
} from '@/shared/utils/format';
|
||||||
import {
|
import {
|
||||||
useAlerts,
|
useAlerts,
|
||||||
useCompany,
|
useCompany,
|
||||||
|
useCompanyStores,
|
||||||
useEvents,
|
useEvents,
|
||||||
useStore,
|
useStore,
|
||||||
useStoreCameras,
|
useStoreCameras,
|
||||||
} from '@/features/admin/hooks/useMonitoring';
|
} 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 {useAiContext} from '@/features/admin/hooks/useAiContext';
|
||||||
import type {Company} from '@/features/admin/types/company';
|
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 {AdminPageHeader} from './common/AdminPageHeader';
|
||||||
import {AdminSection} from './common/AdminSection';
|
|
||||||
import {CompanyNotFound} from './CompanyDetail';
|
import {CompanyNotFound} from './CompanyDetail';
|
||||||
import {
|
import {AlertTable, EventTable, adminHref} from './MonitoringTables';
|
||||||
AlertTable,
|
import {CameraGrid} from './ShopCards';
|
||||||
CameraTable,
|
import {AnalyticsPanel, StatPanel} from './analytics/AnalyticsPanel';
|
||||||
EventTable,
|
import {FootfallTable} from './analytics/FootfallDashboard';
|
||||||
adminHref,
|
|
||||||
} from './MonitoringTables';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* The route is /admin/merchants/{merchantId}/shops/{shopId}, and every
|
||||||
* request this page makes nests the same way. The company is resolved first
|
* request this page makes nests the same way — shop detail, cameras, events,
|
||||||
* from the real list — a store under a company that does not exist is a
|
* alerts, and the footfall / commerce reads (merchant + shop as filters). The
|
||||||
* not-found page, not a request. Whether the store really belongs to that
|
* merchant is resolved first from the real list; whether the shop belongs to
|
||||||
* company is the PLATFORM's check (docs/ADMIN-MONITORING-API.md §Ownership);
|
* it is the PLATFORM's check (docs/ADMIN-MONITORING-API.md §2.2).
|
||||||
* the URL is a claim, and this page never treats it as proof.
|
|
||||||
*
|
*
|
||||||
* Today no store-level endpoint exists, so the page is reachable (the route is
|
* ── Only what the platform returns ───────────────────────────────────────
|
||||||
* real and the breadcrumbs work) and every section says what is missing.
|
* 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({
|
export function StoreDetail({
|
||||||
companyId,
|
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}) {
|
function StoreBody({company, storeId}: {company: Company; storeId: string}) {
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
|
const twoUp = bp === 'desktop' || bp === 'ultrawide';
|
||||||
|
|
||||||
const store = useStore(company.id, storeId);
|
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 cameras = useStoreCameras(company.id, storeId);
|
||||||
const events = useEvents(company.id, storeId);
|
const events = useEvents(company.id, storeId);
|
||||||
const alerts = useAlerts(company.id, storeId);
|
const alerts = useAlerts(company.id, storeId);
|
||||||
|
|
||||||
// The store's NAME only exists once the store endpoint answers. Until then
|
const [now] = useState(() => Date.now());
|
||||||
// the page says "Store" rather than printing a raw id as if it were a name.
|
const filters: AnalyticsFilters = useMemo(
|
||||||
const name =
|
() => ({
|
||||||
|
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.isAvailable && store.resource.status === 'success'
|
||||||
? store.resource.data.name
|
? store.resource.data
|
||||||
: 'Shop';
|
: 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({
|
useAiContext({
|
||||||
level: 'store',
|
level: 'store',
|
||||||
@@ -88,118 +141,262 @@ function StoreBody({company, storeId}: {company: Company; storeId: string}) {
|
|||||||
storeId,
|
storeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (listLoaded && !fromList && !fromDetail) {
|
||||||
|
return <ShopNotFound company={company} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={6} width="100%">
|
<VStack gap={6} width="100%">
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
title={name}
|
title={name}
|
||||||
subtitle={`A shop of ${company.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={[
|
crumbs={[
|
||||||
{label: 'Merchants', href: adminHref.merchants},
|
{label: 'Merchants', href: adminHref.merchants},
|
||||||
{label: company.name, href: adminHref.company(company.id)},
|
{label: company.name, href: adminHref.shops(company.id)},
|
||||||
{label: name},
|
{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
|
<AnalyticsPanel
|
||||||
title="Shop overview"
|
|
||||||
data={store}
|
|
||||||
pending={{
|
|
||||||
icon: 'stores',
|
|
||||||
title: 'Shop details are not available yet',
|
|
||||||
description:
|
|
||||||
'Status, location and camera counts for this shop need the platform admin shop-detail API.',
|
|
||||||
}}
|
|
||||||
loading={<SkeletonRows count={3} />}
|
|
||||||
>
|
|
||||||
{(s) => (
|
|
||||||
<List>
|
|
||||||
<ListItem label="Timezone" endContent={<Text size="sm">{s.timezone ?? '—'}</Text>} />
|
|
||||||
<ListItem
|
|
||||||
label="Cameras"
|
|
||||||
endContent={<Text size="sm">{s.cameras ?? '—'}</Text>}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
label="Cameras online"
|
|
||||||
endContent={<Text size="sm">{s.camerasOnline ?? '—'}</Text>}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
label="Created"
|
|
||||||
endContent={
|
|
||||||
<Text size="sm">
|
|
||||||
{s.createdAt ? new Date(s.createdAt).toLocaleDateString() : '—'}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</List>
|
|
||||||
)}
|
|
||||||
</AdminSection>
|
|
||||||
|
|
||||||
<AdminSection
|
|
||||||
title="Cameras"
|
title="Cameras"
|
||||||
|
subtitle="Every camera registered to this shop. Open one for its status and activity."
|
||||||
|
icon="camera"
|
||||||
data={cameras}
|
data={cameras}
|
||||||
pending={{
|
variant="table"
|
||||||
icon: 'camera',
|
height={200}
|
||||||
title: 'Camera data unavailable',
|
pendingTitle="Cameras cannot be listed yet"
|
||||||
description:
|
pendingDescription="Needs GET /api/admin/clients/{id}/sites/{siteId}/cameras on the platform."
|
||||||
'Connect the platform admin store-cameras API to list this shop’s cameras with their status and last-seen time.',
|
emptyTitle="No cameras"
|
||||||
}}
|
emptyDescription="No camera is registered to this shop."
|
||||||
loading={<SkeletonRows count={4} />}
|
|
||||||
empty={
|
|
||||||
<EmptyPanel
|
|
||||||
icon="camera"
|
|
||||||
title="No cameras"
|
|
||||||
description="No camera is registered to this shop."
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{(rows) => (
|
{(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}>
|
<Grid columns={twoUp ? 2 : 1} gap={6}>
|
||||||
<AdminSection
|
<StatPanel
|
||||||
title="Active alerts"
|
title="Footfall · last 30 days"
|
||||||
data={alerts}
|
data={footfall.overview}
|
||||||
pending={{
|
placeholders={FOOTFALL_TILES}
|
||||||
icon: 'notifications',
|
toStats={(o) => [
|
||||||
title: 'No alerts available',
|
{...FOOTFALL_TILES[0], value: formatCount(o.totalFootfall), caption: 'Walk-ins'},
|
||||||
description:
|
{...FOOTFALL_TILES[1], value: formatCount(Math.round(o.averageDaily)), caption: 'Per day'},
|
||||||
'Alert monitoring is not currently available for Platform Admin.',
|
{
|
||||||
}}
|
...FOOTFALL_TILES[2],
|
||||||
loading={<SkeletonRows count={3} />}
|
value: o.peakDay ? formatCount(o.peakDay.footfall) : undefined,
|
||||||
empty={
|
caption: o.peakDay ? formatDayLabel(o.peakDay.date) : undefined,
|
||||||
<EmptyPanel
|
},
|
||||||
icon="notifications"
|
]}
|
||||||
title="No open alerts"
|
/>
|
||||||
description="Nothing needs attention at this shop."
|
<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} />}
|
{(rows) => (
|
||||||
</AdminSection>
|
<AreaChartView
|
||||||
|
data={rows}
|
||||||
<AdminSection
|
xKey="date"
|
||||||
title="Recent events"
|
series={[{key: 'footfall', label: 'Footfall', format: formatCount}]}
|
||||||
data={events}
|
xFormat={formatDayLabel}
|
||||||
pending={{
|
yFormat={formatCount}
|
||||||
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."
|
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
|
</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} />}
|
{(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>
|
</Grid>
|
||||||
</VStack>
|
</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,
|
icon,
|
||||||
data,
|
data,
|
||||||
pendingTitle,
|
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,
|
height = 260,
|
||||||
variant = 'chart',
|
variant = 'chart',
|
||||||
actions,
|
actions,
|
||||||
@@ -57,6 +60,9 @@ export function AnalyticsPanel<T>({
|
|||||||
data: AdminData<T>;
|
data: AdminData<T>;
|
||||||
/** e.g. "No area footfall data yet". */
|
/** e.g. "No area footfall data yet". */
|
||||||
pendingTitle: string;
|
pendingTitle: string;
|
||||||
|
pendingDescription?: string;
|
||||||
|
emptyTitle?: string;
|
||||||
|
emptyDescription?: string;
|
||||||
height?: number;
|
height?: number;
|
||||||
variant?: 'chart' | 'table';
|
variant?: 'chart' | 'table';
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
@@ -88,8 +94,8 @@ export function AnalyticsPanel<T>({
|
|||||||
<ChartEmpty
|
<ChartEmpty
|
||||||
icon={icon}
|
icon={icon}
|
||||||
height={height}
|
height={height}
|
||||||
title="No data for the selected filters"
|
title={emptyTitle}
|
||||||
description="Try a wider date range, or a different merchant, area or shop."
|
description={emptyDescription}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -100,7 +106,7 @@ export function AnalyticsPanel<T>({
|
|||||||
icon={icon}
|
icon={icon}
|
||||||
height={height}
|
height={height}
|
||||||
title={pendingTitle}
|
title={pendingTitle}
|
||||||
description="The chart is ready. It fills in as soon as the platform exposes this admin endpoint."
|
description={pendingDescription}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ const FOOTFALL_SORTS: SortOption<FootfallRow>[] = [
|
|||||||
{value: 'shop', label: 'Shop A–Z', compare: (a, b) => a.shopName.localeCompare(b.shopName)},
|
{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) => ({
|
const data: FootfallRow[] = rows.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
rowId: `${r.date}|${r.shopId}`,
|
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 {Breadcrumbs, BreadcrumbItem} from '@astryxdesign/core/Breadcrumbs';
|
||||||
import { HStack, VStack } from "@astryxdesign/core/Layout";
|
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||||
import { Text } from "@astryxdesign/core/Text";
|
import {Text} from '@astryxdesign/core/Text';
|
||||||
import { Button } from "@astryxdesign/core/Button";
|
import {Button} from '@astryxdesign/core/Button';
|
||||||
import { Icon } from "@astryxdesign/core/Icon";
|
import {Icon} from '@astryxdesign/core/Icon';
|
||||||
|
|
||||||
export interface Crumb {
|
export interface Crumb {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -32,7 +32,7 @@ export function AdminPageHeader({
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
/** One level up. A drill-down page needs a way back that is not the
|
/** 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. */
|
* browser's — the operator may have arrived from a link. */
|
||||||
back?: { label: string; href: string };
|
back?: {label: string; href: string};
|
||||||
crumbs?: Crumb[];
|
crumbs?: Crumb[];
|
||||||
/** A row under the title — status, slug, dates. */
|
/** A row under the title — status, slug, dates. */
|
||||||
meta?: React.ReactNode;
|
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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -7,13 +7,16 @@ import type {IconKey} from '@/shared/utils/icons';
|
|||||||
*
|
*
|
||||||
* companies → company → stores → store → cameras → camera → events / alerts
|
* companies → company → stores → store → cameras → camera → events / alerts
|
||||||
*
|
*
|
||||||
* — but the platform's admin surface (`/api/admin/*`) currently stops at the
|
* — and the platform's admin surface (`/api/admin/*`) answers down to the
|
||||||
* first rung. Every tenant endpoint below it (`/api/sites`, cameras, visits)
|
* camera, plus the platform totals. Events, alerts and everything else below
|
||||||
* takes its company from the signed-in account, and a platform operator's
|
* are not served yet. Tenant endpoints (`/api/sites`, cameras, visits) take
|
||||||
* account has none. There is no way to ask "Company B's stores" without an
|
* their company from the signed-in account, and a platform operator's account
|
||||||
* explicit admin endpoint, and this console will not fake one: no reusing a
|
* has none, so this console will not fake a level: no reusing a tenant route
|
||||||
* tenant route with a swapped id, no listing every store under every company,
|
* with a swapped id, no placeholder rows.
|
||||||
* 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
|
* So each level is a flag. `false` means the repository hands back no
|
||||||
* endpoint, the hook reports `unavailable`, and the section renders the
|
* 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 = {
|
export const ADMIN_CAPABILITIES = {
|
||||||
/** `GET /api/admin/clients` — live. */
|
/** `GET /api/admin/clients` — live. */
|
||||||
companies: true,
|
companies: true,
|
||||||
/** `GET /api/admin/clients/{id}` — not upstream yet; the detail page reads
|
/** `GET /api/admin/clients/{id}` — adds the owner to the merchant page.
|
||||||
* the company out of the list instead, which carries the same row. */
|
* The rest of the page still reads the company out of the list. */
|
||||||
companyDetail: false,
|
companyDetail: true,
|
||||||
/** `GET /api/admin/clients/{id}/sites` */
|
/** `GET /api/admin/clients/{id}/sites` */
|
||||||
companyStores: false,
|
companyStores: true,
|
||||||
/** `GET /api/admin/clients/{id}/sites/{siteId}` */
|
/** `GET /api/admin/clients/{id}/sites/{siteId}` */
|
||||||
storeDetail: false,
|
storeDetail: true,
|
||||||
/** `GET /api/admin/clients/{id}/sites/{siteId}/cameras` */
|
/** `GET /api/admin/clients/{id}/sites/{siteId}/cameras` — read-only. */
|
||||||
storeCameras: false,
|
storeCameras: true,
|
||||||
/** `GET /api/admin/clients/{id}/sites/{siteId}/cameras/{cameraId}` */
|
/** `GET /api/admin/clients/{id}/sites/{siteId}/cameras/{cameraId}` */
|
||||||
cameraDetail: false,
|
cameraDetail: true,
|
||||||
/** `GET /api/admin/clients/{id}/sites/{siteId}/events` (optionally ?camera=) */
|
/** `GET /api/admin/clients/{id}/sites/{siteId}/events` (optionally ?camera=) */
|
||||||
events: false,
|
events: false,
|
||||||
/** `GET /api/admin/clients/{id}/sites/{siteId}/alerts` (optionally ?camera=) */
|
/** `GET /api/admin/clients/{id}/sites/{siteId}/alerts` (optionally ?camera=) */
|
||||||
alerts: false,
|
alerts: false,
|
||||||
/** `GET /api/admin/monitoring/summary` — platform-wide camera / alert / event totals. */
|
/** `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 /api/admin/clients/{id}` with name / owner fields. Today the
|
||||||
* PATCH accepts `active` only, so a merchant cannot be renamed. */
|
* PATCH accepts `active` only, so a merchant cannot be renamed. */
|
||||||
merchantEdit: false,
|
merchantEdit: false,
|
||||||
@@ -97,7 +100,7 @@ export interface MerchantArea {
|
|||||||
export const MERCHANT_AREAS: MerchantArea[] = [
|
export const MERCHANT_AREAS: MerchantArea[] = [
|
||||||
{key: 'shops', icon: 'stores', label: 'Shops', capability: 'companyStores', description: 'Every shop or business the merchant runs.'},
|
{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: '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: '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: '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.'},
|
{key: 'sales', icon: 'revenue', label: 'Sales', capability: 'sales', description: 'Sales recorded at the merchant’s shops.'},
|
||||||
|
|||||||
@@ -9,14 +9,17 @@ import {
|
|||||||
alertRepository,
|
alertRepository,
|
||||||
cameraRepository,
|
cameraRepository,
|
||||||
eventRepository,
|
eventRepository,
|
||||||
|
platformMonitoringRepository,
|
||||||
storeRepository,
|
storeRepository,
|
||||||
} from '@/features/admin/repositories/monitoringRepository';
|
} from '@/features/admin/repositories/monitoringRepository';
|
||||||
|
import {companyRepository} from '@/features/admin/repositories/companyRepository';
|
||||||
import type {Company} from '@/features/admin/types/company';
|
import type {Company} from '@/features/admin/types/company';
|
||||||
import type {
|
import type {
|
||||||
AdminAlert,
|
AdminAlert,
|
||||||
AdminCamera,
|
AdminCamera,
|
||||||
AdminEvent,
|
AdminEvent,
|
||||||
AdminStore,
|
AdminStore,
|
||||||
|
PlatformMonitoring,
|
||||||
} from '@/features/admin/types/monitoring';
|
} 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
|
// Called unconditionally — `null` holds the request, which is what keeps the
|
||||||
// hook order stable while a capability is off.
|
// hook order stable while a capability is off.
|
||||||
const resource = useResource(endpoint);
|
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[]> {
|
export function useCompanyStores(companyId: string): AdminData<AdminStore[]> {
|
||||||
@@ -83,10 +93,11 @@ export function useAlerts(
|
|||||||
/**
|
/**
|
||||||
* One company, read out of the list.
|
* One company, read out of the list.
|
||||||
*
|
*
|
||||||
* The platform has no `GET /api/admin/clients/{id}`, but the list row IS the
|
* The list row carries everything the drill-down pages need, and reusing it
|
||||||
* whole company record — there is nothing more a detail endpoint could add
|
* means one fetch and the same row the table showed. The one thing only
|
||||||
* today. So the detail page reuses the list request (one fetch, and the same
|
* `GET /api/admin/clients/{id}` adds is the owner — see {@link useCompanyOwner},
|
||||||
* row the table showed) rather than inventing a route.
|
* 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
|
* `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
|
* 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};
|
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. */
|
/** Platform totals, computed from the company list and nothing else. */
|
||||||
export interface PlatformSummary {
|
export interface PlatformSummary {
|
||||||
companies: number;
|
companies: number;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
postJson,
|
postJson,
|
||||||
type Endpoint,
|
type Endpoint,
|
||||||
} from '@/shared/services/httpClient';
|
} from '@/shared/services/httpClient';
|
||||||
|
import {ADMIN_CAPABILITIES} from '@/features/admin/config/capabilities';
|
||||||
import type {
|
import type {
|
||||||
Company,
|
Company,
|
||||||
CompanyActiveResult,
|
CompanyActiveResult,
|
||||||
@@ -26,6 +27,12 @@ import type {
|
|||||||
export const companyRepository = {
|
export const companyRepository = {
|
||||||
list: (): Endpoint<Company[]> => ({path: '/api/admin/clients', params: {}}),
|
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
|
* Creates the company and its owner together, and hands back the owner's
|
||||||
* generated password ONCE.
|
* generated password ONCE.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
AdminCamera,
|
AdminCamera,
|
||||||
AdminEvent,
|
AdminEvent,
|
||||||
AdminStore,
|
AdminStore,
|
||||||
|
PlatformMonitoring,
|
||||||
} from '@/features/admin/types/monitoring';
|
} from '@/features/admin/types/monitoring';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,3 +102,10 @@ export const alertRepository = {
|
|||||||
}
|
}
|
||||||
: null,
|
: 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';
|
import type {Company} from '@/features/admin/types/company';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,3 +25,12 @@ export function toCompany(c: ApiClientRow): Company {
|
|||||||
createdAt: c.created_at,
|
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.
|
* five components that would each rename them slightly differently.
|
||||||
*
|
*
|
||||||
* Nothing is invented here. Every field comes back from
|
* Nothing is invented here. Every field comes back from
|
||||||
* `GET /api/admin/clients`; the admin surface upstream is company
|
* `GET /api/admin/clients`, except the owner, which only the detail read
|
||||||
* administration and nothing else, so there is no owner name, no contact, no
|
* (`GET /api/admin/clients/{id}`) carries — so it is optional and absent on
|
||||||
* revenue and no health. A column this list cannot fill is a column that would
|
* list rows. There is no revenue and no health: a column the platform cannot
|
||||||
* have to be faked.
|
* fill is a column that would have to be faked.
|
||||||
*/
|
*/
|
||||||
export interface Company {
|
export interface Company {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +27,9 @@ export interface Company {
|
|||||||
users: number;
|
users: number;
|
||||||
/** ISO-8601, from the server's clock. */
|
/** ISO-8601, from the server's clock. */
|
||||||
createdAt: string;
|
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. */
|
/** 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.
|
* What the platform console will render below the company level.
|
||||||
*
|
*
|
||||||
* ── None of these endpoints exist yet ────────────────────────────────────
|
* Shops, cameras and the platform totals are served by the platform's
|
||||||
* These are the shapes the console is written against, so that wiring a real
|
* `/api/admin/*` drill-down; events and alerts are not yet (see
|
||||||
* endpoint is a mapper and a flag rather than a redesign (see
|
|
||||||
* config/capabilities.ts). They are deliberately the SAME fields the tenant
|
* config/capabilities.ts). They are deliberately the SAME fields the tenant
|
||||||
* console already reads for its own stores and cameras (features/stores/types)
|
* 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
|
* — 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.
|
* 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 {
|
export interface AdminStore {
|
||||||
id: string;
|
id: string;
|
||||||
/** Immutable upstream, like a company slug. */
|
/** Immutable upstream, like a merchant slug. */
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
timezone?: string;
|
timezone: string;
|
||||||
/** The platform's own store liveness (`online` on SiteHealth). */
|
/** The shop PC's liveness, from its heartbeat. */
|
||||||
isOnline?: boolean;
|
isOnline: boolean;
|
||||||
/** Cameras registered to the store. */
|
lastHeartbeatAt?: string;
|
||||||
cameras?: number;
|
lastEventAt?: string;
|
||||||
/** Cameras that reported within the platform's liveness window. */
|
agentVersion?: string;
|
||||||
camerasOnline?: number;
|
/** Cameras registered to the shop. */
|
||||||
createdAt?: string;
|
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';
|
export type AdminCameraStatus = 'online' | 'offline' | 'unknown';
|
||||||
|
|
||||||
|
/** The platform's `Camera`, minus connection details an admin view never shows. */
|
||||||
export interface AdminCamera {
|
export interface AdminCamera {
|
||||||
id: string;
|
id: string;
|
||||||
|
siteId: string;
|
||||||
|
/** What the engine knows it by — stable for the camera's life. */
|
||||||
|
cameraId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
isEnabled: boolean;
|
||||||
status: AdminCameraStatus;
|
status: AdminCameraStatus;
|
||||||
/** ISO-8601, from the server's clock. */
|
/** ISO-8601, from the server's clock. */
|
||||||
lastSeenAt?: string;
|
lastSeenAt?: string;
|
||||||
/** Where in the store it points, if the merchant named it. */
|
snapshotAt?: string;
|
||||||
zone?: string;
|
|
||||||
/** Whether the platform can serve a live stream for it right now. */
|
|
||||||
hasLiveStream?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminSeverity = 'critical' | 'warning' | 'info';
|
export type AdminSeverity = 'critical' | 'warning' | 'info';
|
||||||
@@ -65,3 +81,15 @@ export interface AdminAlert {
|
|||||||
cameraId?: string;
|
cameraId?: string;
|
||||||
cameraName?: 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';
|
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. */
|
/** Code → the field it belongs to and what the user reads. */
|
||||||
const LOGIN_ERRORS: Record<string, LoginError> = {
|
const LOGIN_ERRORS: Record<string, LoginError> = {
|
||||||
email_required: {field: 'email', message: 'Invalid email or password.'},
|
email_required: {field: 'email', message: 'Invalid email or password.'},
|
||||||
@@ -51,6 +55,11 @@ const LOGIN_ERRORS: Record<string, LoginError> = {
|
|||||||
field: 'form',
|
field: 'form',
|
||||||
message: 'Sign-in is unavailable right now. Please contact support.',
|
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
|
* `platform_account` used to live here: correct credentials for a platform
|
||||||
* admin, refused because every surface in this console was tenant-scoped.
|
* 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);
|
const payload = verifySessionToken(store.get(sessionCookieFor(tabId))?.value);
|
||||||
if (!payload) return null;
|
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 {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: payload.sub,
|
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.
|
* 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.
|
* A ref rather than state: it must survive every re-render unchanged.
|
||||||
*/
|
*/
|
||||||
const idempotencyKey = useRef<string | null>(null);
|
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 [lines, setLines] = useState<DraftLine[]>([]);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@@ -147,10 +150,12 @@ export function SaleEntryDialog({
|
|||||||
* fresh key.
|
* fresh key.
|
||||||
*/
|
*/
|
||||||
idempotencyKey.current ??= newIdempotencyKey();
|
idempotencyKey.current ??= newIdempotencyKey();
|
||||||
|
clientCreatedAt.current ??= new Date().toISOString();
|
||||||
|
|
||||||
// Through httpClient, so the request carries this tab's session header.
|
// Through httpClient, so the request carries this tab's session header.
|
||||||
const res = await saleRepository.create({
|
const res = await saleRepository.create({
|
||||||
idempotencyKey: idempotencyKey.current,
|
idempotencyKey: idempotencyKey.current,
|
||||||
|
clientCreatedAt: clientCreatedAt.current,
|
||||||
// Taken from the floor context, never typed. §4: do not ask for a
|
// 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
|
// visit id the flow already knows. Staff identity is not sent at
|
||||||
// all — the platform derives it from the session.
|
// 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 {TextInput} from '@astryxdesign/core/TextInput';
|
||||||
import {useToast} from '@astryxdesign/core/Toast';
|
import {useToast} from '@astryxdesign/core/Toast';
|
||||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
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> {
|
export interface LoginAuditItem extends Record<string, unknown> {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -190,11 +180,6 @@ export function SecurityManager() {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</StaticPanel>
|
</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.">
|
<StaticPanel title="Login Audit Trail" subtitle="Recent account authentication events and security attempts.">
|
||||||
<ResponsiveTable
|
<ResponsiveTable
|
||||||
columns={auditColumns}
|
columns={auditColumns}
|
||||||
|
|||||||
23
src/proxy.ts
23
src/proxy.ts
@@ -189,10 +189,22 @@ export function proxy(request: NextRequest): NextResponse {
|
|||||||
? pointer
|
? pointer
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const session = tabId
|
const verified = tabId
|
||||||
? verifySessionToken(request.cookies.get(sessionCookieFor(tabId))?.value)
|
? verifySessionToken(request.cookies.get(sessionCookieFor(tabId))?.value)
|
||||||
: null;
|
: 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.
|
* 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
|
* /login the terminal state of every failed-auth path rather than one more
|
||||||
* hop in it.
|
* 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)) {
|
if (PUBLIC_PATHS.has(pathname)) {
|
||||||
return NextResponse.next();
|
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
|
// 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
|
// than dumping every user on the dashboard. Path only — never an absolute
|
||||||
// URL, which would make this an open redirect.
|
// 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}`);
|
target.searchParams.set('next', `${pathname}${search}`);
|
||||||
}
|
}
|
||||||
return NextResponse.redirect(target);
|
return NextResponse.redirect(target);
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
import {upstreamRequest} from './apiClient';
|
import {upstreamRequest} from './apiClient';
|
||||||
import type {
|
import type {
|
||||||
|
ApiAdminCamera,
|
||||||
ApiClientActiveResult,
|
ApiClientActiveResult,
|
||||||
|
ApiClientDetail,
|
||||||
ApiClientDeleteResult,
|
ApiClientDeleteResult,
|
||||||
ApiClientRow,
|
ApiClientRow,
|
||||||
ApiNewClientInput,
|
ApiNewClientInput,
|
||||||
ApiNewClientResult,
|
ApiNewClientResult,
|
||||||
ApiOwnerPasswordResult,
|
ApiOwnerPasswordResult,
|
||||||
|
ApiPlatformMonitoring,
|
||||||
|
ApiSite,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,10 +32,13 @@ import type {
|
|||||||
* draw. If that answer were ever wrong, these calls would return 404 and the
|
* 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.
|
* screen would be empty — not populated with somebody else's tenants.
|
||||||
*
|
*
|
||||||
* Scope is exactly the five operations the platform documents as the admin
|
* Scope is exactly what the platform serves under `/api/admin/*`: the five
|
||||||
* surface. There is no endpoint to look inside a company, and none is invented
|
* company operations, and the read-only drill-down merchant → shop → camera
|
||||||
* here: seeing a tenant's data means signing in as that tenant's owner.
|
* 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 = {
|
export const adminApi = {
|
||||||
listClients: (accessToken: string) =>
|
listClients: (accessToken: string) =>
|
||||||
upstreamRequest<ApiClientRow[]>({path: '/api/admin/clients', accessToken}),
|
upstreamRequest<ApiClientRow[]>({path: '/api/admin/clients', accessToken}),
|
||||||
@@ -97,4 +104,52 @@ export const adminApi = {
|
|||||||
body: {confirm},
|
body: {confirm},
|
||||||
accessToken,
|
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 {
|
get isUnsupported(): boolean {
|
||||||
return this.status === 501;
|
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 type QueryValue = string | number | boolean | undefined | null;
|
||||||
|
|
||||||
export interface UpstreamRequest {
|
export interface UpstreamRequest {
|
||||||
|
|||||||
@@ -634,6 +634,46 @@ export interface ApiClientRow {
|
|||||||
created_at: string;
|
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
|
* 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
|
* with no owner is a tenant nobody can sign into and it looks normal in every
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
'use client';
|
'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 {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';
|
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}) {
|
export function ProtectedLayout({children}: {children: React.ReactNode}) {
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<WorkspaceShell>{children}</WorkspaceShell>
|
<TenantsOnly>
|
||||||
|
<WorkspaceShell>{children}</WorkspaceShell>
|
||||||
|
</TenantsOnly>
|
||||||
</AuthGuard>
|
</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',
|
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
|
// A 404 is also what another tenant's data returns, always — so it must
|
||||||
// never be phrased as "deleted".
|
// never be phrased as "deleted".
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ export type ApiErrorCode =
|
|||||||
| 'internal'
|
| 'internal'
|
||||||
| 'not_found'
|
| 'not_found'
|
||||||
| 'bad_request'
|
| '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 {
|
export interface ApiFailure {
|
||||||
error: {code: ApiErrorCode; message: string};
|
error: {code: ApiErrorCode; message: string};
|
||||||
|
|||||||
Reference in New Issue
Block a user