@exxatdesignux/ui 0.2.8 → 0.2.10

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.
Files changed (125) hide show
  1. package/consumer-extras/cursor-skills/exxat-card-vs-list-rows/SKILL.md +20 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +33 -0
  3. package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +45 -0
  4. package/consumer-extras/cursor-skills/exxat-drawer-vs-dialog/SKILL.md +20 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +31 -5
  6. package/consumer-extras/cursor-skills/exxat-kpi-max-four/SKILL.md +19 -0
  7. package/consumer-extras/cursor-skills/exxat-kpi-trends/SKILL.md +27 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +1 -0
  9. package/consumer-extras/patterns/collaboration-access-pattern.md +114 -0
  10. package/consumer-extras/patterns/data-views-pattern.md +12 -4
  11. package/package.json +17 -4
  12. package/src/components/ui/banner.tsx +20 -7
  13. package/src/components/ui/date-picker-field.tsx +3 -3
  14. package/src/components/ui/dropdown-menu.tsx +17 -6
  15. package/src/components/ui/input-group.tsx +1 -1
  16. package/src/components/ui/input.tsx +1 -1
  17. package/src/components/ui/select.tsx +1 -1
  18. package/src/components/ui/separator.tsx +2 -2
  19. package/src/components/ui/sidebar.tsx +31 -3
  20. package/src/components/ui/textarea.tsx +1 -1
  21. package/src/globals.css +0 -1
  22. package/src/index.ts +1 -0
  23. package/src/lib/date-filter.ts +13 -4
  24. package/src/lib/dropdown-menu-surface.ts +13 -0
  25. package/template/.claude/skills/exxat-ds-skill/SKILL.md +27 -9
  26. package/template/.cursor/rules/exxat-data-tables.mdc +1 -0
  27. package/template/AGENTS.md +82 -27
  28. package/template/app/(app)/examples/page.tsx +2 -1
  29. package/template/app/(app)/help/page.tsx +6 -0
  30. package/template/app/(app)/layout.tsx +7 -4
  31. package/template/app/(app)/question-bank/find/page.tsx +12 -0
  32. package/template/app/(app)/question-bank/layout.tsx +46 -0
  33. package/template/app/(app)/question-bank/library/page.tsx +11 -0
  34. package/template/app/(app)/question-bank/list/page.tsx +12 -0
  35. package/template/app/(app)/question-bank/page.tsx +4 -3
  36. package/template/app/globals.css +1 -2
  37. package/template/components/app-sidebar.tsx +51 -13
  38. package/template/components/ask-leo-composer.tsx +173 -45
  39. package/template/components/ask-leo-sidebar.tsx +9 -1
  40. package/template/components/chart-area-interactive.tsx +3 -13
  41. package/template/components/charts-overview.tsx +33 -6
  42. package/template/components/collaboration-access-flow.tsx +144 -0
  43. package/template/components/compliance-page-header.tsx +1 -1
  44. package/template/components/compliance-table.tsx +2 -2
  45. package/template/components/dashboard-tabs.tsx +4 -3
  46. package/template/components/data-list-table-cells.tsx +1 -1
  47. package/template/components/data-list-table.tsx +1 -1
  48. package/template/components/data-table/index.tsx +5 -5
  49. package/template/components/data-table/use-table-state.ts +18 -2
  50. package/template/components/data-view-dashboard-charts-compliance.tsx +8 -5
  51. package/template/components/data-view-dashboard-charts-team.tsx +8 -5
  52. package/template/components/data-view-dashboard-charts.tsx +62 -227
  53. package/template/components/dedicated-search-recents.tsx +96 -0
  54. package/template/components/dedicated-search-url-composer.tsx +112 -0
  55. package/template/components/getting-started.tsx +1 -1
  56. package/template/components/hub-tree-panel-view.tsx +10 -26
  57. package/template/components/invite-collaborators-drawer.tsx +453 -0
  58. package/template/components/key-metrics.tsx +54 -8
  59. package/template/components/nav-documents.tsx +1 -1
  60. package/template/components/new-placement-form.tsx +3 -3
  61. package/template/components/page-header.tsx +76 -59
  62. package/template/components/placements-board-view.tsx +3 -3
  63. package/template/components/placements-page-header.tsx +1 -1
  64. package/template/components/placements-table-columns.tsx +3 -2
  65. package/template/components/product-switcher.tsx +0 -1
  66. package/template/components/question-bank-board-view.tsx +35 -47
  67. package/template/components/question-bank-client.tsx +293 -81
  68. package/template/components/question-bank-dashboard-charts.tsx +174 -0
  69. package/template/components/question-bank-favorite-button.tsx +46 -0
  70. package/template/components/question-bank-hub-client.tsx +436 -0
  71. package/template/components/question-bank-list-view.tsx +26 -19
  72. package/template/components/question-bank-new-folder-sheet.tsx +56 -42
  73. package/template/components/question-bank-os-folder-view.tsx +3 -14
  74. package/template/components/question-bank-page-header.tsx +85 -53
  75. package/template/components/question-bank-panel-activator.tsx +3 -4
  76. package/template/components/question-bank-secondary-nav.tsx +523 -65
  77. package/template/components/question-bank-table.tsx +125 -343
  78. package/template/components/secondary-panel.tsx +130 -63
  79. package/template/components/settings-client.tsx +3 -1
  80. package/template/components/sidebar-shell.tsx +2 -0
  81. package/template/components/sites-all-client.tsx +1 -1
  82. package/template/components/sites-table.tsx +1 -1
  83. package/template/components/system-banner-slot.tsx +2 -1
  84. package/template/components/table-properties/drawer.tsx +3 -3
  85. package/template/components/table-properties/sort-card.tsx +1 -1
  86. package/template/components/team-page-header.tsx +1 -1
  87. package/template/components/team-table.tsx +8 -4
  88. package/template/components/templates/dedicated-search-landing-template.tsx +58 -0
  89. package/template/components/templates/dedicated-search-results-template.tsx +19 -0
  90. package/template/components/templates/discovery-hub-template.tsx +273 -0
  91. package/template/components/templates/list-page.tsx +11 -4
  92. package/template/components/templates/nested-secondary-panel-shell.tsx +57 -0
  93. package/template/components/templates/secondary-panel-hub-template.tsx +54 -0
  94. package/template/docs/card-vs-rows-pattern.md +36 -0
  95. package/template/docs/collaboration-access-pattern.md +114 -0
  96. package/template/docs/data-views-pattern.md +12 -4
  97. package/template/docs/drawer-vs-dialog-pattern.md +50 -0
  98. package/template/docs/kpi-strip-max-four-pattern.md +29 -0
  99. package/template/docs/kpi-trend-pattern.md +43 -0
  100. package/template/fontawesome-subset.manifest.json +2 -2
  101. package/template/hooks/use-location-hash.ts +14 -8
  102. package/template/hooks/use-secondary-panel-hub-nav.ts +98 -0
  103. package/template/lib/ask-leo-route-context.ts +24 -0
  104. package/template/lib/collaborator-access.ts +92 -0
  105. package/template/lib/command-menu-config.ts +8 -1
  106. package/template/lib/command-menu-search-data.ts +11 -8
  107. package/template/lib/data-list-display-options.ts +1 -1
  108. package/template/lib/data-view-dashboard-placements-layout.ts +215 -0
  109. package/template/lib/date-filter.ts +1 -0
  110. package/template/lib/dedicated-search-recents.ts +76 -0
  111. package/template/lib/dedicated-search-url.ts +23 -0
  112. package/template/lib/discovery-hub.ts +15 -0
  113. package/template/lib/list-status-badges.ts +1 -21
  114. package/template/lib/mock/navigation.tsx +4 -2
  115. package/template/lib/mock/placements.ts +9 -9
  116. package/template/lib/mock/question-bank-folders.ts +7 -0
  117. package/template/lib/mock/question-bank-header-collaborators.ts +45 -5
  118. package/template/lib/mock/question-bank-inspector.ts +1 -2
  119. package/template/lib/mock/question-bank-kpi.ts +38 -26
  120. package/template/lib/mock/question-bank.ts +43 -16
  121. package/template/lib/question-bank-dedicated-search.ts +19 -0
  122. package/template/lib/question-bank-hub-search.ts +90 -0
  123. package/template/lib/question-bank-nav.ts +322 -6
  124. package/template/lib/question-bank-recent-searches.ts +22 -0
  125. package/template/package.json +0 -1
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import * as React from "react"
9
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react"
9
10
 
