@utopia-studio-design/design-system-cli 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/utopia-ds-mcp.mjs +18 -5
- package/bin/utopia-ds.mjs +16 -3
- package/data/blocks/semantic-pattern-parts/AuthParts.tsx +48 -0
- package/data/blocks/semantic-pattern-parts/ChoiceCards.tsx +7 -0
- package/data/blocks/semantic-pattern-parts/ListFilters.tsx +9 -0
- package/data/blocks/semantic-pattern-parts/OutcomeNotice.tsx +7 -0
- package/data/blocks/semantic-pattern-parts/PatternParts.tsx +7 -0
- package/data/blocks/semantic-pattern-parts/ProductParts.tsx +38 -0
- package/data/blocks/semantic-pattern-parts/ProgressSteps.tsx +6 -0
- package/data/blocks/semantic-pattern-parts/ReviewList.tsx +7 -0
- package/data/blocks/semantic-pattern-parts/ValidatedField.tsx +8 -0
- package/data/blocks/semantic-pattern-parts/WorkflowParts.tsx +339 -0
- package/data/blocks/semantic-pattern-parts/auth-parts.css +27 -0
- package/data/blocks/semantic-pattern-parts/pattern-parts.css +25 -0
- package/data/blocks/semantic-pattern-parts/product-parts.css +25 -0
- package/data/blocks/semantic-pattern-parts/types.ts +3 -0
- package/data/blocks/semantic-pattern-parts/workflow-parts.css +118 -0
- package/data/blocks/ux-pattern-examples/AccessExamples.tsx +46 -0
- package/data/blocks/ux-pattern-examples/AccountExamples.tsx +133 -0
- package/data/blocks/ux-pattern-examples/OnboardingExample.tsx +15 -0
- package/data/blocks/ux-pattern-examples/ProductExamples.tsx +79 -0
- package/data/blocks/ux-pattern-examples/ReviewExample.tsx +14 -0
- package/data/blocks/ux-pattern-examples/SignupExample.tsx +22 -0
- package/data/blocks/ux-pattern-examples/WorkflowExample.tsx +1761 -0
- package/data/blocks/ux-pattern-examples/WorkflowExamples.tsx +190 -0
- package/data/blocks/ux-pattern-examples/copy.ts +6 -0
- package/data/blocks/ux-pattern-examples/ux-examples.css +18 -0
- package/data/blocks/ux-pattern-examples/workflow-copy.ts +76 -0
- package/data/blocks/ux-pattern-examples/workflow-definitions.ts +1291 -0
- package/data/docs/ceramic-release-2026-09-06.md +2 -0
- package/data/docs/create-a-brandbook.md +79 -0
- package/data/docs/dextrum-document-family.md +47 -0
- package/data/docs/guide.md +2 -0
- package/data/docs/presentation-asset-repository.md +5 -0
- package/data/docs/ui-parts-qa.md +172 -0
- package/data/docs/ui-parts.md +105 -0
- package/data/docs/ux-pattern-adaptation-qa.md +38 -0
- package/data/docs/ux-pattern-adaptation.md +41 -0
- package/data/docs/ux-pattern-migration.md +111 -0
- package/data/docs/ux-pattern-release-2026-09-13.md +26 -0
- package/data/docs/ux-source-evidence/account-auth.json +41 -0
- package/data/docs/ux-source-evidence/account-forgot-password.json +36 -0
- package/data/docs/ux-source-evidence/account-invite.json +63 -0
- package/data/docs/ux-source-evidence/account-locked.json +63 -0
- package/data/docs/ux-source-evidence/account-login.json +28 -0
- package/data/docs/ux-source-evidence/account-mfa.json +62 -0
- package/data/docs/ux-source-evidence/account-onboarding-credentials.json +52 -0
- package/data/docs/ux-source-evidence/account-onboarding-legal.json +56 -0
- package/data/docs/ux-source-evidence/account-onboarding-orientation.json +66 -0
- package/data/docs/ux-source-evidence/account-onboarding-profile.json +50 -0
- package/data/docs/ux-source-evidence/account-onboarding.json +69 -0
- package/data/docs/ux-source-evidence/account-reset-password.json +27 -0
- package/data/docs/ux-source-evidence/account-session-expired.json +62 -0
- package/data/docs/ux-source-evidence/account-signup.json +33 -0
- package/data/docs/ux-source-evidence/account-verify-email.json +34 -0
- package/data/docs/ux-source-evidence/account-welcome.json +41 -0
- package/data/docs/ux-source-evidence/admin-analytics.json +54 -0
- package/data/docs/ux-source-evidence/admin-audit.json +31 -0
- package/data/docs/ux-source-evidence/admin-broadcasts-new.json +36 -0
- package/data/docs/ux-source-evidence/admin-broadcasts.json +41 -0
- package/data/docs/ux-source-evidence/admin-closures.json +52 -0
- package/data/docs/ux-source-evidence/admin-devices.json +51 -0
- package/data/docs/ux-source-evidence/admin-escalations.json +35 -0
- package/data/docs/ux-source-evidence/admin-incidents.json +54 -0
- package/data/docs/ux-source-evidence/admin-loading.json +17 -0
- package/data/docs/ux-source-evidence/admin-onboarding-detail.json +80 -0
- package/data/docs/ux-source-evidence/admin-onboarding.json +62 -0
- package/data/docs/ux-source-evidence/admin-payments.json +45 -0
- package/data/docs/ux-source-evidence/admin-providers.json +38 -0
- package/data/docs/ux-source-evidence/admin-settings.json +18 -0
- package/data/docs/ux-source-evidence/admin-subscriptions-detail-allocate.json +42 -0
- package/data/docs/ux-source-evidence/admin-subscriptions-detail.json +48 -0
- package/data/docs/ux-source-evidence/admin-subscriptions-new.json +43 -0
- package/data/docs/ux-source-evidence/admin-subscriptions.json +41 -0
- package/data/docs/ux-source-evidence/admin-transactions.json +41 -0
- package/data/docs/ux-source-evidence/error.json +30 -0
- package/data/docs/ux-source-evidence/loading.json +19 -0
- package/data/docs/ux-source-evidence/marketing-blog.json +48 -0
- package/data/docs/ux-source-evidence/marketing-contact.json +36 -0
- package/data/docs/ux-source-evidence/marketing.json +64 -0
- package/data/docs/ux-source-evidence/not-found.json +28 -0
- package/data/docs/ux-source-evidence/portal-loading.json +17 -0
- package/data/docs/ux-source-evidence/portal-mentor-messages.json +31 -0
- package/data/docs/ux-source-evidence/portal-mentor-profile.json +23 -0
- package/data/docs/ux-source-evidence/portal-mentor-requests-detail.json +54 -0
- package/data/docs/ux-source-evidence/portal-mentor-sessions-detail-assess.json +42 -0
- package/data/docs/ux-source-evidence/portal-mentor-settings.json +18 -0
- package/data/docs/ux-source-evidence/portal-mentor.json +38 -0
- package/data/docs/ux-source-evidence/portal-sessions-detail-live.json +48 -0
- package/data/docs/ux-source-evidence/portal-sponsor-billing.json +28 -0
- package/data/docs/ux-source-evidence/portal-sponsor-profile.json +22 -0
- package/data/docs/ux-source-evidence/portal-sponsor-sessions.json +28 -0
- package/data/docs/ux-source-evidence/portal-sponsor-settings.json +18 -0
- package/data/docs/ux-source-evidence/portal-sponsor-trainees.json +36 -0
- package/data/docs/ux-source-evidence/portal-sponsor.json +52 -0
- package/data/docs/ux-source-evidence/portal-trainee-competency.json +42 -0
- package/data/docs/ux-source-evidence/portal-trainee-credits.json +40 -0
- package/data/docs/ux-source-evidence/portal-trainee-mentors.json +48 -0
- package/data/docs/ux-source-evidence/portal-trainee-messages.json +32 -0
- package/data/docs/ux-source-evidence/portal-trainee-notifications.json +35 -0
- package/data/docs/ux-source-evidence/portal-trainee-profile.json +26 -0
- package/data/docs/ux-source-evidence/portal-trainee-request.json +54 -0
- package/data/docs/ux-source-evidence/portal-trainee-sessions-detail-feedback.json +36 -0
- package/data/docs/ux-source-evidence/portal-trainee-sessions-detail-precheck.json +42 -0
- package/data/docs/ux-source-evidence/portal-trainee-sessions-detail.json +55 -0
- package/data/docs/ux-source-evidence/portal-trainee-sessions.json +36 -0
- package/data/docs/ux-source-evidence/portal-trainee-settings-notifications.json +38 -0
- package/data/docs/ux-source-evidence/portal-trainee-settings.json +18 -0
- package/data/docs/ux-source-evidence/portal-trainee.json +44 -0
- package/data/docs/ux-source-evidence/portal-tv-detail.json +39 -0
- package/data/docs/ux-source-evidence/portal-tv.json +44 -0
- package/data/docs/ux-source-evidence/shared-admin-admin-shell.json +35 -0
- package/data/docs/ux-source-evidence/shared-auth-auth-primitives.json +105 -0
- package/data/docs/ux-source-evidence/shared-auth-auth-shell.json +25 -0
- package/data/docs/ux-source-evidence/shared-auth-onboarding-primitives.json +52 -0
- package/data/docs/ux-source-evidence/shared-auth-onboarding-shell.json +28 -0
- package/data/docs/ux-source-evidence/shared-auth-status-indicator.json +41 -0
- package/data/docs/ux-source-evidence/shared-portal-empty-state.json +24 -0
- package/data/docs/ux-source-evidence/shared-portal-loading-skeleton.json +27 -0
- package/data/docs/ux-source-evidence/shared-portal-notifications-dropdown.json +39 -0
- package/data/docs/ux-source-evidence/shared-portal-portal-shell.json +36 -0
- package/data/docs/ux-source-evidence/shared-portal-profile-form.json +32 -0
- package/data/docs/ux-source-evidence/shared-portal-settings-view.json +81 -0
- package/data/docs/ux-source-evidence/shared-portal-usage-sparkline.json +18 -0
- package/data/docs/ux-source-evidence/shared-portal-user-menu.json +34 -0
- package/data/manifests/blocks.json +230 -21
- package/data/manifests/brandbook-skill.json +33 -0
- package/data/manifests/catalog.json +1 -0
- package/data/manifests/patterns.json +358 -0
- package/data/manifests/theme-dextrum.json +132 -21
- package/data/manifests/themes.json +253 -39
- package/data/manifests/ui-patterns.json +1029 -0
- package/data/manifests/ux-examples.json +2177 -0
- package/data/manifests/ux-patterns.json +1135 -0
- package/data/manifests/ux-source-inventory.json +4669 -0
- package/data/ui-parts/blocks/analytics-dashboard/AnalyticsMetricBlocks.tsx +87 -0
- package/data/ui-parts/blocks/analytics-dashboard/analytics-dashboard.css +92 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/AuthParts.tsx +48 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/ChoiceCards.tsx +7 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/ListFilters.tsx +9 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/OutcomeNotice.tsx +7 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/ProductParts.tsx +38 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/ProgressSteps.tsx +6 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/ReviewList.tsx +7 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/ValidatedField.tsx +8 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/WorkflowParts.tsx +339 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/auth-parts.css +27 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/pattern-parts.css +25 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/product-parts.css +25 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/types.ts +3 -0
- package/data/ui-parts/blocks/semantic-pattern-parts/workflow-parts.css +118 -0
- package/data/ui-parts/blocks/ux-pattern-examples/AccessExamples.tsx +46 -0
- package/data/ui-parts/blocks/ux-pattern-examples/AccountExamples.tsx +133 -0
- package/data/ui-parts/blocks/ux-pattern-examples/ProductExamples.tsx +79 -0
- package/data/ui-parts/blocks/ux-pattern-examples/WorkflowExample.tsx +1761 -0
- package/data/ui-parts/blocks/ux-pattern-examples/WorkflowExamples.tsx +190 -0
- package/data/ui-parts/blocks/ux-pattern-examples/copy.ts +6 -0
- package/data/ui-parts/blocks/ux-pattern-examples/ux-examples.css +18 -0
- package/data/ui-parts/blocks/ux-pattern-examples/workflow-copy.ts +76 -0
- package/data/ui-parts/blocks/ux-pattern-examples/workflow-definitions.ts +1291 -0
- package/data/ux-source-evidence/account-auth.json +41 -0
- package/data/ux-source-evidence/account-forgot-password.json +36 -0
- package/data/ux-source-evidence/account-invite.json +63 -0
- package/data/ux-source-evidence/account-locked.json +63 -0
- package/data/ux-source-evidence/account-login.json +28 -0
- package/data/ux-source-evidence/account-mfa.json +62 -0
- package/data/ux-source-evidence/account-onboarding-credentials.json +52 -0
- package/data/ux-source-evidence/account-onboarding-legal.json +56 -0
- package/data/ux-source-evidence/account-onboarding-orientation.json +66 -0
- package/data/ux-source-evidence/account-onboarding-profile.json +50 -0
- package/data/ux-source-evidence/account-onboarding.json +69 -0
- package/data/ux-source-evidence/account-reset-password.json +27 -0
- package/data/ux-source-evidence/account-session-expired.json +62 -0
- package/data/ux-source-evidence/account-signup.json +33 -0
- package/data/ux-source-evidence/account-verify-email.json +34 -0
- package/data/ux-source-evidence/account-welcome.json +41 -0
- package/data/ux-source-evidence/admin-analytics.json +54 -0
- package/data/ux-source-evidence/admin-audit.json +31 -0
- package/data/ux-source-evidence/admin-broadcasts-new.json +36 -0
- package/data/ux-source-evidence/admin-broadcasts.json +41 -0
- package/data/ux-source-evidence/admin-closures.json +52 -0
- package/data/ux-source-evidence/admin-devices.json +51 -0
- package/data/ux-source-evidence/admin-escalations.json +35 -0
- package/data/ux-source-evidence/admin-incidents.json +54 -0
- package/data/ux-source-evidence/admin-loading.json +17 -0
- package/data/ux-source-evidence/admin-onboarding-detail.json +80 -0
- package/data/ux-source-evidence/admin-onboarding.json +62 -0
- package/data/ux-source-evidence/admin-payments.json +45 -0
- package/data/ux-source-evidence/admin-providers.json +38 -0
- package/data/ux-source-evidence/admin-settings.json +18 -0
- package/data/ux-source-evidence/admin-subscriptions-detail-allocate.json +42 -0
- package/data/ux-source-evidence/admin-subscriptions-detail.json +48 -0
- package/data/ux-source-evidence/admin-subscriptions-new.json +43 -0
- package/data/ux-source-evidence/admin-subscriptions.json +41 -0
- package/data/ux-source-evidence/admin-transactions.json +41 -0
- package/data/ux-source-evidence/error.json +30 -0
- package/data/ux-source-evidence/loading.json +19 -0
- package/data/ux-source-evidence/marketing-blog.json +48 -0
- package/data/ux-source-evidence/marketing-contact.json +36 -0
- package/data/ux-source-evidence/marketing.json +64 -0
- package/data/ux-source-evidence/not-found.json +28 -0
- package/data/ux-source-evidence/portal-loading.json +17 -0
- package/data/ux-source-evidence/portal-mentor-messages.json +31 -0
- package/data/ux-source-evidence/portal-mentor-profile.json +23 -0
- package/data/ux-source-evidence/portal-mentor-requests-detail.json +54 -0
- package/data/ux-source-evidence/portal-mentor-sessions-detail-assess.json +42 -0
- package/data/ux-source-evidence/portal-mentor-settings.json +18 -0
- package/data/ux-source-evidence/portal-mentor.json +38 -0
- package/data/ux-source-evidence/portal-sessions-detail-live.json +48 -0
- package/data/ux-source-evidence/portal-sponsor-billing.json +28 -0
- package/data/ux-source-evidence/portal-sponsor-profile.json +22 -0
- package/data/ux-source-evidence/portal-sponsor-sessions.json +28 -0
- package/data/ux-source-evidence/portal-sponsor-settings.json +18 -0
- package/data/ux-source-evidence/portal-sponsor-trainees.json +36 -0
- package/data/ux-source-evidence/portal-sponsor.json +52 -0
- package/data/ux-source-evidence/portal-trainee-competency.json +42 -0
- package/data/ux-source-evidence/portal-trainee-credits.json +40 -0
- package/data/ux-source-evidence/portal-trainee-mentors.json +48 -0
- package/data/ux-source-evidence/portal-trainee-messages.json +32 -0
- package/data/ux-source-evidence/portal-trainee-notifications.json +35 -0
- package/data/ux-source-evidence/portal-trainee-profile.json +26 -0
- package/data/ux-source-evidence/portal-trainee-request.json +54 -0
- package/data/ux-source-evidence/portal-trainee-sessions-detail-feedback.json +36 -0
- package/data/ux-source-evidence/portal-trainee-sessions-detail-precheck.json +42 -0
- package/data/ux-source-evidence/portal-trainee-sessions-detail.json +55 -0
- package/data/ux-source-evidence/portal-trainee-sessions.json +36 -0
- package/data/ux-source-evidence/portal-trainee-settings-notifications.json +38 -0
- package/data/ux-source-evidence/portal-trainee-settings.json +18 -0
- package/data/ux-source-evidence/portal-trainee.json +44 -0
- package/data/ux-source-evidence/portal-tv-detail.json +39 -0
- package/data/ux-source-evidence/portal-tv.json +44 -0
- package/data/ux-source-evidence/shared-admin-admin-shell.json +35 -0
- package/data/ux-source-evidence/shared-auth-auth-primitives.json +105 -0
- package/data/ux-source-evidence/shared-auth-auth-shell.json +25 -0
- package/data/ux-source-evidence/shared-auth-onboarding-primitives.json +52 -0
- package/data/ux-source-evidence/shared-auth-onboarding-shell.json +28 -0
- package/data/ux-source-evidence/shared-auth-status-indicator.json +41 -0
- package/data/ux-source-evidence/shared-portal-empty-state.json +24 -0
- package/data/ux-source-evidence/shared-portal-loading-skeleton.json +27 -0
- package/data/ux-source-evidence/shared-portal-notifications-dropdown.json +39 -0
- package/data/ux-source-evidence/shared-portal-portal-shell.json +36 -0
- package/data/ux-source-evidence/shared-portal-profile-form.json +32 -0
- package/data/ux-source-evidence/shared-portal-settings-view.json +81 -0
- package/data/ux-source-evidence/shared-portal-usage-sparkline.json +18 -0
- package/data/ux-source-evidence/shared-portal-user-menu.json +34 -0
- package/lib/api.mjs +228 -4
- package/package.json +2 -2
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/escalations/page.tsx",
|
|
4
|
+
"sha256": "3a44415c1f5b67fcf88d41b1285466d4018fe3d140e40325e2c7b2460f6051a7",
|
|
5
|
+
"imports": [
|
|
6
|
+
"react",
|
|
7
|
+
"lucide-react",
|
|
8
|
+
"@/components/ui/button",
|
|
9
|
+
"@/components/ui/badge",
|
|
10
|
+
"@/components/ui/card",
|
|
11
|
+
"@/components/ui/table",
|
|
12
|
+
"@/components/admin/admin-shell",
|
|
13
|
+
"@/components/portal/empty-state",
|
|
14
|
+
"@/components/auth/auth-primitives",
|
|
15
|
+
"@/components/auth/onboarding-primitives",
|
|
16
|
+
"@/lib/closures-mock",
|
|
17
|
+
"@/lib/sessions-mock",
|
|
18
|
+
"@/lib/sessions"
|
|
19
|
+
],
|
|
20
|
+
"tokens": [
|
|
21
|
+
"--color-bg-subtle",
|
|
22
|
+
"--color-status-error-text",
|
|
23
|
+
"--color-status-warning-text",
|
|
24
|
+
"--color-text-muted",
|
|
25
|
+
"--color-text-secondary"
|
|
26
|
+
],
|
|
27
|
+
"fragments": [
|
|
28
|
+
{
|
|
29
|
+
"id": "EscalationsPage",
|
|
30
|
+
"line": 17,
|
|
31
|
+
"code": "export default function EscalationsPage() {\n const [assigning, setAssigning] = useState<string | null>(null);\n const [selectedMentor, setSelectedMentor] = useState<Record<string, string>>({});\n\n return (\n <AdminShell>\n <div className=\"mb-6\">\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Matching escalations\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Generic broadcasts un-matched past the 4-hour SLA. Manually assign a mentor to keep trainees moving.\n </p>\n </div>\n\n {ESCALATIONS.length > 0 && (\n <div className=\"mb-5\">\n <InlineAlert variant=\"warning\" title={`${ESCALATIONS.length} request${ESCALATIONS.length === 1 ? \"\" : \"s\"} need${ESCALATIONS.length === 1 ? \"s\" : \"\"} manual matching`}>\n These sessions are time-sensitive. Assign a mentor as soon as possible.\n </InlineAlert>\n </div>\n )}\n\n {ESCALATIONS.length === 0 ? (\n <EmptyState icon={Radio} title=\"No escalations\" description=\"When broadcast requests go un-matched past the 4-hour SLA, they appear here.\" />\n ) : (\n <Card className=\"p-0\">\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Request</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Trainee</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Scheduled</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Escalated</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Eligible mentors</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Action</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {ESCALATIONS.map((e) => {\n const isAssigning = assigning === e.sessionRequestId;\n const procedureMentors = MENTORS.filter((m) => m.expertise.some((x) => e.procedure.includes(x)));\n return (\n <>\n <TableRow key={e.sessionRequestId}>\n <TableCell className=\"px-5 py-3\">\n <div className=\"text-[12px] font-mono\" style={{ color: \"var(--color-text-secondary)\" }}>{e.sessionRequestId}</div>\n <div className=\"text-[11px] mt-0.5\"><Badge variant=\"outline\" className=\"text-[10px]\">{e.procedure}</Badge> <span style={{ color: \"var(--color-text-muted)\" }}>{e.caseComplexity}</span></div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px] font-medium\">{e.trainee}</TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\">\n <div>{fmtDateTime(e.scheduledFor)}</div>\n <div className=\"text-[10px]\" style={{ color: \"var(--color-text-muted)\" }}>in {hoursUntil(e.scheduledFor)}h</div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <span className=\"inline-flex items-center gap-1.5 text-[12px] font-medium\" style={{ color: e.hoursEscalated > 4 ? \"var(--color-status-error-text)\" : \"var(--color-status-warning-text)\" }}>\n <AlertTriangle size={11} /> {e.hoursEscalated.toFixed(1)}h ago\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n <span className=\"inline-flex items-center gap-1 text-[12px]\">\n <Users size={11} /> {e.eligibleMentors}\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n {!isAssigning ? (\n <Button variant=\"brand\" size=\"sm\" onClick={() => setAssigning(e.sessionRequestId)}>\n Assign mentor <ArrowRight size={11} />\n </Button>\n ) : (\n <Button variant=\"outline\" size=\"sm\" onClick={() => setAssigning(null)}>Cancel</Button>\n )}\n </TableCell>\n </TableRow>\n {isAssigning && (\n <TableRow style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableCell colSpan={6} className=\"px-5 py-4\">\n <div className=\"max-w-2xl\">\n <p className=\"text-[11px] font-semibold uppercase tracking-wide mb-2\" style={{ color: \"var(--color-text-muted)\" }}>\n Assign a mentor manually\n </p>\n <div className=\"flex items-center gap-2\">\n <div className=\"flex-1\">\n <Select\n id={`m-${e.sessionRequestId}`}\n value={selectedMentor[e.sessionRequestId] ?? \"\"}\n onChange={(v) => setSelectedMentor({ ...selectedMentor, [e.sessionRequestId]: v })}\n placeholder=\"Choose from eligible mentors…\"\n options={procedureMentors.map((m) => ({ value: m.id, label: `${m.name} — ${m.institution} (★${m.averageRating})` }))}\n />\n </div>\n <Button variant=\"brand\" size=\"default\" disabled={!selectedMentor[e.sessionRequestId]}>\n Notify mentor & confirm\n </Button>\n </div>\n <p className=\"text-[11px] mt-2\" style={{ color: \"var(--color-text-muted)\" }}>\n The mentor and trainee will be notified. The session will transition to \"Confirmed\" and the credit stays in escrow.\n </p>\n </div>\n </TableCell>\n </TableRow>\n )}\n </>\n );\n })}\n </TableBody>\n </Table>\n </Card>\n )}\n </AdminShell>\n );\n}"
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { ArrowRight, AlertTriangle, Radio, Clock, Users } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Card, CardHeader, CardTitle, CardContent } from \"@/components/ui/card\";\nimport { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from \"@/components/ui/table\";\nimport { AdminShell } from \"@/components/admin/admin-shell\";\nimport { EmptyState } from \"@/components/portal/empty-state\";\nimport { InlineAlert } from \"@/components/auth/auth-primitives\";\nimport { Select } from \"@/components/auth/onboarding-primitives\";\nimport { ESCALATIONS } from \"@/lib/closures-mock\";\nimport { MENTORS } from \"@/lib/sessions-mock\";\nimport { fmtDateTime, hoursUntil } from \"@/lib/sessions\";\n\nexport default function EscalationsPage() {\n const [assigning, setAssigning] = useState<string | null>(null);\n const [selectedMentor, setSelectedMentor] = useState<Record<string, string>>({});\n\n return (\n <AdminShell>\n <div className=\"mb-6\">\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Matching escalations\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Generic broadcasts un-matched past the 4-hour SLA. Manually assign a mentor to keep trainees moving.\n </p>\n </div>\n\n {ESCALATIONS.length > 0 && (\n <div className=\"mb-5\">\n <InlineAlert variant=\"warning\" title={`${ESCALATIONS.length} request${ESCALATIONS.length === 1 ? \"\" : \"s\"} need${ESCALATIONS.length === 1 ? \"s\" : \"\"} manual matching`}>\n These sessions are time-sensitive. Assign a mentor as soon as possible.\n </InlineAlert>\n </div>\n )}\n\n {ESCALATIONS.length === 0 ? (\n <EmptyState icon={Radio} title=\"No escalations\" description=\"When broadcast requests go un-matched past the 4-hour SLA, they appear here.\" />\n ) : (\n <Card className=\"p-0\">\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Request</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Trainee</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Scheduled</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Escalated</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Eligible mentors</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Action</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {ESCALATIONS.map((e) => {\n const isAssigning = assigning === e.sessionRequestId;\n const procedureMentors = MENTORS.filter((m) => m.expertise.some((x) => e.procedure.includes(x)));\n return (\n <>\n <TableRow key={e.sessionRequestId}>\n <TableCell className=\"px-5 py-3\">\n <div className=\"text-[12px] font-mono\" style={{ color: \"var(--color-text-secondary)\" }}>{e.sessionRequestId}</div>\n <div className=\"text-[11px] mt-0.5\"><Badge variant=\"outline\" className=\"text-[10px]\">{e.procedure}</Badge> <span style={{ color: \"var(--color-text-muted)\" }}>{e.caseComplexity}</span></div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px] font-medium\">{e.trainee}</TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\">\n <div>{fmtDateTime(e.scheduledFor)}</div>\n <div className=\"text-[10px]\" style={{ color: \"var(--color-text-muted)\" }}>in {hoursUntil(e.scheduledFor)}h</div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <span className=\"inline-flex items-center gap-1.5 text-[12px] font-medium\" style={{ color: e.hoursEscalated > 4 ? \"var(--color-status-error-text)\" : \"var(--color-status-warning-text)\" }}>\n <AlertTriangle size={11} /> {e.hoursEscalated.toFixed(1)}h ago\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n <span className=\"inline-flex items-center gap-1 text-[12px]\">\n <Users size={11} /> {e.eligibleMentors}\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n {!isAssigning ? (\n <Button variant=\"brand\" size=\"sm\" onClick={() => setAssigning(e.sessionRequestId)}>\n Assign mentor <ArrowRight size={11} />\n </Button>\n ) : (\n <Button variant=\"outline\" size=\"sm\" onClick={() => setAssigning(null)}>Cancel</Button>\n )}\n </TableCell>\n </TableRow>\n {isAssigning && (\n <TableRow style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableCell colSpan={6} className=\"px-5 py-4\">\n <div className=\"max-w-2xl\">\n <p className=\"text-[11px] font-semibold uppercase tracking-wide mb-2\" style={{ color: \"var(--color-text-muted)\" }}>\n Assign a mentor manually\n </p>\n <div className=\"flex items-center gap-2\">\n <div className=\"flex-1\">\n <Select\n id={`m-${e.sessionRequestId}`}\n value={selectedMentor[e.sessionRequestId] ?? \"\"}\n onChange={(v) => setSelectedMentor({ ...selectedMentor, [e.sessionRequestId]: v })}\n placeholder=\"Choose from eligible mentors…\"\n options={procedureMentors.map((m) => ({ value: m.id, label: `${m.name} — ${m.institution} (★${m.averageRating})` }))}\n />\n </div>\n <Button variant=\"brand\" size=\"default\" disabled={!selectedMentor[e.sessionRequestId]}>\n Notify mentor & confirm\n </Button>\n </div>\n <p className=\"text-[11px] mt-2\" style={{ color: \"var(--color-text-muted)\" }}>\n The mentor and trainee will be notified. The session will transition to \"Confirmed\" and the credit stays in escrow.\n </p>\n </div>\n </TableCell>\n </TableRow>\n )}\n </>\n );\n })}\n </TableBody>\n </Table>\n </Card>\n )}\n </AdminShell>\n );\n}\n"
|
|
35
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/incidents/page.tsx",
|
|
4
|
+
"sha256": "1864be74f09b4dc94c1a3b89abf3f7d995abad9d374bb1cf36cf7beab1d3ab94",
|
|
5
|
+
"imports": [
|
|
6
|
+
"next/link",
|
|
7
|
+
"react",
|
|
8
|
+
"lucide-react",
|
|
9
|
+
"@/components/ui/input",
|
|
10
|
+
"@/components/ui/button",
|
|
11
|
+
"@/components/ui/badge",
|
|
12
|
+
"@/components/ui/card",
|
|
13
|
+
"@/components/ui/table",
|
|
14
|
+
"@/components/admin/admin-shell",
|
|
15
|
+
"@/components/portal/empty-state",
|
|
16
|
+
"@/lib/closures-mock"
|
|
17
|
+
],
|
|
18
|
+
"tokens": [
|
|
19
|
+
"--color-bg-brand-muted",
|
|
20
|
+
"--color-bg-subtle",
|
|
21
|
+
"--color-border-default",
|
|
22
|
+
"--color-status-error-bg",
|
|
23
|
+
"--color-status-error-text",
|
|
24
|
+
"--color-status-success-bg",
|
|
25
|
+
"--color-status-success-text",
|
|
26
|
+
"--color-status-warning-bg",
|
|
27
|
+
"--color-status-warning-text",
|
|
28
|
+
"--color-text-brand",
|
|
29
|
+
"--color-text-muted",
|
|
30
|
+
"--color-text-primary",
|
|
31
|
+
"--color-text-secondary",
|
|
32
|
+
"--gray-0",
|
|
33
|
+
"--red-500",
|
|
34
|
+
"--teal-500"
|
|
35
|
+
],
|
|
36
|
+
"fragments": [
|
|
37
|
+
{
|
|
38
|
+
"id": "IncidentsPage",
|
|
39
|
+
"line": 26,
|
|
40
|
+
"code": "export default function IncidentsPage() {\n const [q, setQ] = useState(\"\");\n const [statusFilter, setStatusFilter] = useState<IncidentStatus | \"all\">(\"all\");\n const [categoryFilter, setCategoryFilter] = useState<IncidentCategory | \"all\">(\"all\");\n\n const filtered = useMemo(() => {\n let items = [...INCIDENTS];\n if (statusFilter !== \"all\") items = items.filter((i) => i.status === statusFilter);\n if (categoryFilter !== \"all\") items = items.filter((i) => i.category === categoryFilter);\n if (q) {\n const n = q.toLowerCase();\n items = items.filter((i) =>\n i.id.toLowerCase().includes(n) ||\n i.title.toLowerCase().includes(n) ||\n i.description.toLowerCase().includes(n) ||\n i.owner.toLowerCase().includes(n)\n );\n }\n return items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));\n }, [q, statusFilter, categoryFilter]);\n\n const open = INCIDENTS.filter((i) => i.status !== \"resolved\").length;\n const critical = INCIDENTS.filter((i) => i.severity === \"critical\" || i.severity === \"high\").length;\n\n return (\n <AdminShell>\n <div className=\"flex items-start justify-between gap-4 mb-6\">\n <div>\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Incidents\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Quality, safety, compliance, and technical incidents tracked through to resolution.\n </p>\n </div>\n <Button variant=\"brand\"><Plus size={13} /> New incident</Button>\n </div>\n\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6\">\n <Stat icon={<ShieldAlert size={16} />} label=\"Open\" value={open} accent=\"warning\" />\n <Stat icon={<AlertTriangle size={16} />} label=\"High/Critical severity\" value={critical} accent=\"error\" />\n <Stat icon={<CheckCircle2 size={16} />} label=\"Resolved this month\" value={INCIDENTS.filter((i) => i.status === \"resolved\").length} accent=\"success\" />\n </div>\n\n {/* Filters */}\n <div className=\"flex items-center gap-2 mb-4 flex-wrap\">\n <div className=\"relative flex-1 max-w-md min-w-[200px]\">\n <Search size={14} className=\"absolute left-3 top-1/2 -translate-y-1/2\" style={{ color: \"var(--color-text-muted)\" }} />\n <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder=\"Search incidents…\" className=\"h-9 pl-9\" />\n {q && <button onClick={() => setQ(\"\")} className=\"absolute right-3 top-1/2 -translate-y-1/2\" style={{ color: \"var(--color-text-muted)\" }}><X size={13} /></button>}\n </div>\n <FilterSelect value={statusFilter} onChange={(v) => setStatusFilter(v as IncidentStatus | \"all\")} options={[\n { value: \"all\", label: \"All statuses\" }, { value: \"open\", label: \"Open\" }, { value: \"in_progress\", label: \"In progress\" }, { value: \"resolved\", label: \"Resolved\" },\n ]} />\n <FilterSelect value={categoryFilter} onChange={(v) => setCategoryFilter(v as IncidentCategory | \"all\")} options={[\n { value: \"all\", label: \"All categories\" },\n ...Object.entries(INCIDENT_CATEGORY_LABEL).map(([v, l]) => ({ value: v, label: l })),\n ]} />\n </div>\n\n {filtered.length === 0 ? (\n <EmptyState icon={ShieldAlert} title=\"No incidents match\" description=\"Try adjusting the filters or search.\" />\n ) : (\n <Card className=\"p-0\">\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Incident</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Category</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Severity</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Status</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Owner</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Created</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.map((i) => (\n <TableRow key={i.id}>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-start gap-2.5\">\n <FileText size={13} className=\"mt-0.5 shrink-0\" style={{ color: \"var(--color-text-muted)\" }} />\n <div>\n <div className=\"text-[13px] font-medium\">{i.title}</div>\n <div className=\"text-[11px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>\n <span className=\"font-mono\">{i.id}</span>\n {i.relatedSessionId && <> · session <span className=\"font-mono\">{i.relatedSessionId}</span></>}\n {i.involvedParty && <> · {i.involvedParty}</>}\n </div>\n </div>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <Badge variant=\"outline\" className=\"capitalize\">{INCIDENT_CATEGORY_LABEL[i.category]}</Badge>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <span className=\"inline-flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide px-2 py-1 rounded-md\" style={{ background: SEV_COLOR[i.severity].bg, color: SEV_COLOR[i.severity].fg }}>\n <span className=\"w-1.5 h-1.5 rounded-full\" style={{ background: SEV_COLOR[i.severity].fg }} />\n {i.severity}\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <span className=\"text-[12px] inline-flex items-center gap-1.5\" style={{\n color: i.status === \"resolved\" ? \"var(--color-status-success-text)\" : i.status === \"in_progress\" ? \"var(--color-status-warning-text)\" : \"var(--color-text-secondary)\",\n }}>\n {i.status === \"resolved\" ? <CheckCircle2 size={11} /> : i.status === \"in_progress\" ? <Clock size={11} /> : <ShieldAlert size={11} />}\n {STATUS_LABEL[i.status]}\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>{i.owner}</TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>{new Date(i.createdAt).toLocaleDateString()}</TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </Card>\n )}\n </AdminShell>\n );\n}"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "Stat",
|
|
44
|
+
"line": 146,
|
|
45
|
+
"code": "function Stat({ icon, label, value, accent }: { icon: React.ReactNode; label: string; value: number; accent?: \"success\" | \"warning\" | \"error\" }) {\n const fg = accent === \"success\" ? \"var(--color-status-success-text)\" : accent === \"warning\" ? \"var(--color-status-warning-text)\" : accent === \"error\" ? \"var(--color-status-error-text)\" : \"var(--color-text-brand)\";\n const bg = accent === \"success\" ? \"var(--color-status-success-bg)\" : accent === \"warning\" ? \"var(--color-status-warning-bg)\" : accent === \"error\" ? \"var(--color-status-error-bg)\" : \"var(--color-bg-brand-muted)\";\n return (\n <Card>\n <CardHeader>\n <div className=\"flex items-center gap-3\">\n <div className=\"w-10 h-10 rounded-lg flex items-center justify-center shrink-0\" style={{ background: bg, color: fg }}>{icon}</div>\n <div>\n <div className=\"text-[10px] font-medium uppercase tracking-wide\" style={{ color: \"var(--color-text-muted)\" }}>{label}</div>\n <div className=\"text-2xl font-semibold tabular-nums\" style={{ letterSpacing: \"-0.02em\" }}>{value}</div>\n </div>\n </div>\n </CardHeader>\n </Card>\n );\n}"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "FilterSelect",
|
|
49
|
+
"line": 164,
|
|
50
|
+
"code": "function FilterSelect({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { value: string; label: string }[] }) {\n return (\n <div className=\"relative\">\n <select value={value} onChange={(e) => onChange(e.target.value)} className=\"h-9 pl-3 pr-8 rounded-lg border text-[12px] bg-transparent outline-none appearance-none cursor-pointer focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\" style={{ borderColor: \"var(--color-border-default)\", color: \"var(--color-text-primary)\" }}>\n {options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}\n </select>\n <ChevronDown size={13} strokeWidth={1.8} className=\"absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none\" style={{ color: \"var(--color-text-muted)\" }} />\n </div>\n );\n}"
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useState, useMemo } from \"react\";\nimport {\n Search, X, ChevronDown, Plus, ShieldAlert, AlertTriangle, CheckCircle2, Clock, FileText,\n} from \"lucide-react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Card, CardHeader, CardTitle, CardContent } from \"@/components/ui/card\";\nimport { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from \"@/components/ui/table\";\nimport { AdminShell } from \"@/components/admin/admin-shell\";\nimport { EmptyState } from \"@/components/portal/empty-state\";\nimport { INCIDENTS, INCIDENT_CATEGORY_LABEL, type IncidentCategory, type IncidentSeverity, type IncidentStatus } from \"@/lib/closures-mock\";\n\nconst SEV_COLOR: Record<IncidentSeverity, { fg: string; bg: string }> = {\n low: { fg: \"var(--color-text-muted)\", bg: \"var(--color-bg-subtle)\" },\n medium: { fg: \"var(--color-status-warning-text)\", bg: \"var(--color-status-warning-bg)\" },\n high: { fg: \"var(--color-status-error-text)\", bg: \"var(--color-status-error-bg)\" },\n critical: { fg: \"var(--gray-0)\", bg: \"var(--red-500)\" },\n};\n\nconst STATUS_LABEL: Record<IncidentStatus, string> = { open: \"Open\", in_progress: \"In progress\", resolved: \"Resolved\" };\n\nexport default function IncidentsPage() {\n const [q, setQ] = useState(\"\");\n const [statusFilter, setStatusFilter] = useState<IncidentStatus | \"all\">(\"all\");\n const [categoryFilter, setCategoryFilter] = useState<IncidentCategory | \"all\">(\"all\");\n\n const filtered = useMemo(() => {\n let items = [...INCIDENTS];\n if (statusFilter !== \"all\") items = items.filter((i) => i.status === statusFilter);\n if (categoryFilter !== \"all\") items = items.filter((i) => i.category === categoryFilter);\n if (q) {\n const n = q.toLowerCase();\n items = items.filter((i) =>\n i.id.toLowerCase().includes(n) ||\n i.title.toLowerCase().includes(n) ||\n i.description.toLowerCase().includes(n) ||\n i.owner.toLowerCase().includes(n)\n );\n }\n return items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));\n }, [q, statusFilter, categoryFilter]);\n\n const open = INCIDENTS.filter((i) => i.status !== \"resolved\").length;\n const critical = INCIDENTS.filter((i) => i.severity === \"critical\" || i.severity === \"high\").length;\n\n return (\n <AdminShell>\n <div className=\"flex items-start justify-between gap-4 mb-6\">\n <div>\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Incidents\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Quality, safety, compliance, and technical incidents tracked through to resolution.\n </p>\n </div>\n <Button variant=\"brand\"><Plus size={13} /> New incident</Button>\n </div>\n\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6\">\n <Stat icon={<ShieldAlert size={16} />} label=\"Open\" value={open} accent=\"warning\" />\n <Stat icon={<AlertTriangle size={16} />} label=\"High/Critical severity\" value={critical} accent=\"error\" />\n <Stat icon={<CheckCircle2 size={16} />} label=\"Resolved this month\" value={INCIDENTS.filter((i) => i.status === \"resolved\").length} accent=\"success\" />\n </div>\n\n {/* Filters */}\n <div className=\"flex items-center gap-2 mb-4 flex-wrap\">\n <div className=\"relative flex-1 max-w-md min-w-[200px]\">\n <Search size={14} className=\"absolute left-3 top-1/2 -translate-y-1/2\" style={{ color: \"var(--color-text-muted)\" }} />\n <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder=\"Search incidents…\" className=\"h-9 pl-9\" />\n {q && <button onClick={() => setQ(\"\")} className=\"absolute right-3 top-1/2 -translate-y-1/2\" style={{ color: \"var(--color-text-muted)\" }}><X size={13} /></button>}\n </div>\n <FilterSelect value={statusFilter} onChange={(v) => setStatusFilter(v as IncidentStatus | \"all\")} options={[\n { value: \"all\", label: \"All statuses\" }, { value: \"open\", label: \"Open\" }, { value: \"in_progress\", label: \"In progress\" }, { value: \"resolved\", label: \"Resolved\" },\n ]} />\n <FilterSelect value={categoryFilter} onChange={(v) => setCategoryFilter(v as IncidentCategory | \"all\")} options={[\n { value: \"all\", label: \"All categories\" },\n ...Object.entries(INCIDENT_CATEGORY_LABEL).map(([v, l]) => ({ value: v, label: l })),\n ]} />\n </div>\n\n {filtered.length === 0 ? (\n <EmptyState icon={ShieldAlert} title=\"No incidents match\" description=\"Try adjusting the filters or search.\" />\n ) : (\n <Card className=\"p-0\">\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Incident</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Category</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Severity</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Status</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Owner</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Created</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.map((i) => (\n <TableRow key={i.id}>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-start gap-2.5\">\n <FileText size={13} className=\"mt-0.5 shrink-0\" style={{ color: \"var(--color-text-muted)\" }} />\n <div>\n <div className=\"text-[13px] font-medium\">{i.title}</div>\n <div className=\"text-[11px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>\n <span className=\"font-mono\">{i.id}</span>\n {i.relatedSessionId && <> · session <span className=\"font-mono\">{i.relatedSessionId}</span></>}\n {i.involvedParty && <> · {i.involvedParty}</>}\n </div>\n </div>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <Badge variant=\"outline\" className=\"capitalize\">{INCIDENT_CATEGORY_LABEL[i.category]}</Badge>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <span className=\"inline-flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide px-2 py-1 rounded-md\" style={{ background: SEV_COLOR[i.severity].bg, color: SEV_COLOR[i.severity].fg }}>\n <span className=\"w-1.5 h-1.5 rounded-full\" style={{ background: SEV_COLOR[i.severity].fg }} />\n {i.severity}\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <span className=\"text-[12px] inline-flex items-center gap-1.5\" style={{\n color: i.status === \"resolved\" ? \"var(--color-status-success-text)\" : i.status === \"in_progress\" ? \"var(--color-status-warning-text)\" : \"var(--color-text-secondary)\",\n }}>\n {i.status === \"resolved\" ? <CheckCircle2 size={11} /> : i.status === \"in_progress\" ? <Clock size={11} /> : <ShieldAlert size={11} />}\n {STATUS_LABEL[i.status]}\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>{i.owner}</TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>{new Date(i.createdAt).toLocaleDateString()}</TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </Card>\n )}\n </AdminShell>\n );\n}\n\nfunction Stat({ icon, label, value, accent }: { icon: React.ReactNode; label: string; value: number; accent?: \"success\" | \"warning\" | \"error\" }) {\n const fg = accent === \"success\" ? \"var(--color-status-success-text)\" : accent === \"warning\" ? \"var(--color-status-warning-text)\" : accent === \"error\" ? \"var(--color-status-error-text)\" : \"var(--color-text-brand)\";\n const bg = accent === \"success\" ? \"var(--color-status-success-bg)\" : accent === \"warning\" ? \"var(--color-status-warning-bg)\" : accent === \"error\" ? \"var(--color-status-error-bg)\" : \"var(--color-bg-brand-muted)\";\n return (\n <Card>\n <CardHeader>\n <div className=\"flex items-center gap-3\">\n <div className=\"w-10 h-10 rounded-lg flex items-center justify-center shrink-0\" style={{ background: bg, color: fg }}>{icon}</div>\n <div>\n <div className=\"text-[10px] font-medium uppercase tracking-wide\" style={{ color: \"var(--color-text-muted)\" }}>{label}</div>\n <div className=\"text-2xl font-semibold tabular-nums\" style={{ letterSpacing: \"-0.02em\" }}>{value}</div>\n </div>\n </div>\n </CardHeader>\n </Card>\n );\n}\n\nfunction FilterSelect({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { value: string; label: string }[] }) {\n return (\n <div className=\"relative\">\n <select value={value} onChange={(e) => onChange(e.target.value)} className=\"h-9 pl-3 pr-8 rounded-lg border text-[12px] bg-transparent outline-none appearance-none cursor-pointer focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\" style={{ borderColor: \"var(--color-border-default)\", color: \"var(--color-text-primary)\" }}>\n {options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}\n </select>\n <ChevronDown size={13} strokeWidth={1.8} className=\"absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none\" style={{ color: \"var(--color-text-muted)\" }} />\n </div>\n );\n}\n"
|
|
54
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/loading.tsx",
|
|
4
|
+
"sha256": "b8171e068d93fe387d0eb02c00a4542e941e806c6e3f8a1571e10e8cbc4e4418",
|
|
5
|
+
"imports": [
|
|
6
|
+
"@/components/portal/loading-skeleton"
|
|
7
|
+
],
|
|
8
|
+
"tokens": [],
|
|
9
|
+
"fragments": [
|
|
10
|
+
{
|
|
11
|
+
"id": "AdminLoading",
|
|
12
|
+
"line": 2,
|
|
13
|
+
"code": "export default function AdminLoading() { return <LoadingSkeleton />; }"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"content": "import { LoadingSkeleton } from \"@/components/portal/loading-skeleton\";\nexport default function AdminLoading() { return <LoadingSkeleton />; }\n"
|
|
17
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/onboarding/[id]/page.tsx",
|
|
4
|
+
"sha256": "6cc029ad1fc0c7432a04ce4378b3736b93784ce6b2495cc62002f01e76636a94",
|
|
5
|
+
"imports": [
|
|
6
|
+
"next/link",
|
|
7
|
+
"react",
|
|
8
|
+
"next/navigation",
|
|
9
|
+
"lucide-react",
|
|
10
|
+
"@/components/ui/button",
|
|
11
|
+
"@/components/ui/input",
|
|
12
|
+
"@/components/ui/textarea",
|
|
13
|
+
"@/components/ui/badge",
|
|
14
|
+
"@/components/admin/admin-shell",
|
|
15
|
+
"@/components/auth/status-indicator",
|
|
16
|
+
"@/components/auth/auth-primitives",
|
|
17
|
+
"@/components/auth/onboarding-primitives",
|
|
18
|
+
"@/lib/admin-mock",
|
|
19
|
+
"@/lib/verification"
|
|
20
|
+
],
|
|
21
|
+
"tokens": [
|
|
22
|
+
"--color-bg-subtle",
|
|
23
|
+
"--color-border-default",
|
|
24
|
+
"--color-border-muted",
|
|
25
|
+
"--color-status-error-bg",
|
|
26
|
+
"--color-status-error-text",
|
|
27
|
+
"--color-status-success-bg",
|
|
28
|
+
"--color-status-success-text",
|
|
29
|
+
"--color-status-warning-bg",
|
|
30
|
+
"--color-status-warning-text",
|
|
31
|
+
"--color-text-muted",
|
|
32
|
+
"--color-text-primary",
|
|
33
|
+
"--color-text-secondary",
|
|
34
|
+
"--gray-950",
|
|
35
|
+
"--teal-500"
|
|
36
|
+
],
|
|
37
|
+
"fragments": [
|
|
38
|
+
{
|
|
39
|
+
"id": "ApplicationDetailPage",
|
|
40
|
+
"line": 26,
|
|
41
|
+
"code": "export default function ApplicationDetailPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = use(params);\n const router = useRouter();\n const app = APPLICATIONS.find((a) => a.id === id);\n\n if (!app) {\n return (\n <AdminShell>\n <div className=\"text-center py-20\">\n <p className=\"text-[14px] mb-4\" style={{ color: \"var(--color-text-secondary)\" }}>Application not found.</p>\n <Link href=\"/admin/onboarding\"><Button variant=\"outline\">← Back to queue</Button></Link>\n </div>\n </AdminShell>\n );\n }\n\n const sla = slaState(app.role, app.status, app.appliedHoursAgo);\n const checklistKeys = app.role === \"mentor\" ? MAV_MENTOR_KEYS : MAV_TRAINEE_KEYS;\n\n return (\n <AdminShell>\n {/* Back + heading */}\n <Link href=\"/admin/onboarding\" className=\"inline-flex items-center gap-1.5 text-[12px] mb-4 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-secondary)\" }}>\n <ArrowLeft size={13} /> Back to queue\n </Link>\n\n <div className=\"flex items-start justify-between gap-6 mb-6\">\n <div className=\"flex items-start gap-4\">\n <div className=\"w-12 h-12 rounded-xl flex items-center justify-center text-[14px] font-bold shrink-0\" style={{ background: \"var(--teal-500)\", color: \"var(--gray-950)\" }}>\n {app.name.split(\" \").slice(-2).map((s) => s[0]).join(\"\").slice(0, 2)}\n </div>\n <div>\n <h1 className=\"text-[1.5rem] font-semibold tracking-tight\" style={{ letterSpacing: \"-0.025em\" }}>{app.name}</h1>\n <div className=\"flex items-center gap-2 mt-1\">\n <Badge variant=\"outline\" className=\"capitalize\">{app.role}</Badge>\n <StatusPill status={app.status} size=\"sm\" />\n {sla === \"breach\" && (\n <span className=\"inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-1 rounded-md\" style={{ background: \"var(--color-status-error-bg)\", color: \"var(--color-status-error-text)\" }}>\n <AlertTriangle size={11} /> SLA breach\n </span>\n )}\n </div>\n </div>\n </div>\n <div className=\"flex items-center gap-2\">\n <Button variant=\"outline\">Hold</Button>\n <Button variant=\"destructive\">Reject</Button>\n </div>\n </div>\n\n {/* Status flow */}\n <div className=\"mb-6\">\n <StatusFlow role={app.role} current={app.status} />\n </div>\n\n {/* 2-col layout */}\n <div className=\"grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-6\">\n {/* Main column — MAV checklist */}\n <div className=\"space-y-6\">\n <MavChecklist app={app} keys={checklistKeys} />\n {app.role === \"trainee\" && <HardwareReadinessPanel appId={app.id} />}\n </div>\n\n {/* Sidebar — applicant info + audit */}\n <div className=\"space-y-4\">\n <ApplicantInfoCard app={app} />\n <AuditLogCard appId={app.id} />\n </div>\n </div>\n </AdminShell>\n );\n}"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"id": "MavChecklist",
|
|
45
|
+
"line": 101,
|
|
46
|
+
"code": "function MavChecklist({ app, keys }: { app: typeof APPLICATIONS[number]; keys: readonly { id: string; label: string }[] }) {\n // Local state — initialized with realistic seed values based on app.status\n const seed = (i: number): MavResult => {\n if (app.status === \"active\") return \"verified\";\n if (app.status === \"rejected\") return i === 0 ? \"rejected\" : \"pending\";\n if (app.status === \"under_verification\") return i < 2 ? \"verified\" : \"pending\";\n if (app.status === \"legal_pending\") return i < 4 ? \"verified\" : \"pending\";\n if (app.status === \"hardware_pending\") return i < 3 ? \"verified\" : \"pending\";\n return \"pending\";\n };\n\n const [results, setResults] = useState<MavResult[]>(keys.map((_, i) => seed(i)));\n const [openIdx, setOpenIdx] = useState<number | null>(null);\n const [notes, setNotes] = useState<string[]>(keys.map(() => \"\"));\n\n const allVerified = results.every((r) => r === \"verified\");\n const lastIdx = keys.length - 1;\n\n return (\n <div className=\"rounded-2xl border\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <div className=\"px-5 py-4 border-b flex items-center justify-between\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n <div>\n <h2 className=\"text-[15px] font-semibold\">\n MAV–{app.role === \"mentor\" ? \"Mentor\" : \"Trainee\"} checklist\n </h2>\n <p className=\"text-[12px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>\n {results.filter((r) => r === \"verified\").length} of {keys.length} verified\n </p>\n </div>\n {allVerified && (\n <Button variant=\"brand\">Activate account <ArrowRight size={13} /></Button>\n )}\n </div>\n\n <div className=\"divide-y\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n {keys.map((item, i) => {\n const result = results[i];\n const isLast = i === lastIdx;\n const isOpen = openIdx === i;\n // Last item (activation) is locked until all prior are verified\n const locked = isLast && results.slice(0, -1).some((r) => r !== \"verified\");\n\n const setResult = (r: MavResult) => {\n const next = [...results]; next[i] = r; setResults(next);\n if (r !== \"pending\") setOpenIdx(null);\n };\n\n return (\n <div key={item.id} className=\"px-5 py-3.5\" style={{ opacity: locked ? 0.5 : 1 }}>\n <button\n type=\"button\"\n onClick={() => !locked && setOpenIdx(isOpen ? null : i)}\n className=\"w-full flex items-center gap-3 text-left\"\n disabled={locked}\n >\n <ResultIcon result={result} />\n <div className=\"flex-1 min-w-0\">\n <div className=\"text-[13px] font-medium\" style={{ color: \"var(--color-text-primary)\" }}>\n <span className=\"text-[11px] font-semibold tabular-nums mr-2\" style={{ color: \"var(--color-text-muted)\" }}>{item.id}</span>\n {item.label}\n </div>\n </div>\n <span className=\"text-[11px] font-medium\" style={{ color: resultColor(result) }}>\n {MAV_RESULT_LABEL[result]}\n </span>\n </button>\n\n {isOpen && !locked && (\n <div className=\"mt-4 pl-9 space-y-3\">\n <div className=\"grid grid-cols-2 sm:grid-cols-4 gap-1.5\">\n {([\"verified\", \"issue_found\", \"rejected\", \"pending\"] as MavResult[]).map((opt) => {\n const active = result === opt;\n const variant: \"default\" | \"outline\" | \"destructive\" =\n active && opt === \"rejected\" ? \"destructive\"\n : active && opt === \"verified\" ? \"default\"\n : \"outline\";\n return (\n <Button\n key={opt}\n type=\"button\"\n variant={variant}\n size=\"sm\"\n onClick={() => setResult(opt)}\n className=\"text-[11px]\"\n style={\n active && opt !== \"rejected\" && opt !== \"verified\"\n ? { borderColor: resultColor(opt), color: resultColor(opt), background: `color-mix(in srgb, ${resultColor(opt)} 14%, transparent)` }\n : undefined\n }\n aria-pressed={active}\n >\n {MAV_RESULT_LABEL[opt]}\n </Button>\n );\n })}\n </div>\n <Textarea\n rows={2}\n placeholder=\"Admin notes (visible to other admins only)…\"\n value={notes[i]}\n onChange={(e) => { const next = [...notes]; next[i] = e.target.value; setNotes(next); }}\n />\n <div className=\"flex justify-end\">\n <Button size=\"sm\" variant=\"outline\" onClick={() => setOpenIdx(null)}>Save</Button>\n </div>\n </div>\n )}\n </div>\n );\n })}\n </div>\n </div>\n );\n}"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": "ResultIcon",
|
|
50
|
+
"line": 216,
|
|
51
|
+
"code": "function ResultIcon({ result }: { result: MavResult }) {\n if (result === \"verified\") return <CheckCircle2 size={18} strokeWidth={2} style={{ color: \"var(--color-status-success-text)\" }} />;\n if (result === \"issue_found\") return <AlertTriangle size={18} strokeWidth={1.8} style={{ color: \"var(--color-status-warning-text)\" }} />;\n if (result === \"rejected\") return <XCircle size={18} strokeWidth={1.8} style={{ color: \"var(--color-status-error-text)\" }} />;\n return <Clock size={18} strokeWidth={1.8} style={{ color: \"var(--color-text-muted)\" }} />;\n}"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "resultColor",
|
|
55
|
+
"line": 223,
|
|
56
|
+
"code": "function resultColor(r: MavResult) {\n if (r === \"verified\") return \"var(--color-status-success-text)\";\n if (r === \"issue_found\") return \"var(--color-status-warning-text)\";\n if (r === \"rejected\") return \"var(--color-status-error-text)\";\n return \"var(--color-text-muted)\";\n}"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "HardwareReadinessPanel",
|
|
60
|
+
"line": 232,
|
|
61
|
+
"code": "function HardwareReadinessPanel({ appId }: { appId: string }) {\n const initial = HARDWARE_STATE[appId] ?? {\n siteIdentification: \"pending\" as GateStatus,\n deviceInstallation: \"pending\" as GateStatus,\n igGdprSignOff: \"pending\" as GateStatus,\n menteeMentorAlignment: \"pending\" as GateStatus,\n };\n const [state, setState] = useState(initial);\n\n const gates = [\n { key: \"siteIdentification\" as const, label: HARDWARE_GATE_LABELS.siteIdentification, icon: MapPin },\n { key: \"deviceInstallation\" as const, label: HARDWARE_GATE_LABELS.deviceInstallation, icon: Cpu },\n { key: \"igGdprSignOff\" as const, label: HARDWARE_GATE_LABELS.igGdprSignOff, icon: Shield },\n { key: \"menteeMentorAlignment\" as const, label: HARDWARE_GATE_LABELS.menteeMentorAlignment, icon: Users },\n ];\n\n const ready = hardwareReady(state);\n\n return (\n <div className=\"rounded-2xl border\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <div className=\"px-5 py-4 border-b flex items-center justify-between\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n <div>\n <h2 className=\"text-[15px] font-semibold\">Hardware readiness</h2>\n <p className=\"text-[12px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>\n Four-gate prerequisite for session activation.\n </p>\n </div>\n {ready ? (\n <span className=\"inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-1 rounded-md\" style={{ background: \"var(--color-status-success-bg)\", color: \"var(--color-status-success-text)\" }}>\n <CheckCircle2 size={11} /> All gates complete\n </span>\n ) : (\n <span className=\"inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-1 rounded-md\" style={{ background: \"var(--color-status-warning-bg)\", color: \"var(--color-status-warning-text)\" }}>\n <Clock size={11} /> {Object.values(state).filter((s) => s === \"complete\").length} / 4 complete\n </span>\n )}\n </div>\n\n <div className=\"divide-y\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n {gates.map((g, i) => {\n const status = state[g.key];\n const Icon = g.icon;\n return (\n <div key={g.key} className=\"px-5 py-3.5 flex items-center gap-3\">\n <div className=\"w-9 h-9 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-bg-subtle)\", color: status === \"complete\" ? \"var(--color-status-success-text)\" : \"var(--color-text-secondary)\" }}>\n {status === \"complete\" ? <CheckCircle2 size={16} strokeWidth={2} /> : <Icon size={16} strokeWidth={1.7} />}\n </div>\n <div className=\"flex-1 min-w-0\">\n <div className=\"text-[11px] font-semibold tabular-nums\" style={{ color: \"var(--color-text-muted)\" }}>Gate {i + 1}</div>\n <div className=\"text-[13px] font-medium\">{g.label}</div>\n </div>\n <div className=\"w-36\">\n <Select\n id={`gate-${g.key}`}\n value={status}\n onChange={(v) => setState({ ...state, [g.key]: v as GateStatus })}\n options={[\n { value: \"complete\", label: \"Complete\" },\n { value: \"in_progress\", label: \"In progress\" },\n { value: \"pending\", label: \"Pending\" },\n ]}\n />\n </div>\n </div>\n );\n })}\n </div>\n\n {/* Installation metadata */}\n {(\"provider\" in initial) && (\n <div className=\"px-5 py-4 border-t grid grid-cols-2 gap-x-4 gap-y-1 text-[12px]\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n <div className=\"flex items-center gap-2\">\n <span style={{ color: \"var(--color-text-muted)\" }}>Provider:</span>\n <span style={{ color: \"var(--color-text-primary)\" }}>{initial.provider}</span>\n </div>\n <div className=\"flex items-center gap-2\">\n <span style={{ color: \"var(--color-text-muted)\" }}>Device:</span>\n <span style={{ color: \"var(--color-text-primary)\" }}>{initial.device}</span>\n </div>\n {initial.installStart && (\n <div className=\"flex items-center gap-2\">\n <span style={{ color: \"var(--color-text-muted)\" }}>Install start:</span>\n <span style={{ color: \"var(--color-text-primary)\" }}>{initial.installStart}</span>\n </div>\n )}\n {initial.installEnd && (\n <div className=\"flex items-center gap-2\">\n <span style={{ color: \"var(--color-text-muted)\" }}>Install end:</span>\n <span style={{ color: \"var(--color-text-primary)\" }}>{initial.installEnd}</span>\n </div>\n )}\n {initial.igLead && (\n <div className=\"flex items-center gap-2\">\n <span style={{ color: \"var(--color-text-muted)\" }}>IG lead:</span>\n <span style={{ color: \"var(--color-text-primary)\" }}>{initial.igLead}</span>\n </div>\n )}\n </div>\n )}\n </div>\n );\n}"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"id": "ApplicantInfoCard",
|
|
65
|
+
"line": 337,
|
|
66
|
+
"code": "function ApplicantInfoCard({ app }: { app: typeof APPLICATIONS[number] }) {\n return (\n <div className=\"rounded-2xl border p-5\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <h3 className=\"text-[11px] font-semibold uppercase tracking-[0.12em] mb-3\" style={{ color: \"var(--color-text-muted)\" }}>Applicant</h3>\n <dl className=\"space-y-2.5 text-[12px]\">\n <Row2 label=\"Email\" value={app.email} />\n <Row2 label=\"Institution\" value={app.institution} />\n <Row2 label=\"Country\" value={app.country} />\n {app.specialty && <Row2 label=\"Specialty\" value={app.specialty} />}\n {app.trainingGrade && <Row2 label=\"Training grade\" value={app.trainingGrade} />}\n {app.procedures && app.procedures.length > 0 && (\n <Row2 label=\"Procedures\" value={app.procedures.join(\", \")} />\n )}\n {app.industryPartner && <Row2 label=\"Sponsor\" value={app.industryPartner} />}\n <Row2 label=\"Applied\" value={`${app.appliedHoursAgo < 24 ? `${app.appliedHoursAgo}h` : `${Math.round(app.appliedHoursAgo / 24)}d`} ago`} />\n {app.assignedAdmin && <Row2 label=\"Assigned\" value={app.assignedAdmin} />}\n </dl>\n </div>\n );\n}"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"id": "Row2",
|
|
70
|
+
"line": 358,
|
|
71
|
+
"code": "function Row2({ label, value }: { label: string; value: string }) {\n return (\n <div>\n <dt className=\"text-[10px] uppercase tracking-wide\" style={{ color: \"var(--color-text-muted)\" }}>{label}</dt>\n <dd className=\"text-[12px] mt-0.5\" style={{ color: \"var(--color-text-primary)\" }}>{value}</dd>\n </div>\n );\n}"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"id": "AuditLogCard",
|
|
75
|
+
"line": 369,
|
|
76
|
+
"code": "function AuditLogCard({ appId }: { appId: string }) {\n const log = AUDIT_LOG[appId] ?? [];\n return (\n <div className=\"rounded-2xl border p-5\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <h3 className=\"text-[11px] font-semibold uppercase tracking-[0.12em] mb-3\" style={{ color: \"var(--color-text-muted)\" }}>Audit log</h3>\n {log.length === 0 ? (\n <p className=\"text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>No actions recorded yet.</p>\n ) : (\n <ol className=\"space-y-3 text-[12px]\">\n {log.map((entry) => (\n <li key={entry.id} className=\"flex items-start gap-2.5\">\n <div className=\"w-1.5 h-1.5 rounded-full mt-1.5 shrink-0\" style={{ background: \"var(--teal-500)\" }} />\n <div className=\"flex-1 min-w-0\">\n <div className=\"font-medium\" style={{ color: \"var(--color-text-primary)\" }}>{entry.action}</div>\n {entry.detail && <div className=\"text-[11px] mt-0.5\" style={{ color: \"var(--color-text-secondary)\" }}>{entry.detail}</div>}\n <div className=\"text-[10px] mt-1\" style={{ color: \"var(--color-text-muted)\" }}>\n {new Date(entry.timestamp).toLocaleString()} · {entry.admin}\n </div>\n </div>\n </li>\n ))}\n </ol>\n )}\n </div>\n );\n}"
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useState, use } from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport {\n ArrowLeft, ArrowRight, CheckCircle2, AlertTriangle, XCircle, Clock, FileText,\n Mail, MapPin, Building2, Calendar, Cpu, Shield, Users, Coins, AlertCircle,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { AdminShell } from \"@/components/admin/admin-shell\";\nimport { StatusPill, StatusFlow } from \"@/components/auth/status-indicator\";\nimport { InlineAlert, Field } from \"@/components/auth/auth-primitives\";\nimport { Select } from \"@/components/auth/onboarding-primitives\";\nimport {\n APPLICATIONS, HARDWARE_STATE, MAV_MENTOR_KEYS, MAV_TRAINEE_KEYS, AUDIT_LOG,\n} from \"@/lib/admin-mock\";\nimport {\n HARDWARE_GATE_LABELS, hardwareReady, MAV_RESULT_LABEL, slaState,\n type GateStatus, type MavResult,\n} from \"@/lib/verification\";\n\nexport default function ApplicationDetailPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = use(params);\n const router = useRouter();\n const app = APPLICATIONS.find((a) => a.id === id);\n\n if (!app) {\n return (\n <AdminShell>\n <div className=\"text-center py-20\">\n <p className=\"text-[14px] mb-4\" style={{ color: \"var(--color-text-secondary)\" }}>Application not found.</p>\n <Link href=\"/admin/onboarding\"><Button variant=\"outline\">← Back to queue</Button></Link>\n </div>\n </AdminShell>\n );\n }\n\n const sla = slaState(app.role, app.status, app.appliedHoursAgo);\n const checklistKeys = app.role === \"mentor\" ? MAV_MENTOR_KEYS : MAV_TRAINEE_KEYS;\n\n return (\n <AdminShell>\n {/* Back + heading */}\n <Link href=\"/admin/onboarding\" className=\"inline-flex items-center gap-1.5 text-[12px] mb-4 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-secondary)\" }}>\n <ArrowLeft size={13} /> Back to queue\n </Link>\n\n <div className=\"flex items-start justify-between gap-6 mb-6\">\n <div className=\"flex items-start gap-4\">\n <div className=\"w-12 h-12 rounded-xl flex items-center justify-center text-[14px] font-bold shrink-0\" style={{ background: \"var(--teal-500)\", color: \"var(--gray-950)\" }}>\n {app.name.split(\" \").slice(-2).map((s) => s[0]).join(\"\").slice(0, 2)}\n </div>\n <div>\n <h1 className=\"text-[1.5rem] font-semibold tracking-tight\" style={{ letterSpacing: \"-0.025em\" }}>{app.name}</h1>\n <div className=\"flex items-center gap-2 mt-1\">\n <Badge variant=\"outline\" className=\"capitalize\">{app.role}</Badge>\n <StatusPill status={app.status} size=\"sm\" />\n {sla === \"breach\" && (\n <span className=\"inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-1 rounded-md\" style={{ background: \"var(--color-status-error-bg)\", color: \"var(--color-status-error-text)\" }}>\n <AlertTriangle size={11} /> SLA breach\n </span>\n )}\n </div>\n </div>\n </div>\n <div className=\"flex items-center gap-2\">\n <Button variant=\"outline\">Hold</Button>\n <Button variant=\"destructive\">Reject</Button>\n </div>\n </div>\n\n {/* Status flow */}\n <div className=\"mb-6\">\n <StatusFlow role={app.role} current={app.status} />\n </div>\n\n {/* 2-col layout */}\n <div className=\"grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-6\">\n {/* Main column — MAV checklist */}\n <div className=\"space-y-6\">\n <MavChecklist app={app} keys={checklistKeys} />\n {app.role === \"trainee\" && <HardwareReadinessPanel appId={app.id} />}\n </div>\n\n {/* Sidebar — applicant info + audit */}\n <div className=\"space-y-4\">\n <ApplicantInfoCard app={app} />\n <AuditLogCard appId={app.id} />\n </div>\n </div>\n </AdminShell>\n );\n}\n\n/* ─── MAV Checklist ──────────────────────────────────────────────────────── */\n\nfunction MavChecklist({ app, keys }: { app: typeof APPLICATIONS[number]; keys: readonly { id: string; label: string }[] }) {\n // Local state — initialized with realistic seed values based on app.status\n const seed = (i: number): MavResult => {\n if (app.status === \"active\") return \"verified\";\n if (app.status === \"rejected\") return i === 0 ? \"rejected\" : \"pending\";\n if (app.status === \"under_verification\") return i < 2 ? \"verified\" : \"pending\";\n if (app.status === \"legal_pending\") return i < 4 ? \"verified\" : \"pending\";\n if (app.status === \"hardware_pending\") return i < 3 ? \"verified\" : \"pending\";\n return \"pending\";\n };\n\n const [results, setResults] = useState<MavResult[]>(keys.map((_, i) => seed(i)));\n const [openIdx, setOpenIdx] = useState<number | null>(null);\n const [notes, setNotes] = useState<string[]>(keys.map(() => \"\"));\n\n const allVerified = results.every((r) => r === \"verified\");\n const lastIdx = keys.length - 1;\n\n return (\n <div className=\"rounded-2xl border\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <div className=\"px-5 py-4 border-b flex items-center justify-between\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n <div>\n <h2 className=\"text-[15px] font-semibold\">\n MAV–{app.role === \"mentor\" ? \"Mentor\" : \"Trainee\"} checklist\n </h2>\n <p className=\"text-[12px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>\n {results.filter((r) => r === \"verified\").length} of {keys.length} verified\n </p>\n </div>\n {allVerified && (\n <Button variant=\"brand\">Activate account <ArrowRight size={13} /></Button>\n )}\n </div>\n\n <div className=\"divide-y\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n {keys.map((item, i) => {\n const result = results[i];\n const isLast = i === lastIdx;\n const isOpen = openIdx === i;\n // Last item (activation) is locked until all prior are verified\n const locked = isLast && results.slice(0, -1).some((r) => r !== \"verified\");\n\n const setResult = (r: MavResult) => {\n const next = [...results]; next[i] = r; setResults(next);\n if (r !== \"pending\") setOpenIdx(null);\n };\n\n return (\n <div key={item.id} className=\"px-5 py-3.5\" style={{ opacity: locked ? 0.5 : 1 }}>\n <button\n type=\"button\"\n onClick={() => !locked && setOpenIdx(isOpen ? null : i)}\n className=\"w-full flex items-center gap-3 text-left\"\n disabled={locked}\n >\n <ResultIcon result={result} />\n <div className=\"flex-1 min-w-0\">\n <div className=\"text-[13px] font-medium\" style={{ color: \"var(--color-text-primary)\" }}>\n <span className=\"text-[11px] font-semibold tabular-nums mr-2\" style={{ color: \"var(--color-text-muted)\" }}>{item.id}</span>\n {item.label}\n </div>\n </div>\n <span className=\"text-[11px] font-medium\" style={{ color: resultColor(result) }}>\n {MAV_RESULT_LABEL[result]}\n </span>\n </button>\n\n {isOpen && !locked && (\n <div className=\"mt-4 pl-9 space-y-3\">\n <div className=\"grid grid-cols-2 sm:grid-cols-4 gap-1.5\">\n {([\"verified\", \"issue_found\", \"rejected\", \"pending\"] as MavResult[]).map((opt) => {\n const active = result === opt;\n const variant: \"default\" | \"outline\" | \"destructive\" =\n active && opt === \"rejected\" ? \"destructive\"\n : active && opt === \"verified\" ? \"default\"\n : \"outline\";\n return (\n <Button\n key={opt}\n type=\"button\"\n variant={variant}\n size=\"sm\"\n onClick={() => setResult(opt)}\n className=\"text-[11px]\"\n style={\n active && opt !== \"rejected\" && opt !== \"verified\"\n ? { borderColor: resultColor(opt), color: resultColor(opt), background: `color-mix(in srgb, ${resultColor(opt)} 14%, transparent)` }\n : undefined\n }\n aria-pressed={active}\n >\n {MAV_RESULT_LABEL[opt]}\n </Button>\n );\n })}\n </div>\n <Textarea\n rows={2}\n placeholder=\"Admin notes (visible to other admins only)…\"\n value={notes[i]}\n onChange={(e) => { const next = [...notes]; next[i] = e.target.value; setNotes(next); }}\n />\n <div className=\"flex justify-end\">\n <Button size=\"sm\" variant=\"outline\" onClick={() => setOpenIdx(null)}>Save</Button>\n </div>\n </div>\n )}\n </div>\n );\n })}\n </div>\n </div>\n );\n}\n\nfunction ResultIcon({ result }: { result: MavResult }) {\n if (result === \"verified\") return <CheckCircle2 size={18} strokeWidth={2} style={{ color: \"var(--color-status-success-text)\" }} />;\n if (result === \"issue_found\") return <AlertTriangle size={18} strokeWidth={1.8} style={{ color: \"var(--color-status-warning-text)\" }} />;\n if (result === \"rejected\") return <XCircle size={18} strokeWidth={1.8} style={{ color: \"var(--color-status-error-text)\" }} />;\n return <Clock size={18} strokeWidth={1.8} style={{ color: \"var(--color-text-muted)\" }} />;\n}\n\nfunction resultColor(r: MavResult) {\n if (r === \"verified\") return \"var(--color-status-success-text)\";\n if (r === \"issue_found\") return \"var(--color-status-warning-text)\";\n if (r === \"rejected\") return \"var(--color-status-error-text)\";\n return \"var(--color-text-muted)\";\n}\n\n/* ─── Hardware Readiness Panel (Trainee only) ────────────────────────────── */\n\nfunction HardwareReadinessPanel({ appId }: { appId: string }) {\n const initial = HARDWARE_STATE[appId] ?? {\n siteIdentification: \"pending\" as GateStatus,\n deviceInstallation: \"pending\" as GateStatus,\n igGdprSignOff: \"pending\" as GateStatus,\n menteeMentorAlignment: \"pending\" as GateStatus,\n };\n const [state, setState] = useState(initial);\n\n const gates = [\n { key: \"siteIdentification\" as const, label: HARDWARE_GATE_LABELS.siteIdentification, icon: MapPin },\n { key: \"deviceInstallation\" as const, label: HARDWARE_GATE_LABELS.deviceInstallation, icon: Cpu },\n { key: \"igGdprSignOff\" as const, label: HARDWARE_GATE_LABELS.igGdprSignOff, icon: Shield },\n { key: \"menteeMentorAlignment\" as const, label: HARDWARE_GATE_LABELS.menteeMentorAlignment, icon: Users },\n ];\n\n const ready = hardwareReady(state);\n\n return (\n <div className=\"rounded-2xl border\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <div className=\"px-5 py-4 border-b flex items-center justify-between\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n <div>\n <h2 className=\"text-[15px] font-semibold\">Hardware readiness</h2>\n <p className=\"text-[12px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>\n Four-gate prerequisite for session activation.\n </p>\n </div>\n {ready ? (\n <span className=\"inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-1 rounded-md\" style={{ background: \"var(--color-status-success-bg)\", color: \"var(--color-status-success-text)\" }}>\n <CheckCircle2 size={11} /> All gates complete\n </span>\n ) : (\n <span className=\"inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-1 rounded-md\" style={{ background: \"var(--color-status-warning-bg)\", color: \"var(--color-status-warning-text)\" }}>\n <Clock size={11} /> {Object.values(state).filter((s) => s === \"complete\").length} / 4 complete\n </span>\n )}\n </div>\n\n <div className=\"divide-y\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n {gates.map((g, i) => {\n const status = state[g.key];\n const Icon = g.icon;\n return (\n <div key={g.key} className=\"px-5 py-3.5 flex items-center gap-3\">\n <div className=\"w-9 h-9 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-bg-subtle)\", color: status === \"complete\" ? \"var(--color-status-success-text)\" : \"var(--color-text-secondary)\" }}>\n {status === \"complete\" ? <CheckCircle2 size={16} strokeWidth={2} /> : <Icon size={16} strokeWidth={1.7} />}\n </div>\n <div className=\"flex-1 min-w-0\">\n <div className=\"text-[11px] font-semibold tabular-nums\" style={{ color: \"var(--color-text-muted)\" }}>Gate {i + 1}</div>\n <div className=\"text-[13px] font-medium\">{g.label}</div>\n </div>\n <div className=\"w-36\">\n <Select\n id={`gate-${g.key}`}\n value={status}\n onChange={(v) => setState({ ...state, [g.key]: v as GateStatus })}\n options={[\n { value: \"complete\", label: \"Complete\" },\n { value: \"in_progress\", label: \"In progress\" },\n { value: \"pending\", label: \"Pending\" },\n ]}\n />\n </div>\n </div>\n );\n })}\n </div>\n\n {/* Installation metadata */}\n {(\"provider\" in initial) && (\n <div className=\"px-5 py-4 border-t grid grid-cols-2 gap-x-4 gap-y-1 text-[12px]\" style={{ borderColor: \"var(--color-border-muted)\" }}>\n <div className=\"flex items-center gap-2\">\n <span style={{ color: \"var(--color-text-muted)\" }}>Provider:</span>\n <span style={{ color: \"var(--color-text-primary)\" }}>{initial.provider}</span>\n </div>\n <div className=\"flex items-center gap-2\">\n <span style={{ color: \"var(--color-text-muted)\" }}>Device:</span>\n <span style={{ color: \"var(--color-text-primary)\" }}>{initial.device}</span>\n </div>\n {initial.installStart && (\n <div className=\"flex items-center gap-2\">\n <span style={{ color: \"var(--color-text-muted)\" }}>Install start:</span>\n <span style={{ color: \"var(--color-text-primary)\" }}>{initial.installStart}</span>\n </div>\n )}\n {initial.installEnd && (\n <div className=\"flex items-center gap-2\">\n <span style={{ color: \"var(--color-text-muted)\" }}>Install end:</span>\n <span style={{ color: \"var(--color-text-primary)\" }}>{initial.installEnd}</span>\n </div>\n )}\n {initial.igLead && (\n <div className=\"flex items-center gap-2\">\n <span style={{ color: \"var(--color-text-muted)\" }}>IG lead:</span>\n <span style={{ color: \"var(--color-text-primary)\" }}>{initial.igLead}</span>\n </div>\n )}\n </div>\n )}\n </div>\n );\n}\n\n/* ─── Applicant info card ────────────────────────────────────────────────── */\n\nfunction ApplicantInfoCard({ app }: { app: typeof APPLICATIONS[number] }) {\n return (\n <div className=\"rounded-2xl border p-5\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <h3 className=\"text-[11px] font-semibold uppercase tracking-[0.12em] mb-3\" style={{ color: \"var(--color-text-muted)\" }}>Applicant</h3>\n <dl className=\"space-y-2.5 text-[12px]\">\n <Row2 label=\"Email\" value={app.email} />\n <Row2 label=\"Institution\" value={app.institution} />\n <Row2 label=\"Country\" value={app.country} />\n {app.specialty && <Row2 label=\"Specialty\" value={app.specialty} />}\n {app.trainingGrade && <Row2 label=\"Training grade\" value={app.trainingGrade} />}\n {app.procedures && app.procedures.length > 0 && (\n <Row2 label=\"Procedures\" value={app.procedures.join(\", \")} />\n )}\n {app.industryPartner && <Row2 label=\"Sponsor\" value={app.industryPartner} />}\n <Row2 label=\"Applied\" value={`${app.appliedHoursAgo < 24 ? `${app.appliedHoursAgo}h` : `${Math.round(app.appliedHoursAgo / 24)}d`} ago`} />\n {app.assignedAdmin && <Row2 label=\"Assigned\" value={app.assignedAdmin} />}\n </dl>\n </div>\n );\n}\n\nfunction Row2({ label, value }: { label: string; value: string }) {\n return (\n <div>\n <dt className=\"text-[10px] uppercase tracking-wide\" style={{ color: \"var(--color-text-muted)\" }}>{label}</dt>\n <dd className=\"text-[12px] mt-0.5\" style={{ color: \"var(--color-text-primary)\" }}>{value}</dd>\n </div>\n );\n}\n\n/* ─── Audit log card ─────────────────────────────────────────────────────── */\n\nfunction AuditLogCard({ appId }: { appId: string }) {\n const log = AUDIT_LOG[appId] ?? [];\n return (\n <div className=\"rounded-2xl border p-5\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <h3 className=\"text-[11px] font-semibold uppercase tracking-[0.12em] mb-3\" style={{ color: \"var(--color-text-muted)\" }}>Audit log</h3>\n {log.length === 0 ? (\n <p className=\"text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>No actions recorded yet.</p>\n ) : (\n <ol className=\"space-y-3 text-[12px]\">\n {log.map((entry) => (\n <li key={entry.id} className=\"flex items-start gap-2.5\">\n <div className=\"w-1.5 h-1.5 rounded-full mt-1.5 shrink-0\" style={{ background: \"var(--teal-500)\" }} />\n <div className=\"flex-1 min-w-0\">\n <div className=\"font-medium\" style={{ color: \"var(--color-text-primary)\" }}>{entry.action}</div>\n {entry.detail && <div className=\"text-[11px] mt-0.5\" style={{ color: \"var(--color-text-secondary)\" }}>{entry.detail}</div>}\n <div className=\"text-[10px] mt-1\" style={{ color: \"var(--color-text-muted)\" }}>\n {new Date(entry.timestamp).toLocaleString()} · {entry.admin}\n </div>\n </div>\n </li>\n ))}\n </ol>\n )}\n </div>\n );\n}\n"
|
|
80
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/onboarding/page.tsx",
|
|
4
|
+
"sha256": "f6744f93aa6ee8fd45cfd6164be4298001735cc94774fa674022df4d06fce1fd",
|
|
5
|
+
"imports": [
|
|
6
|
+
"next/link",
|
|
7
|
+
"react",
|
|
8
|
+
"next/navigation",
|
|
9
|
+
"lucide-react",
|
|
10
|
+
"@/components/ui/input",
|
|
11
|
+
"@/components/ui/button",
|
|
12
|
+
"@/components/ui/badge",
|
|
13
|
+
"@/components/ui/table",
|
|
14
|
+
"@/components/admin/admin-shell",
|
|
15
|
+
"@/components/auth/status-indicator",
|
|
16
|
+
"@/lib/admin-mock",
|
|
17
|
+
"@/lib/verification",
|
|
18
|
+
"@/components/auth/onboarding-shell"
|
|
19
|
+
],
|
|
20
|
+
"tokens": [
|
|
21
|
+
"--color-bg-brand-muted",
|
|
22
|
+
"--color-bg-subtle",
|
|
23
|
+
"--color-border-default",
|
|
24
|
+
"--color-status-error-bg",
|
|
25
|
+
"--color-status-error-text",
|
|
26
|
+
"--color-status-warning-bg",
|
|
27
|
+
"--color-status-warning-text",
|
|
28
|
+
"--color-text-brand",
|
|
29
|
+
"--color-text-muted",
|
|
30
|
+
"--color-text-primary",
|
|
31
|
+
"--color-text-secondary",
|
|
32
|
+
"--teal-500"
|
|
33
|
+
],
|
|
34
|
+
"fragments": [
|
|
35
|
+
{
|
|
36
|
+
"id": "OnboardingDashboardPage",
|
|
37
|
+
"line": 19,
|
|
38
|
+
"code": "export default function OnboardingDashboardPage() {\n const search = useSearchParams();\n\n const [q, setQ] = useState(\"\");\n const [roleFilter, setRoleFilter] = useState<Role | \"all\">(\"all\");\n const [statusFilter, setStatusFilter] = useState<VerificationStatus | \"all\">(\n (search.get(\"filter\") as VerificationStatus) ?? \"all\"\n );\n const [sort, setSort] = useState<\"newest\" | \"oldest\" | \"sla\">(\"sla\");\n\n const filtered = useMemo(() => {\n let items = [...APPLICATIONS];\n if (roleFilter !== \"all\") items = items.filter((a) => a.role === roleFilter);\n if (statusFilter !== \"all\") items = items.filter((a) => a.status === statusFilter);\n if (q) {\n const needle = q.toLowerCase();\n items = items.filter((a) =>\n a.name.toLowerCase().includes(needle) ||\n a.email.toLowerCase().includes(needle) ||\n a.institution.toLowerCase().includes(needle)\n );\n }\n if (sort === \"newest\") items.sort((a, b) => a.appliedHoursAgo - b.appliedHoursAgo);\n if (sort === \"oldest\") items.sort((a, b) => b.appliedHoursAgo - a.appliedHoursAgo);\n if (sort === \"sla\") {\n const rank = (a: Application) => {\n const s = slaState(a.role, a.status, a.appliedHoursAgo);\n return s === \"breach\" ? 0 : s === \"approaching\" ? 1 : 2;\n };\n items.sort((a, b) => rank(a) - rank(b));\n }\n return items;\n }, [q, roleFilter, statusFilter, sort]);\n\n // Metrics\n const totals = useMemo(() => {\n const active = APPLICATIONS.length;\n const breach = APPLICATIONS.filter((a) => slaState(a.role, a.status, a.appliedHoursAgo) === \"breach\").length;\n const approaching = APPLICATIONS.filter((a) => slaState(a.role, a.status, a.appliedHoursAgo) === \"approaching\").length;\n return { active, breach, approaching };\n }, []);\n\n const statusCounts = useMemo(() => {\n const counts: Record<string, number> = {};\n for (const a of APPLICATIONS) counts[a.status] = (counts[a.status] || 0) + 1;\n return counts;\n }, []);\n\n return (\n <AdminShell>\n {/* Heading */}\n <div className=\"mb-8\">\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Onboarding queue\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Verify credentials, track hardware, activate accounts.\n </p>\n </div>\n\n {/* Metric cards */}\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6\">\n <MetricCard label=\"Pending applications\" value={totals.active} icon={<Users size={16} strokeWidth={1.7} />} />\n <MetricCard label=\"Approaching SLA\" value={totals.approaching} icon={<Clock size={16} strokeWidth={1.7} />} accent=\"warning\" />\n <MetricCard label=\"SLA breached\" value={totals.breach} icon={<AlertTriangle size={16} strokeWidth={1.7} />} accent=\"error\" />\n </div>\n\n {/* Filters */}\n <div className=\"flex items-center gap-2 mb-4\">\n <div className=\"relative flex-1 max-w-md\">\n <Search size={14} className=\"absolute left-3 top-1/2 -translate-y-1/2\" style={{ color: \"var(--color-text-muted)\" }} />\n <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder=\"Search name, email, institution…\" className=\"h-9 pl-9\" />\n {q && (\n <button onClick={() => setQ(\"\")} className=\"absolute right-3 top-1/2 -translate-y-1/2 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-muted)\" }}>\n <X size={13} />\n </button>\n )}\n </div>\n <FilterButton label=\"Role\" value={roleFilter} onChange={(v) => setRoleFilter(v as Role | \"all\")} options={[\n { value: \"all\", label: \"All roles\" },\n { value: \"mentor\", label: \"Mentors\" },\n { value: \"trainee\", label: \"Trainees\" },\n { value: \"sponsor\", label: \"Sponsors\" },\n ]} />\n <FilterButton label=\"Status\" value={statusFilter} onChange={(v) => setStatusFilter(v as VerificationStatus | \"all\")} options={[\n { value: \"all\", label: \"All statuses\" },\n ...(Object.entries(STATUS_LABEL).map(([v, l]) => ({ value: v, label: `${l}${statusCounts[v] ? ` (${statusCounts[v]})` : \"\"}` }))),\n ]} />\n <FilterButton label=\"Sort\" value={sort} onChange={(v) => setSort(v as typeof sort)} options={[\n { value: \"sla\", label: \"SLA priority\" },\n { value: \"oldest\", label: \"Oldest first\" },\n { value: \"newest\", label: \"Newest first\" },\n ]} />\n </div>\n\n {/* Queue table */}\n <div className=\"rounded-xl border overflow-hidden\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Applicant</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Role</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Status</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>SLA</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Assigned</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Action</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.length === 0 && (\n <TableRow>\n <TableCell colSpan={6} className=\"text-center py-12\" style={{ color: \"var(--color-text-muted)\" }}>\n No applications match these filters.\n </TableCell>\n </TableRow>\n )}\n {filtered.map((app) => (\n <Row key={app.id} app={app} />\n ))}\n </TableBody>\n </Table>\n </div>\n </AdminShell>\n );\n}"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "MetricCard",
|
|
42
|
+
"line": 147,
|
|
43
|
+
"code": "function MetricCard({ label, value, icon, accent }: { label: string; value: number; icon: React.ReactNode; accent?: \"warning\" | \"error\" }) {\n const accentColor = accent === \"error\"\n ? \"var(--color-status-error-text)\"\n : accent === \"warning\"\n ? \"var(--color-status-warning-text)\"\n : \"var(--color-text-brand)\";\n const accentBg = accent === \"error\"\n ? \"var(--color-status-error-bg)\"\n : accent === \"warning\"\n ? \"var(--color-status-warning-bg)\"\n : \"var(--color-bg-brand-muted)\";\n return (\n <div className=\"rounded-xl border p-4 flex items-center gap-4\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <div className=\"w-10 h-10 rounded-lg flex items-center justify-center shrink-0\" style={{ background: accentBg, color: accentColor }}>\n {icon}\n </div>\n <div>\n <div className=\"text-[11px] font-medium uppercase tracking-wide\" style={{ color: \"var(--color-text-muted)\" }}>{label}</div>\n <div className=\"text-2xl font-semibold tabular-nums\" style={{ color: \"var(--color-text-primary)\", letterSpacing: \"-0.02em\" }}>{value}</div>\n </div>\n </div>\n );\n}"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": "FilterButton",
|
|
47
|
+
"line": 171,
|
|
48
|
+
"code": "function FilterButton({ label, value, onChange, options }: { label: string; value: string; onChange: (v: string) => void; options: { value: string; label: string }[] }) {\n const current = options.find((o) => o.value === value);\n return (\n <div className=\"relative\">\n <select\n value={value}\n onChange={(e) => onChange(e.target.value)}\n className=\"h-9 pl-3 pr-8 rounded-lg border text-[12px] bg-transparent outline-none appearance-none cursor-pointer focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\"\n style={{ borderColor: \"var(--color-border-default)\", color: \"var(--color-text-primary)\" }}\n aria-label={label}\n >\n {options.map((o) => <option key={o.value} value={o.value}>{label}: {o.label}</option>)}\n </select>\n <ChevronDown size={13} strokeWidth={1.8} className=\"absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none\" style={{ color: \"var(--color-text-muted)\" }} />\n </div>\n );\n}"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "Row",
|
|
52
|
+
"line": 189,
|
|
53
|
+
"code": "function Row({ app }: { app: Application }) {\n const sla = slaState(app.role, app.status, app.appliedHoursAgo);\n return (\n <TableRow>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-center gap-3\">\n <div className=\"w-8 h-8 rounded-full flex items-center justify-center text-[10px] font-semibold shrink-0\" style={{ background: \"var(--color-bg-brand-muted)\", color: \"var(--color-text-brand)\" }}>\n {app.name.split(\" \").slice(-2).map((s) => s[0]).join(\"\").slice(0, 2)}\n </div>\n <div className=\"min-w-0\">\n <div className=\"font-medium truncate\" style={{ color: \"var(--color-text-primary)\" }}>{app.name}</div>\n <div className=\"text-[11px] truncate\" style={{ color: \"var(--color-text-muted)\" }}>{app.institution} · {app.country}</div>\n </div>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <Badge variant=\"outline\" className=\"capitalize\">{app.role}</Badge>\n </TableCell>\n <TableCell className=\"px-5 py-3\"><StatusPill status={app.status} size=\"sm\" /></TableCell>\n <TableCell className=\"px-5 py-3\"><SlaCell sla={sla} hours={app.appliedHoursAgo} /></TableCell>\n <TableCell className=\"px-5 py-3\" style={{ color: \"var(--color-text-secondary)\" }}>\n {app.assignedAdmin ?? <span style={{ color: \"var(--color-text-muted)\" }}>Unassigned</span>}\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n <Link href={`/admin/onboarding/${app.id}`}>\n <Button variant=\"outline\" size=\"sm\">\n Review <ArrowRight size={11} />\n </Button>\n </Link>\n </TableCell>\n </TableRow>\n );\n}"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"id": "SlaCell",
|
|
57
|
+
"line": 223,
|
|
58
|
+
"code": "function SlaCell({ sla, hours }: { sla: SlaState; hours: number }) {\n const text = hours < 24 ? `${hours}h ago` : `${Math.round(hours / 24)}d ago`;\n if (sla === \"breach\") {\n return (\n <span className=\"inline-flex items-center gap-1.5 text-[12px] font-medium\" style={{ color: \"var(--color-status-error-text)\" }}>\n <AlertTriangle size={12} strokeWidth={1.8} /> Breach · {text}\n </span>\n );\n }\n if (sla === \"approaching\") {\n return (\n <span className=\"inline-flex items-center gap-1.5 text-[12px] font-medium\" style={{ color: \"var(--color-status-warning-text)\" }}>\n <Clock size={12} strokeWidth={1.8} /> {text}\n </span>\n );\n }\n return <span className=\"text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>{text}</span>;\n}"
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useState, useMemo } from \"react\";\nimport { useSearchParams } from \"next/navigation\";\nimport {\n ArrowRight, AlertTriangle, Clock, Search, X, Users, Filter, ChevronDown,\n} from \"lucide-react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from \"@/components/ui/table\";\nimport { AdminShell } from \"@/components/admin/admin-shell\";\nimport { StatusPill } from \"@/components/auth/status-indicator\";\nimport { APPLICATIONS, type Application } from \"@/lib/admin-mock\";\nimport { VerificationStatus, STATUS_LABEL, slaState, type SlaState } from \"@/lib/verification\";\nimport type { Role } from \"@/components/auth/onboarding-shell\";\n\nexport default function OnboardingDashboardPage() {\n const search = useSearchParams();\n\n const [q, setQ] = useState(\"\");\n const [roleFilter, setRoleFilter] = useState<Role | \"all\">(\"all\");\n const [statusFilter, setStatusFilter] = useState<VerificationStatus | \"all\">(\n (search.get(\"filter\") as VerificationStatus) ?? \"all\"\n );\n const [sort, setSort] = useState<\"newest\" | \"oldest\" | \"sla\">(\"sla\");\n\n const filtered = useMemo(() => {\n let items = [...APPLICATIONS];\n if (roleFilter !== \"all\") items = items.filter((a) => a.role === roleFilter);\n if (statusFilter !== \"all\") items = items.filter((a) => a.status === statusFilter);\n if (q) {\n const needle = q.toLowerCase();\n items = items.filter((a) =>\n a.name.toLowerCase().includes(needle) ||\n a.email.toLowerCase().includes(needle) ||\n a.institution.toLowerCase().includes(needle)\n );\n }\n if (sort === \"newest\") items.sort((a, b) => a.appliedHoursAgo - b.appliedHoursAgo);\n if (sort === \"oldest\") items.sort((a, b) => b.appliedHoursAgo - a.appliedHoursAgo);\n if (sort === \"sla\") {\n const rank = (a: Application) => {\n const s = slaState(a.role, a.status, a.appliedHoursAgo);\n return s === \"breach\" ? 0 : s === \"approaching\" ? 1 : 2;\n };\n items.sort((a, b) => rank(a) - rank(b));\n }\n return items;\n }, [q, roleFilter, statusFilter, sort]);\n\n // Metrics\n const totals = useMemo(() => {\n const active = APPLICATIONS.length;\n const breach = APPLICATIONS.filter((a) => slaState(a.role, a.status, a.appliedHoursAgo) === \"breach\").length;\n const approaching = APPLICATIONS.filter((a) => slaState(a.role, a.status, a.appliedHoursAgo) === \"approaching\").length;\n return { active, breach, approaching };\n }, []);\n\n const statusCounts = useMemo(() => {\n const counts: Record<string, number> = {};\n for (const a of APPLICATIONS) counts[a.status] = (counts[a.status] || 0) + 1;\n return counts;\n }, []);\n\n return (\n <AdminShell>\n {/* Heading */}\n <div className=\"mb-8\">\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Onboarding queue\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Verify credentials, track hardware, activate accounts.\n </p>\n </div>\n\n {/* Metric cards */}\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6\">\n <MetricCard label=\"Pending applications\" value={totals.active} icon={<Users size={16} strokeWidth={1.7} />} />\n <MetricCard label=\"Approaching SLA\" value={totals.approaching} icon={<Clock size={16} strokeWidth={1.7} />} accent=\"warning\" />\n <MetricCard label=\"SLA breached\" value={totals.breach} icon={<AlertTriangle size={16} strokeWidth={1.7} />} accent=\"error\" />\n </div>\n\n {/* Filters */}\n <div className=\"flex items-center gap-2 mb-4\">\n <div className=\"relative flex-1 max-w-md\">\n <Search size={14} className=\"absolute left-3 top-1/2 -translate-y-1/2\" style={{ color: \"var(--color-text-muted)\" }} />\n <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder=\"Search name, email, institution…\" className=\"h-9 pl-9\" />\n {q && (\n <button onClick={() => setQ(\"\")} className=\"absolute right-3 top-1/2 -translate-y-1/2 transition-opacity hover:opacity-70\" style={{ color: \"var(--color-text-muted)\" }}>\n <X size={13} />\n </button>\n )}\n </div>\n <FilterButton label=\"Role\" value={roleFilter} onChange={(v) => setRoleFilter(v as Role | \"all\")} options={[\n { value: \"all\", label: \"All roles\" },\n { value: \"mentor\", label: \"Mentors\" },\n { value: \"trainee\", label: \"Trainees\" },\n { value: \"sponsor\", label: \"Sponsors\" },\n ]} />\n <FilterButton label=\"Status\" value={statusFilter} onChange={(v) => setStatusFilter(v as VerificationStatus | \"all\")} options={[\n { value: \"all\", label: \"All statuses\" },\n ...(Object.entries(STATUS_LABEL).map(([v, l]) => ({ value: v, label: `${l}${statusCounts[v] ? ` (${statusCounts[v]})` : \"\"}` }))),\n ]} />\n <FilterButton label=\"Sort\" value={sort} onChange={(v) => setSort(v as typeof sort)} options={[\n { value: \"sla\", label: \"SLA priority\" },\n { value: \"oldest\", label: \"Oldest first\" },\n { value: \"newest\", label: \"Newest first\" },\n ]} />\n </div>\n\n {/* Queue table */}\n <div className=\"rounded-xl border overflow-hidden\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Applicant</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Role</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Status</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>SLA</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Assigned</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Action</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.length === 0 && (\n <TableRow>\n <TableCell colSpan={6} className=\"text-center py-12\" style={{ color: \"var(--color-text-muted)\" }}>\n No applications match these filters.\n </TableCell>\n </TableRow>\n )}\n {filtered.map((app) => (\n <Row key={app.id} app={app} />\n ))}\n </TableBody>\n </Table>\n </div>\n </AdminShell>\n );\n}\n\n/* ─── Sub-components ─────────────────────────────────────────────────────── */\n\nfunction MetricCard({ label, value, icon, accent }: { label: string; value: number; icon: React.ReactNode; accent?: \"warning\" | \"error\" }) {\n const accentColor = accent === \"error\"\n ? \"var(--color-status-error-text)\"\n : accent === \"warning\"\n ? \"var(--color-status-warning-text)\"\n : \"var(--color-text-brand)\";\n const accentBg = accent === \"error\"\n ? \"var(--color-status-error-bg)\"\n : accent === \"warning\"\n ? \"var(--color-status-warning-bg)\"\n : \"var(--color-bg-brand-muted)\";\n return (\n <div className=\"rounded-xl border p-4 flex items-center gap-4\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <div className=\"w-10 h-10 rounded-lg flex items-center justify-center shrink-0\" style={{ background: accentBg, color: accentColor }}>\n {icon}\n </div>\n <div>\n <div className=\"text-[11px] font-medium uppercase tracking-wide\" style={{ color: \"var(--color-text-muted)\" }}>{label}</div>\n <div className=\"text-2xl font-semibold tabular-nums\" style={{ color: \"var(--color-text-primary)\", letterSpacing: \"-0.02em\" }}>{value}</div>\n </div>\n </div>\n );\n}\n\nfunction FilterButton({ label, value, onChange, options }: { label: string; value: string; onChange: (v: string) => void; options: { value: string; label: string }[] }) {\n const current = options.find((o) => o.value === value);\n return (\n <div className=\"relative\">\n <select\n value={value}\n onChange={(e) => onChange(e.target.value)}\n className=\"h-9 pl-3 pr-8 rounded-lg border text-[12px] bg-transparent outline-none appearance-none cursor-pointer focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\"\n style={{ borderColor: \"var(--color-border-default)\", color: \"var(--color-text-primary)\" }}\n aria-label={label}\n >\n {options.map((o) => <option key={o.value} value={o.value}>{label}: {o.label}</option>)}\n </select>\n <ChevronDown size={13} strokeWidth={1.8} className=\"absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none\" style={{ color: \"var(--color-text-muted)\" }} />\n </div>\n );\n}\n\nfunction Row({ app }: { app: Application }) {\n const sla = slaState(app.role, app.status, app.appliedHoursAgo);\n return (\n <TableRow>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-center gap-3\">\n <div className=\"w-8 h-8 rounded-full flex items-center justify-center text-[10px] font-semibold shrink-0\" style={{ background: \"var(--color-bg-brand-muted)\", color: \"var(--color-text-brand)\" }}>\n {app.name.split(\" \").slice(-2).map((s) => s[0]).join(\"\").slice(0, 2)}\n </div>\n <div className=\"min-w-0\">\n <div className=\"font-medium truncate\" style={{ color: \"var(--color-text-primary)\" }}>{app.name}</div>\n <div className=\"text-[11px] truncate\" style={{ color: \"var(--color-text-muted)\" }}>{app.institution} · {app.country}</div>\n </div>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <Badge variant=\"outline\" className=\"capitalize\">{app.role}</Badge>\n </TableCell>\n <TableCell className=\"px-5 py-3\"><StatusPill status={app.status} size=\"sm\" /></TableCell>\n <TableCell className=\"px-5 py-3\"><SlaCell sla={sla} hours={app.appliedHoursAgo} /></TableCell>\n <TableCell className=\"px-5 py-3\" style={{ color: \"var(--color-text-secondary)\" }}>\n {app.assignedAdmin ?? <span style={{ color: \"var(--color-text-muted)\" }}>Unassigned</span>}\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n <Link href={`/admin/onboarding/${app.id}`}>\n <Button variant=\"outline\" size=\"sm\">\n Review <ArrowRight size={11} />\n </Button>\n </Link>\n </TableCell>\n </TableRow>\n );\n}\n\nfunction SlaCell({ sla, hours }: { sla: SlaState; hours: number }) {\n const text = hours < 24 ? `${hours}h ago` : `${Math.round(hours / 24)}d ago`;\n if (sla === \"breach\") {\n return (\n <span className=\"inline-flex items-center gap-1.5 text-[12px] font-medium\" style={{ color: \"var(--color-status-error-text)\" }}>\n <AlertTriangle size={12} strokeWidth={1.8} /> Breach · {text}\n </span>\n );\n }\n if (sla === \"approaching\") {\n return (\n <span className=\"inline-flex items-center gap-1.5 text-[12px] font-medium\" style={{ color: \"var(--color-status-warning-text)\" }}>\n <Clock size={12} strokeWidth={1.8} /> {text}\n </span>\n );\n }\n return <span className=\"text-[12px]\" style={{ color: \"var(--color-text-muted)\" }}>{text}</span>;\n}\n"
|
|
62
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/payments/page.tsx",
|
|
4
|
+
"sha256": "424ec9e65ef5b35eeb6efabea708b38a59f90e3dd81a8cc8d89907c6a1dea85a",
|
|
5
|
+
"imports": [
|
|
6
|
+
"react",
|
|
7
|
+
"lucide-react",
|
|
8
|
+
"@/components/ui/button",
|
|
9
|
+
"@/components/ui/badge",
|
|
10
|
+
"@/components/ui/card",
|
|
11
|
+
"@/components/ui/table",
|
|
12
|
+
"@/components/admin/admin-shell",
|
|
13
|
+
"@/components/portal/empty-state",
|
|
14
|
+
"@/lib/closures-mock",
|
|
15
|
+
"@/lib/credits"
|
|
16
|
+
],
|
|
17
|
+
"tokens": [
|
|
18
|
+
"--color-bg-brand-muted",
|
|
19
|
+
"--color-bg-subtle",
|
|
20
|
+
"--color-border-default",
|
|
21
|
+
"--color-status-info-bg",
|
|
22
|
+
"--color-status-info-text",
|
|
23
|
+
"--color-status-success-bg",
|
|
24
|
+
"--color-status-success-text",
|
|
25
|
+
"--color-status-warning-bg",
|
|
26
|
+
"--color-status-warning-text",
|
|
27
|
+
"--color-text-brand",
|
|
28
|
+
"--color-text-muted",
|
|
29
|
+
"--color-text-secondary",
|
|
30
|
+
"--teal-500"
|
|
31
|
+
],
|
|
32
|
+
"fragments": [
|
|
33
|
+
{
|
|
34
|
+
"id": "PaymentsPage",
|
|
35
|
+
"line": 20,
|
|
36
|
+
"code": "export default function PaymentsPage() {\n const [filter, setFilter] = useState<PaymentStatus | \"all\">(\"all\");\n\n const filtered = useMemo(() => {\n if (filter === \"all\") return PAYMENTS;\n return PAYMENTS.filter((p) => p.status === filter);\n }, [filter]);\n\n const totals = useMemo(() => ({\n queued: PAYMENTS.filter((p) => p.status === \"queued\").reduce((a, p) => a + p.totalGBP, 0),\n invoiced: PAYMENTS.filter((p) => p.status === \"invoiced\").reduce((a, p) => a + p.totalGBP, 0),\n paid: PAYMENTS.filter((p) => p.status === \"paid\").reduce((a, p) => a + p.totalGBP, 0),\n }), []);\n\n return (\n <AdminShell>\n <div className=\"flex items-start justify-between gap-4 mb-6\">\n <div>\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Mentor payments\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Monthly payment cycle. Export to CSV → process via bank transfer → mark Paid.\n </p>\n </div>\n <div className=\"flex items-center gap-2\">\n <Button variant=\"outline\"><Download size={13} /> Export CSV</Button>\n <Button variant=\"brand\"><FileCheck size={13} /> Mark all queued as invoiced</Button>\n </div>\n </div>\n\n {/* Totals */}\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6\">\n <Total icon={<Clock size={16} />} label=\"Queued for invoice\" amount={totals.queued} accent=\"warning\" />\n <Total icon={<Coins size={16} />} label=\"Invoiced — awaiting\" amount={totals.invoiced} accent=\"info\" />\n <Total icon={<CheckCircle2 size={16} />} label=\"Paid this month\" amount={totals.paid} accent=\"success\" />\n </div>\n\n {/* Filters */}\n <div className=\"flex items-center gap-2 mb-4\">\n <div className=\"relative\">\n <select\n value={filter}\n onChange={(e) => setFilter(e.target.value as PaymentStatus | \"all\")}\n className=\"h-9 pl-3 pr-8 rounded-lg border text-[12px] bg-transparent outline-none appearance-none cursor-pointer focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\"\n style={{ borderColor: \"var(--color-border-default)\" }}\n >\n <option value=\"all\">All payments</option>\n <option value=\"queued\">Queued</option>\n <option value=\"invoiced\">Invoiced</option>\n <option value=\"paid\">Paid</option>\n </select>\n <ChevronDown size={13} strokeWidth={1.8} className=\"absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none\" style={{ color: \"var(--color-text-muted)\" }} />\n </div>\n </div>\n\n {filtered.length === 0 ? (\n <EmptyState icon={Coins} title=\"No payments in this view\" description=\"Try a different filter.\" />\n ) : (\n <Card className=\"p-0\">\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Mentor</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Sessions</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Amount</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Status</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Reference</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Action</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.map((p) => {\n const meta = STATUS_META[p.status];\n return (\n <TableRow key={p.id}>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-center gap-3\">\n <div className=\"w-8 h-8 rounded-full flex items-center justify-center text-[10px] font-semibold shrink-0\" style={{ background: \"var(--color-bg-brand-muted)\", color: \"var(--color-text-brand)\" }}>\n {p.mentorInitials}\n </div>\n <span className=\"text-[13px] font-medium\">{p.mentor}</span>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right tabular-nums text-[12px]\">{p.sessions}</TableCell>\n <TableCell className=\"px-5 py-3 text-right tabular-nums font-medium\">{gbp(p.totalGBP)}</TableCell>\n <TableCell className=\"px-5 py-3\">\n <span className=\"inline-flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide px-2 py-1 rounded-md\" style={{ background: meta.bg, color: meta.fg }}>\n <span className=\"w-1.5 h-1.5 rounded-full\" style={{ background: meta.fg }} />\n {meta.label}\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>\n {p.reference ?? (p.invoicedAt ? `Invoiced ${p.invoicedAt}` : \"—\")}\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n {p.status === \"queued\" && <Button variant=\"outline\" size=\"sm\">Mark invoiced</Button>}\n {p.status === \"invoiced\" && <Button variant=\"brand\" size=\"sm\">Mark paid</Button>}\n {p.status === \"paid\" && <Button variant=\"outline\" size=\"sm\">View receipt</Button>}\n </TableCell>\n </TableRow>\n );\n })}\n </TableBody>\n </Table>\n </Card>\n )}\n </AdminShell>\n );\n}"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "Total",
|
|
40
|
+
"line": 131,
|
|
41
|
+
"code": "function Total({ icon, label, amount, accent }: { icon: React.ReactNode; label: string; amount: number; accent: \"warning\" | \"info\" | \"success\" }) {\n const bg = accent === \"warning\" ? \"var(--color-status-warning-bg)\" : accent === \"info\" ? \"var(--color-status-info-bg)\" : \"var(--color-status-success-bg)\";\n const fg = accent === \"warning\" ? \"var(--color-status-warning-text)\" : accent === \"info\" ? \"var(--color-status-info-text)\" : \"var(--color-status-success-text)\";\n return (\n <Card>\n <CardHeader>\n <div className=\"flex items-center gap-3\">\n <div className=\"w-10 h-10 rounded-lg flex items-center justify-center shrink-0\" style={{ background: bg, color: fg }}>{icon}</div>\n <div>\n <div className=\"text-[10px] font-medium uppercase tracking-wide\" style={{ color: \"var(--color-text-muted)\" }}>{label}</div>\n <div className=\"text-2xl font-semibold tabular-nums\" style={{ letterSpacing: \"-0.02em\" }}>{gbp(amount)}</div>\n </div>\n </div>\n </CardHeader>\n </Card>\n );\n}"
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"content": "\"use client\";\n\nimport { useState, useMemo } from \"react\";\nimport { Download, ChevronDown, Coins, CheckCircle2, Clock, FileCheck } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Card, CardHeader, CardTitle, CardContent } from \"@/components/ui/card\";\nimport { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from \"@/components/ui/table\";\nimport { AdminShell } from \"@/components/admin/admin-shell\";\nimport { EmptyState } from \"@/components/portal/empty-state\";\nimport { PAYMENTS, type PaymentStatus } from \"@/lib/closures-mock\";\nimport { gbp } from \"@/lib/credits\";\n\nconst STATUS_META: Record<PaymentStatus, { label: string; bg: string; fg: string }> = {\n queued: { label: \"Queued\", bg: \"var(--color-status-warning-bg)\", fg: \"var(--color-status-warning-text)\" },\n invoiced: { label: \"Invoiced\", bg: \"var(--color-status-info-bg)\", fg: \"var(--color-status-info-text)\" },\n paid: { label: \"Paid\", bg: \"var(--color-status-success-bg)\", fg: \"var(--color-status-success-text)\" },\n};\n\nexport default function PaymentsPage() {\n const [filter, setFilter] = useState<PaymentStatus | \"all\">(\"all\");\n\n const filtered = useMemo(() => {\n if (filter === \"all\") return PAYMENTS;\n return PAYMENTS.filter((p) => p.status === filter);\n }, [filter]);\n\n const totals = useMemo(() => ({\n queued: PAYMENTS.filter((p) => p.status === \"queued\").reduce((a, p) => a + p.totalGBP, 0),\n invoiced: PAYMENTS.filter((p) => p.status === \"invoiced\").reduce((a, p) => a + p.totalGBP, 0),\n paid: PAYMENTS.filter((p) => p.status === \"paid\").reduce((a, p) => a + p.totalGBP, 0),\n }), []);\n\n return (\n <AdminShell>\n <div className=\"flex items-start justify-between gap-4 mb-6\">\n <div>\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Mentor payments\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Monthly payment cycle. Export to CSV → process via bank transfer → mark Paid.\n </p>\n </div>\n <div className=\"flex items-center gap-2\">\n <Button variant=\"outline\"><Download size={13} /> Export CSV</Button>\n <Button variant=\"brand\"><FileCheck size={13} /> Mark all queued as invoiced</Button>\n </div>\n </div>\n\n {/* Totals */}\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6\">\n <Total icon={<Clock size={16} />} label=\"Queued for invoice\" amount={totals.queued} accent=\"warning\" />\n <Total icon={<Coins size={16} />} label=\"Invoiced — awaiting\" amount={totals.invoiced} accent=\"info\" />\n <Total icon={<CheckCircle2 size={16} />} label=\"Paid this month\" amount={totals.paid} accent=\"success\" />\n </div>\n\n {/* Filters */}\n <div className=\"flex items-center gap-2 mb-4\">\n <div className=\"relative\">\n <select\n value={filter}\n onChange={(e) => setFilter(e.target.value as PaymentStatus | \"all\")}\n className=\"h-9 pl-3 pr-8 rounded-lg border text-[12px] bg-transparent outline-none appearance-none cursor-pointer focus-visible:ring-3 focus-visible:ring-[var(--teal-500)]/30\"\n style={{ borderColor: \"var(--color-border-default)\" }}\n >\n <option value=\"all\">All payments</option>\n <option value=\"queued\">Queued</option>\n <option value=\"invoiced\">Invoiced</option>\n <option value=\"paid\">Paid</option>\n </select>\n <ChevronDown size={13} strokeWidth={1.8} className=\"absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none\" style={{ color: \"var(--color-text-muted)\" }} />\n </div>\n </div>\n\n {filtered.length === 0 ? (\n <EmptyState icon={Coins} title=\"No payments in this view\" description=\"Try a different filter.\" />\n ) : (\n <Card className=\"p-0\">\n <Table>\n <TableHeader style={{ background: \"var(--color-bg-subtle)\" }}>\n <TableRow>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Mentor</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Sessions</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Amount</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Status</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Reference</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Action</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.map((p) => {\n const meta = STATUS_META[p.status];\n return (\n <TableRow key={p.id}>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-center gap-3\">\n <div className=\"w-8 h-8 rounded-full flex items-center justify-center text-[10px] font-semibold shrink-0\" style={{ background: \"var(--color-bg-brand-muted)\", color: \"var(--color-text-brand)\" }}>\n {p.mentorInitials}\n </div>\n <span className=\"text-[13px] font-medium\">{p.mentor}</span>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right tabular-nums text-[12px]\">{p.sessions}</TableCell>\n <TableCell className=\"px-5 py-3 text-right tabular-nums font-medium\">{gbp(p.totalGBP)}</TableCell>\n <TableCell className=\"px-5 py-3\">\n <span className=\"inline-flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide px-2 py-1 rounded-md\" style={{ background: meta.bg, color: meta.fg }}>\n <span className=\"w-1.5 h-1.5 rounded-full\" style={{ background: meta.fg }} />\n {meta.label}\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>\n {p.reference ?? (p.invoicedAt ? `Invoiced ${p.invoicedAt}` : \"—\")}\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n {p.status === \"queued\" && <Button variant=\"outline\" size=\"sm\">Mark invoiced</Button>}\n {p.status === \"invoiced\" && <Button variant=\"brand\" size=\"sm\">Mark paid</Button>}\n {p.status === \"paid\" && <Button variant=\"outline\" size=\"sm\">View receipt</Button>}\n </TableCell>\n </TableRow>\n );\n })}\n </TableBody>\n </Table>\n </Card>\n )}\n </AdminShell>\n );\n}\n\nfunction Total({ icon, label, amount, accent }: { icon: React.ReactNode; label: string; amount: number; accent: \"warning\" | \"info\" | \"success\" }) {\n const bg = accent === \"warning\" ? \"var(--color-status-warning-bg)\" : accent === \"info\" ? \"var(--color-status-info-bg)\" : \"var(--color-status-success-bg)\";\n const fg = accent === \"warning\" ? \"var(--color-status-warning-text)\" : accent === \"info\" ? \"var(--color-status-info-text)\" : \"var(--color-status-success-text)\";\n return (\n <Card>\n <CardHeader>\n <div className=\"flex items-center gap-3\">\n <div className=\"w-10 h-10 rounded-lg flex items-center justify-center shrink-0\" style={{ background: bg, color: fg }}>{icon}</div>\n <div>\n <div className=\"text-[10px] font-medium uppercase tracking-wide\" style={{ color: \"var(--color-text-muted)\" }}>{label}</div>\n <div className=\"text-2xl font-semibold tabular-nums\" style={{ letterSpacing: \"-0.02em\" }}>{gbp(amount)}</div>\n </div>\n </div>\n </CardHeader>\n </Card>\n );\n}\n"
|
|
45
|
+
}
|