@handled-ai/design-system 0.17.1 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/charts/empty-chart-state.d.ts +11 -0
  2. package/dist/charts/empty-chart-state.js +70 -0
  3. package/dist/charts/empty-chart-state.js.map +1 -0
  4. package/dist/charts/index.d.ts +1 -0
  5. package/dist/charts/index.js +1 -0
  6. package/dist/charts/index.js.map +1 -1
  7. package/dist/charts/pipeline-overview.d.ts +2 -1
  8. package/dist/charts/pipeline-overview.js +29 -1
  9. package/dist/charts/pipeline-overview.js.map +1 -1
  10. package/dist/components/actor-byline.d.ts +3 -0
  11. package/dist/components/actor-byline.js +5 -0
  12. package/dist/components/actor-byline.js.map +1 -0
  13. package/dist/components/days-open-cell.d.ts +16 -0
  14. package/dist/components/days-open-cell.js +73 -0
  15. package/dist/components/days-open-cell.js.map +1 -0
  16. package/dist/components/detail-drawer.d.ts +16 -0
  17. package/dist/components/detail-drawer.js +45 -0
  18. package/dist/components/detail-drawer.js.map +1 -0
  19. package/dist/components/feedback-primitives.d.ts +66 -0
  20. package/dist/components/feedback-primitives.js +295 -0
  21. package/dist/components/feedback-primitives.js.map +1 -0
  22. package/dist/components/insights-filter-bar.d.ts +2 -1
  23. package/dist/components/insights-filter-bar.js +13 -5
  24. package/dist/components/insights-filter-bar.js.map +1 -1
  25. package/dist/components/linked-entity-cell.d.ts +14 -0
  26. package/dist/components/linked-entity-cell.js +96 -0
  27. package/dist/components/linked-entity-cell.js.map +1 -0
  28. package/dist/components/metric-card.d.ts +14 -1
  29. package/dist/components/metric-card.js +86 -0
  30. package/dist/components/metric-card.js.map +1 -1
  31. package/dist/components/performance-metrics-table.d.ts +2 -1
  32. package/dist/components/performance-metrics-table.js +78 -46
  33. package/dist/components/performance-metrics-table.js.map +1 -1
  34. package/dist/components/pill.d.ts +26 -0
  35. package/dist/components/pill.js +77 -0
  36. package/dist/components/pill.js.map +1 -0
  37. package/dist/components/quick-segment.d.ts +13 -0
  38. package/dist/components/quick-segment.js +96 -0
  39. package/dist/components/quick-segment.js.map +1 -0
  40. package/dist/components/score-why-chips.d.ts +8 -17
  41. package/dist/components/score-why-chips.js +266 -180
  42. package/dist/components/score-why-chips.js.map +1 -1
  43. package/dist/components/signal-priority-popover.d.ts +17 -0
  44. package/dist/components/signal-priority-popover.js +247 -0
  45. package/dist/components/signal-priority-popover.js.map +1 -0
  46. package/dist/components/user-display.d.ts +22 -0
  47. package/dist/components/user-display.js +138 -0
  48. package/dist/components/user-display.js.map +1 -0
  49. package/dist/components/user-pill.d.ts +3 -0
  50. package/dist/components/user-pill.js +5 -0
  51. package/dist/components/user-pill.js.map +1 -0
  52. package/dist/index.d.ts +13 -4
  53. package/dist/index.js +17 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/lib/user-display.d.ts +31 -0
  56. package/dist/lib/user-display.js +57 -0
  57. package/dist/lib/user-display.js.map +1 -0
  58. package/dist/prototype/index.d.ts +2 -1
  59. package/dist/prototype/prototype-accounts-view.d.ts +2 -1
  60. package/dist/prototype/prototype-admin-view.d.ts +2 -1
  61. package/dist/prototype/prototype-config.d.ts +15 -332
  62. package/dist/prototype/prototype-inbox-view.d.ts +2 -1
  63. package/dist/prototype/prototype-inbox-view.js +11 -12
  64. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  65. package/dist/prototype/prototype-insights-view.d.ts +2 -1
  66. package/dist/prototype/prototype-shell.d.ts +2 -1
  67. package/dist/signal-priority-popover-DQ_VuHac.d.ts +390 -0
  68. package/package.json +1 -1
  69. package/src/charts/__tests__/insights-charts.test.tsx +62 -0
  70. package/src/charts/empty-chart-state.tsx +44 -0
  71. package/src/charts/index.ts +1 -0
  72. package/src/charts/pipeline-overview.tsx +38 -1
  73. package/src/components/__tests__/contextual-quick-action-launcher.test.tsx +99 -188
  74. package/src/components/__tests__/feedback-primitives.test.tsx +509 -0
  75. package/src/components/__tests__/insights-primitives.test.tsx +117 -0
  76. package/src/components/__tests__/performance-metrics-table.test.tsx +54 -0
  77. package/src/components/__tests__/score-why-chips.test.tsx +540 -0
  78. package/src/components/__tests__/signal-priority-popover.test.tsx +312 -0
  79. package/src/components/__tests__/user-display.test.tsx +75 -0
  80. package/src/components/actor-byline.tsx +1 -0
  81. package/src/components/days-open-cell.tsx +50 -0
  82. package/src/components/detail-drawer.tsx +60 -0
  83. package/src/components/feedback-primitives.tsx +424 -0
  84. package/src/components/insights-filter-bar.tsx +13 -4
  85. package/src/components/linked-entity-cell.tsx +74 -0
  86. package/src/components/metric-card.tsx +82 -0
  87. package/src/components/performance-metrics-table.tsx +99 -63
  88. package/src/components/pill.tsx +67 -0
  89. package/src/components/quick-segment.tsx +68 -0
  90. package/src/components/score-why-chips.tsx +413 -203
  91. package/src/components/signal-priority-popover.tsx +359 -0
  92. package/src/components/user-display.tsx +96 -0
  93. package/src/components/user-pill.tsx +1 -0
  94. package/src/index.ts +11 -0
  95. package/src/lib/__tests__/user-display.test.ts +85 -0
  96. package/src/lib/user-display.ts +88 -0
  97. package/src/prototype/__tests__/detail-view-score-why.test.tsx +33 -29
  98. package/src/prototype/__tests__/detail-view-title-slots.test.tsx +65 -0
  99. package/src/prototype/prototype-config.ts +28 -4
  100. package/src/prototype/prototype-inbox-view.tsx +8 -10
  101. package/src/prototype/__tests__/detail-view-title-subtext.test.tsx +0 -72
