@visns-studio/visns-components 6.6.3 → 6.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +1658 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +619 -158
  4. package/src/components/Fetch.jsx +80 -3
  5. package/src/components/Form.jsx +9 -0
  6. package/src/components/Navigation.jsx +109 -50
  7. package/src/components/Notification.jsx +279 -9
  8. package/src/components/TableFilter.jsx +14 -1
  9. package/src/components/auth/AuthBrandPanel.jsx +43 -0
  10. package/src/components/auth/AuthLoading.jsx +78 -0
  11. package/src/components/auth/AuthShell.jsx +59 -0
  12. package/src/components/auth/ClientAuth.jsx +161 -0
  13. package/src/components/auth/ClientAuthFrame.jsx +59 -0
  14. package/src/components/auth/ClientLogin.jsx +266 -55
  15. package/src/components/auth/ClientOTPVerify.jsx +587 -115
  16. package/src/components/auth/ImpersonateGate.jsx +254 -0
  17. package/src/components/auth/Login.jsx +134 -41
  18. package/src/components/auth/LogoutScreen.jsx +112 -0
  19. package/src/components/auth/Reset.jsx +134 -77
  20. package/src/components/auth/TwoFactorAuth.jsx +475 -297
  21. package/src/components/auth/Verify.jsx +237 -126
  22. package/src/components/auth/authEndpoints.js +105 -0
  23. package/src/components/auth/authFont.js +23 -0
  24. package/src/components/auth/authHelpers.js +465 -0
  25. package/src/components/auth/clientAuthProtocols.js +240 -0
  26. package/src/components/auth/useOptionalRouter.js +49 -0
  27. package/src/components/callQueue/CallQueuePop.jsx +1502 -0
  28. package/src/components/callQueue/CallQueueSettings.jsx +508 -0
  29. package/src/components/callQueue/callQueueHelpers.js +280 -0
  30. package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
  31. package/src/components/columns/AutoGrowCell.jsx +141 -0
  32. package/src/components/columns/ColumnRenderers.jsx +53 -7
  33. package/src/components/generic/GenericAuth.jsx +163 -96
  34. package/src/components/generic/GenericDetail.jsx +215 -90
  35. package/src/components/generic/GenericIndex.jsx +20 -47
  36. package/src/components/generic/GenericMain.jsx +5 -0
  37. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  38. package/src/components/generic/StandardModal.jsx +17 -5
  39. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  40. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  41. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  42. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  44. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  45. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  46. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  47. package/src/components/navActive.js +60 -0
  48. package/src/components/notify/desktopNotifications.js +256 -0
  49. package/src/components/sketch/SketchField.jsx +12 -2
  50. package/src/components/sms/SmsComposeModal.jsx +495 -0
  51. package/src/components/sms/SmsInbox.jsx +596 -0
  52. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  53. package/src/components/sms/SmsLineSettings.jsx +854 -0
  54. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  55. package/src/components/sms/smsEndpoints.js +56 -0
  56. package/src/components/sms/smsHelpers.js +1075 -0
  57. package/src/components/sms/smsLiveState.js +132 -0
  58. package/src/components/sms/useSmsLive.js +307 -0
  59. package/src/components/styles/CallQueuePop.module.scss +646 -0
  60. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  61. package/src/components/styles/ClientAuth.module.scss +229 -0
  62. package/src/components/styles/DataGrid.module.scss +10 -0
  63. package/src/components/styles/GenericDetail.module.scss +153 -26
  64. package/src/components/styles/GenericIndex.module.scss +13 -0
  65. package/src/components/styles/GenericMain.module.scss +19 -2
  66. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  67. package/src/components/styles/Login.module.scss +13 -122
  68. package/src/components/styles/LogoutScreen.module.scss +89 -0
  69. package/src/components/styles/Navigation.module.scss +509 -223
  70. package/src/components/styles/Notification.module.scss +103 -0
  71. package/src/components/styles/Reset.module.scss +52 -186
  72. package/src/components/styles/Sms.module.scss +2382 -0
  73. package/src/components/styles/TableFilter.module.scss +118 -98
  74. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  75. package/src/components/styles/Vault.module.scss +1983 -0
  76. package/src/components/styles/Verify.module.scss +57 -180
  77. package/src/components/styles/_authBrandPanel.scss +163 -0
  78. package/src/components/styles/_authShell.scss +369 -0
  79. package/src/components/styles/global-datagrid.css +29 -0
  80. package/src/components/utils/buildEnv.js +67 -0
  81. package/src/components/utils/displayValue.js +94 -0
  82. package/src/components/utils/useDensity.js +345 -9
  83. package/src/components/vault/OtpChip.jsx +208 -0
  84. package/src/components/vault/PasswordGenerator.jsx +145 -0
  85. package/src/components/vault/QrScanner.jsx +326 -0
  86. package/src/components/vault/VaultAccessLog.jsx +225 -0
  87. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  88. package/src/components/vault/VaultEntryForm.jsx +740 -0
  89. package/src/components/vault/VaultManager.jsx +1000 -0
  90. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  91. package/src/components/vault/qrDecode.js +257 -0
  92. package/src/components/vault/useDebouncedValue.js +22 -0
  93. package/src/components/vault/useVaultReveal.js +160 -0
  94. package/src/components/vault/vaultClipboard.js +33 -0
  95. package/src/components/vault/vaultEndpoints.js +40 -0
  96. package/src/components/vault/vaultFit.js +116 -0
  97. package/src/components/vault/vaultHelpers.js +547 -0
  98. package/src/components/vault/vaultNavigation.jsx +58 -0
  99. package/src/components/vault/vaultOtp.js +105 -0
  100. package/src/index.js +233 -1
