@utopia-studio-design/design-system-cli 0.8.1 → 0.8.3
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 +15 -4
- 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/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-publish-2026-09-13.md +31 -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/patterns.json +330 -0
- 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 +230 -4
- package/package.json +2 -2
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/analytics/page.tsx",
|
|
4
|
+
"sha256": "7ef27f157c7f16f8bd98a5b684e5193f6e2f9bd0f2e1cf5e16cdf231e28240aa",
|
|
5
|
+
"imports": [
|
|
6
|
+
"next/link",
|
|
7
|
+
"lucide-react",
|
|
8
|
+
"@/components/ui/button",
|
|
9
|
+
"@/components/ui/badge",
|
|
10
|
+
"@/components/ui/card",
|
|
11
|
+
"@/components/ui/tabs",
|
|
12
|
+
"@/components/ui/table",
|
|
13
|
+
"@/components/admin/admin-shell",
|
|
14
|
+
"@/components/portal/usage-sparkline",
|
|
15
|
+
"@/lib/sessions-mock"
|
|
16
|
+
],
|
|
17
|
+
"tokens": [
|
|
18
|
+
"--amber-500",
|
|
19
|
+
"--color-bg-brand-muted",
|
|
20
|
+
"--color-bg-subtle",
|
|
21
|
+
"--color-border-default",
|
|
22
|
+
"--color-status-error-text",
|
|
23
|
+
"--color-status-success-text",
|
|
24
|
+
"--color-status-warning-text",
|
|
25
|
+
"--color-text-brand",
|
|
26
|
+
"--color-text-muted",
|
|
27
|
+
"--color-text-secondary",
|
|
28
|
+
"--red-500",
|
|
29
|
+
"--teal-500"
|
|
30
|
+
],
|
|
31
|
+
"fragments": [
|
|
32
|
+
{
|
|
33
|
+
"id": "AnalyticsPage",
|
|
34
|
+
"line": 36,
|
|
35
|
+
"code": "export default function AnalyticsPage() {\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 Platform analytics\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Session quality, mentor performance, trainee progression, and risk flags.\n </p>\n </div>\n <Button variant=\"outline\"><Download size={13} /> Export report</Button>\n </div>\n\n {/* KPI row */}\n <div className=\"grid grid-cols-2 md:grid-cols-4 gap-3 mb-6\">\n <KpiCard label=\"Sessions YTD\" value=\"56\" sub=\"↑ 32% vs Q1\" icon={<Activity size={16} />} />\n <KpiCard label=\"Active users\" value=\"89\" sub=\"trainees + mentors\" icon={<Users size={16} />} />\n <KpiCard label=\"Assessment SLA\" value=\"92%\" sub=\"< 48h submission rate\" icon={<Clock size={16} />} />\n <KpiCard label=\"System uptime\" value=\"99.97%\" sub=\"last 30 days\" icon={<TrendingUp size={16} />} />\n </div>\n\n <Tabs defaultValue=\"overview\">\n <TabsList>\n <TabsTrigger value=\"overview\">Overview</TabsTrigger>\n <TabsTrigger value=\"mentor\">Mentor performance</TabsTrigger>\n <TabsTrigger value=\"trainee\">Trainee progression</TabsTrigger>\n <TabsTrigger value=\"risk\">Risk flags ({RISK_FLAGS.length})</TabsTrigger>\n </TabsList>\n\n <TabsContent value=\"overview\">\n <div className=\"grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Sessions completed (cumulative)</CardTitle>\n <CardDescription>5-month trend</CardDescription>\n </CardHeader>\n <CardContent>\n <UsageSparkline data={MONTHLY_SESSIONS} max={56} />\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Session quality</CardTitle>\n <CardDescription>Current month</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"space-y-4\">\n <QualityBar label=\"Assessments on time\" value={92} />\n <QualityBar label=\"Full feedback (4 streams)\" value={78} />\n <QualityBar label=\"Sessions with connectivity issues\" value={6} variant=\"warning\" />\n <QualityBar label=\"Sessions with safety events\" value={2} variant=\"error\" />\n </div>\n </CardContent>\n </Card>\n </div>\n </TabsContent>\n\n <TabsContent value=\"mentor\">\n <Card className=\"p-0\">\n <CardHeader className=\"px-5 py-4\">\n <CardTitle className=\"text-base\">Top mentors</CardTitle>\n <CardDescription>By volume and rating.</CardDescription>\n </CardHeader>\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)\" }}>Rating</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)\" }}>Quality</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {MENTORS.slice(0, 6).map((m, i) => (\n <TableRow key={m.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)\" }}>{m.initials}</div>\n <div>\n <div className=\"text-[13px] font-medium\">{m.name}</div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>{m.institution}</div>\n </div>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right tabular-nums\">{m.sessionsDelivered}</TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n <span className=\"inline-flex items-center gap-1 tabular-nums text-[12px]\">\n <Star size={11} fill=\"currentColor\" strokeWidth={0} style={{ color: \"var(--amber-500)\" }} />\n {m.averageRating.toFixed(1)}\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <Badge variant=\"outline\" className=\"text-[10px]\" style={{ color: i === 1 ? \"var(--color-status-warning-text)\" : \"var(--color-status-success-text)\" }}>\n {i === 1 ? \"1 overdue\" : \"On time\"}\n </Badge>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <Badge variant=\"outline\" className=\"text-[10px]\">Pass</Badge>\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </Card>\n </TabsContent>\n\n <TabsContent value=\"trainee\">\n <div className=\"grid grid-cols-1 lg:grid-cols-2 gap-4\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Entrustment distribution</CardTitle>\n <CardDescription>Across all sponsored trainees, current month</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"space-y-3\">\n {ENTRUSTMENT_DISTRIBUTION.map((d) => (\n <div key={d.label}>\n <div className=\"flex items-center justify-between mb-1.5\">\n <span className=\"text-[12px]\">{d.label}</span>\n <span className=\"text-[12px] tabular-nums\" style={{ color: \"var(--color-text-muted)\" }}>{d.count} trainees · {d.pct}%</span>\n </div>\n <div className=\"h-2 rounded-full overflow-hidden\" style={{ background: \"var(--color-border-default)\" }}>\n <div className=\"h-full\" style={{ width: `${d.pct}%`, background: \"var(--teal-500)\" }} />\n </div>\n </div>\n ))}\n </div>\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Average time to \"Independent\"</CardTitle>\n <CardDescription>By procedure type</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"space-y-3 text-[12px]\">\n <Row label=\"ESD\" value=\"12.4 sessions\" />\n <Row label=\"EMR\" value=\"8.7 sessions\" />\n <Row label=\"POEM\" value=\"14.1 sessions\" />\n <Row label=\"ERCP\" value=\"11.2 sessions\" />\n </div>\n </CardContent>\n </Card>\n </div>\n </TabsContent>\n\n <TabsContent value=\"risk\">\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)\" }}>Flag</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Subject</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Detail</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 {RISK_FLAGS.map((r, i) => {\n const Icon = r.severity === \"error\" ? ShieldAlert : AlertTriangle;\n return (\n <TableRow key={i}>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-center gap-2\">\n <Icon size={14} strokeWidth={1.8} style={{ color: r.severity === \"error\" ? \"var(--color-status-error-text)\" : \"var(--color-status-warning-text)\" }} />\n <span className=\"text-[13px] font-medium\">{r.type}</span>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px] font-medium\">{r.trainee}</TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>{r.detail}</TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n <Button variant=\"outline\" size=\"sm\">Review <ArrowRight size={11} /></Button>\n </TableCell>\n </TableRow>\n );\n })}\n </TableBody>\n </Table>\n </Card>\n </TabsContent>\n </Tabs>\n </AdminShell>\n );\n}"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "KpiCard",
|
|
39
|
+
"line": 226,
|
|
40
|
+
"code": "function KpiCard({ label, value, sub, icon }: { label: string; value: string; sub: string; icon: React.ReactNode }) {\n return (\n <Card>\n <CardContent className=\"pt-4 flex items-center gap-3\">\n <div className=\"w-10 h-10 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-bg-brand-muted)\", color: \"var(--color-text-brand)\" }}>\n {icon}\n </div>\n <div className=\"min-w-0\">\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 className=\"text-[10px]\" style={{ color: \"var(--color-text-muted)\" }}>{sub}</div>\n </div>\n </CardContent>\n </Card>\n );\n}"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "QualityBar",
|
|
44
|
+
"line": 243,
|
|
45
|
+
"code": "function QualityBar({ label, value, variant }: { label: string; value: number; variant?: \"warning\" | \"error\" }) {\n const color = variant === \"error\" ? \"var(--red-500)\" : variant === \"warning\" ? \"var(--amber-500)\" : \"var(--teal-500)\";\n return (\n <div>\n <div className=\"flex items-center justify-between mb-1.5\">\n <span className=\"text-[12px]\">{label}</span>\n <span className=\"text-[12px] tabular-nums font-medium\">{value}%</span>\n </div>\n <div className=\"h-1.5 rounded-full overflow-hidden\" style={{ background: \"var(--color-border-default)\" }}>\n <div className=\"h-full\" style={{ width: `${value}%`, background: color }} />\n </div>\n </div>\n );\n}"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "Row",
|
|
49
|
+
"line": 258,
|
|
50
|
+
"code": "function Row({ label, value }: { label: string; value: string }) {\n return (\n <div className=\"flex items-center justify-between\">\n <span style={{ color: \"var(--color-text-muted)\" }}>{label}</span>\n <span className=\"font-medium tabular-nums\">{value}</span>\n </div>\n );\n}"
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { ArrowRight, Activity, AlertTriangle, Users, TrendingUp, Star, Clock, ShieldAlert, Download } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Card, CardHeader, CardTitle, CardDescription, CardContent } from \"@/components/ui/card\";\nimport { Tabs, TabsList, TabsTrigger, TabsContent } from \"@/components/ui/tabs\";\nimport { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from \"@/components/ui/table\";\nimport { AdminShell } from \"@/components/admin/admin-shell\";\nimport { UsageSparkline } from \"@/components/portal/usage-sparkline\";\nimport { MENTORS } from \"@/lib/sessions-mock\";\n\nconst MONTHLY_SESSIONS = [\n { month: \"Jan\", cumulative: 4 },\n { month: \"Feb\", cumulative: 11 },\n { month: \"Mar\", cumulative: 22 },\n { month: \"Apr\", cumulative: 38 },\n { month: \"May\", cumulative: 56 },\n];\n\nconst RISK_FLAGS = [\n { type: \"Stagnant progression\", trainee: \"Dr. Aaron Cohen\", detail: \"5 sessions, no entrustment advancement\", severity: \"warning\" as const },\n { type: \"Regression detected\", trainee: \"Dr. Jamal Khan\", detail: \"GRS dropped 1.2 points across last 2 sessions\", severity: \"error\" as const },\n { type: \"Hardware failures\", trainee: \"Mount Sinai site\", detail: \"3 connectivity issues in 14 days\", severity: \"warning\" as const },\n { type: \"Safety event\", trainee: \"Dr. Yuki Tanaka\", detail: \"Unexpected bleeding flagged · session #2401\", severity: \"error\" as const },\n];\n\nconst ENTRUSTMENT_DISTRIBUTION = [\n { label: \"Observation Only\", count: 6, pct: 18 },\n { label: \"Active Assistance\", count: 12, pct: 36 },\n { label: \"Supervision\", count: 11, pct: 33 },\n { label: \"Independent\", count: 4, pct: 13 },\n];\n\nexport default function AnalyticsPage() {\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 Platform analytics\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Session quality, mentor performance, trainee progression, and risk flags.\n </p>\n </div>\n <Button variant=\"outline\"><Download size={13} /> Export report</Button>\n </div>\n\n {/* KPI row */}\n <div className=\"grid grid-cols-2 md:grid-cols-4 gap-3 mb-6\">\n <KpiCard label=\"Sessions YTD\" value=\"56\" sub=\"↑ 32% vs Q1\" icon={<Activity size={16} />} />\n <KpiCard label=\"Active users\" value=\"89\" sub=\"trainees + mentors\" icon={<Users size={16} />} />\n <KpiCard label=\"Assessment SLA\" value=\"92%\" sub=\"< 48h submission rate\" icon={<Clock size={16} />} />\n <KpiCard label=\"System uptime\" value=\"99.97%\" sub=\"last 30 days\" icon={<TrendingUp size={16} />} />\n </div>\n\n <Tabs defaultValue=\"overview\">\n <TabsList>\n <TabsTrigger value=\"overview\">Overview</TabsTrigger>\n <TabsTrigger value=\"mentor\">Mentor performance</TabsTrigger>\n <TabsTrigger value=\"trainee\">Trainee progression</TabsTrigger>\n <TabsTrigger value=\"risk\">Risk flags ({RISK_FLAGS.length})</TabsTrigger>\n </TabsList>\n\n <TabsContent value=\"overview\">\n <div className=\"grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Sessions completed (cumulative)</CardTitle>\n <CardDescription>5-month trend</CardDescription>\n </CardHeader>\n <CardContent>\n <UsageSparkline data={MONTHLY_SESSIONS} max={56} />\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Session quality</CardTitle>\n <CardDescription>Current month</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"space-y-4\">\n <QualityBar label=\"Assessments on time\" value={92} />\n <QualityBar label=\"Full feedback (4 streams)\" value={78} />\n <QualityBar label=\"Sessions with connectivity issues\" value={6} variant=\"warning\" />\n <QualityBar label=\"Sessions with safety events\" value={2} variant=\"error\" />\n </div>\n </CardContent>\n </Card>\n </div>\n </TabsContent>\n\n <TabsContent value=\"mentor\">\n <Card className=\"p-0\">\n <CardHeader className=\"px-5 py-4\">\n <CardTitle className=\"text-base\">Top mentors</CardTitle>\n <CardDescription>By volume and rating.</CardDescription>\n </CardHeader>\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)\" }}>Rating</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)\" }}>Quality</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {MENTORS.slice(0, 6).map((m, i) => (\n <TableRow key={m.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)\" }}>{m.initials}</div>\n <div>\n <div className=\"text-[13px] font-medium\">{m.name}</div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>{m.institution}</div>\n </div>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right tabular-nums\">{m.sessionsDelivered}</TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n <span className=\"inline-flex items-center gap-1 tabular-nums text-[12px]\">\n <Star size={11} fill=\"currentColor\" strokeWidth={0} style={{ color: \"var(--amber-500)\" }} />\n {m.averageRating.toFixed(1)}\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <Badge variant=\"outline\" className=\"text-[10px]\" style={{ color: i === 1 ? \"var(--color-status-warning-text)\" : \"var(--color-status-success-text)\" }}>\n {i === 1 ? \"1 overdue\" : \"On time\"}\n </Badge>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <Badge variant=\"outline\" className=\"text-[10px]\">Pass</Badge>\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </Card>\n </TabsContent>\n\n <TabsContent value=\"trainee\">\n <div className=\"grid grid-cols-1 lg:grid-cols-2 gap-4\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Entrustment distribution</CardTitle>\n <CardDescription>Across all sponsored trainees, current month</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"space-y-3\">\n {ENTRUSTMENT_DISTRIBUTION.map((d) => (\n <div key={d.label}>\n <div className=\"flex items-center justify-between mb-1.5\">\n <span className=\"text-[12px]\">{d.label}</span>\n <span className=\"text-[12px] tabular-nums\" style={{ color: \"var(--color-text-muted)\" }}>{d.count} trainees · {d.pct}%</span>\n </div>\n <div className=\"h-2 rounded-full overflow-hidden\" style={{ background: \"var(--color-border-default)\" }}>\n <div className=\"h-full\" style={{ width: `${d.pct}%`, background: \"var(--teal-500)\" }} />\n </div>\n </div>\n ))}\n </div>\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Average time to \"Independent\"</CardTitle>\n <CardDescription>By procedure type</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"space-y-3 text-[12px]\">\n <Row label=\"ESD\" value=\"12.4 sessions\" />\n <Row label=\"EMR\" value=\"8.7 sessions\" />\n <Row label=\"POEM\" value=\"14.1 sessions\" />\n <Row label=\"ERCP\" value=\"11.2 sessions\" />\n </div>\n </CardContent>\n </Card>\n </div>\n </TabsContent>\n\n <TabsContent value=\"risk\">\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)\" }}>Flag</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Subject</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Detail</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 {RISK_FLAGS.map((r, i) => {\n const Icon = r.severity === \"error\" ? ShieldAlert : AlertTriangle;\n return (\n <TableRow key={i}>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-center gap-2\">\n <Icon size={14} strokeWidth={1.8} style={{ color: r.severity === \"error\" ? \"var(--color-status-error-text)\" : \"var(--color-status-warning-text)\" }} />\n <span className=\"text-[13px] font-medium\">{r.type}</span>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px] font-medium\">{r.trainee}</TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>{r.detail}</TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n <Button variant=\"outline\" size=\"sm\">Review <ArrowRight size={11} /></Button>\n </TableCell>\n </TableRow>\n );\n })}\n </TableBody>\n </Table>\n </Card>\n </TabsContent>\n </Tabs>\n </AdminShell>\n );\n}\n\nfunction KpiCard({ label, value, sub, icon }: { label: string; value: string; sub: string; icon: React.ReactNode }) {\n return (\n <Card>\n <CardContent className=\"pt-4 flex items-center gap-3\">\n <div className=\"w-10 h-10 rounded-lg flex items-center justify-center shrink-0\" style={{ background: \"var(--color-bg-brand-muted)\", color: \"var(--color-text-brand)\" }}>\n {icon}\n </div>\n <div className=\"min-w-0\">\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 className=\"text-[10px]\" style={{ color: \"var(--color-text-muted)\" }}>{sub}</div>\n </div>\n </CardContent>\n </Card>\n );\n}\n\nfunction QualityBar({ label, value, variant }: { label: string; value: number; variant?: \"warning\" | \"error\" }) {\n const color = variant === \"error\" ? \"var(--red-500)\" : variant === \"warning\" ? \"var(--amber-500)\" : \"var(--teal-500)\";\n return (\n <div>\n <div className=\"flex items-center justify-between mb-1.5\">\n <span className=\"text-[12px]\">{label}</span>\n <span className=\"text-[12px] tabular-nums font-medium\">{value}%</span>\n </div>\n <div className=\"h-1.5 rounded-full overflow-hidden\" style={{ background: \"var(--color-border-default)\" }}>\n <div className=\"h-full\" style={{ width: `${value}%`, background: color }} />\n </div>\n </div>\n );\n}\n\nfunction Row({ label, value }: { label: string; value: string }) {\n return (\n <div className=\"flex items-center justify-between\">\n <span style={{ color: \"var(--color-text-muted)\" }}>{label}</span>\n <span className=\"font-medium tabular-nums\">{value}</span>\n </div>\n );\n}\n"
|
|
54
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/audit/page.tsx",
|
|
4
|
+
"sha256": "80b3689a3db935f0810d0f16163ae4d7878c1d68bd25d60b49e47aadde089291",
|
|
5
|
+
"imports": [
|
|
6
|
+
"next/link",
|
|
7
|
+
"react",
|
|
8
|
+
"lucide-react",
|
|
9
|
+
"@/components/ui/input",
|
|
10
|
+
"@/components/ui/badge",
|
|
11
|
+
"@/components/admin/admin-shell",
|
|
12
|
+
"@/lib/admin-mock"
|
|
13
|
+
],
|
|
14
|
+
"tokens": [
|
|
15
|
+
"--color-bg-subtle",
|
|
16
|
+
"--color-border-default",
|
|
17
|
+
"--color-border-muted",
|
|
18
|
+
"--color-text-muted",
|
|
19
|
+
"--color-text-primary",
|
|
20
|
+
"--color-text-secondary",
|
|
21
|
+
"--teal-500"
|
|
22
|
+
],
|
|
23
|
+
"fragments": [
|
|
24
|
+
{
|
|
25
|
+
"id": "AuditPage",
|
|
26
|
+
"line": 22,
|
|
27
|
+
"code": "export default function AuditPage() {\n const [q, setQ] = useState(\"\");\n\n const entries: FlatEntry[] = useMemo(() => {\n const flat: FlatEntry[] = [];\n for (const [appId, log] of Object.entries(AUDIT_LOG)) {\n const app = APPLICATIONS.find((a) => a.id === appId);\n if (!app) continue;\n for (const e of log) {\n flat.push({\n appId,\n appName: app.name,\n appRole: app.role,\n ...e,\n });\n }\n }\n return flat.sort((a, b) => b.timestamp.localeCompare(a.timestamp));\n }, []);\n\n const filtered = useMemo(() => {\n if (!q) return entries;\n const n = q.toLowerCase();\n return entries.filter((e) =>\n e.action.toLowerCase().includes(n) ||\n e.appName.toLowerCase().includes(n) ||\n e.admin.toLowerCase().includes(n) ||\n e.detail?.toLowerCase().includes(n)\n );\n }, [entries, q]);\n\n return (\n <AdminShell>\n <div className=\"mb-8\">\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Audit log\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Every verification action, status change, and activation. Retained for compliance.\n </p>\n </div>\n\n <div className=\"relative mb-5 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 actions, admins, applicants…\" className=\"h-9 pl-9\" />\n </div>\n\n {filtered.length === 0 ? (\n <div className=\"rounded-2xl border p-12 text-center\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <FileSearch size={28} strokeWidth={1.4} className=\"mx-auto mb-3\" style={{ color: \"var(--color-text-muted)\" }} />\n <p className=\"text-[13px]\" style={{ color: \"var(--color-text-secondary)\" }}>\n No audit entries match \"{q || \"your filter\"}\".\n </p>\n </div>\n ) : (\n <div className=\"rounded-xl border overflow-hidden\" style={{ borderColor: \"var(--color-border-default)\" }}>\n {filtered.map((e, i) => (\n <Link\n key={e.id}\n href={`/admin/onboarding/${e.appId}`}\n className=\"flex items-start gap-4 px-5 py-3.5 transition-colors hover:bg-[var(--color-bg-subtle)]\"\n style={{ borderBottom: i === filtered.length - 1 ? \"0\" : \"1px solid var(--color-border-muted)\" }}\n >\n <div className=\"w-1.5 h-1.5 rounded-full mt-2 shrink-0\" style={{ background: \"var(--teal-500)\" }} />\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 mb-1\">\n <span className=\"text-[13px] font-medium\" style={{ color: \"var(--color-text-primary)\" }}>\n {e.action}\n </span>\n <Badge variant=\"outline\" className=\"capitalize\">{e.appRole}</Badge>\n </div>\n {e.detail && (\n <p className=\"text-[12px] mb-1\" style={{ color: \"var(--color-text-secondary)\" }}>{e.detail}</p>\n )}\n <div className=\"text-[11px] flex items-center gap-2\" style={{ color: \"var(--color-text-muted)\" }}>\n <span>{new Date(e.timestamp).toLocaleString()}</span>\n <span>·</span>\n <span>by {e.admin}</span>\n <span>·</span>\n <span>on {e.appName}</span>\n </div>\n </div>\n <ArrowRight size={13} strokeWidth={1.8} className=\"shrink-0 mt-1.5\" style={{ color: \"var(--color-text-muted)\" }} />\n </Link>\n ))}\n </div>\n )}\n </AdminShell>\n );\n}"
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useMemo, useState } from \"react\";\nimport { Search, Filter, FileSearch, ArrowRight } from \"lucide-react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { AdminShell } from \"@/components/admin/admin-shell\";\nimport { APPLICATIONS, AUDIT_LOG } from \"@/lib/admin-mock\";\n\ninterface FlatEntry {\n appId: string;\n appName: string;\n appRole: string;\n id: string;\n timestamp: string;\n admin: string;\n action: string;\n detail?: string;\n}\n\nexport default function AuditPage() {\n const [q, setQ] = useState(\"\");\n\n const entries: FlatEntry[] = useMemo(() => {\n const flat: FlatEntry[] = [];\n for (const [appId, log] of Object.entries(AUDIT_LOG)) {\n const app = APPLICATIONS.find((a) => a.id === appId);\n if (!app) continue;\n for (const e of log) {\n flat.push({\n appId,\n appName: app.name,\n appRole: app.role,\n ...e,\n });\n }\n }\n return flat.sort((a, b) => b.timestamp.localeCompare(a.timestamp));\n }, []);\n\n const filtered = useMemo(() => {\n if (!q) return entries;\n const n = q.toLowerCase();\n return entries.filter((e) =>\n e.action.toLowerCase().includes(n) ||\n e.appName.toLowerCase().includes(n) ||\n e.admin.toLowerCase().includes(n) ||\n e.detail?.toLowerCase().includes(n)\n );\n }, [entries, q]);\n\n return (\n <AdminShell>\n <div className=\"mb-8\">\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n Audit log\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Every verification action, status change, and activation. Retained for compliance.\n </p>\n </div>\n\n <div className=\"relative mb-5 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 actions, admins, applicants…\" className=\"h-9 pl-9\" />\n </div>\n\n {filtered.length === 0 ? (\n <div className=\"rounded-2xl border p-12 text-center\" style={{ borderColor: \"var(--color-border-default)\" }}>\n <FileSearch size={28} strokeWidth={1.4} className=\"mx-auto mb-3\" style={{ color: \"var(--color-text-muted)\" }} />\n <p className=\"text-[13px]\" style={{ color: \"var(--color-text-secondary)\" }}>\n No audit entries match \"{q || \"your filter\"}\".\n </p>\n </div>\n ) : (\n <div className=\"rounded-xl border overflow-hidden\" style={{ borderColor: \"var(--color-border-default)\" }}>\n {filtered.map((e, i) => (\n <Link\n key={e.id}\n href={`/admin/onboarding/${e.appId}`}\n className=\"flex items-start gap-4 px-5 py-3.5 transition-colors hover:bg-[var(--color-bg-subtle)]\"\n style={{ borderBottom: i === filtered.length - 1 ? \"0\" : \"1px solid var(--color-border-muted)\" }}\n >\n <div className=\"w-1.5 h-1.5 rounded-full mt-2 shrink-0\" style={{ background: \"var(--teal-500)\" }} />\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 mb-1\">\n <span className=\"text-[13px] font-medium\" style={{ color: \"var(--color-text-primary)\" }}>\n {e.action}\n </span>\n <Badge variant=\"outline\" className=\"capitalize\">{e.appRole}</Badge>\n </div>\n {e.detail && (\n <p className=\"text-[12px] mb-1\" style={{ color: \"var(--color-text-secondary)\" }}>{e.detail}</p>\n )}\n <div className=\"text-[11px] flex items-center gap-2\" style={{ color: \"var(--color-text-muted)\" }}>\n <span>{new Date(e.timestamp).toLocaleString()}</span>\n <span>·</span>\n <span>by {e.admin}</span>\n <span>·</span>\n <span>on {e.appName}</span>\n </div>\n </div>\n <ArrowRight size={13} strokeWidth={1.8} className=\"shrink-0 mt-1.5\" style={{ color: \"var(--color-text-muted)\" }} />\n </Link>\n ))}\n </div>\n )}\n </AdminShell>\n );\n}\n"
|
|
31
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/broadcasts/new/page.tsx",
|
|
4
|
+
"sha256": "9506cfc9d3038166e1a803fc3572500e2adf849084530990bb36ee84b5edfdeb",
|
|
5
|
+
"imports": [
|
|
6
|
+
"next/link",
|
|
7
|
+
"react",
|
|
8
|
+
"lucide-react",
|
|
9
|
+
"@/components/ui/button",
|
|
10
|
+
"@/components/ui/input",
|
|
11
|
+
"@/components/ui/textarea",
|
|
12
|
+
"@/components/ui/card",
|
|
13
|
+
"@/components/admin/admin-shell",
|
|
14
|
+
"@/components/auth/auth-primitives",
|
|
15
|
+
"@/components/auth/onboarding-primitives",
|
|
16
|
+
"@/lib/sessions-mock"
|
|
17
|
+
],
|
|
18
|
+
"tokens": [
|
|
19
|
+
"--color-bg-brand-muted",
|
|
20
|
+
"--color-border-brand",
|
|
21
|
+
"--color-border-default",
|
|
22
|
+
"--color-status-success-bg",
|
|
23
|
+
"--color-status-success-text",
|
|
24
|
+
"--color-text-brand",
|
|
25
|
+
"--color-text-primary",
|
|
26
|
+
"--color-text-secondary"
|
|
27
|
+
],
|
|
28
|
+
"fragments": [
|
|
29
|
+
{
|
|
30
|
+
"id": "NewBroadcastPage",
|
|
31
|
+
"line": 15,
|
|
32
|
+
"code": "export default function NewBroadcastPage() {\n const [title, setTitle] = useState(\"\");\n const [description, setDescription] = useState(\"\");\n const [host, setHost] = useState(\"\");\n const [date, setDate] = useState(\"\");\n const [time, setTime] = useState(\"\");\n const [duration, setDuration] = useState(\"60\");\n const [access, setAccess] = useState<\"all\" | \"cohort\" | \"invite\">(\"all\");\n const [saving, setSaving] = useState(false);\n const [done, setDone] = useState(false);\n\n const canSubmit = title && description.length >= 20 && host && date && time && duration;\n\n if (done) {\n return (\n <AdminShell>\n <div className=\"max-w-md mx-auto text-center py-16\">\n <div className=\"w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-5\" style={{ background: \"var(--color-status-success-bg)\", color: \"var(--color-status-success-text)\" }}>\n <CheckCircle2 size={32} strokeWidth={1.6} />\n </div>\n <h1 className=\"text-[1.5rem] font-semibold mb-2\" style={{ letterSpacing: \"-0.025em\" }}>\n Broadcast scheduled\n </h1>\n <p className=\"text-sm mb-6\" style={{ color: \"var(--color-text-secondary)\" }}>\n \"{title}\" is now in Draft state. Publish to notify eligible viewers.\n </p>\n <div className=\"flex items-center gap-2 justify-center\">\n <Link href=\"/admin/broadcasts\"><Button variant=\"outline\">All broadcasts</Button></Link>\n <Button variant=\"brand\">Publish now <ArrowRight size={13} /></Button>\n </div>\n </div>\n </AdminShell>\n );\n }\n\n return (\n <AdminShell>\n <Link href=\"/admin/broadcasts\" 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 broadcasts\n </Link>\n\n <div className=\"mb-6\">\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n New broadcast\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Schedule a Mentix TV broadcast. Free to attend — no credits consumed.\n </p>\n </div>\n\n <form onSubmit={(e) => { e.preventDefault(); setSaving(true); setTimeout(() => setDone(true), 800); }} className=\"max-w-2xl space-y-5\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Broadcast details</CardTitle>\n <CardDescription>What's the broadcast about, and who's hosting?</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Field label=\"Title\" htmlFor=\"t\">\n <Input id=\"t\" value={title} onChange={(e) => setTitle(e.target.value)} placeholder=\"e.g. Live ESD masterclass — caecal lesions\" className=\"h-10\" />\n </Field>\n <Field label=\"Description\" htmlFor=\"d\" hint={`${description.length} / 20 minimum, 500 max`}>\n <Textarea id=\"d\" value={description} onChange={(e) => setDescription(e.target.value.slice(0, 500))} rows={4} placeholder=\"What viewers will see and learn.\" />\n </Field>\n <Field label=\"Designated host\" htmlFor=\"h\" hint=\"The host will receive a 'Go Live' button in their Mentix account.\">\n <Select\n id=\"h\"\n value={host}\n onChange={setHost}\n placeholder=\"Select a host…\"\n options={[\n ...MENTORS.map((m) => ({ value: m.id, label: `${m.name} — ${m.institution}` })),\n { value: \"mentix-team\", label: \"Mentix Product Team\" },\n ]}\n />\n </Field>\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Schedule</CardTitle>\n <CardDescription>When does the broadcast start?</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <div className=\"grid grid-cols-2 gap-3\">\n <Field label=\"Date\" htmlFor=\"dt\"><Input id=\"dt\" type=\"date\" value={date} onChange={(e) => setDate(e.target.value)} className=\"h-10\" /></Field>\n <Field label=\"Time\" htmlFor=\"tm\"><Input id=\"tm\" type=\"time\" value={time} onChange={(e) => setTime(e.target.value)} className=\"h-10\" /></Field>\n </div>\n <Field label=\"Estimated duration (minutes)\" htmlFor=\"du\">\n <Input id=\"du\" type=\"number\" value={duration} onChange={(e) => setDuration(e.target.value)} className=\"h-10 tabular-nums\" />\n </Field>\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Access level</CardTitle>\n <CardDescription>Who can watch this broadcast?</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-2\">\n {([\n { id: \"all\" as const, title: \"All platform users\", desc: \"Trainees, mentors, sponsors, and admins can join.\" },\n { id: \"cohort\" as const, title: \"Cohort-restricted\", desc: \"Limited to specific sponsor cohorts.\" },\n { id: \"invite\" as const, title: \"Invite-only\", desc: \"Hand-picked users only.\" },\n ]).map((opt) => {\n const active = access === opt.id;\n return (\n <button\n key={opt.id}\n type=\"button\"\n onClick={() => setAccess(opt.id)}\n aria-pressed={active}\n className=\"text-left p-3 rounded-xl border transition-all\"\n style={{\n borderColor: active ? \"var(--color-border-brand)\" : \"var(--color-border-default)\",\n background: active ? \"var(--color-bg-brand-muted)\" : \"transparent\",\n }}\n >\n <div className=\"text-[13px] font-semibold mb-1\" style={{ color: active ? \"var(--color-text-brand)\" : \"var(--color-text-primary)\" }}>{opt.title}</div>\n <p className=\"text-[11px]\" style={{ color: \"var(--color-text-secondary)\" }}>{opt.desc}</p>\n </button>\n );\n })}\n </div>\n </CardContent>\n </Card>\n\n <div className=\"flex items-center justify-end gap-2\">\n <Link href=\"/admin/broadcasts\"><Button type=\"button\" variant=\"outline\">Cancel</Button></Link>\n <Button type=\"submit\" variant=\"brand\" disabled={!canSubmit || saving}>\n {saving ? <><Spinner className=\"w-3.5 h-3.5 mr-1\" /> Saving…</> : <>Save as draft <ArrowRight size={13} /></>}\n </Button>\n </div>\n </form>\n </AdminShell>\n );\n}"
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useState } from \"react\";\nimport { ArrowLeft, ArrowRight, CheckCircle2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { Card, CardHeader, CardTitle, CardDescription, CardContent } from \"@/components/ui/card\";\nimport { AdminShell } from \"@/components/admin/admin-shell\";\nimport { Field, Spinner } from \"@/components/auth/auth-primitives\";\nimport { Select } from \"@/components/auth/onboarding-primitives\";\nimport { MENTORS } from \"@/lib/sessions-mock\";\n\nexport default function NewBroadcastPage() {\n const [title, setTitle] = useState(\"\");\n const [description, setDescription] = useState(\"\");\n const [host, setHost] = useState(\"\");\n const [date, setDate] = useState(\"\");\n const [time, setTime] = useState(\"\");\n const [duration, setDuration] = useState(\"60\");\n const [access, setAccess] = useState<\"all\" | \"cohort\" | \"invite\">(\"all\");\n const [saving, setSaving] = useState(false);\n const [done, setDone] = useState(false);\n\n const canSubmit = title && description.length >= 20 && host && date && time && duration;\n\n if (done) {\n return (\n <AdminShell>\n <div className=\"max-w-md mx-auto text-center py-16\">\n <div className=\"w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-5\" style={{ background: \"var(--color-status-success-bg)\", color: \"var(--color-status-success-text)\" }}>\n <CheckCircle2 size={32} strokeWidth={1.6} />\n </div>\n <h1 className=\"text-[1.5rem] font-semibold mb-2\" style={{ letterSpacing: \"-0.025em\" }}>\n Broadcast scheduled\n </h1>\n <p className=\"text-sm mb-6\" style={{ color: \"var(--color-text-secondary)\" }}>\n \"{title}\" is now in Draft state. Publish to notify eligible viewers.\n </p>\n <div className=\"flex items-center gap-2 justify-center\">\n <Link href=\"/admin/broadcasts\"><Button variant=\"outline\">All broadcasts</Button></Link>\n <Button variant=\"brand\">Publish now <ArrowRight size={13} /></Button>\n </div>\n </div>\n </AdminShell>\n );\n }\n\n return (\n <AdminShell>\n <Link href=\"/admin/broadcasts\" 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 broadcasts\n </Link>\n\n <div className=\"mb-6\">\n <h1 className=\"text-[1.75rem] font-semibold tracking-tight mb-1\" style={{ letterSpacing: \"-0.025em\" }}>\n New broadcast\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Schedule a Mentix TV broadcast. Free to attend — no credits consumed.\n </p>\n </div>\n\n <form onSubmit={(e) => { e.preventDefault(); setSaving(true); setTimeout(() => setDone(true), 800); }} className=\"max-w-2xl space-y-5\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Broadcast details</CardTitle>\n <CardDescription>What's the broadcast about, and who's hosting?</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Field label=\"Title\" htmlFor=\"t\">\n <Input id=\"t\" value={title} onChange={(e) => setTitle(e.target.value)} placeholder=\"e.g. Live ESD masterclass — caecal lesions\" className=\"h-10\" />\n </Field>\n <Field label=\"Description\" htmlFor=\"d\" hint={`${description.length} / 20 minimum, 500 max`}>\n <Textarea id=\"d\" value={description} onChange={(e) => setDescription(e.target.value.slice(0, 500))} rows={4} placeholder=\"What viewers will see and learn.\" />\n </Field>\n <Field label=\"Designated host\" htmlFor=\"h\" hint=\"The host will receive a 'Go Live' button in their Mentix account.\">\n <Select\n id=\"h\"\n value={host}\n onChange={setHost}\n placeholder=\"Select a host…\"\n options={[\n ...MENTORS.map((m) => ({ value: m.id, label: `${m.name} — ${m.institution}` })),\n { value: \"mentix-team\", label: \"Mentix Product Team\" },\n ]}\n />\n </Field>\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Schedule</CardTitle>\n <CardDescription>When does the broadcast start?</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <div className=\"grid grid-cols-2 gap-3\">\n <Field label=\"Date\" htmlFor=\"dt\"><Input id=\"dt\" type=\"date\" value={date} onChange={(e) => setDate(e.target.value)} className=\"h-10\" /></Field>\n <Field label=\"Time\" htmlFor=\"tm\"><Input id=\"tm\" type=\"time\" value={time} onChange={(e) => setTime(e.target.value)} className=\"h-10\" /></Field>\n </div>\n <Field label=\"Estimated duration (minutes)\" htmlFor=\"du\">\n <Input id=\"du\" type=\"number\" value={duration} onChange={(e) => setDuration(e.target.value)} className=\"h-10 tabular-nums\" />\n </Field>\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Access level</CardTitle>\n <CardDescription>Who can watch this broadcast?</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-2\">\n {([\n { id: \"all\" as const, title: \"All platform users\", desc: \"Trainees, mentors, sponsors, and admins can join.\" },\n { id: \"cohort\" as const, title: \"Cohort-restricted\", desc: \"Limited to specific sponsor cohorts.\" },\n { id: \"invite\" as const, title: \"Invite-only\", desc: \"Hand-picked users only.\" },\n ]).map((opt) => {\n const active = access === opt.id;\n return (\n <button\n key={opt.id}\n type=\"button\"\n onClick={() => setAccess(opt.id)}\n aria-pressed={active}\n className=\"text-left p-3 rounded-xl border transition-all\"\n style={{\n borderColor: active ? \"var(--color-border-brand)\" : \"var(--color-border-default)\",\n background: active ? \"var(--color-bg-brand-muted)\" : \"transparent\",\n }}\n >\n <div className=\"text-[13px] font-semibold mb-1\" style={{ color: active ? \"var(--color-text-brand)\" : \"var(--color-text-primary)\" }}>{opt.title}</div>\n <p className=\"text-[11px]\" style={{ color: \"var(--color-text-secondary)\" }}>{opt.desc}</p>\n </button>\n );\n })}\n </div>\n </CardContent>\n </Card>\n\n <div className=\"flex items-center justify-end gap-2\">\n <Link href=\"/admin/broadcasts\"><Button type=\"button\" variant=\"outline\">Cancel</Button></Link>\n <Button type=\"submit\" variant=\"brand\" disabled={!canSubmit || saving}>\n {saving ? <><Spinner className=\"w-3.5 h-3.5 mr-1\" /> Saving…</> : <>Save as draft <ArrowRight size={13} /></>}\n </Button>\n </div>\n </form>\n </AdminShell>\n );\n}\n"
|
|
36
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/broadcasts/page.tsx",
|
|
4
|
+
"sha256": "9a1317b36db0e4b751477fa79b7951b08ed4233a8072ce0e11806249055e60f2",
|
|
5
|
+
"imports": [
|
|
6
|
+
"next/link",
|
|
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
|
+
"@/lib/broadcasts-mock"
|
|
14
|
+
],
|
|
15
|
+
"tokens": [
|
|
16
|
+
"--color-bg-brand-muted",
|
|
17
|
+
"--color-bg-subtle",
|
|
18
|
+
"--color-status-error-bg",
|
|
19
|
+
"--color-status-error-text",
|
|
20
|
+
"--color-text-brand",
|
|
21
|
+
"--color-text-muted",
|
|
22
|
+
"--color-text-secondary",
|
|
23
|
+
"--gray-0",
|
|
24
|
+
"--gray-950",
|
|
25
|
+
"--red-500",
|
|
26
|
+
"--teal-500"
|
|
27
|
+
],
|
|
28
|
+
"fragments": [
|
|
29
|
+
{
|
|
30
|
+
"id": "AdminBroadcastsPage",
|
|
31
|
+
"line": 12,
|
|
32
|
+
"code": "export default function AdminBroadcastsPage() {\n const live = BROADCASTS.filter((b) => b.state === \"live\");\n const scheduled = BROADCASTS.filter((b) => b.state === \"scheduled\");\n const past = BROADCASTS.filter((b) => b.state === \"ended\");\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 Mentix TV — broadcasts\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Schedule and manage live broadcasts, masterclasses, and educational sessions.\n </p>\n </div>\n <Link href=\"/admin/broadcasts/new\">\n <Button variant=\"brand\"><Plus size={13} /> New broadcast</Button>\n </Link>\n </div>\n\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6\">\n <Metric icon={<Radio size={16} />} label=\"Live now\" value={live.length} accent={live.length > 0 ? \"error\" : \"muted\"} />\n <Metric icon={<Calendar size={16} />} label=\"Scheduled\" value={scheduled.length} accent=\"brand\" />\n <Metric icon={<Tv size={16} />} label=\"Past\" value={past.length} accent=\"muted\" />\n </div>\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)\" }}>Broadcast</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Host</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Access</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>When</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>State</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 {BROADCASTS.map((b) => (\n <TableRow key={b.id}>\n <TableCell className=\"px-5 py-3\">\n <div className=\"text-[13px] font-semibold\">{b.title}</div>\n <div className=\"text-[11px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>{b.id} · {b.durationMin}m</div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-center gap-2\">\n <div className=\"w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-semibold shrink-0\" style={{ background: \"var(--teal-500)\", color: \"var(--gray-950)\" }}>{b.hostInitials}</div>\n <span className=\"text-[12px]\">{b.host}</span>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <Badge variant=\"outline\" className=\"text-[10px] capitalize\">\n {b.access === \"all\" ? \"All users\" : b.access === \"cohort\" ? \"Cohort\" : \"Invite-only\"}\n </Badge>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\">{fmtBroadcastTime(b.scheduledFor)}</TableCell>\n <TableCell className=\"px-5 py-3\">\n {b.state === \"live\" && (\n <Badge className=\"text-[10px] inline-flex items-center gap-1\" style={{ background: \"var(--red-500)\", color: \"var(--gray-0)\" }}>\n <span className=\"w-1.5 h-1.5 rounded-full animate-pulse\" style={{ background: \"var(--gray-0)\" }} /> Live · {b.viewers}\n </Badge>\n )}\n {b.state === \"scheduled\" && <Badge variant=\"outline\" className=\"text-[10px]\">Scheduled</Badge>}\n {b.state === \"ended\" && <Badge variant=\"outline\" className=\"text-[10px]\">Ended · {b.viewers}</Badge>}\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n <div className=\"inline-flex items-center gap-1\">\n {b.state === \"live\" && <Button variant=\"destructive\" size=\"sm\">End broadcast</Button>}\n {b.state === \"scheduled\" && (\n <>\n <Link href={`/portal/tv/${b.id}`}><Button variant=\"outline\" size=\"sm\">Preview</Button></Link>\n <Button variant=\"brand\" size=\"sm\">Publish</Button>\n </>\n )}\n {b.state === \"ended\" && <Link href={`/portal/tv/${b.id}`}><Button variant=\"outline\" size=\"sm\">View recording</Button></Link>}\n </div>\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </Card>\n </AdminShell>\n );\n}"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "Metric",
|
|
36
|
+
"line": 100,
|
|
37
|
+
"code": "function Metric({ icon, label, value, accent }: { icon: React.ReactNode; label: string; value: number; accent: \"error\" | \"brand\" | \"muted\" }) {\n const bg = accent === \"error\" ? \"var(--color-status-error-bg)\" : accent === \"muted\" ? \"var(--color-bg-subtle)\" : \"var(--color-bg-brand-muted)\";\n const fg = accent === \"error\" ? \"var(--color-status-error-text)\" : accent === \"muted\" ? \"var(--color-text-muted)\" : \"var(--color-text-brand)\";\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}"
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { Plus, Tv, Radio, Users, Calendar, MoreVertical } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Card, CardHeader, CardTitle, CardDescription, CardContent } from \"@/components/ui/card\";\nimport { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from \"@/components/ui/table\";\nimport { AdminShell } from \"@/components/admin/admin-shell\";\nimport { BROADCASTS, fmtBroadcastTime } from \"@/lib/broadcasts-mock\";\n\nexport default function AdminBroadcastsPage() {\n const live = BROADCASTS.filter((b) => b.state === \"live\");\n const scheduled = BROADCASTS.filter((b) => b.state === \"scheduled\");\n const past = BROADCASTS.filter((b) => b.state === \"ended\");\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 Mentix TV — broadcasts\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Schedule and manage live broadcasts, masterclasses, and educational sessions.\n </p>\n </div>\n <Link href=\"/admin/broadcasts/new\">\n <Button variant=\"brand\"><Plus size={13} /> New broadcast</Button>\n </Link>\n </div>\n\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6\">\n <Metric icon={<Radio size={16} />} label=\"Live now\" value={live.length} accent={live.length > 0 ? \"error\" : \"muted\"} />\n <Metric icon={<Calendar size={16} />} label=\"Scheduled\" value={scheduled.length} accent=\"brand\" />\n <Metric icon={<Tv size={16} />} label=\"Past\" value={past.length} accent=\"muted\" />\n </div>\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)\" }}>Broadcast</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Host</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Access</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>When</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>State</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 {BROADCASTS.map((b) => (\n <TableRow key={b.id}>\n <TableCell className=\"px-5 py-3\">\n <div className=\"text-[13px] font-semibold\">{b.title}</div>\n <div className=\"text-[11px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>{b.id} · {b.durationMin}m</div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-center gap-2\">\n <div className=\"w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-semibold shrink-0\" style={{ background: \"var(--teal-500)\", color: \"var(--gray-950)\" }}>{b.hostInitials}</div>\n <span className=\"text-[12px]\">{b.host}</span>\n </div>\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <Badge variant=\"outline\" className=\"text-[10px] capitalize\">\n {b.access === \"all\" ? \"All users\" : b.access === \"cohort\" ? \"Cohort\" : \"Invite-only\"}\n </Badge>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\">{fmtBroadcastTime(b.scheduledFor)}</TableCell>\n <TableCell className=\"px-5 py-3\">\n {b.state === \"live\" && (\n <Badge className=\"text-[10px] inline-flex items-center gap-1\" style={{ background: \"var(--red-500)\", color: \"var(--gray-0)\" }}>\n <span className=\"w-1.5 h-1.5 rounded-full animate-pulse\" style={{ background: \"var(--gray-0)\" }} /> Live · {b.viewers}\n </Badge>\n )}\n {b.state === \"scheduled\" && <Badge variant=\"outline\" className=\"text-[10px]\">Scheduled</Badge>}\n {b.state === \"ended\" && <Badge variant=\"outline\" className=\"text-[10px]\">Ended · {b.viewers}</Badge>}\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n <div className=\"inline-flex items-center gap-1\">\n {b.state === \"live\" && <Button variant=\"destructive\" size=\"sm\">End broadcast</Button>}\n {b.state === \"scheduled\" && (\n <>\n <Link href={`/portal/tv/${b.id}`}><Button variant=\"outline\" size=\"sm\">Preview</Button></Link>\n <Button variant=\"brand\" size=\"sm\">Publish</Button>\n </>\n )}\n {b.state === \"ended\" && <Link href={`/portal/tv/${b.id}`}><Button variant=\"outline\" size=\"sm\">View recording</Button></Link>}\n </div>\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </Card>\n </AdminShell>\n );\n}\n\nfunction Metric({ icon, label, value, accent }: { icon: React.ReactNode; label: string; value: number; accent: \"error\" | \"brand\" | \"muted\" }) {\n const bg = accent === \"error\" ? \"var(--color-status-error-bg)\" : accent === \"muted\" ? \"var(--color-bg-subtle)\" : \"var(--color-bg-brand-muted)\";\n const fg = accent === \"error\" ? \"var(--color-status-error-text)\" : accent === \"muted\" ? \"var(--color-text-muted)\" : \"var(--color-text-brand)\";\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"
|
|
41
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/closures/page.tsx",
|
|
4
|
+
"sha256": "2d522db5b3c7c684f28def26424f86214bcfda733f72b2be360f50759398a55a",
|
|
5
|
+
"imports": [
|
|
6
|
+
"react",
|
|
7
|
+
"lucide-react",
|
|
8
|
+
"@/components/ui/input",
|
|
9
|
+
"@/components/ui/button",
|
|
10
|
+
"@/components/ui/badge",
|
|
11
|
+
"@/components/ui/card",
|
|
12
|
+
"@/components/ui/table",
|
|
13
|
+
"@/components/admin/admin-shell",
|
|
14
|
+
"@/components/portal/empty-state",
|
|
15
|
+
"@/lib/closures-mock",
|
|
16
|
+
"@/lib/credits"
|
|
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
|
+
"--teal-500"
|
|
33
|
+
],
|
|
34
|
+
"fragments": [
|
|
35
|
+
{
|
|
36
|
+
"id": "ClosuresPage",
|
|
37
|
+
"line": 15,
|
|
38
|
+
"code": "export default function ClosuresPage() {\n const [q, setQ] = useState(\"\");\n const [statusFilter, setStatusFilter] = useState<ClosureStatus | \"all\">(\"all\");\n const [blockingFilter, setBlockingFilter] = useState<BlockingIssue | \"all\">(\"all\");\n\n const filtered = useMemo(() => {\n let items = [...CLOSURE_QUEUE];\n if (statusFilter !== \"all\") items = items.filter((c) => c.status === statusFilter);\n if (blockingFilter !== \"all\") items = items.filter((c) => c.blocking.includes(blockingFilter));\n if (q) {\n const n = q.toLowerCase();\n items = items.filter((c) =>\n c.sessionId.toLowerCase().includes(n) ||\n c.trainee.toLowerCase().includes(n) ||\n c.mentor.toLowerCase().includes(n) ||\n c.sponsor?.toLowerCase().includes(n)\n );\n }\n return items;\n }, [q, statusFilter, blockingFilter]);\n\n const ready = CLOSURE_QUEUE.filter((c) => c.status === \"ready\").length;\n const pending = CLOSURE_QUEUE.filter((c) => c.status === \"pending\").length;\n const overdue = CLOSURE_QUEUE.filter((c) => c.status === \"pending\" && c.completedHoursAgo > 72).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 Session closure queue\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Verify all workflow steps are complete before closing sessions, consuming credits, and queuing mentor fees.\n </p>\n </div>\n </div>\n\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6\">\n <Metric icon={<CheckCircle2 size={16} />} label=\"Ready to close\" value={ready} accent=\"success\" />\n <Metric icon={<AlertCircle size={16} />} label=\"Closure pending\" value={pending} accent=\"warning\" />\n <Metric icon={<AlertTriangle size={16} />} label=\"Overdue (>72h)\" value={overdue} accent=\"error\" />\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 session, trainee, mentor, sponsor…\" 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 <FilterSelect value={statusFilter} onChange={(v) => setStatusFilter(v as ClosureStatus | \"all\")} options={[\n { value: \"all\", label: \"All statuses\" }, { value: \"ready\", label: \"Ready\" }, { value: \"pending\", label: \"Pending\" },\n ]} />\n <FilterSelect value={blockingFilter} onChange={(v) => setBlockingFilter(v as BlockingIssue | \"all\")} options={[\n { value: \"all\", label: \"All blocking issues\" },\n { value: \"assessment_missing\", label: \"Assessment missing\" },\n { value: \"recording_not_archived\", label: \"Recording not archived\" },\n { value: \"trainee_feedback_pending\", label: \"Trainee feedback pending\" },\n { value: \"sponsor_feedback_pending\", label: \"Sponsor feedback pending\" },\n ]} />\n </div>\n\n {filtered.length === 0 ? (\n <EmptyState icon={FileCheck} title=\"No sessions in the closure queue\" description=\"When sessions complete and all checks pass, they'll appear here for closure.\" />\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)\" }}>Session</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)\" }}>Mentor</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Duration</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Fee</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 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Action</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.map((c) => {\n const isOverdue = c.completedHoursAgo > 72 && c.status === \"pending\";\n return (\n <TableRow key={c.sessionId}>\n <TableCell className=\"px-5 py-3\">\n <div className=\"text-[12px] font-mono\" style={{ color: \"var(--color-text-secondary)\" }}>{c.sessionId}</div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>{c.procedure} · {c.sessionDate}</div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\">{c.trainee}</TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\">{c.mentor}</TableCell>\n <TableCell className=\"px-5 py-3 text-right text-[12px] tabular-nums\">\n {Math.floor(c.durationMin / 60)}h {String(c.durationMin % 60).padStart(2, \"0\")}m\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right tabular-nums font-medium\">{gbp(c.feeGBP)}</TableCell>\n <TableCell className=\"px-5 py-3\">\n {c.status === \"ready\" ? (\n <Badge className=\"text-[10px]\" style={{ background: \"var(--color-status-success-bg)\", color: \"var(--color-status-success-text)\" }}>\n Ready\n </Badge>\n ) : (\n <div className=\"space-y-1\">\n <Badge variant=\"outline\" className=\"text-[10px]\" style={{ color: isOverdue ? \"var(--color-status-error-text)\" : \"var(--color-status-warning-text)\" }}>\n {isOverdue ? \"Overdue\" : \"Pending\"} · {Math.round(c.completedHoursAgo)}h\n </Badge>\n <div className=\"text-[10px] space-y-0.5\" style={{ color: \"var(--color-text-muted)\" }}>\n {c.blocking.map((b) => (\n <div key={b} className=\"inline-flex items-center gap-1\">\n <AlertCircle size={9} /> {BLOCKING_LABEL[b]}\n </div>\n ))}\n </div>\n </div>\n )}\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n {c.status === \"ready\" ? (\n <Button variant=\"brand\" size=\"sm\">Close session <ArrowRight size={11} /></Button>\n ) : (\n <Button variant=\"outline\" size=\"sm\">Review</Button>\n )}\n </TableCell>\n </TableRow>\n );\n })}\n </TableBody>\n </Table>\n </Card>\n )}\n </AdminShell>\n );\n}"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "Metric",
|
|
42
|
+
"line": 151,
|
|
43
|
+
"code": "function Metric({ icon, label, value, accent }: { icon: React.ReactNode; label: string; value: number; accent?: \"success\" | \"warning\" | \"error\" }) {\n const fg =\n accent === \"success\" ? \"var(--color-status-success-text)\" :\n accent === \"warning\" ? \"var(--color-status-warning-text)\" :\n accent === \"error\" ? \"var(--color-status-error-text)\" : \"var(--color-text-brand)\";\n const bg =\n accent === \"success\" ? \"var(--color-status-success-bg)\" :\n accent === \"warning\" ? \"var(--color-status-warning-bg)\" :\n 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 <CardDescription className=\"text-[10px] font-medium uppercase tracking-wide\">{label}</CardDescription>\n <CardTitle className=\"text-2xl font-semibold tabular-nums\" style={{ letterSpacing: \"-0.02em\" }}>{value}</CardTitle>\n </div>\n </div>\n </CardHeader>\n </Card>\n );\n}"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": "FilterSelect",
|
|
47
|
+
"line": 175,
|
|
48
|
+
"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\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 >\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}"
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"content": "\"use client\";\n\nimport { useState, useMemo } from \"react\";\nimport { Search, X, ChevronDown, ArrowRight, CheckCircle2, AlertCircle, FileCheck, AlertTriangle } 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, CardDescription } 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 { CLOSURE_QUEUE, BLOCKING_LABEL, type ClosureStatus, type BlockingIssue } from \"@/lib/closures-mock\";\nimport { gbp } from \"@/lib/credits\";\n\nexport default function ClosuresPage() {\n const [q, setQ] = useState(\"\");\n const [statusFilter, setStatusFilter] = useState<ClosureStatus | \"all\">(\"all\");\n const [blockingFilter, setBlockingFilter] = useState<BlockingIssue | \"all\">(\"all\");\n\n const filtered = useMemo(() => {\n let items = [...CLOSURE_QUEUE];\n if (statusFilter !== \"all\") items = items.filter((c) => c.status === statusFilter);\n if (blockingFilter !== \"all\") items = items.filter((c) => c.blocking.includes(blockingFilter));\n if (q) {\n const n = q.toLowerCase();\n items = items.filter((c) =>\n c.sessionId.toLowerCase().includes(n) ||\n c.trainee.toLowerCase().includes(n) ||\n c.mentor.toLowerCase().includes(n) ||\n c.sponsor?.toLowerCase().includes(n)\n );\n }\n return items;\n }, [q, statusFilter, blockingFilter]);\n\n const ready = CLOSURE_QUEUE.filter((c) => c.status === \"ready\").length;\n const pending = CLOSURE_QUEUE.filter((c) => c.status === \"pending\").length;\n const overdue = CLOSURE_QUEUE.filter((c) => c.status === \"pending\" && c.completedHoursAgo > 72).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 Session closure queue\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Verify all workflow steps are complete before closing sessions, consuming credits, and queuing mentor fees.\n </p>\n </div>\n </div>\n\n <div className=\"grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6\">\n <Metric icon={<CheckCircle2 size={16} />} label=\"Ready to close\" value={ready} accent=\"success\" />\n <Metric icon={<AlertCircle size={16} />} label=\"Closure pending\" value={pending} accent=\"warning\" />\n <Metric icon={<AlertTriangle size={16} />} label=\"Overdue (>72h)\" value={overdue} accent=\"error\" />\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 session, trainee, mentor, sponsor…\" 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 <FilterSelect value={statusFilter} onChange={(v) => setStatusFilter(v as ClosureStatus | \"all\")} options={[\n { value: \"all\", label: \"All statuses\" }, { value: \"ready\", label: \"Ready\" }, { value: \"pending\", label: \"Pending\" },\n ]} />\n <FilterSelect value={blockingFilter} onChange={(v) => setBlockingFilter(v as BlockingIssue | \"all\")} options={[\n { value: \"all\", label: \"All blocking issues\" },\n { value: \"assessment_missing\", label: \"Assessment missing\" },\n { value: \"recording_not_archived\", label: \"Recording not archived\" },\n { value: \"trainee_feedback_pending\", label: \"Trainee feedback pending\" },\n { value: \"sponsor_feedback_pending\", label: \"Sponsor feedback pending\" },\n ]} />\n </div>\n\n {filtered.length === 0 ? (\n <EmptyState icon={FileCheck} title=\"No sessions in the closure queue\" description=\"When sessions complete and all checks pass, they'll appear here for closure.\" />\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)\" }}>Session</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)\" }}>Mentor</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Duration</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Fee</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 text-right\" style={{ color: \"var(--color-text-muted)\" }}>Action</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.map((c) => {\n const isOverdue = c.completedHoursAgo > 72 && c.status === \"pending\";\n return (\n <TableRow key={c.sessionId}>\n <TableCell className=\"px-5 py-3\">\n <div className=\"text-[12px] font-mono\" style={{ color: \"var(--color-text-secondary)\" }}>{c.sessionId}</div>\n <div className=\"text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>{c.procedure} · {c.sessionDate}</div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\">{c.trainee}</TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\">{c.mentor}</TableCell>\n <TableCell className=\"px-5 py-3 text-right text-[12px] tabular-nums\">\n {Math.floor(c.durationMin / 60)}h {String(c.durationMin % 60).padStart(2, \"0\")}m\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right tabular-nums font-medium\">{gbp(c.feeGBP)}</TableCell>\n <TableCell className=\"px-5 py-3\">\n {c.status === \"ready\" ? (\n <Badge className=\"text-[10px]\" style={{ background: \"var(--color-status-success-bg)\", color: \"var(--color-status-success-text)\" }}>\n Ready\n </Badge>\n ) : (\n <div className=\"space-y-1\">\n <Badge variant=\"outline\" className=\"text-[10px]\" style={{ color: isOverdue ? \"var(--color-status-error-text)\" : \"var(--color-status-warning-text)\" }}>\n {isOverdue ? \"Overdue\" : \"Pending\"} · {Math.round(c.completedHoursAgo)}h\n </Badge>\n <div className=\"text-[10px] space-y-0.5\" style={{ color: \"var(--color-text-muted)\" }}>\n {c.blocking.map((b) => (\n <div key={b} className=\"inline-flex items-center gap-1\">\n <AlertCircle size={9} /> {BLOCKING_LABEL[b]}\n </div>\n ))}\n </div>\n </div>\n )}\n </TableCell>\n <TableCell className=\"px-5 py-3 text-right\">\n {c.status === \"ready\" ? (\n <Button variant=\"brand\" size=\"sm\">Close session <ArrowRight size={11} /></Button>\n ) : (\n <Button variant=\"outline\" size=\"sm\">Review</Button>\n )}\n </TableCell>\n </TableRow>\n );\n })}\n </TableBody>\n </Table>\n </Card>\n )}\n </AdminShell>\n );\n}\n\nfunction Metric({ icon, label, value, accent }: { icon: React.ReactNode; label: string; value: number; accent?: \"success\" | \"warning\" | \"error\" }) {\n const fg =\n accent === \"success\" ? \"var(--color-status-success-text)\" :\n accent === \"warning\" ? \"var(--color-status-warning-text)\" :\n accent === \"error\" ? \"var(--color-status-error-text)\" : \"var(--color-text-brand)\";\n const bg =\n accent === \"success\" ? \"var(--color-status-success-bg)\" :\n accent === \"warning\" ? \"var(--color-status-warning-bg)\" :\n 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 <CardDescription className=\"text-[10px] font-medium uppercase tracking-wide\">{label}</CardDescription>\n <CardTitle className=\"text-2xl font-semibold tabular-nums\" style={{ letterSpacing: \"-0.02em\" }}>{value}</CardTitle>\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\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 >\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"
|
|
52
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"revision": "6b4b2770d04e42cc2e5453c3d80e35c282b3438f",
|
|
3
|
+
"file": "app/admin/devices/page.tsx",
|
|
4
|
+
"sha256": "2ab2d2812506801b9ff3e14f3ef02dd0c0c9511c6c6824ce58fa7025d792fed4",
|
|
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-text-brand",
|
|
27
|
+
"--color-text-muted",
|
|
28
|
+
"--color-text-primary",
|
|
29
|
+
"--color-text-secondary",
|
|
30
|
+
"--red-500",
|
|
31
|
+
"--teal-500"
|
|
32
|
+
],
|
|
33
|
+
"fragments": [
|
|
34
|
+
{
|
|
35
|
+
"id": "DevicesPage",
|
|
36
|
+
"line": 23,
|
|
37
|
+
"code": "export default function DevicesPage() {\n const [q, setQ] = useState(\"\");\n const [statusFilter, setStatusFilter] = useState<DeviceStatus | \"all\">(\"all\");\n const [providerFilter, setProviderFilter] = useState<string>(\"all\");\n\n const filtered = useMemo(() => {\n let items = [...TPI_DEVICES];\n if (statusFilter !== \"all\") items = items.filter((d) => d.status === statusFilter);\n if (providerFilter !== \"all\") items = items.filter((d) => d.providerId === providerFilter);\n if (q) {\n const n = q.toLowerCase();\n items = items.filter((d) =>\n d.id.toLowerCase().includes(n) ||\n d.hospitalSite.toLowerCase().includes(n) ||\n d.trainee?.toLowerCase().includes(n)\n );\n }\n return items;\n }, [q, statusFilter, providerFilter]);\n\n const onlineCount = TPI_DEVICES.filter((d) => d.status === \"online\" || d.status === \"in_session\").length;\n const offlineCount = TPI_DEVICES.filter((d) => d.status === \"offline\" || d.status === \"error\").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 Teleproctoring devices\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Live status of teleproctoring hardware at hospital sites.\n </p>\n </div>\n <div className=\"flex items-center gap-2\">\n <Link href=\"/admin/providers\"><Button variant=\"outline\">Manage providers</Button></Link>\n <Button variant=\"brand\"><Plus size={13} /> Register device</Button>\n </div>\n </div>\n\n <div className=\"grid grid-cols-1 sm:grid-cols-4 gap-3 mb-6\">\n <Stat icon={<Cpu size={16} />} label=\"Total devices\" value={TPI_DEVICES.length} />\n <Stat icon={<Activity size={16} />} label=\"Online\" value={onlineCount} accent=\"success\" />\n <Stat icon={<AlertTriangle size={16} />} label=\"Offline / error\" value={offlineCount} accent=\"error\" />\n <Stat icon={<Wrench size={16} />} label=\"Maintenance\" value={TPI_DEVICES.filter((d) => d.status === \"maintenance\").length} accent=\"muted\" />\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 device ID, site, trainee…\" 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 DeviceStatus | \"all\")} options={[\n { value: \"all\", label: \"All statuses\" },\n ...Object.entries(DEVICE_STATUS_LABEL).map(([v, l]) => ({ value: v, label: l })),\n ]} />\n <FilterSelect value={providerFilter} onChange={setProviderFilter} options={[\n { value: \"all\", label: \"All providers\" },\n ...TPI_PROVIDERS.map((p) => ({ value: p.id, label: p.name })),\n ]} />\n </div>\n\n {filtered.length === 0 ? (\n <EmptyState icon={Cpu} title=\"No devices 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)\" }}>Device</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Site</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Provider</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Linked trainee</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)\" }}>Last check</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.map((d) => (\n <TableRow key={d.id}>\n <TableCell className=\"px-5 py-3 font-mono text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>{d.id}</TableCell>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-center gap-2 text-[12px]\">\n <MapPin size={11} style={{ color: \"var(--color-text-muted)\" }} />\n <span className=\"font-medium\">{d.hospitalSite}</span>\n </div>\n <div className=\"text-[10px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>{d.country}</div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\">{d.providerName}</TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\">\n {d.trainee ?? <span style={{ color: \"var(--color-text-muted)\" }}>—</span>}\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <span className=\"inline-flex items-center gap-1.5 text-[11px] font-medium\">\n <span className=\"w-1.5 h-1.5 rounded-full\" style={{ background: STATUS_DOT[d.status] }} />\n {DEVICE_STATUS_LABEL[d.status]}\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>{d.lastStatusAt}</TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </Card>\n )}\n </AdminShell>\n );\n}"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "Stat",
|
|
41
|
+
"line": 134,
|
|
42
|
+
"code": "function Stat({ icon, label, value, accent }: { icon: React.ReactNode; label: string; value: number; accent?: \"success\" | \"error\" | \"muted\" }) {\n const fg = accent === \"success\" ? \"var(--color-status-success-text)\" : accent === \"error\" ? \"var(--color-status-error-text)\" : accent === \"muted\" ? \"var(--color-text-muted)\" : \"var(--color-text-brand)\";\n const bg = accent === \"success\" ? \"var(--color-status-success-bg)\" : accent === \"error\" ? \"var(--color-status-error-bg)\" : accent === \"muted\" ? \"var(--color-bg-subtle)\" : \"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}"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "FilterSelect",
|
|
46
|
+
"line": 152,
|
|
47
|
+
"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}"
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
"content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useState, useMemo } from \"react\";\nimport { Search, X, ChevronDown, Cpu, Plus, MapPin, ArrowRight, Activity, AlertTriangle, Wrench } 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 { TPI_DEVICES, TPI_PROVIDERS, DEVICE_STATUS_LABEL, type DeviceStatus } from \"@/lib/closures-mock\";\n\nconst STATUS_DOT: Record<DeviceStatus, string> = {\n online: \"var(--color-status-success-text)\",\n offline: \"var(--color-status-error-text)\",\n in_session: \"var(--color-text-brand)\",\n error: \"var(--red-500)\",\n maintenance: \"var(--color-text-muted)\",\n};\n\nexport default function DevicesPage() {\n const [q, setQ] = useState(\"\");\n const [statusFilter, setStatusFilter] = useState<DeviceStatus | \"all\">(\"all\");\n const [providerFilter, setProviderFilter] = useState<string>(\"all\");\n\n const filtered = useMemo(() => {\n let items = [...TPI_DEVICES];\n if (statusFilter !== \"all\") items = items.filter((d) => d.status === statusFilter);\n if (providerFilter !== \"all\") items = items.filter((d) => d.providerId === providerFilter);\n if (q) {\n const n = q.toLowerCase();\n items = items.filter((d) =>\n d.id.toLowerCase().includes(n) ||\n d.hospitalSite.toLowerCase().includes(n) ||\n d.trainee?.toLowerCase().includes(n)\n );\n }\n return items;\n }, [q, statusFilter, providerFilter]);\n\n const onlineCount = TPI_DEVICES.filter((d) => d.status === \"online\" || d.status === \"in_session\").length;\n const offlineCount = TPI_DEVICES.filter((d) => d.status === \"offline\" || d.status === \"error\").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 Teleproctoring devices\n </h1>\n <p className=\"text-sm\" style={{ color: \"var(--color-text-secondary)\" }}>\n Live status of teleproctoring hardware at hospital sites.\n </p>\n </div>\n <div className=\"flex items-center gap-2\">\n <Link href=\"/admin/providers\"><Button variant=\"outline\">Manage providers</Button></Link>\n <Button variant=\"brand\"><Plus size={13} /> Register device</Button>\n </div>\n </div>\n\n <div className=\"grid grid-cols-1 sm:grid-cols-4 gap-3 mb-6\">\n <Stat icon={<Cpu size={16} />} label=\"Total devices\" value={TPI_DEVICES.length} />\n <Stat icon={<Activity size={16} />} label=\"Online\" value={onlineCount} accent=\"success\" />\n <Stat icon={<AlertTriangle size={16} />} label=\"Offline / error\" value={offlineCount} accent=\"error\" />\n <Stat icon={<Wrench size={16} />} label=\"Maintenance\" value={TPI_DEVICES.filter((d) => d.status === \"maintenance\").length} accent=\"muted\" />\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 device ID, site, trainee…\" 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 DeviceStatus | \"all\")} options={[\n { value: \"all\", label: \"All statuses\" },\n ...Object.entries(DEVICE_STATUS_LABEL).map(([v, l]) => ({ value: v, label: l })),\n ]} />\n <FilterSelect value={providerFilter} onChange={setProviderFilter} options={[\n { value: \"all\", label: \"All providers\" },\n ...TPI_PROVIDERS.map((p) => ({ value: p.id, label: p.name })),\n ]} />\n </div>\n\n {filtered.length === 0 ? (\n <EmptyState icon={Cpu} title=\"No devices 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)\" }}>Device</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Site</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Provider</TableHead>\n <TableHead className=\"text-[10px] font-semibold uppercase tracking-wider px-5\" style={{ color: \"var(--color-text-muted)\" }}>Linked trainee</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)\" }}>Last check</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.map((d) => (\n <TableRow key={d.id}>\n <TableCell className=\"px-5 py-3 font-mono text-[12px]\" style={{ color: \"var(--color-text-secondary)\" }}>{d.id}</TableCell>\n <TableCell className=\"px-5 py-3\">\n <div className=\"flex items-center gap-2 text-[12px]\">\n <MapPin size={11} style={{ color: \"var(--color-text-muted)\" }} />\n <span className=\"font-medium\">{d.hospitalSite}</span>\n </div>\n <div className=\"text-[10px] mt-0.5\" style={{ color: \"var(--color-text-muted)\" }}>{d.country}</div>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\">{d.providerName}</TableCell>\n <TableCell className=\"px-5 py-3 text-[12px]\">\n {d.trainee ?? <span style={{ color: \"var(--color-text-muted)\" }}>—</span>}\n </TableCell>\n <TableCell className=\"px-5 py-3\">\n <span className=\"inline-flex items-center gap-1.5 text-[11px] font-medium\">\n <span className=\"w-1.5 h-1.5 rounded-full\" style={{ background: STATUS_DOT[d.status] }} />\n {DEVICE_STATUS_LABEL[d.status]}\n </span>\n </TableCell>\n <TableCell className=\"px-5 py-3 text-[11px]\" style={{ color: \"var(--color-text-muted)\" }}>{d.lastStatusAt}</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\" | \"error\" | \"muted\" }) {\n const fg = accent === \"success\" ? \"var(--color-status-success-text)\" : accent === \"error\" ? \"var(--color-status-error-text)\" : accent === \"muted\" ? \"var(--color-text-muted)\" : \"var(--color-text-brand)\";\n const bg = accent === \"success\" ? \"var(--color-status-success-bg)\" : accent === \"error\" ? \"var(--color-status-error-bg)\" : accent === \"muted\" ? \"var(--color-bg-subtle)\" : \"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"
|
|
51
|
+
}
|