@exxatdesignux/ui 0.3.0 → 0.4.1
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/CHANGELOG.md +701 -6
- package/README.md +138 -0
- package/bin/init.mjs +134 -31
- package/consumer-extras/cursor-rules/exxat-board-cards.mdc +1 -1
- package/consumer-extras/cursor-rules/exxat-centralized-list-dataset.mdc +2 -2
- package/consumer-extras/cursor-rules/exxat-collaboration-access.mdc +1 -1
- package/consumer-extras/cursor-rules/exxat-data-tables.mdc +2 -0
- package/consumer-extras/cursor-rules/exxat-dedicated-search-surfaces.mdc +1 -1
- package/consumer-extras/cursor-rules/exxat-ds-agents.mdc +3 -3
- package/consumer-extras/cursor-rules/exxat-library-hub-header.mdc +28 -0
- package/consumer-extras/cursor-rules/exxat-mono-ids.mdc +1 -1
- package/consumer-extras/cursor-rules/exxat-person-identity-display.mdc +1 -1
- package/consumer-extras/cursor-rules/exxat-primary-nav-secondary-panel.mdc +6 -6
- package/consumer-extras/cursor-rules/exxat-reuse-before-custom.mdc +1 -1
- package/consumer-extras/cursor-skills/exxat-board-cards/SKILL.md +2 -2
- package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +1 -1
- package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -3
- package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +2 -2
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +7 -7
- package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +1 -1
- package/consumer-extras/cursor-skills/exxat-list-page-view-shells/SKILL.md +1 -1
- package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +4 -4
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +8 -8
- package/consumer-extras/cursor-skills/exxat-token-economy/SKILL.md +277 -0
- package/consumer-extras/handbook/HANDBOOK.md +2 -0
- package/consumer-extras/handbook/glossary.md +2 -1
- package/consumer-extras/handbook/reference-implementations.md +31 -4
- package/consumer-extras/patterns/collaboration-access-pattern.md +7 -7
- package/consumer-extras/patterns/data-views-pattern.md +18 -16
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +2 -2
- package/dist/components/data-table/index.js +2 -2
- package/dist/components/data-table/index.js.map +1 -1
- package/dist/components/data-table/pagination.js +3 -3
- package/dist/components/data-table/pagination.js.map +1 -1
- package/dist/components/data-table/use-table-state.d.ts +1 -1
- package/dist/components/data-table/use-table-state.js.map +1 -1
- package/dist/components/data-views/data-row-list.js.map +1 -1
- package/dist/components/data-views/finder-panel-view.d.ts +1 -1
- package/dist/components/data-views/finder-panel-view.js.map +1 -1
- package/dist/components/data-views/hub-table.d.ts +9 -3
- package/dist/components/data-views/hub-table.js +262 -40
- package/dist/components/data-views/hub-table.js.map +1 -1
- package/dist/components/data-views/index.js +262 -40
- package/dist/components/data-views/index.js.map +1 -1
- package/dist/components/data-views/list-page-split-hub-tokens.d.ts +2 -2
- package/dist/components/data-views/list-page-split-hub-tokens.js.map +1 -1
- package/dist/components/data-views/list-page-tree-column-header.d.ts +1 -1
- package/dist/components/data-views/list-page-tree-column-header.js.map +1 -1
- package/dist/components/data-views/list-page-tree-panel-shell.js.map +1 -1
- package/dist/components/data-views/os-folder-glyph.d.ts +1 -1
- package/dist/components/data-views/os-folder-glyph.js.map +1 -1
- package/dist/components/ui/avatar.d.ts +1 -1
- package/dist/components/ui/key-metrics.js.map +1 -1
- package/dist/index.js +136 -39
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/components/data-table/index.tsx +2 -2
- package/src/components/data-table/pagination.tsx +5 -1
- package/src/components/data-table/use-table-state.ts +1 -1
- package/src/components/data-views/data-row-list.tsx +1 -1
- package/src/components/data-views/finder-panel-view.tsx +2 -2
- package/src/components/data-views/hub-table.tsx +149 -41
- package/src/components/data-views/list-page-split-hub-tokens.ts +2 -2
- package/src/components/data-views/list-page-tree-column-header.tsx +1 -1
- package/src/components/data-views/os-folder-glyph.tsx +1 -1
- package/src/components/ui/key-metrics.tsx +1 -1
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +8 -7
- package/template/.cursor/rules/exxat-accessibility.mdc +1 -1
- package/template/.cursor/rules/exxat-command-menu.mdc +1 -1
- package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +6 -6
- package/template/.cursor/rules/exxat-data-tables.mdc +3 -3
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +5 -5
- package/template/.cursor/rules/exxat-mono-ids.mdc +1 -1
- package/template/.cursor/rules/exxat-page-vs-drawer.mdc +1 -1
- package/template/.cursor/rules/exxat-table-properties-drawer.mdc +1 -1
- package/template/AGENTS.md +43 -37
- package/template/app/(app)/columns/page.tsx +11 -0
- package/template/app/(app)/library/all/page.tsx +11 -0
- package/template/app/(app)/library/find/page.tsx +12 -0
- package/template/app/(app)/{question-bank → library}/layout.tsx +16 -16
- package/template/app/(app)/library/list/page.tsx +12 -0
- package/template/app/(app)/{question-bank → library}/new/page.tsx +10 -10
- package/template/app/(app)/library/page.tsx +11 -0
- package/template/app/(app)/tokens-themes/page.tsx +11 -0
- package/template/components/ask-leo-composer.tsx +2 -2
- package/template/components/columns-client.tsx +158 -0
- package/template/components/columns-showcase.tsx +541 -0
- package/template/components/data-views/index.ts +32 -6
- package/template/components/data-views/{question-bank-folder-tree-branch.tsx → library-folder-tree-branch.tsx} +19 -19
- package/template/components/data-views/table-cells.tsx +673 -0
- package/template/components/folder-details-shell.tsx +11 -11
- package/template/components/hub-tree-panel-view.tsx +24 -24
- package/template/components/{question-bank-board-view.tsx → library-board-view.tsx} +44 -44
- package/template/components/{question-bank-client.tsx → library-client.tsx} +82 -82
- package/template/components/{question-bank-dashboard-charts.tsx → library-dashboard-charts.tsx} +14 -14
- package/template/components/{question-bank-favorite-button.tsx → library-favorite-button.tsx} +7 -7
- package/template/components/{question-bank-hub-client.tsx → library-hub-client.tsx} +43 -43
- package/template/components/{question-bank-new-folder-sheet.tsx → library-new-folder-sheet.tsx} +14 -14
- package/template/components/{question-bank-os-folder-view.tsx → library-os-folder-view.tsx} +31 -31
- package/template/components/{question-bank-page-header.tsx → library-page-header.tsx} +6 -6
- package/template/components/library-panel-activator.tsx +8 -0
- package/template/components/{question-bank-secondary-nav.tsx → library-secondary-nav.tsx} +60 -60
- package/template/components/{question-bank-table.tsx → library-table.tsx} +97 -97
- package/template/components/list-hub-status-badge.tsx +2 -2
- package/template/components/{new-question-composer.tsx → new-library-item-form.tsx} +37 -37
- package/template/components/sidebar/app-sidebar.tsx +61 -5
- package/template/components/sidebar/secondary-panel.tsx +109 -56
- package/template/components/sidebar/sidebar-auto-collapse.tsx +2 -2
- package/template/components/sidebar/sidebar-auto-open.tsx +2 -1
- package/template/components/table-properties/types.ts +1 -1
- package/template/components/templates/discovery-hub-template.tsx +1 -1
- package/template/components/templates/new-focus-template.tsx +2 -2
- package/template/components/templates/secondary-panel-hub-template.tsx +1 -1
- package/template/components/tokens-secondary-nav.tsx +192 -0
- package/template/components/tokens-themes-client.tsx +476 -0
- package/template/components/tokens-themes-section.tsx +386 -0
- package/template/docs/HANDBOOK.md +187 -0
- package/template/docs/blueprints/README.md +1 -1
- package/template/docs/blueprints/board-card.md +1 -1
- package/template/docs/blueprints/data-table.md +2 -2
- package/template/docs/blueprints/list-page-template.md +3 -3
- package/template/docs/blueprints/page-header.md +4 -4
- package/template/docs/collaboration-access-pattern.md +7 -7
- package/template/docs/component-selection-guide.md +1 -1
- package/template/docs/data-views-pattern.md +18 -16
- package/template/docs/glossary.md +58 -0
- package/template/docs/kpi-flat-band-pattern.md +3 -3
- package/template/docs/kpi-trend-pattern.md +18 -3
- package/template/docs/large-dataset-strategy.md +155 -0
- package/template/docs/library-hub-header-pattern.md +25 -0
- package/template/docs/migrations/_template.md +1 -1
- package/template/docs/reference-implementations.md +151 -0
- package/template/docs/token-taxonomy.md +1 -1
- package/template/docs/voice-and-tone.md +262 -0
- package/template/eslint.config.mjs +9 -39
- package/template/hooks/use-secondary-panel-hub-nav.ts +10 -10
- package/template/lib/ask-leo-route-context.ts +6 -18
- package/template/lib/coach-mark-registry.ts +0 -16
- package/template/lib/command-menu-config.ts +5 -12
- package/template/lib/command-menu-search-data.ts +8 -39
- package/template/lib/{question-bank-authoring.ts → library-authoring.ts} +89 -88
- package/template/lib/library-dedicated-search.ts +19 -0
- package/template/lib/library-hub-search.ts +90 -0
- package/template/lib/library-nav.ts +477 -0
- package/template/lib/library-recent-searches.ts +22 -0
- package/template/lib/{placements-supported-views.ts → library-supported-views.ts} +2 -2
- package/template/lib/list-status-badges.ts +16 -104
- package/template/lib/mock/dashboard.ts +1 -1
- package/template/lib/mock/{question-bank-folders.ts → library-folders.ts} +30 -30
- package/template/lib/mock/library-header-collaborators.ts +54 -0
- package/template/lib/mock/{question-bank-inspector.ts → library-inspector.ts} +29 -29
- package/template/lib/mock/{question-bank-kpi.ts → library-kpi.ts} +20 -20
- package/template/lib/mock/library.ts +249 -0
- package/template/lib/mock/navigation.tsx +32 -26
- package/template/lib/table-state-lifecycle.ts +1 -1
- package/template/next.config.mjs +7 -4
- package/template/package.json +0 -1
- package/tokens/hooks-index.json +2874 -0
- package/consumer-extras/cursor-rules/exxat-question-bank-hub-header.mdc +0 -28
- package/template/app/(app)/examples/page.tsx +0 -41
- package/template/app/(app)/question-bank/find/page.tsx +0 -12
- package/template/app/(app)/question-bank/library/page.tsx +0 -11
- package/template/app/(app)/question-bank/list/page.tsx +0 -12
- package/template/app/(app)/question-bank/page.tsx +0 -11
- package/template/components/compliance-board-view.tsx +0 -142
- package/template/components/compliance-client.tsx +0 -92
- package/template/components/compliance-page-header.tsx +0 -89
- package/template/components/compliance-table.tsx +0 -468
- package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
- package/template/components/data-view-dashboard-charts-team.tsx +0 -971
- package/template/components/data-view-dashboard-charts.tsx +0 -1503
- package/template/components/new-placement-back-btn.tsx +0 -28
- package/template/components/new-placement-form.tsx +0 -942
- package/template/components/placement-board-card.tsx +0 -250
- package/template/components/placement-detail.tsx +0 -438
- package/template/components/placements-board-view.tsx +0 -397
- package/template/components/placements-client.tsx +0 -220
- package/template/components/placements-list-view.tsx +0 -124
- package/template/components/placements-page-header.tsx +0 -166
- package/template/components/placements-table-cells.test.tsx +0 -22
- package/template/components/placements-table-cells.tsx +0 -173
- package/template/components/placements-table-columns.tsx +0 -210
- package/template/components/placements-table.tsx +0 -934
- package/template/components/question-bank-panel-activator.tsx +0 -8
- package/template/components/rotations-empty-state.tsx +0 -50
- package/template/components/rotations-panel-activator.tsx +0 -8
- package/template/components/sites-board-view.tsx +0 -67
- package/template/components/sites-client.tsx +0 -154
- package/template/components/sites-table.tsx +0 -249
- package/template/components/team-board-view.tsx +0 -122
- package/template/components/team-client.tsx +0 -100
- package/template/components/team-page-header.tsx +0 -92
- package/template/components/team-table.tsx +0 -553
- package/template/docs/question-bank-hub-header-pattern.md +0 -25
- package/template/lib/compliance-supported-views.ts +0 -10
- package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
- package/template/lib/mock/compliance-kpi.ts +0 -61
- package/template/lib/mock/compliance.ts +0 -146
- package/template/lib/mock/placements-kpi.ts +0 -134
- package/template/lib/mock/placements.ts +0 -176
- package/template/lib/mock/question-bank-header-collaborators.ts +0 -54
- package/template/lib/mock/question-bank.ts +0 -249
- package/template/lib/mock/sites-directory.ts +0 -16
- package/template/lib/mock/sites-kpi.ts +0 -25
- package/template/lib/mock/team-kpi.ts +0 -60
- package/template/lib/mock/team.ts +0 -118
- package/template/lib/placement-board-card-layout.ts +0 -79
- package/template/lib/question-bank-dedicated-search.ts +0 -19
- package/template/lib/question-bank-hub-search.ts +0 -90
- package/template/lib/question-bank-nav.ts +0 -477
- package/template/lib/question-bank-recent-searches.ts +0 -22
- package/template/lib/question-bank-supported-views.ts +0 -12
- package/template/lib/sites-supported-views.ts +0 -10
- package/template/lib/team-supported-views.ts +0 -10
|
@@ -1,1503 +0,0 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Data view dashboard charts — contextual charts for the **Data** view tab (Placements hub).
|
|
5
|
-
*
|
|
6
|
-
* Tells a story about the placements data:
|
|
7
|
-
* 1. Status pipeline — where are placements in the workflow?
|
|
8
|
-
* 2. Program mix — which programs have the most activity?
|
|
9
|
-
* 3. Compliance — how ready are upcoming placements?
|
|
10
|
-
* 4. Progress tracker — how far along are ongoing placements?
|
|
11
|
-
* 5. Site utilisation — which sites are busiest?
|
|
12
|
-
* 6. Readiness — how prepared are students?
|
|
13
|
-
* 7. Completion outcomes — pass rate + ratings for completed placements
|
|
14
|
-
* 8. Timeline — upcoming start dates over the next 8 weeks
|
|
15
|
-
*
|
|
16
|
-
* Each chart is a `DashboardCard` — users can show/hide, reorder, change
|
|
17
|
-
* column span, and switch chart type from the canvas (edit layout mode).
|
|
18
|
-
*
|
|
19
|
-
* ── WCAG AA ────────────────────────────────────────────────────────────────
|
|
20
|
-
* Every chart is wrapped in `<ChartFigure>` (keyboard navigable, announced)
|
|
21
|
-
* and includes `<ChartDataTable>` (sr-only fallback table) — matching the
|
|
22
|
-
* accessibility pattern established in `charts-overview.tsx`.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import * as React from "react"
|
|
26
|
-
import {
|
|
27
|
-
closestCorners,
|
|
28
|
-
DndContext,
|
|
29
|
-
type DragEndEvent,
|
|
30
|
-
KeyboardSensor,
|
|
31
|
-
PointerSensor,
|
|
32
|
-
useSensor,
|
|
33
|
-
useSensors,
|
|
34
|
-
} from "@dnd-kit/core"
|
|
35
|
-
import {
|
|
36
|
-
arrayMove,
|
|
37
|
-
rectSortingStrategy,
|
|
38
|
-
SortableContext,
|
|
39
|
-
sortableKeyboardCoordinates,
|
|
40
|
-
useSortable,
|
|
41
|
-
} from "@dnd-kit/sortable"
|
|
42
|
-
import { CSS } from "@dnd-kit/utilities"
|
|
43
|
-
import {
|
|
44
|
-
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis,
|
|
45
|
-
Area, AreaChart, RadialBar, RadialBarChart, Line, LineChart,
|
|
46
|
-
} from "recharts"
|
|
47
|
-
import {
|
|
48
|
-
ChartCard,
|
|
49
|
-
ChartFigure,
|
|
50
|
-
ChartDataTable,
|
|
51
|
-
ChartLeoPlotInsightOverlay,
|
|
52
|
-
type ChartLeoInsight,
|
|
53
|
-
} from "@/components/charts-overview"
|
|
54
|
-
import { useChartVariant } from "@/contexts/chart-variant-context"
|
|
55
|
-
import { KeyMetrics, type MetricInsight, type MetricItem } from "@/components/key-metrics"
|
|
56
|
-
import { Button } from "@/components/ui/button"
|
|
57
|
-
import {
|
|
58
|
-
DropdownMenu,
|
|
59
|
-
DropdownMenuContent,
|
|
60
|
-
DropdownMenuItem,
|
|
61
|
-
DropdownMenuTrigger,
|
|
62
|
-
} from "@/components/ui/dropdown-menu"
|
|
63
|
-
import { ViewSegmentedControl } from "@/components/ui/view-segmented-control"
|
|
64
|
-
import { Tip } from "@/components/ui/tip"
|
|
65
|
-
import { DragHandleGripIcon } from "@/components/ui/drag-handle-grip"
|
|
66
|
-
import {
|
|
67
|
-
KEY_METRICS_KPI_COUNT_DEFAULT,
|
|
68
|
-
KEY_METRICS_KPI_COUNT_MAX,
|
|
69
|
-
KEY_METRICS_KPI_COUNT_MIN,
|
|
70
|
-
} from "@/lib/dashboard-layout-merge"
|
|
71
|
-
import {
|
|
72
|
-
type ChartType,
|
|
73
|
-
type DashboardCardDef,
|
|
74
|
-
KEY_METRICS_CARD_ID,
|
|
75
|
-
ALL_DASHBOARD_CARDS,
|
|
76
|
-
DEFAULT_SPANS,
|
|
77
|
-
DEFAULT_CHART_TYPES,
|
|
78
|
-
applyVisibleReorder,
|
|
79
|
-
} from "@/lib/data-view-dashboard-placements-layout"
|
|
80
|
-
import { cn } from "@/lib/utils"
|
|
81
|
-
import {
|
|
82
|
-
ChartContainer,
|
|
83
|
-
ChartTooltip,
|
|
84
|
-
chartTooltipKeyboardSyncProps,
|
|
85
|
-
ChartTooltipContent,
|
|
86
|
-
type ChartConfig,
|
|
87
|
-
} from "@/components/ui/chart"
|
|
88
|
-
import type { Placement } from "@/lib/mock/placements"
|
|
89
|
-
import {
|
|
90
|
-
CHART_KBD_ACTIVE_BAR,
|
|
91
|
-
CHART_KBD_ACTIVE_PIE_SHAPE,
|
|
92
|
-
} from "@/lib/chart-keyboard-selection"
|
|
93
|
-
|
|
94
|
-
export type { ChartType, ChartTypeOption, DashboardCardDef, DashboardLayout } from "@/lib/data-view-dashboard-placements-layout"
|
|
95
|
-
export {
|
|
96
|
-
KEY_METRICS_CARD_ID,
|
|
97
|
-
ALL_DASHBOARD_CARDS,
|
|
98
|
-
DEFAULT_VISIBLE_CARDS,
|
|
99
|
-
DEFAULT_SPANS,
|
|
100
|
-
DEFAULT_CHART_TYPES,
|
|
101
|
-
loadDashboardLayout,
|
|
102
|
-
mergeDashboardLayout,
|
|
103
|
-
saveDashboardLayout,
|
|
104
|
-
applyVisibleReorder,
|
|
105
|
-
} from "@/lib/data-view-dashboard-placements-layout"
|
|
106
|
-
|
|
107
|
-
/* ── Chart colour tokens ───────────────────────────────────────────────── */
|
|
108
|
-
|
|
109
|
-
const STATUS_COLORS: Record<string, string> = {
|
|
110
|
-
confirmed: "var(--color-chart-2)",
|
|
111
|
-
pending: "var(--color-chart-4)",
|
|
112
|
-
"under-review": "var(--color-chart-3)",
|
|
113
|
-
rejected: "var(--destructive)",
|
|
114
|
-
completed: "var(--primary)",
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const COMPLIANCE_COLORS: Record<string, string> = {
|
|
118
|
-
Complete: "var(--color-chart-2)",
|
|
119
|
-
"In progress": "var(--color-chart-3)",
|
|
120
|
-
"Pending documents": "var(--color-chart-4)",
|
|
121
|
-
Review: "var(--color-chart-1)",
|
|
122
|
-
Incomplete: "var(--destructive)",
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const READINESS_COLORS: Record<string, string> = {
|
|
126
|
-
Ready: "var(--color-chart-2)",
|
|
127
|
-
"In review": "var(--color-chart-3)",
|
|
128
|
-
"At risk": "var(--color-chart-4)",
|
|
129
|
-
Blocked: "var(--destructive)",
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/* ── Chart configs ─────────────────────────────────────────────────────── */
|
|
133
|
-
|
|
134
|
-
const BAR_CFG: ChartConfig = { value: { label: "Placements", color: "var(--primary)" } }
|
|
135
|
-
const AREA_CFG: ChartConfig = { count: { label: "Starting", color: "var(--color-chart-1)" } }
|
|
136
|
-
|
|
137
|
-
const CHART_MARGIN = { top: 8, right: 8, left: 0, bottom: 0 } as const
|
|
138
|
-
const CHART_MARGIN_HORIZONTAL = { top: 8, right: 8, left: 4, bottom: 0 } as const
|
|
139
|
-
|
|
140
|
-
const PALETTE_COLORS = [
|
|
141
|
-
"var(--color-chart-1)",
|
|
142
|
-
"var(--color-chart-2)",
|
|
143
|
-
"var(--color-chart-3)",
|
|
144
|
-
"var(--color-chart-4)",
|
|
145
|
-
"var(--color-chart-5)",
|
|
146
|
-
] as const
|
|
147
|
-
|
|
148
|
-
/** Demo Leo “smart scan” copy per chart (swap for model output when wired). */
|
|
149
|
-
const PLACEMENTS_CHART_LEO_INSIGHTS: Partial<Record<string, ChartLeoInsight>> = {
|
|
150
|
-
"status-pipeline": {
|
|
151
|
-
headline: "Under review is stacking up",
|
|
152
|
-
explanation:
|
|
153
|
-
"A larger share of placements sits in under-review than in confirmed. That usually means paperwork or coordinator capacity is lagging, and start dates can slip if the queue keeps growing.",
|
|
154
|
-
},
|
|
155
|
-
"compliance-status": {
|
|
156
|
-
headline: "Pending documents cluster",
|
|
157
|
-
explanation:
|
|
158
|
-
"Several items still show pending documents. Gaps close to rotation dates are the most common reason placements get paused—worth prioritizing the oldest due dates first.",
|
|
159
|
-
},
|
|
160
|
-
"site-utilisation": {
|
|
161
|
-
headline: "Load may be uneven across sites",
|
|
162
|
-
explanation:
|
|
163
|
-
"A few sites carry most of the volume while others are light. That can hide fatigue or preceptor limits on the busiest sites even when totals look fine.",
|
|
164
|
-
},
|
|
165
|
-
"upcoming-timeline": {
|
|
166
|
-
headline: "Starts bunch in the same window",
|
|
167
|
-
explanation:
|
|
168
|
-
"Multiple placements begin in adjacent weeks. Check orientation, onboarding staff, and site capacity so you are not compressing the same resources.",
|
|
169
|
-
anchor: {
|
|
170
|
-
xValue: "This week",
|
|
171
|
-
yDataKeys: ["count"],
|
|
172
|
-
yCombine: "max",
|
|
173
|
-
},
|
|
174
|
-
},
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/* ── Individual chart renderers (ChartFigure + ChartDataTable from charts-overview) ─ */
|
|
178
|
-
/* Keyboard highlight: `CHART_KBD_*` — same ring-on-active pattern as `charts-overview`. */
|
|
179
|
-
|
|
180
|
-
function StatusPipelineChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
|
|
181
|
-
const data = React.useMemo(() => {
|
|
182
|
-
const map = new Map<string, number>()
|
|
183
|
-
for (const r of rows) map.set(r.status, (map.get(r.status) ?? 0) + 1)
|
|
184
|
-
return ["confirmed", "pending", "under-review", "completed", "rejected"]
|
|
185
|
-
.filter(s => map.has(s))
|
|
186
|
-
.map(s => ({
|
|
187
|
-
name: s === "under-review" ? "Under Review" : s.charAt(0).toUpperCase() + s.slice(1),
|
|
188
|
-
value: map.get(s)!,
|
|
189
|
-
fill: STATUS_COLORS[s] ?? "var(--primary)",
|
|
190
|
-
}))
|
|
191
|
-
}, [rows])
|
|
192
|
-
|
|
193
|
-
if (data.length === 0) return <EmptyChart />
|
|
194
|
-
|
|
195
|
-
const summary = `${data.length} status categories. Largest: ${data.reduce((a, b) => a.value > b.value ? a : b).name} with ${data.reduce((a, b) => a.value > b.value ? a : b).value} placements.`
|
|
196
|
-
|
|
197
|
-
return (
|
|
198
|
-
<ChartFigure label="Status Pipeline" summary={summary} dataLength={data.length}>
|
|
199
|
-
{(activeIndex) => (
|
|
200
|
-
<>
|
|
201
|
-
{chartType === "pie" ? (
|
|
202
|
-
<PieChartRenderer data={data} activeIndex={activeIndex} />
|
|
203
|
-
) : chartType === "horizontal-bar" ? (
|
|
204
|
-
<HBarChartRenderer data={data} colored activeIndex={activeIndex} />
|
|
205
|
-
) : (
|
|
206
|
-
<ChartContainer config={BAR_CFG} className="h-[220px] w-full">
|
|
207
|
-
<BarChart data={data} margin={CHART_MARGIN}>
|
|
208
|
-
<CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
|
|
209
|
-
<XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
210
|
-
<YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
211
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
212
|
-
<Bar
|
|
213
|
-
dataKey="value"
|
|
214
|
-
radius={[4, 4, 0, 0]}
|
|
215
|
-
maxBarSize={48}
|
|
216
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
217
|
-
activeIndex={activeIndex ?? undefined}
|
|
218
|
-
>
|
|
219
|
-
{data.map((d, i) => (
|
|
220
|
-
<Cell key={i} fill={d.fill} />
|
|
221
|
-
))}
|
|
222
|
-
</Bar>
|
|
223
|
-
</BarChart>
|
|
224
|
-
</ChartContainer>
|
|
225
|
-
)}
|
|
226
|
-
<ChartDataTable
|
|
227
|
-
caption="Status Pipeline data"
|
|
228
|
-
headers={["Status", "Placements"]}
|
|
229
|
-
rows={data.map(d => [d.name, d.value])}
|
|
230
|
-
/>
|
|
231
|
-
</>
|
|
232
|
-
)}
|
|
233
|
-
</ChartFigure>
|
|
234
|
-
)
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function ProgramMixChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
|
|
238
|
-
const data = React.useMemo(() => {
|
|
239
|
-
const map = new Map<string, number>()
|
|
240
|
-
for (const r of rows) map.set(r.program, (map.get(r.program) ?? 0) + 1)
|
|
241
|
-
return [...map.entries()]
|
|
242
|
-
.map(([name, value]) => ({ name, value }))
|
|
243
|
-
.sort((a, b) => b.value - a.value)
|
|
244
|
-
}, [rows])
|
|
245
|
-
|
|
246
|
-
const colors = PALETTE_COLORS
|
|
247
|
-
const coloredData = React.useMemo(
|
|
248
|
-
() => data.map((d, i) => ({ ...d, fill: colors[i % colors.length] })),
|
|
249
|
-
[data, colors],
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
if (data.length === 0) return <EmptyChart />
|
|
253
|
-
|
|
254
|
-
const summary = `${data.length} programs. Largest: ${data[0].name} with ${data[0].value} placements.`
|
|
255
|
-
|
|
256
|
-
return (
|
|
257
|
-
<ChartFigure label="Placements by Program" summary={summary} dataLength={data.length}>
|
|
258
|
-
{(activeIndex) => (
|
|
259
|
-
<>
|
|
260
|
-
{chartType === "bar" ? (
|
|
261
|
-
<ChartContainer config={BAR_CFG} className="h-[220px] w-full">
|
|
262
|
-
<BarChart data={coloredData} margin={CHART_MARGIN}>
|
|
263
|
-
<CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
|
|
264
|
-
<XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
265
|
-
<YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
266
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
267
|
-
<Bar
|
|
268
|
-
dataKey="value"
|
|
269
|
-
radius={[4, 4, 0, 0]}
|
|
270
|
-
maxBarSize={48}
|
|
271
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
272
|
-
activeIndex={activeIndex ?? undefined}
|
|
273
|
-
>
|
|
274
|
-
{coloredData.map((d, i) => (
|
|
275
|
-
<Cell key={i} fill={d.fill} />
|
|
276
|
-
))}
|
|
277
|
-
</Bar>
|
|
278
|
-
</BarChart>
|
|
279
|
-
</ChartContainer>
|
|
280
|
-
) : chartType === "horizontal-bar" ? (
|
|
281
|
-
<ChartContainer config={BAR_CFG} className="h-[220px] w-full">
|
|
282
|
-
<BarChart data={coloredData} layout="vertical" margin={CHART_MARGIN_HORIZONTAL}>
|
|
283
|
-
<CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
|
|
284
|
-
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
285
|
-
<YAxis type="category" dataKey="name" width={100} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
286
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
287
|
-
<Bar
|
|
288
|
-
dataKey="value"
|
|
289
|
-
radius={[0, 4, 4, 0]}
|
|
290
|
-
maxBarSize={22}
|
|
291
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
292
|
-
activeIndex={activeIndex ?? undefined}
|
|
293
|
-
>
|
|
294
|
-
{coloredData.map((d, i) => (
|
|
295
|
-
<Cell key={i} fill={d.fill} />
|
|
296
|
-
))}
|
|
297
|
-
</Bar>
|
|
298
|
-
</BarChart>
|
|
299
|
-
</ChartContainer>
|
|
300
|
-
) : (
|
|
301
|
-
<PieChartRenderer data={coloredData} activeIndex={activeIndex} />
|
|
302
|
-
)}
|
|
303
|
-
<ChartDataTable
|
|
304
|
-
caption="Placements by Program data"
|
|
305
|
-
headers={["Program", "Placements"]}
|
|
306
|
-
rows={data.map(d => [d.name, d.value])}
|
|
307
|
-
/>
|
|
308
|
-
</>
|
|
309
|
-
)}
|
|
310
|
-
</ChartFigure>
|
|
311
|
-
)
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function ComplianceChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
|
|
315
|
-
const upcoming = rows.filter(r => r.placementPhase === "upcoming")
|
|
316
|
-
const data = React.useMemo(() => {
|
|
317
|
-
const map = new Map<string, number>()
|
|
318
|
-
for (const r of upcoming) map.set(r.compliance, (map.get(r.compliance) ?? 0) + 1)
|
|
319
|
-
return ["Complete", "In progress", "Pending documents", "Review", "Incomplete"]
|
|
320
|
-
.filter(s => map.has(s))
|
|
321
|
-
.map(s => ({
|
|
322
|
-
name: s,
|
|
323
|
-
value: map.get(s)!,
|
|
324
|
-
fill: COMPLIANCE_COLORS[s] ?? "var(--primary)",
|
|
325
|
-
}))
|
|
326
|
-
}, [upcoming])
|
|
327
|
-
|
|
328
|
-
if (data.length === 0) return <EmptyChart message="No upcoming placements to show compliance." />
|
|
329
|
-
|
|
330
|
-
const summary = `${data.length} compliance states. ${data.map(d => `${d.name}: ${d.value}`).join(", ")}.`
|
|
331
|
-
|
|
332
|
-
return (
|
|
333
|
-
<ChartFigure label="Compliance Status" summary={summary} dataLength={data.length}>
|
|
334
|
-
{(activeIndex) => (
|
|
335
|
-
<>
|
|
336
|
-
{chartType === "bar" ? (
|
|
337
|
-
<ChartContainer config={BAR_CFG} className="h-[220px] w-full">
|
|
338
|
-
<BarChart data={data} margin={CHART_MARGIN}>
|
|
339
|
-
<CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
|
|
340
|
-
<XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
341
|
-
<YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
342
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
343
|
-
<Bar
|
|
344
|
-
dataKey="value"
|
|
345
|
-
radius={[4, 4, 0, 0]}
|
|
346
|
-
maxBarSize={48}
|
|
347
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
348
|
-
activeIndex={activeIndex ?? undefined}
|
|
349
|
-
>
|
|
350
|
-
{data.map((d, i) => (
|
|
351
|
-
<Cell key={i} fill={d.fill} />
|
|
352
|
-
))}
|
|
353
|
-
</Bar>
|
|
354
|
-
</BarChart>
|
|
355
|
-
</ChartContainer>
|
|
356
|
-
) : chartType === "pie" ? (
|
|
357
|
-
<PieChartRenderer data={data} activeIndex={activeIndex} />
|
|
358
|
-
) : (
|
|
359
|
-
<HBarChartRenderer data={data} colored activeIndex={activeIndex} />
|
|
360
|
-
)}
|
|
361
|
-
<ChartDataTable
|
|
362
|
-
caption="Compliance Status data"
|
|
363
|
-
headers={["Status", "Placements"]}
|
|
364
|
-
rows={data.map(d => [d.name, d.value])}
|
|
365
|
-
/>
|
|
366
|
-
</>
|
|
367
|
-
)}
|
|
368
|
-
</ChartFigure>
|
|
369
|
-
)
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
function ReadinessChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
|
|
373
|
-
const upcoming = rows.filter(r => r.placementPhase === "upcoming")
|
|
374
|
-
const data = React.useMemo(() => {
|
|
375
|
-
const map = new Map<string, number>()
|
|
376
|
-
for (const r of upcoming) map.set(r.readiness, (map.get(r.readiness) ?? 0) + 1)
|
|
377
|
-
return ["Ready", "In review", "At risk", "Blocked"]
|
|
378
|
-
.filter(s => map.has(s))
|
|
379
|
-
.map(s => ({
|
|
380
|
-
name: s,
|
|
381
|
-
value: map.get(s)!,
|
|
382
|
-
fill: READINESS_COLORS[s] ?? "var(--primary)",
|
|
383
|
-
}))
|
|
384
|
-
}, [upcoming])
|
|
385
|
-
|
|
386
|
-
if (data.length === 0) return <EmptyChart message="No upcoming placements to show readiness." />
|
|
387
|
-
|
|
388
|
-
const summary = `${data.length} readiness states. ${data.map(d => `${d.name}: ${d.value}`).join(", ")}.`
|
|
389
|
-
|
|
390
|
-
return (
|
|
391
|
-
<ChartFigure label="Student Readiness" summary={summary} dataLength={data.length}>
|
|
392
|
-
{(activeIndex) => (
|
|
393
|
-
<>
|
|
394
|
-
{chartType === "pie" ? (
|
|
395
|
-
<PieChartRenderer data={data} activeIndex={activeIndex} />
|
|
396
|
-
) : chartType === "horizontal-bar" ? (
|
|
397
|
-
<HBarChartRenderer data={data} colored activeIndex={activeIndex} />
|
|
398
|
-
) : (
|
|
399
|
-
<ChartContainer config={BAR_CFG} className="h-[220px] w-full">
|
|
400
|
-
<BarChart data={data} margin={CHART_MARGIN}>
|
|
401
|
-
<CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
|
|
402
|
-
<XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
403
|
-
<YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
404
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
405
|
-
<Bar
|
|
406
|
-
dataKey="value"
|
|
407
|
-
radius={[4, 4, 0, 0]}
|
|
408
|
-
maxBarSize={48}
|
|
409
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
410
|
-
activeIndex={activeIndex ?? undefined}
|
|
411
|
-
>
|
|
412
|
-
{data.map((d, i) => (
|
|
413
|
-
<Cell key={i} fill={d.fill} />
|
|
414
|
-
))}
|
|
415
|
-
</Bar>
|
|
416
|
-
</BarChart>
|
|
417
|
-
</ChartContainer>
|
|
418
|
-
)}
|
|
419
|
-
<ChartDataTable
|
|
420
|
-
caption="Student Readiness data"
|
|
421
|
-
headers={["State", "Placements"]}
|
|
422
|
-
rows={data.map(d => [d.name, d.value])}
|
|
423
|
-
/>
|
|
424
|
-
</>
|
|
425
|
-
)}
|
|
426
|
-
</ChartFigure>
|
|
427
|
-
)
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function ProgressTrackerChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
|
|
431
|
-
const ongoing = rows.filter(r => r.placementPhase === "ongoing")
|
|
432
|
-
const data = React.useMemo(() => {
|
|
433
|
-
return ongoing.map(r => ({
|
|
434
|
-
name: r.student.split(" ")[0],
|
|
435
|
-
done: r.progressWeeksDone,
|
|
436
|
-
remaining: r.progressWeeksTotal - r.progressWeeksDone,
|
|
437
|
-
pct: Math.round((r.progressWeeksDone / r.progressWeeksTotal) * 100),
|
|
438
|
-
}))
|
|
439
|
-
}, [ongoing])
|
|
440
|
-
|
|
441
|
-
if (data.length === 0) return <EmptyChart message="No ongoing placements to track progress." />
|
|
442
|
-
|
|
443
|
-
const avgPct = Math.round(data.reduce((s, d) => s + d.pct, 0) / data.length)
|
|
444
|
-
const summary = `${data.length} ongoing placements. Average progress: ${avgPct}%.`
|
|
445
|
-
|
|
446
|
-
const cfg: ChartConfig = {
|
|
447
|
-
done: { label: "Completed", color: "var(--color-chart-2)" },
|
|
448
|
-
remaining: { label: "Remaining", color: "var(--muted)" },
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
return (
|
|
452
|
-
<ChartFigure label="Ongoing Progress" summary={summary} dataLength={data.length}>
|
|
453
|
-
{(activeIndex) => (
|
|
454
|
-
<>
|
|
455
|
-
{chartType === "bar" ? (
|
|
456
|
-
<ChartContainer config={cfg} className="h-[220px] w-full">
|
|
457
|
-
<BarChart data={data} margin={CHART_MARGIN}>
|
|
458
|
-
<CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
|
|
459
|
-
<XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
460
|
-
<YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
461
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
462
|
-
<Bar
|
|
463
|
-
dataKey="done"
|
|
464
|
-
stackId="progress"
|
|
465
|
-
fill="var(--color-chart-2)"
|
|
466
|
-
radius={[0, 0, 0, 0]}
|
|
467
|
-
maxBarSize={32}
|
|
468
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
469
|
-
activeIndex={activeIndex ?? undefined}
|
|
470
|
-
/>
|
|
471
|
-
<Bar
|
|
472
|
-
dataKey="remaining"
|
|
473
|
-
stackId="progress"
|
|
474
|
-
fill="var(--muted)"
|
|
475
|
-
radius={[4, 4, 0, 0]}
|
|
476
|
-
maxBarSize={32}
|
|
477
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
478
|
-
activeIndex={activeIndex ?? undefined}
|
|
479
|
-
/>
|
|
480
|
-
</BarChart>
|
|
481
|
-
</ChartContainer>
|
|
482
|
-
) : (
|
|
483
|
-
<ChartContainer config={cfg} className="h-[220px] w-full">
|
|
484
|
-
<BarChart data={data} layout="vertical" margin={CHART_MARGIN_HORIZONTAL}>
|
|
485
|
-
<CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
|
|
486
|
-
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
487
|
-
<YAxis type="category" dataKey="name" width={72} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
488
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
489
|
-
<Bar
|
|
490
|
-
dataKey="done"
|
|
491
|
-
stackId="progress"
|
|
492
|
-
fill="var(--color-chart-2)"
|
|
493
|
-
radius={[0, 0, 0, 0]}
|
|
494
|
-
maxBarSize={20}
|
|
495
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
496
|
-
activeIndex={activeIndex ?? undefined}
|
|
497
|
-
/>
|
|
498
|
-
<Bar
|
|
499
|
-
dataKey="remaining"
|
|
500
|
-
stackId="progress"
|
|
501
|
-
fill="var(--muted)"
|
|
502
|
-
radius={[0, 4, 4, 0]}
|
|
503
|
-
maxBarSize={20}
|
|
504
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
505
|
-
activeIndex={activeIndex ?? undefined}
|
|
506
|
-
/>
|
|
507
|
-
</BarChart>
|
|
508
|
-
</ChartContainer>
|
|
509
|
-
)}
|
|
510
|
-
<ChartDataTable
|
|
511
|
-
caption="Ongoing Progress data"
|
|
512
|
-
headers={["Student", "Weeks Done", "Weeks Remaining", "Progress %"]}
|
|
513
|
-
rows={data.map(d => [d.name, d.done, d.remaining, `${d.pct}%`])}
|
|
514
|
-
/>
|
|
515
|
-
</>
|
|
516
|
-
)}
|
|
517
|
-
</ChartFigure>
|
|
518
|
-
)
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
function SiteUtilisationChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
|
|
522
|
-
const data = React.useMemo(() => {
|
|
523
|
-
const map = new Map<string, number>()
|
|
524
|
-
for (const r of rows) map.set(r.site, (map.get(r.site) ?? 0) + 1)
|
|
525
|
-
return [...map.entries()]
|
|
526
|
-
.map(([name, value]) => ({
|
|
527
|
-
name: name.length > 24 ? `${name.slice(0, 22)}…` : name,
|
|
528
|
-
value,
|
|
529
|
-
}))
|
|
530
|
-
.sort((a, b) => b.value - a.value)
|
|
531
|
-
.slice(0, 8)
|
|
532
|
-
}, [rows])
|
|
533
|
-
|
|
534
|
-
const colors = PALETTE_COLORS
|
|
535
|
-
const coloredData = React.useMemo(
|
|
536
|
-
() => data.map((d, i) => ({ ...d, fill: colors[i % colors.length] })),
|
|
537
|
-
[data, colors],
|
|
538
|
-
)
|
|
539
|
-
|
|
540
|
-
if (data.length === 0) return <EmptyChart />
|
|
541
|
-
|
|
542
|
-
const summary = `Top ${data.length} sites. Busiest: ${data[0].name} with ${data[0].value} placements.`
|
|
543
|
-
|
|
544
|
-
return (
|
|
545
|
-
<ChartFigure label="Site Utilisation" summary={summary} dataLength={data.length}>
|
|
546
|
-
{(activeIndex) => (
|
|
547
|
-
<>
|
|
548
|
-
{chartType === "bar" ? (
|
|
549
|
-
<ChartContainer config={BAR_CFG} className="h-[220px] w-full">
|
|
550
|
-
<BarChart data={data} margin={CHART_MARGIN}>
|
|
551
|
-
<CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
|
|
552
|
-
<XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
553
|
-
<YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
554
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
555
|
-
<Bar
|
|
556
|
-
dataKey="value"
|
|
557
|
-
fill="var(--color-chart-1)"
|
|
558
|
-
radius={[4, 4, 0, 0]}
|
|
559
|
-
maxBarSize={48}
|
|
560
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
561
|
-
activeIndex={activeIndex ?? undefined}
|
|
562
|
-
>
|
|
563
|
-
{data.map((_, i) => (
|
|
564
|
-
<Cell key={i} fill="var(--color-chart-1)" />
|
|
565
|
-
))}
|
|
566
|
-
</Bar>
|
|
567
|
-
</BarChart>
|
|
568
|
-
</ChartContainer>
|
|
569
|
-
) : chartType === "pie" ? (
|
|
570
|
-
<PieChartRenderer data={coloredData} activeIndex={activeIndex} />
|
|
571
|
-
) : (
|
|
572
|
-
<ChartContainer config={BAR_CFG} className="h-[220px] w-full">
|
|
573
|
-
<BarChart data={data} layout="vertical" margin={CHART_MARGIN_HORIZONTAL}>
|
|
574
|
-
<CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
|
|
575
|
-
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
576
|
-
<YAxis type="category" dataKey="name" width={130} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
577
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
578
|
-
<Bar
|
|
579
|
-
dataKey="value"
|
|
580
|
-
fill="var(--color-chart-1)"
|
|
581
|
-
radius={[0, 4, 4, 0]}
|
|
582
|
-
maxBarSize={20}
|
|
583
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
584
|
-
activeIndex={activeIndex ?? undefined}
|
|
585
|
-
>
|
|
586
|
-
{data.map((_, i) => (
|
|
587
|
-
<Cell key={i} fill="var(--color-chart-1)" />
|
|
588
|
-
))}
|
|
589
|
-
</Bar>
|
|
590
|
-
</BarChart>
|
|
591
|
-
</ChartContainer>
|
|
592
|
-
)}
|
|
593
|
-
<ChartDataTable
|
|
594
|
-
caption="Site Utilisation data"
|
|
595
|
-
headers={["Site", "Placements"]}
|
|
596
|
-
rows={data.map(d => [d.name, d.value])}
|
|
597
|
-
/>
|
|
598
|
-
</>
|
|
599
|
-
)}
|
|
600
|
-
</ChartFigure>
|
|
601
|
-
)
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
function CompletionOutcomesChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
|
|
605
|
-
const completed = rows.filter(r => r.placementPhase === "completed")
|
|
606
|
-
const stats = React.useMemo(() => {
|
|
607
|
-
if (completed.length === 0) return null
|
|
608
|
-
const passed = completed.filter(r => r.finalStatus === "Passed").length
|
|
609
|
-
const passRate = Math.round((passed / completed.length) * 100)
|
|
610
|
-
const avgRating = completed.reduce((sum, r) => sum + r.rating, 0) / completed.length
|
|
611
|
-
const hireYes = completed.filter(r => r.suggestedToHire === "Yes").length
|
|
612
|
-
return { passRate, avgRating: avgRating.toFixed(1), hireRate: Math.round((hireYes / completed.length) * 100), total: completed.length }
|
|
613
|
-
}, [completed])
|
|
614
|
-
|
|
615
|
-
if (!stats) return <EmptyChart message="No completed placements yet." />
|
|
616
|
-
|
|
617
|
-
const summary = `${stats.total} completed placements. Pass rate: ${stats.passRate}%, Average rating: ${stats.avgRating}/5.0, Suggested to hire: ${stats.hireRate}%.`
|
|
618
|
-
|
|
619
|
-
if (chartType === "bar") {
|
|
620
|
-
const barData = [
|
|
621
|
-
{ name: "Pass Rate", value: stats.passRate, fill: "var(--color-chart-2)" },
|
|
622
|
-
{ name: "Avg Rating", value: Math.round(parseFloat(stats.avgRating) * 20), fill: "var(--color-chart-1)" },
|
|
623
|
-
{ name: "Hire Rate", value: stats.hireRate, fill: "var(--color-chart-3)" },
|
|
624
|
-
]
|
|
625
|
-
return (
|
|
626
|
-
<ChartFigure label="Completion Outcomes" summary={summary} dataLength={3}>
|
|
627
|
-
{(activeIndex) => (
|
|
628
|
-
<>
|
|
629
|
-
<ChartContainer config={BAR_CFG} className="h-[220px] w-full">
|
|
630
|
-
<BarChart data={barData} margin={CHART_MARGIN}>
|
|
631
|
-
<CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
|
|
632
|
-
<XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
633
|
-
<YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} domain={[0, 100]} />
|
|
634
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
635
|
-
<Bar
|
|
636
|
-
dataKey="value"
|
|
637
|
-
radius={[4, 4, 0, 0]}
|
|
638
|
-
maxBarSize={48}
|
|
639
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
640
|
-
activeIndex={activeIndex ?? undefined}
|
|
641
|
-
>
|
|
642
|
-
{barData.map((d, i) => (
|
|
643
|
-
<Cell key={i} fill={d.fill} />
|
|
644
|
-
))}
|
|
645
|
-
</Bar>
|
|
646
|
-
</BarChart>
|
|
647
|
-
</ChartContainer>
|
|
648
|
-
<ChartDataTable
|
|
649
|
-
caption="Completion Outcomes data"
|
|
650
|
-
headers={["Metric", "Value"]}
|
|
651
|
-
rows={[["Pass Rate", `${stats.passRate}%`], ["Average Rating", `${stats.avgRating}/5.0`], ["Suggested to Hire", `${stats.hireRate}%`]]}
|
|
652
|
-
/>
|
|
653
|
-
</>
|
|
654
|
-
)}
|
|
655
|
-
</ChartFigure>
|
|
656
|
-
)
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
const radialData = [{ name: "Pass Rate", value: stats.passRate, fill: "var(--color-chart-2)" }]
|
|
660
|
-
|
|
661
|
-
return (
|
|
662
|
-
<ChartFigure label="Completion Outcomes" summary={summary} dataLength={3}>
|
|
663
|
-
{(activeIndex) => (
|
|
664
|
-
<>
|
|
665
|
-
<div className="flex items-start gap-6">
|
|
666
|
-
<div
|
|
667
|
-
className={cn(
|
|
668
|
-
"shrink-0 rounded-md",
|
|
669
|
-
activeIndex === 0 && "ring-2 ring-ring ring-offset-2 ring-offset-background",
|
|
670
|
-
)}
|
|
671
|
-
>
|
|
672
|
-
<ChartContainer config={BAR_CFG} className="h-[180px] w-[180px]">
|
|
673
|
-
<RadialBarChart
|
|
674
|
-
cx="50%" cy="50%" innerRadius={55} outerRadius={80}
|
|
675
|
-
data={radialData} startAngle={90} endAngle={-270}
|
|
676
|
-
barSize={14}
|
|
677
|
-
>
|
|
678
|
-
<RadialBar
|
|
679
|
-
dataKey="value"
|
|
680
|
-
background
|
|
681
|
-
cornerRadius={10}
|
|
682
|
-
activeIndex={activeIndex === 0 ? 0 : undefined}
|
|
683
|
-
>
|
|
684
|
-
{radialData.map((d) => (
|
|
685
|
-
<Cell key={d.name} fill={d.fill} />
|
|
686
|
-
))}
|
|
687
|
-
</RadialBar>
|
|
688
|
-
</RadialBarChart>
|
|
689
|
-
</ChartContainer>
|
|
690
|
-
</div>
|
|
691
|
-
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
|
692
|
-
<div
|
|
693
|
-
className={cn(
|
|
694
|
-
"rounded-md px-1 py-0.5",
|
|
695
|
-
activeIndex === 0 && "ring-2 ring-ring ring-offset-2 ring-offset-background",
|
|
696
|
-
)}
|
|
697
|
-
>
|
|
698
|
-
<p className="text-2xl font-semibold text-foreground tabular-nums">{stats.passRate}%</p>
|
|
699
|
-
<p className="text-xs text-muted-foreground">Pass rate ({stats.total} placements)</p>
|
|
700
|
-
</div>
|
|
701
|
-
<div
|
|
702
|
-
className={cn(
|
|
703
|
-
"rounded-md px-1 py-0.5",
|
|
704
|
-
activeIndex === 1 && "ring-2 ring-ring ring-offset-2 ring-offset-background",
|
|
705
|
-
)}
|
|
706
|
-
>
|
|
707
|
-
<p className="text-lg font-semibold text-foreground tabular-nums">{stats.avgRating} <span className="text-xs text-muted-foreground font-normal">/ 5.0</span></p>
|
|
708
|
-
<p className="text-xs text-muted-foreground">Average rating</p>
|
|
709
|
-
</div>
|
|
710
|
-
<div
|
|
711
|
-
className={cn(
|
|
712
|
-
"rounded-md px-1 py-0.5",
|
|
713
|
-
activeIndex === 2 && "ring-2 ring-ring ring-offset-2 ring-offset-background",
|
|
714
|
-
)}
|
|
715
|
-
>
|
|
716
|
-
<p className="text-lg font-semibold text-foreground tabular-nums">{stats.hireRate}%</p>
|
|
717
|
-
<p className="text-xs text-muted-foreground">Suggested to hire</p>
|
|
718
|
-
</div>
|
|
719
|
-
</div>
|
|
720
|
-
</div>
|
|
721
|
-
<ChartDataTable
|
|
722
|
-
caption="Completion Outcomes data"
|
|
723
|
-
headers={["Metric", "Value"]}
|
|
724
|
-
rows={[["Pass Rate", `${stats.passRate}%`], ["Average Rating", `${stats.avgRating}/5.0`], ["Suggested to Hire", `${stats.hireRate}%`]]}
|
|
725
|
-
/>
|
|
726
|
-
</>
|
|
727
|
-
)}
|
|
728
|
-
</ChartFigure>
|
|
729
|
-
)
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
function UpcomingTimelineChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
|
|
733
|
-
const upcoming = rows.filter(r => r.placementPhase === "upcoming" && r.daysUntilStart > 0)
|
|
734
|
-
const data = React.useMemo(() => {
|
|
735
|
-
const buckets = [
|
|
736
|
-
{ name: "This week", min: 0, max: 7, count: 0 },
|
|
737
|
-
{ name: "Week 2", min: 8, max: 14, count: 0 },
|
|
738
|
-
{ name: "Week 3", min: 15, max: 21, count: 0 },
|
|
739
|
-
{ name: "Week 4", min: 22, max: 28, count: 0 },
|
|
740
|
-
{ name: "Week 5–6", min: 29, max: 42, count: 0 },
|
|
741
|
-
{ name: "Week 7–8", min: 43, max: 56, count: 0 },
|
|
742
|
-
]
|
|
743
|
-
for (const r of upcoming) {
|
|
744
|
-
const b = buckets.find(b => r.daysUntilStart >= b.min && r.daysUntilStart <= b.max)
|
|
745
|
-
if (b) b.count++
|
|
746
|
-
}
|
|
747
|
-
return buckets
|
|
748
|
-
}, [upcoming])
|
|
749
|
-
|
|
750
|
-
if (upcoming.length === 0) return <EmptyChart message="No upcoming start dates in the next 8 weeks." />
|
|
751
|
-
|
|
752
|
-
const summary = `${upcoming.length} upcoming placements across 8 weeks. Most starts: ${data.reduce((a, b) => a.count > b.count ? a : b).name}.`
|
|
753
|
-
|
|
754
|
-
return (
|
|
755
|
-
<ChartFigure label="Upcoming Start Dates" summary={summary} dataLength={data.length}>
|
|
756
|
-
{(activeIndex) => (
|
|
757
|
-
<>
|
|
758
|
-
{chartType === "line" ? (
|
|
759
|
-
<div className="relative w-full">
|
|
760
|
-
<ChartContainer config={AREA_CFG} className="h-[200px] w-full">
|
|
761
|
-
<LineChart data={data} margin={CHART_MARGIN}>
|
|
762
|
-
<CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
|
|
763
|
-
<XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
764
|
-
<YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
765
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
766
|
-
<Line
|
|
767
|
-
type="monotone"
|
|
768
|
-
dataKey="count"
|
|
769
|
-
stroke="var(--color-chart-1)"
|
|
770
|
-
strokeWidth={2}
|
|
771
|
-
dot={(props: { cx?: number; cy?: number; index?: number; stroke?: string; key?: string }) => {
|
|
772
|
-
const idx = props.index ?? 0
|
|
773
|
-
const isSel = activeIndex === idx
|
|
774
|
-
return (
|
|
775
|
-
<circle
|
|
776
|
-
key={props.key}
|
|
777
|
-
cx={props.cx}
|
|
778
|
-
cy={props.cy}
|
|
779
|
-
r={isSel ? 5 : 3}
|
|
780
|
-
fill={props.stroke ?? "var(--color-chart-1)"}
|
|
781
|
-
stroke="var(--ring)"
|
|
782
|
-
strokeWidth={isSel ? 2 : 0}
|
|
783
|
-
/>
|
|
784
|
-
)
|
|
785
|
-
}}
|
|
786
|
-
activeDot={false}
|
|
787
|
-
/>
|
|
788
|
-
</LineChart>
|
|
789
|
-
</ChartContainer>
|
|
790
|
-
<ChartLeoPlotInsightOverlay
|
|
791
|
-
data={data}
|
|
792
|
-
xDataKey="name"
|
|
793
|
-
insetPct={{ left: 6, right: 6, top: 10, bottom: 14 }}
|
|
794
|
-
/>
|
|
795
|
-
</div>
|
|
796
|
-
) : chartType === "bar" ? (
|
|
797
|
-
<div className="relative w-full">
|
|
798
|
-
<ChartContainer config={AREA_CFG} className="h-[200px] w-full">
|
|
799
|
-
<BarChart data={data} margin={CHART_MARGIN}>
|
|
800
|
-
<CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
|
|
801
|
-
<XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
802
|
-
<YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
803
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
804
|
-
<Bar
|
|
805
|
-
dataKey="count"
|
|
806
|
-
fill="var(--color-chart-1)"
|
|
807
|
-
radius={[4, 4, 0, 0]}
|
|
808
|
-
maxBarSize={48}
|
|
809
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
810
|
-
activeIndex={activeIndex ?? undefined}
|
|
811
|
-
>
|
|
812
|
-
{data.map((_, i) => (
|
|
813
|
-
<Cell key={i} fill="var(--color-chart-1)" />
|
|
814
|
-
))}
|
|
815
|
-
</Bar>
|
|
816
|
-
</BarChart>
|
|
817
|
-
</ChartContainer>
|
|
818
|
-
<ChartLeoPlotInsightOverlay
|
|
819
|
-
data={data}
|
|
820
|
-
xDataKey="name"
|
|
821
|
-
insetPct={{ left: 6, right: 6, top: 10, bottom: 14 }}
|
|
822
|
-
/>
|
|
823
|
-
</div>
|
|
824
|
-
) : (
|
|
825
|
-
<div className="relative w-full">
|
|
826
|
-
<ChartContainer config={AREA_CFG} className="h-[200px] w-full">
|
|
827
|
-
<AreaChart data={data} margin={CHART_MARGIN}>
|
|
828
|
-
<CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
|
|
829
|
-
<XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
830
|
-
<YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
831
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
832
|
-
<defs>
|
|
833
|
-
<linearGradient id="timeline-fill" x1="0" y1="0" x2="0" y2="1">
|
|
834
|
-
<stop offset="5%" stopColor="var(--color-chart-1)" stopOpacity={0.3} />
|
|
835
|
-
<stop offset="95%" stopColor="var(--color-chart-1)" stopOpacity={0} />
|
|
836
|
-
</linearGradient>
|
|
837
|
-
</defs>
|
|
838
|
-
<Area
|
|
839
|
-
key="timeline-count"
|
|
840
|
-
type="monotone"
|
|
841
|
-
dataKey="count"
|
|
842
|
-
stroke="var(--color-chart-1)"
|
|
843
|
-
fill="url(#timeline-fill)"
|
|
844
|
-
strokeWidth={2}
|
|
845
|
-
dot={(props: { cx?: number; cy?: number; index?: number; key?: string }) => {
|
|
846
|
-
const idx = props.index ?? 0
|
|
847
|
-
const isSel = activeIndex === idx
|
|
848
|
-
return (
|
|
849
|
-
<circle
|
|
850
|
-
key={props.key}
|
|
851
|
-
cx={props.cx}
|
|
852
|
-
cy={props.cy}
|
|
853
|
-
r={isSel ? 5 : 3}
|
|
854
|
-
fill="var(--color-chart-1)"
|
|
855
|
-
stroke="var(--ring)"
|
|
856
|
-
strokeWidth={isSel ? 2 : 0}
|
|
857
|
-
/>
|
|
858
|
-
)
|
|
859
|
-
}}
|
|
860
|
-
/>
|
|
861
|
-
</AreaChart>
|
|
862
|
-
</ChartContainer>
|
|
863
|
-
<ChartLeoPlotInsightOverlay
|
|
864
|
-
data={data}
|
|
865
|
-
xDataKey="name"
|
|
866
|
-
insetPct={{ left: 6, right: 6, top: 10, bottom: 14 }}
|
|
867
|
-
/>
|
|
868
|
-
</div>
|
|
869
|
-
)}
|
|
870
|
-
<ChartDataTable
|
|
871
|
-
caption="Upcoming Start Dates data"
|
|
872
|
-
headers={["Period", "Starting"]}
|
|
873
|
-
rows={data.map(d => [d.name, d.count])}
|
|
874
|
-
/>
|
|
875
|
-
</>
|
|
876
|
-
)}
|
|
877
|
-
</ChartFigure>
|
|
878
|
-
)
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
/* ── Shared chart renderers for type switching ────────────────────────── */
|
|
882
|
-
|
|
883
|
-
function PieChartRenderer({
|
|
884
|
-
data,
|
|
885
|
-
activeIndex = null,
|
|
886
|
-
}: {
|
|
887
|
-
data: { name: string; value: number; fill?: string }[]
|
|
888
|
-
activeIndex?: number | null
|
|
889
|
-
}) {
|
|
890
|
-
const colors = PALETTE_COLORS
|
|
891
|
-
return (
|
|
892
|
-
<ChartContainer config={BAR_CFG} className="h-[220px] w-full">
|
|
893
|
-
<PieChart>
|
|
894
|
-
<Pie
|
|
895
|
-
data={data}
|
|
896
|
-
dataKey="value"
|
|
897
|
-
nameKey="name"
|
|
898
|
-
cx="50%"
|
|
899
|
-
cy="50%"
|
|
900
|
-
innerRadius={50}
|
|
901
|
-
outerRadius={80}
|
|
902
|
-
strokeWidth={2}
|
|
903
|
-
stroke="var(--card)"
|
|
904
|
-
activeIndex={activeIndex ?? undefined}
|
|
905
|
-
activeShape={CHART_KBD_ACTIVE_PIE_SHAPE}
|
|
906
|
-
labelLine={false}
|
|
907
|
-
>
|
|
908
|
-
{data.map((d, i) => (
|
|
909
|
-
<Cell key={i} fill={d.fill ?? colors[i % colors.length]} />
|
|
910
|
-
))}
|
|
911
|
-
</Pie>
|
|
912
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
913
|
-
</PieChart>
|
|
914
|
-
</ChartContainer>
|
|
915
|
-
)
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
function HBarChartRenderer({
|
|
919
|
-
data,
|
|
920
|
-
colored,
|
|
921
|
-
activeIndex = null,
|
|
922
|
-
}: {
|
|
923
|
-
data: { name: string; value: number; fill?: string }[]
|
|
924
|
-
colored?: boolean
|
|
925
|
-
activeIndex?: number | null
|
|
926
|
-
}) {
|
|
927
|
-
return (
|
|
928
|
-
<ChartContainer config={BAR_CFG} className="h-[220px] w-full">
|
|
929
|
-
<BarChart data={data} layout="vertical" margin={CHART_MARGIN_HORIZONTAL}>
|
|
930
|
-
<CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
|
|
931
|
-
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
932
|
-
<YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
933
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
934
|
-
<Bar
|
|
935
|
-
dataKey="value"
|
|
936
|
-
radius={[0, 4, 4, 0]}
|
|
937
|
-
maxBarSize={22}
|
|
938
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
939
|
-
activeIndex={activeIndex ?? undefined}
|
|
940
|
-
>
|
|
941
|
-
{colored
|
|
942
|
-
? data.map((d, i) => (
|
|
943
|
-
<Cell key={i} fill={d.fill ?? "var(--primary)"} />
|
|
944
|
-
))
|
|
945
|
-
: data.map((_, i) => (
|
|
946
|
-
<Cell key={i} fill="var(--primary)" />
|
|
947
|
-
))}
|
|
948
|
-
</Bar>
|
|
949
|
-
</BarChart>
|
|
950
|
-
</ChartContainer>
|
|
951
|
-
)
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
function EmptyChart({ message = "No data matches the current filters." }: { message?: string }) {
|
|
955
|
-
return (
|
|
956
|
-
<div className="flex items-center justify-center h-[200px] text-sm text-muted-foreground" role="status">
|
|
957
|
-
{message}
|
|
958
|
-
</div>
|
|
959
|
-
)
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
/* ── Card renderer map ─────────────────────────────────────────────────── */
|
|
963
|
-
|
|
964
|
-
const CHART_RENDERERS: Record<string, React.FC<{ rows: Placement[]; chartType: ChartType }>> = {
|
|
965
|
-
"status-pipeline": StatusPipelineChart,
|
|
966
|
-
"program-mix": ProgramMixChart,
|
|
967
|
-
"compliance-status": ComplianceChart,
|
|
968
|
-
"readiness-overview": ReadinessChart,
|
|
969
|
-
"progress-tracker": ProgressTrackerChart,
|
|
970
|
-
"site-utilisation": SiteUtilisationChart,
|
|
971
|
-
"completion-outcomes": CompletionOutcomesChart,
|
|
972
|
-
"upcoming-timeline": UpcomingTimelineChart,
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
/* ── Canvas layout (edit mode) ─────────────────────────────────────────── */
|
|
976
|
-
|
|
977
|
-
function SortableDashboardChartCard({
|
|
978
|
-
card,
|
|
979
|
-
placements,
|
|
980
|
-
span,
|
|
981
|
-
chartType,
|
|
982
|
-
cardIndex,
|
|
983
|
-
totalCards,
|
|
984
|
-
onSpanChange,
|
|
985
|
-
onChartTypeChange,
|
|
986
|
-
onRemove,
|
|
987
|
-
onMoveStep,
|
|
988
|
-
keyMetrics,
|
|
989
|
-
keyMetricsKpiCount,
|
|
990
|
-
onKeyMetricsKpiCountChange,
|
|
991
|
-
}: {
|
|
992
|
-
card: DashboardCardDef
|
|
993
|
-
placements: Placement[]
|
|
994
|
-
span: 1 | 2
|
|
995
|
-
chartType: ChartType
|
|
996
|
-
cardIndex: number
|
|
997
|
-
totalCards: number
|
|
998
|
-
onSpanChange: (id: string, span: 1 | 2) => void
|
|
999
|
-
onChartTypeChange: (id: string, t: ChartType) => void
|
|
1000
|
-
onRemove: (id: string) => void
|
|
1001
|
-
onMoveStep: (direction: -1 | 1) => void
|
|
1002
|
-
keyMetrics?: { metrics: MetricItem[]; insight: MetricInsight } | null
|
|
1003
|
-
keyMetricsKpiCount: number
|
|
1004
|
-
onKeyMetricsKpiCountChange?: (n: number) => void
|
|
1005
|
-
}) {
|
|
1006
|
-
const {
|
|
1007
|
-
attributes,
|
|
1008
|
-
listeners,
|
|
1009
|
-
setNodeRef,
|
|
1010
|
-
setActivatorNodeRef,
|
|
1011
|
-
transform,
|
|
1012
|
-
transition,
|
|
1013
|
-
isDragging,
|
|
1014
|
-
} = useSortable({ id: card.id })
|
|
1015
|
-
const { chartVariant } = useChartVariant()
|
|
1016
|
-
|
|
1017
|
-
/* Only apply transform while dragging — idle `transform` breaks grid row sizing + stacking. */
|
|
1018
|
-
const style: React.CSSProperties = {
|
|
1019
|
-
...(transform ? { transform: CSS.Transform.toString(transform) } : {}),
|
|
1020
|
-
transition,
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
const isKeyMetrics = card.id === KEY_METRICS_CARD_ID
|
|
1024
|
-
const Renderer = isKeyMetrics ? null : CHART_RENDERERS[card.id]
|
|
1025
|
-
if (!isKeyMetrics && !Renderer) return null
|
|
1026
|
-
if (isKeyMetrics && !keyMetrics) return null
|
|
1027
|
-
|
|
1028
|
-
const chartLeoInsight = PLACEMENTS_CHART_LEO_INSIGHTS[card.id]
|
|
1029
|
-
|
|
1030
|
-
const canMoveEarlier = cardIndex > 0
|
|
1031
|
-
const canMoveLater = cardIndex < totalCards - 1
|
|
1032
|
-
|
|
1033
|
-
return (
|
|
1034
|
-
<div
|
|
1035
|
-
ref={setNodeRef}
|
|
1036
|
-
style={style}
|
|
1037
|
-
className={cn(
|
|
1038
|
-
"group flex min-h-0 w-full min-w-0 flex-col self-start rounded-xl border-2 border-dashed border-border bg-transparent p-2",
|
|
1039
|
-
span === 2 ? "lg:col-span-2" : undefined,
|
|
1040
|
-
isDragging && "z-20 opacity-95 ring-2 ring-ring",
|
|
1041
|
-
)}
|
|
1042
|
-
>
|
|
1043
|
-
<div
|
|
1044
|
-
className="mb-2 flex w-full min-w-0 flex-wrap items-center gap-2"
|
|
1045
|
-
role="toolbar"
|
|
1046
|
-
aria-label={`${card.title} layout controls`}
|
|
1047
|
-
>
|
|
1048
|
-
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
|
1049
|
-
<Tip label="Drag to reorder" side="top">
|
|
1050
|
-
<button
|
|
1051
|
-
type="button"
|
|
1052
|
-
ref={setActivatorNodeRef}
|
|
1053
|
-
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
1054
|
-
aria-label={`Drag to reorder ${card.title}`}
|
|
1055
|
-
{...attributes}
|
|
1056
|
-
{...listeners}
|
|
1057
|
-
>
|
|
1058
|
-
<DragHandleGripIcon className="text-[15px]" />
|
|
1059
|
-
</button>
|
|
1060
|
-
</Tip>
|
|
1061
|
-
{card.chartTypes.length > 0 ? (
|
|
1062
|
-
<ViewSegmentedControl
|
|
1063
|
-
aria-label={`Chart type for ${card.title}`}
|
|
1064
|
-
iconOnly
|
|
1065
|
-
value={chartType}
|
|
1066
|
-
onValueChange={v => onChartTypeChange(card.id, v as ChartType)}
|
|
1067
|
-
options={card.chartTypes.map(opt => ({
|
|
1068
|
-
value: opt.type,
|
|
1069
|
-
label: opt.label,
|
|
1070
|
-
icon: opt.icon,
|
|
1071
|
-
}))}
|
|
1072
|
-
/>
|
|
1073
|
-
) : null}
|
|
1074
|
-
{isKeyMetrics && onKeyMetricsKpiCountChange ? (
|
|
1075
|
-
<ViewSegmentedControl
|
|
1076
|
-
aria-label="Number of KPIs to show"
|
|
1077
|
-
iconOnly={false}
|
|
1078
|
-
value={String(keyMetricsKpiCount)}
|
|
1079
|
-
onValueChange={v => onKeyMetricsKpiCountChange(Number(v))}
|
|
1080
|
-
options={Array.from(
|
|
1081
|
-
{ length: KEY_METRICS_KPI_COUNT_MAX - KEY_METRICS_KPI_COUNT_MIN + 1 },
|
|
1082
|
-
(_, i) => {
|
|
1083
|
-
const n = KEY_METRICS_KPI_COUNT_MIN + i
|
|
1084
|
-
return { value: String(n), label: String(n) }
|
|
1085
|
-
},
|
|
1086
|
-
)}
|
|
1087
|
-
/>
|
|
1088
|
-
) : null}
|
|
1089
|
-
<ViewSegmentedControl
|
|
1090
|
-
aria-label={`Width for ${card.title}`}
|
|
1091
|
-
iconOnly
|
|
1092
|
-
value={String(span) as "1" | "2"}
|
|
1093
|
-
onValueChange={v => onSpanChange(card.id, Number(v) as 1 | 2)}
|
|
1094
|
-
options={[
|
|
1095
|
-
{ value: "1", label: "Half width", icon: "fa-light fa-table-columns" },
|
|
1096
|
-
{ value: "2", label: "Full width (all columns)", icon: "fa-light fa-maximize" },
|
|
1097
|
-
]}
|
|
1098
|
-
/>
|
|
1099
|
-
</div>
|
|
1100
|
-
<div className="ms-auto flex shrink-0 items-center gap-1">
|
|
1101
|
-
<div
|
|
1102
|
-
className="pointer-events-none flex items-center gap-0.5 opacity-0 transition-opacity duration-150 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100"
|
|
1103
|
-
role="group"
|
|
1104
|
-
aria-label={`Reorder ${card.title}`}
|
|
1105
|
-
>
|
|
1106
|
-
<div className="flex items-center gap-0.5 lg:hidden">
|
|
1107
|
-
<Tip label="Move up" side="top">
|
|
1108
|
-
<Button
|
|
1109
|
-
type="button"
|
|
1110
|
-
variant="ghost"
|
|
1111
|
-
size="icon-sm"
|
|
1112
|
-
className="size-7 shrink-0"
|
|
1113
|
-
disabled={!canMoveEarlier}
|
|
1114
|
-
aria-label={`Move ${card.title} up`}
|
|
1115
|
-
onClick={() => onMoveStep(-1)}
|
|
1116
|
-
>
|
|
1117
|
-
<i className="fa-light fa-chevron-up text-xs" aria-hidden="true" />
|
|
1118
|
-
</Button>
|
|
1119
|
-
</Tip>
|
|
1120
|
-
<Tip label="Move down" side="top">
|
|
1121
|
-
<Button
|
|
1122
|
-
type="button"
|
|
1123
|
-
variant="ghost"
|
|
1124
|
-
size="icon-sm"
|
|
1125
|
-
className="size-7 shrink-0"
|
|
1126
|
-
disabled={!canMoveLater}
|
|
1127
|
-
aria-label={`Move ${card.title} down`}
|
|
1128
|
-
onClick={() => onMoveStep(1)}
|
|
1129
|
-
>
|
|
1130
|
-
<i className="fa-light fa-chevron-down text-xs" aria-hidden="true" />
|
|
1131
|
-
</Button>
|
|
1132
|
-
</Tip>
|
|
1133
|
-
</div>
|
|
1134
|
-
<div className="hidden items-center gap-0.5 lg:flex">
|
|
1135
|
-
<Tip label="Move left" side="top">
|
|
1136
|
-
<Button
|
|
1137
|
-
type="button"
|
|
1138
|
-
variant="ghost"
|
|
1139
|
-
size="icon-sm"
|
|
1140
|
-
className="size-7 shrink-0"
|
|
1141
|
-
disabled={!canMoveEarlier}
|
|
1142
|
-
aria-label={`Move ${card.title} left`}
|
|
1143
|
-
onClick={() => onMoveStep(-1)}
|
|
1144
|
-
>
|
|
1145
|
-
<i className="fa-light fa-chevron-left text-xs" aria-hidden="true" />
|
|
1146
|
-
</Button>
|
|
1147
|
-
</Tip>
|
|
1148
|
-
<Tip label="Move right" side="top">
|
|
1149
|
-
<Button
|
|
1150
|
-
type="button"
|
|
1151
|
-
variant="ghost"
|
|
1152
|
-
size="icon-sm"
|
|
1153
|
-
className="size-7 shrink-0"
|
|
1154
|
-
disabled={!canMoveLater}
|
|
1155
|
-
aria-label={`Move ${card.title} right`}
|
|
1156
|
-
onClick={() => onMoveStep(1)}
|
|
1157
|
-
>
|
|
1158
|
-
<i className="fa-light fa-chevron-right text-xs" aria-hidden="true" />
|
|
1159
|
-
</Button>
|
|
1160
|
-
</Tip>
|
|
1161
|
-
</div>
|
|
1162
|
-
</div>
|
|
1163
|
-
<Tip label={`Remove ${card.title}`} side="top">
|
|
1164
|
-
<Button
|
|
1165
|
-
type="button"
|
|
1166
|
-
variant="ghost"
|
|
1167
|
-
size="icon-sm"
|
|
1168
|
-
className="size-8 shrink-0 text-muted-foreground hover:text-destructive"
|
|
1169
|
-
aria-label={`Remove ${card.title} from dashboard`}
|
|
1170
|
-
onClick={() => onRemove(card.id)}
|
|
1171
|
-
>
|
|
1172
|
-
<i className="fa-light fa-trash text-[13px]" aria-hidden="true" />
|
|
1173
|
-
</Button>
|
|
1174
|
-
</Tip>
|
|
1175
|
-
</div>
|
|
1176
|
-
</div>
|
|
1177
|
-
{isKeyMetrics && keyMetrics ? (
|
|
1178
|
-
<KeyMetrics
|
|
1179
|
-
variant="card"
|
|
1180
|
-
title={card.title}
|
|
1181
|
-
description={card.description}
|
|
1182
|
-
metrics={keyMetrics.metrics.slice(0, keyMetricsKpiCount)}
|
|
1183
|
-
insight={keyMetrics.insight}
|
|
1184
|
-
metricsSingleRow
|
|
1185
|
-
metricsHalfWidthLayout={span === 1}
|
|
1186
|
-
className="w-full min-w-0"
|
|
1187
|
-
/>
|
|
1188
|
-
) : (
|
|
1189
|
-
<ChartCard
|
|
1190
|
-
variant={chartVariant}
|
|
1191
|
-
title={card.title}
|
|
1192
|
-
description={card.description}
|
|
1193
|
-
className="!h-auto min-h-0 shrink-0"
|
|
1194
|
-
leoInsight={chartLeoInsight}
|
|
1195
|
-
>
|
|
1196
|
-
{Renderer ? <Renderer rows={placements} chartType={chartType} /> : null}
|
|
1197
|
-
</ChartCard>
|
|
1198
|
-
)}
|
|
1199
|
-
</div>
|
|
1200
|
-
)
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
/* ── Main export ───────────────────────────────────────────────────────── */
|
|
1204
|
-
|
|
1205
|
-
export interface PlacementsDashboardChartsSectionProps {
|
|
1206
|
-
placements: Placement[]
|
|
1207
|
-
/** KPI strip rendered as the `key-metrics` dashboard card (same slot as customise canvas). */
|
|
1208
|
-
keyMetrics: { metrics: MetricItem[]; insight: MetricInsight }
|
|
1209
|
-
visibleCards: string[]
|
|
1210
|
-
cardOrder: string[]
|
|
1211
|
-
/** Column span per card (1 = half row on large screens, 2 = full width). Defaults merged from saved layout. */
|
|
1212
|
-
cardSpans?: Record<string, 1 | 2>
|
|
1213
|
-
/** Chart visualization per card. Defaults merged from saved layout. */
|
|
1214
|
-
cardChartTypes?: Record<string, ChartType>
|
|
1215
|
-
/** How many KPIs to show on the key-metrics card (1–4). */
|
|
1216
|
-
keyMetricsKpiCount?: number
|
|
1217
|
-
/** When true, show canvas controls: drag, remove, width, chart type (no side panel). */
|
|
1218
|
-
layoutEditMode?: boolean
|
|
1219
|
-
onVisibleChange?: (visible: string[]) => void
|
|
1220
|
-
onOrderChange?: (order: string[]) => void
|
|
1221
|
-
onSpanChange?: (id: string, span: 1 | 2) => void
|
|
1222
|
-
onChartTypeChange?: (id: string, chartType: ChartType) => void
|
|
1223
|
-
onKeyMetricsKpiCountChange?: (count: number) => void
|
|
1224
|
-
onResetLayout?: () => void
|
|
1225
|
-
/** Exit edit mode (layout already persisted). */
|
|
1226
|
-
onLayoutEditDone?: () => void
|
|
1227
|
-
/** Exit edit mode and restore layout from when edit started. */
|
|
1228
|
-
onLayoutEditCancel?: () => void
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
export function PlacementsDashboardChartsSection({
|
|
1232
|
-
placements,
|
|
1233
|
-
keyMetrics,
|
|
1234
|
-
visibleCards,
|
|
1235
|
-
cardOrder,
|
|
1236
|
-
cardSpans = DEFAULT_SPANS,
|
|
1237
|
-
cardChartTypes = DEFAULT_CHART_TYPES,
|
|
1238
|
-
keyMetricsKpiCount = KEY_METRICS_KPI_COUNT_DEFAULT,
|
|
1239
|
-
layoutEditMode = false,
|
|
1240
|
-
onVisibleChange,
|
|
1241
|
-
onOrderChange,
|
|
1242
|
-
onSpanChange,
|
|
1243
|
-
onChartTypeChange,
|
|
1244
|
-
onKeyMetricsKpiCountChange,
|
|
1245
|
-
onResetLayout,
|
|
1246
|
-
onLayoutEditDone,
|
|
1247
|
-
onLayoutEditCancel,
|
|
1248
|
-
}: PlacementsDashboardChartsSectionProps) {
|
|
1249
|
-
const { chartVariant } = useChartVariant()
|
|
1250
|
-
const orderedCards = React.useMemo(() => {
|
|
1251
|
-
const defs = new Map(ALL_DASHBOARD_CARDS.map(c => [c.id, c]))
|
|
1252
|
-
return cardOrder
|
|
1253
|
-
.filter(id => visibleCards.includes(id) && defs.has(id))
|
|
1254
|
-
.map(id => defs.get(id)!)
|
|
1255
|
-
}, [visibleCards, cardOrder])
|
|
1256
|
-
|
|
1257
|
-
const hiddenCardDefs = React.useMemo(
|
|
1258
|
-
() => ALL_DASHBOARD_CARDS.filter(c => !visibleCards.includes(c.id)),
|
|
1259
|
-
[visibleCards],
|
|
1260
|
-
)
|
|
1261
|
-
|
|
1262
|
-
const sortableIds = React.useMemo(() => orderedCards.map(c => c.id), [orderedCards])
|
|
1263
|
-
|
|
1264
|
-
const sensors = useSensors(
|
|
1265
|
-
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
|
1266
|
-
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
1267
|
-
)
|
|
1268
|
-
|
|
1269
|
-
const handleDragEnd = React.useCallback(
|
|
1270
|
-
(event: DragEndEvent) => {
|
|
1271
|
-
if (!onOrderChange) return
|
|
1272
|
-
const { active, over } = event
|
|
1273
|
-
if (!over || active.id === over.id) return
|
|
1274
|
-
const oldIndex = sortableIds.indexOf(String(active.id))
|
|
1275
|
-
const newIndex = sortableIds.indexOf(String(over.id))
|
|
1276
|
-
if (oldIndex < 0 || newIndex < 0) return
|
|
1277
|
-
const nextVisibleOrder = arrayMove(sortableIds, oldIndex, newIndex)
|
|
1278
|
-
const visibleSet = new Set(visibleCards)
|
|
1279
|
-
onOrderChange(applyVisibleReorder(cardOrder, visibleSet, nextVisibleOrder))
|
|
1280
|
-
},
|
|
1281
|
-
[cardOrder, onOrderChange, sortableIds, visibleCards],
|
|
1282
|
-
)
|
|
1283
|
-
|
|
1284
|
-
const moveStep = React.useCallback(
|
|
1285
|
-
(id: string, direction: -1 | 1) => {
|
|
1286
|
-
if (!onOrderChange) return
|
|
1287
|
-
const idx = sortableIds.indexOf(id)
|
|
1288
|
-
if (idx < 0) return
|
|
1289
|
-
const newIdx = idx + direction
|
|
1290
|
-
if (newIdx < 0 || newIdx >= sortableIds.length) return
|
|
1291
|
-
const nextVisibleOrder = arrayMove(sortableIds, idx, newIdx)
|
|
1292
|
-
const visibleSet = new Set(visibleCards)
|
|
1293
|
-
onOrderChange(applyVisibleReorder(cardOrder, visibleSet, nextVisibleOrder))
|
|
1294
|
-
},
|
|
1295
|
-
[cardOrder, onOrderChange, sortableIds, visibleCards],
|
|
1296
|
-
)
|
|
1297
|
-
|
|
1298
|
-
const addCard = React.useCallback(
|
|
1299
|
-
(id: string) => {
|
|
1300
|
-
if (!onVisibleChange) return
|
|
1301
|
-
if (visibleCards.includes(id)) return
|
|
1302
|
-
onVisibleChange([...visibleCards, id])
|
|
1303
|
-
},
|
|
1304
|
-
[onVisibleChange, visibleCards],
|
|
1305
|
-
)
|
|
1306
|
-
|
|
1307
|
-
const removeCard = React.useCallback(
|
|
1308
|
-
(id: string) => {
|
|
1309
|
-
if (!onVisibleChange) return
|
|
1310
|
-
onVisibleChange(visibleCards.filter(v => v !== id))
|
|
1311
|
-
},
|
|
1312
|
-
[onVisibleChange, visibleCards],
|
|
1313
|
-
)
|
|
1314
|
-
|
|
1315
|
-
if (orderedCards.length === 0) {
|
|
1316
|
-
return (
|
|
1317
|
-
<div className="flex flex-col items-center justify-center gap-3 py-12 px-4 text-center">
|
|
1318
|
-
<i className="fa-light fa-chart-column text-2xl text-muted-foreground/40" aria-hidden="true" />
|
|
1319
|
-
<p className="text-sm text-muted-foreground">
|
|
1320
|
-
No charts on the dashboard.
|
|
1321
|
-
{layoutEditMode && hiddenCardDefs.length > 0
|
|
1322
|
-
? " Add a chart below."
|
|
1323
|
-
: " Turn on Edit layout and add charts back."}
|
|
1324
|
-
</p>
|
|
1325
|
-
{layoutEditMode && hiddenCardDefs.length > 0 && onVisibleChange ? (
|
|
1326
|
-
<DropdownMenu>
|
|
1327
|
-
<DropdownMenuTrigger asChild>
|
|
1328
|
-
<Button type="button" variant="outline" size="sm" className="size-9 p-0" aria-label="Add chart">
|
|
1329
|
-
<i className="fa-light fa-plus text-sm" aria-hidden="true" />
|
|
1330
|
-
</Button>
|
|
1331
|
-
</DropdownMenuTrigger>
|
|
1332
|
-
<DropdownMenuContent align="center">
|
|
1333
|
-
{hiddenCardDefs.map(c => (
|
|
1334
|
-
<DropdownMenuItem key={c.id} onSelect={() => addCard(c.id)}>
|
|
1335
|
-
{c.title}
|
|
1336
|
-
</DropdownMenuItem>
|
|
1337
|
-
))}
|
|
1338
|
-
</DropdownMenuContent>
|
|
1339
|
-
</DropdownMenu>
|
|
1340
|
-
) : null}
|
|
1341
|
-
</div>
|
|
1342
|
-
)
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
const grid = (
|
|
1346
|
-
<div
|
|
1347
|
-
className={cn(
|
|
1348
|
-
"grid grid-cols-1 gap-4 lg:grid-cols-2",
|
|
1349
|
-
/* stretch + ChartCard h-full + sortable transform causes row overlap — pin to content height */
|
|
1350
|
-
layoutEditMode && "lg:items-start lg:content-start lg:auto-rows-min",
|
|
1351
|
-
)}
|
|
1352
|
-
>
|
|
1353
|
-
{orderedCards.map((card, cardIndex) => {
|
|
1354
|
-
const isKeyMetricsCard = card.id === KEY_METRICS_CARD_ID
|
|
1355
|
-
const Renderer = isKeyMetricsCard ? null : CHART_RENDERERS[card.id]
|
|
1356
|
-
if (!isKeyMetricsCard && !Renderer) return null
|
|
1357
|
-
const span = cardSpans[card.id] ?? card.defaultSpan
|
|
1358
|
-
const requestedType = cardChartTypes[card.id] ?? card.defaultChartType
|
|
1359
|
-
const allowedTypes = card.chartTypes.map(o => o.type)
|
|
1360
|
-
const chartType =
|
|
1361
|
-
allowedTypes.length === 0
|
|
1362
|
-
? card.defaultChartType
|
|
1363
|
-
: allowedTypes.includes(requestedType)
|
|
1364
|
-
? requestedType
|
|
1365
|
-
: card.defaultChartType
|
|
1366
|
-
|
|
1367
|
-
if (
|
|
1368
|
-
layoutEditMode &&
|
|
1369
|
-
onOrderChange &&
|
|
1370
|
-
onSpanChange &&
|
|
1371
|
-
onChartTypeChange &&
|
|
1372
|
-
onVisibleChange
|
|
1373
|
-
) {
|
|
1374
|
-
return (
|
|
1375
|
-
<SortableDashboardChartCard
|
|
1376
|
-
key={card.id}
|
|
1377
|
-
card={card}
|
|
1378
|
-
placements={placements}
|
|
1379
|
-
span={span}
|
|
1380
|
-
chartType={chartType}
|
|
1381
|
-
cardIndex={cardIndex}
|
|
1382
|
-
totalCards={orderedCards.length}
|
|
1383
|
-
onSpanChange={onSpanChange}
|
|
1384
|
-
onChartTypeChange={onChartTypeChange}
|
|
1385
|
-
onRemove={removeCard}
|
|
1386
|
-
onMoveStep={dir => moveStep(card.id, dir)}
|
|
1387
|
-
keyMetrics={isKeyMetricsCard ? keyMetrics : null}
|
|
1388
|
-
keyMetricsKpiCount={keyMetricsKpiCount}
|
|
1389
|
-
onKeyMetricsKpiCountChange={
|
|
1390
|
-
isKeyMetricsCard ? onKeyMetricsKpiCountChange : undefined
|
|
1391
|
-
}
|
|
1392
|
-
/>
|
|
1393
|
-
)
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
return (
|
|
1397
|
-
<div
|
|
1398
|
-
key={card.id}
|
|
1399
|
-
className={cn(span === 2 ? "lg:col-span-2" : undefined)}
|
|
1400
|
-
>
|
|
1401
|
-
{isKeyMetricsCard ? (
|
|
1402
|
-
<KeyMetrics
|
|
1403
|
-
variant="card"
|
|
1404
|
-
title={card.title}
|
|
1405
|
-
description={card.description}
|
|
1406
|
-
metrics={keyMetrics.metrics.slice(0, keyMetricsKpiCount)}
|
|
1407
|
-
insight={keyMetrics.insight}
|
|
1408
|
-
metricsSingleRow
|
|
1409
|
-
metricsHalfWidthLayout={span === 1}
|
|
1410
|
-
className="w-full min-w-0"
|
|
1411
|
-
/>
|
|
1412
|
-
) : (
|
|
1413
|
-
<ChartCard
|
|
1414
|
-
variant={chartVariant}
|
|
1415
|
-
title={card.title}
|
|
1416
|
-
description={card.description}
|
|
1417
|
-
leoInsight={PLACEMENTS_CHART_LEO_INSIGHTS[card.id]}
|
|
1418
|
-
>
|
|
1419
|
-
{Renderer ? <Renderer rows={placements} chartType={chartType} /> : null}
|
|
1420
|
-
</ChartCard>
|
|
1421
|
-
)}
|
|
1422
|
-
</div>
|
|
1423
|
-
)
|
|
1424
|
-
})}
|
|
1425
|
-
</div>
|
|
1426
|
-
)
|
|
1427
|
-
|
|
1428
|
-
const editToolbar =
|
|
1429
|
-
layoutEditMode && onVisibleChange && onResetLayout ? (
|
|
1430
|
-
<div
|
|
1431
|
-
className="mb-3 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-transparent px-3 py-2"
|
|
1432
|
-
role="region"
|
|
1433
|
-
aria-label="Dashboard layout options"
|
|
1434
|
-
>
|
|
1435
|
-
<p className="text-xs text-muted-foreground">
|
|
1436
|
-
Drag cards to reorder. Changes save automatically.
|
|
1437
|
-
</p>
|
|
1438
|
-
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
1439
|
-
<Button type="button" size="sm" variant="ghost" className="h-8 text-xs" onClick={() => onVisibleChange(ALL_DASHBOARD_CARDS.map(c => c.id))}>
|
|
1440
|
-
Show all
|
|
1441
|
-
</Button>
|
|
1442
|
-
<Button type="button" size="sm" variant="ghost" className="h-8 text-xs" onClick={() => onVisibleChange([])}>
|
|
1443
|
-
Hide all
|
|
1444
|
-
</Button>
|
|
1445
|
-
<Tip side="bottom" label="Reset visibility, order, widths, and chart types">
|
|
1446
|
-
<Button type="button" size="sm" variant="ghost" className="h-8 px-2 text-xs" onClick={onResetLayout}>
|
|
1447
|
-
<i className="fa-light fa-rotate-left me-1 text-xs" aria-hidden="true" />
|
|
1448
|
-
Reset
|
|
1449
|
-
</Button>
|
|
1450
|
-
</Tip>
|
|
1451
|
-
{hiddenCardDefs.length > 0 ? (
|
|
1452
|
-
<DropdownMenu>
|
|
1453
|
-
<DropdownMenuTrigger asChild>
|
|
1454
|
-
<Button type="button" variant="outline" size="sm" className="size-8 p-0" aria-label="Add chart">
|
|
1455
|
-
<i className="fa-light fa-plus text-[13px]" aria-hidden="true" />
|
|
1456
|
-
</Button>
|
|
1457
|
-
</DropdownMenuTrigger>
|
|
1458
|
-
<DropdownMenuContent align="end">
|
|
1459
|
-
{hiddenCardDefs.map(c => (
|
|
1460
|
-
<DropdownMenuItem key={c.id} onSelect={() => addCard(c.id)}>
|
|
1461
|
-
{c.title}
|
|
1462
|
-
</DropdownMenuItem>
|
|
1463
|
-
))}
|
|
1464
|
-
</DropdownMenuContent>
|
|
1465
|
-
</DropdownMenu>
|
|
1466
|
-
) : null}
|
|
1467
|
-
{onLayoutEditCancel ? (
|
|
1468
|
-
<Button type="button" size="sm" variant="outline" className="h-8 text-xs" onClick={onLayoutEditCancel}>
|
|
1469
|
-
Cancel
|
|
1470
|
-
</Button>
|
|
1471
|
-
) : null}
|
|
1472
|
-
{onLayoutEditDone ? (
|
|
1473
|
-
<Button type="button" size="sm" className="h-8 text-xs" onClick={onLayoutEditDone}>
|
|
1474
|
-
Done
|
|
1475
|
-
</Button>
|
|
1476
|
-
) : null}
|
|
1477
|
-
</div>
|
|
1478
|
-
</div>
|
|
1479
|
-
) : null
|
|
1480
|
-
|
|
1481
|
-
const gridBody =
|
|
1482
|
-
layoutEditMode && onOrderChange ? (
|
|
1483
|
-
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleDragEnd}>
|
|
1484
|
-
<SortableContext items={sortableIds} strategy={rectSortingStrategy}>
|
|
1485
|
-
{grid}
|
|
1486
|
-
</SortableContext>
|
|
1487
|
-
</DndContext>
|
|
1488
|
-
) : (
|
|
1489
|
-
grid
|
|
1490
|
-
)
|
|
1491
|
-
|
|
1492
|
-
return (
|
|
1493
|
-
<div
|
|
1494
|
-
className={cn(
|
|
1495
|
-
"flex flex-col gap-4 px-4 pb-2 lg:px-6",
|
|
1496
|
-
layoutEditMode && "rounded-xl border border-dashed border-border/80 bg-transparent py-3",
|
|
1497
|
-
)}
|
|
1498
|
-
>
|
|
1499
|
-
{editToolbar}
|
|
1500
|
-
{gridBody}
|
|
1501
|
-
</div>
|
|
1502
|
-
)
|
|
1503
|
-
}
|