@handled-ai/design-system 0.18.45 → 0.18.46
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/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/data-table-filter.d.ts +6 -21
- package/dist/components/data-table-filter.js +9 -134
- package/dist/components/data-table-filter.js.map +1 -1
- package/dist/components/pill.d.ts +1 -1
- package/dist/components/score-why-chips.d.ts +2 -2
- package/dist/components/signal-feedback-inline.d.ts +28 -12
- package/dist/components/signal-feedback-inline.js +130 -16
- package/dist/components/signal-feedback-inline.js.map +1 -1
- package/dist/components/signal-priority-popover.d.ts +2 -2
- package/dist/components/tabs.d.ts +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/prototype/index.d.ts +2 -2
- package/dist/prototype/prototype-accounts-view.d.ts +2 -2
- package/dist/prototype/prototype-admin-view.d.ts +2 -2
- package/dist/prototype/prototype-config.d.ts +2 -2
- package/dist/prototype/prototype-inbox-view.d.ts +3 -3
- package/dist/prototype/prototype-inbox-view.js +1 -1
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/dist/prototype/prototype-insights-view.d.ts +2 -2
- package/dist/prototype/prototype-shell.d.ts +2 -2
- package/dist/{signal-priority-popover-B5b-XZ7i.d.ts → signal-priority-popover-DM02Eg_F.d.ts} +2 -2
- package/package.json +1 -1
- package/src/components/__tests__/case-panel-why.test.tsx +59 -0
- package/src/components/__tests__/data-table-filter.test.tsx +0 -130
- package/src/components/data-table-filter.tsx +9 -160
- package/src/components/signal-feedback-inline.tsx +152 -21
- package/src/prototype/prototype-config.ts +1 -1
- package/src/prototype/prototype-inbox-view.tsx +3 -3
|
@@ -264,136 +264,6 @@ describe("DataTableFilter", () => {
|
|
|
264
264
|
expect(openItem!.querySelector("span.rounded-full")).toBeNull();
|
|
265
265
|
});
|
|
266
266
|
|
|
267
|
-
|
|
268
|
-
it("renders a text category as a submenu with a text input", () => {
|
|
269
|
-
const textCategory: DataTableFilterCategory = {
|
|
270
|
-
id: "callsign",
|
|
271
|
-
label: "Callsign",
|
|
272
|
-
icon: ListFilter,
|
|
273
|
-
type: "text",
|
|
274
|
-
valuePlaceholder: "Enter callsign",
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
render(
|
|
278
|
-
<DataTableFilter
|
|
279
|
-
categories={[textCategory]}
|
|
280
|
-
selectedFilters={{}}
|
|
281
|
-
onToggleFilter={() => {}}
|
|
282
|
-
/>
|
|
283
|
-
);
|
|
284
|
-
|
|
285
|
-
const subTrigger = document.querySelector('[data-slot="dropdown-menu-sub-trigger"]');
|
|
286
|
-
expect(subTrigger).not.toBeNull();
|
|
287
|
-
expect(subTrigger!.textContent).toContain("Callsign");
|
|
288
|
-
expect(screen.getByLabelText("Callsign")).toBeDefined();
|
|
289
|
-
expect(screen.getByPlaceholderText("Enter callsign")).toBeDefined();
|
|
290
|
-
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
|
291
|
-
expect(screen.queryByText("No matches")).toBeNull();
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
it("applies a trimmed text filter value", () => {
|
|
295
|
-
const onTextFilterChange = vi.fn();
|
|
296
|
-
const textCategory: DataTableFilterCategory = {
|
|
297
|
-
id: "callsign",
|
|
298
|
-
label: "Callsign",
|
|
299
|
-
icon: ListFilter,
|
|
300
|
-
type: "text",
|
|
301
|
-
valuePlaceholder: "Enter callsign",
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
render(
|
|
305
|
-
<DataTableFilter
|
|
306
|
-
categories={[textCategory]}
|
|
307
|
-
selectedFilters={{}}
|
|
308
|
-
onToggleFilter={() => {}}
|
|
309
|
-
textFilters={{}}
|
|
310
|
-
onTextFilterChange={onTextFilterChange}
|
|
311
|
-
/>
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
fireEvent.change(screen.getByLabelText("Callsign"), {
|
|
315
|
-
target: { value: " M42 " },
|
|
316
|
-
});
|
|
317
|
-
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
|
318
|
-
|
|
319
|
-
expect(onTextFilterChange).toHaveBeenCalledWith("callsign", "M42");
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
it("clears an active text filter value", () => {
|
|
323
|
-
const onTextFilterChange = vi.fn();
|
|
324
|
-
const textCategory: DataTableFilterCategory = {
|
|
325
|
-
id: "organizationId",
|
|
326
|
-
label: "Organization ID",
|
|
327
|
-
icon: ListFilter,
|
|
328
|
-
type: "text",
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
render(
|
|
332
|
-
<DataTableFilter
|
|
333
|
-
categories={[textCategory]}
|
|
334
|
-
selectedFilters={{}}
|
|
335
|
-
onToggleFilter={() => {}}
|
|
336
|
-
textFilters={{ organizationId: "ORG-123" }}
|
|
337
|
-
onTextFilterChange={onTextFilterChange}
|
|
338
|
-
/>
|
|
339
|
-
);
|
|
340
|
-
|
|
341
|
-
expect(screen.getByLabelText("Organization ID")).toHaveProperty("value", "ORG-123");
|
|
342
|
-
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
|
343
|
-
|
|
344
|
-
expect(onTextFilterChange).toHaveBeenCalledWith("organizationId", "");
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
it("includes active text filters in the filter count", () => {
|
|
348
|
-
const textCategory: DataTableFilterCategory = {
|
|
349
|
-
id: "callsign",
|
|
350
|
-
label: "Callsign",
|
|
351
|
-
icon: ListFilter,
|
|
352
|
-
type: "text",
|
|
353
|
-
};
|
|
354
|
-
const optionCategory: DataTableFilterCategory = {
|
|
355
|
-
id: "status",
|
|
356
|
-
label: "Status",
|
|
357
|
-
icon: ListFilter,
|
|
358
|
-
options: ["Open", "Closed"],
|
|
359
|
-
};
|
|
360
|
-
|
|
361
|
-
render(
|
|
362
|
-
<DataTableFilter
|
|
363
|
-
categories={[textCategory, optionCategory]}
|
|
364
|
-
selectedFilters={{ status: ["Open"] }}
|
|
365
|
-
onToggleFilter={() => {}}
|
|
366
|
-
textFilters={{ callsign: " M42 " }}
|
|
367
|
-
/>
|
|
368
|
-
);
|
|
369
|
-
|
|
370
|
-
const badge = screen.getByText("2");
|
|
371
|
-
expect(badge.tagName).toBe("SPAN");
|
|
372
|
-
expect(badge.className).toContain("bg-muted");
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
it("does not count blank text filter values as active", () => {
|
|
376
|
-
const textCategory: DataTableFilterCategory = {
|
|
377
|
-
id: "callsign",
|
|
378
|
-
label: "Callsign",
|
|
379
|
-
icon: ListFilter,
|
|
380
|
-
type: "text",
|
|
381
|
-
};
|
|
382
|
-
|
|
383
|
-
render(
|
|
384
|
-
<DataTableFilter
|
|
385
|
-
categories={[textCategory]}
|
|
386
|
-
selectedFilters={{}}
|
|
387
|
-
onToggleFilter={() => {}}
|
|
388
|
-
textFilters={{ callsign: " " }}
|
|
389
|
-
/>
|
|
390
|
-
);
|
|
391
|
-
|
|
392
|
-
const triggerButton = document.querySelector('[data-slot="dropdown-menu-trigger"]');
|
|
393
|
-
expect(triggerButton).not.toBeNull();
|
|
394
|
-
expect(triggerButton!.querySelector("span.bg-muted")).toBeNull();
|
|
395
|
-
});
|
|
396
|
-
|
|
397
267
|
it("does not expose the condition builder entry point without condition fields", () => {
|
|
398
268
|
render(<DataTableFilter {...defaultProps} />);
|
|
399
269
|
|
|
@@ -28,10 +28,13 @@ export interface FilterOption {
|
|
|
28
28
|
value: string
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
interface
|
|
31
|
+
export interface DataTableFilterCategory {
|
|
32
32
|
id: string
|
|
33
33
|
label: string
|
|
34
34
|
icon: React.ComponentType<{ className?: string }>
|
|
35
|
+
options: (string | FilterOption)[]
|
|
36
|
+
/** Filter behavior. Defaults to "multi" (checkbox multi-select). */
|
|
37
|
+
type?: "multi" | "single" | "boolean"
|
|
35
38
|
/**
|
|
36
39
|
* Submenu search behavior. Defaults to the DataTableFilter
|
|
37
40
|
* optionSearchThreshold prop. Use true to always show search or false to
|
|
@@ -40,25 +43,6 @@ interface DataTableFilterCategoryBase {
|
|
|
40
43
|
searchable?: boolean | { threshold?: number }
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
export interface DataTableOptionFilterCategory extends DataTableFilterCategoryBase {
|
|
44
|
-
options: (string | FilterOption)[]
|
|
45
|
-
/** Filter behavior. Defaults to "multi" (checkbox multi-select). */
|
|
46
|
-
type?: "multi" | "single" | "boolean"
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface DataTableTextFilterCategory extends DataTableFilterCategoryBase {
|
|
50
|
-
/** Free-text filter behavior. Renders a top-level submenu with a text input. */
|
|
51
|
-
type: "text"
|
|
52
|
-
/** Placeholder shown in the text filter input. */
|
|
53
|
-
valuePlaceholder?: string
|
|
54
|
-
/** Not used for text filters; optional for backwards-compatible category shapes. */
|
|
55
|
-
options?: (string | FilterOption)[]
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export type DataTableFilterCategory =
|
|
59
|
-
| DataTableOptionFilterCategory
|
|
60
|
-
| DataTableTextFilterCategory
|
|
61
|
-
|
|
62
46
|
function getOptionValue(option: string | FilterOption): string {
|
|
63
47
|
return typeof option === "string" ? option : option.value
|
|
64
48
|
}
|
|
@@ -66,111 +50,6 @@ function getOptionLabel(option: string | FilterOption): string {
|
|
|
66
50
|
return typeof option === "string" ? option : option.label
|
|
67
51
|
}
|
|
68
52
|
|
|
69
|
-
function isTextFilterCategory(
|
|
70
|
-
category: DataTableFilterCategory
|
|
71
|
-
): category is DataTableTextFilterCategory {
|
|
72
|
-
return category.type === "text"
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function TextFilterSubmenu({
|
|
76
|
-
category,
|
|
77
|
-
value,
|
|
78
|
-
onValueChange,
|
|
79
|
-
}: {
|
|
80
|
-
category: DataTableTextFilterCategory
|
|
81
|
-
value: string
|
|
82
|
-
onValueChange?: (categoryId: string, value: string) => void
|
|
83
|
-
}) {
|
|
84
|
-
const [draftValue, setDraftValue] = React.useState(value)
|
|
85
|
-
|
|
86
|
-
React.useEffect(() => {
|
|
87
|
-
setDraftValue(value)
|
|
88
|
-
}, [value])
|
|
89
|
-
|
|
90
|
-
const active = value.trim().length > 0
|
|
91
|
-
const applyValue = React.useCallback(() => {
|
|
92
|
-
onValueChange?.(category.id, draftValue.trim())
|
|
93
|
-
}, [category.id, draftValue, onValueChange])
|
|
94
|
-
|
|
95
|
-
return (
|
|
96
|
-
<DropdownMenuSub
|
|
97
|
-
onOpenChange={(open) => {
|
|
98
|
-
if (!open) {
|
|
99
|
-
setDraftValue(value)
|
|
100
|
-
}
|
|
101
|
-
}}
|
|
102
|
-
>
|
|
103
|
-
<DropdownMenuSubTrigger
|
|
104
|
-
className={cn(
|
|
105
|
-
"cursor-pointer py-1.5 text-xs",
|
|
106
|
-
active && "text-brand-purple"
|
|
107
|
-
)}
|
|
108
|
-
>
|
|
109
|
-
<category.icon
|
|
110
|
-
className={cn(
|
|
111
|
-
"mr-2 h-3.5 w-3.5 text-muted-foreground",
|
|
112
|
-
active && "text-brand-purple"
|
|
113
|
-
)}
|
|
114
|
-
/>
|
|
115
|
-
{category.label}
|
|
116
|
-
{active ? <Check className="ml-auto h-4 w-4" /> : null}
|
|
117
|
-
</DropdownMenuSubTrigger>
|
|
118
|
-
<DropdownMenuSubContent className="w-64 p-2">
|
|
119
|
-
<div className="space-y-2">
|
|
120
|
-
<input
|
|
121
|
-
aria-label={category.label}
|
|
122
|
-
className="h-8 w-full rounded-md bg-muted/50 px-2 py-1 text-xs outline-none transition-colors placeholder:text-muted-foreground/70 focus:bg-muted"
|
|
123
|
-
placeholder={
|
|
124
|
-
category.valuePlaceholder ??
|
|
125
|
-
`Enter ${category.label.toLowerCase()}...`
|
|
126
|
-
}
|
|
127
|
-
value={draftValue}
|
|
128
|
-
onChange={(event) => setDraftValue(event.target.value)}
|
|
129
|
-
onClick={(event) => event.stopPropagation()}
|
|
130
|
-
onKeyDown={(event) => {
|
|
131
|
-
event.stopPropagation()
|
|
132
|
-
if (event.key === "Enter") {
|
|
133
|
-
event.preventDefault()
|
|
134
|
-
applyValue()
|
|
135
|
-
}
|
|
136
|
-
}}
|
|
137
|
-
/>
|
|
138
|
-
<div className="flex items-center justify-end gap-2">
|
|
139
|
-
{active ? (
|
|
140
|
-
<Button
|
|
141
|
-
type="button"
|
|
142
|
-
variant="ghost"
|
|
143
|
-
size="sm"
|
|
144
|
-
className="h-7 px-2 text-xs"
|
|
145
|
-
onClick={(event) => {
|
|
146
|
-
event.preventDefault()
|
|
147
|
-
event.stopPropagation()
|
|
148
|
-
setDraftValue("")
|
|
149
|
-
onValueChange?.(category.id, "")
|
|
150
|
-
}}
|
|
151
|
-
>
|
|
152
|
-
Clear
|
|
153
|
-
</Button>
|
|
154
|
-
) : null}
|
|
155
|
-
<Button
|
|
156
|
-
type="button"
|
|
157
|
-
size="sm"
|
|
158
|
-
className="h-7 px-2 text-xs"
|
|
159
|
-
onClick={(event) => {
|
|
160
|
-
event.preventDefault()
|
|
161
|
-
event.stopPropagation()
|
|
162
|
-
applyValue()
|
|
163
|
-
}}
|
|
164
|
-
>
|
|
165
|
-
Apply
|
|
166
|
-
</Button>
|
|
167
|
-
</div>
|
|
168
|
-
</div>
|
|
169
|
-
</DropdownMenuSubContent>
|
|
170
|
-
</DropdownMenuSub>
|
|
171
|
-
)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
53
|
export interface DataTableFilterProps {
|
|
175
54
|
categories: DataTableFilterCategory[]
|
|
176
55
|
selectedFilters: Record<string, string[]>
|
|
@@ -192,10 +71,6 @@ export interface DataTableFilterProps {
|
|
|
192
71
|
onConditionFiltersChange?: (conditions: ConditionFilterValue[]) => void
|
|
193
72
|
/** Dropdown entry label for the condition-builder panel. Default: "Add filter". */
|
|
194
73
|
conditionBuilderLabel?: string
|
|
195
|
-
/** Active free-text filters keyed by category id. */
|
|
196
|
-
textFilters?: Record<string, string>
|
|
197
|
-
/** Callback when a free-text filter value is applied or cleared. */
|
|
198
|
-
onTextFilterChange?: (categoryId: string, value: string) => void
|
|
199
74
|
}
|
|
200
75
|
|
|
201
76
|
export function DataTableFilter({
|
|
@@ -211,8 +86,6 @@ export function DataTableFilter({
|
|
|
211
86
|
conditionFilters = [],
|
|
212
87
|
onConditionFiltersChange,
|
|
213
88
|
conditionBuilderLabel = "Add filter",
|
|
214
|
-
textFilters = {},
|
|
215
|
-
onTextFilterChange,
|
|
216
89
|
}: DataTableFilterProps) {
|
|
217
90
|
const [query, setQuery] = React.useState("")
|
|
218
91
|
const [subQueries, setSubQueries] = React.useState<Record<string, string>>({})
|
|
@@ -230,10 +103,6 @@ export function DataTableFilter({
|
|
|
230
103
|
return true
|
|
231
104
|
}
|
|
232
105
|
|
|
233
|
-
if (isTextFilterCategory(category)) {
|
|
234
|
-
return false
|
|
235
|
-
}
|
|
236
|
-
|
|
237
106
|
return category.options.some((option) =>
|
|
238
107
|
getOptionLabel(option).toLowerCase().includes(normalized)
|
|
239
108
|
)
|
|
@@ -254,16 +123,8 @@ export function DataTableFilter({
|
|
|
254
123
|
0
|
|
255
124
|
)
|
|
256
125
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
return count
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
return textFilters[category.id]?.trim() ? count + 1 : count
|
|
263
|
-
}, 0)
|
|
264
|
-
|
|
265
|
-
return userCount + conditionFilters.length + textCount
|
|
266
|
-
}, [categories, selectedFilters, conditionFilters.length, textFilters])
|
|
126
|
+
return userCount + conditionFilters.length
|
|
127
|
+
}, [selectedFilters, conditionFilters.length])
|
|
267
128
|
|
|
268
129
|
/** Collect all preset chips to render */
|
|
269
130
|
const presetChips = React.useMemo(() => {
|
|
@@ -274,9 +135,9 @@ export function DataTableFilter({
|
|
|
274
135
|
for (const [categoryId, values] of Object.entries(presetFilters)) {
|
|
275
136
|
const category = categories.find((c) => c.id === categoryId)
|
|
276
137
|
for (const value of values) {
|
|
277
|
-
const option = category
|
|
278
|
-
|
|
279
|
-
|
|
138
|
+
const option = category?.options.find(
|
|
139
|
+
(opt) => getOptionValue(opt) === value
|
|
140
|
+
)
|
|
280
141
|
const label = option ? getOptionLabel(option) : value
|
|
281
142
|
const active = selectedFilters[categoryId]?.includes(value) ?? false
|
|
282
143
|
chips.push({ categoryId, value, label, active })
|
|
@@ -347,18 +208,6 @@ export function DataTableFilter({
|
|
|
347
208
|
)
|
|
348
209
|
}
|
|
349
210
|
|
|
350
|
-
/* ── Free-text submenu ───────────────────────────────── */
|
|
351
|
-
if (isTextFilterCategory(category)) {
|
|
352
|
-
return (
|
|
353
|
-
<TextFilterSubmenu
|
|
354
|
-
key={category.id}
|
|
355
|
-
category={category}
|
|
356
|
-
value={textFilters[category.id] ?? ""}
|
|
357
|
-
onValueChange={onTextFilterChange}
|
|
358
|
-
/>
|
|
359
|
-
)
|
|
360
|
-
}
|
|
361
|
-
|
|
362
211
|
/* ── Sub-menu (single / multi) ──────────────────────── */
|
|
363
212
|
const subQuery = (subQueries[category.id] ?? "").trim().toLowerCase()
|
|
364
213
|
const filteredOptions = subQuery
|
|
@@ -73,6 +73,32 @@ const approveReasons = [
|
|
|
73
73
|
|
|
74
74
|
type ApprovalState = "pending" | "confirming" | "creating" | "approving-feedback" | "dismissing" | "approved" | "dismissed" | "auto-approved"
|
|
75
75
|
|
|
76
|
+
interface OpportunityPreviewOption {
|
|
77
|
+
value: string
|
|
78
|
+
label: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface OpportunityPreview {
|
|
82
|
+
name: string
|
|
83
|
+
stage: string
|
|
84
|
+
closeDate: string
|
|
85
|
+
closeDateValue?: string
|
|
86
|
+
amount: string
|
|
87
|
+
/** Raw draft input value. Numeric values render as currency in the editable field. */
|
|
88
|
+
amountValue?: string | number | null
|
|
89
|
+
accountName: string
|
|
90
|
+
description?: string | null
|
|
91
|
+
churnType?: string | null
|
|
92
|
+
churnTypeOptions?: Array<string | OpportunityPreviewOption>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface OpportunityDraft {
|
|
96
|
+
closeDate: string
|
|
97
|
+
amount: string
|
|
98
|
+
description: string
|
|
99
|
+
churnType: string
|
|
100
|
+
}
|
|
101
|
+
|
|
76
102
|
interface SignalApprovalLabels {
|
|
77
103
|
approveButton?: string
|
|
78
104
|
dismissButton?: string
|
|
@@ -106,9 +132,9 @@ interface SignalApprovalContextValue {
|
|
|
106
132
|
labels: Required<SignalApprovalLabels>
|
|
107
133
|
hideApproveButton?: boolean
|
|
108
134
|
approveButtonIconUrl?: string
|
|
109
|
-
opportunityPreview?:
|
|
135
|
+
opportunityPreview?: OpportunityPreview
|
|
110
136
|
requestingApproval: boolean
|
|
111
|
-
approve: () => void
|
|
137
|
+
approve: (draft?: OpportunityDraft) => void
|
|
112
138
|
submitApproveFeedback: (reasons: string[], detail: string) => void
|
|
113
139
|
skipApproveFeedback: () => void
|
|
114
140
|
dismiss: (reasons: string[], detail: string, subReason?: string) => void
|
|
@@ -137,13 +163,7 @@ interface RootProps {
|
|
|
137
163
|
/** Optional icon URL for the approve button. Renders an img instead of CirclePlus when provided. */
|
|
138
164
|
approveButtonIconUrl?: string
|
|
139
165
|
/** Optional structured preview data shown in the confirmation dialog. */
|
|
140
|
-
opportunityPreview?:
|
|
141
|
-
name: string
|
|
142
|
-
stage: string
|
|
143
|
-
closeDate: string
|
|
144
|
-
amount: string
|
|
145
|
-
accountName: string
|
|
146
|
-
}
|
|
166
|
+
opportunityPreview?: OpportunityPreview
|
|
147
167
|
/**
|
|
148
168
|
* Async callback fired when the user clicks the approve button, BEFORE
|
|
149
169
|
* transitioning to the "confirming" state. While the promise is pending,
|
|
@@ -160,7 +180,7 @@ interface RootProps {
|
|
|
160
180
|
* "creating" loading state while the promise is pending. On `true` it
|
|
161
181
|
* transitions to the feedback step; on `false` it reverts to "pending".
|
|
162
182
|
*/
|
|
163
|
-
onApprove?: () => void | Promise<boolean>
|
|
183
|
+
onApprove?: (draft?: OpportunityDraft) => void | Promise<boolean>
|
|
164
184
|
onApproveFeedback?: (reasons: string[], detail: string) => void
|
|
165
185
|
onDismiss?: (reasons: string[], detail: string, subReason?: string) => void
|
|
166
186
|
}
|
|
@@ -205,8 +225,8 @@ function Root({ children, companyName, opportunityUrl, scheduledTime, initialApp
|
|
|
205
225
|
setApprovalState("pending")
|
|
206
226
|
}, [])
|
|
207
227
|
|
|
208
|
-
const approve = React.useCallback(() => {
|
|
209
|
-
const result = onApprove?.()
|
|
228
|
+
const approve = React.useCallback((draft?: OpportunityDraft) => {
|
|
229
|
+
const result = onApprove?.(draft)
|
|
210
230
|
// If the callback returns a Promise, show a loading state and wait for it.
|
|
211
231
|
if (result && typeof (result as Promise<boolean>).then === "function") {
|
|
212
232
|
setApprovalState("creating")
|
|
@@ -417,6 +437,43 @@ function SubmittedFeedback({
|
|
|
417
437
|
)
|
|
418
438
|
}
|
|
419
439
|
|
|
440
|
+
function optionValue(option: string | OpportunityPreviewOption): string {
|
|
441
|
+
return typeof option === "string" ? option : option.value
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function optionLabel(option: string | OpportunityPreviewOption): string {
|
|
445
|
+
return typeof option === "string" ? option : option.label
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function formatAmountDraftValue(value: string | number | null | undefined): string {
|
|
449
|
+
if (value == null || value === "") return ""
|
|
450
|
+
if (typeof value === "number") {
|
|
451
|
+
return new Intl.NumberFormat("en-US", {
|
|
452
|
+
style: "currency",
|
|
453
|
+
currency: "USD",
|
|
454
|
+
maximumFractionDigits: 0,
|
|
455
|
+
}).format(value)
|
|
456
|
+
}
|
|
457
|
+
return value
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function buildOpportunityDraft(preview?: OpportunityPreview): OpportunityDraft {
|
|
461
|
+
return {
|
|
462
|
+
closeDate: preview?.closeDateValue ?? preview?.closeDate ?? "",
|
|
463
|
+
amount: preview?.amountValue == null ? "" : formatAmountDraftValue(preview.amountValue),
|
|
464
|
+
description: preview?.description ?? "",
|
|
465
|
+
churnType: preview?.churnType ?? "",
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function isValidDateInput(value: string): boolean {
|
|
470
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
|
|
471
|
+
if (!match) return false
|
|
472
|
+
const parsed = new Date(`${value}T00:00:00Z`)
|
|
473
|
+
if (Number.isNaN(parsed.getTime())) return false
|
|
474
|
+
return parsed.toISOString().slice(0, 10) === value
|
|
475
|
+
}
|
|
476
|
+
|
|
420
477
|
function Actions() {
|
|
421
478
|
const { approvalState, companyName, opportunityUrl, scheduledTime, labels, hideApproveButton, approveButtonIconUrl, opportunityPreview, requestingApproval, approve, submitApproveFeedback, skipApproveFeedback, dismiss, requestApproval, requestDismiss, cancel } =
|
|
422
479
|
useSignalApproval()
|
|
@@ -426,6 +483,16 @@ function Actions() {
|
|
|
426
483
|
const [detailText, setDetailText] = React.useState("")
|
|
427
484
|
const [submittedFeedback, setSubmittedFeedback] = React.useState<{ reasons: string[]; detail: string; subReason?: string } | null>(null)
|
|
428
485
|
const [isEditing, setIsEditing] = React.useState(false)
|
|
486
|
+
const [opportunityDraft, setOpportunityDraft] = React.useState<OpportunityDraft>(() => buildOpportunityDraft(opportunityPreview))
|
|
487
|
+
|
|
488
|
+
React.useEffect(() => {
|
|
489
|
+
if (approvalState === "confirming") {
|
|
490
|
+
setOpportunityDraft(buildOpportunityDraft(opportunityPreview))
|
|
491
|
+
}
|
|
492
|
+
}, [approvalState, opportunityPreview])
|
|
493
|
+
|
|
494
|
+
const churnTypeOptions = opportunityPreview?.churnTypeOptions ?? []
|
|
495
|
+
const hasChurnTypeOptions = churnTypeOptions.length > 0
|
|
429
496
|
|
|
430
497
|
const topNode = dismissReasonTree.find((n) => n.label === selectedTopReason)
|
|
431
498
|
const hasSubOptions = !!(topNode?.subOptions && topNode.subOptions.length > 0)
|
|
@@ -438,6 +505,7 @@ function Actions() {
|
|
|
438
505
|
(!needsText || detailText.trim().length > 0)
|
|
439
506
|
|
|
440
507
|
const canSubmitApprove = selectedReasons.length > 0 || detailText.trim().length > 0
|
|
508
|
+
const canConfirmOpportunity = !opportunityPreview || isValidDateInput(opportunityDraft.closeDate)
|
|
441
509
|
|
|
442
510
|
const selectTopReason = (label: string) => {
|
|
443
511
|
if (selectedTopReason === label) {
|
|
@@ -740,27 +808,91 @@ function Actions() {
|
|
|
740
808
|
{labels.confirmPrompt} <strong>{companyName}</strong>. Confirm?
|
|
741
809
|
</p>
|
|
742
810
|
{opportunityPreview && (
|
|
743
|
-
<div className="mt-3 space-y-
|
|
811
|
+
<div className="mt-3 space-y-3 border-t border-border/50 pt-3">
|
|
744
812
|
{[
|
|
745
813
|
{ label: "Opportunity", value: opportunityPreview.name },
|
|
746
814
|
{ label: "Account", value: opportunityPreview.accountName },
|
|
747
815
|
{ label: "Stage", value: opportunityPreview.stage },
|
|
748
|
-
{ label: "Close Date", value: opportunityPreview.closeDate },
|
|
749
|
-
{ label: "Amount", value: opportunityPreview.amount },
|
|
750
816
|
].map(({ label, value }) => (
|
|
751
|
-
<div key={label} className="flex items-center justify-between text-xs">
|
|
817
|
+
<div key={label} className="flex items-center justify-between gap-3 text-xs">
|
|
752
818
|
<span className="text-muted-foreground">{label}</span>
|
|
753
|
-
<span className="font-medium text-foreground">{value}</span>
|
|
819
|
+
<span className="text-right font-medium text-foreground">{value}</span>
|
|
754
820
|
</div>
|
|
755
821
|
))}
|
|
822
|
+
|
|
823
|
+
<div className="grid gap-2 sm:grid-cols-2">
|
|
824
|
+
<label className="space-y-1 text-xs">
|
|
825
|
+
<span className="font-medium text-muted-foreground">Close Date</span>
|
|
826
|
+
<input
|
|
827
|
+
type="date"
|
|
828
|
+
value={opportunityDraft.closeDate}
|
|
829
|
+
onChange={(event) => setOpportunityDraft((draft) => ({ ...draft, closeDate: event.target.value }))}
|
|
830
|
+
aria-invalid={!canConfirmOpportunity}
|
|
831
|
+
className="h-8 w-full rounded-md border border-border bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
|
832
|
+
/>
|
|
833
|
+
{!canConfirmOpportunity && (
|
|
834
|
+
<span className="text-[11px] text-red-600">Enter a valid close date.</span>
|
|
835
|
+
)}
|
|
836
|
+
</label>
|
|
837
|
+
|
|
838
|
+
<label className="space-y-1 text-xs">
|
|
839
|
+
<span className="font-medium text-muted-foreground">Amount</span>
|
|
840
|
+
<input
|
|
841
|
+
type="text"
|
|
842
|
+
inputMode="decimal"
|
|
843
|
+
value={opportunityDraft.amount}
|
|
844
|
+
onChange={(event) => setOpportunityDraft((draft) => ({ ...draft, amount: event.target.value }))}
|
|
845
|
+
placeholder={opportunityPreview.amount}
|
|
846
|
+
className="h-8 w-full rounded-md border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring"
|
|
847
|
+
/>
|
|
848
|
+
</label>
|
|
849
|
+
</div>
|
|
850
|
+
|
|
851
|
+
<label className="space-y-1 text-xs">
|
|
852
|
+
<span className="font-medium text-muted-foreground">Churn Type</span>
|
|
853
|
+
{hasChurnTypeOptions ? (
|
|
854
|
+
<select
|
|
855
|
+
value={opportunityDraft.churnType}
|
|
856
|
+
onChange={(event) => setOpportunityDraft((draft) => ({ ...draft, churnType: event.target.value }))}
|
|
857
|
+
className="h-8 w-full rounded-md border border-border bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
|
858
|
+
>
|
|
859
|
+
<option value="">No churn type</option>
|
|
860
|
+
{churnTypeOptions.map((option) => (
|
|
861
|
+
<option key={optionValue(option)} value={optionValue(option)}>
|
|
862
|
+
{optionLabel(option)}
|
|
863
|
+
</option>
|
|
864
|
+
))}
|
|
865
|
+
</select>
|
|
866
|
+
) : (
|
|
867
|
+
<input
|
|
868
|
+
type="text"
|
|
869
|
+
value={opportunityDraft.churnType}
|
|
870
|
+
onChange={(event) => setOpportunityDraft((draft) => ({ ...draft, churnType: event.target.value }))}
|
|
871
|
+
placeholder="No churn type"
|
|
872
|
+
className="h-8 w-full rounded-md border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring"
|
|
873
|
+
/>
|
|
874
|
+
)}
|
|
875
|
+
</label>
|
|
876
|
+
|
|
877
|
+
<label className="space-y-1 text-xs">
|
|
878
|
+
<span className="font-medium text-muted-foreground">Description</span>
|
|
879
|
+
<textarea
|
|
880
|
+
value={opportunityDraft.description}
|
|
881
|
+
onChange={(event) => setOpportunityDraft((draft) => ({ ...draft, description: event.target.value }))}
|
|
882
|
+
rows={3}
|
|
883
|
+
placeholder="Add a short description"
|
|
884
|
+
className="w-full resize-none rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring"
|
|
885
|
+
/>
|
|
886
|
+
</label>
|
|
756
887
|
</div>
|
|
757
888
|
)}
|
|
758
889
|
</div>
|
|
759
890
|
<div className="flex items-center gap-2">
|
|
760
891
|
<button
|
|
761
892
|
type="button"
|
|
762
|
-
onClick={approve}
|
|
763
|
-
|
|
893
|
+
onClick={() => approve(opportunityDraft)}
|
|
894
|
+
disabled={!canConfirmOpportunity}
|
|
895
|
+
className="inline-flex h-7 items-center gap-1.5 rounded-md bg-foreground px-3 text-xs font-semibold text-background transition-colors hover:bg-foreground/90 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground"
|
|
764
896
|
>
|
|
765
897
|
<Check className="h-3 w-3" />
|
|
766
898
|
Confirm
|
|
@@ -861,5 +993,4 @@ export {
|
|
|
861
993
|
Gate as SignalApprovalGate,
|
|
862
994
|
}
|
|
863
995
|
export const SignalApproval = { Root, Actions, Gate }
|
|
864
|
-
export type OpportunityPreview
|
|
865
|
-
export type { ApprovalState, SignalApprovalLabels, SignalApprovalContextValue, RootProps as SignalApprovalRootProps }
|
|
996
|
+
export type { ApprovalState, OpportunityPreview, OpportunityDraft, SignalApprovalLabels, SignalApprovalContextValue, RootProps as SignalApprovalRootProps }
|
|
@@ -180,7 +180,7 @@ export interface InboxViewConfig {
|
|
|
180
180
|
quickFilterTabs?: Array<{ id: string; label: string; matchValue?: string; count?: number }>
|
|
181
181
|
hideAccountsButton?: boolean
|
|
182
182
|
accountDetailsLabel?: string
|
|
183
|
-
onSignalApprove?: (item: QueueItem) => void | Promise<boolean>
|
|
183
|
+
onSignalApprove?: (item: QueueItem, draft?: import("../components/signal-feedback-inline").OpportunityDraft) => void | Promise<boolean>
|
|
184
184
|
getSignalApprovalState?: (item: QueueItem) => ApprovalState | undefined
|
|
185
185
|
signalLabels?: {
|
|
186
186
|
approveButton?: string
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
type InboxFilterCategory,
|
|
39
39
|
} from "../components/inbox-toolbar"
|
|
40
40
|
import { GroupedListView, type GroupedListGroup } from "../components/item-list"
|
|
41
|
-
import { SignalApproval, type ApprovalState, type OpportunityPreview } from "../components/signal-feedback-inline"
|
|
41
|
+
import { SignalApproval, type ApprovalState, type OpportunityDraft, type OpportunityPreview } from "../components/signal-feedback-inline"
|
|
42
42
|
import { ScoreWhyChips } from "../components/score-why-chips"
|
|
43
43
|
import { SignalPriorityPopover } from "../components/signal-priority-popover"
|
|
44
44
|
import { type SourceDef } from "../components/detail-view"
|
|
@@ -149,7 +149,7 @@ export interface DetailViewProps {
|
|
|
149
149
|
onSuggestedActionFeedback?: (actionId: number | string, feedback: string, actionTitle?: string) => void
|
|
150
150
|
/** @deprecated The compact score UX no longer renders score-level thumbs by default. */
|
|
151
151
|
onScoreFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
|
|
152
|
-
onSignalApprove?: (item: QueueItem) => void | Promise<boolean>
|
|
152
|
+
onSignalApprove?: (item: QueueItem, draft?: OpportunityDraft) => void | Promise<boolean>
|
|
153
153
|
getSignalApprovalState?: (item: QueueItem) => ApprovalState | undefined
|
|
154
154
|
signalLabels?: InboxViewConfig["signalLabels"]
|
|
155
155
|
hideApproveButton?: boolean
|
|
@@ -475,7 +475,7 @@ export function DetailView({
|
|
|
475
475
|
opportunityPreview={opportunityPreview}
|
|
476
476
|
onRequestApproval={onRequestApproval}
|
|
477
477
|
initialApprovalState={getSignalApprovalState?.(item)}
|
|
478
|
-
onApprove={() => onSignalApprove?.(item)}
|
|
478
|
+
onApprove={(draft) => onSignalApprove?.(item, draft)}
|
|
479
479
|
onApproveFeedback={(reasons, detail) => {
|
|
480
480
|
signalData.onApproveFeedback?.(reasons, detail)
|
|
481
481
|
console.log("Approval feedback:", { taskId: item.id, company: item.company, reasons, detail })
|