@@ -0,0 +1,854 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Loader2, MessageSquare, Plus, TriangleAlert, X } from 'lucide-react';
3
+ import { toast } from 'react-toastify';
4
+
5
+ import CustomFetch from '../Fetch';
6
+ import StandardModal from '../generic/StandardModal';
7
+ import showConfirmDialog from '../generic/ConfirmationDialog';
8
+ import styles from '../styles/Sms.module.scss';
9
+ import { resolveSmsEndpoints } from './smsEndpoints';
10
+ import {
11
+ describeTransport,
12
+ normaliseNumberForDisplay,
13
+ segmentCount,
14
+ } from './smsHelpers';
15
+
16
+ const emptyLine = {
17
+ id: null,
18
+ label: '',
19
+ phone_number: '',
20
+ zoom_user_id: '',
21
+ zoom_user_email: '',
22
+ active: true,
23
+ user_ids: [],
24
+ };
25
+
26
+ const emptyTemplate = { id: null, name: '', body: '', active: true, sort: 0 };
27
+
28
+ /**
29
+ * Settings → Messaging.
30
+ *
31
+ * Two things live here: the lines (a Zoom Phone number, the staff who can see
32
+ * it) and the templates. The status card above them exists because "transport:
33
+ * null" is not a sentence anybody can act on — it says, in words, whether
34
+ * anything actually leaves the building right now.
35
+ *
36
+ * `staffOptions` is required rather than fetched: the contract's
37
+ * `/settings/lines` response carries the staff already ON each line, but there
38
+ * is no route that lists everybody who COULD be on one, and inventing a URL
39
+ * for it here would be a second contract nobody agreed to. The CRM already has
40
+ * that list on every settings screen, so it passes it in.
41
+ */
42
+ const SmsLineSettings = ({
43
+ endpoints,
44
+ // Accepted for consistency with the CRM's other settings screens.
45
+ userProfile: _userProfile = null,
46
+ staffOptions = [],
47
+ pageTitle = 'Messaging',
48
+ }) => {
49
+ const routes = useMemo(() => resolveSmsEndpoints(endpoints), [endpoints]);
50
+
51
+ const [status, setStatus] = useState(null);
52
+ const [lines, setLines] = useState([]);
53
+ const [zoom, setZoom] = useState({ connected: false, users: [], error: null });
54
+ const [templates, setTemplates] = useState([]);
55
+ const [loading, setLoading] = useState(true);
56
+
57
+ const [lineDraft, setLineDraft] = useState(null);
58
+ const [templateDraft, setTemplateDraft] = useState(null);
59
+ const [saving, setSaving] = useState(false);
60
+
61
+ const mountedRef = useRef(true);
62
+
63
+ useEffect(
64
+ () => () => {
65
+ mountedRef.current = false;
66
+ },
67
+ []
68
+ );
69
+
70
+ /* -------------------------------------------------------------- loading */
71
+
72
+ const loadLines = useCallback(() => {
73
+ CustomFetch(
74
+ routes.settingsLines,
75
+ 'GET',
76
+ null,
77
+ (result) => {
78
+ if (!mountedRef.current) return;
79
+
80
+ setLines(Array.isArray(result?.data) ? result.data : []);
81
+ setZoom({
82
+ connected: Boolean(result?.zoom_connected),
83
+ users: Array.isArray(result?.zoom_users) ? result.zoom_users : [],
84
+ error: result?.zoom_error ?? null,
85
+ });
86
+ setLoading(false);
87
+ },
88
+ () => {
89
+ if (mountedRef.current) setLoading(false);
90
+ }
91
+ ).catch(() => {
92
+ if (mountedRef.current) setLoading(false);
93
+ });
94
+ }, [routes]);
95
+
96
+ const loadTemplates = useCallback(() => {
97
+ CustomFetch(
98
+ routes.templates,
99
+ 'GET',
100
+ null,
101
+ (result) => {
102
+ if (!mountedRef.current) return;
103
+
104
+ setTemplates(Array.isArray(result?.data) ? result.data : []);
105
+ },
106
+ () => {}
107
+ ).catch(() => {});
108
+ }, [routes]);
109
+
110
+ useEffect(() => {
111
+ CustomFetch(
112
+ routes.status,
113
+ 'GET',
114
+ null,
115
+ (result) => {
116
+ if (mountedRef.current && result) setStatus(result);
117
+ },
118
+ () => {}
119
+ ).catch(() => {});
120
+
121
+ loadLines();
122
+ loadTemplates();
123
+ }, [routes, loadLines, loadTemplates]);
124
+
125
+ /**
126
+ * Everyone who could be put on a line.
127
+ *
128
+ * `staffOptions` is the real answer. Failing that, the staff already on a
129
+ * line are at least a list — enough to move somebody between lines, which
130
+ * is the commonest edit — so the picker is never empty when it does not
131
+ * have to be.
132
+ */
133
+ const staff = useMemo(() => {
134
+ if (staffOptions.length > 0) return staffOptions;
135
+
136
+ const seen = new Map();
137
+
138
+ lines.forEach((line) => {
139
+ (line.users || []).forEach((user) => {
140
+ if (!seen.has(user.id)) seen.set(user.id, user);
141
+ });
142
+ });
143
+
144
+ return Array.from(seen.values());
145
+ }, [staffOptions, lines]);
146
+
147
+ /* --------------------------------------------------------------- saving */
148
+
149
+ const saveLine = () => {
150
+ if (!lineDraft) return;
151
+
152
+ const creating = !lineDraft.id;
153
+
154
+ setSaving(true);
155
+
156
+ CustomFetch(
157
+ creating ? routes.settingsLines : routes.settingsLine(lineDraft.id),
158
+ creating ? 'POST' : 'PUT',
159
+ {
160
+ label: lineDraft.label,
161
+ phone_number: lineDraft.phone_number,
162
+ zoom_user_id: lineDraft.zoom_user_id || null,
163
+ zoom_user_email: lineDraft.zoom_user_email || null,
164
+ active: Boolean(lineDraft.active),
165
+ user_ids: lineDraft.user_ids || [],
166
+ },
167
+ () => {
168
+ if (!mountedRef.current) return;
169
+
170
+ setSaving(false);
171
+ setLineDraft(null);
172
+ toast.success(creating ? 'Line added' : 'Line saved');
173
+ loadLines();
174
+ },
175
+ null
176
+ ).catch(() => {
177
+ if (mountedRef.current) setSaving(false);
178
+ });
179
+ };
180
+
181
+ const deleteLine = (line) => {
182
+ showConfirmDialog({
183
+ title: 'Delete this line?',
184
+ message:
185
+ 'Staff will stop seeing its conversations. The messages themselves are kept.',
186
+ confirmLabel: 'Delete',
187
+ cancelLabel: 'Keep it',
188
+ data: line,
189
+ onConfirm: () => {
190
+ CustomFetch(
191
+ routes.settingsLine(line.id),
192
+ 'DELETE',
193
+ {},
194
+ () => {
195
+ toast.success('Line deleted');
196
+ loadLines();
197
+ },
198
+ null
199
+ ).catch(() => {});
200
+ },
201
+ });
202
+ };
203
+
204
+ const saveTemplate = () => {
205
+ if (!templateDraft) return;
206
+
207
+ const creating = !templateDraft.id;
208
+
209
+ setSaving(true);
210
+
211
+ CustomFetch(
212
+ creating ? routes.templates : routes.template(templateDraft.id),
213
+ creating ? 'POST' : 'PUT',
214
+ {
215
+ name: templateDraft.name,
216
+ body: templateDraft.body,
217
+ active: Boolean(templateDraft.active),
218
+ sort: Number(templateDraft.sort) || 0,
219
+ },
220
+ () => {
221
+ if (!mountedRef.current) return;
222
+
223
+ setSaving(false);
224
+ setTemplateDraft(null);
225
+ toast.success(creating ? 'Template added' : 'Template saved');
226
+ loadTemplates();
227
+ },
228
+ null
229
+ ).catch(() => {
230
+ if (mountedRef.current) setSaving(false);
231
+ });
232
+ };
233
+
234
+ const deleteTemplate = (template) => {
235
+ showConfirmDialog({
236
+ title: 'Delete this template?',
237
+ message: 'It disappears from the Templates menu in the composer.',
238
+ confirmLabel: 'Delete',
239
+ cancelLabel: 'Keep it',
240
+ data: template,
241
+ onConfirm: () => {
242
+ CustomFetch(
243
+ routes.template(template.id),
244
+ 'DELETE',
245
+ {},
246
+ () => {
247
+ toast.success('Template deleted');
248
+ loadTemplates();
249
+ },
250
+ null
251
+ ).catch(() => {});
252
+ },
253
+ });
254
+ };
255
+
256
+ /* --------------------------------------------------------------- render */
257
+
258
+ const transport = describeTransport(status);
259
+
260
+ return (
261
+ <div className={styles.settings}>
262
+ <div className={styles.settingsHead}>
263
+ <div>
264
+ <h1 className={styles.pageTitle}>
265
+ <MessageSquare size={22} strokeWidth={2.2} aria-hidden="true" />
266
+ {pageTitle}
267
+ </h1>
268
+ <p className={styles.pageIntro}>
269
+ A line is one Zoom Phone number and the staff who can see its
270
+ conversations. Everything staff send goes out from the line they
271
+ are replying on.
272
+ </p>
273
+ </div>
274
+ </div>
275
+
276
+ <div className={styles.statusCard}>
277
+ <div className={styles.statusItem}>
278
+ <span className={styles.statusLabel}>Transport</span>
279
+ <span className={styles.statusValue}>
280
+ <span
281
+ className={`${styles.pill} ${
282
+ transport.tone === 'ok'
283
+ ? styles.pillOk
284
+ : transport.tone === 'bad'
285
+ ? styles.pillBad
286
+ : styles.pillWarn
287
+ }`}
288
+ >
289
+ {status?.transport ?? '—'}
290
+ </span>
291
+ </span>
292
+ </div>
293
+
294
+ <div className={styles.statusItem}>
295
+ <span className={styles.statusLabel}>Connected</span>
296
+ <span className={styles.statusValue}>
297
+ {status?.connected ? 'Yes' : 'No'}
298
+ </span>
299
+ </div>
300
+
301
+ <div className={styles.statusItem}>
302
+ <span className={styles.statusLabel}>Lines</span>
303
+ <span className={styles.statusValue}>
304
+ {status?.lines_count ?? lines.length}
305
+ </span>
306
+ </div>
307
+
308
+ <p className={styles.statusMeaning}>
309
+ <strong>{transport.title}</strong>
310
+ <br />
311
+ {transport.detail}
312
+ </p>
313
+ </div>
314
+
315
+ {zoom.error && (
316
+ <p className={`${styles.banner} ${styles.bannerBad}`}>
317
+ <TriangleAlert size={16} strokeWidth={2} aria-hidden="true" />
318
+ <span>
319
+ <strong>Zoom said no</strong>
320
+ {zoom.error}
321
+ </span>
322
+ </p>
323
+ )}
324
+
325
+ {/* ------------------------------------------------------- lines */}
326
+
327
+ <section className={styles.section}>
328
+ <div className={styles.sectionHead}>
329
+ <h2 className={styles.sectionTitle}>Lines</h2>
330
+ <button
331
+ type="button"
332
+ className={styles.primaryButton}
333
+ onClick={() => setLineDraft({ ...emptyLine })}
334
+ >
335
+ <Plus size={14} strokeWidth={2} aria-hidden="true" />
336
+ Add a line
337
+ </button>
338
+ </div>
339
+
340
+ <div className={styles.tableWrap}>
341
+ <table className={styles.table}>
342
+ <thead>
343
+ <tr>
344
+ <th>Label</th>
345
+ <th>Number</th>
346
+ <th>Zoom user</th>
347
+ <th>Staff</th>
348
+ <th>Active</th>
349
+ <th className={styles.actionsHead}>Actions</th>
350
+ </tr>
351
+ </thead>
352
+ <tbody>
353
+ {lines.map((line) => (
354
+ <tr
355
+ key={line.id}
356
+ className={line.deleted_at ? styles.deletedRow : ''}
357
+ >
358
+ <td>
359
+ <strong>{line.label || 'Untitled line'}</strong>
360
+ </td>
361
+ <td className={styles.numberCell}>
362
+ {line.display_number ||
363
+ normaliseNumberForDisplay(line.phone_number)}
364
+ </td>
365
+ <td>
366
+ {line.zoom_user_email || (
367
+ <span className={styles.muted}>Not linked</span>
368
+ )}
369
+ </td>
370
+ <td>
371
+ <span className={styles.chipRow}>
372
+ {(line.users || []).length === 0 ? (
373
+ <span className={styles.muted}>Everyone</span>
374
+ ) : (
375
+ (line.users || []).map((user) => (
376
+ <span
377
+ key={user.id}
378
+ className={styles.staffChip}
379
+ >
380
+ {user.name}
381
+ </span>
382
+ ))
383
+ )}
384
+ </span>
385
+ </td>
386
+ <td>
387
+ <span
388
+ className={`${styles.pill} ${
389
+ line.active ? styles.pillOk : styles.pillMuted
390
+ }`}
391
+ >
392
+ {line.active ? 'Active' : 'Off'}
393
+ </span>
394
+ </td>
395
+ <td className={styles.actionsCell}>
396
+ <button
397
+ type="button"
398
+ className={styles.linkButton}
399
+ onClick={() =>
400
+ setLineDraft({
401
+ ...emptyLine,
402
+ ...line,
403
+ zoom_user_id: line.zoom_user_id || '',
404
+ zoom_user_email:
405
+ line.zoom_user_email || '',
406
+ user_ids: (line.users || []).map(
407
+ (user) => user.id
408
+ ),
409
+ })
410
+ }
411
+ >
412
+ Edit
413
+ </button>
414
+ <button
415
+ type="button"
416
+ className={styles.dangerLink}
417
+ onClick={() => deleteLine(line)}
418
+ >
419
+ Delete
420
+ </button>
421
+ </td>
422
+ </tr>
423
+ ))}
424
+
425
+ {lines.length === 0 && (
426
+ <tr>
427
+ <td colSpan={6} className={styles.tableEmpty}>
428
+ {loading ? (
429
+ <Loader2
430
+ size={18}
431
+ className={styles.spin}
432
+ aria-label="Loading"
433
+ />
434
+ ) : (
435
+ <p>
436
+ No lines yet. Add one and give it the Zoom
437
+ Phone number staff should message from.
438
+ </p>
439
+ )}
440
+ </td>
441
+ </tr>
442
+ )}
443
+ </tbody>
444
+ </table>
445
+ </div>
446
+ </section>
447
+
448
+ {/* --------------------------------------------------- templates */}
449
+
450
+ <section className={styles.section}>
451
+ <div className={styles.sectionHead}>
452
+ <h2 className={styles.sectionTitle}>Templates</h2>
453
+ <button
454
+ type="button"
455
+ className={styles.primaryButton}
456
+ onClick={() => setTemplateDraft({ ...emptyTemplate })}
457
+ >
458
+ <Plus size={14} strokeWidth={2} aria-hidden="true" />
459
+ Add a template
460
+ </button>
461
+ </div>
462
+
463
+ <div className={styles.tableWrap}>
464
+ <table className={styles.table}>
465
+ <thead>
466
+ <tr>
467
+ <th>Name</th>
468
+ <th>Body</th>
469
+ <th>Length</th>
470
+ <th>Active</th>
471
+ <th className={styles.actionsHead}>Actions</th>
472
+ </tr>
473
+ </thead>
474
+ <tbody>
475
+ {templates.map((template) => {
476
+ const counts = segmentCount(template.body);
477
+
478
+ return (
479
+ <tr key={template.id}>
480
+ <td>
481
+ <strong>{template.name}</strong>
482
+ </td>
483
+ <td>{template.body}</td>
484
+ <td className={styles.numberCell}>
485
+ {counts.characters} · {counts.segments} SMS
486
+ </td>
487
+ <td>
488
+ <span
489
+ className={`${styles.pill} ${
490
+ template.active
491
+ ? styles.pillOk
492
+ : styles.pillMuted
493
+ }`}
494
+ >
495
+ {template.active ? 'Active' : 'Off'}
496
+ </span>
497
+ </td>
498
+ <td className={styles.actionsCell}>
499
+ <button
500
+ type="button"
501
+ className={styles.linkButton}
502
+ onClick={() =>
503
+ setTemplateDraft({
504
+ ...emptyTemplate,
505
+ ...template,
506
+ })
507
+ }
508
+ >
509
+ Edit
510
+ </button>
511
+ <button
512
+ type="button"
513
+ className={styles.dangerLink}
514
+ onClick={() => deleteTemplate(template)}
515
+ >
516
+ Delete
517
+ </button>
518
+ </td>
519
+ </tr>
520
+ );
521
+ })}
522
+
523
+ {templates.length === 0 && (
524
+ <tr>
525
+ <td colSpan={5} className={styles.tableEmpty}>
526
+ <p>
527
+ No templates yet. They appear in the Templates
528
+ menu beside the composer.
529
+ </p>
530
+ </td>
531
+ </tr>
532
+ )}
533
+ </tbody>
534
+ </table>
535
+ </div>
536
+ </section>
537
+
538
+ {/* ------------------------------------------------------ modals */}
539
+
540
+ {lineDraft && (
541
+ <StandardModal isOpen onClose={() => setLineDraft(null)} size="medium">
542
+ <div className={styles.modalForm}>
543
+ <div className={styles.modalHead}>
544
+ <h3 className={styles.modalTitle}>
545
+ {lineDraft.id ? 'Edit line' : 'Add a line'}
546
+ </h3>
547
+ <button
548
+ type="button"
549
+ className={styles.iconButton}
550
+ onClick={() => setLineDraft(null)}
551
+ aria-label="Close"
552
+ >
553
+ <X size={15} strokeWidth={2} aria-hidden="true" />
554
+ </button>
555
+ </div>
556
+
557
+ <label className={styles.field}>
558
+ <span className={styles.label}>Label</span>
559
+ <input
560
+ type="text"
561
+ className={styles.input}
562
+ value={lineDraft.label}
563
+ placeholder="Reception, Advice team, Reminders…"
564
+ onChange={(event) =>
565
+ setLineDraft({ ...lineDraft, label: event.target.value })
566
+ }
567
+ />
568
+ </label>
569
+
570
+ <label className={styles.field}>
571
+ <span className={styles.label}>Phone number</span>
572
+ <input
573
+ type="text"
574
+ className={styles.input}
575
+ value={lineDraft.phone_number}
576
+ placeholder="+61 8 9375 2549"
577
+ onChange={(event) =>
578
+ setLineDraft({
579
+ ...lineDraft,
580
+ phone_number: event.target.value,
581
+ })
582
+ }
583
+ />
584
+ <p className={styles.help}>
585
+ {lineDraft.phone_number
586
+ ? `Shown as ${normaliseNumberForDisplay(
587
+ lineDraft.phone_number
588
+ )}`
589
+ : 'The Zoom Phone number this line sends from.'}
590
+ </p>
591
+ </label>
592
+
593
+ {zoom.connected ? (
594
+ <label className={styles.field}>
595
+ <span className={styles.label}>Zoom user</span>
596
+ <select
597
+ className={styles.select}
598
+ value={lineDraft.zoom_user_id || ''}
599
+ onChange={(event) => {
600
+ const user = zoom.users.find(
601
+ (one) => String(one.id) === event.target.value
602
+ );
603
+
604
+ setLineDraft({
605
+ ...lineDraft,
606
+ zoom_user_id: event.target.value,
607
+ zoom_user_email: user?.email || '',
608
+ // A Zoom user usually has exactly one
609
+ // number; filling it in saves retyping
610
+ // an E.164 by hand.
611
+ phone_number:
612
+ lineDraft.phone_number ||
613
+ user?.phone_numbers?.[0] ||
614
+ '',
615
+ });
616
+ }}
617
+ >
618
+ <option value="">Not linked to a Zoom user</option>
619
+ {zoom.users.map((user) => (
620
+ <option key={user.id} value={user.id}>
621
+ {user.display_name || user.email}
622
+ {user.phone_numbers?.length
623
+ ? ` · ${user.phone_numbers.join(', ')}`
624
+ : ''}
625
+ </option>
626
+ ))}
627
+ </select>
628
+ </label>
629
+ ) : (
630
+ <p className={styles.help}>
631
+ Zoom is not connected, so there is no user list to pick
632
+ from yet. Fill in the number now and link the Zoom user
633
+ once the connection is live.
634
+ </p>
635
+ )}
636
+
637
+ <div className={styles.field}>
638
+ <span className={styles.label}>Staff who can see this line</span>
639
+ <div className={styles.staffPicker}>
640
+ {staff.length === 0 && (
641
+ <p className={styles.help}>
642
+ No staff list was supplied, so this line is visible
643
+ to everyone with messaging access.
644
+ </p>
645
+ )}
646
+ {staff.map((user) => {
647
+ const checked = (lineDraft.user_ids || []).some(
648
+ (id) => String(id) === String(user.id)
649
+ );
650
+
651
+ return (
652
+ <label key={user.id} className={styles.inlineCheck}>
653
+ <input
654
+ type="checkbox"
655
+ checked={checked}
656
+ onChange={(event) =>
657
+ setLineDraft({
658
+ ...lineDraft,
659
+ user_ids: event.target.checked
660
+ ? [
661
+ ...(lineDraft.user_ids ||
662
+ []),
663
+ user.id,
664
+ ]
665
+ : (
666
+ lineDraft.user_ids || []
667
+ ).filter(
668
+ (id) =>
669
+ String(id) !==
670
+ String(user.id)
671
+ ),
672
+ })
673
+ }
674
+ />
675
+ {user.name}
676
+ </label>
677
+ );
678
+ })}
679
+ </div>
680
+ <p className={styles.help}>
681
+ Leave every box clear to let anyone with messaging access
682
+ see this line.
683
+ </p>
684
+ </div>
685
+
686
+ <label className={styles.inlineCheck}>
687
+ <input
688
+ type="checkbox"
689
+ checked={Boolean(lineDraft.active)}
690
+ onChange={(event) =>
691
+ setLineDraft({
692
+ ...lineDraft,
693
+ active: event.target.checked,
694
+ })
695
+ }
696
+ />
697
+ Active
698
+ </label>
699
+
700
+ <div className={styles.modalActions}>
701
+ <button
702
+ type="button"
703
+ className={styles.ghostButton}
704
+ onClick={() => setLineDraft(null)}
705
+ >
706
+ Cancel
707
+ </button>
708
+ <button
709
+ type="button"
710
+ className={styles.primaryButton}
711
+ onClick={saveLine}
712
+ disabled={
713
+ saving ||
714
+ lineDraft.label.trim() === '' ||
715
+ lineDraft.phone_number.trim() === ''
716
+ }
717
+ >
718
+ {saving && (
719
+ <Loader2
720
+ size={14}
721
+ className={styles.spin}
722
+ aria-hidden="true"
723
+ />
724
+ )}
725
+ Save
726
+ </button>
727
+ </div>
728
+ </div>
729
+ </StandardModal>
730
+ )}
731
+
732
+ {templateDraft && (
733
+ <StandardModal isOpen onClose={() => setTemplateDraft(null)} size="medium">
734
+ <div className={styles.modalForm}>
735
+ <div className={styles.modalHead}>
736
+ <h3 className={styles.modalTitle}>
737
+ {templateDraft.id ? 'Edit template' : 'Add a template'}
738
+ </h3>
739
+ <button
740
+ type="button"
741
+ className={styles.iconButton}
742
+ onClick={() => setTemplateDraft(null)}
743
+ aria-label="Close"
744
+ >
745
+ <X size={15} strokeWidth={2} aria-hidden="true" />
746
+ </button>
747
+ </div>
748
+
749
+ <label className={styles.field}>
750
+ <span className={styles.label}>Name</span>
751
+ <input
752
+ type="text"
753
+ className={styles.input}
754
+ value={templateDraft.name}
755
+ placeholder="Appointment reminder"
756
+ onChange={(event) =>
757
+ setTemplateDraft({
758
+ ...templateDraft,
759
+ name: event.target.value,
760
+ })
761
+ }
762
+ />
763
+ </label>
764
+
765
+ <label className={styles.field}>
766
+ <span className={styles.label}>Body</span>
767
+ <textarea
768
+ className={styles.textarea}
769
+ rows={4}
770
+ value={templateDraft.body}
771
+ onChange={(event) =>
772
+ setTemplateDraft({
773
+ ...templateDraft,
774
+ body: event.target.value,
775
+ })
776
+ }
777
+ />
778
+ <p className={styles.counter}>
779
+ {segmentCount(templateDraft.body).characters} characters ·{' '}
780
+ {segmentCount(templateDraft.body).segments} SMS ·{' '}
781
+ {segmentCount(templateDraft.body).encoding}
782
+ </p>
783
+ <p className={styles.help}>
784
+ Placeholders: {'{first_name}'} {'{last_name}'} {'{name}'}{' '}
785
+ {'{date}'} {'{time}'} {'{number}'} {'{line}'} — filled from
786
+ the conversation when inserted.
787
+ </p>
788
+ </label>
789
+
790
+ <label className={styles.field}>
791
+ <span className={styles.label}>Sort</span>
792
+ <input
793
+ type="number"
794
+ className={styles.input}
795
+ value={templateDraft.sort}
796
+ onChange={(event) =>
797
+ setTemplateDraft({
798
+ ...templateDraft,
799
+ sort: event.target.value,
800
+ })
801
+ }
802
+ />
803
+ </label>
804
+
805
+ <label className={styles.inlineCheck}>
806
+ <input
807
+ type="checkbox"
808
+ checked={Boolean(templateDraft.active)}
809
+ onChange={(event) =>
810
+ setTemplateDraft({
811
+ ...templateDraft,
812
+ active: event.target.checked,
813
+ })
814
+ }
815
+ />
816
+ Active
817
+ </label>
818
+
819
+ <div className={styles.modalActions}>
820
+ <button
821
+ type="button"
822
+ className={styles.ghostButton}
823
+ onClick={() => setTemplateDraft(null)}
824
+ >
825
+ Cancel
826
+ </button>
827
+ <button
828
+ type="button"
829
+ className={styles.primaryButton}
830
+ onClick={saveTemplate}
831
+ disabled={
832
+ saving ||
833
+ templateDraft.name.trim() === '' ||
834
+ templateDraft.body.trim() === ''
835
+ }
836
+ >
837
+ {saving && (
838
+ <Loader2
839
+ size={14}
840
+ className={styles.spin}
841
+ aria-hidden="true"
842
+ />
843
+ )}
844
+ Save
845
+ </button>
846
+ </div>
847
+ </div>
848
+ </StandardModal>
849
+ )}
850
+ </div>
851
+ );
852
+ };
853
+
854
+ export default SmsLineSettings;