10
11
  import { Button } from "@/components/ui/button"
11
12
  import {
@@ -29,20 +30,80 @@ export interface AskLeoComposerProps {
29
30
  /** Called with trimmed message after send (composer clears afterward). */
30
31
  onSubmit?: (message: string) => void
31
32
  placeholder?: string
32
- className?: string
33
+ /**
34
+ * When non-empty and the field is empty (single-line / collapsed), cycles these as an overlaid hint
35
+ * with a soft crossfade. Native `placeholder` is suppressed while the overlay shows.
36
+ */
37
+ animatedPlaceholders?: string[]
38
+ /** Milliseconds between animated placeholder phrases. Default 4200. */
39
+ animatedPlaceholderIntervalMs?: number
40
+ /**
41
+ * When `2`, animated hints can wrap to two lines instead of a single truncated line (e.g. example hub queries).
42
+ * Default `1` matches the original pill composer behavior.
43
+ */
44
+ animatedPlaceholderMaxLines?: 1 | 2
45
+ /**
46
+ * `attachments` — plus menu + file picker (default). `ai-mark` — Leo-style icon only (e.g. question bank hub).
47
+ */
48
+ leadingSlot?: "attachments" | "ai-mark"
49
+ /** Accessible name for the textarea (paired with `htmlFor`). */
50
+ inputLabel?: string
51
+ /** `aria-label` on the submit control when the field has text. */
52
+ submitButtonAriaLabel?: string
53
+ /**
54
+ * `send` — paper plane (chat / Ask Leo). `search` — magnifying glass (question bank hub + dedicated search).
55
+ */
56
+ submitAppearance?: "send" | "search"
33
57
  /** Lets the parent swap pill vs card chrome when the field grows (multiline / long text). */
34
58
  onExpandedChange?: (expanded: boolean) => void
59
+ className?: string
35
60
  }
36
61
 
37
62
  export const AskLeoComposer = React.forwardRef<HTMLTextAreaElement, AskLeoComposerProps>(
38
63
  function AskLeoComposer(
39
- { value, onChange, onSubmit, placeholder = "Ask Leo anything…", className, onExpandedChange },
64
+ {
65
+ value,
66
+ onChange,
67
+ onSubmit,
68
+ placeholder = "Ask Leo anything…",
69
+ className,
70
+ onExpandedChange,
71
+ animatedPlaceholders,
72
+ animatedPlaceholderIntervalMs = 4200,
73
+ animatedPlaceholderMaxLines = 1,
74
+ leadingSlot = "attachments",
75
+ inputLabel = "Message to Leo",
76
+ submitButtonAriaLabel = "Send message",
77
+ submitAppearance = "send",
78
+ },
40
79
  forwardedRef,
41
80
  ) {
42
81
  const [isExpanded, setIsExpanded] = React.useState(false)
82
+ const reduceMotion = useReducedMotion()
43
83
  const fieldId = React.useId()
84
+ const phrases = React.useMemo(
85
+ () => (animatedPlaceholders ?? []).map(s => s.trim()).filter(Boolean),
86
+ [animatedPlaceholders],
87
+ )
88
+ const [phraseIndex, setPhraseIndex] = React.useState(0)
89
+ const showAnimatedPlaceholder = phrases.length > 0 && !value.trim() && !isExpanded
90
+
91
+ React.useEffect(() => {
92
+ if (!showAnimatedPlaceholder) return
93
+ const id = window.setInterval(() => {
94
+ setPhraseIndex(i => (i + 1) % phrases.length)
95
+ }, animatedPlaceholderIntervalMs)
96
+ return () => window.clearInterval(id)
97
+ }, [showAnimatedPlaceholder, phrases.length, animatedPlaceholderIntervalMs])
98
+
99
+ React.useEffect(() => {
100
+ if (!showAnimatedPlaceholder) setPhraseIndex(0)
101
+ }, [showAnimatedPlaceholder])
44
102
 
103
+ const reportedExpandedRef = React.useRef<boolean | undefined>(undefined)
45
104
  React.useEffect(() => {
105
+ if (reportedExpandedRef.current === isExpanded) return
106
+ reportedExpandedRef.current = isExpanded
46
107
  onExpandedChange?.(isExpanded)
47
108
  }, [isExpanded, onExpandedChange])
48
109
  const innerRef = React.useRef<HTMLTextAreaElement>(null)
@@ -92,13 +153,15 @@ export const AskLeoComposer = React.forwardRef<HTMLTextAreaElement, AskLeoCompos
92
153
  <div className={cn("min-w-0 w-full", className)}>
93
154
  <form onSubmit={handleSubmit} className="group/composer min-w-0 w-full" noValidate>
94
155
  <label htmlFor={fieldId} className="sr-only">
95
- Message to Leo
156
+ {inputLabel}
96
157
  </label>
97
- <input ref={fileInputRef} type="file" multiple className="sr-only" onChange={() => {}} />
158
+ {leadingSlot === "attachments" ? (
159
+ <input ref={fileInputRef} type="file" multiple className="sr-only" onChange={() => {}} />
160
+ ) : null}
98
161
 
99
162
  <div
100
163
  className={cn(
101
- "min-w-0 w-full cursor-text overflow-hidden border border-border/80 bg-card transition-[border-radius,padding] duration-200 ease-out",
164
+ "min-w-0 w-full cursor-text overflow-hidden border border-[color:var(--control-border)] bg-card transition-[border-radius,padding] duration-200 ease-out",
102
165
  isExpanded
103
166
  ? "rounded-2xl px-2 py-2 shadow-none grid [grid-template-columns:minmax(0,1fr)] [grid-template-rows:auto_1fr_auto] [grid-template-areas:'header'_'primary'_'footer']"
104
167
  : "rounded-full px-1 py-0.5 shadow-none grid [grid-template-columns:auto_minmax(0,1fr)_auto] [grid-template-rows:minmax(0,auto)] [grid-template-areas:'header_header_header'_'leading_primary_trailing'_'._footer_.']",
@@ -111,65 +174,119 @@ export const AskLeoComposer = React.forwardRef<HTMLTextAreaElement, AskLeoCompos
111
174
  })}
112
175
  style={{ gridArea: "primary" }}
113
176
  >
114
- <div className="max-h-52 min-h-0 min-w-0 flex-1 overflow-y-auto">
177
+ <div className="relative max-h-52 min-h-0 min-w-0 flex-1 overflow-y-auto">
115
178
  <Textarea
116
179
  id={fieldId}
117
180
  ref={setTextareaRef}
118
181
  value={value}
119
182
  onChange={handleTextareaChange}
120
183
  onKeyDown={handleKeyDown}
121
- placeholder={placeholder}
184
+ placeholder={showAnimatedPlaceholder ? " " : placeholder}
122
185
  autoComplete="off"
123
186
  className={cn(
124
- "min-h-0 min-w-0 w-full max-w-full resize-none rounded-none border-0 bg-transparent p-0 text-sm leading-5 text-foreground shadow-none placeholder:text-foreground/55 focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 md:text-sm dark:placeholder:text-foreground/50",
187
+ "min-h-0 min-w-0 w-full max-w-full resize-none rounded-none border-0 bg-transparent p-0 text-sm leading-5 text-foreground shadow-none placeholder:text-muted-foreground focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 md:text-sm dark:bg-transparent",
125
188
  !isExpanded && "min-h-[1.25rem] py-0",
189
+ showAnimatedPlaceholder && "placeholder:text-transparent",
126
190
  )}
127
191
  rows={1}
128
192
  />
193
+ {showAnimatedPlaceholder ? (
194
+ <div
195
+ className={cn(
196
+ "pointer-events-none absolute inset-x-0 top-0 flex overflow-hidden",
197
+ animatedPlaceholderMaxLines === 2
198
+ ? "min-h-[2.5rem] items-start"
199
+ : "min-h-[1.25rem] items-center",
200
+ )}
201
+ aria-hidden="true"
202
+ >
203
+ <AnimatePresence mode="wait" initial={false}>
204
+ <motion.span
205
+ key={phraseIndex}
206
+ initial={{ opacity: 0, y: reduceMotion ? 0 : 3 }}
207
+ animate={{ opacity: 1, y: 0 }}
208
+ exit={{ opacity: 0, y: reduceMotion ? 0 : -3 }}
209
+ transition={{ duration: reduceMotion ? 0 : 0.32, ease: [0.22, 1, 0.36, 1] }}
210
+ className={cn(
211
+ "block w-full text-start text-sm leading-5 text-muted-foreground",
212
+ animatedPlaceholderMaxLines === 2
213
+ ? "line-clamp-2 whitespace-normal break-words"
214
+ : "truncate",
215
+ )}
216
+ >
217
+ {phrases[phraseIndex]}
218
+ </motion.span>
219
+ </AnimatePresence>
220
+ </div>
221
+ ) : null}
129
222
  </div>
130
223
  </div>
131
224
 
132
225
  <div className={cn("flex shrink-0 items-center", { hidden: isExpanded })} style={{ gridArea: "leading" }}>
133
- <DropdownMenu>
226
+ {leadingSlot === "ai-mark" ? (
134
227
  <Tooltip>
135
228
  <TooltipTrigger asChild>
136
- <DropdownMenuTrigger asChild>
137
- <Button
138
- type="button"
139
- variant="ghost"
140
- size="icon"
141
- className="size-8 shrink-0 rounded-full hover:bg-accent"
142
- aria-label="Add attachments"
143
- >
144
- <i className="fa-light fa-plus text-base text-muted-foreground" aria-hidden="true" />
145
- </Button>
146
- </DropdownMenuTrigger>
229
+ <span
230
+ tabIndex={0}
231
+ role="img"
232
+ aria-label="AI search"
233
+ className="flex size-8 shrink-0 items-center justify-center rounded-full text-brand outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
234
+ >
235
+ <i
236
+ className="fa-light fa-star-christmas text-base text-[color:var(--brand-color-dark)] dark:text-[color:var(--brand-color-light)]"
237
+ aria-hidden="true"
238
+ />
239
+ </span>
147
240
  </TooltipTrigger>
148
- <TooltipContent side="top" sideOffset={6} className="max-w-xs text-xs">
149
- Add photos, files, and more
241
+ <TooltipContent side="top" sideOffset={6} className="text-xs">
242
+ AI search
150
243
  </TooltipContent>
151
244
  </Tooltip>
245
+ ) : (
246
+ <DropdownMenu>
247
+ <Tooltip>
248
+ <TooltipTrigger asChild>
249
+ <DropdownMenuTrigger asChild>
250
+ <Button
251
+ type="button"
252
+ variant="ghost"
253
+ size="icon"
254
+ className="size-8 shrink-0 rounded-full hover:bg-accent"
255
+ aria-label="Add attachments"
256
+ >
257
+ <i className="fa-light fa-plus text-base text-muted-foreground" aria-hidden="true" />
258
+ </Button>
259
+ </DropdownMenuTrigger>
260
+ </TooltipTrigger>
261
+ <TooltipContent side="top" sideOffset={6} className="max-w-xs text-xs">
262
+ Add photos, files, and more
263
+ </TooltipContent>
264
+ </Tooltip>
152
265
 
153
- <DropdownMenuContent align="start" className="max-w-xs rounded-2xl p-1.5">
154
- <DropdownMenuGroup className="space-y-1">
155
- <DropdownMenuItem
156
- className="flex items-center gap-2 rounded-md"
157
- onClick={() => fileInputRef.current?.click()}
158
- >
159
- <i className="fa-light fa-paperclip w-4 shrink-0 text-center opacity-60" aria-hidden="true" />
160
- Add photos &amp; files
161
- </DropdownMenuItem>
162
- <DropdownMenuItem className="flex items-center gap-2 rounded-md" onClick={() => {}}>
163
- <i className="fa-light fa-robot w-4 shrink-0 text-center opacity-60" aria-hidden="true" />
164
- Agent mode
165
- </DropdownMenuItem>
166
- <DropdownMenuItem className="flex items-center gap-2 rounded-md" onClick={() => {}}>
167
- <i className="fa-light fa-magnifying-glass w-4 shrink-0 text-center opacity-60" aria-hidden="true" />
168
- Deep Research
169
- </DropdownMenuItem>
170
- </DropdownMenuGroup>
171
- </DropdownMenuContent>
172
- </DropdownMenu>
266
+ <DropdownMenuContent align="start" className="max-w-xs rounded-2xl p-1.5">
267
+ <DropdownMenuGroup className="space-y-1">
268
+ <DropdownMenuItem
269
+ className="flex items-center gap-2 rounded-md"
270
+ onClick={() => fileInputRef.current?.click()}
271
+ >
272
+ <i className="fa-light fa-paperclip w-4 shrink-0 text-center opacity-60" aria-hidden="true" />
273
+ Add photos &amp; files
274
+ </DropdownMenuItem>
275
+ <DropdownMenuItem className="flex items-center gap-2 rounded-md" onClick={() => {}}>
276
+ <i className="fa-light fa-robot w-4 shrink-0 text-center opacity-60" aria-hidden="true" />
277
+ Agent mode
278
+ </DropdownMenuItem>
279
+ <DropdownMenuItem className="flex items-center gap-2 rounded-md" onClick={() => {}}>
280
+ <i
281
+ className="fa-light fa-magnifying-glass w-4 shrink-0 text-center opacity-60"
282
+ aria-hidden="true"
283
+ />
284
+ Deep Research
285
+ </DropdownMenuItem>
286
+ </DropdownMenuGroup>
287
+ </DropdownMenuContent>
288
+ </DropdownMenu>
289
+ )}
173
290
  </div>
174
291
 
175
292
  <div
@@ -197,12 +314,23 @@ export const AskLeoComposer = React.forwardRef<HTMLTextAreaElement, AskLeoCompos
197
314
  {value.trim() ? (
198
315
  <Tooltip>
199
316
  <TooltipTrigger asChild>
200
- <Button type="submit" size="icon" className="size-8 shrink-0 rounded-full" aria-label="Send message">
201
- <i className="fa-light fa-paper-plane-top text-base" aria-hidden="true" />
317
+ <Button
318
+ type="submit"
319
+ size="icon"
320
+ className="size-8 shrink-0 rounded-full"
321
+ aria-label={submitButtonAriaLabel}
322
+ >
323
+ <i
324
+ className={cn(
325
+ "text-base",
326
+ submitAppearance === "search" ? "fa-light fa-magnifying-glass" : "fa-light fa-paper-plane-top",
327
+ )}
328
+ aria-hidden="true"
329
+ />
202
330
  </Button>
203
331
  </TooltipTrigger>
204
332
  <TooltipContent side="top" sideOffset={6} className="text-xs">
205
- Send message
333
+ {submitButtonAriaLabel}
206
334
  </TooltipContent>
207
335
  </Tooltip>
208
336
  ) : null}
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import * as React from "react"
10
+ import dynamic from "next/dynamic"
10
11
  import { usePathname } from "next/navigation"
11
12
  import { AnimatePresence, motion } from "motion/react"
12
13
  import { cn } from "@/lib/utils"
@@ -24,7 +25,14 @@ import { useSidebar } from "@/components/ui/sidebar"
24
25
  import { StatusBadge } from "@/components/ui/status-badge"
25
26
  import { AiThinkingOverlay } from "@/components/ui/ai-thinking-surface"
26
27
  import { LeoTypingDots } from "@/components/leo-typing-dots"
27
- import { LeoIcon } from "@/components/ui/leo-icon"
28
+
29
+ const LeoIcon = dynamic(
30
+ () => import("@/components/ui/leo-icon").then(m => m.LeoIcon),
31
+ {
32
+ ssr: false,
33
+ loading: () => <div className="size-20" aria-hidden="true" />,
34
+ },
35
+ )
28
36
  import { useAltKeyLabel, useModKeyLabel } from "@/hooks/use-mod-key-label"
29
37
  import { ASK_LEO_GENERIC_SUGGESTIONS, getAskLeoRouteContext } from "@/lib/ask-leo-route-context"
30
38
  import { isEditableTarget } from "@/lib/editable-target"
@@ -4,6 +4,7 @@ import * as React from "react"
4
4
  import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
5
5
 
6
6
  import { useIsMobile } from "@/hooks/use-mobile"
7
+ import { formatDateUS } from "@/lib/date-filter"
7
8
  import {
8
9
  Card,
9
10
  CardAction,
@@ -247,24 +248,13 @@ export function ChartAreaInteractive() {
247
248
  axisLine={false}
248
249
  tickMargin={8}
249
250
  minTickGap={32}
250
- tickFormatter={(value) => {
251
- const date = new Date(value)
252
- return date.toLocaleDateString("en-US", {
253
- month: "short",
254
- day: "numeric",
255
- })
256
- }}
251
+ tickFormatter={(value) => formatDateUS(String(value))}
257
252
  />
258
253
  <ChartTooltip
259
254
  cursor={false}
260
255
  content={
261
256
  <ChartTooltipContent
262
- labelFormatter={(value) => {
263
- return new Date(value).toLocaleDateString("en-US", {
264
- month: "short",
265
- day: "numeric",
266
- })
267
- }}
257
+ labelFormatter={(value) => formatDateUS(String(value))}
268
258
  indicator="dot"
269
259
  />
270
260
  }
@@ -108,10 +108,19 @@ import {
108
108
  import { isEditableTarget } from "@/lib/editable-target"
109
109
  import { chartLineStrokeDash } from "@/lib/chart-line-dash"
110
110
  import { cn } from "@/lib/utils"
111
+ import { metricTrendTone, type MetricTrendPolarity } from "@/components/key-metrics"
111
112
 
112
113
  /** Recharts passes `index` into Line `dot` renderers; published `DotProps` omits it. */
113
114
  type LineDotRenderProps = DotProps & { index?: number }
114
115
 
116
+ type MiniMetric = {
117
+ label: string
118
+ value: string
119
+ trend?: "up" | "down" | "neutral"
120
+ /** Same semantics as `MetricItem.trendPolarity` on `KeyMetrics`. */
121
+ trendPolarity?: MetricTrendPolarity
122
+ }
123
+
115
124
  /* ── Colour tokens ────────────────────────────────────────────────────────── */
116
125
  const BRAND = "var(--brand-color)"
117
126
  const CHART_1 = "var(--color-chart-1)"
@@ -712,8 +721,6 @@ function ChartCardHeader({
712
721
  )
713
722
  }
714
723
 
715
- type MiniMetric = { label: string; value: string; trend?: "up" | "down" | "neutral" }
716
-
717
724
  export function ChartCard({
718
725
  title,
719
726
  description,
@@ -856,6 +863,19 @@ export function ChartCard({
856
863
  {metrics.map((m) => {
857
864
  const isUp = m.trend === "up"
858
865
  const isDown = m.trend === "down"
866
+ const tone = metricTrendTone(m.trend ?? "neutral", m.trendPolarity)
867
+ const upClass =
868
+ tone === "positive"
869
+ ? "text-emerald-600"
870
+ : tone === "negative"
871
+ ? "text-destructive"
872
+ : "text-muted-foreground"
873
+ const downClass =
874
+ tone === "positive"
875
+ ? "text-emerald-600"
876
+ : tone === "negative"
877
+ ? "text-destructive"
878
+ : "text-muted-foreground"
859
879
  return (
860
880
  <TabsTrigger
861
881
  key={m.label}
@@ -865,8 +885,8 @@ export function ChartCard({
865
885
  <span className="text-sm font-normal text-muted-foreground leading-none">{m.label}</span>
866
886
  <div className="flex items-baseline gap-1.5">
867
887
  <span className="text-xl font-bold tabular-nums leading-none text-foreground">{m.value}</span>
868
- {isUp && <i className="fa-light fa-arrow-trend-up text-xs text-emerald-600" aria-hidden="true" />}
869
- {isDown && <i className="fa-light fa-arrow-trend-down text-xs text-destructive" aria-hidden="true" />}
888
+ {isUp && <i className={cn("fa-light fa-arrow-trend-up text-xs", upClass)} aria-hidden="true" />}
889
+ {isDown && <i className={cn("fa-light fa-arrow-trend-down text-xs", downClass)} aria-hidden="true" />}
870
890
  </div>
871
891
  </TabsTrigger>
872
892
  )
@@ -896,6 +916,13 @@ export function ChartCard({
896
916
  const kpi = miniMetrics?.[0]
897
917
  const isUp = kpi?.trend === "up"
898
918
  const isDown = kpi?.trend === "down"
919
+ const tone = metricTrendTone(kpi?.trend ?? "neutral", kpi?.trendPolarity)
920
+ const trendClass =
921
+ tone === "positive"
922
+ ? "text-emerald-600"
923
+ : tone === "negative"
924
+ ? "text-destructive"
925
+ : "text-muted-foreground"
899
926
 
900
927
  return (
901
928
  <Card className={`flex flex-col h-full ${className}`} role="figure" aria-label={title}>
@@ -908,13 +935,13 @@ export function ChartCard({
908
935
  {kpi.value}
909
936
  </span>
910
937
  {isUp && (
911
- <span className="flex items-center gap-1 text-sm font-medium text-emerald-600">
938
+ <span className={cn("flex items-center gap-1 text-sm font-medium", trendClass)}>
912
939
  <i className="fa-light fa-arrow-trend-up" aria-hidden="true" />
913
940
  <span className="sr-only">trending up</span>
914
941
  </span>
915
942
  )}
916
943
  {isDown && (
917
- <span className="flex items-center gap-1 text-sm font-medium text-destructive">
944
+ <span className={cn("flex items-center gap-1 text-sm font-medium", trendClass)}>
918
945
  <i className="fa-light fa-arrow-trend-down" aria-hidden="true" />
919
946
  <span className="sr-only">trending down</span>
920
947
  </span>
@@ -0,0 +1,144 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import type { InviteCollaboratorFormValues } from "@/components/invite-collaborators-drawer"
6
+ import { InviteCollaboratorsDrawer } from "@/components/invite-collaborators-drawer"
7
+ import type { PageHeaderCollaborator } from "@/components/page-header"
8
+ import {
9
+ canRemoveCollaboratorFromRoster,
10
+ canSetCollaboratorAccessRole,
11
+ displayNameFromInviteEmail,
12
+ type CollaboratorAccessRole,
13
+ } from "@/lib/collaborator-access"
14
+ import { initialsFromDisplayName } from "@/lib/initials-from-name"
15
+
16
+ export interface CollaborationAccessFlowRenderProps {
17
+ collaborators: PageHeaderCollaborator[]
18
+ openInvite: () => void
19
+ }
20
+
21
+ export interface CollaborationAccessFlowProps {
22
+ initialCollaborators: PageHeaderCollaborator[]
23
+ resourceLabel: string
24
+ onInvite?: (
25
+ values: InviteCollaboratorFormValues,
26
+ collaborators: PageHeaderCollaborator[],
27
+ ) => PageHeaderCollaborator[] | void
28
+ onCollaboratorAccessChange?: (
29
+ id: string,
30
+ access: CollaboratorAccessRole,
31
+ collaborators: PageHeaderCollaborator[],
32
+ ) => PageHeaderCollaborator[] | void
33
+ onCollaboratorRemove?: (
34
+ id: string,
35
+ collaborators: PageHeaderCollaborator[],
36
+ ) => PageHeaderCollaborator[] | void
37
+ children: (props: CollaborationAccessFlowRenderProps) => React.ReactNode
38
+ }
39
+
40
+ function appendInvitedCollaborator(
41
+ collaborators: PageHeaderCollaborator[],
42
+ values: InviteCollaboratorFormValues,
43
+ ): PageHeaderCollaborator[] {
44
+ const name = displayNameFromInviteEmail(values.email)
45
+ return [
46
+ ...collaborators,
47
+ {
48
+ id: `invite-${values.email}`,
49
+ name,
50
+ email: values.email,
51
+ access: values.access,
52
+ initials: initialsFromDisplayName(name),
53
+ },
54
+ ]
55
+ }
56
+
57
+ function updateCollaboratorAccess(
58
+ collaborators: PageHeaderCollaborator[],
59
+ id: string,
60
+ access: CollaboratorAccessRole,
61
+ ): PageHeaderCollaborator[] {
62
+ const person = collaborators.find(entry => entry.id === id)
63
+ if (!person || !canSetCollaboratorAccessRole(person, collaborators, access)) {
64
+ return collaborators
65
+ }
66
+ return collaborators.map(entry => (entry.id === id ? { ...entry, access } : entry))
67
+ }
68
+
69
+ function removeCollaboratorFromRoster(
70
+ collaborators: PageHeaderCollaborator[],
71
+ id: string,
72
+ ): PageHeaderCollaborator[] {
73
+ const person = collaborators.find(entry => entry.id === id)
74
+ if (!person || !canRemoveCollaboratorFromRoster(person, collaborators)) {
75
+ return collaborators
76
+ }
77
+ return collaborators.filter(entry => entry.id !== id)
78
+ }
79
+
80
+ export function CollaborationAccessFlow({
81
+ initialCollaborators,
82
+ resourceLabel,
83
+ onInvite,
84
+ onCollaboratorAccessChange,
85
+ onCollaboratorRemove,
86
+ children,
87
+ }: CollaborationAccessFlowProps) {
88
+ const [collaborators, setCollaborators] = React.useState<PageHeaderCollaborator[]>(
89
+ () => initialCollaborators.map(person => ({ ...person })),
90
+ )
91
+ const [inviteOpen, setInviteOpen] = React.useState(false)
92
+
93
+ const openInvite = React.useCallback(() => {
94
+ setInviteOpen(true)
95
+ }, [])
96
+
97
+ const handleInvite = React.useCallback(
98
+ (values: InviteCollaboratorFormValues) => {
99
+ setCollaborators(current => {
100
+ const next = onInvite?.(values, current) ?? appendInvitedCollaborator(current, values)
101
+ return next
102
+ })
103
+ },
104
+ [onInvite],
105
+ )
106
+
107
+ const handleAccessChange = React.useCallback(
108
+ (id: string, access: CollaboratorAccessRole) => {
109
+ setCollaborators(current => {
110
+ const next =
111
+ onCollaboratorAccessChange?.(id, access, current)
112
+ ?? updateCollaboratorAccess(current, id, access)
113
+ return next
114
+ })
115
+ },
116
+ [onCollaboratorAccessChange],
117
+ )
118
+
119
+ const handleRemove = React.useCallback(
120
+ (id: string) => {
121
+ setCollaborators(current => {
122
+ const next =
123
+ onCollaboratorRemove?.(id, current) ?? removeCollaboratorFromRoster(current, id)
124
+ return next
125
+ })
126
+ },
127
+ [onCollaboratorRemove],
128
+ )
129
+
130
+ return (
131
+ <>
132
+ {children({ collaborators, openInvite })}
133
+ <InviteCollaboratorsDrawer
134
+ open={inviteOpen}
135
+ onOpenChange={setInviteOpen}
136
+ collaborators={collaborators}
137
+ resourceLabel={resourceLabel}
138
+ onInvite={handleInvite}
139
+ onCollaboratorAccessChange={handleAccessChange}
140
+ onCollaboratorRemove={handleRemove}
141
+ />
142
+ </>
143
+ )
144
+ }
@@ -59,7 +59,7 @@ export function CompliancePageHeader({
59
59
  </Button>
60
60
  </DropdownMenuTrigger>
61
61
  </Tip>
62
- <DropdownMenuContent align="end" className="w-52">
62
+ <DropdownMenuContent align="end">
63
63
  <DropdownMenuItem
64
64
  onSelect={() => {
65
65
  window.setTimeout(() => onExport(), 0)
@@ -22,7 +22,7 @@ import {
22
22
  saveComplianceDashboardLayout,
23
23
  } from "@/components/data-view-dashboard-charts-compliance"
24
24
  import { KEY_METRICS_KPI_COUNT_DEFAULT } from "@/lib/dashboard-layout-merge"
25
- import type { ChartType, DashboardLayout } from "@/components/data-view-dashboard-charts"
25
+ import type { ChartType, DashboardLayout } from "@/lib/data-view-dashboard-placements-layout"
26
26
  import { ComplianceListView } from "@/components/compliance-list-view"
27
27
  import { ComplianceBoardView, COMPLIANCE_BOARD_GROUP_OPTIONS } from "@/components/compliance-board-view"
28
28
  import { complianceKpiInsight, complianceKpiMetrics } from "@/lib/mock/compliance-kpi"
@@ -204,7 +204,7 @@ function buildComplianceColumns(items: ComplianceItem[]): ColumnDef<ComplianceIt
204
204
  <i className="fa-light fa-ellipsis text-sm" aria-hidden="true" />
205
205
  </Button>
206
206
  </DropdownMenuTrigger>
207
- <DropdownMenuContent align="end" className="w-40">
207
+ <DropdownMenuContent align="end">
208
208
  <DropdownMenuItem disabled>
209
209
  <i className="fa-light fa-eye" aria-hidden="true" />
210
210
  View details
@@ -29,6 +29,7 @@ import {
29
29
  import { DashboardPromoBanner } from "@/components/dashboard-promo-banner"
30
30
  import { CoachMark } from "@/components/ui/coach-mark"
31
31
  import { useCoachMark } from "@/hooks/use-coach-mark"
32
+ import { formatDateFromDate } from "@/lib/date-filter"
32
33
 
33
34
  /* ── Types passed from the page ─────────────────────────────────────────── */
34
35
  interface DashboardTabsProps {
@@ -59,7 +60,7 @@ function GreetingWidget({ compact = false }: { compact?: boolean }) {
59
60
  <div>
60
61
  {!compact ? (
61
62
  <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider" suppressHydrationWarning>
62
- {now?.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }) ?? ""}
63
+ {now ? formatDateFromDate(now) : ""}
63
64
  </p>
64
65
  ) : null}
65
66
  {compact ? (
@@ -95,8 +96,8 @@ const TASK_ITEMS: TaskListItem[] = [
95
96
  { id: 1, label: "Review pending evaluations", due: "Today", priority: "high", done: false },
96
97
  { id: 2, label: "Approve site contract — City Med", due: "Today", priority: "high", done: false },
97
98
  { id: 3, label: "Send onboarding docs to PT cohort", due: "Tomorrow", priority: "medium", done: false },
98
- { id: 4, label: "Update compliance checklist", due: "Mar 25", priority: "medium", done: false },
99
- { id: 5, label: "Schedule supervisor training", due: "Mar 28", priority: "low", done: true },
99
+ { id: 4, label: "Update compliance checklist", due: "03/25/2026", priority: "medium", done: false },
100
+ { id: 5, label: "Schedule supervisor training", due: "03/28/2026", priority: "low", done: true },
100
101
  ]
101
102
 
102
103
  /* ── Insights ─────────────────────────────────────────────────────────────── */
@@ -154,7 +154,7 @@ export function RowActions({ row, actions }: { row: Placement; actions: RowActio
154
154
  </Button>
155
155
  </DropdownMenuTrigger>
156
156
  </Tip>
157
- <DropdownMenuContent align="end" className="w-40">
157
+ <DropdownMenuContent align="end">
158
158
  {actions.map((a, i) => (
159
159
  <React.Fragment key={a.label}>
160
160
  {a.variant === "destructive" && i > 0 && <DropdownMenuSeparator />}