@handled-ai/design-system 0.18.22 → 0.18.24
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/dist/components/case-panel-activity-timeline.d.ts +100 -0
- package/dist/components/case-panel-activity-timeline.js +270 -0
- package/dist/components/case-panel-activity-timeline.js.map +1 -0
- package/dist/components/case-panel-detail.d.ts +60 -0
- package/dist/components/case-panel-detail.js +129 -0
- package/dist/components/case-panel-detail.js.map +1 -0
- package/dist/components/case-panel-email-composer.d.ts +61 -0
- package/dist/components/case-panel-email-composer.js +304 -0
- package/dist/components/case-panel-email-composer.js.map +1 -0
- package/dist/components/case-panel-why.d.ts +35 -0
- package/dist/components/case-panel-why.js +149 -0
- package/dist/components/case-panel-why.js.map +1 -0
- package/dist/components/contextual-quick-action-launcher.d.ts +7 -3
- package/dist/components/contextual-quick-action-launcher.js +99 -27
- package/dist/components/contextual-quick-action-launcher.js.map +1 -1
- package/dist/components/pill.d.ts +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/case-panel-activity-timeline.test.tsx +152 -0
- package/src/components/__tests__/case-panel-detail.test.tsx +138 -0
- package/src/components/__tests__/case-panel-email-composer.test.tsx +171 -0
- package/src/components/__tests__/case-panel-why.test.tsx +152 -0
- package/src/components/__tests__/contextual-quick-action-launcher.test.tsx +90 -0
- package/src/components/case-panel-activity-timeline.tsx +414 -0
- package/src/components/case-panel-detail.tsx +228 -0
- package/src/components/case-panel-email-composer.tsx +341 -0
- package/src/components/case-panel-why.tsx +214 -0
- package/src/components/contextual-quick-action-launcher.tsx +92 -15
- package/src/index.ts +4 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { ChevronDown, ExternalLink } from "lucide-react"
|
|
5
|
+
import { cn } from "../lib/utils"
|
|
6
|
+
import { TONE_CLASSES, type TimelineEventTone } from "./timeline-activity"
|
|
7
|
+
|
|
8
|
+
export type CasePanelActivityTone = TimelineEventTone
|
|
9
|
+
|
|
10
|
+
export type CasePanelActivityActor =
|
|
11
|
+
| { kind: "system" }
|
|
12
|
+
| { kind: "integration"; name: string; iconUrl?: string }
|
|
13
|
+
| { kind: "user"; name: string; avatarUrl?: string; verb?: string }
|
|
14
|
+
|
|
15
|
+
export type CasePanelPayloadAction = {
|
|
16
|
+
kind: "openSignal"
|
|
17
|
+
key: string
|
|
18
|
+
eventId: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type CasePanelActivityPayload =
|
|
22
|
+
| {
|
|
23
|
+
kind: "signal"
|
|
24
|
+
key: string
|
|
25
|
+
summary: string
|
|
26
|
+
detail?: string
|
|
27
|
+
actionLabel?: string
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
kind: "scoreUpdate"
|
|
31
|
+
label?: string
|
|
32
|
+
previousScore?: number
|
|
33
|
+
nextScore: number
|
|
34
|
+
reason?: string
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
kind: "recommendation"
|
|
38
|
+
recommendation: string
|
|
39
|
+
rationale?: string
|
|
40
|
+
actionLabel?: string
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
kind: "email"
|
|
44
|
+
from: string
|
|
45
|
+
to?: string
|
|
46
|
+
subject: string
|
|
47
|
+
preview?: string
|
|
48
|
+
body?: string
|
|
49
|
+
}
|
|
50
|
+
| {
|
|
51
|
+
kind: "salesforce"
|
|
52
|
+
objectLabel: string
|
|
53
|
+
recordLabel?: string
|
|
54
|
+
changeSummary: string
|
|
55
|
+
deepLink?: {
|
|
56
|
+
href: string
|
|
57
|
+
label?: string
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
| {
|
|
61
|
+
kind: "deadline"
|
|
62
|
+
dueLabel: string
|
|
63
|
+
status?: "upcoming" | "due" | "overdue" | "met"
|
|
64
|
+
description?: string
|
|
65
|
+
}
|
|
66
|
+
| {
|
|
67
|
+
kind: "operatorNote"
|
|
68
|
+
note: string
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
kind: "assignment"
|
|
72
|
+
assignee: string
|
|
73
|
+
from?: string
|
|
74
|
+
role?: string
|
|
75
|
+
}
|
|
76
|
+
| {
|
|
77
|
+
kind: "caseOpened"
|
|
78
|
+
source?: string
|
|
79
|
+
openedBy?: string
|
|
80
|
+
description?: string
|
|
81
|
+
}
|
|
82
|
+
| {
|
|
83
|
+
kind: "generic"
|
|
84
|
+
description: string
|
|
85
|
+
metadata?: Array<{ label: string; value: string }>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface CasePanelActivityEvent {
|
|
89
|
+
id: string
|
|
90
|
+
title: string
|
|
91
|
+
timeLabel: string
|
|
92
|
+
tone: CasePanelActivityTone
|
|
93
|
+
actor?: CasePanelActivityActor
|
|
94
|
+
isSystemNoise?: boolean
|
|
95
|
+
payload: CasePanelActivityPayload
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface CasePanelActivityTimelineProps {
|
|
99
|
+
events: CasePanelActivityEvent[]
|
|
100
|
+
className?: string
|
|
101
|
+
title?: string
|
|
102
|
+
defaultExpanded?: boolean
|
|
103
|
+
defaultShowSystemEvents?: boolean
|
|
104
|
+
onPayloadAction?: (action: CasePanelPayloadAction) => void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const NEUTRAL_DOT_CLASSES = "bg-background border-border/60"
|
|
108
|
+
const NEUTRAL_ICON_CLASSES = "text-muted-foreground"
|
|
109
|
+
|
|
110
|
+
const PAYLOAD_LABELS: Record<CasePanelActivityPayload["kind"], string> = {
|
|
111
|
+
signal: "Signal",
|
|
112
|
+
scoreUpdate: "Score update",
|
|
113
|
+
recommendation: "Recommendation",
|
|
114
|
+
email: "Email",
|
|
115
|
+
salesforce: "Salesforce",
|
|
116
|
+
deadline: "Deadline",
|
|
117
|
+
operatorNote: "Operator note",
|
|
118
|
+
assignment: "Assignment",
|
|
119
|
+
caseOpened: "Case opened",
|
|
120
|
+
generic: "Update",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const STATUS_CLASSES: Record<NonNullable<Extract<CasePanelActivityPayload, { kind: "deadline" }>["status"]>, string> = {
|
|
124
|
+
upcoming: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/40 dark:bg-blue-950/30 dark:text-blue-300",
|
|
125
|
+
due: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-300",
|
|
126
|
+
overdue: "border-red-200 bg-red-50 text-red-700 dark:border-red-900/40 dark:bg-red-950/30 dark:text-red-300",
|
|
127
|
+
met: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-300",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function CasePanelActivityTimeline({
|
|
131
|
+
events,
|
|
132
|
+
className,
|
|
133
|
+
title = "Activity",
|
|
134
|
+
defaultExpanded = false,
|
|
135
|
+
defaultShowSystemEvents = false,
|
|
136
|
+
onPayloadAction,
|
|
137
|
+
}: CasePanelActivityTimelineProps) {
|
|
138
|
+
const [expanded, setExpanded] = React.useState(defaultExpanded)
|
|
139
|
+
const [showSystemEvents, setShowSystemEvents] = React.useState(defaultShowSystemEvents)
|
|
140
|
+
|
|
141
|
+
const nonSystemEvents = events.filter((event) => !event.isSystemNoise)
|
|
142
|
+
const visibleEvents = showSystemEvents ? events : nonSystemEvents
|
|
143
|
+
const systemNoiseCount = events.length - nonSystemEvents.length
|
|
144
|
+
const lastActivityText = nonSystemEvents[0]?.timeLabel ?? events[0]?.timeLabel ?? "No activity yet"
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<section className={cn("rounded-xl border border-border bg-card text-card-foreground shadow-sm", className)}>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/30"
|
|
151
|
+
aria-expanded={expanded}
|
|
152
|
+
onClick={() => setExpanded((current) => !current)}
|
|
153
|
+
>
|
|
154
|
+
<span className="min-w-0">
|
|
155
|
+
<span className="flex items-center gap-2">
|
|
156
|
+
<span className="text-sm font-semibold text-foreground">{title}</span>
|
|
157
|
+
<span className="rounded-full border border-border bg-muted/40 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
|
158
|
+
{nonSystemEvents.length} {nonSystemEvents.length === 1 ? "event" : "events"}
|
|
159
|
+
</span>
|
|
160
|
+
</span>
|
|
161
|
+
<span className="mt-1 block truncate text-xs text-muted-foreground">Last activity {lastActivityText}</span>
|
|
162
|
+
</span>
|
|
163
|
+
<ChevronDown className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-transform", expanded && "rotate-180")} />
|
|
164
|
+
</button>
|
|
165
|
+
|
|
166
|
+
{expanded ? (
|
|
167
|
+
<div className="border-t border-border px-4 py-4">
|
|
168
|
+
{systemNoiseCount > 0 ? (
|
|
169
|
+
<label className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
|
170
|
+
<span>Show system events</span>
|
|
171
|
+
<input
|
|
172
|
+
type="checkbox"
|
|
173
|
+
className="h-4 w-4 rounded border-border text-primary accent-current"
|
|
174
|
+
checked={showSystemEvents}
|
|
175
|
+
onChange={(event) => setShowSystemEvents(event.target.checked)}
|
|
176
|
+
/>
|
|
177
|
+
</label>
|
|
178
|
+
) : null}
|
|
179
|
+
|
|
180
|
+
{visibleEvents.length > 0 ? (
|
|
181
|
+
<div className="space-y-0">
|
|
182
|
+
{visibleEvents.map((event, index) => (
|
|
183
|
+
<CasePanelActivityTimelineItem
|
|
184
|
+
key={event.id}
|
|
185
|
+
event={event}
|
|
186
|
+
isLast={index === visibleEvents.length - 1}
|
|
187
|
+
onPayloadAction={onPayloadAction}
|
|
188
|
+
/>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
) : (
|
|
192
|
+
<p className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
|
|
193
|
+
No activity to show.
|
|
194
|
+
</p>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
) : null}
|
|
198
|
+
</section>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function CasePanelActivityTimelineItem({
|
|
203
|
+
event,
|
|
204
|
+
isLast,
|
|
205
|
+
onPayloadAction,
|
|
206
|
+
}: {
|
|
207
|
+
event: CasePanelActivityEvent
|
|
208
|
+
isLast: boolean
|
|
209
|
+
onPayloadAction?: (action: CasePanelPayloadAction) => void
|
|
210
|
+
}) {
|
|
211
|
+
const toneStyle = TONE_CLASSES[event.tone]
|
|
212
|
+
const dotClasses = toneStyle ? toneStyle.dot : NEUTRAL_DOT_CLASSES
|
|
213
|
+
const iconClasses = toneStyle ? toneStyle.icon : NEUTRAL_ICON_CLASSES
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<article className="group relative flex gap-3.5" data-testid="case-panel-activity-event">
|
|
217
|
+
{!isLast ? <div className="absolute bottom-[-6px] left-[9px] top-5 w-px bg-border/60" /> : null}
|
|
218
|
+
<div className="relative z-10 mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-card">
|
|
219
|
+
<span
|
|
220
|
+
aria-hidden="true"
|
|
221
|
+
className={cn("flex h-4.5 w-4.5 items-center justify-center rounded-full border ring-4 ring-card", dotClasses, iconClasses)}
|
|
222
|
+
data-testid="case-panel-activity-dot"
|
|
223
|
+
>
|
|
224
|
+
<span className="h-1.5 w-1.5 rounded-full bg-current" />
|
|
225
|
+
</span>
|
|
226
|
+
</div>
|
|
227
|
+
<div className="min-w-0 flex-1 pb-5 pt-0.5">
|
|
228
|
+
<div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
|
229
|
+
<h3 className="pr-4 text-[13px] font-medium leading-relaxed text-foreground">{event.title}</h3>
|
|
230
|
+
<time className="mt-0.5 shrink-0 whitespace-nowrap text-[11px] text-muted-foreground/70">{event.timeLabel}</time>
|
|
231
|
+
</div>
|
|
232
|
+
<ActorByline actor={event.actor} timeLabel={event.timeLabel} />
|
|
233
|
+
<PayloadCard event={event} onPayloadAction={onPayloadAction} />
|
|
234
|
+
</div>
|
|
235
|
+
</article>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function ActorByline({ actor, timeLabel }: { actor?: CasePanelActivityActor; timeLabel: string }) {
|
|
240
|
+
if (!actor || actor.kind === "system") return null
|
|
241
|
+
|
|
242
|
+
if (actor.kind === "integration") {
|
|
243
|
+
return (
|
|
244
|
+
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground" data-testid="case-panel-activity-byline">
|
|
245
|
+
{actor.iconUrl ? <img src={actor.iconUrl} alt="" className="h-4 w-4 rounded-sm object-cover" /> : null}
|
|
246
|
+
<span className="font-medium text-foreground">{actor.name}</span>
|
|
247
|
+
<span>synced this update</span>
|
|
248
|
+
<span className="text-muted-foreground/40">·</span>
|
|
249
|
+
<span>{timeLabel}</span>
|
|
250
|
+
</div>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const verb = actor.verb ?? "updated this case"
|
|
255
|
+
const initial = actor.name.charAt(0).toUpperCase()
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground" data-testid="case-panel-activity-byline">
|
|
259
|
+
{actor.avatarUrl ? (
|
|
260
|
+
<img src={actor.avatarUrl} alt={actor.name} className="h-4 w-4 rounded-full object-cover" />
|
|
261
|
+
) : (
|
|
262
|
+
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-muted-foreground/10 text-[8px] font-semibold text-muted-foreground">
|
|
263
|
+
{initial}
|
|
264
|
+
</span>
|
|
265
|
+
)}
|
|
266
|
+
<span className="font-medium text-foreground">{actor.name}</span>
|
|
267
|
+
<span>{verb}</span>
|
|
268
|
+
<span className="text-muted-foreground/40">·</span>
|
|
269
|
+
<span>{timeLabel}</span>
|
|
270
|
+
</div>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function PayloadCard({
|
|
275
|
+
event,
|
|
276
|
+
onPayloadAction,
|
|
277
|
+
}: {
|
|
278
|
+
event: CasePanelActivityEvent
|
|
279
|
+
onPayloadAction?: (action: CasePanelPayloadAction) => void
|
|
280
|
+
}) {
|
|
281
|
+
const payload = event.payload
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<div className="mt-2 rounded-lg border border-border/80 bg-muted/20 px-3 py-2.5 text-sm" data-testid={`case-panel-payload-${payload.kind}`}>
|
|
285
|
+
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
286
|
+
{PAYLOAD_LABELS[payload.kind]}
|
|
287
|
+
</div>
|
|
288
|
+
{renderPayloadContent(payload, event.id, onPayloadAction)}
|
|
289
|
+
</div>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function renderPayloadContent(
|
|
294
|
+
payload: CasePanelActivityPayload,
|
|
295
|
+
eventId: string,
|
|
296
|
+
onPayloadAction?: (action: CasePanelPayloadAction) => void
|
|
297
|
+
) {
|
|
298
|
+
switch (payload.kind) {
|
|
299
|
+
case "signal":
|
|
300
|
+
return (
|
|
301
|
+
<div className="space-y-2">
|
|
302
|
+
<p className="text-foreground">{payload.summary}</p>
|
|
303
|
+
{payload.detail ? <p className="text-xs leading-relaxed text-muted-foreground">{payload.detail}</p> : null}
|
|
304
|
+
{onPayloadAction ? (
|
|
305
|
+
<button
|
|
306
|
+
type="button"
|
|
307
|
+
className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
|
|
308
|
+
onClick={() => onPayloadAction({ kind: "openSignal", key: payload.key, eventId })}
|
|
309
|
+
>
|
|
310
|
+
{payload.actionLabel ?? "Open signal"}
|
|
311
|
+
</button>
|
|
312
|
+
) : null}
|
|
313
|
+
</div>
|
|
314
|
+
)
|
|
315
|
+
case "scoreUpdate":
|
|
316
|
+
return (
|
|
317
|
+
<div className="space-y-1">
|
|
318
|
+
<p className="text-foreground">
|
|
319
|
+
{payload.label ?? "Score"}: {payload.previousScore !== undefined ? `${payload.previousScore} → ` : ""}{payload.nextScore}
|
|
320
|
+
</p>
|
|
321
|
+
{payload.reason ? <p className="text-xs leading-relaxed text-muted-foreground">{payload.reason}</p> : null}
|
|
322
|
+
</div>
|
|
323
|
+
)
|
|
324
|
+
case "recommendation":
|
|
325
|
+
return (
|
|
326
|
+
<div className="space-y-1">
|
|
327
|
+
<p className="font-medium text-foreground">{payload.recommendation}</p>
|
|
328
|
+
{payload.rationale ? <p className="text-xs leading-relaxed text-muted-foreground">{payload.rationale}</p> : null}
|
|
329
|
+
{payload.actionLabel ? <p className="text-xs font-medium text-muted-foreground">{payload.actionLabel}</p> : null}
|
|
330
|
+
</div>
|
|
331
|
+
)
|
|
332
|
+
case "email":
|
|
333
|
+
return (
|
|
334
|
+
<div className="space-y-1">
|
|
335
|
+
<p className="font-medium text-foreground">{payload.subject}</p>
|
|
336
|
+
<p className="text-xs text-muted-foreground">
|
|
337
|
+
From {payload.from}{payload.to ? ` to ${payload.to}` : ""}
|
|
338
|
+
</p>
|
|
339
|
+
{payload.preview ? <p className="text-sm text-foreground/90">{payload.preview}</p> : null}
|
|
340
|
+
{payload.body ? <p className="whitespace-pre-line text-xs leading-relaxed text-muted-foreground">{payload.body}</p> : null}
|
|
341
|
+
</div>
|
|
342
|
+
)
|
|
343
|
+
case "salesforce":
|
|
344
|
+
return (
|
|
345
|
+
<div className="space-y-2">
|
|
346
|
+
<p className="text-foreground">
|
|
347
|
+
<span className="font-medium">{payload.objectLabel}</span>
|
|
348
|
+
{payload.recordLabel ? <span className="text-muted-foreground"> · {payload.recordLabel}</span> : null}
|
|
349
|
+
</p>
|
|
350
|
+
<p className="text-xs leading-relaxed text-muted-foreground">{payload.changeSummary}</p>
|
|
351
|
+
{payload.deepLink ? (
|
|
352
|
+
<a
|
|
353
|
+
href={payload.deepLink.href}
|
|
354
|
+
target="_blank"
|
|
355
|
+
rel="noreferrer noopener"
|
|
356
|
+
className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
|
|
357
|
+
>
|
|
358
|
+
{payload.deepLink.label ?? "Open in Salesforce"}
|
|
359
|
+
<ExternalLink className="h-3 w-3" />
|
|
360
|
+
</a>
|
|
361
|
+
) : null}
|
|
362
|
+
</div>
|
|
363
|
+
)
|
|
364
|
+
case "deadline":
|
|
365
|
+
return (
|
|
366
|
+
<div className="space-y-2">
|
|
367
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
368
|
+
<span className="font-medium text-foreground">{payload.dueLabel}</span>
|
|
369
|
+
{payload.status ? (
|
|
370
|
+
<span className={cn("rounded-full border px-2 py-0.5 text-[11px] font-medium", STATUS_CLASSES[payload.status])}>
|
|
371
|
+
{payload.status}
|
|
372
|
+
</span>
|
|
373
|
+
) : null}
|
|
374
|
+
</div>
|
|
375
|
+
{payload.description ? <p className="text-xs leading-relaxed text-muted-foreground">{payload.description}</p> : null}
|
|
376
|
+
</div>
|
|
377
|
+
)
|
|
378
|
+
case "operatorNote":
|
|
379
|
+
return <p className="whitespace-pre-line text-sm leading-relaxed text-foreground">{payload.note}</p>
|
|
380
|
+
case "assignment":
|
|
381
|
+
return (
|
|
382
|
+
<p className="text-foreground">
|
|
383
|
+
Assigned to <span className="font-medium">{payload.assignee}</span>
|
|
384
|
+
{payload.role ? <span className="text-muted-foreground"> as {payload.role}</span> : null}
|
|
385
|
+
{payload.from ? <span className="text-muted-foreground"> from {payload.from}</span> : null}
|
|
386
|
+
</p>
|
|
387
|
+
)
|
|
388
|
+
case "caseOpened":
|
|
389
|
+
return (
|
|
390
|
+
<div className="space-y-1">
|
|
391
|
+
<p className="text-foreground">
|
|
392
|
+
Case opened{payload.openedBy ? ` by ${payload.openedBy}` : ""}{payload.source ? ` from ${payload.source}` : ""}
|
|
393
|
+
</p>
|
|
394
|
+
{payload.description ? <p className="text-xs leading-relaxed text-muted-foreground">{payload.description}</p> : null}
|
|
395
|
+
</div>
|
|
396
|
+
)
|
|
397
|
+
case "generic":
|
|
398
|
+
return (
|
|
399
|
+
<div className="space-y-2">
|
|
400
|
+
<p className="text-foreground">{payload.description}</p>
|
|
401
|
+
{payload.metadata && payload.metadata.length > 0 ? (
|
|
402
|
+
<dl className="grid gap-1 text-xs text-muted-foreground">
|
|
403
|
+
{payload.metadata.map((item) => (
|
|
404
|
+
<div key={`${item.label}-${item.value}`} className="flex gap-2">
|
|
405
|
+
<dt className="font-medium text-foreground">{item.label}</dt>
|
|
406
|
+
<dd>{item.value}</dd>
|
|
407
|
+
</div>
|
|
408
|
+
))}
|
|
409
|
+
</dl>
|
|
410
|
+
) : null}
|
|
411
|
+
</div>
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import {
|
|
5
|
+
ArrowUpRight,
|
|
6
|
+
Check,
|
|
7
|
+
Copy,
|
|
8
|
+
ExternalLink,
|
|
9
|
+
} from "lucide-react"
|
|
10
|
+
import { cn } from "../lib/utils"
|
|
11
|
+
|
|
12
|
+
export type CasePanelDetailWidth = "comfortable" | "modest" | "full"
|
|
13
|
+
|
|
14
|
+
export interface CasePanelDetailProps {
|
|
15
|
+
children: React.ReactNode
|
|
16
|
+
/** Reading measure for the detail content column. Defaults to the handoff's comfortable 880px measure. */
|
|
17
|
+
width?: CasePanelDetailWidth
|
|
18
|
+
/** Accessible label for the detail region. */
|
|
19
|
+
"aria-label"?: string
|
|
20
|
+
className?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const detailWidthClasses: Record<CasePanelDetailWidth, string> = {
|
|
24
|
+
comfortable: "mx-auto w-full max-w-[880px] px-10 pb-[120px] pt-8",
|
|
25
|
+
modest: "mx-auto w-full max-w-[720px] px-8 pb-[112px] pt-7",
|
|
26
|
+
full: "w-full px-8 pb-[120px] pt-8",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function CasePanelDetail({
|
|
30
|
+
children,
|
|
31
|
+
width = "comfortable",
|
|
32
|
+
"aria-label": ariaLabel = "Case detail",
|
|
33
|
+
className,
|
|
34
|
+
}: CasePanelDetailProps) {
|
|
35
|
+
return (
|
|
36
|
+
<section aria-label={ariaLabel} className={cn("min-w-0 bg-background", className)}>
|
|
37
|
+
<div className={detailWidthClasses[width]}>{children}</div>
|
|
38
|
+
</section>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CasePanelHeaderProps {
|
|
43
|
+
title: React.ReactNode
|
|
44
|
+
/** Optional action, usually a Quick action trigger. */
|
|
45
|
+
action?: React.ReactNode
|
|
46
|
+
children?: React.ReactNode
|
|
47
|
+
className?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function CasePanelHeader({
|
|
51
|
+
title,
|
|
52
|
+
action,
|
|
53
|
+
children,
|
|
54
|
+
className,
|
|
55
|
+
}: CasePanelHeaderProps) {
|
|
56
|
+
return (
|
|
57
|
+
<header className={cn("mb-7", className)}>
|
|
58
|
+
<div className="flex items-start justify-between gap-5">
|
|
59
|
+
<div className="min-w-0 flex-1">
|
|
60
|
+
<h1 className="m-0 text-[27px] font-bold leading-[1.18] tracking-[-0.02em] text-foreground [text-wrap:balance]">
|
|
61
|
+
{title}
|
|
62
|
+
</h1>
|
|
63
|
+
{children}
|
|
64
|
+
</div>
|
|
65
|
+
{action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
|
|
66
|
+
</div>
|
|
67
|
+
</header>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CasePanelIdentityLink {
|
|
72
|
+
id: string
|
|
73
|
+
label: string
|
|
74
|
+
href?: string
|
|
75
|
+
icon?: React.ReactNode
|
|
76
|
+
kind?: "icon" | "text"
|
|
77
|
+
disabled?: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface CasePanelIdentitySublineProps {
|
|
81
|
+
callsign: string
|
|
82
|
+
links?: CasePanelIdentityLink[]
|
|
83
|
+
onCopyCallsign?: (callsign: string) => void
|
|
84
|
+
copyLabel?: string
|
|
85
|
+
copiedLabel?: string
|
|
86
|
+
className?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function CasePanelIdentitySubline({
|
|
90
|
+
callsign,
|
|
91
|
+
links = [],
|
|
92
|
+
onCopyCallsign,
|
|
93
|
+
copyLabel = "Copy call sign",
|
|
94
|
+
copiedLabel = "Call sign copied",
|
|
95
|
+
className,
|
|
96
|
+
}: CasePanelIdentitySublineProps) {
|
|
97
|
+
const [copied, setCopied] = React.useState(false)
|
|
98
|
+
const normalizedCallsign = callsign.startsWith("@") ? callsign : `@${callsign}`
|
|
99
|
+
|
|
100
|
+
const handleCopy = React.useCallback(() => {
|
|
101
|
+
onCopyCallsign?.(normalizedCallsign)
|
|
102
|
+
setCopied(true)
|
|
103
|
+
window.setTimeout(() => setCopied(false), 1400)
|
|
104
|
+
}, [normalizedCallsign, onCopyCallsign])
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className={cn("mt-[9px] inline-flex flex-wrap items-center gap-[7px] text-[13px] text-muted-foreground", className)}>
|
|
108
|
+
<span className="font-mono font-medium tracking-[0.01em] text-gray-700">{normalizedCallsign}</span>
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
onClick={handleCopy}
|
|
112
|
+
aria-label={copied ? copiedLabel : copyLabel}
|
|
113
|
+
className="inline-flex h-[22px] w-[22px] items-center justify-center rounded-md text-gray-400 transition-colors hover:bg-accent hover:text-foreground"
|
|
114
|
+
>
|
|
115
|
+
{copied ? <Check className="h-[13px] w-[13px] text-emerald-700" aria-hidden="true" /> : <Copy className="h-[13px] w-[13px]" aria-hidden="true" />}
|
|
116
|
+
</button>
|
|
117
|
+
{links.length > 0 ? (
|
|
118
|
+
<>
|
|
119
|
+
<span aria-hidden="true" className="mx-[3px] h-3.5 w-px bg-gray-200" />
|
|
120
|
+
<span className="inline-flex items-center gap-1.5">
|
|
121
|
+
{links.map((link) => (
|
|
122
|
+
<CasePanelIdentityLinkButton key={link.id} link={link} />
|
|
123
|
+
))}
|
|
124
|
+
</span>
|
|
125
|
+
</>
|
|
126
|
+
) : null}
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function CasePanelIdentityLinkButton({ link }: { link: CasePanelIdentityLink }) {
|
|
132
|
+
const disabled = link.disabled || !link.href
|
|
133
|
+
const iconOnly = link.kind === "icon"
|
|
134
|
+
const content = iconOnly ? (
|
|
135
|
+
<>{link.icon ?? <ExternalLink className="h-[13px] w-[13px]" aria-hidden="true" />}</>
|
|
136
|
+
) : (
|
|
137
|
+
<>
|
|
138
|
+
{link.icon ? <span className="inline-flex h-3.5 w-3.5 items-center justify-center">{link.icon}</span> : null}
|
|
139
|
+
<span>{link.label}</span>
|
|
140
|
+
<ArrowUpRight className="h-[11px] w-[11px] text-gray-400" aria-hidden="true" />
|
|
141
|
+
</>
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const className = iconOnly
|
|
145
|
+
? "inline-flex h-[26px] w-[26px] items-center justify-center rounded-[7px] border border-border bg-background text-foreground shadow-[0_1px_1.5px_rgba(0,0,0,0.03)] transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-45"
|
|
146
|
+
: "inline-flex h-[26px] items-center gap-1.5 rounded-[7px] border border-border bg-background px-2 text-xs font-medium text-foreground shadow-[0_1px_1.5px_rgba(0,0,0,0.03)] transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-45"
|
|
147
|
+
|
|
148
|
+
if (disabled) {
|
|
149
|
+
return (
|
|
150
|
+
<button type="button" disabled aria-label={link.label} className={className}>
|
|
151
|
+
{content}
|
|
152
|
+
</button>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<a href={link.href} aria-label={link.label} className={className}>
|
|
158
|
+
{content}
|
|
159
|
+
</a>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface CasePanelSignalBriefProps {
|
|
164
|
+
children: React.ReactNode
|
|
165
|
+
label?: string
|
|
166
|
+
className?: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function CasePanelSignalBrief({
|
|
170
|
+
children,
|
|
171
|
+
label = "Signal brief",
|
|
172
|
+
className,
|
|
173
|
+
}: CasePanelSignalBriefProps) {
|
|
174
|
+
return (
|
|
175
|
+
<section className={cn("mt-7", className)} aria-labelledby="case-panel-signal-brief-heading">
|
|
176
|
+
<div id="case-panel-signal-brief-heading" className="mb-2.5 text-[11px] font-semibold uppercase tracking-[0.07em] text-muted-foreground">
|
|
177
|
+
{label}
|
|
178
|
+
</div>
|
|
179
|
+
<div className="text-base leading-[1.6] text-foreground [text-wrap:pretty]">{children}</div>
|
|
180
|
+
</section>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface CasePanelMetadataRowProps {
|
|
185
|
+
children: React.ReactNode
|
|
186
|
+
label?: string
|
|
187
|
+
className?: string
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function CasePanelMetadataRow({
|
|
191
|
+
children,
|
|
192
|
+
label = "Case metadata",
|
|
193
|
+
className,
|
|
194
|
+
}: CasePanelMetadataRowProps) {
|
|
195
|
+
return (
|
|
196
|
+
<div aria-label={label} className={cn("mt-[18px] flex flex-wrap items-center gap-2", className)}>
|
|
197
|
+
{children}
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface CasePanelDetailSlotsProps {
|
|
203
|
+
why?: React.ReactNode
|
|
204
|
+
actions?: React.ReactNode
|
|
205
|
+
opportunity?: React.ReactNode
|
|
206
|
+
timeline?: React.ReactNode
|
|
207
|
+
playPanel?: React.ReactNode
|
|
208
|
+
className?: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function CasePanelDetailSlots({
|
|
212
|
+
why,
|
|
213
|
+
actions,
|
|
214
|
+
opportunity,
|
|
215
|
+
timeline,
|
|
216
|
+
playPanel,
|
|
217
|
+
className,
|
|
218
|
+
}: CasePanelDetailSlotsProps) {
|
|
219
|
+
return (
|
|
220
|
+
<div className={cn("mt-7 space-y-6", className)}>
|
|
221
|
+
{why ? <div data-slot="why">{why}</div> : null}
|
|
222
|
+
{actions ? <div data-slot="actions">{actions}</div> : null}
|
|
223
|
+
{opportunity ? <div data-slot="opportunity">{opportunity}</div> : null}
|
|
224
|
+
{timeline ? <div data-slot="timeline">{timeline}</div> : null}
|
|
225
|
+
{playPanel ? <div data-slot="play-panel">{playPanel}</div> : null}
|
|
226
|
+
</div>
|
|
227
|
+
)
|
|
228
|
+
}
|