@handled-ai/design-system 0.17.2 → 0.18.2
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/charts/chart.d.ts +1 -1
- package/dist/components/actor-byline.d.ts +3 -0
- package/dist/components/actor-byline.js +5 -0
- package/dist/components/actor-byline.js.map +1 -0
- package/dist/components/feedback-primitives.d.ts +21 -2
- package/dist/components/feedback-primitives.js +90 -6
- package/dist/components/feedback-primitives.js.map +1 -1
- package/dist/components/performance-metrics-table.d.ts +2 -1
- package/dist/components/performance-metrics-table.js +78 -46
- package/dist/components/performance-metrics-table.js.map +1 -1
- package/dist/components/score-why-chips.d.ts +1 -1
- package/dist/components/score-why-chips.js +26 -5
- package/dist/components/score-why-chips.js.map +1 -1
- package/dist/components/signal-priority-popover.d.ts +1 -1
- package/dist/components/signal-priority-popover.js +172 -7
- package/dist/components/signal-priority-popover.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/prototype/index.d.ts +1 -1
- package/dist/prototype/prototype-accounts-view.d.ts +1 -1
- package/dist/prototype/prototype-admin-view.d.ts +1 -1
- package/dist/prototype/prototype-config.d.ts +1 -1
- package/dist/prototype/prototype-inbox-view.d.ts +1 -1
- package/dist/prototype/prototype-inbox-view.js +4 -1
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/dist/prototype/prototype-insights-view.d.ts +1 -1
- package/dist/prototype/prototype-shell.d.ts +1 -1
- package/dist/{signal-priority-popover-DQ_VuHac.d.ts → signal-priority-popover-DWaAMhPI.d.ts} +26 -2
- package/package.json +3 -1
- package/src/components/__tests__/performance-metrics-table.test.tsx +54 -0
- package/src/components/__tests__/user-display.test.tsx +75 -0
- package/src/components/__tests__/wit-636-feedback-states.test.tsx +546 -0
- package/src/components/actor-byline.tsx +1 -0
- package/src/components/feedback-primitives.tsx +148 -26
- package/src/components/performance-metrics-table.tsx +99 -63
- package/src/components/score-why-chips.tsx +28 -2
- package/src/components/signal-priority-popover.tsx +194 -3
- package/src/index.ts +1 -1
- package/src/lib/__tests__/user-display.test.ts +53 -11
- package/src/prototype/prototype-config.ts +11 -1
- package/src/prototype/prototype-inbox-view.tsx +3 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
-
import { ThumbsUp, ThumbsDown } from "lucide-react"
|
|
4
|
+
import { ThumbsUp, ThumbsDown, Check, Pencil } from "lucide-react"
|
|
5
5
|
import { cn } from "../lib/utils"
|
|
6
6
|
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
@@ -25,6 +25,19 @@ export interface FeedbackSubmitData {
|
|
|
25
25
|
detail: string
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Persisted feedback data from a previous submission, used to hydrate the
|
|
30
|
+
* footer into its "already submitted" visual state.
|
|
31
|
+
*/
|
|
32
|
+
export interface PersistedFeedbackData {
|
|
33
|
+
sentiment: "positive" | "negative"
|
|
34
|
+
reasonTop?: string
|
|
35
|
+
reasonSub?: string
|
|
36
|
+
pills?: string[]
|
|
37
|
+
detail?: string
|
|
38
|
+
ownershipLabel: "Your feedback" | "Team feedback"
|
|
39
|
+
}
|
|
40
|
+
|
|
28
41
|
/**
|
|
29
42
|
* Defines a tier-1 chip that may have tier-2 sub-chips.
|
|
30
43
|
*/
|
|
@@ -185,6 +198,13 @@ export interface FeedbackFooterProps {
|
|
|
185
198
|
negativeChips?: FeedbackChipTree[]
|
|
186
199
|
positiveChips?: string[]
|
|
187
200
|
className?: string
|
|
201
|
+
/** Pre-existing feedback to hydrate from (e.g. after page reload). */
|
|
202
|
+
initialFeedback?: PersistedFeedbackData | null
|
|
203
|
+
/** Label shown in the transient confirmation pill after submit. */
|
|
204
|
+
submittedLabel?: string
|
|
205
|
+
/** Stable key for syncing initialFeedback into local state. When this
|
|
206
|
+
* changes, the component resets to the new initialFeedback value. */
|
|
207
|
+
feedbackKey?: string
|
|
188
208
|
}
|
|
189
209
|
|
|
190
210
|
const SENTIMENT_BUTTON_ACTIVE: Record<"positive" | "negative", string> = {
|
|
@@ -205,6 +225,9 @@ export function FeedbackFooter({
|
|
|
205
225
|
negativeChips = [],
|
|
206
226
|
positiveChips = [],
|
|
207
227
|
className,
|
|
228
|
+
initialFeedback,
|
|
229
|
+
submittedLabel = "Saved",
|
|
230
|
+
feedbackKey,
|
|
208
231
|
}: FeedbackFooterProps) {
|
|
209
232
|
const [expanded, setExpanded] = React.useState(false)
|
|
210
233
|
const [selectedTier1, setSelectedTier1] = React.useState<string | null>(null)
|
|
@@ -214,6 +237,43 @@ export function FeedbackFooter({
|
|
|
214
237
|
const [activeTreeIndex, setActiveTreeIndex] = React.useState<number | null>(
|
|
215
238
|
null,
|
|
216
239
|
)
|
|
240
|
+
/** Transient "Saved" confirmation — shown after successful submit. */
|
|
241
|
+
const [submitted, setSubmitted] = React.useState(false)
|
|
242
|
+
/** Persisted feedback shown as a clickable indicator (survives reload). */
|
|
243
|
+
const [persisted, setPersisted] = React.useState<PersistedFeedbackData | null>(
|
|
244
|
+
initialFeedback ?? null,
|
|
245
|
+
)
|
|
246
|
+
/** Tracks whether the user is actively editing (to guard against prop overwrites). */
|
|
247
|
+
const [isEditing, setIsEditing] = React.useState(false)
|
|
248
|
+
/** Track the last synced feedbackKey to detect key changes. */
|
|
249
|
+
const lastKeyRef = React.useRef<string | undefined>(feedbackKey)
|
|
250
|
+
|
|
251
|
+
// Sync initialFeedback into local state via useEffect keyed on feedbackKey.
|
|
252
|
+
// When feedbackKey changes, reset to new target. Preserve active edits
|
|
253
|
+
// when feedbackKey stays the same.
|
|
254
|
+
React.useEffect(() => {
|
|
255
|
+
const keyChanged = feedbackKey !== lastKeyRef.current
|
|
256
|
+
lastKeyRef.current = feedbackKey
|
|
257
|
+
|
|
258
|
+
if (keyChanged) {
|
|
259
|
+
// Key changed — full reset to new target
|
|
260
|
+
setPersisted(initialFeedback ?? null)
|
|
261
|
+
setSubmitted(false)
|
|
262
|
+
setExpanded(false)
|
|
263
|
+
setIsEditing(false)
|
|
264
|
+
if (initialFeedback) {
|
|
265
|
+
onFeedbackChange(initialFeedback.sentiment)
|
|
266
|
+
} else {
|
|
267
|
+
onFeedbackChange(null)
|
|
268
|
+
}
|
|
269
|
+
} else if (!isEditing) {
|
|
270
|
+
// Same key, not actively editing — safe to sync
|
|
271
|
+
setPersisted(initialFeedback ?? null)
|
|
272
|
+
if (initialFeedback) {
|
|
273
|
+
onFeedbackChange(initialFeedback.sentiment)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}, [initialFeedback, feedbackKey]) // eslint-disable-line react-hooks/exhaustive-deps -- reads isEditing as guard, not trigger
|
|
217
277
|
|
|
218
278
|
// Reset state when feedback collapses
|
|
219
279
|
const resetState = React.useCallback(() => {
|
|
@@ -223,6 +283,7 @@ export function FeedbackFooter({
|
|
|
223
283
|
setAdditionalPills([])
|
|
224
284
|
setDetailText("")
|
|
225
285
|
setActiveTreeIndex(null)
|
|
286
|
+
setIsEditing(false)
|
|
226
287
|
}, [])
|
|
227
288
|
|
|
228
289
|
const handleSentimentClick = React.useCallback(
|
|
@@ -231,10 +292,26 @@ export function FeedbackFooter({
|
|
|
231
292
|
// Reset chip state when switching sentiment, then expand
|
|
232
293
|
resetState()
|
|
233
294
|
setExpanded(true)
|
|
295
|
+
setSubmitted(false)
|
|
296
|
+
setPersisted(null)
|
|
297
|
+
setIsEditing(true)
|
|
234
298
|
},
|
|
235
299
|
[onFeedbackChange, resetState],
|
|
236
300
|
)
|
|
237
301
|
|
|
302
|
+
/** Open the persisted indicator for editing. */
|
|
303
|
+
const handlePersistedClick = React.useCallback(() => {
|
|
304
|
+
if (!persisted) return
|
|
305
|
+
onFeedbackChange(persisted.sentiment)
|
|
306
|
+
setSelectedTier1(persisted.reasonTop ?? null)
|
|
307
|
+
setSelectedTier2(persisted.reasonSub ?? null)
|
|
308
|
+
setAdditionalPills(persisted.pills ?? [])
|
|
309
|
+
setDetailText(persisted.detail ?? "")
|
|
310
|
+
setExpanded(true)
|
|
311
|
+
setSubmitted(false)
|
|
312
|
+
setIsEditing(true)
|
|
313
|
+
}, [persisted, onFeedbackChange])
|
|
314
|
+
|
|
238
315
|
const handleTier1Toggle = React.useCallback(
|
|
239
316
|
(chipLabel: string) => {
|
|
240
317
|
if (selectedTier1 === chipLabel) {
|
|
@@ -295,7 +372,16 @@ export function FeedbackFooter({
|
|
|
295
372
|
pills: additionalPills,
|
|
296
373
|
detail: detailText,
|
|
297
374
|
})
|
|
298
|
-
|
|
375
|
+
// Show transient "Saved" confirmation
|
|
376
|
+
setSubmitted(true)
|
|
377
|
+
// Collapse expansion but keep sentiment visible
|
|
378
|
+
setExpanded(false)
|
|
379
|
+
setSelectedTier1(null)
|
|
380
|
+
setSelectedTier2(null)
|
|
381
|
+
setAdditionalPills([])
|
|
382
|
+
setDetailText("")
|
|
383
|
+
setActiveTreeIndex(null)
|
|
384
|
+
setIsEditing(false)
|
|
299
385
|
}, [
|
|
300
386
|
feedback,
|
|
301
387
|
selectedTier1,
|
|
@@ -303,7 +389,6 @@ export function FeedbackFooter({
|
|
|
303
389
|
additionalPills,
|
|
304
390
|
detailText,
|
|
305
391
|
onSubmit,
|
|
306
|
-
resetState,
|
|
307
392
|
])
|
|
308
393
|
|
|
309
394
|
const handleCancel = React.useCallback(() => {
|
|
@@ -323,38 +408,75 @@ export function FeedbackFooter({
|
|
|
323
408
|
const activeTree =
|
|
324
409
|
activeTreeIndex !== null ? negativeChips[activeTreeIndex] : null
|
|
325
410
|
|
|
411
|
+
// Determine if we should show the persisted indicator instead of bare buttons
|
|
412
|
+
const showPersistedIndicator = persisted && !expanded && !submitted
|
|
413
|
+
|
|
326
414
|
return (
|
|
327
415
|
<div className={cn("space-y-3", className)}>
|
|
328
416
|
{/* Sentiment buttons + meta text bar */}
|
|
329
417
|
<div className="flex items-center justify-between">
|
|
330
|
-
|
|
418
|
+
{showPersistedIndicator ? (
|
|
419
|
+
/* Persisted feedback indicator — clickable to reopen editor */
|
|
331
420
|
<button
|
|
332
421
|
type="button"
|
|
333
|
-
onClick={
|
|
334
|
-
className=
|
|
335
|
-
|
|
336
|
-
feedback === "positive"
|
|
337
|
-
? SENTIMENT_BUTTON_ACTIVE.positive
|
|
338
|
-
: SENTIMENT_BUTTON_IDLE,
|
|
339
|
-
)}
|
|
422
|
+
onClick={handlePersistedClick}
|
|
423
|
+
className="group flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
|
424
|
+
data-testid="persisted-feedback-indicator"
|
|
340
425
|
>
|
|
341
|
-
<
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
onClick={() => handleSentimentClick("negative")}
|
|
347
|
-
className={cn(
|
|
348
|
-
"flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
|
|
349
|
-
feedback === "negative"
|
|
350
|
-
? SENTIMENT_BUTTON_ACTIVE.negative
|
|
351
|
-
: SENTIMENT_BUTTON_IDLE,
|
|
426
|
+
<span className="font-medium">{persisted.ownershipLabel}:</span>
|
|
427
|
+
{persisted.sentiment === "positive" ? (
|
|
428
|
+
<ThumbsUp className="h-[11px] w-[11px]" />
|
|
429
|
+
) : (
|
|
430
|
+
<ThumbsDown className="h-[11px] w-[11px]" />
|
|
352
431
|
)}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
432
|
+
{persisted.detail && (
|
|
433
|
+
<span className="max-w-[200px] truncate text-muted-foreground/70">
|
|
434
|
+
{persisted.detail}
|
|
435
|
+
</span>
|
|
436
|
+
)}
|
|
437
|
+
<Pencil className="h-[9px] w-[9px] opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
356
438
|
</button>
|
|
357
|
-
|
|
439
|
+
) : (
|
|
440
|
+
<div className="flex items-center gap-3">
|
|
441
|
+
<button
|
|
442
|
+
type="button"
|
|
443
|
+
onClick={() => handleSentimentClick("positive")}
|
|
444
|
+
className={cn(
|
|
445
|
+
"flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
|
|
446
|
+
feedback === "positive"
|
|
447
|
+
? SENTIMENT_BUTTON_ACTIVE.positive
|
|
448
|
+
: SENTIMENT_BUTTON_IDLE,
|
|
449
|
+
)}
|
|
450
|
+
>
|
|
451
|
+
<ThumbsUp className="h-[11px] w-[11px]" />
|
|
452
|
+
Helpful
|
|
453
|
+
</button>
|
|
454
|
+
<button
|
|
455
|
+
type="button"
|
|
456
|
+
onClick={() => handleSentimentClick("negative")}
|
|
457
|
+
className={cn(
|
|
458
|
+
"flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
|
|
459
|
+
feedback === "negative"
|
|
460
|
+
? SENTIMENT_BUTTON_ACTIVE.negative
|
|
461
|
+
: SENTIMENT_BUTTON_IDLE,
|
|
462
|
+
)}
|
|
463
|
+
>
|
|
464
|
+
<ThumbsDown className="h-[11px] w-[11px]" />
|
|
465
|
+
Not helpful
|
|
466
|
+
</button>
|
|
467
|
+
{/* Transient "Saved" confirmation pill */}
|
|
468
|
+
{submitted && feedback && (
|
|
469
|
+
<span
|
|
470
|
+
className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-600"
|
|
471
|
+
role="status"
|
|
472
|
+
data-testid="feedback-submitted-pill"
|
|
473
|
+
>
|
|
474
|
+
<Check className="h-[11px] w-[11px]" />
|
|
475
|
+
{submittedLabel}
|
|
476
|
+
</span>
|
|
477
|
+
)}
|
|
478
|
+
</div>
|
|
479
|
+
)}
|
|
358
480
|
{metaText && (
|
|
359
481
|
<span className="text-[11px] text-muted-foreground">{metaText}</span>
|
|
360
482
|
)}
|
|
@@ -52,6 +52,7 @@ interface PerformanceMetricsTableProps {
|
|
|
52
52
|
title?: string
|
|
53
53
|
entityColumnLabel?: string
|
|
54
54
|
primaryMetricColumnLabel?: string
|
|
55
|
+
primaryMetricDisplayMode?: "progress" | "value"
|
|
55
56
|
rateColumnLabel?: string
|
|
56
57
|
metricOneColumnLabel?: string
|
|
57
58
|
metricTwoColumnLabel?: string
|
|
@@ -179,9 +180,48 @@ function getProgressStatus(value: number, target: number) {
|
|
|
179
180
|
}
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
function PrimaryMetricCell({
|
|
184
|
+
row,
|
|
185
|
+
displayMode,
|
|
186
|
+
}: {
|
|
187
|
+
row: PerformanceMetricsTableRow
|
|
188
|
+
displayMode: "progress" | "value"
|
|
189
|
+
}) {
|
|
190
|
+
if (displayMode === "value") {
|
|
191
|
+
return (
|
|
192
|
+
<div className="text-sm font-bold text-foreground">
|
|
193
|
+
{row.primaryValue}
|
|
194
|
+
</div>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const percentage = (row.primaryValue / row.primaryTarget) * 100
|
|
199
|
+
const progress = getProgressStatus(row.primaryValue, row.primaryTarget)
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<div className="flex items-center gap-2">
|
|
203
|
+
<span className="shrink-0">{progress.icon}</span>
|
|
204
|
+
<div className="w-full max-w-[180px]">
|
|
205
|
+
<div className="mb-1 text-sm font-bold text-foreground">
|
|
206
|
+
{row.primaryValue}/{row.primaryTarget}
|
|
207
|
+
</div>
|
|
208
|
+
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
|
209
|
+
<div
|
|
210
|
+
className={cn("h-full rounded-full", progress.color)}
|
|
211
|
+
style={{ width: `${Math.min(100, percentage)}%` }}
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
<div className={cn("mt-1 text-xs font-medium", progress.textColor)}>
|
|
215
|
+
{Math.round(percentage)}%
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
182
222
|
function sortRows(
|
|
183
223
|
rows: PerformanceMetricsTableRow[],
|
|
184
|
-
sortId: PerformanceMetricsTableSortOption["id"]
|
|
224
|
+
sortId: PerformanceMetricsTableSortOption["id"],
|
|
185
225
|
) {
|
|
186
226
|
const copy = [...rows]
|
|
187
227
|
switch (sortId) {
|
|
@@ -201,23 +241,28 @@ export function PerformanceMetricsTable({
|
|
|
201
241
|
title = "Performance Table",
|
|
202
242
|
entityColumnLabel = "Entity",
|
|
203
243
|
primaryMetricColumnLabel = "Primary Goal",
|
|
244
|
+
primaryMetricDisplayMode = "progress",
|
|
204
245
|
rateColumnLabel = "Rate",
|
|
205
246
|
metricOneColumnLabel = "Metric One",
|
|
206
247
|
metricTwoColumnLabel = "Metric Two",
|
|
207
248
|
metricThreeColumnLabel = "Metric Three",
|
|
208
249
|
metricFourColumnLabel = "Metric Four",
|
|
209
250
|
viewOptions = ["By Entity"],
|
|
210
|
-
roleOptions = [
|
|
251
|
+
roleOptions = [
|
|
252
|
+
"All",
|
|
253
|
+
"Senior Coordinator",
|
|
254
|
+
"Coordinator",
|
|
255
|
+
"Junior Coordinator",
|
|
256
|
+
],
|
|
211
257
|
sortOptions = DEFAULT_SORT_OPTIONS,
|
|
212
258
|
rows = DEFAULT_ROWS,
|
|
213
259
|
pageSize = 6,
|
|
214
260
|
searchPlaceholder = "Search rows...",
|
|
215
261
|
}: PerformanceMetricsTableProps) {
|
|
216
262
|
const [view, setView] = React.useState(viewOptions[0] ?? "By Entity")
|
|
217
|
-
const [sortId, setSortId] =
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
)
|
|
263
|
+
const [sortId, setSortId] = React.useState<
|
|
264
|
+
PerformanceMetricsTableSortOption["id"]
|
|
265
|
+
>(sortOptions[0]?.id ?? "primary-desc")
|
|
221
266
|
const [roleFilter, setRoleFilter] = React.useState(roleOptions[0] ?? "All")
|
|
222
267
|
const [search, setSearch] = React.useState("")
|
|
223
268
|
const [page, setPage] = React.useState(1)
|
|
@@ -237,7 +282,7 @@ export function PerformanceMetricsTable({
|
|
|
237
282
|
|
|
238
283
|
const sortedRows = React.useMemo(
|
|
239
284
|
() => sortRows(filteredRows, sortId),
|
|
240
|
-
[filteredRows, sortId]
|
|
285
|
+
[filteredRows, sortId],
|
|
241
286
|
)
|
|
242
287
|
|
|
243
288
|
const pageCount = Math.max(1, Math.ceil(sortedRows.length / pageSize))
|
|
@@ -285,7 +330,10 @@ export function PerformanceMetricsTable({
|
|
|
285
330
|
</DropdownMenuTrigger>
|
|
286
331
|
<DropdownMenuContent align="end" className="w-56">
|
|
287
332
|
{sortOptions.map((option) => (
|
|
288
|
-
<DropdownMenuItem
|
|
333
|
+
<DropdownMenuItem
|
|
334
|
+
key={option.id}
|
|
335
|
+
onClick={() => setSortId(option.id)}
|
|
336
|
+
>
|
|
289
337
|
{option.label}
|
|
290
338
|
</DropdownMenuItem>
|
|
291
339
|
))}
|
|
@@ -301,7 +349,10 @@ export function PerformanceMetricsTable({
|
|
|
301
349
|
</DropdownMenuTrigger>
|
|
302
350
|
<DropdownMenuContent align="end">
|
|
303
351
|
{roleOptions.map((option) => (
|
|
304
|
-
<DropdownMenuItem
|
|
352
|
+
<DropdownMenuItem
|
|
353
|
+
key={option}
|
|
354
|
+
onClick={() => setRoleFilter(option)}
|
|
355
|
+
>
|
|
305
356
|
{option}
|
|
306
357
|
</DropdownMenuItem>
|
|
307
358
|
))}
|
|
@@ -346,61 +397,45 @@ export function PerformanceMetricsTable({
|
|
|
346
397
|
</TableRow>
|
|
347
398
|
</TableHeader>
|
|
348
399
|
<TableBody>
|
|
349
|
-
{paginatedRows.map((row) =>
|
|
350
|
-
|
|
351
|
-
|
|
400
|
+
{paginatedRows.map((row) => (
|
|
401
|
+
<TableRow key={row.id} className="hover:bg-muted/30">
|
|
402
|
+
<TableCell className="px-4 py-3">
|
|
403
|
+
<div className="flex items-center gap-3">
|
|
404
|
+
<Avatar className="h-8 w-8 border border-border">
|
|
405
|
+
<AvatarFallback className="bg-emerald-100 text-[11px] font-medium text-emerald-700">
|
|
406
|
+
{row.avatarFallback}
|
|
407
|
+
</AvatarFallback>
|
|
408
|
+
</Avatar>
|
|
409
|
+
<span className="text-sm font-medium text-foreground">
|
|
410
|
+
{row.label}
|
|
411
|
+
</span>
|
|
412
|
+
</div>
|
|
413
|
+
</TableCell>
|
|
352
414
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
{row.avatarFallback}
|
|
360
|
-
</AvatarFallback>
|
|
361
|
-
</Avatar>
|
|
362
|
-
<span className="text-sm font-medium text-foreground">{row.label}</span>
|
|
363
|
-
</div>
|
|
364
|
-
</TableCell>
|
|
415
|
+
<TableCell className="px-4 py-3">
|
|
416
|
+
<PrimaryMetricCell
|
|
417
|
+
row={row}
|
|
418
|
+
displayMode={primaryMetricDisplayMode}
|
|
419
|
+
/>
|
|
420
|
+
</TableCell>
|
|
365
421
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
</div>
|
|
384
|
-
</TableCell>
|
|
385
|
-
|
|
386
|
-
<TableCell className="px-4 py-3 text-right text-sm font-semibold text-emerald-600">
|
|
387
|
-
{row.ratePercent}%
|
|
388
|
-
</TableCell>
|
|
389
|
-
<TableCell className="px-4 py-3 text-right text-sm font-medium text-foreground">
|
|
390
|
-
{row.metricOne}
|
|
391
|
-
</TableCell>
|
|
392
|
-
<TableCell className="px-4 py-3 text-right text-sm text-muted-foreground">
|
|
393
|
-
{row.metricTwo}
|
|
394
|
-
</TableCell>
|
|
395
|
-
<TableCell className="px-4 py-3 text-right text-sm font-medium text-foreground">
|
|
396
|
-
{row.metricThree}
|
|
397
|
-
</TableCell>
|
|
398
|
-
<TableCell className="px-4 py-3 text-right text-sm font-medium text-foreground">
|
|
399
|
-
{row.metricFour}
|
|
400
|
-
</TableCell>
|
|
401
|
-
</TableRow>
|
|
402
|
-
)
|
|
403
|
-
})}
|
|
422
|
+
<TableCell className="px-4 py-3 text-right text-sm font-semibold text-emerald-600">
|
|
423
|
+
{row.ratePercent}%
|
|
424
|
+
</TableCell>
|
|
425
|
+
<TableCell className="px-4 py-3 text-right text-sm font-medium text-foreground">
|
|
426
|
+
{row.metricOne}
|
|
427
|
+
</TableCell>
|
|
428
|
+
<TableCell className="px-4 py-3 text-right text-sm text-muted-foreground">
|
|
429
|
+
{row.metricTwo}
|
|
430
|
+
</TableCell>
|
|
431
|
+
<TableCell className="px-4 py-3 text-right text-sm font-medium text-foreground">
|
|
432
|
+
{row.metricThree}
|
|
433
|
+
</TableCell>
|
|
434
|
+
<TableCell className="px-4 py-3 text-right text-sm font-medium text-foreground">
|
|
435
|
+
{row.metricFour}
|
|
436
|
+
</TableCell>
|
|
437
|
+
</TableRow>
|
|
438
|
+
))}
|
|
404
439
|
</TableBody>
|
|
405
440
|
</Table>
|
|
406
441
|
</div>
|
|
@@ -410,7 +445,8 @@ export function PerformanceMetricsTable({
|
|
|
410
445
|
<div className="flex items-center justify-between border-t border-border bg-muted/20 px-4 py-3">
|
|
411
446
|
<span className="text-xs text-muted-foreground">
|
|
412
447
|
Showing {sortedRows.length === 0 ? 0 : start + 1} to{" "}
|
|
413
|
-
{Math.min(start + pageSize, sortedRows.length)} of {sortedRows.length}
|
|
448
|
+
{Math.min(start + pageSize, sortedRows.length)} of {sortedRows.length}{" "}
|
|
449
|
+
rows
|
|
414
450
|
</span>
|
|
415
451
|
<div className="flex items-center gap-2">
|
|
416
452
|
<Button
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "lucide-react"
|
|
16
16
|
import type { LucideIcon } from "lucide-react"
|
|
17
17
|
import { FeedbackFooter } from "./feedback-primitives"
|
|
18
|
-
import type { FeedbackChipTree, FeedbackSubmitData } from "./feedback-primitives"
|
|
18
|
+
import type { FeedbackChipTree, FeedbackSubmitData, PersistedFeedbackData } from "./feedback-primitives"
|
|
19
19
|
import { cn } from "../lib/utils"
|
|
20
20
|
import type {
|
|
21
21
|
QueueItem,
|
|
@@ -266,6 +266,7 @@ function StructuredSignalRow({ item, bucketKey, signal, tone, onOpenSignalBucket
|
|
|
266
266
|
const IconComponent = resolveIcon(signal.signalTypeName)
|
|
267
267
|
const toneClass = tone ? (SIGNAL_TONE_CLASSES[tone] ?? DEFAULT_TONE_CLASS) : DEFAULT_TONE_CLASS
|
|
268
268
|
const isCombined = signal.signalTypeName === "combined_signal" && signal.components && signal.components.length > 0
|
|
269
|
+
const hasBalance = Boolean(signal.currentBalance || signal.balanceContext)
|
|
269
270
|
|
|
270
271
|
const rowContent = (
|
|
271
272
|
<>
|
|
@@ -304,6 +305,26 @@ function StructuredSignalRow({ item, bucketKey, signal, tone, onOpenSignalBucket
|
|
|
304
305
|
|
|
305
306
|
{/* Slot 5: Chevron */}
|
|
306
307
|
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5 group-hover:text-foreground/50" />
|
|
308
|
+
|
|
309
|
+
{/* Balance context strip — spans full row below grid columns */}
|
|
310
|
+
{hasBalance && (
|
|
311
|
+
<div
|
|
312
|
+
className="col-span-full mt-0.5 text-[10px] text-muted-foreground/70"
|
|
313
|
+
data-testid="balance-context-strip"
|
|
314
|
+
>
|
|
315
|
+
{signal.currentBalance && (
|
|
316
|
+
<span>
|
|
317
|
+
Current balance <span className="font-medium text-muted-foreground">{signal.currentBalance}</span>
|
|
318
|
+
</span>
|
|
319
|
+
)}
|
|
320
|
+
{signal.balanceContext && (
|
|
321
|
+
<span>
|
|
322
|
+
{signal.currentBalance ? " · " : ""}
|
|
323
|
+
{signal.balanceContext}
|
|
324
|
+
</span>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
307
328
|
</>
|
|
308
329
|
)
|
|
309
330
|
|
|
@@ -405,9 +426,11 @@ interface WhyCardProps {
|
|
|
405
426
|
panelId: string
|
|
406
427
|
onOpenSignalBucket?: ScoreWhyChipsProps["onOpenSignalBucket"]
|
|
407
428
|
onBucketFeedback?: (bucketKey: string, data: FeedbackSubmitData) => void
|
|
429
|
+
/** Persisted bucket-level feedback to hydrate from. */
|
|
430
|
+
initialBucketFeedback?: PersistedFeedbackData | null
|
|
408
431
|
}
|
|
409
432
|
|
|
410
|
-
function WhyCard({ bucket, signals, item, panelId, onOpenSignalBucket, onBucketFeedback }: WhyCardProps) {
|
|
433
|
+
function WhyCard({ bucket, signals, item, panelId, onOpenSignalBucket, onBucketFeedback, initialBucketFeedback }: WhyCardProps) {
|
|
411
434
|
const [showAll, setShowAll] = React.useState(false)
|
|
412
435
|
const [bucketFeedback, setBucketFeedback] = React.useState<"positive" | "negative" | null>(null)
|
|
413
436
|
const totalCount = bucket.signalCount ?? signals.length
|
|
@@ -488,6 +511,8 @@ function WhyCard({ bucket, signals, item, panelId, onOpenSignalBucket, onBucketF
|
|
|
488
511
|
negativeChips={BUCKET_NEGATIVE_CHIPS}
|
|
489
512
|
negativePrompt="Was this bucket useful?"
|
|
490
513
|
positivePrompt="Thanks! What was useful about this bucket?"
|
|
514
|
+
initialFeedback={initialBucketFeedback}
|
|
515
|
+
feedbackKey={bucket.key}
|
|
491
516
|
/>
|
|
492
517
|
</div>
|
|
493
518
|
)}
|
|
@@ -561,6 +586,7 @@ export function ScoreWhyChips({
|
|
|
561
586
|
panelId={selectedPanelId}
|
|
562
587
|
onOpenSignalBucket={onOpenSignalBucket}
|
|
563
588
|
onBucketFeedback={signalData.onBucketFeedback}
|
|
589
|
+
initialBucketFeedback={signalData.initialBucketFeedback?.[selectedBucket.key]}
|
|
564
590
|
/>
|
|
565
591
|
)}
|
|
566
592
|
</div>
|