@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,934 +0,0 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* PlacementsTable — placements hub composed on top of the centralized `<HubTable>`. Owns:
|
|
5
|
-
* placement-specific column defs / cells, board/list/folder/tree/panel/dashboard renderers,
|
|
6
|
-
* pagination chrome wrapping the table + list views, and the dashboard layout state.
|
|
7
|
-
*
|
|
8
|
-
* Single dataset rule: `HubTable` runs one `useTableState(rows, columns, …)`. Every renderer
|
|
9
|
-
* (board, list, folder, tree, panel, dashboard) reads `state.rows`/`state.pagedRows` — the same
|
|
10
|
-
* filtered/sorted/paged bag as the grid.
|
|
11
|
-
*
|
|
12
|
-
* View tabs drive `view` (table | list | board | …). One canonical column set + row bag —
|
|
13
|
-
* lifecycle segmentation has been removed; every tab sees the same placements.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import * as React from "react"
|
|
17
|
-
import dynamic from "next/dynamic"
|
|
18
|
-
import { useRouter } from "next/navigation"
|
|
19
|
-
import { cn } from "@/lib/utils"
|
|
20
|
-
import { mailtoHref } from "@/lib/mailto"
|
|
21
|
-
import { Button } from "@/components/ui/button"
|
|
22
|
-
import { Tip } from "@/components/ui/tip"
|
|
23
|
-
import { Skeleton } from "@/components/ui/skeleton"
|
|
24
|
-
import { AvatarInitials } from "@/components/ui/avatar"
|
|
25
|
-
import { CoachMark } from "@/components/ui/coach-mark"
|
|
26
|
-
import { useCoachMark } from "@/hooks/use-coach-mark"
|
|
27
|
-
import { DASHBOARD_CUSTOMIZE_COACH_STEPS } from "@/lib/dashboard-customize-coach-mark"
|
|
28
|
-
import { KEY_METRICS_KPI_COUNT_DEFAULT } from "@/lib/dashboard-layout-merge"
|
|
29
|
-
import {
|
|
30
|
-
ALL_DASHBOARD_CARDS,
|
|
31
|
-
DEFAULT_VISIBLE_CARDS,
|
|
32
|
-
DEFAULT_SPANS,
|
|
33
|
-
DEFAULT_CHART_TYPES,
|
|
34
|
-
loadDashboardLayout,
|
|
35
|
-
mergeDashboardLayout,
|
|
36
|
-
saveDashboardLayout,
|
|
37
|
-
type ChartType,
|
|
38
|
-
type DashboardLayout,
|
|
39
|
-
} from "@/lib/data-view-dashboard-placements-layout"
|
|
40
|
-
import { PlacementsBoardView, type PlacementsBoardColumnMenu } from "@/components/placements-board-view"
|
|
41
|
-
import {
|
|
42
|
-
PlacementListRowContent,
|
|
43
|
-
PLACEMENT_LIST_ESTIMATE_ROW_PX,
|
|
44
|
-
PLACEMENT_LIST_VIRTUAL_ROWS_THRESHOLD,
|
|
45
|
-
} from "@/components/placements-list-view"
|
|
46
|
-
import {
|
|
47
|
-
FolderGridView,
|
|
48
|
-
ListPageTreePanelShell,
|
|
49
|
-
HubTable,
|
|
50
|
-
type HubTableHandle,
|
|
51
|
-
type HubTableRenderers,
|
|
52
|
-
type HubTableRendererArgs,
|
|
53
|
-
} from "@/components/data-views"
|
|
54
|
-
import { DataRowList } from "@/components/data-views/data-row-list"
|
|
55
|
-
import { ListPageSplitHubChrome } from "@/components/data-views/list-page-split-hub-chrome"
|
|
56
|
-
import { ListPageTreeColumnHeader } from "@/components/data-views/list-page-tree-column-header"
|
|
57
|
-
import { FinderPanelView, type FinderGroup } from "@/components/data-views/finder-panel-view"
|
|
58
|
-
import { ListPageSplitDetailsPlaceholder } from "@/components/data-views/list-page-split-details-placeholder"
|
|
59
|
-
import { getConditionalRowBackground } from "@/lib/conditional-rule-match"
|
|
60
|
-
import { isBoardFieldActive } from "@/lib/placement-board-card-layout"
|
|
61
|
-
import { TablePropertiesDrawerButton } from "@/components/table-properties"
|
|
62
|
-
import type { DataListViewType } from "@/lib/data-list-view"
|
|
63
|
-
import {
|
|
64
|
-
DEFAULT_DATA_LIST_DISPLAY_OPTIONS,
|
|
65
|
-
type DataListDisplayOptions,
|
|
66
|
-
} from "@/lib/data-list-display-options"
|
|
67
|
-
import { StatusBadge } from "@/components/placements-table-cells"
|
|
68
|
-
import { DataTable, DataTableToolbar } from "@/components/data-table"
|
|
69
|
-
import { CountSyncer, PaginationBar } from "@/components/data-table/pagination"
|
|
70
|
-
import type { ColumnDef, ConditionalRule } from "@/components/data-table/types"
|
|
71
|
-
import { useTableState } from "@/components/data-table/use-table-state"
|
|
72
|
-
import { ALL_PLACEMENTS, type Placement, type Status } from "@/lib/mock/placements"
|
|
73
|
-
import {
|
|
74
|
-
getPlacementColumns,
|
|
75
|
-
PLACEMENT_DRAWER_LABEL,
|
|
76
|
-
PLACEMENT_EMPTY_COPY,
|
|
77
|
-
} from "@/components/placements-table-columns"
|
|
78
|
-
import { placementKpiInsightFromRows, placementKpiMetricsFromRows } from "@/lib/mock/placements-kpi"
|
|
79
|
-
import { PLACEMENTS_SUPPORTED_VIEWS } from "@/lib/placements-supported-views"
|
|
80
|
-
|
|
81
|
-
// ─── Dynamic dashboard charts section (heavy; loaded only on dashboard tab) ──
|
|
82
|
-
|
|
83
|
-
const PlacementsDashboardChartsSection = dynamic(
|
|
84
|
-
() =>
|
|
85
|
-
import("@/components/data-view-dashboard-charts").then(mod => ({
|
|
86
|
-
default: mod.PlacementsDashboardChartsSection,
|
|
87
|
-
})),
|
|
88
|
-
{
|
|
89
|
-
ssr: false,
|
|
90
|
-
loading: () => (
|
|
91
|
-
<div className="mx-4 mb-8 mt-2 flex flex-col gap-3 border border-border rounded-xl p-6 lg:mx-6">
|
|
92
|
-
<Skeleton className="h-7 w-48 max-w-full" />
|
|
93
|
-
<Skeleton className="min-h-[200px] w-full rounded-lg" />
|
|
94
|
-
<Skeleton className="min-h-[200px] w-full rounded-lg" />
|
|
95
|
-
</div>
|
|
96
|
-
),
|
|
97
|
-
},
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
// ─── Placement-specific tile for FolderGridView ──────────────────────────────
|
|
101
|
-
|
|
102
|
-
function PlacementFolderTile({
|
|
103
|
-
row,
|
|
104
|
-
hiddenColKeys,
|
|
105
|
-
boardColumns,
|
|
106
|
-
conditionalRules,
|
|
107
|
-
onClick,
|
|
108
|
-
}: {
|
|
109
|
-
row: Placement
|
|
110
|
-
hiddenColKeys: Set<string>
|
|
111
|
-
boardColumns: ColumnDef<Placement>[]
|
|
112
|
-
conditionalRules?: ConditionalRule[]
|
|
113
|
-
onClick: () => void
|
|
114
|
-
}) {
|
|
115
|
-
const ruleBg = getConditionalRowBackground(row, conditionalRules, boardColumns)
|
|
116
|
-
const showStudent = isBoardFieldActive("student", hiddenColKeys, boardColumns)
|
|
117
|
-
const showStatus = isBoardFieldActive("status", hiddenColKeys, boardColumns)
|
|
118
|
-
const showSite = isBoardFieldActive("site", hiddenColKeys, boardColumns)
|
|
119
|
-
const showSpec = isBoardFieldActive("specialization", hiddenColKeys, boardColumns)
|
|
120
|
-
const showProgram = isBoardFieldActive("program", hiddenColKeys, boardColumns)
|
|
121
|
-
const name = showStudent ? row.student : `Placement ${row.id}`
|
|
122
|
-
|
|
123
|
-
const statusDotClass: Record<Status, string> = {
|
|
124
|
-
confirmed: "bg-success",
|
|
125
|
-
pending: "bg-warning",
|
|
126
|
-
"under-review": "bg-brand",
|
|
127
|
-
completed: "bg-muted-foreground",
|
|
128
|
-
rejected: "bg-destructive",
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return (
|
|
132
|
-
<button
|
|
133
|
-
type="button"
|
|
134
|
-
onClick={onClick}
|
|
135
|
-
className={`group relative flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-left hover:border-interactive-hover hover:bg-interactive-hover/30 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-all duration-100 cursor-pointer select-none w-full ${ruleBg}`}
|
|
136
|
-
aria-label={`Open ${name}`}
|
|
137
|
-
>
|
|
138
|
-
<div className="relative">
|
|
139
|
-
<AvatarInitials initials={row.initials} className="size-14 rounded-full text-lg font-semibold" />
|
|
140
|
-
{showStatus && (
|
|
141
|
-
<span className="absolute -bottom-0.5 -right-1 flex size-4 items-center justify-center rounded-full bg-card ring-2 ring-card" aria-hidden="true">
|
|
142
|
-
<span className={`size-2.5 rounded-full ${statusDotClass[row.status]}`} />
|
|
143
|
-
</span>
|
|
144
|
-
)}
|
|
145
|
-
</div>
|
|
146
|
-
<p className="w-full text-center text-[13px] font-medium text-foreground leading-tight line-clamp-2">{name}</p>
|
|
147
|
-
{showStatus && <StatusBadge status={row.status} />}
|
|
148
|
-
{(showSite || showSpec || showProgram) && (
|
|
149
|
-
<div className="flex w-full flex-col gap-0.5">
|
|
150
|
-
{showSite && (
|
|
151
|
-
<p className="truncate text-center text-[11px] text-muted-foreground leading-tight">
|
|
152
|
-
<i className="fa-light fa-building me-1" aria-hidden="true" />{row.site}
|
|
153
|
-
</p>
|
|
154
|
-
)}
|
|
155
|
-
{showSpec && <p className="truncate text-center text-[11px] text-muted-foreground leading-tight">{row.specialization}</p>}
|
|
156
|
-
{showProgram && <p className="truncate text-center text-[11px] text-muted-foreground leading-tight">{row.program}</p>}
|
|
157
|
-
</div>
|
|
158
|
-
)}
|
|
159
|
-
</button>
|
|
160
|
-
)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// ─── Placement-specific list row for FinderPanelView ─────────────────────────
|
|
164
|
-
|
|
165
|
-
function PlacementFinderListRow({
|
|
166
|
-
row,
|
|
167
|
-
isSelected,
|
|
168
|
-
hiddenColKeys,
|
|
169
|
-
boardColumns,
|
|
170
|
-
conditionalRules,
|
|
171
|
-
}: {
|
|
172
|
-
row: Placement
|
|
173
|
-
isSelected: boolean
|
|
174
|
-
hiddenColKeys: Set<string>
|
|
175
|
-
boardColumns: ColumnDef<Placement>[]
|
|
176
|
-
conditionalRules?: ConditionalRule[]
|
|
177
|
-
}) {
|
|
178
|
-
const ruleBg = getConditionalRowBackground(row, conditionalRules, boardColumns)
|
|
179
|
-
const showStudent = isBoardFieldActive("student", hiddenColKeys, boardColumns)
|
|
180
|
-
const showSite = isBoardFieldActive("site", hiddenColKeys, boardColumns)
|
|
181
|
-
const name = showStudent ? row.student : `Placement ${row.id}`
|
|
182
|
-
return (
|
|
183
|
-
<div
|
|
184
|
-
className={`flex w-full min-w-0 items-center gap-3 transition-colors duration-75 ${
|
|
185
|
-
isSelected ? "bg-transparent text-accent-foreground" : cn("text-foreground", ruleBg)
|
|
186
|
-
}`}
|
|
187
|
-
>
|
|
188
|
-
<AvatarInitials
|
|
189
|
-
initials={row.initials}
|
|
190
|
-
className={cn(
|
|
191
|
-
"size-8 shrink-0 rounded-full text-[11px] font-semibold",
|
|
192
|
-
isSelected ? "ring-2 ring-accent-foreground/35" : "",
|
|
193
|
-
)}
|
|
194
|
-
/>
|
|
195
|
-
<div className="min-w-0 flex-1">
|
|
196
|
-
<p className={cn("truncate text-[13px] font-medium leading-tight", isSelected ? "text-accent-foreground" : "text-foreground")}>
|
|
197
|
-
{name}
|
|
198
|
-
</p>
|
|
199
|
-
{showSite && (
|
|
200
|
-
<p className={cn("mt-0.5 truncate text-[11px] leading-tight", isSelected ? "text-accent-foreground/80" : "text-muted-foreground")}>
|
|
201
|
-
{row.site}
|
|
202
|
-
</p>
|
|
203
|
-
)}
|
|
204
|
-
</div>
|
|
205
|
-
{!isSelected && <StatusBadge status={row.status} />}
|
|
206
|
-
</div>
|
|
207
|
-
)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// ─── Placement-specific detail pane for FinderPanelView ──────────────────────
|
|
211
|
-
|
|
212
|
-
function PlacementFinderDetail({
|
|
213
|
-
row,
|
|
214
|
-
hiddenColKeys,
|
|
215
|
-
boardColumns,
|
|
216
|
-
}: {
|
|
217
|
-
row: Placement
|
|
218
|
-
hiddenColKeys: Set<string>
|
|
219
|
-
boardColumns: ColumnDef<Placement>[]
|
|
220
|
-
}) {
|
|
221
|
-
const router = useRouter()
|
|
222
|
-
const show = (key: string) => isBoardFieldActive(key, hiddenColKeys, boardColumns)
|
|
223
|
-
return (
|
|
224
|
-
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
225
|
-
<div className="flex shrink-0 items-start gap-4 border-b border-border px-5 py-4">
|
|
226
|
-
<AvatarInitials initials={row.initials} className="size-14 shrink-0 rounded-full text-lg font-semibold" />
|
|
227
|
-
<div className="min-w-0 flex-1">
|
|
228
|
-
<h2 className="text-base font-semibold text-foreground leading-tight">{row.student}</h2>
|
|
229
|
-
{show("program") && <p className="mt-0.5 text-[13px] text-muted-foreground">{row.program}</p>}
|
|
230
|
-
{show("status") && <div className="mt-2"><StatusBadge status={row.status} /></div>}
|
|
231
|
-
</div>
|
|
232
|
-
<Tip side="bottom" label="Open full detail page">
|
|
233
|
-
<Button
|
|
234
|
-
type="button"
|
|
235
|
-
variant="outline"
|
|
236
|
-
size="sm"
|
|
237
|
-
className="shrink-0"
|
|
238
|
-
onClick={() => router.push(`/data-list/${row.id}`)}
|
|
239
|
-
aria-label={`Open full detail for ${row.student}`}
|
|
240
|
-
>
|
|
241
|
-
<i className="fa-light fa-arrow-up-right-from-square text-[12px]" aria-hidden="true" />
|
|
242
|
-
Open
|
|
243
|
-
</Button>
|
|
244
|
-
</Tip>
|
|
245
|
-
</div>
|
|
246
|
-
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
|
247
|
-
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
248
|
-
{show("email") && (
|
|
249
|
-
<div className="flex flex-col gap-0.5">
|
|
250
|
-
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
|
|
251
|
-
<i className="fa-light fa-envelope text-[10px]" aria-hidden="true" /> Email
|
|
252
|
-
</dt>
|
|
253
|
-
<dd className="text-[13px]">
|
|
254
|
-
<a href={mailtoHref(row.email)} className="text-interactive-foreground hover:underline">{row.email}</a>
|
|
255
|
-
</dd>
|
|
256
|
-
</div>
|
|
257
|
-
)}
|
|
258
|
-
{show("site") && (
|
|
259
|
-
<div className="flex flex-col gap-0.5">
|
|
260
|
-
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
|
|
261
|
-
<i className="fa-light fa-building text-[10px]" aria-hidden="true" /> Site
|
|
262
|
-
</dt>
|
|
263
|
-
<dd className="text-[13px] text-foreground">{row.site}</dd>
|
|
264
|
-
</div>
|
|
265
|
-
)}
|
|
266
|
-
{show("internship") && (
|
|
267
|
-
<div className="flex flex-col gap-0.5">
|
|
268
|
-
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
|
|
269
|
-
<i className="fa-light fa-briefcase text-[10px]" aria-hidden="true" /> Internship
|
|
270
|
-
</dt>
|
|
271
|
-
<dd className="text-[13px] text-foreground">{row.internship}</dd>
|
|
272
|
-
</div>
|
|
273
|
-
)}
|
|
274
|
-
{show("specialization") && (
|
|
275
|
-
<div className="flex flex-col gap-0.5">
|
|
276
|
-
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
|
|
277
|
-
<i className="fa-light fa-stethoscope text-[10px]" aria-hidden="true" /> Specialization
|
|
278
|
-
</dt>
|
|
279
|
-
<dd className="text-[13px] text-foreground">{row.specialization}</dd>
|
|
280
|
-
</div>
|
|
281
|
-
)}
|
|
282
|
-
{show("supervisor") && (
|
|
283
|
-
<div className="flex flex-col gap-0.5">
|
|
284
|
-
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
|
|
285
|
-
<i className="fa-light fa-user-tie text-[10px]" aria-hidden="true" /> Supervisor
|
|
286
|
-
</dt>
|
|
287
|
-
<dd className="text-[13px] text-foreground">{row.supervisor}</dd>
|
|
288
|
-
</div>
|
|
289
|
-
)}
|
|
290
|
-
{show("start") && (
|
|
291
|
-
<div className="flex flex-col gap-0.5">
|
|
292
|
-
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
|
|
293
|
-
<i className="fa-light fa-calendar text-[10px]" aria-hidden="true" /> Start Date
|
|
294
|
-
</dt>
|
|
295
|
-
<dd className="text-[13px] text-foreground">{row.start}</dd>
|
|
296
|
-
</div>
|
|
297
|
-
)}
|
|
298
|
-
{show("duration") && (
|
|
299
|
-
<div className="flex flex-col gap-0.5">
|
|
300
|
-
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
|
|
301
|
-
<i className="fa-light fa-clock text-[10px]" aria-hidden="true" /> Duration
|
|
302
|
-
</dt>
|
|
303
|
-
<dd className="text-[13px] text-foreground">{row.duration}</dd>
|
|
304
|
-
</div>
|
|
305
|
-
)}
|
|
306
|
-
{row.placementPhase === "ongoing" && (
|
|
307
|
-
<div className="flex flex-col gap-0.5 sm:col-span-2">
|
|
308
|
-
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
|
|
309
|
-
<i className="fa-light fa-chart-line text-[10px]" aria-hidden="true" /> Progress
|
|
310
|
-
</dt>
|
|
311
|
-
<dd className="text-[13px] text-foreground flex flex-col gap-1.5">
|
|
312
|
-
<span>{row.progressWeeksDone} / {row.progressWeeksTotal} weeks</span>
|
|
313
|
-
<div role="progressbar" aria-valuenow={row.progressWeeksDone} aria-valuemin={0} aria-valuemax={row.progressWeeksTotal}
|
|
314
|
-
aria-label={`${row.progressWeeksDone} of ${row.progressWeeksTotal} weeks completed`}
|
|
315
|
-
className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
|
316
|
-
<div className="h-full rounded-full bg-primary transition-all"
|
|
317
|
-
style={{ width: `${Math.round((row.progressWeeksDone / Math.max(1, row.progressWeeksTotal)) * 100)}%` }} />
|
|
318
|
-
</div>
|
|
319
|
-
</dd>
|
|
320
|
-
</div>
|
|
321
|
-
)}
|
|
322
|
-
{row.siteAddress && (
|
|
323
|
-
<div className="flex flex-col gap-0.5 sm:col-span-2">
|
|
324
|
-
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
|
|
325
|
-
<i className="fa-light fa-location-dot text-[10px]" aria-hidden="true" /> Site Address
|
|
326
|
-
</dt>
|
|
327
|
-
<dd className="text-[13px] text-foreground">{row.siteAddress}</dd>
|
|
328
|
-
</div>
|
|
329
|
-
)}
|
|
330
|
-
</dl>
|
|
331
|
-
</div>
|
|
332
|
-
</div>
|
|
333
|
-
)
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// ─── Status groups for FinderPanelView ───────────────────────────────────────
|
|
337
|
-
|
|
338
|
-
const STATUS_GROUPS: Array<{ id: Status | "all"; label: string; accent: string }> = [
|
|
339
|
-
{ id: "all", label: "All", accent: "bg-muted-foreground" },
|
|
340
|
-
{ id: "confirmed", label: "Confirmed", accent: "bg-success" },
|
|
341
|
-
{ id: "pending", label: "Pending", accent: "bg-warning" },
|
|
342
|
-
{ id: "under-review", label: "Under Review", accent: "bg-brand" },
|
|
343
|
-
{ id: "rejected", label: "Rejected", accent: "bg-destructive" },
|
|
344
|
-
{ id: "completed", label: "Completed", accent: "bg-muted-foreground/50" },
|
|
345
|
-
]
|
|
346
|
-
|
|
347
|
-
function buildStatusGroups(rows: Placement[]): FinderGroup[] {
|
|
348
|
-
return STATUS_GROUPS.map(sg => ({
|
|
349
|
-
id: sg.id,
|
|
350
|
-
label: sg.label,
|
|
351
|
-
accent: sg.accent,
|
|
352
|
-
count: sg.id === "all" ? rows.length : rows.filter(r => r.status === sg.id).length,
|
|
353
|
-
}))
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// ─── Tree-view body (its own selection state) ────────────────────────────────
|
|
357
|
-
|
|
358
|
-
function PlacementsTreeBody({
|
|
359
|
-
args,
|
|
360
|
-
}: {
|
|
361
|
-
args: HubTableRendererArgs<Placement>
|
|
362
|
-
}) {
|
|
363
|
-
const { state } = args
|
|
364
|
-
const listRows = state.rows as Placement[]
|
|
365
|
-
const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
|
|
366
|
-
const [selectedId, setSelectedId] = React.useState<number | null>(() => listRows[0]?.id ?? null)
|
|
367
|
-
|
|
368
|
-
React.useEffect(() => {
|
|
369
|
-
if (selectedId == null) {
|
|
370
|
-
setSelectedId(listRows[0]?.id ?? null)
|
|
371
|
-
return
|
|
372
|
-
}
|
|
373
|
-
if (!listRows.some(r => r.id === selectedId)) {
|
|
374
|
-
setSelectedId(listRows[0]?.id ?? null)
|
|
375
|
-
}
|
|
376
|
-
}, [listRows, selectedId])
|
|
377
|
-
|
|
378
|
-
const selected = listRows.find(r => r.id === selectedId) ?? null
|
|
379
|
-
|
|
380
|
-
return (
|
|
381
|
-
<ListPageTreePanelShell
|
|
382
|
-
resizableGroupId="data-list-tree"
|
|
383
|
-
ariaLabel="Record outline and details"
|
|
384
|
-
tree={
|
|
385
|
-
<div className="flex min-h-0 flex-1 flex-col">
|
|
386
|
-
<ListPageTreeColumnHeader title="Records" />
|
|
387
|
-
{listRows.length === 0 ? (
|
|
388
|
-
<p className="p-3 text-sm text-muted-foreground">{PLACEMENT_EMPTY_COPY}</p>
|
|
389
|
-
) : (
|
|
390
|
-
<ul
|
|
391
|
-
role="tree"
|
|
392
|
-
aria-label="Demo records"
|
|
393
|
-
className="min-h-0 flex-1 list-none space-y-0.5 overflow-y-auto py-1"
|
|
394
|
-
>
|
|
395
|
-
{listRows.map(row => {
|
|
396
|
-
const isSel = selectedId === row.id
|
|
397
|
-
return (
|
|
398
|
-
<li key={row.id} role="none" className="py-0.5">
|
|
399
|
-
<button
|
|
400
|
-
type="button"
|
|
401
|
-
role="treeitem"
|
|
402
|
-
aria-selected={isSel}
|
|
403
|
-
tabIndex={isSel ? 0 : -1}
|
|
404
|
-
onClick={() => setSelectedId(row.id)}
|
|
405
|
-
className={cn(
|
|
406
|
-
"flex w-full min-h-8 items-center rounded-md px-3 py-2 text-left text-sm transition-colors duration-75",
|
|
407
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
|
408
|
-
isSel
|
|
409
|
-
? "bg-accent font-medium text-accent-foreground"
|
|
410
|
-
: "text-foreground hover:bg-muted/50",
|
|
411
|
-
)}
|
|
412
|
-
>
|
|
413
|
-
<span className="min-w-0 truncate">{row.student}</span>
|
|
414
|
-
</button>
|
|
415
|
-
</li>
|
|
416
|
-
)
|
|
417
|
-
})}
|
|
418
|
-
</ul>
|
|
419
|
-
)}
|
|
420
|
-
</div>
|
|
421
|
-
}
|
|
422
|
-
details={
|
|
423
|
-
selected ? (
|
|
424
|
-
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-card">
|
|
425
|
-
<ListPageTreeColumnHeader title="Details" />
|
|
426
|
-
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
427
|
-
<PlacementFinderDetail
|
|
428
|
-
row={selected}
|
|
429
|
-
hiddenColKeys={state.hiddenCols}
|
|
430
|
-
boardColumns={boardColumns}
|
|
431
|
-
/>
|
|
432
|
-
</div>
|
|
433
|
-
</div>
|
|
434
|
-
) : (
|
|
435
|
-
<ListPageSplitDetailsPlaceholder title="Nothing selected" />
|
|
436
|
-
)
|
|
437
|
-
}
|
|
438
|
-
/>
|
|
439
|
-
)
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// ─── Dashboard body (its own layout state) ───────────────────────────────────
|
|
443
|
-
|
|
444
|
-
function PlacementsDashboardBody({
|
|
445
|
-
args,
|
|
446
|
-
columns,
|
|
447
|
-
}: {
|
|
448
|
-
args: HubTableRendererArgs<Placement>
|
|
449
|
-
columns: ColumnDef<Placement>[]
|
|
450
|
-
}) {
|
|
451
|
-
const { state, drawerToolbarProps, displayOptions } = args
|
|
452
|
-
const rows = state.rows as Placement[]
|
|
453
|
-
|
|
454
|
-
const dashboardKpi = React.useMemo(
|
|
455
|
-
() => ({
|
|
456
|
-
metrics: placementKpiMetricsFromRows(rows),
|
|
457
|
-
insight: placementKpiInsightFromRows(rows),
|
|
458
|
-
}),
|
|
459
|
-
[rows],
|
|
460
|
-
)
|
|
461
|
-
|
|
462
|
-
const [visibleCards, setVisibleCards] = React.useState<string[]>(DEFAULT_VISIBLE_CARDS)
|
|
463
|
-
const [cardOrder, setCardOrder] = React.useState<string[]>(ALL_DASHBOARD_CARDS.map(c => c.id))
|
|
464
|
-
const [cardSpans, setCardSpans] = React.useState<Record<string, 1 | 2>>(() => ({ ...DEFAULT_SPANS }))
|
|
465
|
-
const [cardChartTypes, setCardChartTypes] = React.useState<Record<string, ChartType>>(() => ({ ...DEFAULT_CHART_TYPES }))
|
|
466
|
-
const [keyMetricsKpiCount, setKeyMetricsKpiCount] = React.useState<number>(KEY_METRICS_KPI_COUNT_DEFAULT)
|
|
467
|
-
const [layoutEdit, setLayoutEdit] = React.useState(false)
|
|
468
|
-
const hydrated = React.useRef(false)
|
|
469
|
-
const baselineRef = React.useRef<DashboardLayout | null>(null)
|
|
470
|
-
|
|
471
|
-
React.useEffect(() => {
|
|
472
|
-
const saved = loadDashboardLayout()
|
|
473
|
-
const m = mergeDashboardLayout(saved)
|
|
474
|
-
setVisibleCards(m.visible)
|
|
475
|
-
setCardOrder(m.order)
|
|
476
|
-
setCardSpans(m.spans ?? { ...DEFAULT_SPANS })
|
|
477
|
-
setCardChartTypes(m.chartTypes ?? { ...DEFAULT_CHART_TYPES })
|
|
478
|
-
setKeyMetricsKpiCount(m.keyMetricsKpiCount ?? KEY_METRICS_KPI_COUNT_DEFAULT)
|
|
479
|
-
hydrated.current = true
|
|
480
|
-
}, [])
|
|
481
|
-
|
|
482
|
-
React.useEffect(() => {
|
|
483
|
-
if (!hydrated.current) return
|
|
484
|
-
saveDashboardLayout({
|
|
485
|
-
visible: visibleCards,
|
|
486
|
-
order: cardOrder,
|
|
487
|
-
spans: cardSpans,
|
|
488
|
-
chartTypes: cardChartTypes,
|
|
489
|
-
keyMetricsKpiCount,
|
|
490
|
-
})
|
|
491
|
-
}, [visibleCards, cardOrder, cardSpans, cardChartTypes, keyMetricsKpiCount])
|
|
492
|
-
|
|
493
|
-
const onResetLayout = React.useCallback(() => {
|
|
494
|
-
setVisibleCards(ALL_DASHBOARD_CARDS.map(c => c.id))
|
|
495
|
-
setCardOrder(ALL_DASHBOARD_CARDS.map(c => c.id))
|
|
496
|
-
setCardSpans({ ...DEFAULT_SPANS })
|
|
497
|
-
setCardChartTypes({ ...DEFAULT_CHART_TYPES })
|
|
498
|
-
setKeyMetricsKpiCount(KEY_METRICS_KPI_COUNT_DEFAULT)
|
|
499
|
-
}, [])
|
|
500
|
-
|
|
501
|
-
const onLayoutEditStart = React.useCallback(() => {
|
|
502
|
-
baselineRef.current = {
|
|
503
|
-
visible: [...visibleCards],
|
|
504
|
-
order: [...cardOrder],
|
|
505
|
-
spans: { ...cardSpans },
|
|
506
|
-
chartTypes: { ...cardChartTypes },
|
|
507
|
-
keyMetricsKpiCount,
|
|
508
|
-
}
|
|
509
|
-
setLayoutEdit(true)
|
|
510
|
-
}, [visibleCards, cardOrder, cardSpans, cardChartTypes, keyMetricsKpiCount])
|
|
511
|
-
|
|
512
|
-
const onLayoutEditCancel = React.useCallback(() => {
|
|
513
|
-
const b = baselineRef.current
|
|
514
|
-
if (b) {
|
|
515
|
-
setVisibleCards(b.visible)
|
|
516
|
-
setCardOrder(b.order)
|
|
517
|
-
setCardSpans(b.spans ?? { ...DEFAULT_SPANS })
|
|
518
|
-
setCardChartTypes(b.chartTypes ?? { ...DEFAULT_CHART_TYPES })
|
|
519
|
-
setKeyMetricsKpiCount(b.keyMetricsKpiCount ?? KEY_METRICS_KPI_COUNT_DEFAULT)
|
|
520
|
-
}
|
|
521
|
-
setLayoutEdit(false)
|
|
522
|
-
}, [])
|
|
523
|
-
|
|
524
|
-
const coach = useCoachMark({
|
|
525
|
-
flowId: "data-list-dashboard-customize",
|
|
526
|
-
steps: DASHBOARD_CUSTOMIZE_COACH_STEPS,
|
|
527
|
-
delay: 700,
|
|
528
|
-
dependsOnDismissedFlowId: "data-list-views-tour",
|
|
529
|
-
})
|
|
530
|
-
|
|
531
|
-
return (
|
|
532
|
-
<>
|
|
533
|
-
<CoachMark state={coach} />
|
|
534
|
-
{!layoutEdit ? (
|
|
535
|
-
<DataTableToolbar
|
|
536
|
-
state={state}
|
|
537
|
-
columns={columns}
|
|
538
|
-
searchable={displayOptions.showToolbarSearch}
|
|
539
|
-
renderFilterOptionValue={drawerToolbarProps.renderFilterOptionValue}
|
|
540
|
-
searchAriaLabel="Search rows"
|
|
541
|
-
toolbarSlot={s => (
|
|
542
|
-
<TablePropertiesDrawerButton
|
|
543
|
-
{...drawerToolbarProps}
|
|
544
|
-
state={s}
|
|
545
|
-
extraActions={
|
|
546
|
-
<Tip side="bottom" label="Edit dashboard layout on canvas">
|
|
547
|
-
<Button
|
|
548
|
-
type="button"
|
|
549
|
-
variant="ghost"
|
|
550
|
-
size="icon-sm"
|
|
551
|
-
aria-label="Edit dashboard layout"
|
|
552
|
-
onClick={onLayoutEditStart}
|
|
553
|
-
className="text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover"
|
|
554
|
-
>
|
|
555
|
-
<i className="fa-light fa-pen-ruler text-[13px]" aria-hidden="true" />
|
|
556
|
-
</Button>
|
|
557
|
-
</Tip>
|
|
558
|
-
}
|
|
559
|
-
/>
|
|
560
|
-
)}
|
|
561
|
-
/>
|
|
562
|
-
) : null}
|
|
563
|
-
<PlacementsDashboardChartsSection
|
|
564
|
-
placements={rows}
|
|
565
|
-
keyMetrics={dashboardKpi}
|
|
566
|
-
visibleCards={visibleCards}
|
|
567
|
-
cardOrder={cardOrder}
|
|
568
|
-
cardSpans={cardSpans}
|
|
569
|
-
cardChartTypes={cardChartTypes}
|
|
570
|
-
keyMetricsKpiCount={keyMetricsKpiCount}
|
|
571
|
-
layoutEditMode={layoutEdit}
|
|
572
|
-
onVisibleChange={setVisibleCards}
|
|
573
|
-
onOrderChange={setCardOrder}
|
|
574
|
-
onSpanChange={(id, span) => setCardSpans(prev => ({ ...prev, [id]: span }))}
|
|
575
|
-
onChartTypeChange={(id, t) => setCardChartTypes(prev => ({ ...prev, [id]: t }))}
|
|
576
|
-
onKeyMetricsKpiCountChange={setKeyMetricsKpiCount}
|
|
577
|
-
onResetLayout={onResetLayout}
|
|
578
|
-
onLayoutEditDone={() => setLayoutEdit(false)}
|
|
579
|
-
onLayoutEditCancel={onLayoutEditCancel}
|
|
580
|
-
/>
|
|
581
|
-
</>
|
|
582
|
-
)
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
// ─── Board renderer body ─────────────────────────────────────────────────────
|
|
586
|
-
|
|
587
|
-
function PlacementsBoardBody({
|
|
588
|
-
args,
|
|
589
|
-
}: {
|
|
590
|
-
args: HubTableRendererArgs<Placement>
|
|
591
|
-
}) {
|
|
592
|
-
const { state, displayOptions, drawerToolbarProps } = args
|
|
593
|
-
const columns = state.displayCols
|
|
594
|
-
const boardColumnMenu: PlacementsBoardColumnMenu = React.useMemo(
|
|
595
|
-
() => ({
|
|
596
|
-
filterableColumns: columns.filter(c => c.filter).map(c => ({ key: c.key, label: c.label })),
|
|
597
|
-
sortableColumns: columns.filter(c => c.sortable && c.sortKey).map(c => ({ key: c.key, label: c.label })),
|
|
598
|
-
groupableColumns: columns.filter(c => c.key !== "select" && c.key !== "actions").map(c => ({ key: c.key, label: c.label })),
|
|
599
|
-
groupBy: state.groupBy,
|
|
600
|
-
onAddFilter: state.addFilter,
|
|
601
|
-
onSortByField: (fieldKey, direction) => {
|
|
602
|
-
state.setSortRules(prev => {
|
|
603
|
-
const filtered = prev.filter(r => r.fieldKey !== fieldKey)
|
|
604
|
-
return [{ id: `sort-${Date.now()}`, fieldKey, direction }, ...filtered]
|
|
605
|
-
})
|
|
606
|
-
},
|
|
607
|
-
onToggleGroupBy: (fieldKey: string) => {
|
|
608
|
-
state.setGroupBy(prev => (prev === fieldKey ? null : fieldKey))
|
|
609
|
-
},
|
|
610
|
-
onOpenProperties: () => state.setSheetOpen(true),
|
|
611
|
-
}),
|
|
612
|
-
[columns, state],
|
|
613
|
-
)
|
|
614
|
-
|
|
615
|
-
return (
|
|
616
|
-
<PlacementsBoardView
|
|
617
|
-
placements={state.rows as Placement[]}
|
|
618
|
-
boardColumnMenu={boardColumnMenu}
|
|
619
|
-
boardDisplay={{
|
|
620
|
-
lineCount: displayOptions.boardLineCount,
|
|
621
|
-
showColumnLabels: displayOptions.showColumnLabels,
|
|
622
|
-
showColumnCounts: displayOptions.showBoardColumnCounts,
|
|
623
|
-
newCardAbove: displayOptions.boardNewCardAbove,
|
|
624
|
-
}}
|
|
625
|
-
hiddenColKeys={state.hiddenCols}
|
|
626
|
-
conditionalRules={drawerToolbarProps.conditionalRules}
|
|
627
|
-
boardColumns={state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")}
|
|
628
|
-
/>
|
|
629
|
-
)
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// ─── Props ───────────────────────────────────────────────────────────────────
|
|
633
|
-
|
|
634
|
-
export interface PlacementsTableProps {
|
|
635
|
-
view?: DataListViewType
|
|
636
|
-
onViewChange?: (view: DataListViewType) => void
|
|
637
|
-
/** Shared display options (persist at page level — all view types). */
|
|
638
|
-
displayOptions?: DataListDisplayOptions
|
|
639
|
-
onDisplayOptionsChange?: (patch: Partial<DataListDisplayOptions>) => void
|
|
640
|
-
/** Panel view: custom groups builder. If not provided, uses default placement status groups. */
|
|
641
|
-
panelGroupsBuilder?: (rows: Placement[]) => FinderGroup[]
|
|
642
|
-
/** Panel view: custom list row renderer. If not provided, uses default placement row rendering. */
|
|
643
|
-
panelRenderListRow?: (row: Placement, isSelected: boolean) => React.ReactNode
|
|
644
|
-
/** Panel view: custom detail pane renderer. If not provided, uses default placement detail rendering. */
|
|
645
|
-
panelRenderDetail?: (row: Placement) => React.ReactNode
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/** Imperative handle — open Table Properties (table view only). */
|
|
649
|
-
export type PlacementsTableHandle = HubTableHandle
|
|
650
|
-
|
|
651
|
-
// ─── Public component ───────────────────────────────────────────────────────
|
|
652
|
-
|
|
653
|
-
export const PlacementsTable = React.forwardRef<PlacementsTableHandle, PlacementsTableProps>(function PlacementsTable(
|
|
654
|
-
{
|
|
655
|
-
view = "table",
|
|
656
|
-
onViewChange,
|
|
657
|
-
displayOptions: displayOptionsProp,
|
|
658
|
-
onDisplayOptionsChange,
|
|
659
|
-
panelGroupsBuilder,
|
|
660
|
-
panelRenderListRow,
|
|
661
|
-
panelRenderDetail,
|
|
662
|
-
},
|
|
663
|
-
ref,
|
|
664
|
-
) {
|
|
665
|
-
const router = useRouter()
|
|
666
|
-
const displayOptions = React.useMemo(
|
|
667
|
-
() => ({ ...DEFAULT_DATA_LIST_DISPLAY_OPTIONS, ...displayOptionsProp }),
|
|
668
|
-
[displayOptionsProp],
|
|
669
|
-
)
|
|
670
|
-
|
|
671
|
-
const columns = React.useMemo(() => getPlacementColumns(), [])
|
|
672
|
-
const tableData = ALL_PLACEMENTS
|
|
673
|
-
|
|
674
|
-
const renderFilterOptionValue = React.useCallback(
|
|
675
|
-
(fieldKey: string, value: string): React.ReactNode => {
|
|
676
|
-
if (fieldKey === "status") return <StatusBadge status={value as Status} />
|
|
677
|
-
const col = columns.find(c => c.key === fieldKey)
|
|
678
|
-
const opt = col?.filter?.options?.find(o => o.value === value)
|
|
679
|
-
return <span className="text-foreground">{opt?.label ?? value}</span>
|
|
680
|
-
},
|
|
681
|
-
[columns],
|
|
682
|
-
)
|
|
683
|
-
|
|
684
|
-
// ─ Pagination chrome (only TABLE + LIST views) ────────────────────────────
|
|
685
|
-
const [pagination, setPagination] = React.useState(false)
|
|
686
|
-
const [paginationPage, setPaginationPage] = React.useState(1)
|
|
687
|
-
const [paginationPageSize, setPaginationPageSize] = React.useState(10)
|
|
688
|
-
const [filteredCount, setFilteredCount] = React.useState(tableData.length)
|
|
689
|
-
|
|
690
|
-
React.useEffect(() => {
|
|
691
|
-
setFilteredCount(tableData.length)
|
|
692
|
-
}, [tableData])
|
|
693
|
-
|
|
694
|
-
const totalPages = Math.max(1, Math.ceil(filteredCount / Math.max(1, paginationPageSize)))
|
|
695
|
-
const safePage = Math.min(paginationPage, totalPages)
|
|
696
|
-
|
|
697
|
-
// Pagination only applies to TABLE + LIST views (board/dashboard/folder/panel/tree-panel
|
|
698
|
-
// are not paged). Cards/boards consume `state.rows` directly.
|
|
699
|
-
const paginationEligible = view === "table" || view === "list"
|
|
700
|
-
const paginationOverride =
|
|
701
|
-
pagination && paginationEligible ? { page: safePage, pageSize: paginationPageSize } : undefined
|
|
702
|
-
|
|
703
|
-
const onPageSizeChange = React.useCallback((n: number) => {
|
|
704
|
-
setPaginationPageSize(n)
|
|
705
|
-
setPaginationPage(1)
|
|
706
|
-
}, [])
|
|
707
|
-
|
|
708
|
-
// Renderers --------------------------------------------------------------
|
|
709
|
-
const renderers: HubTableRenderers<Placement> = {
|
|
710
|
-
"board-with-toolbar": (args) =>
|
|
711
|
-
args.toolbarShell(<PlacementsBoardBody args={args} />),
|
|
712
|
-
"list-with-toolbar": (args) => {
|
|
713
|
-
const { state, toolbarShell } = args
|
|
714
|
-
const listRows = pagination ? (state.pagedRows as Placement[]) : (state.rows as Placement[])
|
|
715
|
-
const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
|
|
716
|
-
return (
|
|
717
|
-
<>
|
|
718
|
-
{pagination ? (
|
|
719
|
-
<CountSyncer
|
|
720
|
-
count={state.rows.length}
|
|
721
|
-
onSync={setFilteredCount}
|
|
722
|
-
onReset={() => setPaginationPage(1)}
|
|
723
|
-
/>
|
|
724
|
-
) : null}
|
|
725
|
-
{toolbarShell(
|
|
726
|
-
<>
|
|
727
|
-
<DataRowList<Placement>
|
|
728
|
-
rows={listRows}
|
|
729
|
-
getRowId={row => row.id}
|
|
730
|
-
ariaLabel="Placements"
|
|
731
|
-
emptyState={PLACEMENT_EMPTY_COPY}
|
|
732
|
-
virtualizeThreshold={PLACEMENT_LIST_VIRTUAL_ROWS_THRESHOLD}
|
|
733
|
-
estimatedRowHeight={PLACEMENT_LIST_ESTIMATE_ROW_PX}
|
|
734
|
-
renderRow={row => (
|
|
735
|
-
<PlacementListRowContent
|
|
736
|
-
row={row}
|
|
737
|
-
hiddenColKeys={state.hiddenCols}
|
|
738
|
-
boardColumns={boardColumns}
|
|
739
|
-
conditionalRules={args.drawerToolbarProps.conditionalRules}
|
|
740
|
-
onOpen={id => router.push(`/data-list/${id}`)}
|
|
741
|
-
/>
|
|
742
|
-
)}
|
|
743
|
-
/>
|
|
744
|
-
{pagination ? (
|
|
745
|
-
<div className="mx-4 lg:mx-6 border-x border-b border-border rounded-b-lg overflow-hidden">
|
|
746
|
-
<PaginationBar
|
|
747
|
-
page={safePage}
|
|
748
|
-
pageSize={paginationPageSize}
|
|
749
|
-
total={filteredCount}
|
|
750
|
-
pageSizeOptions={[10, 25, 50, 100]}
|
|
751
|
-
onPageChange={setPaginationPage}
|
|
752
|
-
onPageSizeChange={onPageSizeChange}
|
|
753
|
-
/>
|
|
754
|
-
</div>
|
|
755
|
-
) : null}
|
|
756
|
-
</>,
|
|
757
|
-
)}
|
|
758
|
-
</>
|
|
759
|
-
)
|
|
760
|
-
},
|
|
761
|
-
"folder-with-toolbar": (args) => {
|
|
762
|
-
const { state, toolbarShell } = args
|
|
763
|
-
const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
|
|
764
|
-
return toolbarShell(
|
|
765
|
-
<FolderGridView<Placement>
|
|
766
|
-
rows={state.rows as Placement[]}
|
|
767
|
-
getRowId={r => r.id}
|
|
768
|
-
ariaLabel="Demo folder view"
|
|
769
|
-
emptyContent={<p>{PLACEMENT_EMPTY_COPY}</p>}
|
|
770
|
-
renderTile={row => (
|
|
771
|
-
<PlacementFolderTile
|
|
772
|
-
row={row}
|
|
773
|
-
hiddenColKeys={state.hiddenCols}
|
|
774
|
-
boardColumns={boardColumns}
|
|
775
|
-
onClick={() => router.push(`/data-list/${row.id}`)}
|
|
776
|
-
/>
|
|
777
|
-
)}
|
|
778
|
-
/>,
|
|
779
|
-
)
|
|
780
|
-
},
|
|
781
|
-
"tree-panel-with-toolbar": (args) =>
|
|
782
|
-
args.toolbarShell(<PlacementsTreeBody args={args} />),
|
|
783
|
-
"panel-with-toolbar": (args) => {
|
|
784
|
-
const { state, toolbarShell } = args
|
|
785
|
-
const listRows = state.rows as Placement[]
|
|
786
|
-
const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
|
|
787
|
-
const groups = panelGroupsBuilder ? panelGroupsBuilder(listRows) : buildStatusGroups(listRows)
|
|
788
|
-
return toolbarShell(
|
|
789
|
-
<ListPageSplitHubChrome aria-label={PLACEMENT_DRAWER_LABEL}>
|
|
790
|
-
<FinderPanelView<Placement>
|
|
791
|
-
embedded
|
|
792
|
-
groupsColumnTitle="Status"
|
|
793
|
-
groups={groups}
|
|
794
|
-
rows={listRows}
|
|
795
|
-
getRowId={r => r.id}
|
|
796
|
-
getRowGroupId={r => r.status}
|
|
797
|
-
defaultGroupId="all"
|
|
798
|
-
autoSaveId="finder-panel-view"
|
|
799
|
-
ariaLabel="Demo panel view"
|
|
800
|
-
emptyList={<p>{PLACEMENT_EMPTY_COPY}</p>}
|
|
801
|
-
renderListRow={
|
|
802
|
-
panelRenderListRow
|
|
803
|
-
? panelRenderListRow
|
|
804
|
-
: (row, isSelected) => (
|
|
805
|
-
<PlacementFinderListRow
|
|
806
|
-
row={row}
|
|
807
|
-
isSelected={isSelected}
|
|
808
|
-
hiddenColKeys={state.hiddenCols}
|
|
809
|
-
boardColumns={boardColumns}
|
|
810
|
-
/>
|
|
811
|
-
)
|
|
812
|
-
}
|
|
813
|
-
renderDetail={
|
|
814
|
-
panelRenderDetail
|
|
815
|
-
? panelRenderDetail
|
|
816
|
-
: row => (
|
|
817
|
-
<PlacementFinderDetail
|
|
818
|
-
row={row}
|
|
819
|
-
hiddenColKeys={state.hiddenCols}
|
|
820
|
-
boardColumns={boardColumns}
|
|
821
|
-
/>
|
|
822
|
-
)
|
|
823
|
-
}
|
|
824
|
-
/>
|
|
825
|
-
</ListPageSplitHubChrome>,
|
|
826
|
-
)
|
|
827
|
-
},
|
|
828
|
-
"dashboard-with-toolbar": (args) => <PlacementsDashboardBody args={args} columns={columns} />,
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
// Custom `tableRenderer` so pagination chrome (CountSyncer + PaginationBar) wraps the
|
|
832
|
-
// default DataTable when `pagination` is on. When off, falls back to a plain DataTable.
|
|
833
|
-
const tableRenderer = (args: HubTableRendererArgs<Placement>) => {
|
|
834
|
-
const { state } = args
|
|
835
|
-
return (
|
|
836
|
-
<>
|
|
837
|
-
{pagination ? (
|
|
838
|
-
<CountSyncer
|
|
839
|
-
count={state.rows.length}
|
|
840
|
-
onSync={setFilteredCount}
|
|
841
|
-
onReset={() => setPaginationPage(1)}
|
|
842
|
-
/>
|
|
843
|
-
) : null}
|
|
844
|
-
<div className="pb-6">
|
|
845
|
-
<DataTable<Placement>
|
|
846
|
-
data={tableData}
|
|
847
|
-
columns={columns}
|
|
848
|
-
getRowId={row => row.id}
|
|
849
|
-
getRowSelectionLabel={row => row.student}
|
|
850
|
-
selectable
|
|
851
|
-
searchable={displayOptions.showToolbarSearch}
|
|
852
|
-
showColumnHeaders={displayOptions.showColumnLabels}
|
|
853
|
-
defaultSort={{ key: "student" as const, dir: "asc" as const }}
|
|
854
|
-
emptyState={PLACEMENT_EMPTY_COPY}
|
|
855
|
-
renderFilterOptionValue={renderFilterOptionValue}
|
|
856
|
-
conditionalRules={args.drawerToolbarProps.conditionalRules}
|
|
857
|
-
onRowClick={row => router.push(`/data-list/${row.id}`)}
|
|
858
|
-
state={state}
|
|
859
|
-
hasFooter={pagination}
|
|
860
|
-
toolbarSlot={s => (
|
|
861
|
-
<TablePropertiesDrawerButton {...args.drawerToolbarProps} state={s} />
|
|
862
|
-
)}
|
|
863
|
-
bulkActionsSlot={(selected) => {
|
|
864
|
-
const count = selected.size
|
|
865
|
-
if (count === 0) return null
|
|
866
|
-
const contextId = "bulk-selection-context"
|
|
867
|
-
return (
|
|
868
|
-
<>
|
|
869
|
-
<span id={contextId} className="sr-only">
|
|
870
|
-
{count} {count === 1 ? "row" : "rows"} selected
|
|
871
|
-
</span>
|
|
872
|
-
<Button size="sm" variant="default" aria-describedby={contextId}>
|
|
873
|
-
<i className="fa-light fa-circle-check" aria-hidden="true" /> Confirm
|
|
874
|
-
</Button>
|
|
875
|
-
<Button size="sm" variant="outline" aria-describedby={contextId}>
|
|
876
|
-
<i className="fa-light fa-arrow-down-to-line" aria-hidden="true" /> Export
|
|
877
|
-
</Button>
|
|
878
|
-
<Button size="sm" variant="destructive" aria-describedby={contextId}>
|
|
879
|
-
<i className="fa-light fa-trash" aria-hidden="true" /> Delete
|
|
880
|
-
</Button>
|
|
881
|
-
</>
|
|
882
|
-
)
|
|
883
|
-
}}
|
|
884
|
-
/>
|
|
885
|
-
</div>
|
|
886
|
-
{pagination ? (
|
|
887
|
-
<div className="mx-4 lg:mx-6 border-x border-b border-border rounded-b-lg overflow-hidden">
|
|
888
|
-
<PaginationBar
|
|
889
|
-
page={safePage}
|
|
890
|
-
pageSize={paginationPageSize}
|
|
891
|
-
total={filteredCount}
|
|
892
|
-
pageSizeOptions={[10, 25, 50, 100]}
|
|
893
|
-
onPageChange={setPaginationPage}
|
|
894
|
-
onPageSizeChange={onPageSizeChange}
|
|
895
|
-
/>
|
|
896
|
-
</div>
|
|
897
|
-
) : null}
|
|
898
|
-
</>
|
|
899
|
-
)
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
return (
|
|
903
|
-
<HubTable<Placement>
|
|
904
|
-
rows={tableData}
|
|
905
|
-
columns={columns}
|
|
906
|
-
view={view}
|
|
907
|
-
onViewChange={onViewChange}
|
|
908
|
-
supportedViewTypes={PLACEMENTS_SUPPORTED_VIEWS}
|
|
909
|
-
hubLabel={PLACEMENT_DRAWER_LABEL}
|
|
910
|
-
lifecycleTabLabel={PLACEMENT_DRAWER_LABEL}
|
|
911
|
-
searchAriaLabel="Search rows"
|
|
912
|
-
getRowId={row => row.id}
|
|
913
|
-
getRowSelectionLabel={row => row.student}
|
|
914
|
-
defaultSort={{ key: "student", dir: "asc" }}
|
|
915
|
-
emptyState={PLACEMENT_EMPTY_COPY}
|
|
916
|
-
onRowClick={row => router.push(`/data-list/${row.id}`)}
|
|
917
|
-
displayOptions={displayOptions}
|
|
918
|
-
onDisplayOptionsChange={onDisplayOptionsChange}
|
|
919
|
-
pagination={pagination}
|
|
920
|
-
onPaginationChange={setPagination}
|
|
921
|
-
paginationOverride={paginationOverride}
|
|
922
|
-
renderFilterOptionValue={renderFilterOptionValue}
|
|
923
|
-
renderers={renderers}
|
|
924
|
-
tableRenderer={tableRenderer}
|
|
925
|
-
handleRef={ref}
|
|
926
|
-
/>
|
|
927
|
-
)
|
|
928
|
-
})
|
|
929
|
-
|
|
930
|
-
PlacementsTable.displayName = "PlacementsTable"
|
|
931
|
-
|
|
932
|
-
export type { DataListViewType } from "@/lib/data-list-view"
|
|
933
|
-
export { DATA_LIST_VIEW_TILES } from "@/lib/data-list-view"
|
|
934
|
-
export type { DataListDisplayOptions } from "@/lib/data-list-display-options"
|