@exxatdesignux/ui 0.2.17 → 0.2.19
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 +30 -0
- package/consumer-extras/AGENTS.md +76 -0
- package/consumer-extras/README.md +5 -1
- package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +14 -3
- package/consumer-extras/cursor-skills/exxat-consumer-app/SKILL.md +37 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +22 -7
- package/consumer-extras/cursor-skills/exxat-focused-workflow-page/SKILL.md +57 -0
- package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +10 -3
- package/consumer-extras/patterns/consumer-app-pattern.md +39 -0
- package/consumer-extras/patterns/consumer-upgrade-checklist.md +20 -0
- package/consumer-extras/patterns/data-views-pattern.md +42 -3
- package/consumer-extras/patterns/focused-workflow-page-pattern.md +84 -0
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
- package/consumer-extras/patterns/shell-surface-elevation-pattern.md +54 -0
- package/package.json +2 -1
- package/src/components/ui/button-group.tsx +81 -0
- package/src/components/ui/button.tsx +4 -4
- package/src/components/ui/sidebar.tsx +2 -2
- package/src/globals.css +7 -1807
- package/src/theme.css +10 -1126
- package/src/tokens/README.md +15 -0
- package/src/tokens/base.css +337 -0
- package/src/tokens/high-contrast.css +1195 -0
- package/src/tokens/layers.css +224 -0
- package/src/tokens/tailwind-bridge.css +118 -0
- package/src/tokens/themes.css +201 -0
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
- package/template/AGENTS.md +66 -21
- package/template/app/(app)/dashboard/loading.tsx +3 -15
- package/template/app/(app)/dashboard/page.tsx +2 -14
- package/template/app/(app)/data-list/layout.tsx +43 -0
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/error.tsx +22 -6
- package/template/app/(app)/examples/focused-workflow/page.tsx +5 -0
- package/template/app/(app)/examples/page.tsx +1 -0
- package/template/app/(app)/layout.tsx +13 -6
- package/template/app/(app)/loading.tsx +1 -18
- package/template/app/(app)/question-bank/find/page.tsx +2 -1
- package/template/app/(app)/question-bank/library/page.tsx +2 -1
- package/template/app/(app)/question-bank/list/page.tsx +2 -1
- package/template/app/(app)/question-bank/new/page.tsx +15 -23
- package/template/app/(app)/question-bank/page.tsx +2 -1
- package/template/app/(app)/settings/page.tsx +4 -5
- package/template/app/global-error.tsx +63 -0
- package/template/app/globals.css +7 -1934
- package/template/app/layout.tsx +2 -0
- package/template/components/app-route-loading.tsx +14 -0
- package/template/components/app-sidebar.tsx +71 -55
- package/template/components/data-table/index.tsx +31 -67
- package/template/components/data-table/use-table-state.ts +33 -6
- package/template/components/data-views/index.ts +37 -9
- package/template/components/data-views/list-page-calendar-view.tsx +593 -0
- package/template/components/data-views/list-page-connected-view-body.tsx +66 -0
- package/template/components/data-views/list-page-folder-columns-panel.tsx +345 -0
- package/template/components/data-views/list-page-split-hub-chrome.tsx +8 -0
- package/template/components/dev-chunk-load-recovery.tsx +41 -0
- package/template/components/examples/focused-workflow-showcase.tsx +183 -0
- package/template/components/exxat-product-logo.tsx +2 -6
- package/template/components/key-metrics.tsx +54 -22
- package/template/components/list-hub-board-view.tsx +68 -0
- package/template/components/list-hub-client.tsx +186 -0
- package/template/components/list-hub-list-view.tsx +36 -0
- package/template/components/list-hub-panel-activator.tsx +8 -0
- package/template/components/list-hub-secondary-nav.tsx +121 -0
- package/template/components/list-hub-table.tsx +336 -0
- package/template/components/new-question-composer.tsx +6 -24
- package/template/components/product-switcher.tsx +5 -5
- package/template/components/product-wordmark.tsx +4 -7
- package/template/components/question-bank-client.tsx +4 -1
- package/template/components/question-bank-folder-columns-panel.tsx +104 -0
- package/template/components/question-bank-hub-client.tsx +2 -5
- package/template/components/question-bank-table.tsx +155 -509
- package/template/components/secondary-panel/nav-link-rows.tsx +83 -0
- package/template/components/secondary-panel.tsx +4 -44
- package/template/components/secondary-panels/list-hub-panel.tsx +39 -0
- package/template/components/secondary-panels/question-bank-panel.tsx +39 -0
- package/template/components/secondary-panels/registry.tsx +15 -0
- package/template/components/settings-appearance-card.tsx +3 -2
- package/template/components/settings-client.tsx +59 -15
- package/template/components/settings-form-row.tsx +9 -4
- package/template/components/sidebar-shell.tsx +2 -1
- package/template/components/table-properties/drawer-button.tsx +51 -20
- package/template/components/table-properties/drawer.tsx +81 -17
- package/template/components/templates/focused-workflow-layouts.tsx +448 -0
- package/template/components/templates/focused-workflow-page-template.tsx +69 -0
- package/template/components/templates/list-page.tsx +40 -13
- package/template/components/templates/nested-secondary-panel-shell.tsx +3 -2
- package/template/components/templates/page-loading-shell.tsx +262 -0
- package/template/components/ui/button-group.tsx +1 -0
- package/template/contexts/product-context.tsx +21 -2
- package/template/docs/consumer-app-pattern.md +39 -0
- package/template/docs/data-views-pattern.md +42 -3
- package/template/docs/drawer-vs-dialog-pattern.md +3 -1
- package/template/docs/focused-workflow-page-pattern.md +84 -0
- package/template/docs/kpi-flat-band-pattern.md +57 -0
- package/template/docs/kpi-strip-max-four-pattern.md +1 -0
- package/template/docs/shell-surface-elevation-pattern.md +54 -0
- package/template/lib/chunk-load-error.ts +13 -0
- package/template/lib/command-menu-search-data.ts +11 -27
- package/template/lib/conditional-rule-match.ts +87 -22
- package/template/lib/data-list-display-options.ts +16 -2
- package/template/lib/data-list-view-registry.ts +104 -0
- package/template/lib/data-list-view-surface.ts +15 -1
- package/template/lib/data-list-view.ts +16 -1
- package/template/lib/data-view-dashboard-storage.ts +38 -35
- package/template/lib/hub-connected-view-renderers.ts +58 -0
- package/template/lib/list-hub-nav.ts +121 -0
- package/template/lib/list-hub-supported-views.ts +10 -0
- package/template/lib/list-page-table-properties.ts +3 -7
- package/template/lib/list-status-badges.ts +4 -97
- package/template/lib/mock/list-hub-directory.ts +27 -0
- package/template/lib/mock/list-hub-kpi.ts +27 -0
- package/template/lib/mock/navigation.tsx +1 -0
- package/template/lib/page-loading-variant.ts +40 -0
- package/template/lib/question-bank-supported-views.ts +13 -0
- package/template/lib/sidebar-state-cookie.ts +9 -0
- package/template/lib/table-state-lifecycle.ts +60 -13
- package/template/app/(app)/data-list/[id]/page.tsx +0 -44
- package/template/app/(app)/data-list/new/page.tsx +0 -34
- package/template/components/compliance-board-view.tsx +0 -142
- package/template/components/compliance-client.tsx +0 -92
- package/template/components/compliance-list-view.tsx +0 -54
- package/template/components/compliance-page-header.tsx +0 -89
- package/template/components/compliance-table.tsx +0 -632
- 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 -1068
- package/template/components/placement-board-card.tsx +0 -262
- package/template/components/placement-detail.tsx +0 -438
- package/template/components/placements-board-view.tsx +0 -404
- package/template/components/placements-client.tsx +0 -252
- package/template/components/placements-list-view.tsx +0 -171
- 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 -640
- package/template/components/placements-table.tsx +0 -1675
- package/template/components/rotations-empty-state.tsx +0 -50
- package/template/components/rotations-panel-activator.tsx +0 -8
- package/template/components/sites-all-client.tsx +0 -154
- package/template/components/sites-board-view.tsx +0 -67
- package/template/components/sites-list-view.tsx +0 -42
- package/template/components/sites-table.tsx +0 -402
- package/template/components/team-board-view.tsx +0 -122
- package/template/components/team-client.tsx +0 -100
- package/template/components/team-list-view.tsx +0 -59
- package/template/components/team-page-header.tsx +0 -92
- package/template/components/team-table.tsx +0 -714
- 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 -183
- 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/placement-lifecycle.ts +0 -5
|
@@ -26,7 +26,7 @@ import type { RowHeight } from "@/lib/row-height"
|
|
|
26
26
|
import type { DataListDisplayOptions } from "@/lib/data-list-display-options"
|
|
27
27
|
import type { ActiveFilter, ConditionalRule, SortRule } from "@/components/table-properties/types"
|
|
28
28
|
import type { ViewTab } from "@/components/templates/list-page"
|
|
29
|
-
import type
|
|
29
|
+
import { DATA_LIST_VIEW_TILES, type DataListViewType } from "@/lib/data-list-view"
|
|
30
30
|
|
|
31
31
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
32
|
// Storage key + debounce config
|
|
@@ -139,7 +139,7 @@ export interface TableStatePersistSlice {
|
|
|
139
139
|
// Parsers + validators
|
|
140
140
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
141
141
|
|
|
142
|
-
const VIEW_TYPES: DataListViewType[] =
|
|
142
|
+
const VIEW_TYPES: DataListViewType[] = DATA_LIST_VIEW_TILES.map(t => t.value)
|
|
143
143
|
|
|
144
144
|
function isViewType(v: unknown): v is DataListViewType {
|
|
145
145
|
return typeof v === "string" && (VIEW_TYPES as string[]).includes(v)
|
|
@@ -229,7 +229,19 @@ function filterRecordKeys<T extends Record<string, unknown>>(obj: T, keys: Set<s
|
|
|
229
229
|
return out
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
|
|
232
|
+
function sanitizeActiveFilters(
|
|
233
|
+
filters: ActiveFilter[],
|
|
234
|
+
columnKeys: Set<string>,
|
|
235
|
+
): ActiveFilter[] {
|
|
236
|
+
return filters.filter(f => columnKeys.has(f.fieldKey))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function sanitizeSortRules(rules: SortRule[], columnKeys: Set<string>): SortRule[] {
|
|
240
|
+
return rules.filter(r => columnKeys.has(r.fieldKey))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Column layout only — keeps in-memory search / filters when the column set changes. */
|
|
244
|
+
export function applyLifecycleColumnLayout(
|
|
233
245
|
ts: TableStatePersistSlice,
|
|
234
246
|
p: PersistedLifecycleV1,
|
|
235
247
|
columnKeys: Set<string>,
|
|
@@ -241,11 +253,6 @@ export function applyLifecyclePersisted(
|
|
|
241
253
|
const colWrap = filterRecordKeys(p.colWrap, columnKeys) as Record<string, boolean>
|
|
242
254
|
const colMenuSearch = filterRecordKeys(p.colMenuSearch, columnKeys) as Record<string, string>
|
|
243
255
|
|
|
244
|
-
ts.setSortRules(p.sortRules)
|
|
245
|
-
ts.setSearch(p.search)
|
|
246
|
-
ts.setActiveFilters(p.activeFilters)
|
|
247
|
-
ts.setFilterConnectors(p.filterConnectors)
|
|
248
|
-
ts.setGroupBy(p.groupBy)
|
|
249
256
|
ts.setColOrder(colOrder)
|
|
250
257
|
ts.setHiddenCols(hidden)
|
|
251
258
|
ts.setColWidths(colWidths)
|
|
@@ -254,6 +261,20 @@ export function applyLifecyclePersisted(
|
|
|
254
261
|
ts.setColMenuSearch(colMenuSearch)
|
|
255
262
|
ts.setRowHeight(p.rowHeight)
|
|
256
263
|
ts.setShowGridlines(p.showGridlines)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function applyLifecyclePersisted(
|
|
267
|
+
ts: TableStatePersistSlice,
|
|
268
|
+
p: PersistedLifecycleV1,
|
|
269
|
+
columnKeys: Set<string>,
|
|
270
|
+
): void {
|
|
271
|
+
applyLifecycleColumnLayout(ts, p, columnKeys)
|
|
272
|
+
|
|
273
|
+
ts.setSortRules(sanitizeSortRules(p.sortRules, columnKeys))
|
|
274
|
+
ts.setSearch(p.search)
|
|
275
|
+
ts.setActiveFilters(sanitizeActiveFilters(p.activeFilters, columnKeys))
|
|
276
|
+
ts.setFilterConnectors(p.filterConnectors)
|
|
277
|
+
ts.setGroupBy(p.groupBy != null && columnKeys.has(p.groupBy) ? p.groupBy : null)
|
|
257
278
|
ts.setFilterBarVisible(p.filterBarVisible)
|
|
258
279
|
ts.setSearchOpen(p.searchOpen)
|
|
259
280
|
}
|
|
@@ -427,20 +448,46 @@ export function useTableStateLifecycle<TExtras extends Record<string, unknown> |
|
|
|
427
448
|
onLoadExtrasRef.current = onLoadExtras
|
|
428
449
|
})
|
|
429
450
|
|
|
451
|
+
const columnKeysFingerprint = React.useMemo(
|
|
452
|
+
() => [...columnKeys].sort().join("\0"),
|
|
453
|
+
[columnKeys],
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
const loadedScopeRef = React.useRef<string | null>(null)
|
|
457
|
+
const appliedColumnFingerprintRef = React.useRef<string | null>(null)
|
|
458
|
+
|
|
430
459
|
// ── Load ────────────────────────────────────────────────────────────────
|
|
431
460
|
// useLayoutEffect so the rehydrated state paints in the first frame after
|
|
432
461
|
// mount instead of flashing the unhydrated defaults first.
|
|
433
462
|
React.useLayoutEffect(() => {
|
|
463
|
+
// Wait until column defs exist — applying persisted sort/filters against an
|
|
464
|
+
// empty key set would sanitize everything away and look like Properties broke.
|
|
465
|
+
if (columnKeys.size === 0) return
|
|
466
|
+
|
|
467
|
+
const scope = `${namespace}:${tabId}`
|
|
434
468
|
const raw = loadLifecycleFromStorage(namespace, tabId)
|
|
469
|
+
|
|
470
|
+
if (loadedScopeRef.current !== scope) {
|
|
471
|
+
loadedScopeRef.current = scope
|
|
472
|
+
appliedColumnFingerprintRef.current = columnKeysFingerprint
|
|
473
|
+
if (!raw) return
|
|
474
|
+
applyLifecyclePersisted(tableState, raw, columnKeys)
|
|
475
|
+
const e = readLifecycleExtras<Record<string, unknown>>(raw)
|
|
476
|
+
onLoadExtrasRef.current?.(e as TExtras | Record<string, unknown> | undefined)
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (appliedColumnFingerprintRef.current === columnKeysFingerprint) return
|
|
481
|
+
appliedColumnFingerprintRef.current = columnKeysFingerprint
|
|
435
482
|
if (!raw) return
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
483
|
+
// Column defs changed (e.g. hub scope / dynamic filter options) — re-merge
|
|
484
|
+
// layout only; do not wipe in-memory filters the user set in Properties.
|
|
485
|
+
applyLifecycleColumnLayout(tableState, raw, columnKeys)
|
|
439
486
|
// `tableState` is freshly returned each render; depending on it would
|
|
440
487
|
// re-apply persisted state on every keystroke and undo edits. Depend only
|
|
441
|
-
// on the load scope (namespace + tabId + column
|
|
488
|
+
// on the load scope (namespace + tabId + column fingerprint).
|
|
442
489
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
443
|
-
}, [namespace, tabId,
|
|
490
|
+
}, [namespace, tabId, columnKeysFingerprint])
|
|
444
491
|
|
|
445
492
|
// ── Save ────────────────────────────────────────────────────────────────
|
|
446
493
|
// Serialise + debounce on every persisted slice change. Don't depend on
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import Link from "next/link"
|
|
2
|
-
import { notFound } from "next/navigation"
|
|
3
|
-
import { PrimaryPageTemplate } from "@/components/templates/primary-page-template"
|
|
4
|
-
import { Button } from "@/components/ui/button"
|
|
5
|
-
import { getPlacementById } from "@/lib/mock/placements"
|
|
6
|
-
|
|
7
|
-
export default async function RecordDetailPage({
|
|
8
|
-
params,
|
|
9
|
-
}: {
|
|
10
|
-
params: Promise<{ id: string }>
|
|
11
|
-
}) {
|
|
12
|
-
const { id } = await params
|
|
13
|
-
const row = getPlacementById(Number(id))
|
|
14
|
-
if (!row) notFound()
|
|
15
|
-
|
|
16
|
-
return (
|
|
17
|
-
<PrimaryPageTemplate
|
|
18
|
-
siteHeader={{
|
|
19
|
-
title: "Record",
|
|
20
|
-
breadcrumbs: [{ label: "List hub", href: "/data-list" }],
|
|
21
|
-
}}
|
|
22
|
-
maxWidthClassName="max-w-2xl"
|
|
23
|
-
contentClassName="px-4 lg:px-6 py-6"
|
|
24
|
-
bodyClassName="overflow-y-auto"
|
|
25
|
-
>
|
|
26
|
-
<p className="text-sm text-muted-foreground mb-6">
|
|
27
|
-
Demo read-only detail — replace with your domain route and data fetch.
|
|
28
|
-
</p>
|
|
29
|
-
<dl className="grid gap-3 text-sm sm:grid-cols-[minmax(0,10rem)_1fr]">
|
|
30
|
-
<dt className="text-muted-foreground">Primary label</dt>
|
|
31
|
-
<dd className="font-medium text-foreground">{row.student}</dd>
|
|
32
|
-
<dt className="text-muted-foreground">Status</dt>
|
|
33
|
-
<dd>{row.status}</dd>
|
|
34
|
-
<dt className="text-muted-foreground">Site</dt>
|
|
35
|
-
<dd>{row.site}</dd>
|
|
36
|
-
<dt className="text-muted-foreground">Program</dt>
|
|
37
|
-
<dd>{row.program}</dd>
|
|
38
|
-
</dl>
|
|
39
|
-
<Button asChild variant="outline" className="mt-8">
|
|
40
|
-
<Link href="/data-list">Back to list</Link>
|
|
41
|
-
</Button>
|
|
42
|
-
</PrimaryPageTemplate>
|
|
43
|
-
)
|
|
44
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import Link from "next/link"
|
|
2
|
-
import { NewPlacementForm } from "@/components/new-placement-form"
|
|
3
|
-
import { SidebarAutoCollapse } from "@/components/sidebar-auto-collapse"
|
|
4
|
-
import { PrimaryPageTemplate } from "@/components/templates/primary-page-template"
|
|
5
|
-
|
|
6
|
-
export default function NewRecordPage() {
|
|
7
|
-
return (
|
|
8
|
-
<PrimaryPageTemplate
|
|
9
|
-
beforeSiteHeader={<SidebarAutoCollapse />}
|
|
10
|
-
bodyClassName="overflow-y-auto"
|
|
11
|
-
maxWidthClassName="max-w-3xl"
|
|
12
|
-
contentClassName="px-8 pt-10 pb-32"
|
|
13
|
-
>
|
|
14
|
-
<Link
|
|
15
|
-
href="/data-list"
|
|
16
|
-
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-interactive-hover-foreground transition-colors mb-5 group"
|
|
17
|
-
aria-label="Back to list hub"
|
|
18
|
-
>
|
|
19
|
-
<i className="fa-light fa-arrow-left text-xs transition-transform group-hover:-translate-x-0.5" aria-hidden="true" />
|
|
20
|
-
Back
|
|
21
|
-
</Link>
|
|
22
|
-
<h1
|
|
23
|
-
className="text-[2.25rem] font-semibold tracking-tight leading-none text-foreground mb-2"
|
|
24
|
-
style={{ fontFamily: "var(--font-heading)" }}
|
|
25
|
-
>
|
|
26
|
-
New record
|
|
27
|
-
</h1>
|
|
28
|
-
<p className="text-sm text-muted-foreground mb-8">
|
|
29
|
-
Multi-step wizard shell (demo fields) — swap the form for your product flow.
|
|
30
|
-
</p>
|
|
31
|
-
<NewPlacementForm />
|
|
32
|
-
</PrimaryPageTemplate>
|
|
33
|
-
)
|
|
34
|
-
}
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import * as React from "react"
|
|
4
|
-
import { initialsFromDisplayName } from "@/lib/initials-from-name"
|
|
5
|
-
import {
|
|
6
|
-
COMPLIANCE_STATUS_BADGE_CLASS,
|
|
7
|
-
COMPLIANCE_STATUS_ICON,
|
|
8
|
-
COMPLIANCE_STATUS_LABEL,
|
|
9
|
-
} from "@/lib/list-status-badges"
|
|
10
|
-
import type { ComplianceItem } from "@/lib/mock/compliance"
|
|
11
|
-
import { BoardCardTwoLineBlock } from "@/components/data-views/board-card-primitives"
|
|
12
|
-
import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
|
|
13
|
-
import {
|
|
14
|
-
ListPageBoardCard,
|
|
15
|
-
ListPageBoardCardAvatar,
|
|
16
|
-
ListPageBoardCardBadgeRow,
|
|
17
|
-
ListPageBoardCardBody,
|
|
18
|
-
ListPageBoardCardHeader,
|
|
19
|
-
ListPageBoardCardTitleRow,
|
|
20
|
-
} from "@/components/data-views/list-page-board-card"
|
|
21
|
-
import {
|
|
22
|
-
ListPageBoardTemplate,
|
|
23
|
-
type ListPageBoardColumnDef,
|
|
24
|
-
} from "@/components/data-views/list-page-board-template"
|
|
25
|
-
|
|
26
|
-
const NEUTRAL_COUNT_BADGE = "bg-muted/90 text-foreground"
|
|
27
|
-
|
|
28
|
-
const STATUS_BOARD_COLUMNS: ListPageBoardColumnDef<ComplianceItem>[] = [
|
|
29
|
-
{
|
|
30
|
-
id: "compliant",
|
|
31
|
-
label: "Compliant",
|
|
32
|
-
description: "On track",
|
|
33
|
-
filter: r => r.status === "compliant",
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
id: "due_soon",
|
|
37
|
-
label: "Due soon",
|
|
38
|
-
description: "Within window",
|
|
39
|
-
filter: r => r.status === "due_soon",
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
id: "overdue",
|
|
43
|
-
label: "Overdue",
|
|
44
|
-
description: "Action required",
|
|
45
|
-
filter: r => r.status === "overdue",
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
id: "pending",
|
|
49
|
-
label: "Pending",
|
|
50
|
-
description: "Not started",
|
|
51
|
-
filter: r => r.status === "pending",
|
|
52
|
-
},
|
|
53
|
-
]
|
|
54
|
-
|
|
55
|
-
function categoryBoardColumns(rows: ComplianceItem[]): {
|
|
56
|
-
columns: ListPageBoardColumnDef<ComplianceItem>[]
|
|
57
|
-
badgeMap: Record<string, string>
|
|
58
|
-
} {
|
|
59
|
-
const labels = [...new Set(rows.map(r => r.category))].sort((a, b) => a.localeCompare(b))
|
|
60
|
-
const columns: ListPageBoardColumnDef<ComplianceItem>[] = labels.map(label => ({
|
|
61
|
-
id: `category:${label}`,
|
|
62
|
-
label,
|
|
63
|
-
filter: (r: ComplianceItem) => r.category === label,
|
|
64
|
-
}))
|
|
65
|
-
const badgeMap = Object.fromEntries(labels.map(l => [`category:${l}`, NEUTRAL_COUNT_BADGE]))
|
|
66
|
-
return { columns, badgeMap }
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function useComplianceBoardModel(rows: ComplianceItem[], groupByColumnKey: string) {
|
|
70
|
-
return React.useMemo(() => {
|
|
71
|
-
if (groupByColumnKey === "category") {
|
|
72
|
-
const { columns, badgeMap } = categoryBoardColumns(rows)
|
|
73
|
-
return { columns, badgeMap }
|
|
74
|
-
}
|
|
75
|
-
return {
|
|
76
|
-
columns: STATUS_BOARD_COLUMNS,
|
|
77
|
-
badgeMap: COMPLIANCE_STATUS_BADGE_CLASS as Record<string, string>,
|
|
78
|
-
}
|
|
79
|
-
}, [rows, groupByColumnKey])
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function ComplianceBoardCard({
|
|
83
|
-
row,
|
|
84
|
-
onRowActivate,
|
|
85
|
-
}: {
|
|
86
|
-
row: ComplianceItem
|
|
87
|
-
onRowActivate?: (row: ComplianceItem) => void
|
|
88
|
-
}) {
|
|
89
|
-
const ownerInitials = initialsFromDisplayName(row.owner)
|
|
90
|
-
return (
|
|
91
|
-
<ListPageBoardCard className="w-full" onClick={onRowActivate ? () => onRowActivate(row) : undefined}>
|
|
92
|
-
<ListPageBoardCardHeader>
|
|
93
|
-
<ListPageBoardCardTitleRow
|
|
94
|
-
title={row.title}
|
|
95
|
-
titleClassName="line-clamp-2"
|
|
96
|
-
trailing={<ListPageBoardCardAvatar initials={ownerInitials} />}
|
|
97
|
-
/>
|
|
98
|
-
<ListPageBoardCardBadgeRow>
|
|
99
|
-
<ListHubStatusBadge
|
|
100
|
-
surface="board"
|
|
101
|
-
label={COMPLIANCE_STATUS_LABEL[row.status]}
|
|
102
|
-
tintClassName={COMPLIANCE_STATUS_BADGE_CLASS[row.status]}
|
|
103
|
-
icon={COMPLIANCE_STATUS_ICON[row.status]}
|
|
104
|
-
/>
|
|
105
|
-
</ListPageBoardCardBadgeRow>
|
|
106
|
-
<ListPageBoardCardBody>
|
|
107
|
-
<BoardCardTwoLineBlock iconClass="fa-tag" line1={row.category} line2={`Due ${row.dueDate}`} />
|
|
108
|
-
<BoardCardTwoLineBlock iconClass="fa-user" line1={row.owner} line2="Owner" />
|
|
109
|
-
</ListPageBoardCardBody>
|
|
110
|
-
</ListPageBoardCardHeader>
|
|
111
|
-
</ListPageBoardCard>
|
|
112
|
-
)
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export const COMPLIANCE_BOARD_GROUP_OPTIONS = [
|
|
116
|
-
{ key: "status", label: "Status" },
|
|
117
|
-
{ key: "category", label: "Category" },
|
|
118
|
-
] as const
|
|
119
|
-
|
|
120
|
-
export function ComplianceBoardView({
|
|
121
|
-
rows,
|
|
122
|
-
groupByColumnKey,
|
|
123
|
-
onRowActivate,
|
|
124
|
-
}: {
|
|
125
|
-
rows: ComplianceItem[]
|
|
126
|
-
groupByColumnKey: string
|
|
127
|
-
onRowActivate?: (row: ComplianceItem) => void
|
|
128
|
-
}) {
|
|
129
|
-
const key = groupByColumnKey === "category" ? "category" : "status"
|
|
130
|
-
const { columns, badgeMap } = useComplianceBoardModel(rows, key)
|
|
131
|
-
|
|
132
|
-
return (
|
|
133
|
-
<ListPageBoardTemplate
|
|
134
|
-
columns={columns}
|
|
135
|
-
rows={rows}
|
|
136
|
-
getRowKey={r => r.id}
|
|
137
|
-
columnCountBadgeClassName={badgeMap}
|
|
138
|
-
emptyColumnLabel="No items"
|
|
139
|
-
renderCard={row => <ComplianceBoardCard row={row} onRowActivate={onRowActivate} />}
|
|
140
|
-
/>
|
|
141
|
-
)
|
|
142
|
-
}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Compliance list page — `ListPageTemplate` + `ComplianceTable`; view types from `@/components/data-views`.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import * as React from "react"
|
|
8
|
-
import {
|
|
9
|
-
ListPageTemplate,
|
|
10
|
-
type ViewTab,
|
|
11
|
-
dataListViewIcon,
|
|
12
|
-
type DataListViewType,
|
|
13
|
-
} from "@/components/data-views"
|
|
14
|
-
import { CompliancePageHeader } from "@/components/compliance-page-header"
|
|
15
|
-
import { ComplianceTable, type ComplianceTableHandle } from "@/components/compliance-table"
|
|
16
|
-
import { KeyMetrics } from "@/components/key-metrics"
|
|
17
|
-
import { useAskLeoPageContext } from "@/components/ask-leo-sidebar"
|
|
18
|
-
import { COMPLIANCE_ITEMS } from "@/lib/mock/compliance"
|
|
19
|
-
import { complianceKpiInsight, complianceKpiMetrics } from "@/lib/mock/compliance-kpi"
|
|
20
|
-
|
|
21
|
-
const DEFAULT_TABS: ViewTab[] = [
|
|
22
|
-
{
|
|
23
|
-
id: "obligations",
|
|
24
|
-
label: "Obligations",
|
|
25
|
-
viewType: "table",
|
|
26
|
-
icon: "fa-table",
|
|
27
|
-
filterId: "all",
|
|
28
|
-
},
|
|
29
|
-
]
|
|
30
|
-
|
|
31
|
-
export function ComplianceClient() {
|
|
32
|
-
const [exportOpen, setExportOpen] = React.useState(false)
|
|
33
|
-
const [showMetrics, setShowMetrics] = React.useState(true)
|
|
34
|
-
const tableRef = React.useRef<ComplianceTableHandle>(null)
|
|
35
|
-
const count = COMPLIANCE_ITEMS.length
|
|
36
|
-
|
|
37
|
-
const metrics = React.useMemo(() => complianceKpiMetrics(COMPLIANCE_ITEMS), [])
|
|
38
|
-
const insight = React.useMemo(() => complianceKpiInsight(COMPLIANCE_ITEMS), [])
|
|
39
|
-
|
|
40
|
-
useAskLeoPageContext(
|
|
41
|
-
React.useMemo(
|
|
42
|
-
() => ({
|
|
43
|
-
title: "Compliance",
|
|
44
|
-
description: `${count} obligations tracked on this hub.`,
|
|
45
|
-
suggestions: [
|
|
46
|
-
"What’s due this week?",
|
|
47
|
-
"Summarize open items by student",
|
|
48
|
-
],
|
|
49
|
-
}),
|
|
50
|
-
[count],
|
|
51
|
-
),
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
return (
|
|
55
|
-
<ListPageTemplate
|
|
56
|
-
defaultTabs={DEFAULT_TABS}
|
|
57
|
-
getTabCount={() => count}
|
|
58
|
-
tablePropertiesRef={tableRef}
|
|
59
|
-
header={
|
|
60
|
-
<CompliancePageHeader
|
|
61
|
-
itemCount={count}
|
|
62
|
-
onAddReview={() => {}}
|
|
63
|
-
onExport={() => setExportOpen(true)}
|
|
64
|
-
showMetrics={showMetrics}
|
|
65
|
-
onToggleMetrics={() => setShowMetrics(v => !v)}
|
|
66
|
-
/>
|
|
67
|
-
}
|
|
68
|
-
metrics={
|
|
69
|
-
<KeyMetrics
|
|
70
|
-
variant="flat"
|
|
71
|
-
metrics={metrics}
|
|
72
|
-
insight={insight}
|
|
73
|
-
showHeader={false}
|
|
74
|
-
metricsSingleRow
|
|
75
|
-
/>
|
|
76
|
-
}
|
|
77
|
-
showMetrics={showMetrics}
|
|
78
|
-
exportOpen={exportOpen}
|
|
79
|
-
onExportOpenChange={setExportOpen}
|
|
80
|
-
exportTotalRows={count}
|
|
81
|
-
renderContent={(tab, updateTab) => (
|
|
82
|
-
<ComplianceTable
|
|
83
|
-
key={tab.id}
|
|
84
|
-
ref={tableRef}
|
|
85
|
-
items={COMPLIANCE_ITEMS}
|
|
86
|
-
view={tab.viewType}
|
|
87
|
-
onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
|
|
88
|
-
/>
|
|
89
|
-
)}
|
|
90
|
-
/>
|
|
91
|
-
)
|
|
92
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
|
|
4
|
-
import { ListPageBoardCard } from "@/components/data-views/list-page-board-card"
|
|
5
|
-
import { DataRowList } from "@/components/data-views/data-row-list"
|
|
6
|
-
import {
|
|
7
|
-
COMPLIANCE_STATUS_BADGE_CLASS,
|
|
8
|
-
COMPLIANCE_STATUS_ICON,
|
|
9
|
-
COMPLIANCE_STATUS_LABEL,
|
|
10
|
-
} from "@/lib/list-status-badges"
|
|
11
|
-
import type { ComplianceItem } from "@/lib/mock/compliance"
|
|
12
|
-
|
|
13
|
-
export function ComplianceListView({
|
|
14
|
-
rows,
|
|
15
|
-
onRowActivate,
|
|
16
|
-
}: {
|
|
17
|
-
rows: ComplianceItem[]
|
|
18
|
-
onRowActivate?: (row: ComplianceItem) => void
|
|
19
|
-
}) {
|
|
20
|
-
return (
|
|
21
|
-
<DataRowList<ComplianceItem>
|
|
22
|
-
rows={rows}
|
|
23
|
-
getRowId={row => row.id}
|
|
24
|
-
emptyState="No compliance items match your filters."
|
|
25
|
-
ariaLabel="Compliance items"
|
|
26
|
-
renderRow={row => (
|
|
27
|
-
<ListPageBoardCard
|
|
28
|
-
layout="row"
|
|
29
|
-
rowContainerClassName="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:gap-4"
|
|
30
|
-
onClick={onRowActivate ? () => onRowActivate(row) : undefined}
|
|
31
|
-
rowEnd={
|
|
32
|
-
<div className="flex shrink-0 items-center gap-2">
|
|
33
|
-
<ListHubStatusBadge
|
|
34
|
-
surface="board"
|
|
35
|
-
label={COMPLIANCE_STATUS_LABEL[row.status]}
|
|
36
|
-
tintClassName={COMPLIANCE_STATUS_BADGE_CLASS[row.status]}
|
|
37
|
-
icon={COMPLIANCE_STATUS_ICON[row.status]}
|
|
38
|
-
/>
|
|
39
|
-
<i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
|
|
40
|
-
</div>
|
|
41
|
-
}
|
|
42
|
-
>
|
|
43
|
-
<div className="space-y-0.5">
|
|
44
|
-
<p className="text-sm font-semibold text-foreground">{row.title}</p>
|
|
45
|
-
<p className="text-xs text-muted-foreground">
|
|
46
|
-
{row.category} · Due {row.dueDate}
|
|
47
|
-
</p>
|
|
48
|
-
<p className="text-xs text-muted-foreground">Owner: {row.owner}</p>
|
|
49
|
-
</div>
|
|
50
|
-
</ListPageBoardCard>
|
|
51
|
-
)}
|
|
52
|
-
/>
|
|
53
|
-
)
|
|
54
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import * as React from "react"
|
|
4
|
-
import { Button } from "@/components/ui/button"
|
|
5
|
-
import { PageHeader } from "@/components/page-header"
|
|
6
|
-
import {
|
|
7
|
-
DropdownMenu,
|
|
8
|
-
DropdownMenuContent,
|
|
9
|
-
DropdownMenuItem,
|
|
10
|
-
DropdownMenuSeparator,
|
|
11
|
-
DropdownMenuTrigger,
|
|
12
|
-
} from "@/components/ui/dropdown-menu"
|
|
13
|
-
import { Tip } from "@/components/ui/tip"
|
|
14
|
-
|
|
15
|
-
export interface CompliancePageHeaderProps {
|
|
16
|
-
itemCount: number
|
|
17
|
-
onAddReview: () => void
|
|
18
|
-
onExport: () => void
|
|
19
|
-
showMetrics: boolean
|
|
20
|
-
onToggleMetrics: () => void
|
|
21
|
-
showTitleBlock?: boolean
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function CompliancePageHeader({
|
|
25
|
-
itemCount,
|
|
26
|
-
onAddReview,
|
|
27
|
-
onExport,
|
|
28
|
-
showMetrics,
|
|
29
|
-
onToggleMetrics,
|
|
30
|
-
showTitleBlock = true,
|
|
31
|
-
}: CompliancePageHeaderProps) {
|
|
32
|
-
const [moreOpen, setMoreOpen] = React.useState(false)
|
|
33
|
-
const countLine = `${itemCount} ${itemCount === 1 ? "item" : "items"} · Last updated now`
|
|
34
|
-
|
|
35
|
-
return (
|
|
36
|
-
<PageHeader
|
|
37
|
-
title="Compliance"
|
|
38
|
-
subtitle={countLine}
|
|
39
|
-
showTitleBlock={showTitleBlock}
|
|
40
|
-
actions={(
|
|
41
|
-
<div className="flex items-center gap-2" role="group" aria-label="Compliance actions">
|
|
42
|
-
<Tip side="bottom" label="Schedule a review (demo)">
|
|
43
|
-
<Button type="button" size="lg" onClick={onAddReview}>
|
|
44
|
-
<i className="fa-light fa-calendar-check" aria-hidden="true" />
|
|
45
|
-
New review
|
|
46
|
-
</Button>
|
|
47
|
-
</Tip>
|
|
48
|
-
<DropdownMenu open={moreOpen} onOpenChange={setMoreOpen}>
|
|
49
|
-
<Tip side="bottom" label="More actions">
|
|
50
|
-
<DropdownMenuTrigger asChild>
|
|
51
|
-
<Button
|
|
52
|
-
type="button"
|
|
53
|
-
size="lg"
|
|
54
|
-
variant="outline"
|
|
55
|
-
className="aspect-square px-0"
|
|
56
|
-
aria-label="More actions"
|
|
57
|
-
>
|
|
58
|
-
<i className="fa-light fa-ellipsis text-base" aria-hidden="true" />
|
|
59
|
-
</Button>
|
|
60
|
-
</DropdownMenuTrigger>
|
|
61
|
-
</Tip>
|
|
62
|
-
<DropdownMenuContent align="end">
|
|
63
|
-
<DropdownMenuItem
|
|
64
|
-
onSelect={() => {
|
|
65
|
-
window.setTimeout(() => onExport(), 0)
|
|
66
|
-
}}
|
|
67
|
-
>
|
|
68
|
-
<i className="fa-light fa-arrow-down-to-line" aria-hidden="true" />
|
|
69
|
-
Export
|
|
70
|
-
</DropdownMenuItem>
|
|
71
|
-
<DropdownMenuSeparator />
|
|
72
|
-
<DropdownMenuItem
|
|
73
|
-
onSelect={() => {
|
|
74
|
-
window.setTimeout(() => onToggleMetrics(), 0)
|
|
75
|
-
}}
|
|
76
|
-
>
|
|
77
|
-
<i
|
|
78
|
-
className={`fa-light ${showMetrics ? "fa-eye-slash" : "fa-eye"}`}
|
|
79
|
-
aria-hidden="true"
|
|
80
|
-
/>
|
|
81
|
-
{showMetrics ? "Hide metric section" : "Show metric section"}
|
|
82
|
-
</DropdownMenuItem>
|
|
83
|
-
</DropdownMenuContent>
|
|
84
|
-
</DropdownMenu>
|
|
85
|
-
</div>
|
|
86
|
-
)}
|
|
87
|
-
/>
|
|
88
|
-
)
|
|
89
|
-
}
|