@visns-studio/visns-components 6.17.2 → 6.20.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.
- package/package.json +10 -7
- package/src/components/AssociationManager.jsx +344 -177
- package/src/components/AsyncSelect.jsx +2 -2
- package/src/components/Autocomplete.jsx +57 -12
- package/src/components/Breadcrumb.jsx +23 -4
- package/src/components/ClientAssociationManager.jsx +321 -145
- package/src/components/DataGrid.jsx +625 -20
- package/src/components/DropZone.jsx +4 -1
- package/src/components/Form.jsx +233 -6
- package/src/components/MultiSelect.jsx +2 -2
- package/src/components/Navigation.jsx +39 -23
- package/src/components/TableFilter.jsx +172 -12
- package/src/components/UniversalSearch.jsx +390 -0
- package/src/components/auth/Login.jsx +164 -8
- package/src/components/auth/Profile.jsx +209 -22
- package/src/components/auth/authEndpoints.js +4 -0
- package/src/components/auth/webauthn.js +194 -0
- package/src/components/columns/ColumnRenderers.jsx +6 -1
- package/src/components/controls/DataGridSearch.jsx +11 -5
- package/src/components/generic/GenericAuth.jsx +6 -0
- package/src/components/generic/GenericDashboard.jsx +93 -36
- package/src/components/generic/GenericDetail.jsx +146 -61
- package/src/components/generic/GenericIndex.jsx +142 -38
- package/src/components/generic/GenericSettings.jsx +220 -0
- package/src/components/generic/StandardModal.jsx +165 -8
- package/src/components/generic/dashboardChartDefaults.js +230 -0
- package/src/components/generic/shared/indexLayout.js +38 -0
- package/src/components/settings/IntegrationsManager.jsx +474 -0
- package/src/components/sms/SmsLineSettings.jsx +16 -12
- package/src/components/styles/AssociationManager.module.scss +4 -363
- package/src/components/styles/AuditLog.module.scss +6 -6
- package/src/components/styles/AuditLogs.module.scss +3 -3
- package/src/components/styles/Autocomplete.module.scss +7 -7
- package/src/components/styles/BusinessCardOcr.module.scss +6 -1
- package/src/components/styles/CallQueueSettings.module.scss +4 -1
- package/src/components/styles/CameraPlacement.module.scss +19 -19
- package/src/components/styles/CategorizedDropZone.module.scss +6 -1
- package/src/components/styles/ClientAssociationManager.module.scss +5 -289
- package/src/components/styles/ClientAuth.module.scss +1 -1
- package/src/components/styles/DataGrid.module.scss +455 -8
- package/src/components/styles/Field.module.scss +32 -24
- package/src/components/styles/Form.module.scss +123 -3
- package/src/components/styles/GenericDashboard.module.scss +612 -825
- package/src/components/styles/GenericDetail.module.scss +406 -61
- package/src/components/styles/GenericDynamic.module.scss +25 -25
- package/src/components/styles/GenericEditableTable.module.scss +33 -33
- package/src/components/styles/GenericFormBuilder.module.scss +119 -119
- package/src/components/styles/GenericIndex.module.scss +483 -38
- package/src/components/styles/GenericMain.module.scss +6 -3
- package/src/components/styles/GenericQuote.module.scss +399 -81
- package/src/components/styles/GenericReport.module.scss +8 -8
- package/src/components/styles/GenericSettings.module.scss +209 -0
- package/src/components/styles/GenericSort.module.scss +9 -9
- package/src/components/styles/Integrations.module.scss +321 -0
- package/src/components/styles/List.module.scss +4 -4
- package/src/components/styles/Login.module.scss +57 -0
- package/src/components/styles/MultiSelect.module.scss +4 -4
- package/src/components/styles/Navigation.module.scss +12 -12
- package/src/components/styles/Notification.module.scss +12 -8
- package/src/components/styles/NotificationList.module.scss +5 -5
- package/src/components/styles/Profile.module.scss +5 -5
- package/src/components/styles/QrCode.module.scss +4 -4
- package/src/components/styles/QuickAction.module.scss +20 -20
- package/src/components/styles/SortableQuoteItems.module.scss +4 -4
- package/src/components/styles/TableFilter.module.scss +128 -17
- package/src/components/styles/TicketConversation.module.scss +497 -0
- package/src/components/styles/UniversalSearch.module.scss +229 -0
- package/src/components/styles/Vault.module.scss +413 -0
- package/src/components/styles/_associationManager.scss +467 -0
- package/src/components/styles/_authBrandPanel.scss +15 -9
- package/src/components/styles/_controls.scss +43 -39
- package/src/components/styles/global-datagrid.css +196 -0
- package/src/components/styles/global.css +168 -17
- package/src/components/tickets/TicketConversation.jsx +455 -0
- package/src/components/utils/rowActionSettings.js +117 -0
- package/src/components/vault/VaultClientPicker.jsx +183 -0
- package/src/components/vault/VaultEntryForm.jsx +23 -0
- package/src/components/vault/VaultManager.jsx +382 -34
- package/src/components/vault/VaultShareModal.jsx +454 -0
- package/src/components/vault/vaultEndpoints.js +16 -2
- package/src/components/vault/vaultShares.js +247 -0
- package/src/index.js +35 -0
- package/src/utils/fetchUtil.js +38 -14
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
AlertTriangle,
|
|
4
|
+
ArrowDownLeft,
|
|
5
|
+
ArrowUpRight,
|
|
6
|
+
Loader2,
|
|
7
|
+
Lock,
|
|
8
|
+
ImageOff,
|
|
9
|
+
Mail,
|
|
10
|
+
Paperclip,
|
|
11
|
+
Phone,
|
|
12
|
+
Type,
|
|
13
|
+
Globe,
|
|
14
|
+
} from 'lucide-react';
|
|
15
|
+
|
|
16
|
+
import { toast } from 'react-toastify';
|
|
17
|
+
import parse, { attributesToProps } from 'html-react-parser';
|
|
18
|
+
|
|
19
|
+
import CustomFetch from '../Fetch';
|
|
20
|
+
import styles from '../styles/TicketConversation.module.scss';
|
|
21
|
+
|
|
22
|
+
const CHANNEL_ICON = {
|
|
23
|
+
Email: Mail,
|
|
24
|
+
Phone: Phone,
|
|
25
|
+
Web: Globe,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A support ticket's conversation.
|
|
30
|
+
*
|
|
31
|
+
* The three kinds of entry are visually distinct because confusing them has a
|
|
32
|
+
* cost, and the costs are not symmetrical:
|
|
33
|
+
*
|
|
34
|
+
* in — the customer wrote it. Left, plain surface.
|
|
35
|
+
* out — we wrote it. Left-accented, tinted, so a reply is obvious at a
|
|
36
|
+
* glance when scanning a long thread.
|
|
37
|
+
* note — an internal note. Marked with a lock and its own colour, because
|
|
38
|
+
* an agent must never mistake one for something the customer has
|
|
39
|
+
* seen. That is the single most expensive mistake in a helpdesk.
|
|
40
|
+
*
|
|
41
|
+
* Ordered by when a message was SENT, not when the sync imported it. The
|
|
42
|
+
* server sorts on `posted_at`; this component does not re-sort.
|
|
43
|
+
*/
|
|
44
|
+
const TicketConversation = ({
|
|
45
|
+
ticketId,
|
|
46
|
+
base = '/ajax/tickets',
|
|
47
|
+
onLoaded,
|
|
48
|
+
canReply = true,
|
|
49
|
+
}) => {
|
|
50
|
+
const [entries, setEntries] = useState([]);
|
|
51
|
+
const [ticket, setTicket] = useState(null);
|
|
52
|
+
const [loading, setLoading] = useState(true);
|
|
53
|
+
const [failed, setFailed] = useState(false);
|
|
54
|
+
const mountedRef = useRef(true);
|
|
55
|
+
|
|
56
|
+
useEffect(() => () => {
|
|
57
|
+
mountedRef.current = false;
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
const load = useCallback(() => {
|
|
61
|
+
if (!ticketId) return;
|
|
62
|
+
|
|
63
|
+
setLoading(true);
|
|
64
|
+
setFailed(false);
|
|
65
|
+
|
|
66
|
+
CustomFetch(
|
|
67
|
+
`${base}/${ticketId}/conversation`,
|
|
68
|
+
'GET',
|
|
69
|
+
{},
|
|
70
|
+
(result) => {
|
|
71
|
+
if (!mountedRef.current) return;
|
|
72
|
+
|
|
73
|
+
setEntries(Array.isArray(result?.data) ? result.data : []);
|
|
74
|
+
setTicket(result?.ticket ?? null);
|
|
75
|
+
setLoading(false);
|
|
76
|
+
onLoaded?.(result?.ticket ?? null);
|
|
77
|
+
},
|
|
78
|
+
null,
|
|
79
|
+
{ cache: false }
|
|
80
|
+
).catch(() => {
|
|
81
|
+
if (!mountedRef.current) return;
|
|
82
|
+
|
|
83
|
+
setLoading(false);
|
|
84
|
+
setFailed(true);
|
|
85
|
+
});
|
|
86
|
+
}, [base, ticketId, onLoaded]);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
load();
|
|
90
|
+
}, [load]);
|
|
91
|
+
|
|
92
|
+
const counts = useMemo(() => {
|
|
93
|
+
const out = { in: 0, out: 0, note: 0 };
|
|
94
|
+
|
|
95
|
+
entries.forEach((e) => {
|
|
96
|
+
out[e.direction] = (out[e.direction] ?? 0) + 1;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return out;
|
|
100
|
+
}, [entries]);
|
|
101
|
+
|
|
102
|
+
if (loading) {
|
|
103
|
+
return (
|
|
104
|
+
<p className={styles.state}>
|
|
105
|
+
<Loader2 size={16} className={styles.spin} /> Loading conversation…
|
|
106
|
+
</p>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (failed) {
|
|
111
|
+
return (
|
|
112
|
+
<p className={styles.state}>
|
|
113
|
+
<AlertTriangle size={16} /> The conversation could not be loaded.
|
|
114
|
+
</p>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className={styles.wrap}>
|
|
120
|
+
{ticket && <TicketHeader ticket={ticket} counts={counts} />}
|
|
121
|
+
|
|
122
|
+
{entries.length === 0 ? (
|
|
123
|
+
<p className={styles.state}>
|
|
124
|
+
Nothing has been said on this ticket yet.
|
|
125
|
+
</p>
|
|
126
|
+
) : (
|
|
127
|
+
<ol className={styles.thread}>
|
|
128
|
+
{entries.map((entry) => (
|
|
129
|
+
<Entry key={entry.id} entry={entry} />
|
|
130
|
+
))}
|
|
131
|
+
</ol>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{canReply && (
|
|
135
|
+
<Composer
|
|
136
|
+
base={base}
|
|
137
|
+
ticketId={ticketId}
|
|
138
|
+
onPosted={(entry) => setEntries((prev) => [...prev, entry])}
|
|
139
|
+
/>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Reply, or leave an internal note.
|
|
147
|
+
*
|
|
148
|
+
* The two are one control with an explicit switch rather than two buttons,
|
|
149
|
+
* and the switch RESTATES the consequence in words ("the customer will see
|
|
150
|
+
* this" / "only staff can see this") every time. An agent sending an internal
|
|
151
|
+
* note to a customer is the expensive mistake here, and a toggle whose state
|
|
152
|
+
* you have to remember is how it happens.
|
|
153
|
+
*/
|
|
154
|
+
const Composer = ({ base, ticketId, onPosted }) => {
|
|
155
|
+
const [body, setBody] = useState('');
|
|
156
|
+
const [internal, setInternal] = useState(false);
|
|
157
|
+
const [sending, setSending] = useState(false);
|
|
158
|
+
|
|
159
|
+
const send = () => {
|
|
160
|
+
const text = body.trim();
|
|
161
|
+
|
|
162
|
+
if (text === '' || sending) return;
|
|
163
|
+
|
|
164
|
+
setSending(true);
|
|
165
|
+
|
|
166
|
+
CustomFetch(
|
|
167
|
+
`${base}/${ticketId}/reply`,
|
|
168
|
+
'POST',
|
|
169
|
+
{ body: text, internal },
|
|
170
|
+
(result) => {
|
|
171
|
+
setSending(false);
|
|
172
|
+
setBody('');
|
|
173
|
+
toast.success(result?.message || 'Sent');
|
|
174
|
+
|
|
175
|
+
if (result?.data) onPosted(result.data);
|
|
176
|
+
},
|
|
177
|
+
null,
|
|
178
|
+
{ silentStatuses: [422, 502] }
|
|
179
|
+
).catch((error) => {
|
|
180
|
+
setSending(false);
|
|
181
|
+
// 422 and 502 carry the reason — no email to reply to, or
|
|
182
|
+
// Microsoft refused the send — and the reason is the whole point.
|
|
183
|
+
toast.error(error?.data?.message || 'Could not send.');
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div className={`${styles.composer} ${internal ? styles.composerNote : ''}`}>
|
|
189
|
+
<div className={styles.composerBar}>
|
|
190
|
+
<label className={styles.switch}>
|
|
191
|
+
<input
|
|
192
|
+
type="checkbox"
|
|
193
|
+
checked={internal}
|
|
194
|
+
onChange={(e) => setInternal(e.target.checked)}
|
|
195
|
+
/>
|
|
196
|
+
Internal note
|
|
197
|
+
</label>
|
|
198
|
+
|
|
199
|
+
<span className={styles.consequence}>
|
|
200
|
+
{internal
|
|
201
|
+
? 'Only staff can see this.'
|
|
202
|
+
: 'This is emailed to the customer.'}
|
|
203
|
+
</span>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<textarea
|
|
207
|
+
className={styles.composerInput}
|
|
208
|
+
value={body}
|
|
209
|
+
rows={4}
|
|
210
|
+
placeholder={
|
|
211
|
+
internal
|
|
212
|
+
? 'Leave a note for the team…'
|
|
213
|
+
: 'Write a reply to the customer…'
|
|
214
|
+
}
|
|
215
|
+
onChange={(e) => setBody(e.target.value)}
|
|
216
|
+
/>
|
|
217
|
+
|
|
218
|
+
<div className={styles.composerActions}>
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
className={styles.send}
|
|
222
|
+
onClick={send}
|
|
223
|
+
disabled={sending || body.trim() === ''}
|
|
224
|
+
>
|
|
225
|
+
{sending ? 'Sending…' : internal ? 'Add note' : 'Send reply'}
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const TicketHeader = ({ ticket, counts }) => {
|
|
233
|
+
const Channel = CHANNEL_ICON[ticket.channel] ?? Mail;
|
|
234
|
+
const closed = (ticket.status_type || '').toLowerCase() === 'closed';
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<header className={styles.head}>
|
|
238
|
+
<div className={styles.headMain}>
|
|
239
|
+
<p className={styles.reference}>
|
|
240
|
+
{ticket.reference ? `#${ticket.reference}` : `Ticket ${ticket.id}`}
|
|
241
|
+
<span
|
|
242
|
+
className={`${styles.status} ${closed ? styles.closed : styles.open}`}
|
|
243
|
+
>
|
|
244
|
+
{ticket.status_type || 'Unknown'}
|
|
245
|
+
</span>
|
|
246
|
+
</p>
|
|
247
|
+
<h3>{ticket.subject}</h3>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<dl className={styles.meta}>
|
|
251
|
+
{ticket.customer && (
|
|
252
|
+
<div>
|
|
253
|
+
<dt>Client</dt>
|
|
254
|
+
<dd>{ticket.customer}</dd>
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
{ticket.contact && (
|
|
258
|
+
<div>
|
|
259
|
+
<dt>Raised by</dt>
|
|
260
|
+
<dd>{ticket.contact}</dd>
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
263
|
+
{ticket.assignee_name && (
|
|
264
|
+
<div>
|
|
265
|
+
<dt>Assigned to</dt>
|
|
266
|
+
<dd>{ticket.assignee_name}</dd>
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
<div>
|
|
270
|
+
<dt>Channel</dt>
|
|
271
|
+
<dd className={styles.channel}>
|
|
272
|
+
<Channel size={13} strokeWidth={2} />
|
|
273
|
+
{ticket.channel || '—'}
|
|
274
|
+
</dd>
|
|
275
|
+
</div>
|
|
276
|
+
<div>
|
|
277
|
+
<dt>Messages</dt>
|
|
278
|
+
<dd>
|
|
279
|
+
{counts.in} in · {counts.out} out
|
|
280
|
+
{counts.note > 0 && ` · ${counts.note} internal`}
|
|
281
|
+
</dd>
|
|
282
|
+
</div>
|
|
283
|
+
</dl>
|
|
284
|
+
|
|
285
|
+
</header>
|
|
286
|
+
);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* One message's body.
|
|
291
|
+
*
|
|
292
|
+
* `body_html` is the server's SANITISED copy of the email, and the only reason
|
|
293
|
+
* it can be rendered at all: the markup came from a stranger, and it has been
|
|
294
|
+
* through an allowlist on the way in (App\Services\Mail\HtmlEmailSanitizer).
|
|
295
|
+
* This component adds no sanitising of its own and must not be given markup
|
|
296
|
+
* from anywhere else — html-react-parser builds elements, it does not clean
|
|
297
|
+
* them.
|
|
298
|
+
*
|
|
299
|
+
* `body` (plain text) is the fallback and is what every note imported before
|
|
300
|
+
* the column existed still shows. Both are always present in the payload, so
|
|
301
|
+
* the plain-text switch below is free.
|
|
302
|
+
*
|
|
303
|
+
* Remote images arrive with their URL parked in `data-blocked-src` and no
|
|
304
|
+
* `src`, so the browser has already declined to fetch them by the time this
|
|
305
|
+
* runs. Swapping them in is a per-message, per-session choice held in state and
|
|
306
|
+
* never persisted — the point of blocking a tracking pixel is that opening the
|
|
307
|
+
* ticket does not announce itself, and a remembered preference would announce
|
|
308
|
+
* it on every message thereafter.
|
|
309
|
+
*/
|
|
310
|
+
export const EntryBody = ({ entry }) => {
|
|
311
|
+
const html = entry.body_html || null;
|
|
312
|
+
const [plain, setPlain] = useState(false);
|
|
313
|
+
const [showImages, setShowImages] = useState(false);
|
|
314
|
+
|
|
315
|
+
const blockedCount = useMemo(() => {
|
|
316
|
+
if (!html) return 0;
|
|
317
|
+
|
|
318
|
+
return (html.match(/data-blocked-src=/g) || []).length;
|
|
319
|
+
}, [html]);
|
|
320
|
+
|
|
321
|
+
const options = useMemo(
|
|
322
|
+
() => ({
|
|
323
|
+
replace: (node) => {
|
|
324
|
+
if (node.type !== 'tag' || node.name !== 'img') return undefined;
|
|
325
|
+
|
|
326
|
+
const blocked = node.attribs?.['data-blocked-src'];
|
|
327
|
+
|
|
328
|
+
if (!blocked) return undefined;
|
|
329
|
+
|
|
330
|
+
const props = attributesToProps(node.attribs);
|
|
331
|
+
|
|
332
|
+
delete props['data-blocked-src'];
|
|
333
|
+
|
|
334
|
+
if (showImages) {
|
|
335
|
+
return <img {...props} src={blocked} />;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// A placeholder rather than nothing: a signature whose logo has
|
|
339
|
+
// silently vanished reads as a broken message, and the reader
|
|
340
|
+
// has no way to know there is a button that would fix it.
|
|
341
|
+
return (
|
|
342
|
+
<span className={styles.blockedImage} title={blocked}>
|
|
343
|
+
{props.alt || 'Image'}
|
|
344
|
+
</span>
|
|
345
|
+
);
|
|
346
|
+
},
|
|
347
|
+
}),
|
|
348
|
+
[showImages]
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
if (!html) {
|
|
352
|
+
return <p className={styles.body}>{entry.body}</p>;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<>
|
|
357
|
+
<div className={styles.bodyBar}>
|
|
358
|
+
{blockedCount > 0 && !showImages && (
|
|
359
|
+
<button
|
|
360
|
+
type="button"
|
|
361
|
+
className={styles.bodyAction}
|
|
362
|
+
onClick={() => setShowImages(true)}
|
|
363
|
+
>
|
|
364
|
+
<ImageOff size={12} strokeWidth={2} />
|
|
365
|
+
Show {blockedCount === 1 ? 'image' : `${blockedCount} images`}
|
|
366
|
+
</button>
|
|
367
|
+
)}
|
|
368
|
+
|
|
369
|
+
{blockedCount > 0 && !showImages && (
|
|
370
|
+
<span className={styles.bodyHint}>
|
|
371
|
+
Remote images are blocked — they tell the sender you
|
|
372
|
+
opened this.
|
|
373
|
+
</span>
|
|
374
|
+
)}
|
|
375
|
+
|
|
376
|
+
<button
|
|
377
|
+
type="button"
|
|
378
|
+
className={styles.bodyAction}
|
|
379
|
+
onClick={() => setPlain((v) => !v)}
|
|
380
|
+
>
|
|
381
|
+
<Type size={12} strokeWidth={2} />
|
|
382
|
+
{plain ? 'Formatted' : 'Plain text'}
|
|
383
|
+
</button>
|
|
384
|
+
</div>
|
|
385
|
+
|
|
386
|
+
{plain ? (
|
|
387
|
+
<p className={styles.body}>{entry.body}</p>
|
|
388
|
+
) : (
|
|
389
|
+
<div className={styles.bodyHtml}>{parse(html, options)}</div>
|
|
390
|
+
)}
|
|
391
|
+
</>
|
|
392
|
+
);
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const Entry = ({ entry }) => {
|
|
396
|
+
const isNote = entry.direction === 'note';
|
|
397
|
+
const isInbound = entry.direction === 'in';
|
|
398
|
+
const Icon = isNote ? Lock : isInbound ? ArrowDownLeft : ArrowUpRight;
|
|
399
|
+
|
|
400
|
+
const cls = isNote
|
|
401
|
+
? styles.entryNote
|
|
402
|
+
: isInbound
|
|
403
|
+
? styles.entryIn
|
|
404
|
+
: styles.entryOut;
|
|
405
|
+
|
|
406
|
+
return (
|
|
407
|
+
<li className={`${styles.entry} ${cls}`}>
|
|
408
|
+
<div className={styles.entryHead}>
|
|
409
|
+
<span className={styles.entryIcon} aria-hidden="true">
|
|
410
|
+
<Icon size={13} strokeWidth={2.5} />
|
|
411
|
+
</span>
|
|
412
|
+
|
|
413
|
+
<span className={styles.author}>
|
|
414
|
+
{entry.author_name || entry.author_email || 'Unknown'}
|
|
415
|
+
</span>
|
|
416
|
+
|
|
417
|
+
{/* The label is the point: never colour alone, and never
|
|
418
|
+
leave "is the customer able to see this?" to inference. */}
|
|
419
|
+
<span className={styles.tag}>
|
|
420
|
+
{isNote
|
|
421
|
+
? 'Internal note'
|
|
422
|
+
: isInbound
|
|
423
|
+
? 'From customer'
|
|
424
|
+
: 'Reply sent'}
|
|
425
|
+
</span>
|
|
426
|
+
|
|
427
|
+
{entry.visibility === 'private' && !isNote && (
|
|
428
|
+
<span className={styles.privateTag}>
|
|
429
|
+
<Lock size={10} /> Private
|
|
430
|
+
</span>
|
|
431
|
+
)}
|
|
432
|
+
|
|
433
|
+
{entry.is_first_message && (
|
|
434
|
+
<span className={styles.firstTag}>Original request</span>
|
|
435
|
+
)}
|
|
436
|
+
|
|
437
|
+
{entry.attachment_count > 0 && (
|
|
438
|
+
<span className={styles.attachTag}>
|
|
439
|
+
<Paperclip size={11} /> {entry.attachment_count}
|
|
440
|
+
</span>
|
|
441
|
+
)}
|
|
442
|
+
|
|
443
|
+
{entry.posted_at && (
|
|
444
|
+
<time className={styles.when} dateTime={entry.posted_at}>
|
|
445
|
+
{new Date(entry.posted_at).toLocaleString()}
|
|
446
|
+
</time>
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
<EntryBody entry={entry} />
|
|
451
|
+
</li>
|
|
452
|
+
);
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
export default TicketConversation;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The grid's trailing "Action" column used to render one kind of thing: a
|
|
3
|
+
* shared setting (`update`, `delete`, `view`, …) named by id in the root
|
|
4
|
+
* `settings` array and drawn by DataGrid's own renderSetting.
|
|
5
|
+
*
|
|
6
|
+
* A view often wants one more verb in that same cell — "mark as complete",
|
|
7
|
+
* "mark as spam", "assign to" — which is not a shared setting and never will
|
|
8
|
+
* be, because the endpoint behind it belongs to that one view. The alternative
|
|
9
|
+
* was a second `type: "action"` column sitting beside the Action column, i.e.
|
|
10
|
+
* two action columns per grid.
|
|
11
|
+
*
|
|
12
|
+
* So a root `settings` entry may now ALSO be an inline row-action config, the
|
|
13
|
+
* same object shape an `action` column already accepts:
|
|
14
|
+
*
|
|
15
|
+
* { id, icon, label, show?, confirm?, fetch: { url, method?, pathKey?, … } }
|
|
16
|
+
*
|
|
17
|
+
* and its picker variant, which opens a menu instead of a confirm dialog:
|
|
18
|
+
*
|
|
19
|
+
* { id, icon, label, optionsUrl, optionsParams?, fetch: { …, bodyKey } }
|
|
20
|
+
*
|
|
21
|
+
* Telling the two apart is by SHAPE, not by an opt-in flag: a shared setting
|
|
22
|
+
* is identified by its id and so never carries a `fetch`/`optionsUrl` of its
|
|
23
|
+
* own. Every settings array written before this change therefore partitions
|
|
24
|
+
* entirely into `standard` and behaves exactly as it did.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* True for a root `settings` entry that is an inline row action rather than
|
|
29
|
+
* the id of a shared setting.
|
|
30
|
+
*/
|
|
31
|
+
export const isInlineRowActionSetting = (entry) =>
|
|
32
|
+
Boolean(
|
|
33
|
+
entry &&
|
|
34
|
+
typeof entry === 'object' &&
|
|
35
|
+
!Array.isArray(entry) &&
|
|
36
|
+
(entry.optionsUrl ||
|
|
37
|
+
(entry.fetch &&
|
|
38
|
+
typeof entry.fetch === 'object' &&
|
|
39
|
+
entry.fetch.url))
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* True for an inline row action that opens a picker (quick-assign) rather than
|
|
44
|
+
* firing straight away.
|
|
45
|
+
*/
|
|
46
|
+
export const isPickerRowAction = (config) =>
|
|
47
|
+
Boolean(config && typeof config === 'object' && config.optionsUrl);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Split a root `settings` array into the inline row actions and the shared
|
|
51
|
+
* settings, preserving each group's declared order.
|
|
52
|
+
*
|
|
53
|
+
* Inline actions come first in the rendered cell: they are the verbs specific
|
|
54
|
+
* to this view, and update/delete keep their familiar position at the end of
|
|
55
|
+
* the row where muscle memory expects them.
|
|
56
|
+
*/
|
|
57
|
+
export const splitSettingEntries = (settings) => {
|
|
58
|
+
const entries = Array.isArray(settings) ? settings : [];
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
inlineActions: entries.filter(isInlineRowActionSetting),
|
|
62
|
+
standard: entries.filter((entry) => !isInlineRowActionSetting(entry)),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Cache key for a picker's option list. Options are fetched once per grid
|
|
68
|
+
* mount per (url, params) pair — the user list behind a quick-assign menu does
|
|
69
|
+
* not change between two clicks, and refetching it made the menu flash.
|
|
70
|
+
*/
|
|
71
|
+
export const pickerOptionsCacheKey = (config) =>
|
|
72
|
+
`${config?.optionsUrl || ''}|${JSON.stringify(config?.optionsParams || {})}`;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Normalise a dropdown endpoint's answer to `[{ id, label }]`.
|
|
76
|
+
*
|
|
77
|
+
* The CRM's `/ajax/*\/dropdown` endpoints answer `{ data: [...] }`, which
|
|
78
|
+
* CustomFetch hands back as `res.data.data`; a few answer a bare array. Both
|
|
79
|
+
* are accepted so a picker can point at either without a config flag.
|
|
80
|
+
*/
|
|
81
|
+
export const normalisePickerOptions = (payload) => {
|
|
82
|
+
const list = Array.isArray(payload)
|
|
83
|
+
? payload
|
|
84
|
+
: Array.isArray(payload?.data)
|
|
85
|
+
? payload.data
|
|
86
|
+
: Array.isArray(payload?.data?.data)
|
|
87
|
+
? payload.data.data
|
|
88
|
+
: [];
|
|
89
|
+
|
|
90
|
+
return list
|
|
91
|
+
.map((option) =>
|
|
92
|
+
option && typeof option === 'object'
|
|
93
|
+
? {
|
|
94
|
+
id: option.id ?? option.value ?? null,
|
|
95
|
+
label: String(
|
|
96
|
+
option.label ?? option.name ?? option.id ?? ''
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
: { id: option, label: String(option) }
|
|
100
|
+
)
|
|
101
|
+
.filter((option) => option.label !== '');
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Case-insensitive substring match over the option labels. */
|
|
105
|
+
export const filterPickerOptions = (options, search) => {
|
|
106
|
+
const term = String(search || '')
|
|
107
|
+
.trim()
|
|
108
|
+
.toLowerCase();
|
|
109
|
+
|
|
110
|
+
if (!term) {
|
|
111
|
+
return options;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return options.filter((option) =>
|
|
115
|
+
String(option.label).toLowerCase().includes(term)
|
|
116
|
+
);
|
|
117
|
+
};
|