@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,971 +0,0 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Team **Data** view dashboard — filtered `TeamMember[]` with the same canvas pattern as Placements:
|
|
5
|
-
* key metrics + charts, show/hide, reorder, width, chart type, persistence.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import * as React from "react"
|
|
9
|
-
import {
|
|
10
|
-
closestCorners,
|
|
11
|
-
DndContext,
|
|
12
|
-
type DragEndEvent,
|
|
13
|
-
KeyboardSensor,
|
|
14
|
-
PointerSensor,
|
|
15
|
-
useSensor,
|
|
16
|
-
useSensors,
|
|
17
|
-
} from "@dnd-kit/core"
|
|
18
|
-
import {
|
|
19
|
-
arrayMove,
|
|
20
|
-
rectSortingStrategy,
|
|
21
|
-
SortableContext,
|
|
22
|
-
sortableKeyboardCoordinates,
|
|
23
|
-
useSortable,
|
|
24
|
-
} from "@dnd-kit/sortable"
|
|
25
|
-
import { CSS } from "@dnd-kit/utilities"
|
|
26
|
-
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from "recharts"
|
|
27
|
-
import { ChartCard, ChartDataTable, ChartFigure, type ChartLeoInsight } from "@/components/charts-overview"
|
|
28
|
-
import { useChartVariant } from "@/contexts/chart-variant-context"
|
|
29
|
-
import { KeyMetrics, type MetricInsight, type MetricItem } from "@/components/key-metrics"
|
|
30
|
-
import { Button } from "@/components/ui/button"
|
|
31
|
-
import {
|
|
32
|
-
DropdownMenu,
|
|
33
|
-
DropdownMenuContent,
|
|
34
|
-
DropdownMenuItem,
|
|
35
|
-
DropdownMenuTrigger,
|
|
36
|
-
} from "@/components/ui/dropdown-menu"
|
|
37
|
-
import { ViewSegmentedControl } from "@/components/ui/view-segmented-control"
|
|
38
|
-
import { Tip } from "@/components/ui/tip"
|
|
39
|
-
import { DragHandleGripIcon } from "@/components/ui/drag-handle-grip"
|
|
40
|
-
import {
|
|
41
|
-
ChartContainer,
|
|
42
|
-
ChartTooltip,
|
|
43
|
-
chartTooltipKeyboardSyncProps,
|
|
44
|
-
ChartTooltipContent,
|
|
45
|
-
type ChartConfig,
|
|
46
|
-
} from "@/components/ui/chart"
|
|
47
|
-
import {
|
|
48
|
-
KEY_METRICS_KPI_COUNT_DEFAULT,
|
|
49
|
-
KEY_METRICS_KPI_COUNT_MAX,
|
|
50
|
-
KEY_METRICS_KPI_COUNT_MIN,
|
|
51
|
-
mergeDashboardLayoutGeneric,
|
|
52
|
-
} from "@/lib/dashboard-layout-merge"
|
|
53
|
-
import { cn } from "@/lib/utils"
|
|
54
|
-
import type { TeamMember } from "@/lib/mock/team"
|
|
55
|
-
import {
|
|
56
|
-
KEY_METRICS_CARD_ID,
|
|
57
|
-
applyVisibleReorder,
|
|
58
|
-
type ChartType,
|
|
59
|
-
type DashboardLayout,
|
|
60
|
-
} from "@/lib/data-view-dashboard-placements-layout"
|
|
61
|
-
import {
|
|
62
|
-
CHART_KBD_ACTIVE_BAR,
|
|
63
|
-
CHART_KBD_ACTIVE_PIE_SHAPE,
|
|
64
|
-
} from "@/lib/chart-keyboard-selection"
|
|
65
|
-
import {
|
|
66
|
-
loadDataViewLayout,
|
|
67
|
-
saveDataViewLayout,
|
|
68
|
-
} from "@/lib/data-view-dashboard-storage"
|
|
69
|
-
|
|
70
|
-
const STATUS_CHART_CFG: ChartConfig = {
|
|
71
|
-
value: { label: "Members", color: "var(--primary)" },
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const ROLE_CHART_CFG: ChartConfig = {
|
|
75
|
-
value: { label: "Members", color: "var(--primary)" },
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const CHART_MARGIN = { top: 8, right: 8, left: 0, bottom: 0 } as const
|
|
79
|
-
const CHART_MARGIN_HORIZONTAL = { top: 8, right: 8, left: 4, bottom: 0 } as const
|
|
80
|
-
|
|
81
|
-
interface TeamDashboardCardDef {
|
|
82
|
-
id: string
|
|
83
|
-
title: string
|
|
84
|
-
description: string
|
|
85
|
-
defaultSpan: 1 | 2
|
|
86
|
-
defaultChartType: ChartType
|
|
87
|
-
chartTypes: { type: ChartType; label: string; icon: string }[]
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export const ALL_TEAM_DASHBOARD_CARDS: TeamDashboardCardDef[] = [
|
|
91
|
-
{
|
|
92
|
-
id: KEY_METRICS_CARD_ID,
|
|
93
|
-
title: "Key metrics",
|
|
94
|
-
description: "Summary KPIs for the filtered roster",
|
|
95
|
-
defaultSpan: 2,
|
|
96
|
-
defaultChartType: "bar",
|
|
97
|
-
chartTypes: [],
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
id: "team-by-status",
|
|
101
|
-
title: "Members by status",
|
|
102
|
-
description: "Active, away, and invited in this view",
|
|
103
|
-
defaultSpan: 1,
|
|
104
|
-
defaultChartType: "bar",
|
|
105
|
-
chartTypes: [
|
|
106
|
-
{ type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
|
|
107
|
-
{ type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
|
|
108
|
-
{ type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
|
|
109
|
-
],
|
|
110
|
-
},
|
|
111
|
-
{
|
|
112
|
-
id: "team-by-role",
|
|
113
|
-
title: "Members by role",
|
|
114
|
-
description: "Top roles in the filtered roster",
|
|
115
|
-
defaultSpan: 1,
|
|
116
|
-
defaultChartType: "horizontal-bar",
|
|
117
|
-
chartTypes: [
|
|
118
|
-
{ type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
|
|
119
|
-
{ type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
|
|
120
|
-
{ type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
|
|
121
|
-
],
|
|
122
|
-
},
|
|
123
|
-
]
|
|
124
|
-
|
|
125
|
-
export const DEFAULT_TEAM_VISIBLE_CARDS = ALL_TEAM_DASHBOARD_CARDS.map(c => c.id)
|
|
126
|
-
export const DEFAULT_TEAM_SPANS: Record<string, 1 | 2> = Object.fromEntries(
|
|
127
|
-
ALL_TEAM_DASHBOARD_CARDS.map(c => [c.id, c.defaultSpan]),
|
|
128
|
-
)
|
|
129
|
-
export const DEFAULT_TEAM_CHART_TYPES: Record<string, ChartType> = Object.fromEntries(
|
|
130
|
-
ALL_TEAM_DASHBOARD_CARDS.map(c => [c.id, c.defaultChartType]),
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
const TEAM_CHART_LEO_INSIGHTS: Partial<Record<string, ChartLeoInsight>> = {
|
|
134
|
-
"team-by-status": {
|
|
135
|
-
headline: "Invited members may be waiting on acceptance",
|
|
136
|
-
explanation:
|
|
137
|
-
"A noticeable invited slice often means stale invites or slow onboarding. Clearing or re-sending can tighten roster accuracy for permissions and assignments.",
|
|
138
|
-
},
|
|
139
|
-
"team-by-role": {
|
|
140
|
-
headline: "A few roles carry most of the roster",
|
|
141
|
-
explanation:
|
|
142
|
-
"When bars skew toward a small set of roles, turnover in those groups has an outsized impact. Leo can suggest backup owners or cross-training patterns to reduce single points of failure.",
|
|
143
|
-
},
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export function loadTeamDashboardLayout(): DashboardLayout | null {
|
|
147
|
-
const v = loadDataViewLayout("team")
|
|
148
|
-
if (!v) return null
|
|
149
|
-
return {
|
|
150
|
-
visible: v.visible,
|
|
151
|
-
order: v.order,
|
|
152
|
-
spans: v.spans,
|
|
153
|
-
chartTypes: v.chartTypes as Record<string, ChartType> | undefined,
|
|
154
|
-
keyMetricsKpiCount: v.keyMetricsKpiCount,
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export function mergeTeamDashboardLayout(saved: DashboardLayout | null): DashboardLayout {
|
|
159
|
-
const defaults = {
|
|
160
|
-
visible: [...DEFAULT_TEAM_VISIBLE_CARDS],
|
|
161
|
-
order: ALL_TEAM_DASHBOARD_CARDS.map(c => c.id),
|
|
162
|
-
spans: { ...DEFAULT_TEAM_SPANS },
|
|
163
|
-
chartTypes: { ...DEFAULT_TEAM_CHART_TYPES } as Record<string, string>,
|
|
164
|
-
keyMetricsKpiCount: KEY_METRICS_KPI_COUNT_DEFAULT,
|
|
165
|
-
}
|
|
166
|
-
const ids = ALL_TEAM_DASHBOARD_CARDS.map(c => c.id)
|
|
167
|
-
const m = mergeDashboardLayoutGeneric(saved, defaults, ids)
|
|
168
|
-
return {
|
|
169
|
-
visible: m.visible,
|
|
170
|
-
order: m.order,
|
|
171
|
-
spans: m.spans as Record<string, 1 | 2>,
|
|
172
|
-
chartTypes: m.chartTypes as Record<string, ChartType>,
|
|
173
|
-
keyMetricsKpiCount: m.keyMetricsKpiCount,
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export function saveTeamDashboardLayout(layout: DashboardLayout) {
|
|
178
|
-
saveDataViewLayout("team", {
|
|
179
|
-
visible: layout.visible,
|
|
180
|
-
order: layout.order,
|
|
181
|
-
spans: layout.spans,
|
|
182
|
-
chartTypes: layout.chartTypes as Record<string, string> | undefined,
|
|
183
|
-
keyMetricsKpiCount: layout.keyMetricsKpiCount,
|
|
184
|
-
})
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function EmptyChart({ message = "No team members match the current filters." }: { message?: string }) {
|
|
188
|
-
return (
|
|
189
|
-
<div className="flex h-[200px] items-center justify-center text-sm text-muted-foreground" role="status">
|
|
190
|
-
{message}
|
|
191
|
-
</div>
|
|
192
|
-
)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function TeamByStatusChart({ members, chartType }: { members: TeamMember[]; chartType: ChartType }) {
|
|
196
|
-
const byStatus = React.useMemo(() => {
|
|
197
|
-
let active = 0
|
|
198
|
-
let away = 0
|
|
199
|
-
let invited = 0
|
|
200
|
-
for (const m of members) {
|
|
201
|
-
if (m.status === "active") active++
|
|
202
|
-
else if (m.status === "away") away++
|
|
203
|
-
else invited++
|
|
204
|
-
}
|
|
205
|
-
return [
|
|
206
|
-
{ name: "Active", value: active },
|
|
207
|
-
{ name: "Away", value: away },
|
|
208
|
-
{ name: "Invited", value: invited },
|
|
209
|
-
]
|
|
210
|
-
}, [members])
|
|
211
|
-
|
|
212
|
-
if (members.length === 0) return <EmptyChart />
|
|
213
|
-
|
|
214
|
-
const statusSummary = `Roster distribution: ${byStatus.map(d => `${d.name} ${d.value}`).join(", ")}. Total ${members.length} members.`
|
|
215
|
-
|
|
216
|
-
if (chartType === "pie") {
|
|
217
|
-
return (
|
|
218
|
-
<ChartFigure label="Members by status" summary={statusSummary} dataLength={byStatus.length}>
|
|
219
|
-
{(activeIndex) => (
|
|
220
|
-
<>
|
|
221
|
-
<ChartContainer config={STATUS_CHART_CFG} className="mx-auto aspect-square max-h-[220px] w-full">
|
|
222
|
-
<PieChart>
|
|
223
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
224
|
-
<Pie
|
|
225
|
-
data={byStatus}
|
|
226
|
-
dataKey="value"
|
|
227
|
-
nameKey="name"
|
|
228
|
-
innerRadius={48}
|
|
229
|
-
outerRadius={80}
|
|
230
|
-
strokeWidth={2}
|
|
231
|
-
stroke="var(--card)"
|
|
232
|
-
activeIndex={activeIndex ?? undefined}
|
|
233
|
-
activeShape={CHART_KBD_ACTIVE_PIE_SHAPE}
|
|
234
|
-
>
|
|
235
|
-
{byStatus.map((_, i) => (
|
|
236
|
-
<Cell
|
|
237
|
-
key={i}
|
|
238
|
-
fill={i === 0 ? "var(--color-chart-2)" : i === 1 ? "var(--color-chart-3)" : "var(--color-chart-4)"}
|
|
239
|
-
/>
|
|
240
|
-
))}
|
|
241
|
-
</Pie>
|
|
242
|
-
</PieChart>
|
|
243
|
-
</ChartContainer>
|
|
244
|
-
<ChartDataTable
|
|
245
|
-
caption="Members by status"
|
|
246
|
-
headers={["Status", "Members"]}
|
|
247
|
-
rows={byStatus.map(d => [d.name, d.value])}
|
|
248
|
-
/>
|
|
249
|
-
</>
|
|
250
|
-
)}
|
|
251
|
-
</ChartFigure>
|
|
252
|
-
)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (chartType === "horizontal-bar") {
|
|
256
|
-
return (
|
|
257
|
-
<ChartFigure label="Members by status" summary={statusSummary} dataLength={byStatus.length}>
|
|
258
|
-
{(activeIndex) => (
|
|
259
|
-
<>
|
|
260
|
-
<ChartContainer config={STATUS_CHART_CFG} className="h-[220px] w-full">
|
|
261
|
-
<BarChart data={byStatus} layout="vertical" margin={CHART_MARGIN}>
|
|
262
|
-
<CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
|
|
263
|
-
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
|
|
264
|
-
<YAxis type="category" dataKey="name" width={72} tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
|
|
265
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
266
|
-
<Bar
|
|
267
|
-
dataKey="value"
|
|
268
|
-
fill="var(--color-chart-2)"
|
|
269
|
-
radius={[0, 4, 4, 0]}
|
|
270
|
-
maxBarSize={22}
|
|
271
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
272
|
-
activeIndex={activeIndex ?? undefined}
|
|
273
|
-
>
|
|
274
|
-
{byStatus.map((_, i) => (
|
|
275
|
-
<Cell key={i} fill="var(--color-chart-2)" />
|
|
276
|
-
))}
|
|
277
|
-
</Bar>
|
|
278
|
-
</BarChart>
|
|
279
|
-
</ChartContainer>
|
|
280
|
-
<ChartDataTable
|
|
281
|
-
caption="Members by status"
|
|
282
|
-
headers={["Status", "Members"]}
|
|
283
|
-
rows={byStatus.map(d => [d.name, d.value])}
|
|
284
|
-
/>
|
|
285
|
-
</>
|
|
286
|
-
)}
|
|
287
|
-
</ChartFigure>
|
|
288
|
-
)
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
return (
|
|
292
|
-
<ChartFigure label="Members by status" summary={statusSummary} dataLength={byStatus.length}>
|
|
293
|
-
{(activeIndex) => (
|
|
294
|
-
<>
|
|
295
|
-
<ChartContainer config={STATUS_CHART_CFG} className="h-[220px] w-full">
|
|
296
|
-
<BarChart data={byStatus} margin={CHART_MARGIN}>
|
|
297
|
-
<CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
|
|
298
|
-
<XAxis dataKey="name" tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
|
|
299
|
-
<YAxis allowDecimals={false} width={36} tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
|
|
300
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
301
|
-
<Bar
|
|
302
|
-
dataKey="value"
|
|
303
|
-
fill="var(--color-chart-2)"
|
|
304
|
-
radius={[4, 4, 0, 0]}
|
|
305
|
-
maxBarSize={48}
|
|
306
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
307
|
-
activeIndex={activeIndex ?? undefined}
|
|
308
|
-
>
|
|
309
|
-
{byStatus.map((_, i) => (
|
|
310
|
-
<Cell key={i} fill="var(--color-chart-2)" />
|
|
311
|
-
))}
|
|
312
|
-
</Bar>
|
|
313
|
-
</BarChart>
|
|
314
|
-
</ChartContainer>
|
|
315
|
-
<ChartDataTable
|
|
316
|
-
caption="Members by status"
|
|
317
|
-
headers={["Status", "Members"]}
|
|
318
|
-
rows={byStatus.map(d => [d.name, d.value])}
|
|
319
|
-
/>
|
|
320
|
-
</>
|
|
321
|
-
)}
|
|
322
|
-
</ChartFigure>
|
|
323
|
-
)
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function TeamByRoleChart({ members, chartType }: { members: TeamMember[]; chartType: ChartType }) {
|
|
327
|
-
const byRole = React.useMemo(() => {
|
|
328
|
-
const map = new Map<string, number>()
|
|
329
|
-
for (const m of members) map.set(m.role, (map.get(m.role) ?? 0) + 1)
|
|
330
|
-
return [...map.entries()]
|
|
331
|
-
.map(([name, value]) => ({
|
|
332
|
-
name: name.length > 28 ? `${name.slice(0, 26)}…` : name,
|
|
333
|
-
value,
|
|
334
|
-
}))
|
|
335
|
-
.sort((a, b) => b.value - a.value)
|
|
336
|
-
.slice(0, 10)
|
|
337
|
-
}, [members])
|
|
338
|
-
|
|
339
|
-
if (members.length === 0) return <EmptyChart />
|
|
340
|
-
|
|
341
|
-
const roleSummary = `Top ${byRole.length} roles by member count.`
|
|
342
|
-
|
|
343
|
-
if (chartType === "pie") {
|
|
344
|
-
return (
|
|
345
|
-
<ChartFigure label="Members by role" summary={roleSummary} dataLength={byRole.length}>
|
|
346
|
-
{(activeIndex) => (
|
|
347
|
-
<>
|
|
348
|
-
<ChartContainer config={ROLE_CHART_CFG} className="mx-auto aspect-square max-h-[220px] w-full">
|
|
349
|
-
<PieChart>
|
|
350
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
351
|
-
<Pie
|
|
352
|
-
data={byRole}
|
|
353
|
-
dataKey="value"
|
|
354
|
-
nameKey="name"
|
|
355
|
-
innerRadius={48}
|
|
356
|
-
outerRadius={80}
|
|
357
|
-
strokeWidth={2}
|
|
358
|
-
stroke="var(--card)"
|
|
359
|
-
activeIndex={activeIndex ?? undefined}
|
|
360
|
-
activeShape={CHART_KBD_ACTIVE_PIE_SHAPE}
|
|
361
|
-
>
|
|
362
|
-
{byRole.map((_, i) => (
|
|
363
|
-
<Cell
|
|
364
|
-
key={i}
|
|
365
|
-
fill={["var(--color-chart-1)", "var(--color-chart-2)", "var(--color-chart-3)", "var(--color-chart-4)", "var(--color-chart-5)"][i % 5]}
|
|
366
|
-
/>
|
|
367
|
-
))}
|
|
368
|
-
</Pie>
|
|
369
|
-
</PieChart>
|
|
370
|
-
</ChartContainer>
|
|
371
|
-
<ChartDataTable
|
|
372
|
-
caption="Members by role"
|
|
373
|
-
headers={["Role", "Members"]}
|
|
374
|
-
rows={byRole.map(d => [d.name, d.value])}
|
|
375
|
-
/>
|
|
376
|
-
</>
|
|
377
|
-
)}
|
|
378
|
-
</ChartFigure>
|
|
379
|
-
)
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
if (chartType === "bar") {
|
|
383
|
-
return (
|
|
384
|
-
<ChartFigure label="Members by role" summary={roleSummary} dataLength={byRole.length}>
|
|
385
|
-
{(activeIndex) => (
|
|
386
|
-
<>
|
|
387
|
-
<ChartContainer config={ROLE_CHART_CFG} className="h-[220px] w-full">
|
|
388
|
-
<BarChart data={byRole} margin={CHART_MARGIN}>
|
|
389
|
-
<CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
|
|
390
|
-
<XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
391
|
-
<YAxis allowDecimals={false} width={32} tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
|
|
392
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
393
|
-
<Bar
|
|
394
|
-
dataKey="value"
|
|
395
|
-
fill="var(--color-chart-4)"
|
|
396
|
-
radius={[4, 4, 0, 0]}
|
|
397
|
-
maxBarSize={40}
|
|
398
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
399
|
-
activeIndex={activeIndex ?? undefined}
|
|
400
|
-
>
|
|
401
|
-
{byRole.map((_, i) => (
|
|
402
|
-
<Cell key={i} fill="var(--color-chart-4)" />
|
|
403
|
-
))}
|
|
404
|
-
</Bar>
|
|
405
|
-
</BarChart>
|
|
406
|
-
</ChartContainer>
|
|
407
|
-
<ChartDataTable
|
|
408
|
-
caption="Members by role"
|
|
409
|
-
headers={["Role", "Members"]}
|
|
410
|
-
rows={byRole.map(d => [d.name, d.value])}
|
|
411
|
-
/>
|
|
412
|
-
</>
|
|
413
|
-
)}
|
|
414
|
-
</ChartFigure>
|
|
415
|
-
)
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
return (
|
|
419
|
-
<ChartFigure label="Members by role" summary={roleSummary} dataLength={byRole.length}>
|
|
420
|
-
{(activeIndex) => (
|
|
421
|
-
<>
|
|
422
|
-
<ChartContainer config={ROLE_CHART_CFG} className="h-[220px] w-full">
|
|
423
|
-
<BarChart data={byRole} layout="vertical" margin={CHART_MARGIN_HORIZONTAL}>
|
|
424
|
-
<CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
|
|
425
|
-
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
|
|
426
|
-
<YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
427
|
-
<ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
|
|
428
|
-
<Bar
|
|
429
|
-
dataKey="value"
|
|
430
|
-
fill="var(--color-chart-4)"
|
|
431
|
-
radius={[0, 4, 4, 0]}
|
|
432
|
-
maxBarSize={22}
|
|
433
|
-
activeBar={CHART_KBD_ACTIVE_BAR}
|
|
434
|
-
activeIndex={activeIndex ?? undefined}
|
|
435
|
-
>
|
|
436
|
-
{byRole.map((_, i) => (
|
|
437
|
-
<Cell key={i} fill="var(--color-chart-4)" />
|
|
438
|
-
))}
|
|
439
|
-
</Bar>
|
|
440
|
-
</BarChart>
|
|
441
|
-
</ChartContainer>
|
|
442
|
-
<ChartDataTable
|
|
443
|
-
caption="Members by role"
|
|
444
|
-
headers={["Role", "Members"]}
|
|
445
|
-
rows={byRole.map(d => [d.name, d.value])}
|
|
446
|
-
/>
|
|
447
|
-
</>
|
|
448
|
-
)}
|
|
449
|
-
</ChartFigure>
|
|
450
|
-
)
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
const TEAM_CHART_RENDERERS: Record<string, React.FC<{ members: TeamMember[]; chartType: ChartType }>> = {
|
|
454
|
-
"team-by-status": TeamByStatusChart,
|
|
455
|
-
"team-by-role": TeamByRoleChart,
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
function SortableTeamDashboardCard({
|
|
459
|
-
card,
|
|
460
|
-
members,
|
|
461
|
-
span,
|
|
462
|
-
chartType,
|
|
463
|
-
cardIndex,
|
|
464
|
-
totalCards,
|
|
465
|
-
onSpanChange,
|
|
466
|
-
onChartTypeChange,
|
|
467
|
-
onRemove,
|
|
468
|
-
onMoveStep,
|
|
469
|
-
keyMetrics,
|
|
470
|
-
keyMetricsKpiCount,
|
|
471
|
-
onKeyMetricsKpiCountChange,
|
|
472
|
-
}: {
|
|
473
|
-
card: TeamDashboardCardDef
|
|
474
|
-
members: TeamMember[]
|
|
475
|
-
span: 1 | 2
|
|
476
|
-
chartType: ChartType
|
|
477
|
-
cardIndex: number
|
|
478
|
-
totalCards: number
|
|
479
|
-
onSpanChange: (id: string, span: 1 | 2) => void
|
|
480
|
-
onChartTypeChange: (id: string, t: ChartType) => void
|
|
481
|
-
onRemove: (id: string) => void
|
|
482
|
-
onMoveStep: (direction: -1 | 1) => void
|
|
483
|
-
keyMetrics?: { metrics: MetricItem[]; insight: MetricInsight } | null
|
|
484
|
-
keyMetricsKpiCount: number
|
|
485
|
-
onKeyMetricsKpiCountChange?: (n: number) => void
|
|
486
|
-
}) {
|
|
487
|
-
const {
|
|
488
|
-
attributes,
|
|
489
|
-
listeners,
|
|
490
|
-
setNodeRef,
|
|
491
|
-
setActivatorNodeRef,
|
|
492
|
-
transform,
|
|
493
|
-
transition,
|
|
494
|
-
isDragging,
|
|
495
|
-
} = useSortable({ id: card.id })
|
|
496
|
-
const { chartVariant } = useChartVariant()
|
|
497
|
-
|
|
498
|
-
const style: React.CSSProperties = {
|
|
499
|
-
...(transform ? { transform: CSS.Transform.toString(transform) } : {}),
|
|
500
|
-
transition,
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
const isKeyMetrics = card.id === KEY_METRICS_CARD_ID
|
|
504
|
-
const Renderer = isKeyMetrics ? null : TEAM_CHART_RENDERERS[card.id]
|
|
505
|
-
if (!isKeyMetrics && !Renderer) return null
|
|
506
|
-
if (isKeyMetrics && !keyMetrics) return null
|
|
507
|
-
|
|
508
|
-
const canMoveEarlier = cardIndex > 0
|
|
509
|
-
const canMoveLater = cardIndex < totalCards - 1
|
|
510
|
-
const chartLeoInsight = TEAM_CHART_LEO_INSIGHTS[card.id]
|
|
511
|
-
|
|
512
|
-
return (
|
|
513
|
-
<div
|
|
514
|
-
ref={setNodeRef}
|
|
515
|
-
style={style}
|
|
516
|
-
className={cn(
|
|
517
|
-
"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",
|
|
518
|
-
span === 2 ? "lg:col-span-2" : undefined,
|
|
519
|
-
isDragging && "z-20 opacity-95 ring-2 ring-ring",
|
|
520
|
-
)}
|
|
521
|
-
>
|
|
522
|
-
<div className="mb-2 flex w-full min-w-0 flex-wrap items-center gap-2" role="toolbar" aria-label={`${card.title} layout controls`}>
|
|
523
|
-
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
|
524
|
-
<Tip label="Drag to reorder" side="top">
|
|
525
|
-
<button
|
|
526
|
-
type="button"
|
|
527
|
-
ref={setActivatorNodeRef}
|
|
528
|
-
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"
|
|
529
|
-
aria-label={`Drag to reorder ${card.title}`}
|
|
530
|
-
{...attributes}
|
|
531
|
-
{...listeners}
|
|
532
|
-
>
|
|
533
|
-
<DragHandleGripIcon className="text-[15px]" />
|
|
534
|
-
</button>
|
|
535
|
-
</Tip>
|
|
536
|
-
{card.chartTypes.length > 0 ? (
|
|
537
|
-
<ViewSegmentedControl
|
|
538
|
-
aria-label={`Chart type for ${card.title}`}
|
|
539
|
-
iconOnly
|
|
540
|
-
value={chartType}
|
|
541
|
-
onValueChange={v => onChartTypeChange(card.id, v as ChartType)}
|
|
542
|
-
options={card.chartTypes.map(opt => ({
|
|
543
|
-
value: opt.type,
|
|
544
|
-
label: opt.label,
|
|
545
|
-
icon: opt.icon,
|
|
546
|
-
}))}
|
|
547
|
-
/>
|
|
548
|
-
) : null}
|
|
549
|
-
{isKeyMetrics && onKeyMetricsKpiCountChange ? (
|
|
550
|
-
<ViewSegmentedControl
|
|
551
|
-
aria-label="Number of KPIs to show"
|
|
552
|
-
iconOnly={false}
|
|
553
|
-
value={String(keyMetricsKpiCount)}
|
|
554
|
-
onValueChange={v => onKeyMetricsKpiCountChange(Number(v))}
|
|
555
|
-
options={Array.from(
|
|
556
|
-
{ length: KEY_METRICS_KPI_COUNT_MAX - KEY_METRICS_KPI_COUNT_MIN + 1 },
|
|
557
|
-
(_, i) => {
|
|
558
|
-
const n = KEY_METRICS_KPI_COUNT_MIN + i
|
|
559
|
-
return { value: String(n), label: String(n) }
|
|
560
|
-
},
|
|
561
|
-
)}
|
|
562
|
-
/>
|
|
563
|
-
) : null}
|
|
564
|
-
<ViewSegmentedControl
|
|
565
|
-
aria-label={`Width for ${card.title}`}
|
|
566
|
-
iconOnly
|
|
567
|
-
value={String(span) as "1" | "2"}
|
|
568
|
-
onValueChange={v => onSpanChange(card.id, Number(v) as 1 | 2)}
|
|
569
|
-
options={[
|
|
570
|
-
{ value: "1", label: "Half width", icon: "fa-light fa-table-columns" },
|
|
571
|
-
{ value: "2", label: "Full width (all columns)", icon: "fa-light fa-maximize" },
|
|
572
|
-
]}
|
|
573
|
-
/>
|
|
574
|
-
</div>
|
|
575
|
-
<div className="ms-auto flex shrink-0 items-center gap-1">
|
|
576
|
-
<div
|
|
577
|
-
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"
|
|
578
|
-
role="group"
|
|
579
|
-
aria-label={`Reorder ${card.title}`}
|
|
580
|
-
>
|
|
581
|
-
<div className="flex items-center gap-0.5 lg:hidden">
|
|
582
|
-
<Tip label="Move up" side="top">
|
|
583
|
-
<Button
|
|
584
|
-
type="button"
|
|
585
|
-
variant="ghost"
|
|
586
|
-
size="icon-sm"
|
|
587
|
-
className="size-7 shrink-0"
|
|
588
|
-
disabled={!canMoveEarlier}
|
|
589
|
-
aria-label={`Move ${card.title} up`}
|
|
590
|
-
onClick={() => onMoveStep(-1)}
|
|
591
|
-
>
|
|
592
|
-
<i className="fa-light fa-chevron-up text-xs" aria-hidden="true" />
|
|
593
|
-
</Button>
|
|
594
|
-
</Tip>
|
|
595
|
-
<Tip label="Move down" side="top">
|
|
596
|
-
<Button
|
|
597
|
-
type="button"
|
|
598
|
-
variant="ghost"
|
|
599
|
-
size="icon-sm"
|
|
600
|
-
className="size-7 shrink-0"
|
|
601
|
-
disabled={!canMoveLater}
|
|
602
|
-
aria-label={`Move ${card.title} down`}
|
|
603
|
-
onClick={() => onMoveStep(1)}
|
|
604
|
-
>
|
|
605
|
-
<i className="fa-light fa-chevron-down text-xs" aria-hidden="true" />
|
|
606
|
-
</Button>
|
|
607
|
-
</Tip>
|
|
608
|
-
</div>
|
|
609
|
-
<div className="hidden items-center gap-0.5 lg:flex">
|
|
610
|
-
<Tip label="Move left" side="top">
|
|
611
|
-
<Button
|
|
612
|
-
type="button"
|
|
613
|
-
variant="ghost"
|
|
614
|
-
size="icon-sm"
|
|
615
|
-
className="size-7 shrink-0"
|
|
616
|
-
disabled={!canMoveEarlier}
|
|
617
|
-
aria-label={`Move ${card.title} left`}
|
|
618
|
-
onClick={() => onMoveStep(-1)}
|
|
619
|
-
>
|
|
620
|
-
<i className="fa-light fa-chevron-left text-xs" aria-hidden="true" />
|
|
621
|
-
</Button>
|
|
622
|
-
</Tip>
|
|
623
|
-
<Tip label="Move right" side="top">
|
|
624
|
-
<Button
|
|
625
|
-
type="button"
|
|
626
|
-
variant="ghost"
|
|
627
|
-
size="icon-sm"
|
|
628
|
-
className="size-7 shrink-0"
|
|
629
|
-
disabled={!canMoveLater}
|
|
630
|
-
aria-label={`Move ${card.title} right`}
|
|
631
|
-
onClick={() => onMoveStep(1)}
|
|
632
|
-
>
|
|
633
|
-
<i className="fa-light fa-chevron-right text-xs" aria-hidden="true" />
|
|
634
|
-
</Button>
|
|
635
|
-
</Tip>
|
|
636
|
-
</div>
|
|
637
|
-
</div>
|
|
638
|
-
<Tip label={`Remove ${card.title}`} side="top">
|
|
639
|
-
<Button
|
|
640
|
-
type="button"
|
|
641
|
-
variant="ghost"
|
|
642
|
-
size="icon-sm"
|
|
643
|
-
className="size-8 shrink-0 text-muted-foreground hover:text-destructive"
|
|
644
|
-
aria-label={`Remove ${card.title} from dashboard`}
|
|
645
|
-
onClick={() => onRemove(card.id)}
|
|
646
|
-
>
|
|
647
|
-
<i className="fa-light fa-trash text-[13px]" aria-hidden="true" />
|
|
648
|
-
</Button>
|
|
649
|
-
</Tip>
|
|
650
|
-
</div>
|
|
651
|
-
</div>
|
|
652
|
-
{isKeyMetrics && keyMetrics ? (
|
|
653
|
-
<KeyMetrics
|
|
654
|
-
variant="card"
|
|
655
|
-
title={card.title}
|
|
656
|
-
description={card.description}
|
|
657
|
-
metrics={keyMetrics.metrics.slice(0, keyMetricsKpiCount)}
|
|
658
|
-
insight={keyMetrics.insight}
|
|
659
|
-
metricsSingleRow
|
|
660
|
-
metricsHalfWidthLayout={span === 1}
|
|
661
|
-
className="w-full min-w-0"
|
|
662
|
-
/>
|
|
663
|
-
) : (
|
|
664
|
-
<ChartCard
|
|
665
|
-
variant={chartVariant}
|
|
666
|
-
title={card.title}
|
|
667
|
-
description={card.description}
|
|
668
|
-
className="!h-auto min-h-0 shrink-0"
|
|
669
|
-
leoInsight={chartLeoInsight}
|
|
670
|
-
>
|
|
671
|
-
{Renderer ? <Renderer members={members} chartType={chartType} /> : null}
|
|
672
|
-
</ChartCard>
|
|
673
|
-
)}
|
|
674
|
-
</div>
|
|
675
|
-
)
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
export interface TeamDashboardChartsSectionProps {
|
|
679
|
-
members: TeamMember[]
|
|
680
|
-
keyMetrics: { metrics: MetricItem[]; insight: MetricInsight }
|
|
681
|
-
visibleCards: string[]
|
|
682
|
-
cardOrder: string[]
|
|
683
|
-
cardSpans?: Record<string, 1 | 2>
|
|
684
|
-
cardChartTypes?: Record<string, ChartType>
|
|
685
|
-
keyMetricsKpiCount?: number
|
|
686
|
-
layoutEditMode?: boolean
|
|
687
|
-
onVisibleChange?: (visible: string[]) => void
|
|
688
|
-
onOrderChange?: (order: string[]) => void
|
|
689
|
-
onSpanChange?: (id: string, span: 1 | 2) => void
|
|
690
|
-
onChartTypeChange?: (id: string, chartType: ChartType) => void
|
|
691
|
-
onKeyMetricsKpiCountChange?: (count: number) => void
|
|
692
|
-
onResetLayout?: () => void
|
|
693
|
-
onLayoutEditDone?: () => void
|
|
694
|
-
onLayoutEditCancel?: () => void
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
export function TeamDashboardChartsSection({
|
|
698
|
-
members,
|
|
699
|
-
keyMetrics,
|
|
700
|
-
visibleCards,
|
|
701
|
-
cardOrder,
|
|
702
|
-
cardSpans = DEFAULT_TEAM_SPANS,
|
|
703
|
-
cardChartTypes = DEFAULT_TEAM_CHART_TYPES,
|
|
704
|
-
keyMetricsKpiCount = KEY_METRICS_KPI_COUNT_DEFAULT,
|
|
705
|
-
layoutEditMode = false,
|
|
706
|
-
onVisibleChange,
|
|
707
|
-
onOrderChange,
|
|
708
|
-
onSpanChange,
|
|
709
|
-
onChartTypeChange,
|
|
710
|
-
onKeyMetricsKpiCountChange,
|
|
711
|
-
onResetLayout,
|
|
712
|
-
onLayoutEditDone,
|
|
713
|
-
onLayoutEditCancel,
|
|
714
|
-
}: TeamDashboardChartsSectionProps) {
|
|
715
|
-
const { chartVariant } = useChartVariant()
|
|
716
|
-
const defs = React.useMemo(() => new Map(ALL_TEAM_DASHBOARD_CARDS.map(c => [c.id, c])), [])
|
|
717
|
-
|
|
718
|
-
const orderedCards = React.useMemo(() => {
|
|
719
|
-
return cardOrder
|
|
720
|
-
.filter(id => visibleCards.includes(id) && defs.has(id))
|
|
721
|
-
.map(id => defs.get(id)!)
|
|
722
|
-
}, [visibleCards, cardOrder, defs])
|
|
723
|
-
|
|
724
|
-
const hiddenCardDefs = React.useMemo(
|
|
725
|
-
() => ALL_TEAM_DASHBOARD_CARDS.filter(c => !visibleCards.includes(c.id)),
|
|
726
|
-
[visibleCards],
|
|
727
|
-
)
|
|
728
|
-
|
|
729
|
-
const sortableIds = React.useMemo(() => orderedCards.map(c => c.id), [orderedCards])
|
|
730
|
-
|
|
731
|
-
const sensors = useSensors(
|
|
732
|
-
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
|
733
|
-
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
734
|
-
)
|
|
735
|
-
|
|
736
|
-
const handleDragEnd = React.useCallback(
|
|
737
|
-
(event: DragEndEvent) => {
|
|
738
|
-
if (!onOrderChange) return
|
|
739
|
-
const { active, over } = event
|
|
740
|
-
if (!over || active.id === over.id) return
|
|
741
|
-
const oldIndex = sortableIds.indexOf(String(active.id))
|
|
742
|
-
const newIndex = sortableIds.indexOf(String(over.id))
|
|
743
|
-
if (oldIndex < 0 || newIndex < 0) return
|
|
744
|
-
const nextVisibleOrder = arrayMove(sortableIds, oldIndex, newIndex)
|
|
745
|
-
const visibleSet = new Set(visibleCards)
|
|
746
|
-
onOrderChange(applyVisibleReorder(cardOrder, visibleSet, nextVisibleOrder))
|
|
747
|
-
},
|
|
748
|
-
[cardOrder, onOrderChange, sortableIds, visibleCards],
|
|
749
|
-
)
|
|
750
|
-
|
|
751
|
-
const moveStep = React.useCallback(
|
|
752
|
-
(id: string, direction: -1 | 1) => {
|
|
753
|
-
if (!onOrderChange) return
|
|
754
|
-
const idx = sortableIds.indexOf(id)
|
|
755
|
-
if (idx < 0) return
|
|
756
|
-
const newIdx = idx + direction
|
|
757
|
-
if (newIdx < 0 || newIdx >= sortableIds.length) return
|
|
758
|
-
const nextVisibleOrder = arrayMove(sortableIds, idx, newIdx)
|
|
759
|
-
const visibleSet = new Set(visibleCards)
|
|
760
|
-
onOrderChange(applyVisibleReorder(cardOrder, visibleSet, nextVisibleOrder))
|
|
761
|
-
},
|
|
762
|
-
[cardOrder, onOrderChange, sortableIds, visibleCards],
|
|
763
|
-
)
|
|
764
|
-
|
|
765
|
-
const addCard = React.useCallback(
|
|
766
|
-
(id: string) => {
|
|
767
|
-
if (!onVisibleChange) return
|
|
768
|
-
if (visibleCards.includes(id)) return
|
|
769
|
-
onVisibleChange([...visibleCards, id])
|
|
770
|
-
},
|
|
771
|
-
[onVisibleChange, visibleCards],
|
|
772
|
-
)
|
|
773
|
-
|
|
774
|
-
const removeCard = React.useCallback(
|
|
775
|
-
(id: string) => {
|
|
776
|
-
if (!onVisibleChange) return
|
|
777
|
-
onVisibleChange(visibleCards.filter(v => v !== id))
|
|
778
|
-
},
|
|
779
|
-
[onVisibleChange, visibleCards],
|
|
780
|
-
)
|
|
781
|
-
|
|
782
|
-
if (orderedCards.length === 0) {
|
|
783
|
-
return (
|
|
784
|
-
<div className="flex flex-col items-center justify-center gap-3 px-4 py-12 text-center lg:px-6">
|
|
785
|
-
<i className="fa-light fa-chart-column text-2xl text-muted-foreground/40" aria-hidden="true" />
|
|
786
|
-
<p className="text-sm text-muted-foreground">
|
|
787
|
-
No widgets on the dashboard.
|
|
788
|
-
{layoutEditMode && hiddenCardDefs.length > 0 ? " Add a widget below." : " Turn on Edit layout and add widgets back."}
|
|
789
|
-
</p>
|
|
790
|
-
{layoutEditMode && hiddenCardDefs.length > 0 && onVisibleChange ? (
|
|
791
|
-
<DropdownMenu>
|
|
792
|
-
<DropdownMenuTrigger asChild>
|
|
793
|
-
<Button type="button" variant="outline" size="sm" className="size-9 p-0" aria-label="Add widget">
|
|
794
|
-
<i className="fa-light fa-plus text-sm" aria-hidden="true" />
|
|
795
|
-
</Button>
|
|
796
|
-
</DropdownMenuTrigger>
|
|
797
|
-
<DropdownMenuContent align="center">
|
|
798
|
-
{hiddenCardDefs.map(c => (
|
|
799
|
-
<DropdownMenuItem key={c.id} onSelect={() => addCard(c.id)}>
|
|
800
|
-
{c.title}
|
|
801
|
-
</DropdownMenuItem>
|
|
802
|
-
))}
|
|
803
|
-
</DropdownMenuContent>
|
|
804
|
-
</DropdownMenu>
|
|
805
|
-
) : null}
|
|
806
|
-
</div>
|
|
807
|
-
)
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
const grid = (
|
|
811
|
-
<div
|
|
812
|
-
className={cn(
|
|
813
|
-
"grid grid-cols-1 gap-4 lg:grid-cols-2",
|
|
814
|
-
layoutEditMode && "lg:items-start lg:content-start lg:auto-rows-min",
|
|
815
|
-
)}
|
|
816
|
-
>
|
|
817
|
-
{orderedCards.map((card, cardIndex) => {
|
|
818
|
-
const isKeyMetricsCard = card.id === KEY_METRICS_CARD_ID
|
|
819
|
-
const Renderer = isKeyMetricsCard ? null : TEAM_CHART_RENDERERS[card.id]
|
|
820
|
-
if (!isKeyMetricsCard && !Renderer) return null
|
|
821
|
-
const span = cardSpans[card.id] ?? card.defaultSpan
|
|
822
|
-
const requestedType = cardChartTypes[card.id] ?? card.defaultChartType
|
|
823
|
-
const allowedTypes = card.chartTypes.map(o => o.type)
|
|
824
|
-
const chartType =
|
|
825
|
-
allowedTypes.length === 0
|
|
826
|
-
? card.defaultChartType
|
|
827
|
-
: allowedTypes.includes(requestedType)
|
|
828
|
-
? requestedType
|
|
829
|
-
: card.defaultChartType
|
|
830
|
-
|
|
831
|
-
if (
|
|
832
|
-
layoutEditMode &&
|
|
833
|
-
onOrderChange &&
|
|
834
|
-
onSpanChange &&
|
|
835
|
-
onChartTypeChange &&
|
|
836
|
-
onVisibleChange
|
|
837
|
-
) {
|
|
838
|
-
return (
|
|
839
|
-
<SortableTeamDashboardCard
|
|
840
|
-
key={card.id}
|
|
841
|
-
card={card}
|
|
842
|
-
members={members}
|
|
843
|
-
span={span}
|
|
844
|
-
chartType={chartType}
|
|
845
|
-
cardIndex={cardIndex}
|
|
846
|
-
totalCards={orderedCards.length}
|
|
847
|
-
onSpanChange={onSpanChange}
|
|
848
|
-
onChartTypeChange={onChartTypeChange}
|
|
849
|
-
onRemove={removeCard}
|
|
850
|
-
onMoveStep={dir => moveStep(card.id, dir)}
|
|
851
|
-
keyMetrics={isKeyMetricsCard ? keyMetrics : null}
|
|
852
|
-
keyMetricsKpiCount={keyMetricsKpiCount}
|
|
853
|
-
onKeyMetricsKpiCountChange={
|
|
854
|
-
isKeyMetricsCard ? onKeyMetricsKpiCountChange : undefined
|
|
855
|
-
}
|
|
856
|
-
/>
|
|
857
|
-
)
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
return (
|
|
861
|
-
<div
|
|
862
|
-
key={card.id}
|
|
863
|
-
className={cn(span === 2 ? "lg:col-span-2" : undefined)}
|
|
864
|
-
>
|
|
865
|
-
{isKeyMetricsCard ? (
|
|
866
|
-
<KeyMetrics
|
|
867
|
-
variant="card"
|
|
868
|
-
title={card.title}
|
|
869
|
-
description={card.description}
|
|
870
|
-
metrics={keyMetrics.metrics.slice(0, keyMetricsKpiCount)}
|
|
871
|
-
insight={keyMetrics.insight}
|
|
872
|
-
metricsSingleRow
|
|
873
|
-
metricsHalfWidthLayout={span === 1}
|
|
874
|
-
className="w-full min-w-0"
|
|
875
|
-
/>
|
|
876
|
-
) : (
|
|
877
|
-
<ChartCard
|
|
878
|
-
variant={chartVariant}
|
|
879
|
-
title={card.title}
|
|
880
|
-
description={card.description}
|
|
881
|
-
leoInsight={TEAM_CHART_LEO_INSIGHTS[card.id]}
|
|
882
|
-
>
|
|
883
|
-
{Renderer ? <Renderer members={members} chartType={chartType} /> : null}
|
|
884
|
-
</ChartCard>
|
|
885
|
-
)}
|
|
886
|
-
</div>
|
|
887
|
-
)
|
|
888
|
-
})}
|
|
889
|
-
</div>
|
|
890
|
-
)
|
|
891
|
-
|
|
892
|
-
const editToolbar =
|
|
893
|
-
layoutEditMode && onVisibleChange && onResetLayout ? (
|
|
894
|
-
<div
|
|
895
|
-
className="mb-3 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-transparent px-3 py-2"
|
|
896
|
-
role="region"
|
|
897
|
-
aria-label="Dashboard layout options"
|
|
898
|
-
>
|
|
899
|
-
<p className="text-xs text-muted-foreground">Drag cards to reorder. Changes save automatically.</p>
|
|
900
|
-
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
901
|
-
<Button
|
|
902
|
-
type="button"
|
|
903
|
-
size="sm"
|
|
904
|
-
variant="ghost"
|
|
905
|
-
className="h-8 text-xs"
|
|
906
|
-
onClick={() => onVisibleChange(ALL_TEAM_DASHBOARD_CARDS.map(c => c.id))}
|
|
907
|
-
>
|
|
908
|
-
Show all
|
|
909
|
-
</Button>
|
|
910
|
-
<Button type="button" size="sm" variant="ghost" className="h-8 text-xs" onClick={() => onVisibleChange([])}>
|
|
911
|
-
Hide all
|
|
912
|
-
</Button>
|
|
913
|
-
<Tip side="bottom" label="Reset visibility, order, widths, and chart types">
|
|
914
|
-
<Button type="button" size="sm" variant="ghost" className="h-8 px-2 text-xs" onClick={onResetLayout}>
|
|
915
|
-
<i className="fa-light fa-rotate-left me-1 text-xs" aria-hidden="true" />
|
|
916
|
-
Reset
|
|
917
|
-
</Button>
|
|
918
|
-
</Tip>
|
|
919
|
-
{hiddenCardDefs.length > 0 ? (
|
|
920
|
-
<DropdownMenu>
|
|
921
|
-
<DropdownMenuTrigger asChild>
|
|
922
|
-
<Button type="button" variant="outline" size="sm" className="size-8 p-0" aria-label="Add widget">
|
|
923
|
-
<i className="fa-light fa-plus text-[13px]" aria-hidden="true" />
|
|
924
|
-
</Button>
|
|
925
|
-
</DropdownMenuTrigger>
|
|
926
|
-
<DropdownMenuContent align="end">
|
|
927
|
-
{hiddenCardDefs.map(c => (
|
|
928
|
-
<DropdownMenuItem key={c.id} onSelect={() => addCard(c.id)}>
|
|
929
|
-
{c.title}
|
|
930
|
-
</DropdownMenuItem>
|
|
931
|
-
))}
|
|
932
|
-
</DropdownMenuContent>
|
|
933
|
-
</DropdownMenu>
|
|
934
|
-
) : null}
|
|
935
|
-
{onLayoutEditCancel ? (
|
|
936
|
-
<Button type="button" size="sm" variant="outline" className="h-8 text-xs" onClick={onLayoutEditCancel}>
|
|
937
|
-
Cancel
|
|
938
|
-
</Button>
|
|
939
|
-
) : null}
|
|
940
|
-
{onLayoutEditDone ? (
|
|
941
|
-
<Button type="button" size="sm" className="h-8 text-xs" onClick={onLayoutEditDone}>
|
|
942
|
-
Done
|
|
943
|
-
</Button>
|
|
944
|
-
) : null}
|
|
945
|
-
</div>
|
|
946
|
-
</div>
|
|
947
|
-
) : null
|
|
948
|
-
|
|
949
|
-
const gridBody =
|
|
950
|
-
layoutEditMode && onOrderChange ? (
|
|
951
|
-
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleDragEnd}>
|
|
952
|
-
<SortableContext items={sortableIds} strategy={rectSortingStrategy}>
|
|
953
|
-
{grid}
|
|
954
|
-
</SortableContext>
|
|
955
|
-
</DndContext>
|
|
956
|
-
) : (
|
|
957
|
-
grid
|
|
958
|
-
)
|
|
959
|
-
|
|
960
|
-
return (
|
|
961
|
-
<div
|
|
962
|
-
className={cn(
|
|
963
|
-
"flex flex-col gap-4 px-4 pb-2 lg:px-6",
|
|
964
|
-
layoutEditMode && "rounded-xl border border-dashed border-border/80 bg-transparent py-3",
|
|
965
|
-
)}
|
|
966
|
-
>
|
|
967
|
-
{editToolbar}
|
|
968
|
-
{gridBody}
|
|
969
|
-
</div>
|
|
970
|
-
)
|
|
971
|
-
}
|