@@ -0,0 +1,359 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Popover as PopoverPrimitive } from "radix-ui"
5
+ import type { LucideIcon } from "lucide-react"
6
+ import {
7
+ Radar,
8
+ Wallet,
9
+ Link2,
10
+ MessageSquare,
11
+ TrendingDown,
12
+ ArrowUpRight,
13
+ ArrowDownRight,
14
+ Clock,
15
+ Activity,
16
+ Minus,
17
+ ChevronDown,
18
+ ChevronUp,
19
+ Info,
20
+ } from "lucide-react"
21
+ import { cn } from "../lib/utils"
22
+ import { FeedbackFooter } from "./feedback-primitives"
23
+ import type { FeedbackChipTree, FeedbackSubmitData } from "./feedback-primitives"
24
+ import type { SignalScoreUrgencyLabel } from "../prototype/prototype-config"
25
+ import { getSignalScoreUrgencyLabel, scoreRangeForUrgency, SIGNAL_TONE_CLASSES } from "./score-why-chips"
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Types
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * A single contributing factor in the priority popover.
33
+ */
34
+ export interface PriorityFactor {
35
+ key: string
36
+ label: string
37
+ /** Lucide icon name (e.g. "radar", "wallet", "link-2", "message-square"). */
38
+ icon: string
39
+ /** Drives icon background tint. */
40
+ tone: "alert" | "warn" | "info"
41
+ /** Explicit semantic label - NOT inferred from score+weight. */
42
+ direction: "raises" | "lowers" | "neutral"
43
+ /** 0-100 */
44
+ score: number
45
+ /** Evidence text (e.g. "$3.4M moved in 8h - current treasury balance $0.00"). */
46
+ rationale: string
47
+ }
48
+
49
+ export interface SignalPriorityPopoverProps {
50
+ score: number
51
+ urgencyLabel?: SignalScoreUrgencyLabel
52
+ /** Synthesis sentence displayed in the popover head. */
53
+ urgencyExplanation?: string
54
+ factors: PriorityFactor[]
55
+ /** e.g. "Updated 4m ago - model v3.2" */
56
+ metaText?: string
57
+ /** Negative feedback issue tree. */
58
+ feedbackChips?: FeedbackChipTree[]
59
+ onFeedbackSubmit?: (data: FeedbackSubmitData) => void
60
+ className?: string
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Static class maps (required for Tailwind v4 source scanning)
65
+ // ---------------------------------------------------------------------------
66
+
67
+ const URGENCY_TRIGGER_DEFAULT: Record<SignalScoreUrgencyLabel, string> = {
68
+ Urgent: "border-red-200 bg-red-50 text-red-700",
69
+ High: "border-orange-200 bg-orange-50 text-orange-700",
70
+ Medium: "border-amber-200 bg-amber-50 text-amber-700",
71
+ Low: "border-blue-200 bg-blue-50 text-blue-700",
72
+ }
73
+
74
+ const URGENCY_TRIGGER_HOVER: Record<SignalScoreUrgencyLabel, string> = {
75
+ Urgent: "hover:bg-red-100",
76
+ High: "hover:bg-orange-100",
77
+ Medium: "hover:bg-amber-100",
78
+ Low: "hover:bg-blue-100",
79
+ }
80
+
81
+ const URGENCY_TRIGGER_OPEN: Record<SignalScoreUrgencyLabel, string> = {
82
+ Urgent: "bg-red-100",
83
+ High: "bg-orange-100",
84
+ Medium: "bg-amber-100",
85
+ Low: "bg-blue-100",
86
+ }
87
+
88
+ /** Re-use shared tone classes from score-why-chips. */
89
+ const TONE_ICON_CLASSES: Record<PriorityFactor["tone"], string> = SIGNAL_TONE_CLASSES as Record<PriorityFactor["tone"], string>
90
+
91
+ const DIRECTION_CLASSES: Record<PriorityFactor["direction"], string> = {
92
+ raises: "text-red-600",
93
+ lowers: "text-emerald-600",
94
+ neutral: "text-muted-foreground",
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Icon lookup
99
+ // ---------------------------------------------------------------------------
100
+
101
+ const FACTOR_ICONS: Record<string, LucideIcon> = {
102
+ radar: Radar,
103
+ wallet: Wallet,
104
+ "link-2": Link2,
105
+ "message-square": MessageSquare,
106
+ "trending-down": TrendingDown,
107
+ "arrow-up-right": ArrowUpRight,
108
+ clock: Clock,
109
+ activity: Activity,
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Urgency dot color (static map)
114
+ // ---------------------------------------------------------------------------
115
+
116
+ const URGENCY_DOT_CLASSES: Record<SignalScoreUrgencyLabel, string> = {
117
+ Urgent: "bg-red-500",
118
+ High: "bg-orange-500",
119
+ Medium: "bg-amber-500",
120
+ Low: "bg-blue-500",
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Default feedback chips
125
+ // ---------------------------------------------------------------------------
126
+
127
+ const DEFAULT_PRIORITY_FEEDBACK_CHIPS: FeedbackChipTree[] = [
128
+ { label: "Wrong factor weighting" },
129
+ { label: "Missing context" },
130
+ { label: "Inaccurate data" },
131
+ { label: "Stale" },
132
+ { label: "Other" },
133
+ ]
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Direction icon component
137
+ // ---------------------------------------------------------------------------
138
+
139
+ function DirectionIcon({ direction }: { direction: PriorityFactor["direction"] }) {
140
+ const cls = "h-2.5 w-2.5"
141
+ switch (direction) {
142
+ case "raises":
143
+ return <ArrowUpRight className={cls} />
144
+ case "lowers":
145
+ return <ArrowDownRight className={cls} />
146
+ case "neutral":
147
+ return <Minus className={cls} />
148
+ }
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // PriorityFactorRow
153
+ // ---------------------------------------------------------------------------
154
+
155
+ function PriorityFactorRow({ factor }: { factor: PriorityFactor }) {
156
+ const IconComponent = FACTOR_ICONS[factor.icon] ?? Activity
157
+ const toneClasses = TONE_ICON_CLASSES[factor.tone]
158
+ const directionClasses = DIRECTION_CLASSES[factor.direction]
159
+ const directionLabel =
160
+ factor.direction === "raises"
161
+ ? "Raises"
162
+ : factor.direction === "lowers"
163
+ ? "Lowers"
164
+ : "Neutral"
165
+
166
+ return (
167
+ <div
168
+ className="grid grid-cols-[20px_1fr_auto] gap-x-3 gap-y-1 px-4 py-3"
169
+ data-testid={`factor-row-${factor.key}`}
170
+ >
171
+ {/* Icon */}
172
+ <div
173
+ className={cn(
174
+ "flex h-5 w-5 items-center justify-center rounded",
175
+ toneClasses,
176
+ )}
177
+ >
178
+ <IconComponent className="h-3 w-3" />
179
+ </div>
180
+
181
+ {/* Label + direction tag */}
182
+ <div className="flex items-center gap-2">
183
+ <span className="text-[13px] font-semibold text-foreground">
184
+ {factor.label}
185
+ </span>
186
+ <span
187
+ className={cn(
188
+ "inline-flex items-center gap-0.5 text-[10px] font-medium",
189
+ directionClasses,
190
+ )}
191
+ >
192
+ <DirectionIcon direction={factor.direction} />
193
+ {directionLabel}
194
+ </span>
195
+ </div>
196
+
197
+ {/* Score number */}
198
+ <div className="flex items-center text-right">
199
+ <span className="text-sm font-bold tabular-nums">{factor.score}</span>
200
+ <span className="text-xs font-normal text-muted-foreground">/100</span>
201
+ </div>
202
+
203
+ {/* empty grid cell under icon column */}
204
+ <div />
205
+
206
+ {/* Rationale */}
207
+ <p className="text-xs leading-relaxed text-muted-foreground">
208
+ {factor.rationale}
209
+ </p>
210
+
211
+ {/* empty grid cell under score column */}
212
+ <div />
213
+
214
+ {/* empty grid cell under icon column */}
215
+ <div />
216
+
217
+ {/* Score track */}
218
+ <div className="mt-1 h-0.5 rounded-full bg-muted">
219
+ <div
220
+ className="h-full rounded-full bg-foreground/20"
221
+ style={{ width: `${Math.min(100, Math.max(0, factor.score))}%` }}
222
+ />
223
+ </div>
224
+
225
+ {/* empty grid cell under score column */}
226
+ <div />
227
+ </div>
228
+ )
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // SignalPriorityPopover
233
+ // ---------------------------------------------------------------------------
234
+
235
+ export function SignalPriorityPopover({
236
+ score,
237
+ urgencyLabel: providedLabel,
238
+ urgencyExplanation,
239
+ factors,
240
+ metaText,
241
+ feedbackChips,
242
+ onFeedbackSubmit,
243
+ className,
244
+ }: SignalPriorityPopoverProps) {
245
+ const urgencyLabel = getSignalScoreUrgencyLabel(score, providedLabel)
246
+ const scoreRange = scoreRangeForUrgency(urgencyLabel)
247
+
248
+ const [open, setOpen] = React.useState(false)
249
+ const [feedback, setFeedback] = React.useState<"positive" | "negative" | null>(null)
250
+
251
+ const triggerDefault = URGENCY_TRIGGER_DEFAULT[urgencyLabel]
252
+ const triggerHover = URGENCY_TRIGGER_HOVER[urgencyLabel]
253
+ const triggerOpen = URGENCY_TRIGGER_OPEN[urgencyLabel]
254
+
255
+ return (
256
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
257
+ <PopoverPrimitive.Trigger asChild>
258
+ <button
259
+ type="button"
260
+ className={cn(
261
+ "inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
262
+ triggerDefault,
263
+ triggerHover,
264
+ open && triggerOpen,
265
+ open && "outline-2 outline-foreground outline-offset-2",
266
+ className,
267
+ )}
268
+ data-testid="priority-popover-trigger"
269
+ >
270
+ {urgencyLabel} Priority
271
+ {open ? (
272
+ <ChevronUp className="h-3 w-3" />
273
+ ) : (
274
+ <ChevronDown className="h-3 w-3" />
275
+ )}
276
+ </button>
277
+ </PopoverPrimitive.Trigger>
278
+
279
+ <PopoverPrimitive.Portal>
280
+ <PopoverPrimitive.Content
281
+ side="bottom"
282
+ align="start"
283
+ sideOffset={8}
284
+ onOpenAutoFocus={(e) => e.preventDefault()}
285
+ className="z-50 w-[420px] rounded-lg border border-border bg-background shadow-lg p-0"
286
+ data-testid="priority-popover-content"
287
+ >
288
+ {/* Head section */}
289
+ <div className="p-4 pb-3">
290
+ <div className="flex items-start justify-between gap-3">
291
+ <p className="text-sm font-semibold text-foreground">
292
+ Why this is {urgencyLabel.toLowerCase()} priority
293
+ </p>
294
+ <span className="text-2xl font-bold tabular-nums text-foreground">
295
+ {score}
296
+ <span className="text-sm font-normal text-muted-foreground">/100</span>
297
+ </span>
298
+ </div>
299
+
300
+ {/* Band indicator */}
301
+ <div className="mt-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
302
+ <span
303
+ className={cn(
304
+ "inline-block h-2 w-2 rounded-full",
305
+ URGENCY_DOT_CLASSES[urgencyLabel],
306
+ )}
307
+ />
308
+ {urgencyLabel} range: {scoreRange}
309
+ </div>
310
+
311
+ {/* Synthesis sentence */}
312
+ {urgencyExplanation && (
313
+ <p className="mt-2 text-xs leading-relaxed text-muted-foreground">
314
+ {urgencyExplanation}
315
+ </p>
316
+ )}
317
+ </div>
318
+
319
+ {/* Section label */}
320
+ {factors.length > 0 && (
321
+ <>
322
+ <div className="flex items-center justify-between border-t border-border px-4 py-2">
323
+ <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
324
+ Contributing factors
325
+ </span>
326
+ <span className="flex items-center gap-1 text-[10px] text-muted-foreground">
327
+ <Info className="h-3 w-3" />
328
+ Score = weighted sum
329
+ </span>
330
+ </div>
331
+
332
+ {/* Factor rows */}
333
+ <div className="divide-y divide-border/40">
334
+ {factors.map((factor) => (
335
+ <PriorityFactorRow key={factor.key} factor={factor} />
336
+ ))}
337
+ </div>
338
+ </>
339
+ )}
340
+
341
+ {/* Feedback footer */}
342
+ {onFeedbackSubmit && (
343
+ <div className="border-t border-border">
344
+ <FeedbackFooter
345
+ feedback={feedback}
346
+ onFeedbackChange={setFeedback}
347
+ onSubmit={onFeedbackSubmit}
348
+ metaText={metaText}
349
+ negativeChips={feedbackChips ?? DEFAULT_PRIORITY_FEEDBACK_CHIPS}
350
+ positivePrompt="Thanks. Anything to keep about this score?"
351
+ className="px-4 py-3"
352
+ />
353
+ </div>
354
+ )}
355
+ </PopoverPrimitive.Content>
356
+ </PopoverPrimitive.Portal>
357
+ </PopoverPrimitive.Root>
358
+ )
359
+ }
@@ -0,0 +1,96 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { Avatar, AvatarFallback, AvatarImage } from "./avatar"
6
+ import { cn } from "../lib/utils"
7
+ import { displayName, getInitials, type ProfileLike } from "../lib/user-display"
8
+
9
+ export interface UserPillProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
10
+ profile?: ProfileLike | null
11
+ name?: string | null
12
+ email?: string | null
13
+ avatarUrl?: string | null
14
+ subtitle?: React.ReactNode
15
+ variant?: "default" | "compact" | "large"
16
+ }
17
+
18
+ function UserPill({
19
+ profile,
20
+ name,
21
+ email,
22
+ avatarUrl,
23
+ subtitle,
24
+ variant = "default",
25
+ className,
26
+ ...props
27
+ }: UserPillProps) {
28
+ const userProfile: ProfileLike = {
29
+ ...profile,
30
+ name: name ?? profile?.name,
31
+ email: email ?? profile?.email,
32
+ avatar_url: avatarUrl ?? profile?.avatar_url,
33
+ }
34
+ const resolvedName = displayName(userProfile)
35
+ const resolvedAvatarUrl = avatarUrl ?? profile?.avatar_url
36
+ const avatarSize = variant === "large" ? "default" : "sm"
37
+
38
+ return (
39
+ <div
40
+ data-slot="user-pill"
41
+ data-variant={variant}
42
+ className={cn(
43
+ "inline-flex max-w-full items-center gap-2 rounded-full border border-border bg-background text-sm text-foreground shadow-xs",
44
+ variant === "compact" && "px-2 py-0.5",
45
+ variant === "default" && "px-2.5 py-1",
46
+ variant === "large" && "px-3 py-1.5",
47
+ className
48
+ )}
49
+ title={resolvedName}
50
+ {...props}
51
+ >
52
+ <Avatar size={avatarSize} aria-hidden="true">
53
+ {resolvedAvatarUrl ? <AvatarImage src={resolvedAvatarUrl} alt="" /> : null}
54
+ <AvatarFallback>{getInitials(userProfile)}</AvatarFallback>
55
+ </Avatar>
56
+ <span className="flex min-w-0 flex-col leading-tight">
57
+ <span className="min-w-0 truncate font-medium">{resolvedName}</span>
58
+ {subtitle ? <span className="min-w-0 truncate text-xs text-muted-foreground">{subtitle}</span> : null}
59
+ </span>
60
+ </div>
61
+ )
62
+ }
63
+
64
+ export interface ActorBylineProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
65
+ actor?: ProfileLike | null
66
+ verb?: React.ReactNode
67
+ subject?: React.ReactNode
68
+ timestamp?: string | Date | null
69
+ className?: string
70
+ }
71
+
72
+ function ActorByline({
73
+ actor,
74
+ verb,
75
+ subject,
76
+ timestamp,
77
+ className,
78
+ ...props
79
+ }: ActorBylineProps) {
80
+ const actorName = displayName(actor)
81
+ const renderedTimestamp = timestamp instanceof Date ? timestamp.toISOString() : timestamp
82
+ return (
83
+ <div
84
+ data-slot="actor-byline"
85
+ className={cn("text-sm text-muted-foreground", className)}
86
+ {...props}
87
+ >
88
+ <span className="text-foreground">{actorName}</span>
89
+ {verb ? <> {verb}</> : null}
90
+ {subject ? <> {subject}</> : null}
91
+ {renderedTimestamp ? <> <span aria-hidden="true">·</span> {renderedTimestamp}</> : null}
92
+ </div>
93
+ )
94
+ }
95
+
96
+ export { ActorByline, UserPill }
@@ -0,0 +1 @@
1
+ export { UserPill, type UserPillProps } from "./user-display"
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // Utilities
7
7
  export { cn } from "./lib/utils"
8
8
  export { BRAND_ICONS, BRAND_GRAPHICS } from "./lib/icons"
9
+ export { displayName, getInitials, shortName, type ProfileLike } from "./lib/user-display"
9
10
 
10
11
  // Hooks
11
12
  export { useIsMobile } from "./hooks/use-mobile"
@@ -32,16 +33,23 @@ export * from "./components/data-table-filter"
32
33
  export * from "./components/data-table-quick-views"
33
34
  export * from "./components/data-table-toolbar"
34
35
  export * from "./components/detail-view"
36
+ export * from "./components/detail-drawer"
35
37
  export * from "./components/dialog"
36
38
  export * from "./components/dropdown-menu"
37
39
  export * from "./components/empty-state"
38
40
  export * from "./components/entity-panel"
41
+ export { FeedbackFooter, FeedbackChipGroup, FeedbackInput, FeedbackActions } from "./components/feedback-primitives"
42
+ export type { FeedbackFooterProps, FeedbackChipTree, FeedbackChipGroupProps, FeedbackInputProps, FeedbackActionsProps, FeedbackSubmitData } from "./components/feedback-primitives"
43
+ export { SignalPriorityPopover } from "./components/signal-priority-popover"
44
+ export type { SignalPriorityPopoverProps, PriorityFactor } from "./components/signal-priority-popover"
39
45
  export * from "./components/filter-chip"
40
46
  export * from "./components/inbox-row"
41
47
  export * from "./components/inbox-toolbar"
42
48
  export * from "./components/inline-banner"
43
49
  export * from "./components/input"
44
50
  export * from "./components/insights-filter-bar"
51
+ export * from "./components/days-open-cell"
52
+ export * from "./components/linked-entity-cell"
45
53
  export * from "./components/item-list"
46
54
  export * from "./components/item-list-display"
47
55
  export * from "./components/item-list-filter"
@@ -51,9 +59,11 @@ export * from "./components/label"
51
59
  export * from "./components/message"
52
60
  export * from "./components/metric-card"
53
61
  export * from "./components/performance-metrics-table"
62
+ export * from "./components/pill"
54
63
  export * from "./components/preview-list"
55
64
  export * from "./components/progress"
56
65
  export * from "./components/quick-action-chat-area"
66
+ export * from "./components/quick-segment"
57
67
  export {
58
68
  QuickActionModal,
59
69
  type QuickActionPriority,
@@ -92,6 +102,7 @@ export * from "./components/tabs"
92
102
  export * from "./components/textarea"
93
103
  export * from "./components/timeline-activity"
94
104
  export * from "./components/tooltip"
105
+ export * from "./components/user-display"
95
106
  export * from "./components/variable-autocomplete"
96
107
  export * from "./components/view-mode-toggle"
97
108
  export * from "./components/virtualized-data-table"
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { displayName, getInitials, shortName } from "../user-display"
4
+
5
+ describe("displayName", () => {
6
+ it("returns first_name + last_name when both are present", () => {
7
+ expect(
8
+ displayName({ first_name: "Sarah", last_name: "Mitchell", name: "S Mitchell", email: "sarah@example.com" })
9
+ ).toBe("Sarah Mitchell")
10
+ })
11
+
12
+ it("returns first_name alone when last_name is missing", () => {
13
+ expect(displayName({ first_name: "Sarah", last_name: null, name: null, email: "sarah@example.com" })).toBe(
14
+ "Sarah"
15
+ )
16
+ })
17
+
18
+ it("falls back to name when first_name is missing", () => {
19
+ expect(displayName({ first_name: null, last_name: null, name: "Sarah Mitchell", email: "sarah@example.com" })).toBe(
20
+ "Sarah Mitchell"
21
+ )
22
+ })
23
+
24
+ it("falls back to email local-part when name fields are missing or empty", () => {
25
+ expect(displayName({ first_name: "", last_name: "", name: "", email: "sarah@example.com" })).toBe("sarah")
26
+ })
27
+
28
+ it("falls back to Unknown user when profile fields are absent", () => {
29
+ expect(displayName({})).toBe("Unknown user")
30
+ expect(displayName()).toBe("Unknown user")
31
+ })
32
+
33
+ it("ignores last_name if first_name is missing", () => {
34
+ expect(displayName({ first_name: null, last_name: "Mitchell", name: "Old Name", email: "sarah@example.com" })).toBe(
35
+ "Old Name"
36
+ )
37
+ })
38
+ })
39
+
40
+ describe("getInitials", () => {
41
+ it("returns uppercased initials from first_name and last_name", () => {
42
+ expect(getInitials({ first_name: "joe", last_name: "kim", email: "joe@example.com" })).toBe("JK")
43
+ })
44
+
45
+ it("splits name into two parts for initials when first/last are not set", () => {
46
+ expect(getInitials({ first_name: null, last_name: null, name: "Sarah Mitchell", email: "sarah@example.com" })).toBe(
47
+ "SM"
48
+ )
49
+ })
50
+
51
+ it("uses first two characters of a single-word name", () => {
52
+ expect(getInitials({ first_name: null, last_name: null, name: "Sarah", email: "sarah@example.com" })).toBe("SA")
53
+ })
54
+
55
+ it("falls back to first two email local characters when no name fields are set", () => {
56
+ expect(getInitials({ first_name: null, last_name: null, name: null, email: "zz@example.com" })).toBe("ZZ")
57
+ })
58
+
59
+ it("returns question mark when no initials can be derived", () => {
60
+ expect(getInitials({})).toBe("?")
61
+ expect(getInitials()).toBe("?")
62
+ })
63
+
64
+ it("handles multi-word name taking the first two initials", () => {
65
+ expect(getInitials({ name: "Mary Jane Watson", email: "mj@example.com" })).toBe("MJ")
66
+ })
67
+ })
68
+
69
+ describe("shortName", () => {
70
+ it("returns 'First L.' when first and last are present", () => {
71
+ expect(shortName({ first_name: "Sarah", last_name: "Mitchell", email: "sarah@example.com" })).toBe("Sarah M.")
72
+ })
73
+
74
+ it("falls back to displayName when last_name is unavailable", () => {
75
+ expect(shortName({ first_name: "Sarah", last_name: null, name: null, email: "sarah@example.com" })).toBe("Sarah")
76
+ })
77
+
78
+ it("falls back to email local-part when all names are missing", () => {
79
+ expect(shortName({ first_name: null, last_name: null, name: null, email: "sarah@example.com" })).toBe("sarah")
80
+ })
81
+
82
+ it("falls back to Unknown user when no profile fields are present", () => {
83
+ expect(shortName({})).toBe("Unknown user")
84
+ })
85
+ })
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Unified display name and initials utilities for user profiles.
3
+ *
4
+ * Keep this behavior aligned with handled-platform's display-name utility so
5
+ * user labels and avatar fallbacks render consistently across products.
6
+ */
7
+ export interface ProfileLike {
8
+ first_name?: string | null
9
+ last_name?: string | null
10
+ name?: string | null
11
+ email?: string | null
12
+ avatar_url?: string | null
13
+ role?: string | null
14
+ }
15
+
16
+ function clean(value: string | null | undefined): string | undefined {
17
+ const trimmed = value?.trim()
18
+ return trimmed ? trimmed : undefined
19
+ }
20
+
21
+ function emailLocalPart(email: string | null | undefined): string | undefined {
22
+ const normalizedEmail = clean(email)
23
+ return normalizedEmail?.split("@")[0] || undefined
24
+ }
25
+
26
+ /**
27
+ * Returns the best display name for a profile.
28
+ * Priority: first_name + last_name > first_name > name > email local-part > Unknown user.
29
+ */
30
+ export function displayName(profile?: ProfileLike | null): string {
31
+ const firstName = clean(profile?.first_name)
32
+ const lastName = clean(profile?.last_name)
33
+
34
+ if (firstName && lastName) {
35
+ return `${firstName} ${lastName}`
36
+ }
37
+ if (firstName) return firstName
38
+
39
+ const fullName = clean(profile?.name)
40
+ if (fullName) return fullName
41
+
42
+ return emailLocalPart(profile?.email) ?? "Unknown user"
43
+ }
44
+
45
+ /**
46
+ * Returns up to 2-character initials for avatar display.
47
+ * Priority: first/last initials > split name > first two email local chars > ?.
48
+ */
49
+ export function getInitials(profile?: ProfileLike | null): string {
50
+ const firstName = clean(profile?.first_name)
51
+ const lastName = clean(profile?.last_name)
52
+
53
+ if (firstName && lastName) {
54
+ return (firstName[0] + lastName[0]).toUpperCase()
55
+ }
56
+
57
+ const fullName = clean(profile?.name) ?? firstName
58
+ if (fullName) {
59
+ const parts = fullName.split(/\s+/).filter(Boolean)
60
+ if (parts.length >= 2) {
61
+ return (parts[0][0] + parts[1][0]).toUpperCase()
62
+ }
63
+ if (parts.length === 1 && parts[0].length > 0) {
64
+ return parts[0].slice(0, 2).toUpperCase()
65
+ }
66
+ }
67
+
68
+ const localPart = emailLocalPart(profile?.email)
69
+ if (localPart) {
70
+ return localPart.slice(0, 2).toUpperCase()
71
+ }
72
+
73
+ return "?"
74
+ }
75
+
76
+ /**
77
+ * Returns "First L." format for compact display.
78
+ * Falls back to displayName() if last name is unavailable.
79
+ */
80
+ export function shortName(profile?: ProfileLike | null): string {
81
+ const firstName = clean(profile?.first_name)
82
+ const lastName = clean(profile?.last_name)
83
+
84
+ if (firstName && lastName) {
85
+ return `${firstName} ${lastName[0]}.`
86
+ }
87
+ return displayName(profile)
88
+ }