@visns-studio/visns-components 6.25.0 → 6.26.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.
@@ -4,6 +4,7 @@ import { toast } from 'react-toastify';
4
4
  import Toggle from 'react-toggle';
5
5
  import {
6
6
  AlertTriangle,
7
+ ChevronDown,
7
8
  ChevronRight,
8
9
  Info,
9
10
  Loader2,
@@ -12,6 +13,7 @@ import {
12
13
  } from 'lucide-react';
13
14
 
14
15
  import CustomFetch from '../Fetch';
16
+ import CallQueueDiagnostics from './CallQueueDiagnostics';
15
17
  import styles from '../styles/CallQueueSettings.module.scss';
16
18
 
17
19
  // Pure helpers live in a plain .js sibling so `node --test` can import them;
@@ -81,6 +83,57 @@ const defaultBreadcrumb = (title) => (
81
83
  </div>
82
84
  );
83
85
 
86
+ /**
87
+ * The pseudo row's copy. Direct calls are not a Zoom call queue at all — they
88
+ * are the calls that ring one staff member's own extension — so the server
89
+ * appends a single made-up row for them, keyed 'direct', carrying the same two
90
+ * settings every queue row has.
91
+ */
92
+ const DIRECT_SECTION_TITLE = 'Direct calls';
93
+
94
+ const DIRECT_INFO_NOTE =
95
+ 'Intercepting a direct call in Zoom uses a call pickup GROUP code, ' +
96
+ 'configured in Zoom admin — not a per-queue code. Enter that group’s code ' +
97
+ 'here; leave it blank to pop direct calls without a Pick up button.';
98
+
99
+ /**
100
+ * Rows come back keyed `queue_id`, except the pseudo row, whose id the server
101
+ * sends as `id`. Filling it in here means every other line of this page — the
102
+ * draft map, dirty checks, saving — goes on treating rows identically.
103
+ */
104
+ const normaliseRows = (rows) =>
105
+ (Array.isArray(rows) ? rows : []).map((row) =>
106
+ row?.queue_id === undefined || row?.queue_id === null
107
+ ? { ...row, queue_id: row?.id }
108
+ : row
109
+ );
110
+
111
+ /**
112
+ * Where the diagnostics disclosure remembers whether it was left open. A
113
+ * support session opens it once and every later page load starts there.
114
+ */
115
+ const DIAGNOSTICS_STORAGE_KEY = 'callQueueDiagnosticsOpen';
116
+
117
+ /** Storage is optional everywhere: private mode, SSR, a locked-down browser. */
118
+ const readDiagnosticsOpen = () => {
119
+ try {
120
+ return window?.localStorage?.getItem(DIAGNOSTICS_STORAGE_KEY) === '1';
121
+ } catch (error) {
122
+ return false;
123
+ }
124
+ };
125
+
126
+ const writeDiagnosticsOpen = (open) => {
127
+ try {
128
+ window?.localStorage?.setItem(
129
+ DIAGNOSTICS_STORAGE_KEY,
130
+ open ? '1' : '0'
131
+ );
132
+ } catch (error) {
133
+ // Nothing to do: the disclosure still works, it just forgets.
134
+ }
135
+ };
136
+
84
137
  const CallQueueSettings = ({
85
138
  endpoints,
86
139
  title = 'Call Queues',
@@ -89,6 +142,7 @@ const CallQueueSettings = ({
89
142
  infoNote = DEFAULT_INFO_NOTE,
90
143
  codePrefix = '*99',
91
144
  onSaved = null,
145
+ diagnostics = false,
92
146
  }) => {
93
147
  const [rows, setRows] = useState([]);
94
148
  const [draft, setDraft] = useState({});
@@ -96,6 +150,8 @@ const CallQueueSettings = ({
96
150
  const [isLoading, setIsLoading] = useState(true);
97
151
  const [zoomUnreachable, setZoomUnreachable] = useState(false);
98
152
  const [zoomError, setZoomError] = useState('');
153
+ // Collapsed by default — this is a support tool, not part of the daily job.
154
+ const [diagnosticsOpen, setDiagnosticsOpen] = useState(readDiagnosticsOpen);
99
155
 
100
156
  // Plain string, so it can sit in the loader's dep list without an object
101
157
  // identity re-running the request on every render.
@@ -140,7 +196,7 @@ const CallQueueSettings = ({
140
196
  try {
141
197
  const res = await CustomFetch(listUrl, 'GET', {});
142
198
  const data = res?.data ?? {};
143
- const queues = data.queues ?? [];
199
+ const queues = normaliseRows(data.queues ?? []);
144
200
 
145
201
  setRows(queues);
146
202
  seedDraft(queues);
@@ -245,6 +301,173 @@ const CallQueueSettings = ({
245
301
  }
246
302
  };
247
303
 
304
+ /**
305
+ * One settings row. Shared by the real call queues and by the pseudo
306
+ * 'direct' row, which carries the same two settings and saves through
307
+ * the same endpoint — only its identity column differs.
308
+ */
309
+ const renderRow = (queue) => {
310
+ const current = draft[queue.queue_id] ?? {
311
+ pickup_code: '',
312
+ excluded: false,
313
+ };
314
+ const error = validateCode(current.pickup_code);
315
+ const dirty = isDirty(queue);
316
+ const isSaving = Boolean(saving[queue.queue_id]);
317
+ const isExcluded = Boolean(current.excluded);
318
+ const hasCode = Boolean(current.pickup_code);
319
+ const number = formatAuNumber(queue.number);
320
+ const name = queue.name || queue.queue_id;
321
+ // The 'direct' row is not a queue in Zoom: it has no number, no
322
+ // extension and no status to report, only the two settings.
323
+ const isPseudo = Boolean(queue.pseudo);
324
+
325
+ return (
326
+ <tr
327
+ key={queue.queue_id}
328
+ className={isExcluded ? styles.isExcluded : undefined}
329
+ >
330
+ <td>
331
+ <span className={styles.cqsettingsName}>
332
+ <span className={styles.cqsettingsIcon}>
333
+ <Phone size={13} />
334
+ </span>
335
+ <span title={name}>{name}</span>
336
+ </span>
337
+ {isPseudo ? null : (
338
+ <span className={styles.cqsettingsMeta}>
339
+ {queue.extension_number
340
+ ? `Ext ${queue.extension_number}`
341
+ : 'No extension'}
342
+ {queue.missing_in_zoom && (
343
+ <span className={styles.cqsettingsFlag}>
344
+ not in Zoom any more
345
+ </span>
346
+ )}
347
+ </span>
348
+ )}
349
+ </td>
350
+ <td>
351
+ {number ? (
352
+ <span className={styles.cqsettingsNumber}>
353
+ {number}
354
+ </span>
355
+ ) : (
356
+ <span className={styles.cqsettingsNone}>—</span>
357
+ )}
358
+ </td>
359
+ <td>
360
+ {isPseudo ? (
361
+ <span className={styles.cqsettingsNone}>—</span>
362
+ ) : (
363
+ <span
364
+ className={
365
+ styles.cqsettingsPill +
366
+ (queue.status === 'active'
367
+ ? ' ' + styles.cqsettingsPillOk
368
+ : '')
369
+ }
370
+ >
371
+ {titleCase(queue.status)}
372
+ </span>
373
+ )}
374
+ </td>
375
+ <td>
376
+ <span
377
+ className={
378
+ styles.cqsettingsCode +
379
+ (error ? ' ' + styles.hasError : '')
380
+ }
381
+ >
382
+ <span className={styles.cqsettingsCodePrefix}>
383
+ {codePrefix}
384
+ </span>
385
+ <input
386
+ className={styles.cqsettingsCodeInput}
387
+ type="text"
388
+ inputMode="numeric"
389
+ maxLength={4}
390
+ placeholder="0000"
391
+ aria-label={`Pickup code for ${name}`}
392
+ aria-invalid={Boolean(error)}
393
+ value={current.pickup_code}
394
+ onChange={(e) =>
395
+ setField(
396
+ queue.queue_id,
397
+ 'pickup_code',
398
+ cleanCode(e.target.value)
399
+ )
400
+ }
401
+ />
402
+ </span>
403
+ {error && (
404
+ <span className={styles.cqsettingsError}>{error}</span>
405
+ )}
406
+ </td>
407
+ <td>
408
+ {isExcluded ? (
409
+ <span className={styles.cqsettingsPill}>Excluded</span>
410
+ ) : hasCode ? (
411
+ <span
412
+ className={`${styles.cqsettingsPill} ${styles.cqsettingsPillOk}`}
413
+ >
414
+ Pick up enabled
415
+ </span>
416
+ ) : (
417
+ <span className={styles.cqsettingsPill}>
418
+ No pick-up code
419
+ </span>
420
+ )}
421
+ </td>
422
+ <td className={styles.isCentred}>
423
+ <span className={styles.cqsettingsToggle}>
424
+ <Toggle
425
+ icons={false}
426
+ checked={isExcluded}
427
+ aria-label={`Exclude ${name} from the call pop`}
428
+ onChange={(e) =>
429
+ setField(
430
+ queue.queue_id,
431
+ 'excluded',
432
+ e.target.checked
433
+ )
434
+ }
435
+ />
436
+ </span>
437
+ </td>
438
+ <td>
439
+ <button
440
+ type="button"
441
+ className={styles.cqsettingsSave}
442
+ disabled={!dirty || isSaving || Boolean(error)}
443
+ onClick={() => save(queue)}
444
+ >
445
+ {isSaving ? (
446
+ <Loader2 size={13} className={styles.spin} />
447
+ ) : (
448
+ <Save size={13} />
449
+ )}
450
+ <span>{isSaving ? 'Saving' : 'Save'}</span>
451
+ </button>
452
+ </td>
453
+ </tr>
454
+ );
455
+ };
456
+
457
+ // The real Zoom queues, and the server's single pseudo row for direct
458
+ // calls. An older server sends no pseudo row at all, and the section simply
459
+ // does not appear.
460
+ const queueRows = rows.filter((row) => !row.pseudo);
461
+ const directRow = rows.find((row) => row.pseudo) ?? null;
462
+
463
+ const toggleDiagnostics = () => {
464
+ setDiagnosticsOpen((open) => {
465
+ writeDiagnosticsOpen(!open);
466
+
467
+ return !open;
468
+ });
469
+ };
470
+
248
471
  // `undefined` keeps the default header, `null` removes it, anything else is
249
472
  // rendered in its place.
250
473
  const header =
@@ -339,165 +562,104 @@ const CallQueueSettings = ({
339
562
  </tr>
340
563
  </thead>
341
564
  <tbody>
342
- {rows.map((queue) => {
343
- const current = draft[queue.queue_id] ?? {
344
- pickup_code: '',
345
- excluded: false,
346
- };
347
- const error = validateCode(current.pickup_code);
348
- const dirty = isDirty(queue);
349
- const isSaving = Boolean(saving[queue.queue_id]);
350
- const isExcluded = Boolean(current.excluded);
351
- const hasCode = Boolean(current.pickup_code);
352
- const number = formatAuNumber(queue.number);
353
- const name = queue.name || queue.queue_id;
354
-
355
- return (
356
- <tr
357
- key={queue.queue_id}
565
+ {queueRows.map(renderRow)}
566
+ {queueRows.length === 0 ? (
567
+ <tr>
568
+ <td
569
+ colSpan={7}
358
570
  className={
359
- isExcluded
360
- ? styles.isExcluded
361
- : undefined
571
+ styles.cqsettingsEmptyCell
362
572
  }
363
573
  >
364
- <td>
365
- <span className={styles.cqsettingsName}>
366
- <span className={styles.cqsettingsIcon}>
367
- <Phone size={13} />
368
- </span>
369
- <span title={name}>{name}</span>
370
- </span>
371
- <span className={styles.cqsettingsMeta}>
372
- {queue.extension_number
373
- ? `Ext ${queue.extension_number}`
374
- : 'No extension'}
375
- {queue.missing_in_zoom && (
376
- <span className={styles.cqsettingsFlag}>
377
- not in Zoom any more
378
- </span>
379
- )}
380
- </span>
381
- </td>
382
- <td>
383
- {number ? (
384
- <span className={styles.cqsettingsNumber}>
385
- {number}
386
- </span>
387
- ) : (
388
- <span className={styles.cqsettingsNone}>
389
-
390
- </span>
391
- )}
392
- </td>
393
- <td>
574
+ No call queues found in the
575
+ Zoom account.
576
+ </td>
577
+ </tr>
578
+ ) : null}
579
+
580
+ {/* Not a Zoom call queue: the calls that
581
+ ring one staff member's own extension.
582
+ Set apart so nobody reads it as a queue
583
+ they have never heard of. */}
584
+ {directRow ? (
585
+ <tr
586
+ className={
587
+ styles.cqsettingsSection
588
+ }
589
+ >
590
+ <td colSpan={7}>
591
+ <span
592
+ className={
593
+ styles.cqsettingsSectionTitle
594
+ }
595
+ >
596
+ {directRow.name ||
597
+ DIRECT_SECTION_TITLE}
598
+ </span>
599
+ {directRow.description ? (
394
600
  <span
395
601
  className={
396
- styles.cqsettingsPill +
397
- (queue.status === 'active'
398
- ? ' ' + styles.cqsettingsPillOk
399
- : '')
602
+ styles.cqsettingsSectionText
400
603
  }
401
604
  >
402
- {titleCase(queue.status)}
403
- </span>
404
- </td>
405
- <td>
406
- <span
407
- className={
408
- styles.cqsettingsCode +
409
- (error ? ' ' + styles.hasError : '')
605
+ {
606
+ directRow.description
410
607
  }
411
- >
412
- <span className={styles.cqsettingsCodePrefix}>
413
- {codePrefix}
414
- </span>
415
- <input
416
- className={styles.cqsettingsCodeInput}
417
- type="text"
418
- inputMode="numeric"
419
- maxLength={4}
420
- placeholder="0000"
421
- aria-label={`Pickup code for ${name}`}
422
- aria-invalid={Boolean(error)}
423
- value={current.pickup_code}
424
- onChange={(e) =>
425
- setField(
426
- queue.queue_id,
427
- 'pickup_code',
428
- cleanCode(e.target.value)
429
- )
430
- }
431
- />
432
608
  </span>
433
- {error && (
434
- <span className={styles.cqsettingsError}>
435
- {error}
436
- </span>
437
- )}
438
- </td>
439
- <td>
440
- {isExcluded ? (
441
- <span className={styles.cqsettingsPill}>
442
- Excluded
443
- </span>
444
- ) : hasCode ? (
445
- <span
446
- className={`${styles.cqsettingsPill} ${styles.cqsettingsPillOk}`}
447
- >
448
- Pick up enabled
449
- </span>
450
- ) : (
451
- <span className={styles.cqsettingsPill}>
452
- No pick-up code
453
- </span>
454
- )}
455
- </td>
456
- <td className={styles.isCentred}>
457
- <span className={styles.cqsettingsToggle}>
458
- <Toggle
459
- icons={false}
460
- checked={isExcluded}
461
- aria-label={`Exclude ${name} from the call pop`}
462
- onChange={(e) =>
463
- setField(
464
- queue.queue_id,
465
- 'excluded',
466
- e.target.checked
467
- )
468
- }
469
- />
609
+ ) : null}
610
+ <span
611
+ className={
612
+ styles.cqsettingsSectionNote
613
+ }
614
+ >
615
+ <Info size={14} />
616
+ <span>
617
+ {DIRECT_INFO_NOTE}
470
618
  </span>
471
- </td>
472
- <td>
473
- <button
474
- type="button"
475
- className={styles.cqsettingsSave}
476
- disabled={
477
- !dirty || isSaving || Boolean(error)
478
- }
479
- onClick={() => save(queue)}
480
- >
481
- {isSaving ? (
482
- <Loader2
483
- size={13}
484
- className={styles.spin}
485
- />
486
- ) : (
487
- <Save size={13} />
488
- )}
489
- <span>
490
- {isSaving ? 'Saving' : 'Save'}
491
- </span>
492
- </button>
493
- </td>
494
- </tr>
495
- );
496
- })}
619
+ </span>
620
+ </td>
621
+ </tr>
622
+ ) : null}
623
+ {directRow
624
+ ? renderRow(directRow)
625
+ : null}
497
626
  </tbody>
498
627
  </table>
499
628
  )}
500
629
  </div>
630
+
631
+ {/* Support tooling, folded away: why a pop did or did
632
+ not appear, from both ends of the pipeline. */}
633
+ {diagnostics ? (
634
+ <div className={styles.cqsettingsDiagnostics}>
635
+ <button
636
+ type="button"
637
+ className={styles.cqsettingsDisclosure}
638
+ aria-expanded={diagnosticsOpen}
639
+ aria-controls="cqsettings-diagnostics"
640
+ onClick={toggleDiagnostics}
641
+ >
642
+ {diagnosticsOpen ? (
643
+ <ChevronDown size={15} />
644
+ ) : (
645
+ <ChevronRight size={15} />
646
+ )}
647
+ <span>Diagnostics</span>
648
+ </button>
649
+
650
+ {diagnosticsOpen ? (
651
+ <div id="cqsettings-diagnostics">
652
+ <CallQueueDiagnostics
653
+ endpoints={
654
+ diagnostics?.endpoints ??
655
+ undefined
656
+ }
657
+ embedded
658
+ />
659
+ </div>
660
+ ) : null}
661
+ </div>
662
+ ) : null}
501
663
  </div>
502
664
  </div>
503
665
  </div>