@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.
- package/consumer-extras/cursor-skills/exxat-card-vs-list-rows/SKILL.md +20 -0
- package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +33 -0
- package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +45 -0
- package/consumer-extras/cursor-skills/exxat-drawer-vs-dialog/SKILL.md +20 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +31 -5
- package/consumer-extras/cursor-skills/exxat-kpi-max-four/SKILL.md +19 -0
- package/consumer-extras/cursor-skills/exxat-kpi-trends/SKILL.md +27 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +1 -0
- package/consumer-extras/patterns/collaboration-access-pattern.md +114 -0
- package/consumer-extras/patterns/data-views-pattern.md +12 -4
- package/package.json +17 -4
- package/src/components/ui/banner.tsx +20 -7
- package/src/components/ui/date-picker-field.tsx +3 -3
- package/src/components/ui/dropdown-menu.tsx +17 -6
- package/src/components/ui/input-group.tsx +1 -1
- package/src/components/ui/input.tsx +1 -1
- package/src/components/ui/select.tsx +1 -1
- package/src/components/ui/separator.tsx +2 -2
- package/src/components/ui/sidebar.tsx +31 -3
- package/src/components/ui/textarea.tsx +1 -1
- package/src/globals.css +0 -1
- package/src/index.ts +1 -0
- package/src/lib/date-filter.ts +13 -4
- package/src/lib/dropdown-menu-surface.ts +13 -0
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +27 -9
- package/template/.cursor/rules/exxat-data-tables.mdc +1 -0
- package/template/AGENTS.md +82 -27
- package/template/app/(app)/examples/page.tsx +2 -1
- package/template/app/(app)/help/page.tsx +6 -0
- package/template/app/(app)/layout.tsx +7 -4
- package/template/app/(app)/question-bank/find/page.tsx +12 -0
- package/template/app/(app)/question-bank/layout.tsx +46 -0
- package/template/app/(app)/question-bank/library/page.tsx +11 -0
- package/template/app/(app)/question-bank/list/page.tsx +12 -0
- package/template/app/(app)/question-bank/page.tsx +4 -3
- package/template/app/globals.css +1 -2
- package/template/components/app-sidebar.tsx +51 -13
- package/template/components/ask-leo-composer.tsx +173 -45
- package/template/components/ask-leo-sidebar.tsx +9 -1
- package/template/components/chart-area-interactive.tsx +3 -13
- package/template/components/charts-overview.tsx +33 -6
- package/template/components/collaboration-access-flow.tsx +144 -0
- package/template/components/compliance-page-header.tsx +1 -1
- package/template/components/compliance-table.tsx +2 -2
- package/template/components/dashboard-tabs.tsx +4 -3
- package/template/components/data-list-table-cells.tsx +1 -1
- package/template/components/data-list-table.tsx +1 -1
- package/template/components/data-table/index.tsx +5 -5
- package/template/components/data-table/use-table-state.ts +18 -2
- package/template/components/data-view-dashboard-charts-compliance.tsx +8 -5
- package/template/components/data-view-dashboard-charts-team.tsx +8 -5
- package/template/components/data-view-dashboard-charts.tsx +62 -227
- package/template/components/dedicated-search-recents.tsx +96 -0
- package/template/components/dedicated-search-url-composer.tsx +112 -0
- package/template/components/getting-started.tsx +1 -1
- package/template/components/hub-tree-panel-view.tsx +10 -26
- package/template/components/invite-collaborators-drawer.tsx +453 -0
- package/template/components/key-metrics.tsx +54 -8
- package/template/components/nav-documents.tsx +1 -1
- package/template/components/new-placement-form.tsx +3 -3
- package/template/components/page-header.tsx +76 -59
- package/template/components/placements-board-view.tsx +3 -3
- package/template/components/placements-page-header.tsx +1 -1
- package/template/components/placements-table-columns.tsx +3 -2
- package/template/components/product-switcher.tsx +0 -1
- package/template/components/question-bank-board-view.tsx +35 -47
- package/template/components/question-bank-client.tsx +293 -81
- package/template/components/question-bank-dashboard-charts.tsx +174 -0
- package/template/components/question-bank-favorite-button.tsx +46 -0
- package/template/components/question-bank-hub-client.tsx +436 -0
- package/template/components/question-bank-list-view.tsx +26 -19
- package/template/components/question-bank-new-folder-sheet.tsx +56 -42
- package/template/components/question-bank-os-folder-view.tsx +3 -14
- package/template/components/question-bank-page-header.tsx +85 -53
- package/template/components/question-bank-panel-activator.tsx +3 -4
- package/template/components/question-bank-secondary-nav.tsx +523 -65
- package/template/components/question-bank-table.tsx +125 -343
- package/template/components/secondary-panel.tsx +130 -63
- package/template/components/settings-client.tsx +3 -1
- package/template/components/sidebar-shell.tsx +2 -0
- package/template/components/sites-all-client.tsx +1 -1
- package/template/components/sites-table.tsx +1 -1
- package/template/components/system-banner-slot.tsx +2 -1
- package/template/components/table-properties/drawer.tsx +3 -3
- package/template/components/table-properties/sort-card.tsx +1 -1
- package/template/components/team-page-header.tsx +1 -1
- package/template/components/team-table.tsx +8 -4
- package/template/components/templates/dedicated-search-landing-template.tsx +58 -0
- package/template/components/templates/dedicated-search-results-template.tsx +19 -0
- package/template/components/templates/discovery-hub-template.tsx +273 -0
- package/template/components/templates/list-page.tsx +11 -4
- package/template/components/templates/nested-secondary-panel-shell.tsx +57 -0
- package/template/components/templates/secondary-panel-hub-template.tsx +54 -0
- package/template/docs/card-vs-rows-pattern.md +36 -0
- package/template/docs/collaboration-access-pattern.md +114 -0
- package/template/docs/data-views-pattern.md +12 -4
- package/template/docs/drawer-vs-dialog-pattern.md +50 -0
- package/template/docs/kpi-strip-max-four-pattern.md +29 -0
- package/template/docs/kpi-trend-pattern.md +43 -0
- package/template/fontawesome-subset.manifest.json +2 -2
- package/template/hooks/use-location-hash.ts +14 -8
- package/template/hooks/use-secondary-panel-hub-nav.ts +98 -0
- package/template/lib/ask-leo-route-context.ts +24 -0
- package/template/lib/collaborator-access.ts +92 -0
- package/template/lib/command-menu-config.ts +8 -1
- package/template/lib/command-menu-search-data.ts +11 -8
- package/template/lib/data-list-display-options.ts +1 -1
- package/template/lib/data-view-dashboard-placements-layout.ts +215 -0
- package/template/lib/date-filter.ts +1 -0
- package/template/lib/dedicated-search-recents.ts +76 -0
- package/template/lib/dedicated-search-url.ts +23 -0
- package/template/lib/discovery-hub.ts +15 -0
- package/template/lib/list-status-badges.ts +1 -21
- package/template/lib/mock/navigation.tsx +4 -2
- package/template/lib/mock/placements.ts +9 -9
- package/template/lib/mock/question-bank-folders.ts +7 -0
- package/template/lib/mock/question-bank-header-collaborators.ts +45 -5
- package/template/lib/mock/question-bank-inspector.ts +1 -2
- package/template/lib/mock/question-bank-kpi.ts +38 -26
- package/template/lib/mock/question-bank.ts +43 -16
- package/template/lib/question-bank-dedicated-search.ts +19 -0
- package/template/lib/question-bank-hub-search.ts +90 -0
- package/template/lib/question-bank-nav.ts +322 -6
- package/template/lib/question-bank-recent-searches.ts +22 -0
- 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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
156
|
+
{inputLabel}
|
|
96
157
|
</label>
|
|
97
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
226
|
+
{leadingSlot === "ai-mark" ? (
|
|
134
227
|
<Tooltip>
|
|
135
228
|
<TooltipTrigger asChild>
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
</
|
|
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="
|
|
149
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 & 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
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
869
|
-
{isDown && <i className="fa-light fa-arrow-trend-down text-xs
|
|
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
|
|
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
|
|
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"
|
|
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 "@/
|
|
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"
|
|
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
|
|
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: "
|
|
99
|
-
{ id: 5, label: "Schedule supervisor training", due: "
|
|
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"
|
|
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 />}
|