@exxatdesignux/ui 0.0.6 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/init.mjs +29 -0
- package/package.json +7 -2
- package/template/.nvmrc +1 -0
- package/template/.prettierignore +7 -0
- package/template/.prettierrc +11 -0
- package/template/AGENTS.md +485 -0
- package/template/Logo/Exxat_Prism.svg +39 -0
- package/template/Logo/Exxat_one.svg +36 -0
- package/template/README.md +58 -0
- package/template/app/(app)/compliance/page.tsx +10 -0
- package/template/app/(app)/dashboard/loading.tsx +18 -0
- package/template/app/(app)/dashboard/page.tsx +36 -0
- package/template/app/(app)/data-list/[id]/page.tsx +28 -0
- package/template/app/(app)/data-list/new/page.tsx +31 -0
- package/template/app/(app)/data-list/page.tsx +10 -0
- package/template/app/(app)/error.tsx +43 -0
- package/template/app/(app)/help/page.tsx +34 -0
- package/template/app/(app)/layout.tsx +54 -0
- package/template/app/(app)/loading.tsx +18 -0
- package/template/app/(app)/question-bank/page.tsx +10 -0
- package/template/app/(app)/rotations/page.tsx +15 -0
- package/template/app/(app)/settings/page.tsx +17 -0
- package/template/app/(app)/sites/all/page.tsx +13 -0
- package/template/app/(app)/team/page.tsx +10 -0
- package/template/app/favicon.ico +0 -0
- package/template/app/globals.css +1811 -0
- package/template/app/layout.tsx +95 -0
- package/template/app/page.tsx +9 -0
- package/template/components/.gitkeep +0 -0
- package/template/components/app-sidebar-dynamic.tsx +15 -0
- package/template/components/app-sidebar.tsx +901 -0
- package/template/components/ask-leo-composer.tsx +216 -0
- package/template/components/ask-leo-sidebar.tsx +509 -0
- package/template/components/chart-area-interactive.tsx +293 -0
- package/template/components/charts-overview.tsx +2321 -0
- package/template/components/command-menu-01.tsx +133 -0
- package/template/components/command-menu-02.tsx +386 -0
- package/template/components/command-menu.tsx +182 -0
- package/template/components/compliance-board-view.tsx +134 -0
- package/template/components/compliance-client.tsx +92 -0
- package/template/components/compliance-list-view.tsx +59 -0
- package/template/components/compliance-page-header.tsx +89 -0
- package/template/components/compliance-table.tsx +525 -0
- package/template/components/dashboard-onboarding-gallery.tsx +13 -0
- package/template/components/dashboard-onboarding.tsx +21 -0
- package/template/components/dashboard-promo-banner.tsx +67 -0
- package/template/components/dashboard-quota-progress-card.tsx +369 -0
- package/template/components/dashboard-report-charts.tsx +69 -0
- package/template/components/dashboard-section-heading.tsx +68 -0
- package/template/components/dashboard-tabs.tsx +598 -0
- package/template/components/data-list-client.tsx +239 -0
- package/template/components/data-list-table-cells.test.tsx +22 -0
- package/template/components/data-list-table-cells.tsx +173 -0
- package/template/components/data-list-table.tsx +879 -0
- package/template/components/data-table/filter-date-calendar.tsx +38 -0
- package/template/components/data-table/filter-text-value-input.tsx +77 -0
- package/template/components/data-table/index.tsx +1612 -0
- package/template/components/data-table/pagination.tsx +256 -0
- package/template/components/data-table/types.ts +91 -0
- package/template/components/data-table/use-table-state.ts +566 -0
- package/template/components/data-view-dashboard-charts-compliance.tsx +960 -0
- package/template/components/data-view-dashboard-charts-team.tsx +968 -0
- package/template/components/data-view-dashboard-charts.tsx +1668 -0
- package/template/components/data-views/board-card-primitives.tsx +93 -0
- package/template/components/data-views/index.ts +41 -0
- package/template/components/data-views/list-page-board-card.tsx +192 -0
- package/template/components/data-views/list-page-board-template.tsx +122 -0
- package/template/components/data-views/placement-board-card.tsx +262 -0
- package/template/components/export-drawer.tsx +375 -0
- package/template/components/exxat-product-logo.tsx +453 -0
- package/template/components/form-layout-01.tsx +131 -0
- package/template/components/getting-started.tsx +625 -0
- package/template/components/key-metrics.tsx +920 -0
- package/template/components/leo-insight-indicator.tsx +364 -0
- package/template/components/leo-typing-dots.tsx +121 -0
- package/template/components/list-hub-status-badge.tsx +51 -0
- package/template/components/list-page-dashboard-charts.tsx +18 -0
- package/template/components/nav-documents.tsx +89 -0
- package/template/components/nav-main.tsx +58 -0
- package/template/components/nav-secondary.tsx +64 -0
- package/template/components/nav-user.tsx +190 -0
- package/template/components/new-placement-back-btn.tsx +28 -0
- package/template/components/new-placement-form.tsx +1066 -0
- package/template/components/onboarding/index.ts +4 -0
- package/template/components/onboarding/onboarding-01.tsx +7 -0
- package/template/components/onboarding/onboarding-02.tsx +7 -0
- package/template/components/onboarding/onboarding-03.tsx +7 -0
- package/template/components/onboarding/onboarding-04.tsx +7 -0
- package/template/components/page-header.tsx +57 -0
- package/template/components/placement-detail.tsx +438 -0
- package/template/components/placements-board-view.tsx +404 -0
- package/template/components/placements-list-view.tsx +285 -0
- package/template/components/placements-page-header.tsx +160 -0
- package/template/components/placements-table-columns.tsx +639 -0
- package/template/components/product-switcher.tsx +116 -0
- package/template/components/question-bank-board-view.tsx +205 -0
- package/template/components/question-bank-client.tsx +77 -0
- package/template/components/question-bank-list-view.tsx +59 -0
- package/template/components/question-bank-page-header.tsx +89 -0
- package/template/components/question-bank-table.tsx +586 -0
- package/template/components/rotations-empty-state.tsx +47 -0
- package/template/components/rotations-panel-activator.tsx +8 -0
- package/template/components/secondary-nav.tsx +394 -0
- package/template/components/secondary-panel.tsx +239 -0
- package/template/components/section-cards.tsx +106 -0
- package/template/components/settings-appearance-card.tsx +424 -0
- package/template/components/settings-client.tsx +537 -0
- package/template/components/settings-form-row.tsx +42 -0
- package/template/components/sidebar-auto-collapse.tsx +23 -0
- package/template/components/sidebar-auto-open.tsx +18 -0
- package/template/components/sidebar-shell.tsx +37 -0
- package/template/components/site-header.tsx +93 -0
- package/template/components/sites-all-client.tsx +154 -0
- package/template/components/sites-board-view.tsx +67 -0
- package/template/components/sites-list-view.tsx +47 -0
- package/template/components/sites-table.tsx +312 -0
- package/template/components/system-banner-slot.tsx +66 -0
- package/template/components/table-properties/column-row.tsx +90 -0
- package/template/components/table-properties/draggable-list.ts +49 -0
- package/template/components/table-properties/drawer-button.tsx +231 -0
- package/template/components/table-properties/drawer.tsx +1102 -0
- package/template/components/table-properties/filter-card.tsx +251 -0
- package/template/components/table-properties/index.ts +22 -0
- package/template/components/table-properties/sort-card.tsx +59 -0
- package/template/components/table-properties/types.ts +124 -0
- package/template/components/task-list-panel.tsx +98 -0
- package/template/components/task-priority-badge.tsx +28 -0
- package/template/components/team-board-view.tsx +114 -0
- package/template/components/team-client.tsx +93 -0
- package/template/components/team-list-view.tsx +62 -0
- package/template/components/team-page-header.tsx +92 -0
- package/template/components/team-table.tsx +525 -0
- package/template/components/templates/list-page.tsx +576 -0
- package/template/components/templates/primary-page-template.tsx +56 -0
- package/template/components/theme-color-sync.tsx +32 -0
- package/template/components/theme-provider.tsx +71 -0
- package/template/components/tinted-icon-disc.tsx +53 -0
- package/template/components/ui/ai-thinking-surface.tsx +121 -0
- package/template/components/ui/avatar.tsx +1 -0
- package/template/components/ui/badge.tsx +1 -0
- package/template/components/ui/banner.tsx +1 -0
- package/template/components/ui/breadcrumb.tsx +1 -0
- package/template/components/ui/button.tsx +1 -0
- package/template/components/ui/calendar.tsx +1 -0
- package/template/components/ui/card.tsx +1 -0
- package/template/components/ui/chart.tsx +1 -0
- package/template/components/ui/checkbox.tsx +1 -0
- package/template/components/ui/coach-mark.tsx +1 -0
- package/template/components/ui/collapsible.tsx +1 -0
- package/template/components/ui/command.tsx +1 -0
- package/template/components/ui/date-picker-field.tsx +1 -0
- package/template/components/ui/dialog.tsx +1 -0
- package/template/components/ui/dot-pattern.tsx +159 -0
- package/template/components/ui/drag-handle-grip.tsx +1 -0
- package/template/components/ui/drawer.tsx +1 -0
- package/template/components/ui/dropdown-menu.tsx +1 -0
- package/template/components/ui/field.tsx +1 -0
- package/template/components/ui/form.tsx +1 -0
- package/template/components/ui/input-group.tsx +1 -0
- package/template/components/ui/input-mask.tsx +1 -0
- package/template/components/ui/input.tsx +1 -0
- package/template/components/ui/kbd.tsx +1 -0
- package/template/components/ui/label.tsx +1 -0
- package/template/components/ui/leo-icon.tsx +726 -0
- package/template/components/ui/payment-card-fields.tsx +1 -0
- package/template/components/ui/popover.tsx +1 -0
- package/template/components/ui/radio-group.tsx +1 -0
- package/template/components/ui/select.tsx +1 -0
- package/template/components/ui/selection-tile-grid.tsx +1 -0
- package/template/components/ui/separator.tsx +1 -0
- package/template/components/ui/sheet.tsx +1 -0
- package/template/components/ui/sidebar.tsx +1 -0
- package/template/components/ui/skeleton.tsx +1 -0
- package/template/components/ui/sonner.tsx +1 -0
- package/template/components/ui/status-badge.tsx +1 -0
- package/template/components/ui/table.tsx +1 -0
- package/template/components/ui/tabs.tsx +1 -0
- package/template/components/ui/textarea.tsx +1 -0
- package/template/components/ui/tip.tsx +1 -0
- package/template/components/ui/toggle-group.tsx +1 -0
- package/template/components/ui/toggle-switch.tsx +1 -0
- package/template/components/ui/toggle.tsx +1 -0
- package/template/components/ui/tooltip.tsx +1 -0
- package/template/components/ui/view-segmented-control.tsx +1 -0
- package/template/components.json +27 -0
- package/template/contexts/chart-variant-context.tsx +35 -0
- package/template/contexts/command-menu-context.tsx +28 -0
- package/template/contexts/dashboard-view-context.tsx +35 -0
- package/template/contexts/product-context.tsx +38 -0
- package/template/contexts/system-banner-context.tsx +127 -0
- package/template/docs/command-menu-pattern.md +45 -0
- package/template/docs/data-views-pattern.md +160 -0
- package/template/ecosystem.config.cjs +20 -0
- package/template/eslint.config.mjs +18 -0
- package/template/fontawesome-subset.manifest.json +190 -0
- package/template/hooks/.gitkeep +0 -0
- package/template/hooks/use-app-theme.ts +1 -0
- package/template/hooks/use-coach-mark.ts +1 -0
- package/template/hooks/use-mobile.ts +1 -0
- package/template/hooks/use-mod-key-label.ts +1 -0
- package/template/lib/.gitkeep +0 -0
- package/template/lib/ask-leo-route-context.ts +133 -0
- package/template/lib/chart-keyboard-selection.test.ts +20 -0
- package/template/lib/chart-keyboard-selection.ts +17 -0
- package/template/lib/chart-line-dash.ts +16 -0
- package/template/lib/coach-mark-registry.ts +68 -0
- package/template/lib/command-menu-config.ts +127 -0
- package/template/lib/command-menu-search-data.ts +44 -0
- package/template/lib/conditional-rule-match.ts +32 -0
- package/template/lib/dashboard-customize-coach-mark.ts +18 -0
- package/template/lib/dashboard-layout-merge.ts +63 -0
- package/template/lib/data-list-display-options.ts +35 -0
- package/template/lib/data-list-persistence.ts +280 -0
- package/template/lib/data-list-view-surface.ts +58 -0
- package/template/lib/data-list-view.ts +29 -0
- package/template/lib/data-view-dashboard-storage.ts +101 -0
- package/template/lib/date-filter.ts +8 -0
- package/template/lib/dev-log.test.ts +28 -0
- package/template/lib/dev-log.ts +8 -0
- package/template/lib/editable-target.ts +10 -0
- package/template/lib/floating-sheet-panel.ts +72 -0
- package/template/lib/initials-from-name.ts +7 -0
- package/template/lib/list-page-table-properties.ts +52 -0
- package/template/lib/list-status-badges.ts +168 -0
- package/template/lib/logo-dev.ts +12 -0
- package/template/lib/mock/compliance-kpi.ts +61 -0
- package/template/lib/mock/compliance.ts +146 -0
- package/template/lib/mock/dashboard.ts +105 -0
- package/template/lib/mock/navigation.tsx +231 -0
- package/template/lib/mock/placements-kpi.ts +134 -0
- package/template/lib/mock/placements.ts +183 -0
- package/template/lib/mock/question-bank-kpi.ts +61 -0
- package/template/lib/mock/question-bank.ts +142 -0
- package/template/lib/mock/sites-directory.ts +16 -0
- package/template/lib/mock/sites-kpi.ts +25 -0
- package/template/lib/mock/team-kpi.ts +60 -0
- package/template/lib/mock/team.ts +118 -0
- package/template/lib/motion-ui.ts +17 -0
- package/template/lib/placement-board-card-layout.ts +79 -0
- package/template/lib/placement-lifecycle.ts +5 -0
- package/template/lib/row-height.ts +10 -0
- package/template/lib/stock-portrait.ts +11 -0
- package/template/lib/utils.test.ts +13 -0
- package/template/lib/utils.ts +1 -0
- package/template/next.config.mjs +15 -0
- package/template/package.json +83 -0
- package/template/postcss.config.mjs +8 -0
- package/template/public/.gitkeep +0 -0
- package/template/public/Illustration/Rotation.svg +74 -0
- package/template/public/avatars/user.svg +11 -0
- package/template/public/favicon/favicon.ico +0 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/logos/exxat-one.svg +36 -0
- package/template/public/logos/exxat-prism.svg +39 -0
- package/template/public/mock-schools/emory.svg +4 -0
- package/template/public/mock-schools/rush.svg +4 -0
- package/template/scripts/fontawesome-subset-audit.mjs +190 -0
- package/template/scripts/pm2-startup-macos.sh +13 -0
- package/template/skills-lock.json +10 -0
- package/template/stores/app-store.ts +33 -0
- package/template/tests/setup.ts +1 -0
- package/template/tsconfig.json +35 -0
- package/template/types/react-payment-inputs.d.ts +19 -0
- package/template/vitest.config.ts +18 -0
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* KeyMetrics — WCAG 2.1 AA reusable KPI panel
|
|
5
|
+
*
|
|
6
|
+
* Variants:
|
|
7
|
+
* "card" (default) — shadcn Card wrapper with brand gradient fill
|
|
8
|
+
* "flat" — full-width brand gradient band, no card chrome
|
|
9
|
+
*
|
|
10
|
+
* AA checklist:
|
|
11
|
+
* ✓ Trend text never relies on colour alone — icon + label (WCAG 1.4.1)
|
|
12
|
+
* ✓ Trend icons have aria-hidden; sr-only label carries meaning (1.1.1)
|
|
13
|
+
* ✓ Select has accessible label via aria-label (4.1.2)
|
|
14
|
+
* ✓ Insight action button has descriptive text (4.1.2)
|
|
15
|
+
* ✓ Decorative dividers are aria-hidden (1.1.1)
|
|
16
|
+
* ✓ Contrast: value text foreground ≥ 17:1, trend colours ≥ 4.5:1 (1.4.3)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as React from "react"
|
|
20
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
21
|
+
import {
|
|
22
|
+
Select,
|
|
23
|
+
SelectContent,
|
|
24
|
+
SelectItem,
|
|
25
|
+
SelectTrigger,
|
|
26
|
+
SelectValue,
|
|
27
|
+
} from "@/components/ui/select"
|
|
28
|
+
import { Separator } from "@/components/ui/separator"
|
|
29
|
+
import { AskLeoShortcutKbds, useAskLeo } from "@/components/ask-leo-sidebar"
|
|
30
|
+
import { Button } from "@/components/ui/button"
|
|
31
|
+
import {
|
|
32
|
+
Tooltip,
|
|
33
|
+
TooltipContent,
|
|
34
|
+
TooltipTrigger,
|
|
35
|
+
} from "@/components/ui/tooltip"
|
|
36
|
+
import { cn } from "@/lib/utils"
|
|
37
|
+
|
|
38
|
+
/** Tooltip + optional ⌘⌥K when the insight CTA is the default Ask Leo action. */
|
|
39
|
+
function InsightAskLeoTooltip({
|
|
40
|
+
actionLabel,
|
|
41
|
+
children,
|
|
42
|
+
}: {
|
|
43
|
+
actionLabel?: string
|
|
44
|
+
children: React.ReactNode
|
|
45
|
+
}) {
|
|
46
|
+
const label = actionLabel ?? "Ask Leo"
|
|
47
|
+
const showShortcut = !actionLabel || actionLabel === "Ask Leo"
|
|
48
|
+
if (!showShortcut) {
|
|
49
|
+
return (
|
|
50
|
+
<Tooltip>
|
|
51
|
+
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
|
52
|
+
<TooltipContent side="top">{label}</TooltipContent>
|
|
53
|
+
</Tooltip>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
return (
|
|
57
|
+
<Tooltip>
|
|
58
|
+
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
|
59
|
+
<TooltipContent side="top" className="flex flex-wrap items-center gap-1.5">
|
|
60
|
+
<span>{label}</span>
|
|
61
|
+
<AskLeoShortcutKbds />
|
|
62
|
+
</TooltipContent>
|
|
63
|
+
</Tooltip>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ── Types ────────────────────────────────────────────────────────────────── */
|
|
68
|
+
|
|
69
|
+
export interface MetricItem {
|
|
70
|
+
/** Unique identifier for React keying */
|
|
71
|
+
id: string
|
|
72
|
+
/** Short label shown above the value */
|
|
73
|
+
label: string
|
|
74
|
+
/** Displayed value — e.g. "23", "98%", "1,250" */
|
|
75
|
+
value: string | number
|
|
76
|
+
/** Change delta — e.g. "+5", "-3", "+12" */
|
|
77
|
+
delta: string | number
|
|
78
|
+
/** Visual + semantic trend direction */
|
|
79
|
+
trend: "up" | "down" | "neutral"
|
|
80
|
+
/** Makes the cell a link */
|
|
81
|
+
href?: string
|
|
82
|
+
/** Makes the cell a button */
|
|
83
|
+
onClick?: () => void
|
|
84
|
+
/**
|
|
85
|
+
* "hero" — primary KPI (e.g. total count): larger value, same structure as siblings.
|
|
86
|
+
* "default" — standard KPI strip cell.
|
|
87
|
+
*/
|
|
88
|
+
metricVariant?: "default" | "hero"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface MetricInsight {
|
|
92
|
+
/** Optional single line for custom copy; rail prefers `title` + `description` when both are set */
|
|
93
|
+
statement?: string
|
|
94
|
+
/** Card headline */
|
|
95
|
+
title: string
|
|
96
|
+
/** Supporting body copy */
|
|
97
|
+
description?: string
|
|
98
|
+
/** Optional deep-link for the ↗ button */
|
|
99
|
+
href?: string
|
|
100
|
+
/** CTA label — defaults to "Ask Leo" */
|
|
101
|
+
actionLabel?: string
|
|
102
|
+
/** Font Awesome class for the CTA icon — defaults to fa-wand-magic-sparkles */
|
|
103
|
+
actionIcon?: string
|
|
104
|
+
/** Callback for the CTA button */
|
|
105
|
+
onAction?: () => void
|
|
106
|
+
/** Severity determines the badge colour (default: warning) */
|
|
107
|
+
severity?: "warning" | "info" | "error"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface PeriodOption {
|
|
111
|
+
value: string
|
|
112
|
+
label: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface KeyMetricsProps {
|
|
116
|
+
/**
|
|
117
|
+
* "card" — shadcn Card with brand gradient (default)
|
|
118
|
+
* "flat" — full-width gradient band, no card chrome
|
|
119
|
+
*/
|
|
120
|
+
variant?: "card" | "flat" | "compact"
|
|
121
|
+
/** Panel title */
|
|
122
|
+
title?: string
|
|
123
|
+
/** Subtitle / description below title */
|
|
124
|
+
description?: string
|
|
125
|
+
/** Array of KPI items — by default split into rows of 3 */
|
|
126
|
+
metrics: MetricItem[]
|
|
127
|
+
/** When true, all metrics share one horizontal row (md+ and compact mobile grid) */
|
|
128
|
+
metricsSingleRow?: boolean
|
|
129
|
+
/**
|
|
130
|
+
* When true with `metricsSingleRow`, use a 2-column KPI grid so half-width dashboard cards
|
|
131
|
+
* fit 1–4 KPIs without horizontal overflow (pair rows on md+; 2-col grid on small screens).
|
|
132
|
+
* The insight rail (if any) stacks below the KPI grid instead of sitting beside it on md+.
|
|
133
|
+
*/
|
|
134
|
+
metricsHalfWidthLayout?: boolean
|
|
135
|
+
/** Optional insight card — see `insightFullWidth` */
|
|
136
|
+
insight?: MetricInsight
|
|
137
|
+
/**
|
|
138
|
+
* When true, the insight sits on its own full-width row under the metrics (not a narrow side rail).
|
|
139
|
+
*/
|
|
140
|
+
insightFullWidth?: boolean
|
|
141
|
+
/** Comparison-period options for the Select */
|
|
142
|
+
periods?: PeriodOption[]
|
|
143
|
+
/** Initially-selected period value */
|
|
144
|
+
defaultPeriod?: string
|
|
145
|
+
/** Called with the new period value when the Select changes */
|
|
146
|
+
onPeriodChange?: (period: string) => void
|
|
147
|
+
/** When false, hides the title/description/period-selector header row (default: true) */
|
|
148
|
+
showHeader?: boolean
|
|
149
|
+
/**
|
|
150
|
+
* Tighter insight card: one short title + line of body, no vertical filler;
|
|
151
|
+
* aligns visually with a single-row KPI band.
|
|
152
|
+
*/
|
|
153
|
+
insightCompact?: boolean
|
|
154
|
+
className?: string
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Wrap KPI columns when the strip is narrow (high zoom, 5+ tiles) instead of squeezing cells. */
|
|
158
|
+
const METRICS_GRID_TEMPLATE =
|
|
159
|
+
"repeat(auto-fit, minmax(min(100%, 11.5rem), 1fr))"
|
|
160
|
+
|
|
161
|
+
/** Equal columns in one row — up to 4 KPIs beside an insight rail without premature wrap. */
|
|
162
|
+
function metricsRowColumns(
|
|
163
|
+
rowLength: number,
|
|
164
|
+
metricsSingleRow: boolean,
|
|
165
|
+
metricsHalfWidthLayout: boolean,
|
|
166
|
+
): string {
|
|
167
|
+
if (metricsHalfWidthLayout) {
|
|
168
|
+
return `repeat(${rowLength}, minmax(0, 1fr))`
|
|
169
|
+
}
|
|
170
|
+
if (metricsSingleRow) {
|
|
171
|
+
return rowLength > 4 ? METRICS_GRID_TEMPLATE : `repeat(${rowLength}, minmax(0, 1fr))`
|
|
172
|
+
}
|
|
173
|
+
return `repeat(${rowLength}, minmax(0, 1fr))`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* ── Default data ─────────────────────────────────────────────────────────── */
|
|
177
|
+
|
|
178
|
+
const DEFAULT_PERIODS: PeriodOption[] = [
|
|
179
|
+
{ value: "week", label: "vs last week" },
|
|
180
|
+
{ value: "month", label: "vs last month" },
|
|
181
|
+
{ value: "quarter", label: "vs last quarter" },
|
|
182
|
+
{ value: "year", label: "vs last year" },
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
/* ── Sub-components ───────────────────────────────────────────────────────── */
|
|
186
|
+
|
|
187
|
+
/** Single KPI cell inside the metrics grid */
|
|
188
|
+
function MetricCell({
|
|
189
|
+
label,
|
|
190
|
+
value,
|
|
191
|
+
delta,
|
|
192
|
+
trend,
|
|
193
|
+
href,
|
|
194
|
+
onClick,
|
|
195
|
+
metricVariant = "default",
|
|
196
|
+
dense = false,
|
|
197
|
+
edgeGutter = true,
|
|
198
|
+
}: Omit<MetricItem, "id"> & { dense?: boolean; edgeGutter?: boolean }) {
|
|
199
|
+
const isUp = trend === "up"
|
|
200
|
+
const isDown = trend === "down"
|
|
201
|
+
const isInteractive = !!(href || onClick)
|
|
202
|
+
const isHero = metricVariant === "hero"
|
|
203
|
+
|
|
204
|
+
const inner = (
|
|
205
|
+
<>
|
|
206
|
+
{/* Label row — min-height = 2 lines so values align when some titles wrap */}
|
|
207
|
+
<div
|
|
208
|
+
className={cn(
|
|
209
|
+
"grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 gap-y-0.5",
|
|
210
|
+
dense ? "min-h-[2.125rem]" : "min-h-[2.625rem]",
|
|
211
|
+
)}
|
|
212
|
+
>
|
|
213
|
+
<p
|
|
214
|
+
className={cn(
|
|
215
|
+
"min-w-0 text-muted-foreground leading-snug wrap-break-word",
|
|
216
|
+
dense ? "text-xs" : "text-sm",
|
|
217
|
+
isHero && "font-medium",
|
|
218
|
+
)}
|
|
219
|
+
>
|
|
220
|
+
{label}
|
|
221
|
+
</p>
|
|
222
|
+
{isInteractive ? (
|
|
223
|
+
<span className="mt-0.5 inline-flex shrink-0" aria-hidden="true">
|
|
224
|
+
<i className="fa-light fa-arrow-right text-xs text-foreground/70 transition-colors duration-150 group-hover:text-interactive-hover-foreground sm:group-hover:translate-x-0.5" />
|
|
225
|
+
</span>
|
|
226
|
+
) : null}
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{/* Value + trend badge */}
|
|
230
|
+
<div className="flex items-baseline gap-2 flex-wrap">
|
|
231
|
+
<span
|
|
232
|
+
className={cn(
|
|
233
|
+
"font-bold tabular-nums leading-none text-foreground",
|
|
234
|
+
dense
|
|
235
|
+
? isHero
|
|
236
|
+
? "text-lg sm:text-xl"
|
|
237
|
+
: "text-base sm:text-lg"
|
|
238
|
+
: isHero
|
|
239
|
+
? "text-2xl sm:text-[1.625rem]"
|
|
240
|
+
: "text-xl sm:text-2xl",
|
|
241
|
+
)}
|
|
242
|
+
>
|
|
243
|
+
{value}
|
|
244
|
+
</span>
|
|
245
|
+
|
|
246
|
+
{/* Trend chip — icon + text, never colour-only (WCAG 1.4.1) */}
|
|
247
|
+
<span
|
|
248
|
+
className={cn(
|
|
249
|
+
"inline-flex items-center gap-1 font-medium leading-none",
|
|
250
|
+
dense ? "text-xs sm:text-xs" : "text-xs sm:text-sm",
|
|
251
|
+
isUp && "text-chart-2",
|
|
252
|
+
isDown && "text-destructive",
|
|
253
|
+
!isUp && !isDown && "text-muted-foreground"
|
|
254
|
+
)}
|
|
255
|
+
aria-label={`${isUp ? "up" : isDown ? "down" : "no change"} ${delta}`}
|
|
256
|
+
>
|
|
257
|
+
{isUp && <i className="fa-light fa-arrow-trend-up text-[0.8rem]" aria-hidden="true" />}
|
|
258
|
+
{isDown && <i className="fa-light fa-arrow-trend-down text-[0.8rem]" aria-hidden="true" />}
|
|
259
|
+
{!isUp && !isDown && <i className="fa-light fa-minus text-[0.8rem]" aria-hidden="true" />}
|
|
260
|
+
<span>{delta}</span>
|
|
261
|
+
</span>
|
|
262
|
+
</div>
|
|
263
|
+
</>
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
const sharedClass = cn(
|
|
267
|
+
"group flex min-w-0 flex-col gap-2 text-left outline-none",
|
|
268
|
+
edgeGutter && "first:pl-0 last:pr-0",
|
|
269
|
+
dense ? "gap-1.5 px-2 py-2 sm:px-3 sm:py-3" : "gap-2 px-3 py-3 sm:px-5 sm:py-4",
|
|
270
|
+
isHero && "gap-2.5",
|
|
271
|
+
isInteractive && [
|
|
272
|
+
"cursor-pointer transition-colors duration-150",
|
|
273
|
+
"hover:bg-foreground/5",
|
|
274
|
+
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
|
275
|
+
]
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if (href) {
|
|
279
|
+
return (
|
|
280
|
+
<a href={href} className={sharedClass} aria-label={`${label}: ${value}`}>
|
|
281
|
+
{inner}
|
|
282
|
+
</a>
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (onClick) {
|
|
287
|
+
return (
|
|
288
|
+
<button type="button" onClick={onClick} className={sharedClass} aria-label={`${label}: ${value}`}>
|
|
289
|
+
{inner}
|
|
290
|
+
</button>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return <div className={sharedClass}>{inner}</div>
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Body line for rail: `description`, else optional `statement` */
|
|
298
|
+
function insightRailBody(insight: MetricInsight): string {
|
|
299
|
+
const d = insight.description?.trim()
|
|
300
|
+
if (d) return d
|
|
301
|
+
return insight.statement?.trim() ?? ""
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Rail insight: severity badge + title + description + optional ↗, Ask Leo (no rule between copy and action).
|
|
306
|
+
*/
|
|
307
|
+
function InsightRailStatementAction({
|
|
308
|
+
insight,
|
|
309
|
+
compact,
|
|
310
|
+
}: {
|
|
311
|
+
insight: MetricInsight
|
|
312
|
+
compact: boolean
|
|
313
|
+
}) {
|
|
314
|
+
const badgeSize = compact ? "sm" : "default"
|
|
315
|
+
const surface = compact
|
|
316
|
+
? "border border-border/50 bg-gradient-to-b from-muted/35 to-card"
|
|
317
|
+
: "bg-card"
|
|
318
|
+
const body = insightRailBody(insight)
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<Card
|
|
322
|
+
role="region"
|
|
323
|
+
aria-label="Insight"
|
|
324
|
+
className={cn(
|
|
325
|
+
"flex h-full min-h-0 flex-col overflow-hidden rounded-lg border-0 p-0 shadow-none ring-1 ring-foreground/8",
|
|
326
|
+
surface
|
|
327
|
+
)}
|
|
328
|
+
>
|
|
329
|
+
{/* flex-1 + mt-auto on the CTA: copy stays top-aligned when the rail stretches to KPI height */}
|
|
330
|
+
<div className="flex min-h-0 flex-1 flex-col px-3 py-3 sm:px-4 sm:py-4">
|
|
331
|
+
<div className="flex items-start gap-2.5">
|
|
332
|
+
<InsightBadge severity={insight.severity} size={badgeSize} />
|
|
333
|
+
<div className="min-w-0 flex-1">
|
|
334
|
+
<p className="text-sm font-semibold leading-snug text-foreground">{insight.title}</p>
|
|
335
|
+
{body ? (
|
|
336
|
+
<p className="mt-1 text-sm leading-snug text-muted-foreground">{body}</p>
|
|
337
|
+
) : null}
|
|
338
|
+
</div>
|
|
339
|
+
{insight.href && (
|
|
340
|
+
<a
|
|
341
|
+
href={insight.href}
|
|
342
|
+
className="mt-0.5 shrink-0 text-muted-foreground transition-colors hover:text-interactive-hover-foreground focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-ring"
|
|
343
|
+
aria-label={`Open ${insight.title} — details`}
|
|
344
|
+
>
|
|
345
|
+
<i className="fa-light fa-arrow-up-right text-xs" aria-hidden="true" />
|
|
346
|
+
</a>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
<div className="mt-auto flex shrink-0 justify-end pt-3">
|
|
351
|
+
<InsightAskLeoTooltip actionLabel={insight.actionLabel}>
|
|
352
|
+
<Button
|
|
353
|
+
variant={compact ? "outline" : "ghost"}
|
|
354
|
+
size="sm"
|
|
355
|
+
className={cn(
|
|
356
|
+
"h-8 w-full gap-1.5 text-xs sm:w-auto",
|
|
357
|
+
compact
|
|
358
|
+
? "border-border/60 bg-background px-3 text-foreground hover:bg-background"
|
|
359
|
+
: "px-3 text-muted-foreground hover:text-interactive-hover-foreground"
|
|
360
|
+
)}
|
|
361
|
+
onClick={insight.onAction}
|
|
362
|
+
aria-label={insight.actionLabel ?? "Ask Leo"}
|
|
363
|
+
>
|
|
364
|
+
<i
|
|
365
|
+
className={
|
|
366
|
+
insight.actionIcon
|
|
367
|
+
? `fa-light ${insight.actionIcon} text-xs`
|
|
368
|
+
: "fa-duotone fa-solid fa-star-christmas text-xs text-brand"
|
|
369
|
+
}
|
|
370
|
+
aria-hidden="true"
|
|
371
|
+
/>
|
|
372
|
+
{insight.actionLabel ?? "Ask Leo"}
|
|
373
|
+
</Button>
|
|
374
|
+
</InsightAskLeoTooltip>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
</Card>
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Severity icon badge for the insight card */
|
|
382
|
+
|
|
383
|
+
function InsightBadge({
|
|
384
|
+
severity = "warning",
|
|
385
|
+
size = "default",
|
|
386
|
+
}: {
|
|
387
|
+
severity?: MetricInsight["severity"]
|
|
388
|
+
size?: "default" | "sm"
|
|
389
|
+
}) {
|
|
390
|
+
const styles = {
|
|
391
|
+
warning: {
|
|
392
|
+
bg: "bg-[var(--insight-severity-warning-bg)]",
|
|
393
|
+
icon: "fa-circle-exclamation",
|
|
394
|
+
color: "text-[var(--insight-severity-warning-fg)]",
|
|
395
|
+
},
|
|
396
|
+
info: {
|
|
397
|
+
bg: "bg-[var(--insight-severity-info-bg)]",
|
|
398
|
+
icon: "fa-circle-info",
|
|
399
|
+
color: "text-[var(--insight-severity-info-fg)]",
|
|
400
|
+
},
|
|
401
|
+
error: { bg: "bg-destructive/15", icon: "fa-circle-xmark", color: "text-destructive" },
|
|
402
|
+
}[severity]
|
|
403
|
+
|
|
404
|
+
return (
|
|
405
|
+
<span
|
|
406
|
+
className={cn(
|
|
407
|
+
"inline-flex shrink-0 items-center justify-center rounded-full",
|
|
408
|
+
size === "sm" ? "h-6 w-6 text-xs" : "h-7 w-7 text-sm",
|
|
409
|
+
styles.bg,
|
|
410
|
+
styles.color
|
|
411
|
+
)}
|
|
412
|
+
aria-hidden="true"
|
|
413
|
+
>
|
|
414
|
+
<i className={`fa-light ${styles.icon}`} />
|
|
415
|
+
</span>
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/* ── Shared inner content ─────────────────────────────────────────────────── */
|
|
420
|
+
|
|
421
|
+
interface InnerProps {
|
|
422
|
+
title: string
|
|
423
|
+
description: string
|
|
424
|
+
period: string
|
|
425
|
+
periods: PeriodOption[]
|
|
426
|
+
metrics: MetricItem[]
|
|
427
|
+
rows: MetricItem[][]
|
|
428
|
+
insight?: MetricInsight
|
|
429
|
+
onPeriodChange: (v: string) => void
|
|
430
|
+
/** Extra padding class injected by flat variant */
|
|
431
|
+
innerPadding?: string
|
|
432
|
+
/** When false, the header (title/description/period select) is hidden */
|
|
433
|
+
showHeader?: boolean
|
|
434
|
+
insightCompact?: boolean
|
|
435
|
+
insightFullWidth?: boolean
|
|
436
|
+
metricsSingleRow?: boolean
|
|
437
|
+
/** Tighter KPI cells + 2-col mobile grid (half-width dashboard card). */
|
|
438
|
+
metricsHalfWidthLayout?: boolean
|
|
439
|
+
/** Opaque fill behind each KPI cell when using hairline grid gaps (below `lg`). */
|
|
440
|
+
metricsCellSurfaceClassName?: string
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function KeyMetricsInner({
|
|
444
|
+
title,
|
|
445
|
+
description,
|
|
446
|
+
period,
|
|
447
|
+
periods,
|
|
448
|
+
metrics,
|
|
449
|
+
rows,
|
|
450
|
+
insight,
|
|
451
|
+
onPeriodChange,
|
|
452
|
+
innerPadding = "",
|
|
453
|
+
showHeader = true,
|
|
454
|
+
insightCompact = false,
|
|
455
|
+
insightFullWidth = false,
|
|
456
|
+
metricsSingleRow = false,
|
|
457
|
+
metricsHalfWidthLayout = false,
|
|
458
|
+
metricsCellSurfaceClassName = "bg-background",
|
|
459
|
+
}: InnerProps) {
|
|
460
|
+
/** Side-by-side KPI + insight rail (md+). Disabled for half-width dashboard cards — insight stacks below. */
|
|
461
|
+
const insightSideBySide = insight && !insightFullWidth && !metricsHalfWidthLayout
|
|
462
|
+
const stackedRailInsight = insight && !insightFullWidth && metricsHalfWidthLayout
|
|
463
|
+
|
|
464
|
+
return (
|
|
465
|
+
<div data-slot="key-metrics" className="contents">
|
|
466
|
+
{/* ── Header ──────────────────────────────────────────────────── */}
|
|
467
|
+
{showHeader && (
|
|
468
|
+
<div className={cn(
|
|
469
|
+
"flex flex-col gap-2 pb-3",
|
|
470
|
+
"sm:flex-row sm:items-center sm:justify-between sm:gap-4",
|
|
471
|
+
innerPadding
|
|
472
|
+
)}>
|
|
473
|
+
<div>
|
|
474
|
+
<p className="text-base font-semibold text-foreground leading-tight">{title}</p>
|
|
475
|
+
<p className="mt-0.5 text-sm text-muted-foreground">{description}</p>
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
{/* Period selector — align="end" keeps dropdown flush-right */}
|
|
479
|
+
<Select value={period} onValueChange={onPeriodChange}>
|
|
480
|
+
<SelectTrigger
|
|
481
|
+
className="h-8 w-full sm:w-auto sm:min-w-[9rem] shrink-0 text-sm"
|
|
482
|
+
aria-label="Select comparison period"
|
|
483
|
+
>
|
|
484
|
+
<SelectValue />
|
|
485
|
+
</SelectTrigger>
|
|
486
|
+
<SelectContent align="end" sideOffset={4}>
|
|
487
|
+
{periods.map((p) => (
|
|
488
|
+
<SelectItem key={p.value} value={p.value}>
|
|
489
|
+
{p.label}
|
|
490
|
+
</SelectItem>
|
|
491
|
+
))}
|
|
492
|
+
</SelectContent>
|
|
493
|
+
</Select>
|
|
494
|
+
</div>
|
|
495
|
+
)}
|
|
496
|
+
|
|
497
|
+
{/* ── Body: metrics grid + optional insight ───────────────────── */}
|
|
498
|
+
<div
|
|
499
|
+
className={cn(
|
|
500
|
+
"flex flex-col gap-0",
|
|
501
|
+
/* 60% KPIs / 40% insight (3fr:2fr); lg+ only so phones/tablets stack KPIs + insight */
|
|
502
|
+
insightSideBySide &&
|
|
503
|
+
"lg:grid lg:grid-cols-[minmax(0,3fr)_minmax(13rem,2fr)] lg:items-stretch lg:gap-x-6 lg:gap-y-0",
|
|
504
|
+
innerPadding
|
|
505
|
+
)}
|
|
506
|
+
>
|
|
507
|
+
|
|
508
|
+
{/* Metrics section — self-start so KPI cells don’t stretch when the insight column is taller */}
|
|
509
|
+
<div
|
|
510
|
+
className={cn(
|
|
511
|
+
"min-w-0 lg:flex lg:min-h-0 lg:flex-col",
|
|
512
|
+
!insightSideBySide && "w-full",
|
|
513
|
+
insightSideBySide && "lg:self-start"
|
|
514
|
+
)}
|
|
515
|
+
>
|
|
516
|
+
{/*
|
|
517
|
+
Phone (<md): one column. Tablet (md–lg): 2-column grid (e.g. 2×2 for four KPIs).
|
|
518
|
+
Hairline separators use gap-px + opaque cell surfaces (divide-* breaks for 2-col order).
|
|
519
|
+
Half-width dashboard cards keep divide-x + optional template columns.
|
|
520
|
+
*/}
|
|
521
|
+
{metricsHalfWidthLayout ? (
|
|
522
|
+
<div
|
|
523
|
+
className="grid grid-cols-2 divide-x divide-border lg:hidden"
|
|
524
|
+
style={
|
|
525
|
+
metricsSingleRow
|
|
526
|
+
? {
|
|
527
|
+
gridTemplateColumns: metricsRowColumns(
|
|
528
|
+
metrics.length,
|
|
529
|
+
metricsSingleRow,
|
|
530
|
+
metricsHalfWidthLayout,
|
|
531
|
+
),
|
|
532
|
+
}
|
|
533
|
+
: undefined
|
|
534
|
+
}
|
|
535
|
+
>
|
|
536
|
+
{metrics.map((m) => (
|
|
537
|
+
<MetricCell key={m.id} {...m} dense />
|
|
538
|
+
))}
|
|
539
|
+
</div>
|
|
540
|
+
) : (
|
|
541
|
+
<div
|
|
542
|
+
className={cn(
|
|
543
|
+
"grid gap-px bg-border lg:hidden",
|
|
544
|
+
"grid-cols-1 md:grid-cols-2",
|
|
545
|
+
)}
|
|
546
|
+
>
|
|
547
|
+
{metrics.map((m) => (
|
|
548
|
+
<div key={m.id} className={cn("min-w-0", metricsCellSurfaceClassName)}>
|
|
549
|
+
<MetricCell {...m} dense={false} edgeGutter={false} />
|
|
550
|
+
</div>
|
|
551
|
+
))}
|
|
552
|
+
</div>
|
|
553
|
+
)}
|
|
554
|
+
|
|
555
|
+
{/* lg+: row-by-row 3-col with horizontal separator between rows */}
|
|
556
|
+
<div className="hidden lg:block">
|
|
557
|
+
{rows.map((row, rowIdx) => (
|
|
558
|
+
<React.Fragment key={rowIdx}>
|
|
559
|
+
{rowIdx > 0 && (
|
|
560
|
+
<Separator aria-hidden="true" className="my-1" />
|
|
561
|
+
)}
|
|
562
|
+
<div
|
|
563
|
+
className="grid divide-x divide-border"
|
|
564
|
+
style={{
|
|
565
|
+
gridTemplateColumns: metricsRowColumns(
|
|
566
|
+
row.length,
|
|
567
|
+
metricsSingleRow,
|
|
568
|
+
metricsHalfWidthLayout,
|
|
569
|
+
),
|
|
570
|
+
}}
|
|
571
|
+
>
|
|
572
|
+
{row.map((m) => (
|
|
573
|
+
<MetricCell key={m.id} {...m} dense={metricsHalfWidthLayout} />
|
|
574
|
+
))}
|
|
575
|
+
</div>
|
|
576
|
+
</React.Fragment>
|
|
577
|
+
))}
|
|
578
|
+
</div>
|
|
579
|
+
</div>
|
|
580
|
+
|
|
581
|
+
{/* Insight card — only rendered when data provided */}
|
|
582
|
+
{insight && (
|
|
583
|
+
<>
|
|
584
|
+
{insightFullWidth ? (
|
|
585
|
+
<Separator aria-hidden="true" className="my-4 w-full" />
|
|
586
|
+
) : stackedRailInsight ? (
|
|
587
|
+
<Separator aria-hidden="true" className="my-4 w-full" />
|
|
588
|
+
) : (
|
|
589
|
+
<Separator aria-hidden="true" className="my-3 lg:hidden" />
|
|
590
|
+
)}
|
|
591
|
+
|
|
592
|
+
<div
|
|
593
|
+
className={cn(
|
|
594
|
+
"flex min-h-0 min-w-0 w-full flex-col",
|
|
595
|
+
/* Divider + padding replace vertical Separator so grid stays 2 columns */
|
|
596
|
+
insightSideBySide &&
|
|
597
|
+
!insightFullWidth &&
|
|
598
|
+
"lg:h-full lg:border-l lg:border-border lg:pl-6"
|
|
599
|
+
)}
|
|
600
|
+
>
|
|
601
|
+
{insight && !insightFullWidth ? (
|
|
602
|
+
<InsightRailStatementAction insight={insight} compact={insightCompact} />
|
|
603
|
+
) : (
|
|
604
|
+
<Card
|
|
605
|
+
role="region"
|
|
606
|
+
aria-label="Insight"
|
|
607
|
+
className={cn(
|
|
608
|
+
"overflow-hidden rounded-lg p-0 ring-1 ring-foreground/8 shadow-none",
|
|
609
|
+
"flex min-h-0 flex-col bg-muted/25"
|
|
610
|
+
)}
|
|
611
|
+
>
|
|
612
|
+
{insightCompact ? (
|
|
613
|
+
<div className="flex min-h-0 flex-1 flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between md:gap-8 md:p-5">
|
|
614
|
+
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
615
|
+
<div className="flex items-start gap-2.5">
|
|
616
|
+
<InsightBadge severity={insight.severity} size="sm" />
|
|
617
|
+
<div className="flex min-w-0 flex-1 items-start justify-between gap-2">
|
|
618
|
+
<p className="text-base font-semibold leading-tight text-foreground">
|
|
619
|
+
{insight.title}
|
|
620
|
+
</p>
|
|
621
|
+
{insight.href && (
|
|
622
|
+
<a
|
|
623
|
+
href={insight.href}
|
|
624
|
+
className="shrink-0 text-muted-foreground transition-colors hover:text-interactive-hover-foreground focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-ring"
|
|
625
|
+
aria-label={`Open ${insight.title} — details`}
|
|
626
|
+
>
|
|
627
|
+
<i className="fa-light fa-arrow-up-right text-xs" aria-hidden="true" />
|
|
628
|
+
</a>
|
|
629
|
+
)}
|
|
630
|
+
</div>
|
|
631
|
+
</div>
|
|
632
|
+
{insight.description ? (
|
|
633
|
+
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
634
|
+
{insight.description}
|
|
635
|
+
</p>
|
|
636
|
+
) : null}
|
|
637
|
+
</div>
|
|
638
|
+
<div className="flex w-full shrink-0 md:w-auto">
|
|
639
|
+
<InsightAskLeoTooltip actionLabel={insight.actionLabel}>
|
|
640
|
+
<Button
|
|
641
|
+
variant="ghost"
|
|
642
|
+
size="sm"
|
|
643
|
+
className="h-9 w-full gap-1.5 px-4 text-xs text-muted-foreground hover:text-interactive-hover-foreground md:min-w-[8.5rem]"
|
|
644
|
+
onClick={insight.onAction}
|
|
645
|
+
aria-label={insight.actionLabel ?? "Ask Leo"}
|
|
646
|
+
>
|
|
647
|
+
<i
|
|
648
|
+
className={insight.actionIcon ? `fa-light ${insight.actionIcon} text-xs` : "fa-duotone fa-solid fa-star-christmas text-xs text-brand"}
|
|
649
|
+
aria-hidden="true"
|
|
650
|
+
/>
|
|
651
|
+
{insight.actionLabel ?? "Ask Leo"}
|
|
652
|
+
</Button>
|
|
653
|
+
</InsightAskLeoTooltip>
|
|
654
|
+
</div>
|
|
655
|
+
</div>
|
|
656
|
+
) : (
|
|
657
|
+
<div className="flex min-h-0 flex-1 flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between md:gap-8 md:p-5">
|
|
658
|
+
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
|
659
|
+
<div className="flex items-start gap-3">
|
|
660
|
+
<InsightBadge severity={insight.severity} />
|
|
661
|
+
<div className="flex min-w-0 flex-1 items-start justify-between gap-2">
|
|
662
|
+
<p className="text-base font-semibold leading-snug text-foreground">
|
|
663
|
+
{insight.title}
|
|
664
|
+
</p>
|
|
665
|
+
{insight.href && (
|
|
666
|
+
<a
|
|
667
|
+
href={insight.href}
|
|
668
|
+
className="shrink-0 text-muted-foreground transition-colors hover:text-interactive-hover-foreground focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-ring"
|
|
669
|
+
aria-label={`Open ${insight.title} — details`}
|
|
670
|
+
>
|
|
671
|
+
<i className="fa-light fa-arrow-up-right text-xs" aria-hidden="true" />
|
|
672
|
+
</a>
|
|
673
|
+
)}
|
|
674
|
+
</div>
|
|
675
|
+
</div>
|
|
676
|
+
{insight.description ? (
|
|
677
|
+
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
678
|
+
{insight.description}
|
|
679
|
+
</p>
|
|
680
|
+
) : null}
|
|
681
|
+
</div>
|
|
682
|
+
<div className="flex w-full shrink-0 md:w-auto">
|
|
683
|
+
<InsightAskLeoTooltip actionLabel={insight.actionLabel}>
|
|
684
|
+
<Button
|
|
685
|
+
variant="ghost"
|
|
686
|
+
size="sm"
|
|
687
|
+
className="h-9 w-full gap-1.5 px-4 text-xs text-muted-foreground hover:text-interactive-hover-foreground md:min-w-[8.5rem]"
|
|
688
|
+
onClick={insight.onAction}
|
|
689
|
+
aria-label={insight.actionLabel ?? "Ask Leo"}
|
|
690
|
+
>
|
|
691
|
+
<i
|
|
692
|
+
className={insight.actionIcon ? `fa-light ${insight.actionIcon} text-xs` : "fa-duotone fa-solid fa-star-christmas text-xs text-brand"}
|
|
693
|
+
aria-hidden="true"
|
|
694
|
+
/>
|
|
695
|
+
{insight.actionLabel ?? "Ask Leo"}
|
|
696
|
+
</Button>
|
|
697
|
+
</InsightAskLeoTooltip>
|
|
698
|
+
</div>
|
|
699
|
+
</div>
|
|
700
|
+
)}
|
|
701
|
+
</Card>
|
|
702
|
+
)}
|
|
703
|
+
</div>
|
|
704
|
+
</>
|
|
705
|
+
)}
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function chunkMetricPairs(metrics: MetricItem[]): MetricItem[][] {
|
|
712
|
+
const out: MetricItem[][] = []
|
|
713
|
+
for (let i = 0; i < metrics.length; i += 2) out.push(metrics.slice(i, i + 2))
|
|
714
|
+
return out
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/* ── Main component ───────────────────────────────────────────────────────── */
|
|
718
|
+
|
|
719
|
+
export function KeyMetrics({
|
|
720
|
+
variant = "card",
|
|
721
|
+
title = "Key Metrics",
|
|
722
|
+
description = "Overview of performance indicators",
|
|
723
|
+
metrics = [],
|
|
724
|
+
insight,
|
|
725
|
+
periods = DEFAULT_PERIODS,
|
|
726
|
+
defaultPeriod = "week",
|
|
727
|
+
onPeriodChange,
|
|
728
|
+
showHeader = true,
|
|
729
|
+
insightCompact = false,
|
|
730
|
+
insightFullWidth = false,
|
|
731
|
+
metricsSingleRow = false,
|
|
732
|
+
metricsHalfWidthLayout = false,
|
|
733
|
+
className,
|
|
734
|
+
}: KeyMetricsProps) {
|
|
735
|
+
const [period, setPeriod] = React.useState(defaultPeriod)
|
|
736
|
+
const { toggle: toggleAskLeo } = useAskLeo()
|
|
737
|
+
|
|
738
|
+
function handlePeriodChange(v: string) {
|
|
739
|
+
setPeriod(v)
|
|
740
|
+
onPeriodChange?.(v)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/* Split metrics into rows of 3, or paired rows when half-width + single row, else one row */
|
|
744
|
+
const rows: MetricItem[][] = metricsSingleRow
|
|
745
|
+
? metrics.length
|
|
746
|
+
? metricsHalfWidthLayout
|
|
747
|
+
? chunkMetricPairs(metrics)
|
|
748
|
+
: [metrics]
|
|
749
|
+
: []
|
|
750
|
+
: (() => {
|
|
751
|
+
const out: MetricItem[][] = []
|
|
752
|
+
for (let i = 0; i < metrics.length; i += 3) {
|
|
753
|
+
out.push(metrics.slice(i, i + 3))
|
|
754
|
+
}
|
|
755
|
+
return out
|
|
756
|
+
})()
|
|
757
|
+
|
|
758
|
+
const metricsCellSurfaceClassName = variant === "flat" ? "bg-background" : "bg-card"
|
|
759
|
+
|
|
760
|
+
const innerProps: InnerProps = {
|
|
761
|
+
title,
|
|
762
|
+
description,
|
|
763
|
+
period,
|
|
764
|
+
periods,
|
|
765
|
+
metrics,
|
|
766
|
+
rows,
|
|
767
|
+
insight,
|
|
768
|
+
onPeriodChange: handlePeriodChange,
|
|
769
|
+
insightCompact,
|
|
770
|
+
insightFullWidth,
|
|
771
|
+
metricsSingleRow,
|
|
772
|
+
metricsHalfWidthLayout,
|
|
773
|
+
metricsCellSurfaceClassName,
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/*
|
|
777
|
+
* ── GLOW GUIDELINE ────────────────────────────────────────────────────────
|
|
778
|
+
* The bottom-glow treatment is a deliberate design signal. Use it only for:
|
|
779
|
+
*
|
|
780
|
+
* 1. AI / intelligence surfaces — e.g. AI Insights, Ask Leo responses,
|
|
781
|
+
* any card that surfaces machine-generated content.
|
|
782
|
+
* Opacity: 0.12–0.16 (subtle; the glow should not dominate)
|
|
783
|
+
*
|
|
784
|
+
* 2. Designer-designated hero sections — e.g. Key Metrics (the primary
|
|
785
|
+
* KPI band), onboarding completion, or any section the product team
|
|
786
|
+
* explicitly wants to "elevate" visually.
|
|
787
|
+
* Opacity: 0.18–0.24 (more pronounced; intentional focal point)
|
|
788
|
+
*
|
|
789
|
+
* Do NOT add glow to:
|
|
790
|
+
* • Standard data/content cards (Tasks, Activity, Learn, Charts…)
|
|
791
|
+
* • Navigation or shell elements
|
|
792
|
+
* • Cards that already use a coloured border or badge for status
|
|
793
|
+
*
|
|
794
|
+
* Implementation:
|
|
795
|
+
* style={{ background: "radial-gradient(ellipse 110% 90% at 50% 100%,
|
|
796
|
+
* oklch(from var(--brand-color) l c h / <opacity>) 0%, transparent 68%)" }}
|
|
797
|
+
* + className="overflow-hidden" ← required to clip the gradient
|
|
798
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
799
|
+
*/
|
|
800
|
+
const glowStyle: React.CSSProperties = {
|
|
801
|
+
/* oklch relative color: inherit brand hue/chroma/lightness, set alpha only */
|
|
802
|
+
background:
|
|
803
|
+
"radial-gradient(ellipse 110% 90% at 50% 100%, oklch(from var(--brand-color) l c h / 0.13) 0%, transparent 65%)",
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/* ── Card variant — ChartCard-style chrome ───────────────────────────── */
|
|
807
|
+
if (variant === "card") {
|
|
808
|
+
return (
|
|
809
|
+
<Card className={cn("shadow-xs overflow-hidden flex flex-col", className)} style={glowStyle}>
|
|
810
|
+
<CardHeader className={cn("shrink-0 pb-2", metricsHalfWidthLayout && "space-y-2")}>
|
|
811
|
+
<div
|
|
812
|
+
className={cn(
|
|
813
|
+
"flex gap-2",
|
|
814
|
+
metricsHalfWidthLayout
|
|
815
|
+
? "flex-col min-[400px]:flex-row min-[400px]:items-start min-[400px]:justify-between"
|
|
816
|
+
: "items-start",
|
|
817
|
+
)}
|
|
818
|
+
>
|
|
819
|
+
<div className="flex-1 min-w-0">
|
|
820
|
+
<CardTitle className="text-sm font-semibold leading-tight">{title}</CardTitle>
|
|
821
|
+
<CardDescription className="text-xs mt-0.5">{description}</CardDescription>
|
|
822
|
+
</div>
|
|
823
|
+
<div className="flex flex-wrap items-center gap-1.5 shrink-0">
|
|
824
|
+
<InsightAskLeoTooltip actionLabel="Ask Leo">
|
|
825
|
+
<Button
|
|
826
|
+
size="sm"
|
|
827
|
+
variant="outline"
|
|
828
|
+
className="h-7 shrink-0 text-xs gap-1.5 px-2"
|
|
829
|
+
aria-label="Ask Leo about these metrics"
|
|
830
|
+
onClick={toggleAskLeo}
|
|
831
|
+
type="button"
|
|
832
|
+
>
|
|
833
|
+
<i className="fa-duotone fa-solid fa-star-christmas text-xs text-brand" aria-hidden="true" />
|
|
834
|
+
<span>Ask Leo</span>
|
|
835
|
+
</Button>
|
|
836
|
+
</InsightAskLeoTooltip>
|
|
837
|
+
<Select value={period} onValueChange={handlePeriodChange}>
|
|
838
|
+
<SelectTrigger
|
|
839
|
+
size="sm"
|
|
840
|
+
className="w-auto min-w-[9rem] shrink-0 text-sm"
|
|
841
|
+
aria-label="Select comparison period"
|
|
842
|
+
>
|
|
843
|
+
<SelectValue />
|
|
844
|
+
</SelectTrigger>
|
|
845
|
+
<SelectContent align="end" sideOffset={4}>
|
|
846
|
+
{periods.map((p) => (
|
|
847
|
+
<SelectItem key={p.value} value={p.value}>
|
|
848
|
+
{p.label}
|
|
849
|
+
</SelectItem>
|
|
850
|
+
))}
|
|
851
|
+
</SelectContent>
|
|
852
|
+
</Select>
|
|
853
|
+
</div>
|
|
854
|
+
</div>
|
|
855
|
+
</CardHeader>
|
|
856
|
+
<CardContent className="flex-1 pb-4">
|
|
857
|
+
<KeyMetricsInner {...innerProps} showHeader={false} />
|
|
858
|
+
</CardContent>
|
|
859
|
+
</Card>
|
|
860
|
+
)
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/* ── Compact variant — card chrome, no header, metrics only ──────────── */
|
|
864
|
+
if (variant === "compact") {
|
|
865
|
+
return (
|
|
866
|
+
<Card className={cn("shadow-xs overflow-hidden", className)} style={glowStyle}>
|
|
867
|
+
<CardContent className="py-3 px-4">
|
|
868
|
+
<KeyMetricsInner {...innerProps} showHeader={false} />
|
|
869
|
+
</CardContent>
|
|
870
|
+
</Card>
|
|
871
|
+
)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/* ── Flat variant — full-width bottom-glow band ───────────────────────── */
|
|
875
|
+
return (
|
|
876
|
+
<section
|
|
877
|
+
aria-label={title}
|
|
878
|
+
className={cn("w-full py-5", className)}
|
|
879
|
+
style={glowStyle}
|
|
880
|
+
>
|
|
881
|
+
<KeyMetricsInner
|
|
882
|
+
{...innerProps}
|
|
883
|
+
innerPadding="px-4 lg:px-6"
|
|
884
|
+
showHeader={showHeader}
|
|
885
|
+
/>
|
|
886
|
+
</section>
|
|
887
|
+
)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* KeyMetricsContent — renders just the metrics grid + optional insight panel.
|
|
892
|
+
* No card wrapper, no header, no period selector.
|
|
893
|
+
* Designed for embedding inside a ChartCard with tabOptions period tabs.
|
|
894
|
+
*/
|
|
895
|
+
export function KeyMetricsContent({
|
|
896
|
+
metrics = [],
|
|
897
|
+
insight,
|
|
898
|
+
insightCompact = false,
|
|
899
|
+
insightFullWidth = false,
|
|
900
|
+
}: Pick<KeyMetricsProps, "metrics" | "insight" | "insightCompact" | "insightFullWidth">) {
|
|
901
|
+
const rows: MetricItem[][] = []
|
|
902
|
+
for (let i = 0; i < metrics.length; i += 3) rows.push(metrics.slice(i, i + 3))
|
|
903
|
+
|
|
904
|
+
return (
|
|
905
|
+
<KeyMetricsInner
|
|
906
|
+
title=""
|
|
907
|
+
description=""
|
|
908
|
+
period=""
|
|
909
|
+
periods={[]}
|
|
910
|
+
metrics={metrics}
|
|
911
|
+
rows={rows}
|
|
912
|
+
insight={insight}
|
|
913
|
+
onPeriodChange={() => {}}
|
|
914
|
+
showHeader={false}
|
|
915
|
+
insightCompact={insightCompact}
|
|
916
|
+
insightFullWidth={insightFullWidth}
|
|
917
|
+
metricsCellSurfaceClassName="bg-card"
|
|
918
|
+
/>
|
|
919
|
+
)
|
|
920
|
+
}
|