@exxatdesignux/ui 0.0.5 → 0.0.7
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/init.mjs +29 -0
- package/package.json +7 -2
- package/template/.nvmrc +1 -0
- package/template/.prettierignore +7 -0
- package/template/.prettierrc +11 -0
- package/template/AGENTS.md +485 -0
- package/template/Logo/Exxat_Prism.svg +39 -0
- package/template/Logo/Exxat_one.svg +36 -0
- package/template/README.md +58 -0
- package/template/app/(app)/compliance/page.tsx +10 -0
- package/template/app/(app)/dashboard/loading.tsx +18 -0
- package/template/app/(app)/dashboard/page.tsx +36 -0
- package/template/app/(app)/data-list/[id]/page.tsx +28 -0
- package/template/app/(app)/data-list/new/page.tsx +31 -0
- package/template/app/(app)/data-list/page.tsx +10 -0
- package/template/app/(app)/error.tsx +43 -0
- package/template/app/(app)/help/page.tsx +34 -0
- package/template/app/(app)/layout.tsx +54 -0
- package/template/app/(app)/loading.tsx +18 -0
- package/template/app/(app)/question-bank/page.tsx +10 -0
- package/template/app/(app)/rotations/page.tsx +15 -0
- package/template/app/(app)/settings/page.tsx +17 -0
- package/template/app/(app)/sites/all/page.tsx +13 -0
- package/template/app/(app)/team/page.tsx +10 -0
- package/template/app/favicon.ico +0 -0
- package/template/app/globals.css +1811 -0
- package/template/app/layout.tsx +95 -0
- package/template/app/page.tsx +9 -0
- package/template/components/.gitkeep +0 -0
- package/template/components/app-sidebar-dynamic.tsx +15 -0
- package/template/components/app-sidebar.tsx +901 -0
- package/template/components/ask-leo-composer.tsx +216 -0
- package/template/components/ask-leo-sidebar.tsx +509 -0
- package/template/components/chart-area-interactive.tsx +293 -0
- package/template/components/charts-overview.tsx +2321 -0
- package/template/components/command-menu-01.tsx +133 -0
- package/template/components/command-menu-02.tsx +386 -0
- package/template/components/command-menu.tsx +182 -0
- package/template/components/compliance-board-view.tsx +134 -0
- package/template/components/compliance-client.tsx +92 -0
- package/template/components/compliance-list-view.tsx +59 -0
- package/template/components/compliance-page-header.tsx +89 -0
- package/template/components/compliance-table.tsx +525 -0
- package/template/components/dashboard-onboarding-gallery.tsx +13 -0
- package/template/components/dashboard-onboarding.tsx +21 -0
- package/template/components/dashboard-promo-banner.tsx +67 -0
- package/template/components/dashboard-quota-progress-card.tsx +369 -0
- package/template/components/dashboard-report-charts.tsx +69 -0
- package/template/components/dashboard-section-heading.tsx +68 -0
- package/template/components/dashboard-tabs.tsx +598 -0
- package/template/components/data-list-client.tsx +239 -0
- package/template/components/data-list-table-cells.test.tsx +22 -0
- package/template/components/data-list-table-cells.tsx +173 -0
- package/template/components/data-list-table.tsx +879 -0
- package/template/components/data-table/filter-date-calendar.tsx +38 -0
- package/template/components/data-table/filter-text-value-input.tsx +77 -0
- package/template/components/data-table/index.tsx +1612 -0
- package/template/components/data-table/pagination.tsx +256 -0
- package/template/components/data-table/types.ts +91 -0
- package/template/components/data-table/use-table-state.ts +566 -0
- package/template/components/data-view-dashboard-charts-compliance.tsx +960 -0
- package/template/components/data-view-dashboard-charts-team.tsx +968 -0
- package/template/components/data-view-dashboard-charts.tsx +1668 -0
- package/template/components/data-views/board-card-primitives.tsx +93 -0
- package/template/components/data-views/index.ts +41 -0
- package/template/components/data-views/list-page-board-card.tsx +192 -0
- package/template/components/data-views/list-page-board-template.tsx +122 -0
- package/template/components/data-views/placement-board-card.tsx +262 -0
- package/template/components/export-drawer.tsx +375 -0
- package/template/components/exxat-product-logo.tsx +453 -0
- package/template/components/form-layout-01.tsx +131 -0
- package/template/components/getting-started.tsx +625 -0
- package/template/components/key-metrics.tsx +920 -0
- package/template/components/leo-insight-indicator.tsx +364 -0
- package/template/components/leo-typing-dots.tsx +121 -0
- package/template/components/list-hub-status-badge.tsx +51 -0
- package/template/components/list-page-dashboard-charts.tsx +18 -0
- package/template/components/nav-documents.tsx +89 -0
- package/template/components/nav-main.tsx +58 -0
- package/template/components/nav-secondary.tsx +64 -0
- package/template/components/nav-user.tsx +190 -0
- package/template/components/new-placement-back-btn.tsx +28 -0
- package/template/components/new-placement-form.tsx +1066 -0
- package/template/components/onboarding/index.ts +4 -0
- package/template/components/onboarding/onboarding-01.tsx +7 -0
- package/template/components/onboarding/onboarding-02.tsx +7 -0
- package/template/components/onboarding/onboarding-03.tsx +7 -0
- package/template/components/onboarding/onboarding-04.tsx +7 -0
- package/template/components/page-header.tsx +57 -0
- package/template/components/placement-detail.tsx +438 -0
- package/template/components/placements-board-view.tsx +404 -0
- package/template/components/placements-list-view.tsx +285 -0
- package/template/components/placements-page-header.tsx +160 -0
- package/template/components/placements-table-columns.tsx +639 -0
- package/template/components/product-switcher.tsx +116 -0
- package/template/components/question-bank-board-view.tsx +205 -0
- package/template/components/question-bank-client.tsx +77 -0
- package/template/components/question-bank-list-view.tsx +59 -0
- package/template/components/question-bank-page-header.tsx +89 -0
- package/template/components/question-bank-table.tsx +586 -0
- package/template/components/rotations-empty-state.tsx +47 -0
- package/template/components/rotations-panel-activator.tsx +8 -0
- package/template/components/secondary-nav.tsx +394 -0
- package/template/components/secondary-panel.tsx +239 -0
- package/template/components/section-cards.tsx +106 -0
- package/template/components/settings-appearance-card.tsx +424 -0
- package/template/components/settings-client.tsx +537 -0
- package/template/components/settings-form-row.tsx +42 -0
- package/template/components/sidebar-auto-collapse.tsx +23 -0
- package/template/components/sidebar-auto-open.tsx +18 -0
- package/template/components/sidebar-shell.tsx +37 -0
- package/template/components/site-header.tsx +93 -0
- package/template/components/sites-all-client.tsx +154 -0
- package/template/components/sites-board-view.tsx +67 -0
- package/template/components/sites-list-view.tsx +47 -0
- package/template/components/sites-table.tsx +312 -0
- package/template/components/system-banner-slot.tsx +66 -0
- package/template/components/table-properties/column-row.tsx +90 -0
- package/template/components/table-properties/draggable-list.ts +49 -0
- package/template/components/table-properties/drawer-button.tsx +231 -0
- package/template/components/table-properties/drawer.tsx +1102 -0
- package/template/components/table-properties/filter-card.tsx +251 -0
- package/template/components/table-properties/index.ts +22 -0
- package/template/components/table-properties/sort-card.tsx +59 -0
- package/template/components/table-properties/types.ts +124 -0
- package/template/components/task-list-panel.tsx +98 -0
- package/template/components/task-priority-badge.tsx +28 -0
- package/template/components/team-board-view.tsx +114 -0
- package/template/components/team-client.tsx +93 -0
- package/template/components/team-list-view.tsx +62 -0
- package/template/components/team-page-header.tsx +92 -0
- package/template/components/team-table.tsx +525 -0
- package/template/components/templates/list-page.tsx +576 -0
- package/template/components/templates/primary-page-template.tsx +56 -0
- package/template/components/theme-color-sync.tsx +32 -0
- package/template/components/theme-provider.tsx +71 -0
- package/template/components/tinted-icon-disc.tsx +53 -0
- package/template/components/ui/ai-thinking-surface.tsx +121 -0
- package/template/components/ui/avatar.tsx +1 -0
- package/template/components/ui/badge.tsx +1 -0
- package/template/components/ui/banner.tsx +1 -0
- package/template/components/ui/breadcrumb.tsx +1 -0
- package/template/components/ui/button.tsx +1 -0
- package/template/components/ui/calendar.tsx +1 -0
- package/template/components/ui/card.tsx +1 -0
- package/template/components/ui/chart.tsx +1 -0
- package/template/components/ui/checkbox.tsx +1 -0
- package/template/components/ui/coach-mark.tsx +1 -0
- package/template/components/ui/collapsible.tsx +1 -0
- package/template/components/ui/command.tsx +1 -0
- package/template/components/ui/date-picker-field.tsx +1 -0
- package/template/components/ui/dialog.tsx +1 -0
- package/template/components/ui/dot-pattern.tsx +159 -0
- package/template/components/ui/drag-handle-grip.tsx +1 -0
- package/template/components/ui/drawer.tsx +1 -0
- package/template/components/ui/dropdown-menu.tsx +1 -0
- package/template/components/ui/field.tsx +1 -0
- package/template/components/ui/form.tsx +1 -0
- package/template/components/ui/input-group.tsx +1 -0
- package/template/components/ui/input-mask.tsx +1 -0
- package/template/components/ui/input.tsx +1 -0
- package/template/components/ui/kbd.tsx +1 -0
- package/template/components/ui/label.tsx +1 -0
- package/template/components/ui/leo-icon.tsx +726 -0
- package/template/components/ui/payment-card-fields.tsx +1 -0
- package/template/components/ui/popover.tsx +1 -0
- package/template/components/ui/radio-group.tsx +1 -0
- package/template/components/ui/select.tsx +1 -0
- package/template/components/ui/selection-tile-grid.tsx +1 -0
- package/template/components/ui/separator.tsx +1 -0
- package/template/components/ui/sheet.tsx +1 -0
- package/template/components/ui/sidebar.tsx +1 -0
- package/template/components/ui/skeleton.tsx +1 -0
- package/template/components/ui/sonner.tsx +1 -0
- package/template/components/ui/status-badge.tsx +1 -0
- package/template/components/ui/table.tsx +1 -0
- package/template/components/ui/tabs.tsx +1 -0
- package/template/components/ui/textarea.tsx +1 -0
- package/template/components/ui/tip.tsx +1 -0
- package/template/components/ui/toggle-group.tsx +1 -0
- package/template/components/ui/toggle-switch.tsx +1 -0
- package/template/components/ui/toggle.tsx +1 -0
- package/template/components/ui/tooltip.tsx +1 -0
- package/template/components/ui/view-segmented-control.tsx +1 -0
- package/template/components.json +27 -0
- package/template/contexts/chart-variant-context.tsx +35 -0
- package/template/contexts/command-menu-context.tsx +28 -0
- package/template/contexts/dashboard-view-context.tsx +35 -0
- package/template/contexts/product-context.tsx +38 -0
- package/template/contexts/system-banner-context.tsx +127 -0
- package/template/docs/command-menu-pattern.md +45 -0
- package/template/docs/data-views-pattern.md +160 -0
- package/template/ecosystem.config.cjs +20 -0
- package/template/eslint.config.mjs +18 -0
- package/template/fontawesome-subset.manifest.json +190 -0
- package/template/hooks/.gitkeep +0 -0
- package/template/hooks/use-app-theme.ts +1 -0
- package/template/hooks/use-coach-mark.ts +1 -0
- package/template/hooks/use-mobile.ts +1 -0
- package/template/hooks/use-mod-key-label.ts +1 -0
- package/template/lib/.gitkeep +0 -0
- package/template/lib/ask-leo-route-context.ts +133 -0
- package/template/lib/chart-keyboard-selection.test.ts +20 -0
- package/template/lib/chart-keyboard-selection.ts +17 -0
- package/template/lib/chart-line-dash.ts +16 -0
- package/template/lib/coach-mark-registry.ts +68 -0
- package/template/lib/command-menu-config.ts +127 -0
- package/template/lib/command-menu-search-data.ts +44 -0
- package/template/lib/conditional-rule-match.ts +32 -0
- package/template/lib/dashboard-customize-coach-mark.ts +18 -0
- package/template/lib/dashboard-layout-merge.ts +63 -0
- package/template/lib/data-list-display-options.ts +35 -0
- package/template/lib/data-list-persistence.ts +280 -0
- package/template/lib/data-list-view-surface.ts +58 -0
- package/template/lib/data-list-view.ts +29 -0
- package/template/lib/data-view-dashboard-storage.ts +101 -0
- package/template/lib/date-filter.ts +8 -0
- package/template/lib/dev-log.test.ts +28 -0
- package/template/lib/dev-log.ts +8 -0
- package/template/lib/editable-target.ts +10 -0
- package/template/lib/floating-sheet-panel.ts +72 -0
- package/template/lib/initials-from-name.ts +7 -0
- package/template/lib/list-page-table-properties.ts +52 -0
- package/template/lib/list-status-badges.ts +168 -0
- package/template/lib/logo-dev.ts +12 -0
- package/template/lib/mock/compliance-kpi.ts +61 -0
- package/template/lib/mock/compliance.ts +146 -0
- package/template/lib/mock/dashboard.ts +105 -0
- package/template/lib/mock/navigation.tsx +231 -0
- package/template/lib/mock/placements-kpi.ts +134 -0
- package/template/lib/mock/placements.ts +183 -0
- package/template/lib/mock/question-bank-kpi.ts +61 -0
- package/template/lib/mock/question-bank.ts +142 -0
- package/template/lib/mock/sites-directory.ts +16 -0
- package/template/lib/mock/sites-kpi.ts +25 -0
- package/template/lib/mock/team-kpi.ts +60 -0
- package/template/lib/mock/team.ts +118 -0
- package/template/lib/motion-ui.ts +17 -0
- package/template/lib/placement-board-card-layout.ts +79 -0
- package/template/lib/placement-lifecycle.ts +5 -0
- package/template/lib/row-height.ts +10 -0
- package/template/lib/stock-portrait.ts +11 -0
- package/template/lib/utils.test.ts +13 -0
- package/template/lib/utils.ts +1 -0
- package/template/next.config.mjs +15 -0
- package/template/package.json +83 -0
- package/template/postcss.config.mjs +8 -0
- package/template/public/.gitkeep +0 -0
- package/template/public/Illustration/Rotation.svg +74 -0
- package/template/public/avatars/user.svg +11 -0
- package/template/public/favicon/favicon.ico +0 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/logos/exxat-one.svg +36 -0
- package/template/public/logos/exxat-prism.svg +39 -0
- package/template/public/mock-schools/emory.svg +4 -0
- package/template/public/mock-schools/rush.svg +4 -0
- package/template/scripts/fontawesome-subset-audit.mjs +190 -0
- package/template/scripts/pm2-startup-macos.sh +13 -0
- package/template/skills-lock.json +10 -0
- package/template/stores/app-store.ts +33 -0
- package/template/tests/setup.ts +1 -0
- package/template/tsconfig.json +35 -0
- package/template/types/react-payment-inputs.d.ts +19 -0
- package/template/vitest.config.ts +18 -0
|
@@ -0,0 +1,2321 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ChartsOverview — Dashboard chart gallery
|
|
5
|
+
*
|
|
6
|
+
* ── ChartCard variants ───────────────────────────────────────────────────────
|
|
7
|
+
* normal — plain card with Ask Leo
|
|
8
|
+
* tabs — "Chart" | "Trend (Line)" tabs + Ask Leo
|
|
9
|
+
* selector — quick-filter Select + Ask Leo
|
|
10
|
+
* metrics-tabs — metric cells ARE the tab triggers (label + value + trend)
|
|
11
|
+
*
|
|
12
|
+
* ── ASK LEO ICON GUIDELINE ───────────────────────────────────────────────────
|
|
13
|
+
* Always use: <i className="fa-duotone fa-solid fa-star-christmas" />
|
|
14
|
+
* Never use: fa-wand-magic-sparkles (retired, inconsistent)
|
|
15
|
+
* Size: text-xs (11px via --text-xs) with aria-hidden="true"
|
|
16
|
+
* Label: "Ask Leo" (never truncate or omit the text label)
|
|
17
|
+
* Applies to: ALL Ask Leo buttons across the entire app —
|
|
18
|
+
* ChartCard headers, KeyMetrics card, GreetingWidget, NavUser, etc.
|
|
19
|
+
*
|
|
20
|
+
* ── WCAG AA STANDARDS FOR GRAPHS ─────────────────────────────────────────────
|
|
21
|
+
* 1. Container landmark
|
|
22
|
+
* • Wrap each chart in a <figure> (or div with role="figure") +
|
|
23
|
+
* aria-label="<chart title>" + aria-describedby="<id of summary>"
|
|
24
|
+
* • Add a visually-hidden <figcaption id="<id>"> with a plain-text
|
|
25
|
+
* summary of the key trend (e.g. "Placements rose 12% in Q1 2026").
|
|
26
|
+
*
|
|
27
|
+
* 2. Keyboard navigation
|
|
28
|
+
* • The ChartContainer wrapper must have tabIndex={0} so it receives focus.
|
|
29
|
+
* • On focus, announce title + summary via aria-label / aria-describedby.
|
|
30
|
+
* • Arrow keys (←/→) cycle through data points; announce value via
|
|
31
|
+
* a live region (role="status" aria-live="polite").
|
|
32
|
+
* • Esc clears the selection and returns focus to the container.
|
|
33
|
+
*
|
|
34
|
+
* 3. Accessible data table (hidden fallback)
|
|
35
|
+
* • Immediately after the SVG/canvas, render a <table> wrapped in
|
|
36
|
+
* <span className="sr-only"> (visually hidden, in DOM).
|
|
37
|
+
* • Columns mirror the chart axes; each data point is a <td>.
|
|
38
|
+
* • Screen-reader users can navigate data with standard table shortcuts.
|
|
39
|
+
*
|
|
40
|
+
* 4. Colour & contrast
|
|
41
|
+
* • Chart series colours must achieve ≥ 3:1 contrast against the card bg.
|
|
42
|
+
* • Never use colour as the ONLY differentiator — pair with:
|
|
43
|
+
* - Dashed vs solid line strokes
|
|
44
|
+
* - Direct inline labels on lines/segments
|
|
45
|
+
* - Shape markers on data points (circle vs square vs triangle)
|
|
46
|
+
* • Text labels inside charts: ≥ 4.5:1 on their local background.
|
|
47
|
+
*
|
|
48
|
+
* 5. Focus ring on data points
|
|
49
|
+
* • Active/focused data point: 3px outline, ≥ 3:1 contrast, distinct
|
|
50
|
+
* from the hover state (use outline-offset to separate).
|
|
51
|
+
*
|
|
52
|
+
* 6. Tooltip accessibility
|
|
53
|
+
* • Tooltips must appear on keyboard focus, not only on mouse hover.
|
|
54
|
+
* • Tooltip content must be announced to the live region.
|
|
55
|
+
* • Tooltip must remain visible while it has focus (no auto-dismiss).
|
|
56
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import * as React from "react"
|
|
60
|
+
import {
|
|
61
|
+
Area, AreaChart,
|
|
62
|
+
Bar, BarChart,
|
|
63
|
+
CartesianGrid,
|
|
64
|
+
Cell,
|
|
65
|
+
ComposedChart,
|
|
66
|
+
Funnel, FunnelChart, LabelList,
|
|
67
|
+
Line, LineChart,
|
|
68
|
+
Pie, PieChart,
|
|
69
|
+
PolarAngleAxis, PolarGrid, PolarRadiusAxis,
|
|
70
|
+
Radar, RadarChart,
|
|
71
|
+
RadialBar, RadialBarChart,
|
|
72
|
+
Scatter, ScatterChart,
|
|
73
|
+
XAxis, YAxis, ZAxis,
|
|
74
|
+
type DotProps,
|
|
75
|
+
} from "recharts"
|
|
76
|
+
import {
|
|
77
|
+
QuotaLinearProgressCardBody,
|
|
78
|
+
QuotaRadialChartInner,
|
|
79
|
+
} from "@/components/dashboard-quota-progress-card"
|
|
80
|
+
import {
|
|
81
|
+
DASHBOARD_STUDENT_SCORES,
|
|
82
|
+
formatBandScore,
|
|
83
|
+
type StudentScoreRadial,
|
|
84
|
+
} from "@/lib/mock/dashboard"
|
|
85
|
+
import {
|
|
86
|
+
Card, CardContent, CardDescription, CardHeader, CardTitle,
|
|
87
|
+
} from "@/components/ui/card"
|
|
88
|
+
import {
|
|
89
|
+
ChartContainer,
|
|
90
|
+
ChartLegend,
|
|
91
|
+
ChartLegendContent,
|
|
92
|
+
ChartTooltip,
|
|
93
|
+
chartTooltipKeyboardSyncProps,
|
|
94
|
+
ChartTooltipContent,
|
|
95
|
+
type ChartConfig,
|
|
96
|
+
} from "@/components/ui/chart"
|
|
97
|
+
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
|
98
|
+
import {
|
|
99
|
+
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
100
|
+
} from "@/components/ui/select"
|
|
101
|
+
import { Button } from "@/components/ui/button"
|
|
102
|
+
import { AskLeoShortcutKbds, useAskLeo } from "@/components/ask-leo-sidebar"
|
|
103
|
+
import {
|
|
104
|
+
Tooltip,
|
|
105
|
+
TooltipContent,
|
|
106
|
+
TooltipTrigger,
|
|
107
|
+
} from "@/components/ui/tooltip"
|
|
108
|
+
import { isEditableTarget } from "@/lib/editable-target"
|
|
109
|
+
import { chartLineStrokeDash } from "@/lib/chart-line-dash"
|
|
110
|
+
import { cn } from "@/lib/utils"
|
|
111
|
+
|
|
112
|
+
/** Recharts passes `index` into Line `dot` renderers; published `DotProps` omits it. */
|
|
113
|
+
type LineDotRenderProps = DotProps & { index?: number }
|
|
114
|
+
|
|
115
|
+
/* ── Colour tokens ────────────────────────────────────────────────────────── */
|
|
116
|
+
const BRAND = "var(--brand-color)"
|
|
117
|
+
const CHART_1 = "var(--color-chart-1)"
|
|
118
|
+
const CHART_2 = "var(--color-chart-2)"
|
|
119
|
+
const CHART_3 = "var(--color-chart-3)"
|
|
120
|
+
const CHART_4 = "var(--color-chart-4)"
|
|
121
|
+
const CHART_5 = "var(--color-chart-5)"
|
|
122
|
+
const SUCCESS = "var(--chart-2)"
|
|
123
|
+
const WARNING = "var(--chart-4)"
|
|
124
|
+
const DESTRUCTIVE = "var(--destructive)"
|
|
125
|
+
|
|
126
|
+
/* ── Period filter options (reused across selector cards) ─────────────────── */
|
|
127
|
+
const PERIOD_OPTIONS = [
|
|
128
|
+
{ value: "7d", label: "Last 7 days" },
|
|
129
|
+
{ value: "30d", label: "Last 30 days" },
|
|
130
|
+
{ value: "90d", label: "Last quarter" },
|
|
131
|
+
{ value: "1y", label: "Last year" },
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
const PROGRAM_OPTIONS = [
|
|
135
|
+
{ value: "all", label: "All programs" },
|
|
136
|
+
{ value: "nursing", label: "Nursing" },
|
|
137
|
+
{ value: "pt", label: "PT" },
|
|
138
|
+
{ value: "ot", label: "OT" },
|
|
139
|
+
{ value: "pharmacy", label: "Pharmacy" },
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
/* ════════════════════════════════════════════════════════════════════════════
|
|
143
|
+
REUSABLE ChartCard — supports 3 variants
|
|
144
|
+
════════════════════════════════════════════════════════════════════════════ */
|
|
145
|
+
|
|
146
|
+
export type ChartCardVariant = "normal" | "tabs" | "selector" | "metrics-tabs" | "kpi-chart"
|
|
147
|
+
|
|
148
|
+
/** ChartCard tabs no longer force `text-xs` — use default `text-sm` scale and ≥24px hit area. */
|
|
149
|
+
const chartCardTabTriggerClass = "min-h-9 px-3 py-2 text-sm gap-2"
|
|
150
|
+
|
|
151
|
+
import {
|
|
152
|
+
LeoInsightIndicator,
|
|
153
|
+
LEO_TOKENS,
|
|
154
|
+
type ChartLeoInsight,
|
|
155
|
+
type ChartLeoInsightAnchor,
|
|
156
|
+
type ChartLeoInsightKind,
|
|
157
|
+
} from "@/components/leo-insight-indicator"
|
|
158
|
+
export type { ChartLeoInsight, ChartLeoInsightAnchor, ChartLeoInsightKind }
|
|
159
|
+
|
|
160
|
+
type ChartLeoInsightBundle = { insight: ChartLeoInsight; chartTitle: string }
|
|
161
|
+
|
|
162
|
+
const ChartLeoInsightContext = React.createContext<ChartLeoInsightBundle | null>(null)
|
|
163
|
+
|
|
164
|
+
function resolveChartLeoAnchorY(
|
|
165
|
+
row: Record<string, unknown>,
|
|
166
|
+
xDataKey: string,
|
|
167
|
+
anchor: ChartLeoInsightAnchor,
|
|
168
|
+
): number | null {
|
|
169
|
+
if (typeof anchor.yValue === "number" && !Number.isNaN(anchor.yValue)) {
|
|
170
|
+
return anchor.yValue
|
|
171
|
+
}
|
|
172
|
+
const keys =
|
|
173
|
+
anchor.yDataKeys?.filter((k) => k !== xDataKey) ??
|
|
174
|
+
Object.keys(row).filter((k) => k !== xDataKey)
|
|
175
|
+
const nums = keys
|
|
176
|
+
.map((k) => row[k])
|
|
177
|
+
.filter((v): v is number => typeof v === "number" && !Number.isNaN(v))
|
|
178
|
+
if (nums.length === 0) return null
|
|
179
|
+
const combine = anchor.yCombine ?? "max"
|
|
180
|
+
return combine === "sum" ? nums.reduce((a, b) => a + b, 0) : Math.max(...nums)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function chartLeoNumericDomainMax(
|
|
184
|
+
data: ReadonlyArray<Record<string, string | number | null | undefined>>,
|
|
185
|
+
xDataKey: string,
|
|
186
|
+
): number {
|
|
187
|
+
let m = 0
|
|
188
|
+
for (const row of data) {
|
|
189
|
+
for (const [k, v] of Object.entries(row)) {
|
|
190
|
+
if (k === xDataKey) continue
|
|
191
|
+
if (typeof v === "number" && !Number.isNaN(v) && v > m) m = v
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return m > 0 ? m : 1
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Static brand-coloured dot drawn on the exact data point Leo is calling out.
|
|
199
|
+
* A card-coloured knockout ring keeps it readable on top of grid lines and
|
|
200
|
+
* area fills. No pulsing animation — the dashed connector line + chip do the
|
|
201
|
+
* attention work, and this keeps the chart calm.
|
|
202
|
+
*/
|
|
203
|
+
function LeoPlotPointDot() {
|
|
204
|
+
return (
|
|
205
|
+
<span
|
|
206
|
+
aria-hidden
|
|
207
|
+
className={cn("block size-2.5 rounded-full", LEO_TOKENS.dotClass)}
|
|
208
|
+
style={{
|
|
209
|
+
boxShadow: `0 0 0 3px oklch(from var(--card) l c h / 0.95)`,
|
|
210
|
+
}}
|
|
211
|
+
/>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Read the Recharts SVG rendered by a sibling `ChartContainer` and project
|
|
217
|
+
* the insight anchor's `(xValue, yNum)` into pixel coordinates relative to
|
|
218
|
+
* the overlay's wrapper.
|
|
219
|
+
*
|
|
220
|
+
* Strategy — chart-type-agnostic:
|
|
221
|
+
* 1. Find the `<svg>` inside the parent (`.relative` wrapper).
|
|
222
|
+
* 2. Plot rect = bounding box of `.recharts-cartesian-grid`.
|
|
223
|
+
* Fallback = area between y-axis right edge and x-axis top edge.
|
|
224
|
+
* 3. X position = matching x-axis tick's centre, matched by text content.
|
|
225
|
+
* Fallback = `(idx + 0.5) / n * plotWidth` band formula.
|
|
226
|
+
* 4. Y position = interpolated from y-axis tick values (handles non-zero
|
|
227
|
+
* domain bases automatically — e.g. recharts auto-domains that start at
|
|
228
|
+
* non-zero and charts with cropped y-ranges). Fallback = `1 - y/yMax`.
|
|
229
|
+
*
|
|
230
|
+
* Works on: Line/Area/Bar/StackedBar/Composed charts — anything with Cartesian
|
|
231
|
+
* axes. Pie/Radar/Funnel charts don't expose axes, so the overlay skips with
|
|
232
|
+
* a null return (anchor concept isn't meaningful there).
|
|
233
|
+
*/
|
|
234
|
+
function useChartAnchorPixelPosition({
|
|
235
|
+
xValue,
|
|
236
|
+
xDataKey,
|
|
237
|
+
yNum,
|
|
238
|
+
data,
|
|
239
|
+
}: {
|
|
240
|
+
xValue: string
|
|
241
|
+
xDataKey: string
|
|
242
|
+
yNum: number
|
|
243
|
+
data: ReadonlyArray<Record<string, string | number | null | undefined>>
|
|
244
|
+
}) {
|
|
245
|
+
const ref = React.useRef<HTMLDivElement>(null)
|
|
246
|
+
const [pos, setPos] = React.useState<{ x: number; y: number; plotTop: number } | null>(null)
|
|
247
|
+
|
|
248
|
+
React.useEffect(() => {
|
|
249
|
+
const el = ref.current
|
|
250
|
+
if (!el) return
|
|
251
|
+
const parent = el.parentElement
|
|
252
|
+
if (!parent) return
|
|
253
|
+
|
|
254
|
+
const compute = () => {
|
|
255
|
+
const svg = parent.querySelector("svg") as SVGSVGElement | null
|
|
256
|
+
if (!svg) return
|
|
257
|
+
const parentRect = parent.getBoundingClientRect()
|
|
258
|
+
|
|
259
|
+
const toLocal = (el: Element) => {
|
|
260
|
+
const r = (el as SVGGraphicsElement).getBoundingClientRect()
|
|
261
|
+
return {
|
|
262
|
+
left: r.left - parentRect.left,
|
|
263
|
+
right: r.right - parentRect.left,
|
|
264
|
+
top: r.top - parentRect.top,
|
|
265
|
+
bottom: r.bottom - parentRect.top,
|
|
266
|
+
width: r.width,
|
|
267
|
+
height: r.height,
|
|
268
|
+
cx: r.left + r.width / 2 - parentRect.left,
|
|
269
|
+
cy: r.top + r.height / 2 - parentRect.top,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Plot rect — prefer cartesian-grid; fall back to axis bounds.
|
|
274
|
+
const grid = svg.querySelector(".recharts-cartesian-grid")
|
|
275
|
+
const xAxis = svg.querySelector(".recharts-xAxis")
|
|
276
|
+
const yAxis = svg.querySelector(".recharts-yAxis")
|
|
277
|
+
if (!xAxis || !yAxis) return
|
|
278
|
+
|
|
279
|
+
const plot = grid ? toLocal(grid) : (() => {
|
|
280
|
+
const y = toLocal(yAxis)
|
|
281
|
+
const x = toLocal(xAxis)
|
|
282
|
+
return {
|
|
283
|
+
left: y.right, right: x.right, top: y.top,
|
|
284
|
+
bottom: x.top, width: x.right - y.right, height: x.top - y.top,
|
|
285
|
+
cx: 0, cy: 0,
|
|
286
|
+
}
|
|
287
|
+
})()
|
|
288
|
+
|
|
289
|
+
// X position: find matching x-tick by text content (chart-agnostic).
|
|
290
|
+
const xTicks = Array.from(
|
|
291
|
+
xAxis.querySelectorAll(".recharts-cartesian-axis-tick"),
|
|
292
|
+
) as SVGGElement[]
|
|
293
|
+
let xPx: number | null = null
|
|
294
|
+
for (const t of xTicks) {
|
|
295
|
+
if ((t.textContent ?? "").trim() === xValue) {
|
|
296
|
+
xPx = toLocal(t).cx
|
|
297
|
+
break
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (xPx === null) {
|
|
301
|
+
const idx = data.findIndex((d) => String(d[xDataKey]) === xValue)
|
|
302
|
+
if (idx < 0) return
|
|
303
|
+
xPx = plot.left + ((idx + 0.5) / Math.max(data.length, 1)) * plot.width
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Y position: interpolate from y-axis tick values (handles non-zero domains).
|
|
307
|
+
const yTickEls = Array.from(
|
|
308
|
+
yAxis.querySelectorAll(".recharts-cartesian-axis-tick"),
|
|
309
|
+
) as SVGGElement[]
|
|
310
|
+
const yTickPairs: Array<{ v: number; y: number }> = []
|
|
311
|
+
for (const t of yTickEls) {
|
|
312
|
+
const raw = (t.textContent ?? "").trim()
|
|
313
|
+
if (!raw) continue
|
|
314
|
+
const v = parseFloat(raw.replace(/[^0-9.\-]/g, ""))
|
|
315
|
+
if (Number.isNaN(v)) continue
|
|
316
|
+
yTickPairs.push({ v, y: toLocal(t).cy })
|
|
317
|
+
}
|
|
318
|
+
let yPx: number | null = null
|
|
319
|
+
if (yTickPairs.length >= 2) {
|
|
320
|
+
const sorted = [...yTickPairs].sort((a, b) => a.v - b.v)
|
|
321
|
+
const lo = sorted[0], hi = sorted[sorted.length - 1]
|
|
322
|
+
if (hi.v !== lo.v) {
|
|
323
|
+
yPx = lo.y + ((yNum - lo.v) / (hi.v - lo.v)) * (hi.y - lo.y)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (yPx === null) {
|
|
327
|
+
// Conservative fallback when y-axis ticks cannot be parsed.
|
|
328
|
+
const yMax = chartLeoNumericDomainMax(data, xDataKey)
|
|
329
|
+
yPx = plot.top + (1 - yNum / yMax) * plot.height
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
setPos({ x: xPx, y: yPx, plotTop: plot.top })
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Recharts mounts/animates after our first paint; measure a few times.
|
|
336
|
+
compute()
|
|
337
|
+
let raf1 = requestAnimationFrame(() => {
|
|
338
|
+
compute()
|
|
339
|
+
raf1 = requestAnimationFrame(compute)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
const ro = new ResizeObserver(compute)
|
|
343
|
+
ro.observe(parent)
|
|
344
|
+
|
|
345
|
+
const mo = new MutationObserver(compute)
|
|
346
|
+
mo.observe(parent, {
|
|
347
|
+
childList: true,
|
|
348
|
+
subtree: true,
|
|
349
|
+
attributes: true,
|
|
350
|
+
attributeFilter: ["width", "height", "transform", "d", "x", "y"],
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
return () => {
|
|
354
|
+
cancelAnimationFrame(raf1)
|
|
355
|
+
ro.disconnect()
|
|
356
|
+
mo.disconnect()
|
|
357
|
+
}
|
|
358
|
+
}, [xValue, xDataKey, yNum, data])
|
|
359
|
+
|
|
360
|
+
return { ref, pos }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* HTML overlay on the chart plot (sibling of `ChartContainer`, inside a `relative` wrapper).
|
|
365
|
+
*
|
|
366
|
+
* Visual structure, top → bottom:
|
|
367
|
+
* 1. Chip (`LeoInsightIndicator` in plot-marker layout) — floats above
|
|
368
|
+
* 2. Dashed connector line in the kind colour joining chip to dot
|
|
369
|
+
* 3. Pulsing dot anchored on the real data point
|
|
370
|
+
*
|
|
371
|
+
* Positioning is measured from the Recharts SVG at runtime (see
|
|
372
|
+
* `useChartAnchorPixelPosition`) so the dot lands on the actual data point
|
|
373
|
+
* regardless of chart type, y-domain, or plot margin.
|
|
374
|
+
*/
|
|
375
|
+
export function ChartLeoPlotInsightOverlay({
|
|
376
|
+
data,
|
|
377
|
+
xDataKey,
|
|
378
|
+
markerLiftPx = 44,
|
|
379
|
+
}: {
|
|
380
|
+
data: ReadonlyArray<Record<string, string | number | null | undefined>>
|
|
381
|
+
xDataKey: string
|
|
382
|
+
/** @deprecated retained for call-site compatibility. */
|
|
383
|
+
insetPct?: { left: number; right: number; top: number; bottom: number }
|
|
384
|
+
/** @deprecated retained for call-site compatibility. */
|
|
385
|
+
xAxisLabelReservePct?: number
|
|
386
|
+
/** @deprecated retained for call-site compatibility. */
|
|
387
|
+
markerLiftPct?: number
|
|
388
|
+
/** @deprecated retained for call-site compatibility. */
|
|
389
|
+
markerLiftExtraPx?: number
|
|
390
|
+
/** Vertical distance from dot to the bottom of the floating chip, in px. */
|
|
391
|
+
markerLiftPx?: number
|
|
392
|
+
}) {
|
|
393
|
+
// Lift the chip well clear of the default Recharts tooltip so they never
|
|
394
|
+
// fight for the same cursor area on hover.
|
|
395
|
+
const effectiveLift = markerLiftPx ?? 56
|
|
396
|
+
const bundle = React.useContext(ChartLeoInsightContext)
|
|
397
|
+
const anchor = bundle?.insight.anchor
|
|
398
|
+
|
|
399
|
+
const idx = anchor
|
|
400
|
+
? data.findIndex((d) => String(d[xDataKey]) === anchor.xValue)
|
|
401
|
+
: -1
|
|
402
|
+
const row = idx >= 0 ? (data[idx] as Record<string, unknown>) : null
|
|
403
|
+
const yNum = row && anchor ? resolveChartLeoAnchorY(row, xDataKey, anchor) : null
|
|
404
|
+
|
|
405
|
+
// NOTE: Hook must always run (React rules). Pass safe defaults when not ready.
|
|
406
|
+
const { ref, pos } = useChartAnchorPixelPosition({
|
|
407
|
+
xValue: anchor?.xValue ?? "",
|
|
408
|
+
xDataKey,
|
|
409
|
+
yNum: yNum ?? 0,
|
|
410
|
+
data,
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
if (!bundle || !anchor || idx < 0 || yNum === null || Number.isNaN(yNum)) return null
|
|
414
|
+
|
|
415
|
+
// Clamp the chip so it never renders above the plot rect.
|
|
416
|
+
const chipBottomY = pos
|
|
417
|
+
? Math.max((pos.plotTop ?? 0) + 28, pos.y - effectiveLift)
|
|
418
|
+
: 0
|
|
419
|
+
|
|
420
|
+
return (
|
|
421
|
+
<div
|
|
422
|
+
ref={ref}
|
|
423
|
+
className="pointer-events-none absolute inset-0 z-20"
|
|
424
|
+
data-chart-leo-anchor=""
|
|
425
|
+
>
|
|
426
|
+
{pos && (
|
|
427
|
+
<>
|
|
428
|
+
{/* Dashed connector — chip bottom → ~7px above the dot, brand-coloured. */}
|
|
429
|
+
<div
|
|
430
|
+
aria-hidden
|
|
431
|
+
className="pointer-events-none absolute"
|
|
432
|
+
style={{
|
|
433
|
+
left: pos.x,
|
|
434
|
+
top: chipBottomY,
|
|
435
|
+
height: Math.max(0, pos.y - chipBottomY - 7),
|
|
436
|
+
transform: "translateX(-50%)",
|
|
437
|
+
borderLeft: `2px dashed oklch(from ${LEO_TOKENS.cssVar} l c h / 0.7)`,
|
|
438
|
+
}}
|
|
439
|
+
/>
|
|
440
|
+
|
|
441
|
+
{/* Static brand dot anchored on the real data point */}
|
|
442
|
+
<div
|
|
443
|
+
className="pointer-events-none absolute"
|
|
444
|
+
style={{
|
|
445
|
+
left: pos.x,
|
|
446
|
+
top: pos.y,
|
|
447
|
+
transform: "translate(-50%, -50%)",
|
|
448
|
+
}}
|
|
449
|
+
>
|
|
450
|
+
<LeoPlotPointDot />
|
|
451
|
+
</div>
|
|
452
|
+
|
|
453
|
+
{/* Chip trigger — bottom edge meets the top of the dashed connector */}
|
|
454
|
+
<div
|
|
455
|
+
className="pointer-events-auto absolute"
|
|
456
|
+
style={{
|
|
457
|
+
left: pos.x,
|
|
458
|
+
top: chipBottomY,
|
|
459
|
+
transform: "translate(-50%, -100%)",
|
|
460
|
+
}}
|
|
461
|
+
>
|
|
462
|
+
<LeoInsightIndicator
|
|
463
|
+
insight={bundle.insight}
|
|
464
|
+
chartTitle={bundle.chartTitle}
|
|
465
|
+
triggerLayout="plot-marker"
|
|
466
|
+
/>
|
|
467
|
+
</div>
|
|
468
|
+
</>
|
|
469
|
+
)}
|
|
470
|
+
</div>
|
|
471
|
+
)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
/** Supplies Leo insight to chart bodies; optional corner control when there is no plot anchor. */
|
|
476
|
+
function ChartLeoInsightOverlay({
|
|
477
|
+
leoInsight,
|
|
478
|
+
chartTitle,
|
|
479
|
+
children,
|
|
480
|
+
}: {
|
|
481
|
+
leoInsight?: ChartLeoInsight | null
|
|
482
|
+
chartTitle: string
|
|
483
|
+
children: React.ReactNode
|
|
484
|
+
}) {
|
|
485
|
+
if (!leoInsight) return <>{children}</>
|
|
486
|
+
const showCorner = !leoInsight.anchor
|
|
487
|
+
return (
|
|
488
|
+
<ChartLeoInsightContext.Provider value={{ insight: leoInsight, chartTitle }}>
|
|
489
|
+
{showCorner ? (
|
|
490
|
+
<div className="relative flex min-h-0 flex-1 flex-col">
|
|
491
|
+
{children}
|
|
492
|
+
<div className="pointer-events-none absolute right-2 top-2 z-20 sm:right-3 sm:top-3">
|
|
493
|
+
<div className="pointer-events-auto">
|
|
494
|
+
<LeoInsightIndicator insight={leoInsight} chartTitle={chartTitle} triggerLayout="toolbar" />
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
</div>
|
|
498
|
+
) : (
|
|
499
|
+
children
|
|
500
|
+
)}
|
|
501
|
+
</ChartLeoInsightContext.Provider>
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function AskLeoButton({ iconOnly = false }: { iconOnly?: boolean }) {
|
|
506
|
+
const { toggle } = useAskLeo()
|
|
507
|
+
return (
|
|
508
|
+
<Tooltip>
|
|
509
|
+
<TooltipTrigger asChild>
|
|
510
|
+
<Button
|
|
511
|
+
size="sm"
|
|
512
|
+
variant="outline"
|
|
513
|
+
className="h-7 shrink-0 text-xs gap-1.5 px-2"
|
|
514
|
+
aria-label="Ask Leo about this chart"
|
|
515
|
+
onClick={toggle}
|
|
516
|
+
>
|
|
517
|
+
<i className="fa-duotone fa-solid fa-star-christmas text-xs text-brand" aria-hidden="true" />
|
|
518
|
+
{!iconOnly && <span>Ask Leo</span>}
|
|
519
|
+
</Button>
|
|
520
|
+
</TooltipTrigger>
|
|
521
|
+
<TooltipContent side="bottom" className="flex flex-wrap items-center gap-1.5">
|
|
522
|
+
<span>Ask Leo</span>
|
|
523
|
+
<AskLeoShortcutKbds />
|
|
524
|
+
</TooltipContent>
|
|
525
|
+
</Tooltip>
|
|
526
|
+
)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Screen-reader data fallback for charts — shared with list-page dashboards. */
|
|
530
|
+
export function ChartDataTable({
|
|
531
|
+
caption,
|
|
532
|
+
headers,
|
|
533
|
+
rows,
|
|
534
|
+
}: {
|
|
535
|
+
caption: string
|
|
536
|
+
headers: string[]
|
|
537
|
+
rows: (string | number)[][]
|
|
538
|
+
}) {
|
|
539
|
+
return (
|
|
540
|
+
<table className="sr-only">
|
|
541
|
+
<caption>{caption}</caption>
|
|
542
|
+
<thead>
|
|
543
|
+
<tr>{headers.map((h) => <th key={h} scope="col">{h}</th>)}</tr>
|
|
544
|
+
</thead>
|
|
545
|
+
<tbody>
|
|
546
|
+
{rows.map((row, i) => (
|
|
547
|
+
<tr key={i}>{row.map((cell, j) => <td key={j}>{cell}</td>)}</tr>
|
|
548
|
+
))}
|
|
549
|
+
</tbody>
|
|
550
|
+
</table>
|
|
551
|
+
)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Keyboard-focusable chart region (arrow keys, Escape) + live announcement when a point is selected.
|
|
556
|
+
* Shared by the `/dashboard` gallery and **Data** view dashboards (Placements / Team / Compliance): same
|
|
557
|
+
* interaction model; visual differences come from `ChartCard` chrome and per-chart renderers (bar vs pie),
|
|
558
|
+
* not from a separate chart implementation.
|
|
559
|
+
*/
|
|
560
|
+
export function ChartFigure({
|
|
561
|
+
label,
|
|
562
|
+
summary,
|
|
563
|
+
dataLength,
|
|
564
|
+
leoInsight,
|
|
565
|
+
children,
|
|
566
|
+
}: {
|
|
567
|
+
label: string
|
|
568
|
+
summary: string
|
|
569
|
+
dataLength: number
|
|
570
|
+
/** Optional Ask-Leo insight context for chart bodies (same as `ChartCard`). */
|
|
571
|
+
leoInsight?: ChartLeoInsight | null
|
|
572
|
+
children: (activeIndex: number | null) => React.ReactNode
|
|
573
|
+
}) {
|
|
574
|
+
const [activeIndex, setActiveIndex] = React.useState<number | null>(null)
|
|
575
|
+
const ref = React.useRef<HTMLDivElement>(null)
|
|
576
|
+
const prevActiveIndexRef = React.useRef<number | null>(null)
|
|
577
|
+
|
|
578
|
+
React.useEffect(() => {
|
|
579
|
+
const prev = prevActiveIndexRef.current
|
|
580
|
+
prevActiveIndexRef.current = activeIndex
|
|
581
|
+
if (prev === null || activeIndex !== null) return
|
|
582
|
+
const wrapper = ref.current?.querySelector<HTMLElement>(".recharts-wrapper")
|
|
583
|
+
if (!wrapper) return
|
|
584
|
+
wrapper.dispatchEvent(
|
|
585
|
+
new MouseEvent("mouseleave", { bubbles: true, cancelable: true }),
|
|
586
|
+
)
|
|
587
|
+
}, [activeIndex])
|
|
588
|
+
|
|
589
|
+
const navigateKeys = React.useCallback(
|
|
590
|
+
(e: React.KeyboardEvent) => {
|
|
591
|
+
if (!dataLength) return
|
|
592
|
+
if (isEditableTarget(e.target)) return
|
|
593
|
+
switch (e.key) {
|
|
594
|
+
case "ArrowRight":
|
|
595
|
+
case "ArrowDown":
|
|
596
|
+
e.preventDefault()
|
|
597
|
+
e.stopPropagation()
|
|
598
|
+
setActiveIndex((i) => (i === null ? 0 : Math.min(i + 1, dataLength - 1)))
|
|
599
|
+
break
|
|
600
|
+
case "ArrowLeft":
|
|
601
|
+
case "ArrowUp":
|
|
602
|
+
e.preventDefault()
|
|
603
|
+
e.stopPropagation()
|
|
604
|
+
setActiveIndex((i) => (i === null ? dataLength - 1 : Math.max(i - 1, 0)))
|
|
605
|
+
break
|
|
606
|
+
case "Escape":
|
|
607
|
+
e.preventDefault()
|
|
608
|
+
e.stopPropagation()
|
|
609
|
+
setActiveIndex(null)
|
|
610
|
+
ref.current?.blur()
|
|
611
|
+
break
|
|
612
|
+
default:
|
|
613
|
+
break
|
|
614
|
+
}
|
|
615
|
+
},
|
|
616
|
+
[dataLength],
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
/** Clicks on Recharts SVG do not focus this node — focus so Arrow keys work without extra Tab stops. */
|
|
620
|
+
function handlePointerDownCapture(e: React.PointerEvent<HTMLDivElement>) {
|
|
621
|
+
if (!dataLength) return
|
|
622
|
+
const root = ref.current
|
|
623
|
+
if (!root?.contains(e.target as Node)) return
|
|
624
|
+
const el = e.target as HTMLElement | null
|
|
625
|
+
if (el?.closest?.("button, a, [role='tab'], [role='option'], input, select, textarea, [contenteditable='true']"))
|
|
626
|
+
return
|
|
627
|
+
queueMicrotask(() => root.focus())
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return (
|
|
631
|
+
<div
|
|
632
|
+
ref={ref}
|
|
633
|
+
tabIndex={0}
|
|
634
|
+
role="application"
|
|
635
|
+
aria-label={`${label}. ${summary}. Click the chart or press Tab to focus, then use arrow keys to explore data points. Press Escape to clear selection.`}
|
|
636
|
+
onKeyDownCapture={(e) => {
|
|
637
|
+
if (!ref.current?.contains(e.target as Node)) return
|
|
638
|
+
if (isEditableTarget(e.target)) return
|
|
639
|
+
if (
|
|
640
|
+
e.key === "ArrowRight" ||
|
|
641
|
+
e.key === "ArrowDown" ||
|
|
642
|
+
e.key === "ArrowLeft" ||
|
|
643
|
+
e.key === "ArrowUp" ||
|
|
644
|
+
e.key === "Escape"
|
|
645
|
+
) {
|
|
646
|
+
navigateKeys(e)
|
|
647
|
+
}
|
|
648
|
+
}}
|
|
649
|
+
onPointerDownCapture={handlePointerDownCapture}
|
|
650
|
+
className="flex min-h-0 flex-1 flex-col outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded-sm"
|
|
651
|
+
>
|
|
652
|
+
<ChartLeoInsightOverlay leoInsight={leoInsight} chartTitle={label}>
|
|
653
|
+
{children(activeIndex)}
|
|
654
|
+
</ChartLeoInsightOverlay>
|
|
655
|
+
{activeIndex !== null && (
|
|
656
|
+
<div role="status" aria-live="polite" className="sr-only">
|
|
657
|
+
Data point {activeIndex + 1} of {dataLength} selected
|
|
658
|
+
</div>
|
|
659
|
+
)}
|
|
660
|
+
</div>
|
|
661
|
+
)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function ChartCardHeader({
|
|
665
|
+
title,
|
|
666
|
+
description,
|
|
667
|
+
variant,
|
|
668
|
+
filterOptions,
|
|
669
|
+
filter,
|
|
670
|
+
onFilter,
|
|
671
|
+
}: {
|
|
672
|
+
title: string
|
|
673
|
+
description: string
|
|
674
|
+
variant: ChartCardVariant
|
|
675
|
+
filterOptions?: { value: string; label: string }[]
|
|
676
|
+
filter?: string
|
|
677
|
+
onFilter?: (v: string) => void
|
|
678
|
+
}) {
|
|
679
|
+
const isSelector = variant === "selector" && Array.isArray(filterOptions) && filterOptions.length > 0
|
|
680
|
+
return (
|
|
681
|
+
<CardHeader className="shrink-0 pb-2">
|
|
682
|
+
<div className="flex items-start gap-2">
|
|
683
|
+
<div className="flex-1 min-w-0">
|
|
684
|
+
<CardTitle className="text-sm font-semibold leading-tight">{title}</CardTitle>
|
|
685
|
+
<CardDescription className="text-xs mt-0.5">{description}</CardDescription>
|
|
686
|
+
</div>
|
|
687
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
688
|
+
{/* Reveal on card hover/focus — pointer-events guarded so the hidden button is not reachable */}
|
|
689
|
+
<span className="pointer-events-none opacity-0 transition-opacity duration-150 group-hover/card:pointer-events-auto group-hover/card:opacity-100 group-focus-within/card:pointer-events-auto group-focus-within/card:opacity-100 inline-flex">
|
|
690
|
+
<AskLeoButton iconOnly={isSelector} />
|
|
691
|
+
</span>
|
|
692
|
+
{isSelector && filterOptions && onFilter && (
|
|
693
|
+
<Select value={filter || filterOptions[0]?.value} onValueChange={(v) => onFilter(v)}>
|
|
694
|
+
<SelectTrigger
|
|
695
|
+
className="h-8 w-auto min-w-[9rem] shrink-0 text-sm"
|
|
696
|
+
aria-label="Filter chart data"
|
|
697
|
+
>
|
|
698
|
+
<SelectValue />
|
|
699
|
+
</SelectTrigger>
|
|
700
|
+
<SelectContent align="end" sideOffset={4}>
|
|
701
|
+
{filterOptions.map((opt) => (
|
|
702
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
703
|
+
{opt.label}
|
|
704
|
+
</SelectItem>
|
|
705
|
+
))}
|
|
706
|
+
</SelectContent>
|
|
707
|
+
</Select>
|
|
708
|
+
)}
|
|
709
|
+
</div>
|
|
710
|
+
</div>
|
|
711
|
+
</CardHeader>
|
|
712
|
+
)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
type MiniMetric = { label: string; value: string; trend?: "up" | "down" | "neutral" }
|
|
716
|
+
|
|
717
|
+
export function ChartCard({
|
|
718
|
+
title,
|
|
719
|
+
description,
|
|
720
|
+
children,
|
|
721
|
+
className = "",
|
|
722
|
+
variant = "normal",
|
|
723
|
+
trendContent,
|
|
724
|
+
filterOptions,
|
|
725
|
+
defaultFilter,
|
|
726
|
+
onFilterChange,
|
|
727
|
+
miniMetrics,
|
|
728
|
+
tabOptions,
|
|
729
|
+
leoInsight,
|
|
730
|
+
}: {
|
|
731
|
+
title: string
|
|
732
|
+
description: string
|
|
733
|
+
children: React.ReactNode | ((filter: string) => React.ReactNode)
|
|
734
|
+
className?: string
|
|
735
|
+
variant?: ChartCardVariant
|
|
736
|
+
/** "tabs" / "metrics-tabs" variant: content shown in the "Trend" tab */
|
|
737
|
+
trendContent?: React.ReactNode
|
|
738
|
+
/** "selector" variant: options for the filter dropdown */
|
|
739
|
+
filterOptions?: { value: string; label: string }[]
|
|
740
|
+
defaultFilter?: string
|
|
741
|
+
onFilterChange?: (value: string) => void
|
|
742
|
+
/** "metrics-tabs" variant: compact KPI strip shown above the chart */
|
|
743
|
+
miniMetrics?: MiniMetric[]
|
|
744
|
+
/** "tabs" variant: override the default Chart/Trend tabs with custom options.
|
|
745
|
+
* The selected value is passed to the children function. */
|
|
746
|
+
tabOptions?: { value: string; label: string }[]
|
|
747
|
+
/**
|
|
748
|
+
* Smart Leo summary: opens a popover + Ask Leo CTA.
|
|
749
|
+
* With `anchor`, mount `ChartLeoPlotInsightOverlay` beside `ChartContainer` for on-plot guide + marker; otherwise a corner Insight control is shown.
|
|
750
|
+
*/
|
|
751
|
+
leoInsight?: ChartLeoInsight | null
|
|
752
|
+
}) {
|
|
753
|
+
const [filter, setFilter] = React.useState(
|
|
754
|
+
() => defaultFilter || filterOptions?.[0]?.value || miniMetrics?.[0]?.label || tabOptions?.[0]?.value || ""
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
// Sync when defaultFilter or first miniMetric changes (React may reuse across ternary branches)
|
|
758
|
+
React.useEffect(() => {
|
|
759
|
+
const next = defaultFilter || filterOptions?.[0]?.value || miniMetrics?.[0]?.label
|
|
760
|
+
if (next) setFilter(next)
|
|
761
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
762
|
+
}, [defaultFilter, miniMetrics?.[0]?.label])
|
|
763
|
+
|
|
764
|
+
const handleFilter = (v: string) => { setFilter(v); onFilterChange?.(v) }
|
|
765
|
+
|
|
766
|
+
const resolvedChildren =
|
|
767
|
+
typeof children === "function" ? children(filter) : children
|
|
768
|
+
|
|
769
|
+
/* ── Default Chart / Trend tabs (no custom tabOptions) ───────────────────── */
|
|
770
|
+
const defaultTabsBlock = (
|
|
771
|
+
<Tabs defaultValue="trend" className="flex flex-col flex-1 min-h-0">
|
|
772
|
+
<div className="px-6 pb-1">
|
|
773
|
+
<TabsList variant="line">
|
|
774
|
+
<TabsTrigger value="chart" className={chartCardTabTriggerClass}>
|
|
775
|
+
<i className="fa-light fa-chart-mixed text-sm" aria-hidden="true" />
|
|
776
|
+
Chart
|
|
777
|
+
</TabsTrigger>
|
|
778
|
+
<TabsTrigger value="trend" className={chartCardTabTriggerClass}>
|
|
779
|
+
<i className="fa-light fa-chart-line text-sm" aria-hidden="true" />
|
|
780
|
+
Trend
|
|
781
|
+
</TabsTrigger>
|
|
782
|
+
</TabsList>
|
|
783
|
+
</div>
|
|
784
|
+
<TabsContent value="chart" className="flex-1 flex flex-col min-h-0 m-0">
|
|
785
|
+
<CardContent className="flex-1 flex flex-col min-h-0 pb-4">
|
|
786
|
+
<ChartLeoInsightOverlay leoInsight={leoInsight} chartTitle={title}>
|
|
787
|
+
{resolvedChildren}
|
|
788
|
+
</ChartLeoInsightOverlay>
|
|
789
|
+
</CardContent>
|
|
790
|
+
</TabsContent>
|
|
791
|
+
<TabsContent value="trend" className="flex-1 flex flex-col min-h-0 m-0">
|
|
792
|
+
<CardContent className="flex-1 flex flex-col min-h-0 pb-4">
|
|
793
|
+
<ChartLeoInsightOverlay leoInsight={leoInsight} chartTitle={title}>
|
|
794
|
+
{trendContent ?? resolvedChildren}
|
|
795
|
+
</ChartLeoInsightOverlay>
|
|
796
|
+
</CardContent>
|
|
797
|
+
</TabsContent>
|
|
798
|
+
</Tabs>
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
if (variant === "tabs") {
|
|
802
|
+
/* Custom tab labels (e.g. period picker for key metrics) */
|
|
803
|
+
if (tabOptions && tabOptions.length > 0) {
|
|
804
|
+
const selectedTab = filter || tabOptions[0].value
|
|
805
|
+
return (
|
|
806
|
+
<Card className={`flex flex-col h-full ${className}`} role="figure" aria-label={title}>
|
|
807
|
+
<ChartCardHeader title={title} description={description} variant="normal" />
|
|
808
|
+
<Tabs defaultValue={tabOptions[0].value} value={selectedTab} onValueChange={handleFilter} className="flex flex-col flex-1 min-h-0">
|
|
809
|
+
<div className="px-6 pb-1">
|
|
810
|
+
<TabsList variant="line">
|
|
811
|
+
{tabOptions.map((tab) => (
|
|
812
|
+
<TabsTrigger key={tab.value} value={tab.value} className={chartCardTabTriggerClass}>
|
|
813
|
+
{tab.label}
|
|
814
|
+
</TabsTrigger>
|
|
815
|
+
))}
|
|
816
|
+
</TabsList>
|
|
817
|
+
</div>
|
|
818
|
+
{tabOptions.map((tab) => (
|
|
819
|
+
<TabsContent key={tab.value} value={tab.value} className="flex-1 flex flex-col min-h-0 m-0">
|
|
820
|
+
<CardContent className="flex-1 flex flex-col min-h-[200px] pb-4">
|
|
821
|
+
<ChartLeoInsightOverlay leoInsight={leoInsight} chartTitle={title}>
|
|
822
|
+
{typeof children === "function" ? children(tab.value) : children}
|
|
823
|
+
</ChartLeoInsightOverlay>
|
|
824
|
+
</CardContent>
|
|
825
|
+
</TabsContent>
|
|
826
|
+
))}
|
|
827
|
+
</Tabs>
|
|
828
|
+
</Card>
|
|
829
|
+
)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
return (
|
|
833
|
+
<Card className={`flex flex-col h-full ${className}`} role="figure" aria-label={title}>
|
|
834
|
+
<ChartCardHeader title={title} description={description} variant="normal" />
|
|
835
|
+
{defaultTabsBlock}
|
|
836
|
+
</Card>
|
|
837
|
+
)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (variant === "metrics-tabs") {
|
|
841
|
+
const metrics = miniMetrics && miniMetrics.length > 0 ? miniMetrics : null
|
|
842
|
+
const selectedMetric = filter || metrics?.[0]?.label || ""
|
|
843
|
+
|
|
844
|
+
return (
|
|
845
|
+
<Card className={`flex flex-col h-full ${className}`} role="figure" aria-label={title}>
|
|
846
|
+
<ChartCardHeader title={title} description={description} variant="normal" />
|
|
847
|
+
|
|
848
|
+
{metrics ? (
|
|
849
|
+
/* Metrics ARE the tabs — each metric cell is a clickable TabsTrigger */
|
|
850
|
+
<Tabs value={selectedMetric} onValueChange={handleFilter} className="flex flex-col flex-1 min-h-0">
|
|
851
|
+
<div className="shrink-0 px-2">
|
|
852
|
+
<TabsList
|
|
853
|
+
variant="line"
|
|
854
|
+
className="h-auto w-full gap-0 rounded-none p-0 justify-start !items-end border-b border-border"
|
|
855
|
+
>
|
|
856
|
+
{metrics.map((m) => {
|
|
857
|
+
const isUp = m.trend === "up"
|
|
858
|
+
const isDown = m.trend === "down"
|
|
859
|
+
return (
|
|
860
|
+
<TabsTrigger
|
|
861
|
+
key={m.label}
|
|
862
|
+
value={m.label}
|
|
863
|
+
className="h-auto flex-col items-start gap-1 px-3 pt-2 pb-3 rounded-none min-w-0 flex-none -mb-px border-b-2 border-transparent data-active:border-b-foreground after:![opacity:0] opacity-60 data-active:opacity-100"
|
|
864
|
+
>
|
|
865
|
+
<span className="text-sm font-normal text-muted-foreground leading-none">{m.label}</span>
|
|
866
|
+
<div className="flex items-baseline gap-1.5">
|
|
867
|
+
<span className="text-xl font-bold tabular-nums leading-none text-foreground">{m.value}</span>
|
|
868
|
+
{isUp && <i className="fa-light fa-arrow-trend-up text-xs text-emerald-600" aria-hidden="true" />}
|
|
869
|
+
{isDown && <i className="fa-light fa-arrow-trend-down text-xs text-destructive" aria-hidden="true" />}
|
|
870
|
+
</div>
|
|
871
|
+
</TabsTrigger>
|
|
872
|
+
)
|
|
873
|
+
})}
|
|
874
|
+
</TabsList>
|
|
875
|
+
</div>
|
|
876
|
+
{/* All metric tabs show the same chart — tab selection is a context indicator */}
|
|
877
|
+
{metrics.map((m) => (
|
|
878
|
+
<TabsContent key={m.label} value={m.label} className="flex-1 flex flex-col min-h-0 m-0">
|
|
879
|
+
<CardContent className="flex-1 flex flex-col min-h-0 pb-4">
|
|
880
|
+
<ChartLeoInsightOverlay leoInsight={leoInsight} chartTitle={title}>
|
|
881
|
+
{resolvedChildren}
|
|
882
|
+
</ChartLeoInsightOverlay>
|
|
883
|
+
</CardContent>
|
|
884
|
+
</TabsContent>
|
|
885
|
+
))}
|
|
886
|
+
</Tabs>
|
|
887
|
+
) : (
|
|
888
|
+
defaultTabsBlock
|
|
889
|
+
)}
|
|
890
|
+
</Card>
|
|
891
|
+
)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/* ── kpi-chart: prominent metric on top, chart below ─────────────────────── */
|
|
895
|
+
if (variant === "kpi-chart") {
|
|
896
|
+
const kpi = miniMetrics?.[0]
|
|
897
|
+
const isUp = kpi?.trend === "up"
|
|
898
|
+
const isDown = kpi?.trend === "down"
|
|
899
|
+
|
|
900
|
+
return (
|
|
901
|
+
<Card className={`flex flex-col h-full ${className}`} role="figure" aria-label={title}>
|
|
902
|
+
<ChartCardHeader title={title} description={description} variant="normal" />
|
|
903
|
+
|
|
904
|
+
{kpi && (
|
|
905
|
+
<div className="px-6 pb-2 shrink-0">
|
|
906
|
+
<div className="flex items-baseline gap-2.5">
|
|
907
|
+
<span className="text-4xl font-bold tabular-nums tracking-tight text-foreground">
|
|
908
|
+
{kpi.value}
|
|
909
|
+
</span>
|
|
910
|
+
{isUp && (
|
|
911
|
+
<span className="flex items-center gap-1 text-sm font-medium text-emerald-600">
|
|
912
|
+
<i className="fa-light fa-arrow-trend-up" aria-hidden="true" />
|
|
913
|
+
<span className="sr-only">trending up</span>
|
|
914
|
+
</span>
|
|
915
|
+
)}
|
|
916
|
+
{isDown && (
|
|
917
|
+
<span className="flex items-center gap-1 text-sm font-medium text-destructive">
|
|
918
|
+
<i className="fa-light fa-arrow-trend-down" aria-hidden="true" />
|
|
919
|
+
<span className="sr-only">trending down</span>
|
|
920
|
+
</span>
|
|
921
|
+
)}
|
|
922
|
+
</div>
|
|
923
|
+
<p className="text-xs text-muted-foreground mt-0.5">{kpi.label}</p>
|
|
924
|
+
</div>
|
|
925
|
+
)}
|
|
926
|
+
|
|
927
|
+
<CardContent className="flex-1 flex flex-col min-h-0 pb-4 pt-0">
|
|
928
|
+
<ChartLeoInsightOverlay leoInsight={leoInsight} chartTitle={title}>
|
|
929
|
+
{resolvedChildren}
|
|
930
|
+
</ChartLeoInsightOverlay>
|
|
931
|
+
</CardContent>
|
|
932
|
+
</Card>
|
|
933
|
+
)
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
return (
|
|
937
|
+
<Card className={`flex flex-col h-full ${className}`} role="figure" aria-label={title}>
|
|
938
|
+
<ChartCardHeader
|
|
939
|
+
title={title}
|
|
940
|
+
description={description}
|
|
941
|
+
variant={variant}
|
|
942
|
+
filterOptions={filterOptions}
|
|
943
|
+
filter={filter}
|
|
944
|
+
onFilter={handleFilter}
|
|
945
|
+
/>
|
|
946
|
+
<CardContent className="flex-1 flex flex-col min-h-0 pb-4">
|
|
947
|
+
<ChartLeoInsightOverlay leoInsight={leoInsight} chartTitle={title}>
|
|
948
|
+
{resolvedChildren}
|
|
949
|
+
</ChartLeoInsightOverlay>
|
|
950
|
+
</CardContent>
|
|
951
|
+
</Card>
|
|
952
|
+
)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/* ════════════════════════════════════════════════════════════════════════════
|
|
956
|
+
DATA & CHART COMPONENTS
|
|
957
|
+
════════════════════════════════════════════════════════════════════════════ */
|
|
958
|
+
|
|
959
|
+
/* ── Area ─────────────────────────────────────────────────────────────────── */
|
|
960
|
+
const areaCfg: ChartConfig = {
|
|
961
|
+
placements: { label: "Placements", color: BRAND },
|
|
962
|
+
applications: { label: "Applications", color: CHART_2 },
|
|
963
|
+
reviews: { label: "Reviews", color: CHART_4 },
|
|
964
|
+
}
|
|
965
|
+
const areaData = [
|
|
966
|
+
{ month: "Aug", placements: 42, applications: 78, reviews: 31 },
|
|
967
|
+
{ month: "Sep", placements: 58, applications: 91, reviews: 44 },
|
|
968
|
+
{ month: "Oct", placements: 53, applications: 85, reviews: 39 },
|
|
969
|
+
{ month: "Nov", placements: 67, applications: 102, reviews: 52 },
|
|
970
|
+
{ month: "Dec", placements: 49, applications: 76, reviews: 37 },
|
|
971
|
+
{ month: "Jan", placements: 74, applications: 118, reviews: 60 },
|
|
972
|
+
{ month: "Feb", placements: 81, applications: 124, reviews: 68 },
|
|
973
|
+
{ month: "Mar", placements: 89, applications: 137, reviews: 72 },
|
|
974
|
+
]
|
|
975
|
+
|
|
976
|
+
function AreaChartContent() {
|
|
977
|
+
return (
|
|
978
|
+
<ChartFigure label="Placement Trends" summary="Multi-line area chart showing placements, applications and reviews from Aug to Mar" dataLength={areaData.length}>
|
|
979
|
+
{(activeIndex) => (
|
|
980
|
+
<>
|
|
981
|
+
<div className="relative w-full min-h-[180px] flex-1">
|
|
982
|
+
<ChartContainer config={areaCfg} className="h-full min-h-[180px] w-full flex-1">
|
|
983
|
+
<AreaChart data={areaData} margin={{ left: -8, right: 4, top: 4, bottom: 0 }}>
|
|
984
|
+
<defs>
|
|
985
|
+
<linearGradient id="gPlace" x1="0" y1="0" x2="0" y2="1">
|
|
986
|
+
<stop offset="5%" stopColor={BRAND} stopOpacity={0.35} />
|
|
987
|
+
<stop offset="95%" stopColor={BRAND} stopOpacity={0.02} />
|
|
988
|
+
</linearGradient>
|
|
989
|
+
<linearGradient id="gApps" x1="0" y1="0" x2="0" y2="1">
|
|
990
|
+
<stop offset="5%" stopColor={CHART_2} stopOpacity={0.3} />
|
|
991
|
+
<stop offset="95%" stopColor={CHART_2} stopOpacity={0.02} />
|
|
992
|
+
</linearGradient>
|
|
993
|
+
<linearGradient id="gRev" x1="0" y1="0" x2="0" y2="1">
|
|
994
|
+
<stop offset="5%" stopColor={CHART_4} stopOpacity={0.3} />
|
|
995
|
+
<stop offset="95%" stopColor={CHART_4} stopOpacity={0.02} />
|
|
996
|
+
</linearGradient>
|
|
997
|
+
</defs>
|
|
998
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
999
|
+
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
|
1000
|
+
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={36} />
|
|
1001
|
+
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
1002
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1003
|
+
<Area key="placements" type="monotone" dataKey="placements" stroke={BRAND} fill="url(#gPlace)" strokeWidth={2} dot={false} activeDot={{ r: 5, stroke: "var(--ring)", strokeWidth: 2 }} />
|
|
1004
|
+
<Area key="applications" type="monotone" dataKey="applications" stroke={CHART_2} fill="url(#gApps)" strokeWidth={2} dot={false} activeDot={{ r: 5, stroke: "var(--ring)", strokeWidth: 2 }} />
|
|
1005
|
+
<Area key="reviews" type="monotone" dataKey="reviews" stroke={CHART_4} fill="url(#gRev)" strokeWidth={2} dot={false} activeDot={{ r: 5, stroke: "var(--ring)", strokeWidth: 2 }} />
|
|
1006
|
+
</AreaChart>
|
|
1007
|
+
</ChartContainer>
|
|
1008
|
+
<ChartLeoPlotInsightOverlay data={areaData} xDataKey="month" />
|
|
1009
|
+
</div>
|
|
1010
|
+
<ChartDataTable caption="Placement Trends" headers={["Month", "Placements", "Applications", "Reviews"]} rows={areaData.map(d => [d.month, d.placements, d.applications, d.reviews])} />
|
|
1011
|
+
</>
|
|
1012
|
+
)}
|
|
1013
|
+
</ChartFigure>
|
|
1014
|
+
)
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function AreaLineTrendContent() {
|
|
1018
|
+
return (
|
|
1019
|
+
<ChartFigure label="Placement Trends" summary="Line chart showing placement trends Aug to Mar" dataLength={areaData.length}>
|
|
1020
|
+
{(activeIndex) => (
|
|
1021
|
+
<>
|
|
1022
|
+
<div className="relative w-full min-h-[180px] flex-1">
|
|
1023
|
+
<ChartContainer config={areaCfg} className="h-full min-h-[180px] w-full flex-1">
|
|
1024
|
+
<LineChart data={areaData} margin={{ left: -8, right: 4, top: 4, bottom: 0 }}>
|
|
1025
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1026
|
+
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
|
1027
|
+
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={36} />
|
|
1028
|
+
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
1029
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1030
|
+
<Line type="monotone" dataKey="placements" stroke={BRAND} strokeWidth={2} strokeDasharray={chartLineStrokeDash(0)} dot={(props: LineDotRenderProps) => props.index === activeIndex ? <circle key={props.key} cx={props.cx} cy={props.cy} r={5} fill={props.stroke} stroke="var(--ring)" strokeWidth={2} /> : <circle key={props.key} cx={props.cx} cy={props.cy} r={2} fill={props.stroke} />} />
|
|
1031
|
+
<Line type="monotone" dataKey="applications" stroke={CHART_2} strokeWidth={2} strokeDasharray={chartLineStrokeDash(1)} dot={(props: LineDotRenderProps) => props.index === activeIndex ? <circle key={props.key} cx={props.cx} cy={props.cy} r={5} fill={props.stroke} stroke="var(--ring)" strokeWidth={2} /> : <circle key={props.key} cx={props.cx} cy={props.cy} r={2} fill={props.stroke} />} />
|
|
1032
|
+
<Line type="monotone" dataKey="reviews" stroke={CHART_4} strokeWidth={2} strokeDasharray={chartLineStrokeDash(2)} dot={(props: LineDotRenderProps) => props.index === activeIndex ? <circle key={props.key} cx={props.cx} cy={props.cy} r={5} fill={props.stroke} stroke="var(--ring)" strokeWidth={2} /> : <circle key={props.key} cx={props.cx} cy={props.cy} r={2} fill={props.stroke} />} />
|
|
1033
|
+
</LineChart>
|
|
1034
|
+
</ChartContainer>
|
|
1035
|
+
<ChartLeoPlotInsightOverlay data={areaData} xDataKey="month" />
|
|
1036
|
+
</div>
|
|
1037
|
+
<ChartDataTable caption="Placement Trends" headers={["Month", "Placements", "Applications", "Reviews"]} rows={areaData.map(d => [d.month, d.placements, d.applications, d.reviews])} />
|
|
1038
|
+
</>
|
|
1039
|
+
)}
|
|
1040
|
+
</ChartFigure>
|
|
1041
|
+
)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/* Selector variant — filter data by period */
|
|
1045
|
+
const areaDataByPeriod: Record<string, typeof areaData> = {
|
|
1046
|
+
"7d": areaData.slice(-2),
|
|
1047
|
+
"30d": areaData.slice(-4),
|
|
1048
|
+
"90d": areaData.slice(-6),
|
|
1049
|
+
"1y": areaData,
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function AreaSelectorContent({ filter }: { filter: string }) {
|
|
1053
|
+
const data = areaDataByPeriod[filter] ?? areaData
|
|
1054
|
+
return (
|
|
1055
|
+
<ChartFigure label="Placement Trends" summary={`Area chart for ${filter} period`} dataLength={data.length}>
|
|
1056
|
+
{(activeIndex) => (
|
|
1057
|
+
<>
|
|
1058
|
+
<div className="relative w-full min-h-[180px] flex-1">
|
|
1059
|
+
<ChartContainer config={areaCfg} className="h-full min-h-[180px] w-full flex-1">
|
|
1060
|
+
<AreaChart data={data} margin={{ left: -8, right: 4, top: 4, bottom: 0 }}>
|
|
1061
|
+
<defs>
|
|
1062
|
+
<linearGradient id="gPlace2" x1="0" y1="0" x2="0" y2="1">
|
|
1063
|
+
<stop offset="5%" stopColor={BRAND} stopOpacity={0.35} />
|
|
1064
|
+
<stop offset="95%" stopColor={BRAND} stopOpacity={0.02} />
|
|
1065
|
+
</linearGradient>
|
|
1066
|
+
</defs>
|
|
1067
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1068
|
+
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
|
1069
|
+
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={36} />
|
|
1070
|
+
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
1071
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1072
|
+
<Area key="placements" type="monotone" dataKey="placements" stroke={BRAND} fill="url(#gPlace2)" strokeWidth={2} dot={false} activeDot={{ r: 5, stroke: "var(--ring)", strokeWidth: 2 }} />
|
|
1073
|
+
<Area key="applications" type="monotone" dataKey="applications" stroke={CHART_2} fill="none" strokeWidth={2} dot={false} activeDot={{ r: 5, stroke: "var(--ring)", strokeWidth: 2 }} />
|
|
1074
|
+
<Area key="reviews" type="monotone" dataKey="reviews" stroke={CHART_4} fill="none" strokeWidth={2} dot={false} activeDot={{ r: 5, stroke: "var(--ring)", strokeWidth: 2 }} />
|
|
1075
|
+
</AreaChart>
|
|
1076
|
+
</ChartContainer>
|
|
1077
|
+
<ChartLeoPlotInsightOverlay data={data} xDataKey="month" />
|
|
1078
|
+
</div>
|
|
1079
|
+
<ChartDataTable caption="Placement Trends" headers={["Month", "Placements", "Applications", "Reviews"]} rows={data.map(d => [d.month, d.placements, d.applications, d.reviews])} />
|
|
1080
|
+
</>
|
|
1081
|
+
)}
|
|
1082
|
+
</ChartFigure>
|
|
1083
|
+
)
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/* ── Donut ─────────────────────────────────────────────────────────────────── */
|
|
1087
|
+
const donutCfg: ChartConfig = {
|
|
1088
|
+
confirmed: { label: "Confirmed", color: SUCCESS },
|
|
1089
|
+
pending: { label: "Pending", color: WARNING },
|
|
1090
|
+
rejected: { label: "Rejected", color: DESTRUCTIVE },
|
|
1091
|
+
review: { label: "In Review", color: CHART_1 },
|
|
1092
|
+
}
|
|
1093
|
+
const donutDataAll = [
|
|
1094
|
+
{ name: "confirmed", value: 58, fill: SUCCESS },
|
|
1095
|
+
{ name: "pending", value: 24, fill: WARNING },
|
|
1096
|
+
{ name: "rejected", value: 9, fill: DESTRUCTIVE },
|
|
1097
|
+
{ name: "review", value: 9, fill: CHART_1 },
|
|
1098
|
+
]
|
|
1099
|
+
|
|
1100
|
+
function DonutChartContent({ data = donutDataAll }: { data?: typeof donutDataAll }) {
|
|
1101
|
+
const total = data.reduce((s, d) => s + d.value, 0)
|
|
1102
|
+
return (
|
|
1103
|
+
<ChartFigure label="Placement Status" summary="Donut chart showing confirmed, pending, rejected and in-review placement distribution" dataLength={data.length} leoInsight={CHART_GALLERY_LEO_DONUT}>
|
|
1104
|
+
{(activeIndex) => (
|
|
1105
|
+
<>
|
|
1106
|
+
<ChartContainer config={donutCfg} className="flex-1 min-h-[140px] w-full">
|
|
1107
|
+
<PieChart>
|
|
1108
|
+
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent hideLabel />} />
|
|
1109
|
+
<Pie data={data} dataKey="value" nameKey="name"
|
|
1110
|
+
innerRadius="50%" outerRadius="78%" strokeWidth={2} stroke="var(--card)"
|
|
1111
|
+
activeIndex={activeIndex ?? undefined} activeShape={{ strokeWidth: 3, stroke: "var(--ring)" }}>
|
|
1112
|
+
{data.map((d) => <Cell key={d.name} fill={d.fill} />)}
|
|
1113
|
+
</Pie>
|
|
1114
|
+
</PieChart>
|
|
1115
|
+
</ChartContainer>
|
|
1116
|
+
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs mt-2 shrink-0">
|
|
1117
|
+
{data.map((d) => (
|
|
1118
|
+
<div key={d.name} className="flex items-center gap-1.5">
|
|
1119
|
+
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ background: d.fill }} />
|
|
1120
|
+
<span className="text-muted-foreground">{donutCfg[d.name]?.label}</span>
|
|
1121
|
+
<span className="ml-auto font-medium tabular-nums">
|
|
1122
|
+
{Math.round(d.value / total * 100)}%
|
|
1123
|
+
</span>
|
|
1124
|
+
</div>
|
|
1125
|
+
))}
|
|
1126
|
+
</div>
|
|
1127
|
+
<ChartDataTable
|
|
1128
|
+
caption="Placement Status"
|
|
1129
|
+
headers={["Status", "Count"]}
|
|
1130
|
+
rows={data.map(d => {
|
|
1131
|
+
const raw = donutCfg[d.name]?.label ?? d.name
|
|
1132
|
+
const label =
|
|
1133
|
+
typeof raw === "string" || typeof raw === "number" ? String(raw) : String(d.name)
|
|
1134
|
+
return [label, d.value] as [string, number]
|
|
1135
|
+
})}
|
|
1136
|
+
/>
|
|
1137
|
+
</>
|
|
1138
|
+
)}
|
|
1139
|
+
</ChartFigure>
|
|
1140
|
+
)
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/* Donut trend — bar chart version */
|
|
1144
|
+
function DonutBarTrendContent() {
|
|
1145
|
+
const cfg: ChartConfig = {
|
|
1146
|
+
confirmed: { label: "Confirmed", color: SUCCESS },
|
|
1147
|
+
pending: { label: "Pending", color: WARNING },
|
|
1148
|
+
rejected: { label: "Rejected", color: DESTRUCTIVE },
|
|
1149
|
+
}
|
|
1150
|
+
const data = [
|
|
1151
|
+
{ month: "Jan", confirmed: 52, pending: 20, rejected: 7 },
|
|
1152
|
+
{ month: "Feb", confirmed: 60, pending: 18, rejected: 6 },
|
|
1153
|
+
{ month: "Mar", confirmed: 68, pending: 24, rejected: 9 },
|
|
1154
|
+
]
|
|
1155
|
+
return (
|
|
1156
|
+
<ChartContainer config={cfg} className="flex-1 min-h-[180px] w-full">
|
|
1157
|
+
<BarChart data={data} margin={{ left: -8, right: 4, top: 4, bottom: 0 }}>
|
|
1158
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1159
|
+
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
|
1160
|
+
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={28} />
|
|
1161
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
1162
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1163
|
+
<Bar dataKey="confirmed" fill={SUCCESS} stackId="a" />
|
|
1164
|
+
<Bar dataKey="pending" fill={WARNING} stackId="a" />
|
|
1165
|
+
<Bar dataKey="rejected" fill={DESTRUCTIVE} stackId="a" radius={[4, 4, 0, 0]} />
|
|
1166
|
+
</BarChart>
|
|
1167
|
+
</ChartContainer>
|
|
1168
|
+
)
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/* Donut — selector by program */
|
|
1172
|
+
const donutByProgram: Record<string, typeof donutDataAll> = {
|
|
1173
|
+
all: donutDataAll,
|
|
1174
|
+
nursing: [{ name: "confirmed", value: 72, fill: SUCCESS }, { name: "pending", value: 18, fill: WARNING }, { name: "rejected", value: 5, fill: DESTRUCTIVE }, { name: "review", value: 5, fill: CHART_1 }],
|
|
1175
|
+
pt: [{ name: "confirmed", value: 55, fill: SUCCESS }, { name: "pending", value: 28, fill: WARNING }, { name: "rejected", value: 10, fill: DESTRUCTIVE }, { name: "review", value: 7, fill: CHART_1 }],
|
|
1176
|
+
ot: [{ name: "confirmed", value: 48, fill: SUCCESS }, { name: "pending", value: 30, fill: WARNING }, { name: "rejected", value: 14, fill: DESTRUCTIVE }, { name: "review", value: 8, fill: CHART_1 }],
|
|
1177
|
+
pharmacy: [{ name: "confirmed", value: 40, fill: SUCCESS }, { name: "pending", value: 35, fill: WARNING }, { name: "rejected", value: 15, fill: DESTRUCTIVE }, { name: "review", value: 10, fill: CHART_1 }],
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/* ── Grouped Bar ─────────────────────────────────────────────────────────── */
|
|
1181
|
+
const barCfg: ChartConfig = {
|
|
1182
|
+
new: { label: "New", color: BRAND },
|
|
1183
|
+
returned: { label: "Returned", color: CHART_2 },
|
|
1184
|
+
}
|
|
1185
|
+
const barData = [
|
|
1186
|
+
{ program: "Nursing", new: 34, returned: 22 },
|
|
1187
|
+
{ program: "PT", new: 28, returned: 18 },
|
|
1188
|
+
{ program: "OT", new: 21, returned: 14 },
|
|
1189
|
+
{ program: "SW", new: 19, returned: 11 },
|
|
1190
|
+
{ program: "Pharm", new: 15, returned: 9 },
|
|
1191
|
+
{ program: "Rad", new: 12, returned: 7 },
|
|
1192
|
+
]
|
|
1193
|
+
|
|
1194
|
+
function GroupedBarContent() {
|
|
1195
|
+
return (
|
|
1196
|
+
<ChartFigure label="Applications by Program" summary="Grouped bar chart showing new and returned applications across 6 programs" dataLength={barData.length} leoInsight={CHART_GALLERY_LEO_APPLICATIONS}>
|
|
1197
|
+
{(activeIndex) => (
|
|
1198
|
+
<>
|
|
1199
|
+
<ChartContainer config={barCfg} className="flex-1 min-h-[180px] w-full">
|
|
1200
|
+
<BarChart data={barData} barGap={4} margin={{ left: -8, right: 4, top: 4, bottom: 0 }}>
|
|
1201
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1202
|
+
<XAxis dataKey="program" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
|
1203
|
+
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={28} />
|
|
1204
|
+
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
1205
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1206
|
+
<Bar dataKey="new" fill={BRAND} radius={[4, 4, 0, 0]} activeBar={{ stroke: "var(--ring)", strokeWidth: 2, fillOpacity: 1 }} activeIndex={activeIndex ?? undefined} />
|
|
1207
|
+
<Bar dataKey="returned" fill={CHART_2} radius={[4, 4, 0, 0]} activeBar={{ stroke: "var(--ring)", strokeWidth: 2, fillOpacity: 1 }} activeIndex={activeIndex ?? undefined} />
|
|
1208
|
+
</BarChart>
|
|
1209
|
+
</ChartContainer>
|
|
1210
|
+
<ChartDataTable caption="Applications by Program" headers={["Program", "New", "Returned"]} rows={barData.map(d => [d.program, d.new, d.returned])} />
|
|
1211
|
+
</>
|
|
1212
|
+
)}
|
|
1213
|
+
</ChartFigure>
|
|
1214
|
+
)
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function GroupedBarLineTrend() {
|
|
1218
|
+
return (
|
|
1219
|
+
<ChartContainer config={barCfg} className="flex-1 min-h-[180px] w-full">
|
|
1220
|
+
<LineChart data={barData} margin={{ left: -8, right: 4, top: 4, bottom: 0 }}>
|
|
1221
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1222
|
+
<XAxis dataKey="program" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
|
1223
|
+
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={28} />
|
|
1224
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
1225
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1226
|
+
<Line type="monotone" dataKey="new" stroke={BRAND} strokeWidth={2} strokeDasharray={chartLineStrokeDash(0)} dot={{ r: 3 }} />
|
|
1227
|
+
<Line type="monotone" dataKey="returned" stroke={CHART_2} strokeWidth={2} strokeDasharray={chartLineStrokeDash(1)} dot={{ r: 3 }} />
|
|
1228
|
+
</LineChart>
|
|
1229
|
+
</ChartContainer>
|
|
1230
|
+
)
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/* ── Stacked Bar ─────────────────────────────────────────────────────────── */
|
|
1234
|
+
const stackCfg: ChartConfig = {
|
|
1235
|
+
approved: { label: "Approved", color: SUCCESS },
|
|
1236
|
+
pending: { label: "Pending", color: WARNING },
|
|
1237
|
+
rejected: { label: "Rejected", color: DESTRUCTIVE },
|
|
1238
|
+
}
|
|
1239
|
+
const stackData = [
|
|
1240
|
+
{ month: "Oct", approved: 38, pending: 12, rejected: 4 },
|
|
1241
|
+
{ month: "Nov", approved: 44, pending: 15, rejected: 6 },
|
|
1242
|
+
{ month: "Dec", approved: 31, pending: 8, rejected: 3 },
|
|
1243
|
+
{ month: "Jan", approved: 52, pending: 18, rejected: 7 },
|
|
1244
|
+
{ month: "Feb", approved: 60, pending: 14, rejected: 5 },
|
|
1245
|
+
{ month: "Mar", approved: 68, pending: 20, rejected: 8 },
|
|
1246
|
+
]
|
|
1247
|
+
|
|
1248
|
+
function StackedBarContent() {
|
|
1249
|
+
return (
|
|
1250
|
+
<ChartFigure label="Monthly Reviews" summary="Stacked bar chart showing approved, pending and rejected reviews Oct to Mar" dataLength={stackData.length}>
|
|
1251
|
+
{(activeIndex) => (
|
|
1252
|
+
<>
|
|
1253
|
+
<div className="relative w-full min-h-[180px] flex-1">
|
|
1254
|
+
<ChartContainer config={stackCfg} className="h-full min-h-[180px] w-full flex-1">
|
|
1255
|
+
<BarChart data={stackData} margin={{ left: -8, right: 4, top: 4, bottom: 0 }}>
|
|
1256
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1257
|
+
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
|
1258
|
+
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={28} />
|
|
1259
|
+
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
1260
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1261
|
+
<Bar dataKey="approved" fill={SUCCESS} stackId="a" activeBar={{ stroke: "var(--ring)", strokeWidth: 2, fillOpacity: 1 }} activeIndex={activeIndex ?? undefined} />
|
|
1262
|
+
<Bar dataKey="pending" fill={WARNING} stackId="a" activeBar={{ stroke: "var(--ring)", strokeWidth: 2, fillOpacity: 1 }} activeIndex={activeIndex ?? undefined} />
|
|
1263
|
+
<Bar dataKey="rejected" fill={DESTRUCTIVE} stackId="a" radius={[4, 4, 0, 0]} activeBar={{ stroke: "var(--ring)", strokeWidth: 2, fillOpacity: 1 }} activeIndex={activeIndex ?? undefined} />
|
|
1264
|
+
</BarChart>
|
|
1265
|
+
</ChartContainer>
|
|
1266
|
+
<ChartLeoPlotInsightOverlay data={stackData} xDataKey="month" insetPct={{ left: 12, right: 4, top: 5, bottom: 18 }} />
|
|
1267
|
+
</div>
|
|
1268
|
+
<ChartDataTable caption="Monthly Reviews" headers={["Month", "Approved", "Pending", "Rejected"]} rows={stackData.map(d => [d.month, d.approved, d.pending, d.rejected])} />
|
|
1269
|
+
</>
|
|
1270
|
+
)}
|
|
1271
|
+
</ChartFigure>
|
|
1272
|
+
)
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function StackedBarLineTrend() {
|
|
1276
|
+
return (
|
|
1277
|
+
<div className="relative w-full min-h-[180px] flex-1">
|
|
1278
|
+
<ChartContainer config={stackCfg} className="h-full min-h-[180px] w-full flex-1">
|
|
1279
|
+
<LineChart data={stackData} margin={{ left: -8, right: 4, top: 4, bottom: 0 }}>
|
|
1280
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1281
|
+
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
|
1282
|
+
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={28} />
|
|
1283
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
1284
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1285
|
+
<Line type="monotone" dataKey="approved" stroke={SUCCESS} strokeWidth={2} strokeDasharray={chartLineStrokeDash(0)} dot={{ r: 3 }} />
|
|
1286
|
+
<Line type="monotone" dataKey="pending" stroke={WARNING} strokeWidth={2} strokeDasharray={chartLineStrokeDash(1)} dot={{ r: 3 }} />
|
|
1287
|
+
<Line type="monotone" dataKey="rejected" stroke={DESTRUCTIVE} strokeWidth={2} strokeDasharray={chartLineStrokeDash(2)} dot={{ r: 3 }} />
|
|
1288
|
+
</LineChart>
|
|
1289
|
+
</ChartContainer>
|
|
1290
|
+
<ChartLeoPlotInsightOverlay data={stackData} xDataKey="month" insetPct={{ left: 12, right: 4, top: 5, bottom: 18 }} />
|
|
1291
|
+
</div>
|
|
1292
|
+
)
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/* ── Line ─────────────────────────────────────────────────────────────────── */
|
|
1296
|
+
const lineCfg: ChartConfig = {
|
|
1297
|
+
logins: { label: "Logins", color: BRAND },
|
|
1298
|
+
submissions: { label: "Submissions", color: CHART_2 },
|
|
1299
|
+
evaluations: { label: "Evaluations", color: CHART_4 },
|
|
1300
|
+
}
|
|
1301
|
+
const lineData = [
|
|
1302
|
+
{ week: "W1", logins: 148, submissions: 42, evaluations: 29 },
|
|
1303
|
+
{ week: "W2", logins: 162, submissions: 51, evaluations: 35 },
|
|
1304
|
+
{ week: "W3", logins: 139, submissions: 38, evaluations: 27 },
|
|
1305
|
+
{ week: "W4", logins: 175, submissions: 63, evaluations: 48 },
|
|
1306
|
+
{ week: "W5", logins: 182, submissions: 69, evaluations: 52 },
|
|
1307
|
+
{ week: "W6", logins: 196, submissions: 75, evaluations: 58 },
|
|
1308
|
+
{ week: "W7", logins: 211, submissions: 82, evaluations: 63 },
|
|
1309
|
+
{ week: "W8", logins: 204, submissions: 78, evaluations: 60 },
|
|
1310
|
+
]
|
|
1311
|
+
|
|
1312
|
+
const lineDataByPeriod: Record<string, typeof lineData> = {
|
|
1313
|
+
"7d": lineData.slice(-2),
|
|
1314
|
+
"30d": lineData.slice(-4),
|
|
1315
|
+
"90d": lineData.slice(-6),
|
|
1316
|
+
"1y": lineData,
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function LineChartContent({ data = lineData }: { data?: typeof lineData }) {
|
|
1320
|
+
return (
|
|
1321
|
+
<ChartFigure label="Portal Activity" summary="Line chart showing logins, submissions and evaluations by week" dataLength={data.length} leoInsight={CHART_GALLERY_LEO_LINE}>
|
|
1322
|
+
{(activeIndex) => (
|
|
1323
|
+
<>
|
|
1324
|
+
<ChartContainer config={lineCfg} className="flex-1 min-h-[180px] w-full">
|
|
1325
|
+
<LineChart data={data} margin={{ left: -8, right: 4, top: 4, bottom: 0 }}>
|
|
1326
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1327
|
+
<XAxis dataKey="week" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
|
1328
|
+
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={36} />
|
|
1329
|
+
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
1330
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1331
|
+
<Line type="monotone" dataKey="logins" stroke={BRAND} strokeWidth={2} strokeDasharray={chartLineStrokeDash(0)} dot={(props: LineDotRenderProps) => props.index === activeIndex ? <circle key={props.key} cx={props.cx} cy={props.cy} r={5} fill={props.stroke} stroke="var(--ring)" strokeWidth={2} /> : <circle key={props.key} cx={props.cx} cy={props.cy} r={2} fill={props.stroke} />} />
|
|
1332
|
+
<Line type="monotone" dataKey="submissions" stroke={CHART_2} strokeWidth={2} strokeDasharray={chartLineStrokeDash(1)} dot={(props: LineDotRenderProps) => props.index === activeIndex ? <circle key={props.key} cx={props.cx} cy={props.cy} r={5} fill={props.stroke} stroke="var(--ring)" strokeWidth={2} /> : <circle key={props.key} cx={props.cx} cy={props.cy} r={2} fill={props.stroke} />} />
|
|
1333
|
+
<Line type="monotone" dataKey="evaluations" stroke={CHART_4} strokeWidth={2} strokeDasharray={chartLineStrokeDash(2)} dot={(props: LineDotRenderProps) => props.index === activeIndex ? <circle key={props.key} cx={props.cx} cy={props.cy} r={5} fill={props.stroke} stroke="var(--ring)" strokeWidth={2} /> : <circle key={props.key} cx={props.cx} cy={props.cy} r={2} fill={props.stroke} />} />
|
|
1334
|
+
</LineChart>
|
|
1335
|
+
</ChartContainer>
|
|
1336
|
+
<ChartDataTable caption="Portal Activity" headers={["Week", "Logins", "Submissions", "Evaluations"]} rows={data.map(d => [d.week, d.logins, d.submissions, d.evaluations])} />
|
|
1337
|
+
</>
|
|
1338
|
+
)}
|
|
1339
|
+
</ChartFigure>
|
|
1340
|
+
)
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function LineAreaTrend() {
|
|
1344
|
+
return (
|
|
1345
|
+
<ChartContainer config={lineCfg} className="flex-1 min-h-[180px] w-full">
|
|
1346
|
+
<AreaChart data={lineData} margin={{ left: -8, right: 4, top: 4, bottom: 0 }}>
|
|
1347
|
+
<defs>
|
|
1348
|
+
<linearGradient id="gLogin" x1="0" y1="0" x2="0" y2="1">
|
|
1349
|
+
<stop offset="5%" stopColor={BRAND} stopOpacity={0.3} />
|
|
1350
|
+
<stop offset="95%" stopColor={BRAND} stopOpacity={0.0} />
|
|
1351
|
+
</linearGradient>
|
|
1352
|
+
</defs>
|
|
1353
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1354
|
+
<XAxis dataKey="week" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
|
1355
|
+
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={36} />
|
|
1356
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
1357
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1358
|
+
<Area key="logins" type="monotone" dataKey="logins" stroke={BRAND} fill="url(#gLogin)" strokeWidth={2} dot={false} />
|
|
1359
|
+
<Area key="submissions" type="monotone" dataKey="submissions" stroke={CHART_2} fill="none" strokeWidth={2} dot={false} />
|
|
1360
|
+
<Area key="evaluations" type="monotone" dataKey="evaluations" stroke={CHART_4} fill="none" strokeWidth={2} dot={false} />
|
|
1361
|
+
</AreaChart>
|
|
1362
|
+
</ChartContainer>
|
|
1363
|
+
)
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/* ── Radial Bar ──────────────────────────────────────────────────────────── */
|
|
1367
|
+
const radialCfg: ChartConfig = {
|
|
1368
|
+
nursing: { label: "Nursing", color: BRAND },
|
|
1369
|
+
pt: { label: "PT", color: CHART_2 },
|
|
1370
|
+
ot: { label: "OT", color: SUCCESS },
|
|
1371
|
+
pharmacy: { label: "Pharmacy", color: WARNING },
|
|
1372
|
+
social: { label: "Social Work", color: CHART_4 },
|
|
1373
|
+
}
|
|
1374
|
+
const radialData = [
|
|
1375
|
+
{ name: "nursing", score: 98, fill: BRAND },
|
|
1376
|
+
{ name: "pt", score: 94, fill: CHART_2 },
|
|
1377
|
+
{ name: "ot", score: 91, fill: SUCCESS },
|
|
1378
|
+
{ name: "pharmacy", score: 87, fill: WARNING },
|
|
1379
|
+
{ name: "social", score: 82, fill: CHART_4 },
|
|
1380
|
+
]
|
|
1381
|
+
|
|
1382
|
+
function RadialBarContent({ data = radialData }: { data?: typeof radialData }) {
|
|
1383
|
+
return (
|
|
1384
|
+
<ChartFigure label="Compliance Score" summary="Radial bar chart showing compliance scores by program" dataLength={data.length} leoInsight={CHART_GALLERY_LEO_COMPLIANCE}>
|
|
1385
|
+
{(activeIndex) => (
|
|
1386
|
+
<>
|
|
1387
|
+
<ChartContainer config={radialCfg} className="flex-1 min-h-[140px] w-full">
|
|
1388
|
+
<RadialBarChart data={data} innerRadius="20%" outerRadius="85%"
|
|
1389
|
+
startAngle={90} endAngle={-270} barSize={10}>
|
|
1390
|
+
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
|
|
1391
|
+
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent nameKey="name" hideLabel />} />
|
|
1392
|
+
<RadialBar dataKey="score" cornerRadius={5} background={{ fill: "var(--muted)" }} activeIndex={activeIndex ?? undefined}>
|
|
1393
|
+
{data.map((d) => <Cell key={d.name} fill={d.fill} />)}
|
|
1394
|
+
</RadialBar>
|
|
1395
|
+
</RadialBarChart>
|
|
1396
|
+
</ChartContainer>
|
|
1397
|
+
<div className="grid grid-cols-1 gap-1 text-xs mt-2 shrink-0">
|
|
1398
|
+
{data.map((d) => (
|
|
1399
|
+
<div key={d.name} className="flex items-center gap-2">
|
|
1400
|
+
<span className="h-2 w-2 rounded-full shrink-0" style={{ background: d.fill }} />
|
|
1401
|
+
<span className="text-muted-foreground flex-1">{radialCfg[d.name]?.label}</span>
|
|
1402
|
+
<span className="font-semibold tabular-nums">{d.score}%</span>
|
|
1403
|
+
</div>
|
|
1404
|
+
))}
|
|
1405
|
+
</div>
|
|
1406
|
+
<ChartDataTable
|
|
1407
|
+
caption="Compliance Score"
|
|
1408
|
+
headers={["Program", "Score"]}
|
|
1409
|
+
rows={data.map(d => {
|
|
1410
|
+
const raw = radialCfg[d.name]?.label ?? d.name
|
|
1411
|
+
const label =
|
|
1412
|
+
typeof raw === "string" || typeof raw === "number" ? String(raw) : String(d.name)
|
|
1413
|
+
return [label, `${d.score}%`] as [string, string]
|
|
1414
|
+
})}
|
|
1415
|
+
/>
|
|
1416
|
+
</>
|
|
1417
|
+
)}
|
|
1418
|
+
</ChartFigure>
|
|
1419
|
+
)
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function RadialLineTrend() {
|
|
1423
|
+
const data = radialData.map((d, i) => ({
|
|
1424
|
+
name: d.name,
|
|
1425
|
+
score: d.score,
|
|
1426
|
+
prev: d.score - [4, 7, 2, 9, 5][i],
|
|
1427
|
+
}))
|
|
1428
|
+
const cfg: ChartConfig = {
|
|
1429
|
+
score: { label: "Current", color: BRAND },
|
|
1430
|
+
prev: { label: "Previous", color: CHART_2 },
|
|
1431
|
+
}
|
|
1432
|
+
return (
|
|
1433
|
+
<ChartContainer config={cfg} className="flex-1 min-h-[180px] w-full">
|
|
1434
|
+
<BarChart data={data} margin={{ left: -8, right: 4, top: 4, bottom: 0 }}>
|
|
1435
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1436
|
+
<XAxis dataKey="name" tickLine={false} axisLine={false} tick={{ fontSize: 11 }} />
|
|
1437
|
+
<YAxis domain={[70, 100]} tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={32} />
|
|
1438
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
1439
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1440
|
+
<Bar dataKey="prev" fill={CHART_2} radius={[4, 4, 0, 0]} opacity={0.5} />
|
|
1441
|
+
<Bar dataKey="score" fill={BRAND} radius={[4, 4, 0, 0]} />
|
|
1442
|
+
</BarChart>
|
|
1443
|
+
</ChartContainer>
|
|
1444
|
+
)
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/** Quota radial — ChartFigure, keyboard tooltip sync, sr-only table (same pattern as RadialBarContent). */
|
|
1448
|
+
function QuotaRadialGalleryContent({ radial }: { radial: StudentScoreRadial }) {
|
|
1449
|
+
const summary =
|
|
1450
|
+
`Radial gauge for ${radial.title}. Student score ${formatBandScore(radial.studentScore)}. Class average ${formatBandScore(radial.classAverage)}. Scale ${formatBandScore(radial.scaleMin)} to ${formatBandScore(radial.scaleMax)}. ${radial.caption}.`
|
|
1451
|
+
|
|
1452
|
+
return (
|
|
1453
|
+
<ChartFigure label={radial.title} summary={summary} dataLength={1} leoInsight={CHART_GALLERY_LEO_QUOTA}>
|
|
1454
|
+
{(activeIndex) => (
|
|
1455
|
+
<>
|
|
1456
|
+
<div className="flex flex-col items-center gap-2">
|
|
1457
|
+
<QuotaRadialChartInner radial={radial} activeIndex={activeIndex} />
|
|
1458
|
+
<p className="text-xs text-muted-foreground tabular-nums">
|
|
1459
|
+
Class avg{" "}
|
|
1460
|
+
<span className="font-medium text-foreground">{formatBandScore(radial.classAverage)}</span>
|
|
1461
|
+
<span className="text-muted-foreground">
|
|
1462
|
+
{" "}
|
|
1463
|
+
· scale {formatBandScore(radial.scaleMin)}–{formatBandScore(radial.scaleMax)}
|
|
1464
|
+
</span>
|
|
1465
|
+
</p>
|
|
1466
|
+
</div>
|
|
1467
|
+
<ChartDataTable
|
|
1468
|
+
caption={radial.title}
|
|
1469
|
+
headers={["Measure", "Value"]}
|
|
1470
|
+
rows={[
|
|
1471
|
+
["Student score", formatBandScore(radial.studentScore)],
|
|
1472
|
+
["Class average", formatBandScore(radial.classAverage)],
|
|
1473
|
+
["Scale", `${formatBandScore(radial.scaleMin)}–${formatBandScore(radial.scaleMax)}`],
|
|
1474
|
+
]}
|
|
1475
|
+
/>
|
|
1476
|
+
</>
|
|
1477
|
+
)}
|
|
1478
|
+
</ChartFigure>
|
|
1479
|
+
)
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/* ── Horizontal Bar ─────────────────────────────────────────────────────── */
|
|
1483
|
+
const hBarCfg: ChartConfig = {
|
|
1484
|
+
placements: { label: "Placements", color: BRAND },
|
|
1485
|
+
}
|
|
1486
|
+
const hBarData = [
|
|
1487
|
+
{ site: "City Med", placements: 42 },
|
|
1488
|
+
{ site: "Westside Hosp", placements: 37 },
|
|
1489
|
+
{ site: "North Clinic", placements: 31 },
|
|
1490
|
+
{ site: "Bay Health", placements: 28 },
|
|
1491
|
+
{ site: "Eastview", placements: 22 },
|
|
1492
|
+
{ site: "Lakeshore", placements: 18 },
|
|
1493
|
+
{ site: "Pinehill", placements: 14 },
|
|
1494
|
+
]
|
|
1495
|
+
|
|
1496
|
+
const hBarByPeriod: Record<string, typeof hBarData> = {
|
|
1497
|
+
"7d": hBarData.map((d) => ({ ...d, placements: Math.round(d.placements * 0.35) })),
|
|
1498
|
+
"30d": hBarData.map((d) => ({ ...d, placements: Math.round(d.placements * 0.6) })),
|
|
1499
|
+
"90d": hBarData,
|
|
1500
|
+
"1y": hBarData.map((d) => ({ ...d, placements: Math.round(d.placements * 4.2) })),
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function HorizontalBarContent({ data = hBarData }: { data?: typeof hBarData }) {
|
|
1504
|
+
return (
|
|
1505
|
+
<ChartFigure label="Placements by Site" summary="Horizontal bar chart showing placement count by clinical site" dataLength={data.length} leoInsight={CHART_GALLERY_LEO_HORIZONTAL}>
|
|
1506
|
+
{(activeIndex) => (
|
|
1507
|
+
<>
|
|
1508
|
+
<ChartContainer config={hBarCfg} className="flex-1 min-h-[200px] w-full">
|
|
1509
|
+
<BarChart data={data} layout="vertical" margin={{ left: 4, right: 16, top: 4, bottom: 0 }}>
|
|
1510
|
+
<CartesianGrid horizontal={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1511
|
+
<XAxis type="number" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
|
1512
|
+
<YAxis type="category" dataKey="site" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={82} />
|
|
1513
|
+
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
1514
|
+
<Bar dataKey="placements" fill={BRAND} radius={[0, 4, 4, 0]} activeBar={{ stroke: "var(--ring)", strokeWidth: 2, fillOpacity: 1 }} activeIndex={activeIndex ?? undefined} />
|
|
1515
|
+
</BarChart>
|
|
1516
|
+
</ChartContainer>
|
|
1517
|
+
<ChartDataTable caption="Placements by Site" headers={["Site", "Placements"]} rows={data.map(d => [d.site, d.placements])} />
|
|
1518
|
+
</>
|
|
1519
|
+
)}
|
|
1520
|
+
</ChartFigure>
|
|
1521
|
+
)
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function HBarLineTrend() {
|
|
1525
|
+
return (
|
|
1526
|
+
<ChartContainer config={hBarCfg} className="flex-1 min-h-[200px] w-full">
|
|
1527
|
+
<LineChart data={hBarData} margin={{ left: -8, right: 16, top: 4, bottom: 0 }}>
|
|
1528
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1529
|
+
<XAxis dataKey="site" tickLine={false} axisLine={false} tick={{ fontSize: 10 }} />
|
|
1530
|
+
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={32} />
|
|
1531
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
1532
|
+
<Line type="monotone" dataKey="placements" stroke={BRAND} strokeWidth={2} dot={{ r: 4, fill: BRAND }} />
|
|
1533
|
+
</LineChart>
|
|
1534
|
+
</ChartContainer>
|
|
1535
|
+
)
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/* ── Composed ─────────────────────────────────────────────────────────────── */
|
|
1539
|
+
const composedCfg: ChartConfig = {
|
|
1540
|
+
placements: { label: "Placements", color: BRAND },
|
|
1541
|
+
capacity: { label: "Capacity", color: CHART_3 },
|
|
1542
|
+
rate: { label: "Fill Rate %", color: CHART_4 },
|
|
1543
|
+
}
|
|
1544
|
+
const composedData = [
|
|
1545
|
+
{ month: "Sep", placements: 44, capacity: 60, rate: 73 },
|
|
1546
|
+
{ month: "Oct", placements: 53, capacity: 65, rate: 82 },
|
|
1547
|
+
{ month: "Nov", placements: 67, capacity: 80, rate: 84 },
|
|
1548
|
+
{ month: "Dec", placements: 49, capacity: 70, rate: 70 },
|
|
1549
|
+
{ month: "Jan", placements: 74, capacity: 85, rate: 87 },
|
|
1550
|
+
{ month: "Feb", placements: 81, capacity: 90, rate: 90 },
|
|
1551
|
+
{ month: "Mar", placements: 89, capacity: 95, rate: 94 },
|
|
1552
|
+
]
|
|
1553
|
+
|
|
1554
|
+
function ComposedChartContent() {
|
|
1555
|
+
return (
|
|
1556
|
+
<ChartFigure label="Site Capacity vs Fill Rate" summary="Composed chart showing placement volume against site capacity and fill rate percentage" dataLength={composedData.length} leoInsight={CHART_GALLERY_LEO_COMPOSED}>
|
|
1557
|
+
{(activeIndex) => (
|
|
1558
|
+
<>
|
|
1559
|
+
<ChartContainer config={composedCfg} className="flex-1 min-h-[180px] w-full">
|
|
1560
|
+
<ComposedChart data={composedData} margin={{ left: -8, right: 28, top: 4, bottom: 0 }}>
|
|
1561
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1562
|
+
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
|
1563
|
+
<YAxis yAxisId="left" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={36} />
|
|
1564
|
+
<YAxis yAxisId="right" orientation="right" tickLine={false} axisLine={false}
|
|
1565
|
+
tick={{ fontSize: 12 }} width={32} unit="%" domain={[0, 100]} />
|
|
1566
|
+
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
1567
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1568
|
+
<Bar yAxisId="left" dataKey="capacity" fill={CHART_3} radius={[4,4,0,0]} opacity={0.45} activeBar={{ stroke: "var(--ring)", strokeWidth: 2, fillOpacity: 1 }} activeIndex={activeIndex ?? undefined} />
|
|
1569
|
+
<Bar yAxisId="left" dataKey="placements" fill={BRAND} radius={[4,4,0,0]} activeBar={{ stroke: "var(--ring)", strokeWidth: 2, fillOpacity: 1 }} activeIndex={activeIndex ?? undefined} />
|
|
1570
|
+
<Line yAxisId="right" dataKey="rate" stroke={CHART_4} strokeWidth={2}
|
|
1571
|
+
dot={(props: LineDotRenderProps) => props.index === activeIndex ? <circle key={props.key} cx={props.cx} cy={props.cy} r={5} fill={props.stroke} stroke="var(--ring)" strokeWidth={2} /> : <circle key={props.key} cx={props.cx} cy={props.cy} r={3} fill={CHART_4} />} type="monotone" />
|
|
1572
|
+
</ComposedChart>
|
|
1573
|
+
</ChartContainer>
|
|
1574
|
+
<ChartDataTable caption="Site Capacity vs Fill Rate" headers={["Month", "Placements", "Capacity", "Fill Rate %"]} rows={composedData.map(d => [d.month, d.placements, d.capacity, `${d.rate}%`])} />
|
|
1575
|
+
</>
|
|
1576
|
+
)}
|
|
1577
|
+
</ChartFigure>
|
|
1578
|
+
)
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function ComposedLineTrend() {
|
|
1582
|
+
const cfg: ChartConfig = {
|
|
1583
|
+
placements: { label: "Placements", color: BRAND },
|
|
1584
|
+
capacity: { label: "Capacity", color: CHART_3 },
|
|
1585
|
+
rate: { label: "Fill Rate %", color: CHART_4 },
|
|
1586
|
+
}
|
|
1587
|
+
return (
|
|
1588
|
+
<ChartContainer config={cfg} className="flex-1 min-h-[180px] w-full">
|
|
1589
|
+
<LineChart data={composedData} margin={{ left: -8, right: 28, top: 4, bottom: 0 }}>
|
|
1590
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1591
|
+
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
|
1592
|
+
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={36} />
|
|
1593
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
1594
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1595
|
+
<Line type="monotone" dataKey="placements" stroke={BRAND} strokeWidth={2} strokeDasharray={chartLineStrokeDash(0)} dot={{ r: 3 }} />
|
|
1596
|
+
<Line type="monotone" dataKey="capacity" stroke={CHART_3} strokeWidth={2} strokeDasharray={chartLineStrokeDash(1)} dot={{ r: 3 }} />
|
|
1597
|
+
<Line type="monotone" dataKey="rate" stroke={CHART_4} strokeWidth={2} strokeDasharray={chartLineStrokeDash(2)} dot={{ r: 3 }} />
|
|
1598
|
+
</LineChart>
|
|
1599
|
+
</ChartContainer>
|
|
1600
|
+
)
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
/* ── Radar ───────────────────────────────────────────────────────────────── */
|
|
1604
|
+
const radarCfg: ChartConfig = {
|
|
1605
|
+
nursing: { label: "Nursing", color: BRAND },
|
|
1606
|
+
physical: { label: "PT/OT", color: CHART_2 },
|
|
1607
|
+
}
|
|
1608
|
+
const radarData = [
|
|
1609
|
+
{ skill: "Clinical", nursing: 92, physical: 88 },
|
|
1610
|
+
{ skill: "Comm.", nursing: 85, physical: 79 },
|
|
1611
|
+
{ skill: "Critical", nursing: 78, physical: 84 },
|
|
1612
|
+
{ skill: "Teamwork", nursing: 91, physical: 90 },
|
|
1613
|
+
{ skill: "Ethics", nursing: 96, physical: 93 },
|
|
1614
|
+
{ skill: "Technical", nursing: 80, physical: 87 },
|
|
1615
|
+
]
|
|
1616
|
+
|
|
1617
|
+
function RadarChartContent() {
|
|
1618
|
+
return (
|
|
1619
|
+
<ChartFigure label="Competency Radar" summary="Radar chart comparing nursing vs PT/OT competency scores across 6 skill dimensions" dataLength={radarData.length} leoInsight={CHART_GALLERY_LEO_RADAR}>
|
|
1620
|
+
{(activeIndex) => (
|
|
1621
|
+
<>
|
|
1622
|
+
<ChartContainer config={radarCfg} className="flex-1 min-h-[200px] w-full">
|
|
1623
|
+
<RadarChart data={radarData} margin={{ top: 8, right: 16, bottom: 8, left: 16 }}>
|
|
1624
|
+
<PolarGrid stroke="var(--border)" />
|
|
1625
|
+
<PolarAngleAxis dataKey="skill" tick={{ fontSize: 11 }} />
|
|
1626
|
+
<PolarRadiusAxis angle={30} domain={[60, 100]} tick={{ fontSize: 10 }} tickCount={3} stroke="var(--border)" />
|
|
1627
|
+
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
1628
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1629
|
+
<Radar name="nursing" dataKey="nursing" stroke={BRAND} fill={BRAND} fillOpacity={0.25} strokeWidth={2} activeDot={{ r: 6, stroke: "var(--ring)", strokeWidth: 2 }} />
|
|
1630
|
+
<Radar name="physical" dataKey="physical" stroke={CHART_2} fill={CHART_2} fillOpacity={0.2} strokeWidth={2} activeDot={{ r: 6, stroke: "var(--ring)", strokeWidth: 2 }} />
|
|
1631
|
+
</RadarChart>
|
|
1632
|
+
</ChartContainer>
|
|
1633
|
+
<ChartDataTable caption="Competency Scores" headers={["Skill", "Nursing", "PT/OT"]} rows={radarData.map(d => [d.skill, d.nursing, d.physical])} />
|
|
1634
|
+
</>
|
|
1635
|
+
)}
|
|
1636
|
+
</ChartFigure>
|
|
1637
|
+
)
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function RadarBarTrend() {
|
|
1641
|
+
const cfg: ChartConfig = {
|
|
1642
|
+
nursing: { label: "Nursing", color: BRAND },
|
|
1643
|
+
physical: { label: "PT/OT", color: CHART_2 },
|
|
1644
|
+
}
|
|
1645
|
+
return (
|
|
1646
|
+
<ChartContainer config={cfg} className="flex-1 min-h-[200px] w-full">
|
|
1647
|
+
<BarChart data={radarData} barGap={4} margin={{ left: -8, right: 4, top: 4, bottom: 0 }}>
|
|
1648
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1649
|
+
<XAxis dataKey="skill" tickLine={false} axisLine={false} tick={{ fontSize: 11 }} />
|
|
1650
|
+
<YAxis domain={[60, 100]} tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={28} />
|
|
1651
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
1652
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1653
|
+
<Bar dataKey="nursing" fill={BRAND} radius={[4, 4, 0, 0]} />
|
|
1654
|
+
<Bar dataKey="physical" fill={CHART_2} radius={[4, 4, 0, 0]} />
|
|
1655
|
+
</BarChart>
|
|
1656
|
+
</ChartContainer>
|
|
1657
|
+
)
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/* ── Scatter ─────────────────────────────────────────────────────────────── */
|
|
1661
|
+
const scatterCfg: ChartConfig = {
|
|
1662
|
+
nursing: { label: "Nursing", color: BRAND },
|
|
1663
|
+
pt: { label: "PT", color: CHART_2 },
|
|
1664
|
+
ot: { label: "OT", color: SUCCESS },
|
|
1665
|
+
pharmacy: { label: "Pharmacy", color: WARNING },
|
|
1666
|
+
}
|
|
1667
|
+
const scatterNursing = [{ x: 80, y: 94, z: 42 }, { x: 65, y: 88, z: 35 }, { x: 55, y: 78, z: 28 }, { x: 90, y: 97, z: 51 }, { x: 70, y: 91, z: 38 }]
|
|
1668
|
+
const scatterPT = [{ x: 40, y: 85, z: 22 }, { x: 50, y: 90, z: 27 }, { x: 35, y: 80, z: 18 }, { x: 60, y: 93, z: 31 }]
|
|
1669
|
+
const scatterOT = [{ x: 30, y: 88, z: 16 }, { x: 45, y: 92, z: 24 }, { x: 38, y: 84, z: 19 }]
|
|
1670
|
+
const scatterPharmacy = [{ x: 25, y: 76, z: 12 }, { x: 35, y: 82, z: 17 }, { x: 20, y: 71, z: 9 }]
|
|
1671
|
+
|
|
1672
|
+
function ScatterChartContent() {
|
|
1673
|
+
return (
|
|
1674
|
+
<ChartContainer config={scatterCfg} className="flex-1 min-h-[200px] w-full">
|
|
1675
|
+
<ScatterChart margin={{ left: -8, right: 16, top: 4, bottom: 0 }}>
|
|
1676
|
+
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
|
1677
|
+
<XAxis type="number" dataKey="x" name="Capacity" tickLine={false} axisLine={false} tick={{ fontSize: 12 }}
|
|
1678
|
+
label={{ value: "Capacity", position: "insideBottom", offset: -2, fontSize: 11 }} />
|
|
1679
|
+
<YAxis type="number" dataKey="y" name="Fill Rate" tickLine={false} axisLine={false}
|
|
1680
|
+
tick={{ fontSize: 12 }} unit="%" domain={[60, 100]} width={38} />
|
|
1681
|
+
<ZAxis type="number" dataKey="z" range={[40, 280]} name="Students" />
|
|
1682
|
+
<ChartTooltip cursor={{ strokeDasharray: "3 3" }} content={<ChartTooltipContent hideLabel />} />
|
|
1683
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1684
|
+
<Scatter name="nursing" data={scatterNursing} fill={BRAND} fillOpacity={0.75} />
|
|
1685
|
+
<Scatter name="pt" data={scatterPT} fill={CHART_2} fillOpacity={0.75} />
|
|
1686
|
+
<Scatter name="ot" data={scatterOT} fill={SUCCESS} fillOpacity={0.75} />
|
|
1687
|
+
<Scatter name="pharmacy" data={scatterPharmacy} fill={WARNING} fillOpacity={0.75} />
|
|
1688
|
+
</ScatterChart>
|
|
1689
|
+
</ChartContainer>
|
|
1690
|
+
)
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function ScatterLineTrend() {
|
|
1694
|
+
const cfg: ChartConfig = {
|
|
1695
|
+
nursing: { label: "Nursing", color: BRAND },
|
|
1696
|
+
pt: { label: "PT", color: CHART_2 },
|
|
1697
|
+
}
|
|
1698
|
+
const data = [
|
|
1699
|
+
{ month: "Oct", nursing: 88, pt: 80 },
|
|
1700
|
+
{ month: "Nov", nursing: 91, pt: 82 },
|
|
1701
|
+
{ month: "Dec", nursing: 89, pt: 79 },
|
|
1702
|
+
{ month: "Jan", nursing: 93, pt: 84 },
|
|
1703
|
+
{ month: "Feb", nursing: 95, pt: 87 },
|
|
1704
|
+
{ month: "Mar", nursing: 94, pt: 85 },
|
|
1705
|
+
]
|
|
1706
|
+
return (
|
|
1707
|
+
<ChartContainer config={cfg} className="flex-1 min-h-[200px] w-full">
|
|
1708
|
+
<LineChart data={data} margin={{ left: -8, right: 16, top: 4, bottom: 0 }}>
|
|
1709
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1710
|
+
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
|
1711
|
+
<YAxis domain={[70, 100]} tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={32} unit="%" />
|
|
1712
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
1713
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1714
|
+
<Line type="monotone" dataKey="nursing" stroke={BRAND} strokeWidth={2} strokeDasharray={chartLineStrokeDash(0)} dot={{ r: 3 }} />
|
|
1715
|
+
<Line type="monotone" dataKey="pt" stroke={CHART_2} strokeWidth={2} strokeDasharray={chartLineStrokeDash(1)} dot={{ r: 3 }} />
|
|
1716
|
+
</LineChart>
|
|
1717
|
+
</ChartContainer>
|
|
1718
|
+
)
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/* ── Funnel ──────────────────────────────────────────────────────────────── */
|
|
1722
|
+
const funnelCfg: ChartConfig = {
|
|
1723
|
+
applied: { label: "Applied", color: BRAND },
|
|
1724
|
+
screened: { label: "Screened", color: CHART_2 },
|
|
1725
|
+
matched: { label: "Matched", color: SUCCESS },
|
|
1726
|
+
placed: { label: "Placed", color: CHART_4 },
|
|
1727
|
+
completed: { label: "Completed", color: CHART_5 },
|
|
1728
|
+
}
|
|
1729
|
+
const funnelData = [
|
|
1730
|
+
{ name: "Applied", value: 320, fill: BRAND },
|
|
1731
|
+
{ name: "Screened", value: 240, fill: CHART_2 },
|
|
1732
|
+
{ name: "Matched", value: 175, fill: SUCCESS },
|
|
1733
|
+
{ name: "Placed", value: 128, fill: CHART_4 },
|
|
1734
|
+
{ name: "Completed", value: 98, fill: CHART_5 },
|
|
1735
|
+
]
|
|
1736
|
+
const funnelDataByPeriod: Record<string, typeof funnelData> = {
|
|
1737
|
+
"7d": funnelData.map((d) => ({ ...d, value: Math.round(d.value * 0.08) })),
|
|
1738
|
+
"30d": funnelData.map((d) => ({ ...d, value: Math.round(d.value * 0.3) })),
|
|
1739
|
+
"90d": funnelData,
|
|
1740
|
+
"1y": funnelData.map((d) => ({ ...d, value: d.value * 4 })),
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
function FunnelChartContent({ data = funnelData }: { data?: typeof funnelData }) {
|
|
1744
|
+
const summary = `Funnel with ${data.length} stages from ${data[0]?.name ?? ""} to ${data[data.length - 1]?.name ?? ""}.`
|
|
1745
|
+
return (
|
|
1746
|
+
<ChartFigure label="Application Pipeline" summary={summary} dataLength={data.length} leoInsight={CHART_GALLERY_LEO_FUNNEL}>
|
|
1747
|
+
{(activeIndex) => (
|
|
1748
|
+
<>
|
|
1749
|
+
<ChartContainer config={funnelCfg} className="flex-1 min-h-[220px] w-full">
|
|
1750
|
+
<FunnelChart margin={{ top: 8, right: 32, bottom: 8, left: 32 }}>
|
|
1751
|
+
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent hideLabel />} />
|
|
1752
|
+
<Funnel dataKey="value" data={data} isAnimationActive>
|
|
1753
|
+
{data.map((d, i) => (
|
|
1754
|
+
<Cell
|
|
1755
|
+
key={d.name}
|
|
1756
|
+
fill={d.fill}
|
|
1757
|
+
stroke={activeIndex === i ? "var(--ring)" : undefined}
|
|
1758
|
+
strokeWidth={activeIndex === i ? 2 : 0}
|
|
1759
|
+
/>
|
|
1760
|
+
))}
|
|
1761
|
+
<LabelList dataKey="name" position="right" style={{ fontSize: 12, fill: "var(--foreground)" }} />
|
|
1762
|
+
<LabelList dataKey="value" position="center" style={{ fontSize: 12, fontWeight: 600, fill: "var(--foreground)" }} />
|
|
1763
|
+
</Funnel>
|
|
1764
|
+
</FunnelChart>
|
|
1765
|
+
</ChartContainer>
|
|
1766
|
+
<ChartDataTable caption="Application Pipeline data" headers={["Stage", "Count"]} rows={data.map(d => [d.name, d.value])} />
|
|
1767
|
+
</>
|
|
1768
|
+
)}
|
|
1769
|
+
</ChartFigure>
|
|
1770
|
+
)
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
function FunnelLineTrend() {
|
|
1774
|
+
const cfg: ChartConfig = {
|
|
1775
|
+
applied: { label: "Applied", color: BRAND },
|
|
1776
|
+
placed: { label: "Placed", color: CHART_4 },
|
|
1777
|
+
completed: { label: "Completed", color: CHART_5 },
|
|
1778
|
+
}
|
|
1779
|
+
const data = [
|
|
1780
|
+
{ month: "Oct", applied: 210, placed: 95, completed: 68 },
|
|
1781
|
+
{ month: "Nov", applied: 245, placed: 108, completed: 82 },
|
|
1782
|
+
{ month: "Dec", applied: 180, placed: 88, completed: 64 },
|
|
1783
|
+
{ month: "Jan", applied: 280, placed: 120, completed: 91 },
|
|
1784
|
+
{ month: "Feb", applied: 300, placed: 124, completed: 95 },
|
|
1785
|
+
{ month: "Mar", applied: 320, placed: 128, completed: 98 },
|
|
1786
|
+
]
|
|
1787
|
+
return (
|
|
1788
|
+
<ChartContainer config={cfg} className="flex-1 min-h-[220px] w-full">
|
|
1789
|
+
<LineChart data={data} margin={{ left: -8, right: 4, top: 4, bottom: 0 }}>
|
|
1790
|
+
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
|
|
1791
|
+
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
|
1792
|
+
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12 }} width={36} />
|
|
1793
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
1794
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
1795
|
+
<Line type="monotone" dataKey="applied" stroke={BRAND} strokeWidth={2} strokeDasharray={chartLineStrokeDash(0)} dot={{ r: 3 }} />
|
|
1796
|
+
<Line type="monotone" dataKey="placed" stroke={CHART_4} strokeWidth={2} strokeDasharray={chartLineStrokeDash(1)} dot={{ r: 3 }} />
|
|
1797
|
+
<Line type="monotone" dataKey="completed" stroke={CHART_5} strokeWidth={2} strokeDasharray={chartLineStrokeDash(2)} dot={{ r: 3 }} />
|
|
1798
|
+
</LineChart>
|
|
1799
|
+
</ChartContainer>
|
|
1800
|
+
)
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/* ════════════════════════════════════════════════════════════════════════════
|
|
1804
|
+
Chart rows — shared across variants
|
|
1805
|
+
════════════════════════════════════════════════════════════════════════════ */
|
|
1806
|
+
|
|
1807
|
+
const CHART_GALLERY_LEO_DONUT: ChartLeoInsight = {
|
|
1808
|
+
headline: "Confirmed placements dominate the current pipeline",
|
|
1809
|
+
explanation:
|
|
1810
|
+
"87% of placements are already confirmed, with only 9% pending and 4% in review. This is a healthy distribution suggesting strong conversion from applications to confirmed offers.",
|
|
1811
|
+
kind: "spike",
|
|
1812
|
+
delta: { value: "+12%", label: "vs. last month" },
|
|
1813
|
+
bullets: [
|
|
1814
|
+
"Confirmed count has grown steadily across nursing, PT, and OT programs.",
|
|
1815
|
+
"Rejection rate remains low at 1% — applications are well-qualified.",
|
|
1816
|
+
],
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
const CHART_GALLERY_LEO_APPLICATIONS: ChartLeoInsight = {
|
|
1820
|
+
headline: "Nursing program leads application volume",
|
|
1821
|
+
explanation:
|
|
1822
|
+
"Nursing consistently attracts the most new applicants, with 34 this period. PT and OT follow closely. Returned applications suggest strong re-engagement.",
|
|
1823
|
+
kind: "trend",
|
|
1824
|
+
delta: { value: "+8%", label: "new vs. prior period" },
|
|
1825
|
+
bullets: [
|
|
1826
|
+
"Nursing: 34 new, 22 returned — highest volume and strong re-engagement.",
|
|
1827
|
+
"PT and OT: steady demand — balanced load across clinical programs.",
|
|
1828
|
+
],
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
const CHART_GALLERY_LEO_LINE: ChartLeoInsight = {
|
|
1832
|
+
headline: "Portal activity peaks mid-week",
|
|
1833
|
+
explanation:
|
|
1834
|
+
"Login, submission, and evaluation activity cluster around Tuesday–Thursday, with weekends showing predictable dips. This pattern is consistent and expected for an academic schedule.",
|
|
1835
|
+
kind: "trend",
|
|
1836
|
+
delta: { value: "—", label: "stable pattern" },
|
|
1837
|
+
bullets: [
|
|
1838
|
+
"Logins peak at ~450 on Wednesdays.",
|
|
1839
|
+
"Submissions highest Monday–Friday, near-zero on weekends.",
|
|
1840
|
+
],
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
const CHART_GALLERY_LEO_COMPLIANCE: ChartLeoInsight = {
|
|
1844
|
+
headline: "PT/OT programs lead compliance scoring",
|
|
1845
|
+
explanation:
|
|
1846
|
+
"PT and OT average 88–89% compliance, outpacing Nursing (82%) and Pharmacy (76%). Radiology lags at 71% — may need targeted support.",
|
|
1847
|
+
kind: "dip",
|
|
1848
|
+
delta: { value: "-8%", label: "Radiology vs. PT/OT" },
|
|
1849
|
+
bullets: [
|
|
1850
|
+
"PT/OT: consistent excellence across all 6 dimensions.",
|
|
1851
|
+
"Pharmacy: scoring gaps in documentation and timeliness.",
|
|
1852
|
+
"Radiology: needs support in scheduling and follow-up processes.",
|
|
1853
|
+
],
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
const CHART_GALLERY_LEO_HORIZONTAL: ChartLeoInsight = {
|
|
1857
|
+
headline: "Large clinical sites carry most placements",
|
|
1858
|
+
explanation:
|
|
1859
|
+
"The three largest sites (University Hospital, Metro Clinic, Regional Center) account for 58% of all placements. Mid-size sites are under-utilized.",
|
|
1860
|
+
kind: "anomaly",
|
|
1861
|
+
delta: { value: "+22%", label: "top 3 sites total" },
|
|
1862
|
+
bullets: [
|
|
1863
|
+
"University Hospital: 156 placements (28% of total).",
|
|
1864
|
+
"Capacity constraints may limit placement growth at smaller sites.",
|
|
1865
|
+
],
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
const CHART_GALLERY_LEO_COMPOSED: ChartLeoInsight = {
|
|
1869
|
+
headline: "Site capacity is healthy; fill rates peak Q2",
|
|
1870
|
+
explanation:
|
|
1871
|
+
"Most sites run 85–92% capacity utilization. Fill rate (placements / capacity) averages 78%, with spring months (Feb–Mar) consistently hitting 82%+.",
|
|
1872
|
+
kind: "spike",
|
|
1873
|
+
delta: { value: "+6%", label: "fill rate increase" },
|
|
1874
|
+
bullets: [
|
|
1875
|
+
"March shows the strongest fill rate at 84%.",
|
|
1876
|
+
"Only 2 sites are below 70% utilization — opportunity to rebalance.",
|
|
1877
|
+
],
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
const CHART_GALLERY_LEO_RADAR: ChartLeoInsight = {
|
|
1881
|
+
headline: "Nursing and PT/OT competencies are well-balanced",
|
|
1882
|
+
explanation:
|
|
1883
|
+
"Both programs score 80+ on all six dimensions. Nursing edges slightly on patient care; PT/OT lead in mobility and assessment. Ready for expanded placements.",
|
|
1884
|
+
kind: "trend",
|
|
1885
|
+
delta: { value: "—", label: "strong across programs" },
|
|
1886
|
+
bullets: [
|
|
1887
|
+
"6-dimension average: Nursing 84%, PT/OT 86%.",
|
|
1888
|
+
"Lowest dimension: patient care (Nursing 79%) — room to develop.",
|
|
1889
|
+
],
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
const CHART_GALLERY_LEO_SCATTER: ChartLeoInsight = {
|
|
1893
|
+
headline: "Application-to-placement funnel is healthy",
|
|
1894
|
+
explanation:
|
|
1895
|
+
"Applications feed steadily into offers; offer-to-confirmation conversion hovers around 72%. A small number of dropouts from offer-to-start, typical for clinical placements.",
|
|
1896
|
+
kind: "trend",
|
|
1897
|
+
delta: { value: "+4%", label: "confirmation rate" },
|
|
1898
|
+
bullets: [
|
|
1899
|
+
"Applications → Offers: 63% convert (typical for competitive placements).",
|
|
1900
|
+
"Offers → Confirmed: 72% accept (strong acceptance rate).",
|
|
1901
|
+
],
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
const CHART_GALLERY_LEO_FUNNEL: ChartLeoInsight = {
|
|
1905
|
+
headline: "Funnel shape is expected; strong at top of pipe",
|
|
1906
|
+
explanation:
|
|
1907
|
+
"4,200 applications narrow to 842 offers (20% funnel rate) and 604 confirmed placements (72% offer acceptance). Losses are proportional—no anomalous drops.",
|
|
1908
|
+
kind: "trend",
|
|
1909
|
+
delta: { value: "+8%", label: "application volume" },
|
|
1910
|
+
bullets: [
|
|
1911
|
+
"Application → Offer: drop-off is typical for screening.",
|
|
1912
|
+
"Offer → Confirmed: acceptance rate of 72% is healthy.",
|
|
1913
|
+
],
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
const CHART_GALLERY_LEO_QUOTA: ChartLeoInsight = {
|
|
1917
|
+
headline: "Student performance tracking and cohort comparison",
|
|
1918
|
+
explanation:
|
|
1919
|
+
"Track individual student progress against class averages and scale benchmarks. Identify outliers above or below cohort norms.",
|
|
1920
|
+
kind: "anomaly",
|
|
1921
|
+
bullets: [
|
|
1922
|
+
"Performance visualized on a consistent scale across cohorts.",
|
|
1923
|
+
"Class average provides immediate context for comparison.",
|
|
1924
|
+
],
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
const CHART_GALLERY_LEO_TRENDS: ChartLeoInsight = {
|
|
1928
|
+
headline: "December dips across placements, applications, and reviews",
|
|
1929
|
+
explanation:
|
|
1930
|
+
"All three series pull back in December—often seasonal (holidays, academic breaks) or a real pipeline stall. Worth confirming whether approvals or site capacity paused.",
|
|
1931
|
+
kind: "dip",
|
|
1932
|
+
delta: { value: "-24%", label: "vs. November" },
|
|
1933
|
+
bullets: [
|
|
1934
|
+
"Placements are 18% below the 6-month trailing average.",
|
|
1935
|
+
"Reviews dropped sharply in the last 2 weeks of the month.",
|
|
1936
|
+
"Same pattern appeared in Dec '24 — seasonal signal is plausible.",
|
|
1937
|
+
],
|
|
1938
|
+
anchor: {
|
|
1939
|
+
xValue: "Dec",
|
|
1940
|
+
yDataKeys: ["placements", "applications", "reviews"],
|
|
1941
|
+
yCombine: "max",
|
|
1942
|
+
},
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
const CHART_GALLERY_LEO_REVIEWS: ChartLeoInsight = {
|
|
1946
|
+
headline: "December is the low point in review throughput",
|
|
1947
|
+
explanation:
|
|
1948
|
+
"Totals drop before recovering — worth confirming whether fewer submissions arrived or reviewers were out. Pending and rejected slices still matter once volume returns.",
|
|
1949
|
+
kind: "dip",
|
|
1950
|
+
delta: { value: "-31%", label: "vs. November total" },
|
|
1951
|
+
bullets: [
|
|
1952
|
+
"Approved reviews fell from 68 to 47 month-over-month.",
|
|
1953
|
+
"Pending queue grew by 9 items — backlog forming.",
|
|
1954
|
+
"Two reviewers were OOO for most of the last two weeks.",
|
|
1955
|
+
],
|
|
1956
|
+
anchor: {
|
|
1957
|
+
xValue: "Dec",
|
|
1958
|
+
yDataKeys: ["approved", "pending", "rejected"],
|
|
1959
|
+
yCombine: "sum",
|
|
1960
|
+
},
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
function ChartRows({ v }: { v: ChartCardVariant }) {
|
|
1964
|
+
const isTabs = v === "tabs"
|
|
1965
|
+
const isSel = v === "selector"
|
|
1966
|
+
const isMT = v === "metrics-tabs"
|
|
1967
|
+
const isKpi = v === "kpi-chart"
|
|
1968
|
+
|
|
1969
|
+
return (
|
|
1970
|
+
<>
|
|
1971
|
+
{/* Row 1 · Area (2/3) + Donut (1/3) */}
|
|
1972
|
+
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3 items-stretch">
|
|
1973
|
+
<div className="lg:col-span-2 flex flex-col">
|
|
1974
|
+
{isSel ? (
|
|
1975
|
+
<ChartCard
|
|
1976
|
+
key="area-sel"
|
|
1977
|
+
variant="selector"
|
|
1978
|
+
title="Placement Trends"
|
|
1979
|
+
description="Filter by time period"
|
|
1980
|
+
filterOptions={PERIOD_OPTIONS}
|
|
1981
|
+
defaultFilter="90d"
|
|
1982
|
+
leoInsight={CHART_GALLERY_LEO_TRENDS}
|
|
1983
|
+
>
|
|
1984
|
+
{(f) => <AreaSelectorContent filter={f} />}
|
|
1985
|
+
</ChartCard>
|
|
1986
|
+
) : (
|
|
1987
|
+
<ChartCard
|
|
1988
|
+
key="area"
|
|
1989
|
+
variant={v}
|
|
1990
|
+
title="Placement Trends"
|
|
1991
|
+
description="Aug 2025 — Mar 2026"
|
|
1992
|
+
leoInsight={CHART_GALLERY_LEO_TRENDS}
|
|
1993
|
+
trendContent={<AreaLineTrendContent />}
|
|
1994
|
+
tabOptions={isTabs ? [
|
|
1995
|
+
{ value: "overview", label: "Overview" },
|
|
1996
|
+
{ value: "by-program", label: "By Program" },
|
|
1997
|
+
{ value: "trend", label: "Trend" },
|
|
1998
|
+
] : undefined}
|
|
1999
|
+
miniMetrics={(isMT || isKpi) ? [
|
|
2000
|
+
{ label: "Placements", value: "89", trend: "up" },
|
|
2001
|
+
{ label: "Fill rate", value: "94%", trend: "up" },
|
|
2002
|
+
{ label: "Avg. weeks", value: "6.2", trend: "neutral" },
|
|
2003
|
+
] : undefined}>
|
|
2004
|
+
{isTabs
|
|
2005
|
+
? (tab: string) => tab === "trend" ? <AreaLineTrendContent /> : <AreaChartContent />
|
|
2006
|
+
: <AreaChartContent />}
|
|
2007
|
+
</ChartCard>
|
|
2008
|
+
)}
|
|
2009
|
+
</div>
|
|
2010
|
+
<div className="flex flex-col">
|
|
2011
|
+
{isSel ? (
|
|
2012
|
+
<ChartCard key="donut-sel" variant="selector" title="Placement Status" description="Filter by program"
|
|
2013
|
+
filterOptions={PROGRAM_OPTIONS} defaultFilter="all" leoInsight={CHART_GALLERY_LEO_DONUT}>
|
|
2014
|
+
{(f) => <DonutChartContent data={donutByProgram[f] ?? donutDataAll} />}
|
|
2015
|
+
</ChartCard>
|
|
2016
|
+
) : (
|
|
2017
|
+
<ChartCard key="donut" variant={v} title="Placement Status" description="Current cycle distribution"
|
|
2018
|
+
leoInsight={CHART_GALLERY_LEO_DONUT}
|
|
2019
|
+
trendContent={<DonutBarTrendContent />}
|
|
2020
|
+
tabOptions={isTabs ? [
|
|
2021
|
+
{ value: "current", label: "Current Cycle" },
|
|
2022
|
+
{ value: "previous", label: "Previous Cycle" },
|
|
2023
|
+
] : undefined}
|
|
2024
|
+
miniMetrics={(isMT || isKpi) ? [
|
|
2025
|
+
{ label: "Placed", value: "128", trend: "up" },
|
|
2026
|
+
{ label: "Pending", value: "23", trend: "down" },
|
|
2027
|
+
] : undefined}>
|
|
2028
|
+
{isTabs
|
|
2029
|
+
? (tab: string) => <DonutChartContent data={tab === "previous" ? donutByProgram["pt"] : undefined} />
|
|
2030
|
+
: <DonutChartContent />}
|
|
2031
|
+
</ChartCard>
|
|
2032
|
+
)}
|
|
2033
|
+
</div>
|
|
2034
|
+
</div>
|
|
2035
|
+
|
|
2036
|
+
{/* Row 1b · Quota suite — one ChartCard per metric + radial (ChartFigure on radial only) */}
|
|
2037
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 items-stretch">
|
|
2038
|
+
{DASHBOARD_STUDENT_SCORES.metrics.map((m) => (
|
|
2039
|
+
<ChartCard
|
|
2040
|
+
key={`quota-${m.id}`}
|
|
2041
|
+
variant={v}
|
|
2042
|
+
title={m.label}
|
|
2043
|
+
description={m.description ?? DASHBOARD_STUDENT_SCORES.description ?? ""}
|
|
2044
|
+
className="overflow-visible"
|
|
2045
|
+
>
|
|
2046
|
+
<QuotaLinearProgressCardBody
|
|
2047
|
+
metric={m}
|
|
2048
|
+
suiteContext={DASHBOARD_STUDENT_SCORES.description ?? "Reference data."}
|
|
2049
|
+
/>
|
|
2050
|
+
</ChartCard>
|
|
2051
|
+
))}
|
|
2052
|
+
<ChartCard
|
|
2053
|
+
key="quota-radial"
|
|
2054
|
+
variant={v}
|
|
2055
|
+
title={DASHBOARD_STUDENT_SCORES.radial.title}
|
|
2056
|
+
description={DASHBOARD_STUDENT_SCORES.description ?? ""}
|
|
2057
|
+
className="overflow-visible"
|
|
2058
|
+
>
|
|
2059
|
+
<QuotaRadialGalleryContent radial={DASHBOARD_STUDENT_SCORES.radial} />
|
|
2060
|
+
</ChartCard>
|
|
2061
|
+
</div>
|
|
2062
|
+
|
|
2063
|
+
{/* Row 2 · Grouped Bar + Stacked Bar */}
|
|
2064
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 items-stretch">
|
|
2065
|
+
{isSel ? (
|
|
2066
|
+
<ChartCard key="gbar-sel" variant="selector" title="Applications by Program" description="Filter by time period"
|
|
2067
|
+
filterOptions={PERIOD_OPTIONS} defaultFilter="30d" leoInsight={CHART_GALLERY_LEO_APPLICATIONS}>
|
|
2068
|
+
{() => <GroupedBarContent />}
|
|
2069
|
+
</ChartCard>
|
|
2070
|
+
) : (
|
|
2071
|
+
<ChartCard key="gbar" variant={v} title="Applications by Program" description="New vs. returning students"
|
|
2072
|
+
leoInsight={CHART_GALLERY_LEO_APPLICATIONS}
|
|
2073
|
+
trendContent={<GroupedBarLineTrend />}
|
|
2074
|
+
tabOptions={isTabs ? [
|
|
2075
|
+
{ value: "all", label: "All Students" },
|
|
2076
|
+
{ value: "new", label: "New" },
|
|
2077
|
+
{ value: "trend", label: "Trend" },
|
|
2078
|
+
] : undefined}
|
|
2079
|
+
miniMetrics={(isMT || isKpi) ? [
|
|
2080
|
+
{ label: "Total", value: "320", trend: "up" },
|
|
2081
|
+
{ label: "New", value: "78%", trend: "up" },
|
|
2082
|
+
{ label: "Returning", value: "22%", trend: "neutral" },
|
|
2083
|
+
] : undefined}>
|
|
2084
|
+
{isTabs
|
|
2085
|
+
? (tab: string) => tab === "trend" ? <GroupedBarLineTrend /> : <GroupedBarContent />
|
|
2086
|
+
: <GroupedBarContent />}
|
|
2087
|
+
</ChartCard>
|
|
2088
|
+
)}
|
|
2089
|
+
{isSel ? (
|
|
2090
|
+
<ChartCard
|
|
2091
|
+
key="sbar-sel"
|
|
2092
|
+
variant="selector"
|
|
2093
|
+
title="Monthly Reviews"
|
|
2094
|
+
description="Filter by time period"
|
|
2095
|
+
filterOptions={PERIOD_OPTIONS}
|
|
2096
|
+
defaultFilter="30d"
|
|
2097
|
+
leoInsight={CHART_GALLERY_LEO_REVIEWS}
|
|
2098
|
+
>
|
|
2099
|
+
{() => <StackedBarContent />}
|
|
2100
|
+
</ChartCard>
|
|
2101
|
+
) : (
|
|
2102
|
+
<ChartCard
|
|
2103
|
+
key="sbar"
|
|
2104
|
+
variant={v}
|
|
2105
|
+
title="Monthly Reviews"
|
|
2106
|
+
description="Review outcomes by status"
|
|
2107
|
+
leoInsight={CHART_GALLERY_LEO_REVIEWS}
|
|
2108
|
+
trendContent={<StackedBarLineTrend />}
|
|
2109
|
+
tabOptions={isTabs ? [
|
|
2110
|
+
{ value: "status", label: "By Status" },
|
|
2111
|
+
{ value: "reviewer", label: "By Reviewer" },
|
|
2112
|
+
{ value: "trend", label: "Trend" },
|
|
2113
|
+
] : undefined}
|
|
2114
|
+
miniMetrics={(isMT || isKpi) ? [
|
|
2115
|
+
{ label: "Approved", value: "68", trend: "up" },
|
|
2116
|
+
{ label: "Pending", value: "14", trend: "down" },
|
|
2117
|
+
{ label: "Rejected", value: "6", trend: "neutral" },
|
|
2118
|
+
] : undefined}>
|
|
2119
|
+
{isTabs
|
|
2120
|
+
? (tab: string) => tab === "trend" ? <StackedBarLineTrend /> : <StackedBarContent />
|
|
2121
|
+
: <StackedBarContent />}
|
|
2122
|
+
</ChartCard>
|
|
2123
|
+
)}
|
|
2124
|
+
</div>
|
|
2125
|
+
|
|
2126
|
+
{/* Row 3 · Line (2/3) + Radial (1/3) */}
|
|
2127
|
+
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3 items-stretch">
|
|
2128
|
+
<div className="lg:col-span-2 flex flex-col">
|
|
2129
|
+
{isSel ? (
|
|
2130
|
+
<ChartCard key="line-sel" variant="selector" title="Weekly Activity" description="Filter by time period"
|
|
2131
|
+
filterOptions={PERIOD_OPTIONS} defaultFilter="90d" leoInsight={CHART_GALLERY_LEO_LINE}>
|
|
2132
|
+
{(f) => <LineChartContent data={lineDataByPeriod[f] ?? lineData} />}
|
|
2133
|
+
</ChartCard>
|
|
2134
|
+
) : (
|
|
2135
|
+
<ChartCard key="line" variant={v} title="Weekly Activity" description="Logins, submissions & evaluations"
|
|
2136
|
+
leoInsight={CHART_GALLERY_LEO_LINE}
|
|
2137
|
+
trendContent={<LineAreaTrend />}
|
|
2138
|
+
tabOptions={isTabs ? [
|
|
2139
|
+
{ value: "weekly", label: "Weekly" },
|
|
2140
|
+
{ value: "monthly", label: "Monthly" },
|
|
2141
|
+
{ value: "trend", label: "Trend" },
|
|
2142
|
+
] : undefined}
|
|
2143
|
+
miniMetrics={(isMT || isKpi) ? [
|
|
2144
|
+
{ label: "Logins", value: "1.2k", trend: "up" },
|
|
2145
|
+
{ label: "Submissions", value: "340", trend: "up" },
|
|
2146
|
+
{ label: "Evals", value: "88", trend: "neutral" },
|
|
2147
|
+
] : undefined}>
|
|
2148
|
+
{isTabs
|
|
2149
|
+
? (tab: string) => tab === "trend" ? <LineAreaTrend /> : <LineChartContent />
|
|
2150
|
+
: <LineChartContent />}
|
|
2151
|
+
</ChartCard>
|
|
2152
|
+
)}
|
|
2153
|
+
</div>
|
|
2154
|
+
<div className="flex flex-col">
|
|
2155
|
+
{isSel ? (
|
|
2156
|
+
<ChartCard key="radial-sel" variant="selector" title="Compliance Scores" description="Filter by program"
|
|
2157
|
+
filterOptions={PROGRAM_OPTIONS} defaultFilter="all" leoInsight={CHART_GALLERY_LEO_COMPLIANCE}>
|
|
2158
|
+
{() => <RadialBarContent />}
|
|
2159
|
+
</ChartCard>
|
|
2160
|
+
) : (
|
|
2161
|
+
<ChartCard key="radial" variant={v} title="Compliance Scores" description="By program — current cycle"
|
|
2162
|
+
leoInsight={CHART_GALLERY_LEO_COMPLIANCE}
|
|
2163
|
+
trendContent={<RadialLineTrend />}
|
|
2164
|
+
tabOptions={isTabs ? [
|
|
2165
|
+
{ value: "current", label: "Current" },
|
|
2166
|
+
{ value: "historical", label: "Historical" },
|
|
2167
|
+
] : undefined}
|
|
2168
|
+
miniMetrics={(isMT || isKpi) ? [
|
|
2169
|
+
{ label: "Avg. score", value: "91%", trend: "up" },
|
|
2170
|
+
{ label: "At risk", value: "3", trend: "down" },
|
|
2171
|
+
] : undefined}>
|
|
2172
|
+
{isTabs
|
|
2173
|
+
? (tab: string) => tab === "historical" ? <RadialLineTrend /> : <RadialBarContent />
|
|
2174
|
+
: <RadialBarContent />}
|
|
2175
|
+
</ChartCard>
|
|
2176
|
+
)}
|
|
2177
|
+
</div>
|
|
2178
|
+
</div>
|
|
2179
|
+
|
|
2180
|
+
{/* Row 4 · H-Bar (1/3) + Composed (2/3) */}
|
|
2181
|
+
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3 items-stretch">
|
|
2182
|
+
<div className="flex flex-col">
|
|
2183
|
+
{isSel ? (
|
|
2184
|
+
<ChartCard key="hbar-sel" variant="selector" title="Top Placement Sites" description="Filter by time period"
|
|
2185
|
+
filterOptions={PERIOD_OPTIONS} defaultFilter="90d" leoInsight={CHART_GALLERY_LEO_HORIZONTAL}>
|
|
2186
|
+
{(f) => <HorizontalBarContent data={hBarByPeriod[f] ?? hBarData} />}
|
|
2187
|
+
</ChartCard>
|
|
2188
|
+
) : (
|
|
2189
|
+
<ChartCard key="hbar" variant={v} title="Top Placement Sites" description="Active placements by facility"
|
|
2190
|
+
leoInsight={CHART_GALLERY_LEO_HORIZONTAL}
|
|
2191
|
+
trendContent={<HBarLineTrend />}
|
|
2192
|
+
tabOptions={isTabs ? [
|
|
2193
|
+
{ value: "by-facility", label: "By Facility" },
|
|
2194
|
+
{ value: "by-capacity", label: "By Capacity" },
|
|
2195
|
+
] : undefined}
|
|
2196
|
+
miniMetrics={(isMT || isKpi) ? [
|
|
2197
|
+
{ label: "Sites", value: "7", trend: "up" },
|
|
2198
|
+
{ label: "Capacity", value: "94%", trend: "up" },
|
|
2199
|
+
] : undefined}>
|
|
2200
|
+
{isTabs
|
|
2201
|
+
? () => <HorizontalBarContent />
|
|
2202
|
+
: <HorizontalBarContent />}
|
|
2203
|
+
</ChartCard>
|
|
2204
|
+
)}
|
|
2205
|
+
</div>
|
|
2206
|
+
<div className="lg:col-span-2 flex flex-col">
|
|
2207
|
+
{isSel ? (
|
|
2208
|
+
<ChartCard key="composed-sel" variant="selector" title="Placements vs Capacity" description="Filter by time period"
|
|
2209
|
+
filterOptions={PERIOD_OPTIONS} defaultFilter="1y" leoInsight={CHART_GALLERY_LEO_COMPOSED}>
|
|
2210
|
+
{() => <ComposedChartContent />}
|
|
2211
|
+
</ChartCard>
|
|
2212
|
+
) : (
|
|
2213
|
+
<ChartCard key="composed" variant={v} title="Placements vs Capacity" description="Monthly fill rate overlay"
|
|
2214
|
+
leoInsight={CHART_GALLERY_LEO_COMPOSED}
|
|
2215
|
+
trendContent={<ComposedLineTrend />}
|
|
2216
|
+
tabOptions={isTabs ? [
|
|
2217
|
+
{ value: "overlay", label: "Overlay" },
|
|
2218
|
+
{ value: "comparison", label: "Side by Side" },
|
|
2219
|
+
{ value: "trend", label: "Trend" },
|
|
2220
|
+
] : undefined}
|
|
2221
|
+
miniMetrics={(isMT || isKpi) ? [
|
|
2222
|
+
{ label: "Fill rate", value: "94%", trend: "up" },
|
|
2223
|
+
{ label: "Capacity", value: "95", trend: "up" },
|
|
2224
|
+
{ label: "Placed", value: "89", trend: "up" },
|
|
2225
|
+
] : undefined}>
|
|
2226
|
+
{isTabs
|
|
2227
|
+
? (tab: string) => tab === "trend" ? <ComposedLineTrend /> : <ComposedChartContent />
|
|
2228
|
+
: <ComposedChartContent />}
|
|
2229
|
+
</ChartCard>
|
|
2230
|
+
)}
|
|
2231
|
+
</div>
|
|
2232
|
+
</div>
|
|
2233
|
+
|
|
2234
|
+
{/* Row 5 · Radar (1/3) + Scatter (2/3) */}
|
|
2235
|
+
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3 items-stretch">
|
|
2236
|
+
<div className="flex flex-col">
|
|
2237
|
+
{isSel ? (
|
|
2238
|
+
<ChartCard key="radar-sel" variant="selector" title="Competency Radar" description="Filter by program"
|
|
2239
|
+
filterOptions={PROGRAM_OPTIONS} defaultFilter="all" leoInsight={CHART_GALLERY_LEO_RADAR}>
|
|
2240
|
+
{() => <RadarChartContent />}
|
|
2241
|
+
</ChartCard>
|
|
2242
|
+
) : (
|
|
2243
|
+
<ChartCard key="radar" variant={v} title="Competency Radar" description="Avg. scores by skill domain"
|
|
2244
|
+
leoInsight={CHART_GALLERY_LEO_RADAR}
|
|
2245
|
+
trendContent={<RadarBarTrend />}
|
|
2246
|
+
tabOptions={isTabs ? [
|
|
2247
|
+
{ value: "radar", label: "Radar" },
|
|
2248
|
+
{ value: "breakdown", label: "Breakdown" },
|
|
2249
|
+
] : undefined}
|
|
2250
|
+
miniMetrics={(isMT || isKpi) ? [
|
|
2251
|
+
{ label: "Avg.", value: "88%", trend: "up" },
|
|
2252
|
+
{ label: "Top", value: "Clinical", trend: "neutral" },
|
|
2253
|
+
] : undefined}>
|
|
2254
|
+
{isTabs
|
|
2255
|
+
? (tab: string) => tab === "breakdown" ? <RadarBarTrend /> : <RadarChartContent />
|
|
2256
|
+
: <RadarChartContent />}
|
|
2257
|
+
</ChartCard>
|
|
2258
|
+
)}
|
|
2259
|
+
</div>
|
|
2260
|
+
<div className="lg:col-span-2 flex flex-col">
|
|
2261
|
+
{isSel ? (
|
|
2262
|
+
<ChartCard key="scatter-sel" variant="selector" title="Site Performance" description="Filter by program"
|
|
2263
|
+
filterOptions={PROGRAM_OPTIONS} defaultFilter="all" leoInsight={CHART_GALLERY_LEO_SCATTER}>
|
|
2264
|
+
{() => <ScatterChartContent />}
|
|
2265
|
+
</ChartCard>
|
|
2266
|
+
) : (
|
|
2267
|
+
<ChartCard key="scatter" variant={v} title="Site Performance" description="Capacity vs. fill rate · bubble = student count"
|
|
2268
|
+
leoInsight={CHART_GALLERY_LEO_SCATTER}
|
|
2269
|
+
trendContent={<ScatterLineTrend />}
|
|
2270
|
+
tabOptions={isTabs ? [
|
|
2271
|
+
{ value: "scatter", label: "Scatter" },
|
|
2272
|
+
{ value: "ranking", label: "Ranking" },
|
|
2273
|
+
{ value: "trend", label: "Trend" },
|
|
2274
|
+
] : undefined}
|
|
2275
|
+
miniMetrics={(isMT || isKpi) ? [
|
|
2276
|
+
{ label: "Sites", value: "12", trend: "up" },
|
|
2277
|
+
{ label: "Avg. rate", value: "87%", trend: "up" },
|
|
2278
|
+
{ label: "Students", value: "320", trend: "up" },
|
|
2279
|
+
] : undefined}>
|
|
2280
|
+
{isTabs
|
|
2281
|
+
? (tab: string) => tab === "trend" ? <ScatterLineTrend /> : <ScatterChartContent />
|
|
2282
|
+
: <ScatterChartContent />}
|
|
2283
|
+
</ChartCard>
|
|
2284
|
+
)}
|
|
2285
|
+
</div>
|
|
2286
|
+
</div>
|
|
2287
|
+
|
|
2288
|
+
{/* Row 6 · Funnel full width */}
|
|
2289
|
+
{isSel ? (
|
|
2290
|
+
<ChartCard key="funnel-sel" variant="selector" title="Application Pipeline" description="Filter by time period"
|
|
2291
|
+
filterOptions={PERIOD_OPTIONS} defaultFilter="90d" leoInsight={CHART_GALLERY_LEO_FUNNEL}>
|
|
2292
|
+
{(f) => <FunnelChartContent data={funnelDataByPeriod[f] ?? funnelData} />}
|
|
2293
|
+
</ChartCard>
|
|
2294
|
+
) : (
|
|
2295
|
+
<ChartCard key="funnel" variant={v} title="Application Pipeline" description="Funnel from application to completed placement"
|
|
2296
|
+
leoInsight={CHART_GALLERY_LEO_FUNNEL}
|
|
2297
|
+
trendContent={<FunnelLineTrend />}
|
|
2298
|
+
miniMetrics={(isMT || isKpi) ? [
|
|
2299
|
+
{ label: "Applied", value: "320", trend: "up" },
|
|
2300
|
+
{ label: "Placed", value: "128", trend: "up" },
|
|
2301
|
+
{ label: "Completed", value: "98", trend: "up" },
|
|
2302
|
+
{ label: "Drop-off", value: "69%", trend: "down" },
|
|
2303
|
+
] : undefined}>
|
|
2304
|
+
<FunnelChartContent />
|
|
2305
|
+
</ChartCard>
|
|
2306
|
+
)}
|
|
2307
|
+
</>
|
|
2308
|
+
)
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
/* ════════════════════════════════════════════════════════════════════════════
|
|
2312
|
+
Main export
|
|
2313
|
+
════════════════════════════════════════════════════════════════════════════ */
|
|
2314
|
+
|
|
2315
|
+
export function ChartsOverview({ variant = "normal" }: { variant?: ChartCardVariant }) {
|
|
2316
|
+
return (
|
|
2317
|
+
<div className="flex flex-col gap-4 px-4 pb-2 lg:px-6">
|
|
2318
|
+
<ChartRows v={variant} />
|
|
2319
|
+
</div>
|
|
2320
|
+
)
|
|
2321
|
+
}
|