@visns-studio/visns-components 6.24.4 → 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.
Files changed (51) hide show
  1. package/package.json +4 -2
  2. package/src/components/Autocomplete.jsx +189 -119
  3. package/src/components/DataGrid.jsx +472 -31
  4. package/src/components/Navigation.jsx +475 -51
  5. package/src/components/auth/ClientAuthFrame.jsx +5 -0
  6. package/src/components/auth/ClientAuthScreen.jsx +29 -0
  7. package/src/components/callQueue/CallQueueDiagnostics.jsx +1043 -0
  8. package/src/components/callQueue/CallQueuePop.jsx +713 -90
  9. package/src/components/callQueue/CallQueueSettings.jsx +308 -146
  10. package/src/components/callQueue/callPopStatus.js +236 -0
  11. package/src/components/callQueue/callQueueHelpers.js +284 -2
  12. package/src/components/columns/ColumnRenderers.jsx +3 -46
  13. package/src/components/columns/StackedRow.jsx +186 -0
  14. package/src/components/controls/DataGridSearch.jsx +110 -2
  15. package/src/components/controls/DataGridSortSheet.jsx +155 -0
  16. package/src/components/generic/GenericAuth.jsx +50 -18
  17. package/src/components/generic/GenericDashboard.jsx +20 -1
  18. package/src/components/generic/GenericDetail.jsx +446 -259
  19. package/src/components/mapboxSearchBox.js +640 -0
  20. package/src/components/navBadges.js +63 -1
  21. package/src/components/navDrawer.js +147 -0
  22. package/src/components/sms/SmsThreadPanel.jsx +34 -6
  23. package/src/components/sms/smsHelpers.js +15 -0
  24. package/src/components/styles/CallQueueDiagnostics.module.scss +398 -0
  25. package/src/components/styles/CallQueuePop.module.scss +29 -0
  26. package/src/components/styles/CallQueueSettings.module.scss +93 -0
  27. package/src/components/styles/ClientAuth.module.scss +39 -0
  28. package/src/components/styles/DataGrid.module.scss +158 -5
  29. package/src/components/styles/Field.module.scss +52 -1
  30. package/src/components/styles/Form.module.scss +82 -0
  31. package/src/components/styles/GenericClientPortal.module.scss +72 -20
  32. package/src/components/styles/GenericDashboard.module.scss +50 -0
  33. package/src/components/styles/GenericDetail.module.scss +63 -1
  34. package/src/components/styles/GenericDynamic.module.scss +23 -0
  35. package/src/components/styles/GenericFormBuilder.module.scss +11 -0
  36. package/src/components/styles/GenericIndex.module.scss +6 -1
  37. package/src/components/styles/Navigation.module.scss +460 -7
  38. package/src/components/styles/Sms.module.scss +92 -0
  39. package/src/components/styles/StackedRow.module.scss +182 -0
  40. package/src/components/styles/TicketConversation.module.scss +76 -0
  41. package/src/components/styles/Vault.module.scss +192 -0
  42. package/src/components/styles/density.css +10 -0
  43. package/src/components/styles/global-datagrid.css +163 -0
  44. package/src/components/styles/global.css +20 -0
  45. package/src/components/tickets/TicketConversation.jsx +13 -8
  46. package/src/components/utils/ConfirmDialog.js +22 -3
  47. package/src/components/utils/cardLayout.js +666 -0
  48. package/src/components/utils/contactChannels.js +130 -0
  49. package/src/components/utils/editPlacement.js +95 -0
  50. package/src/components/utils/useDensity.js +303 -7
  51. package/src/index.js +42 -0
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Call pop status store.
3
+ * -----------------------------------------------------------------------
4
+ * A tiny module-level record of what the call pop's plumbing is actually
5
+ * doing right now: was an Echo instance handed over, did the private channel
6
+ * authorise, what state is the socket in, when did the last `.queue.*` event
7
+ * land, how many cards are on screen.
8
+ *
9
+ * It exists because "the pop only shows up some of the time" is unanswerable
10
+ * from the pop itself — a failed `/broadcasting/auth` is invisible, a dropped
11
+ * socket is invisible, and a webhook that never arrived looks exactly like a
12
+ * browser that never subscribed. Every one of those now leaves a trace here.
13
+ *
14
+ * Deliberately dependency-free and framework-free:
15
+ * - `getCallPopStatus()` returns a plain snapshot (safe to JSON.stringify),
16
+ * - `updateCallPopStatus(patch)` merges, stamps `updatedAt` and fires the
17
+ * `callpop:status` window event so a panel can re-render,
18
+ * - `appendCallPopLog(message, level, patch)` does the same and pushes one
19
+ * line onto a 50-entry ring buffer.
20
+ *
21
+ * Module-level state is the point: the pop mounts once at the app root and the
22
+ * diagnostics panel mounts somewhere else entirely (Settings -> Call Queues),
23
+ * with no shared React tree between them. The window event is the bridge.
24
+ *
25
+ * Nothing here throws. A store that breaks the pop it is meant to explain
26
+ * would be worse than no store at all.
27
+ */
28
+
29
+ /** The window event dispatched on every change. */
30
+ export const CALL_POP_STATUS_EVENT = 'callpop:status';
31
+
32
+ /** How many log lines are kept. Oldest are dropped first. */
33
+ export const CALL_POP_LOG_LIMIT = 50;
34
+
35
+ /**
36
+ * The empty record. Every field is present from the first read, so a panel
37
+ * rendering before the pop has mounted shows "unknown", not `undefined`.
38
+ */
39
+ export const emptyCallPopStatus = () => ({
40
+ /** Did the `echo` prop/factory yield an instance? null = not asked yet. */
41
+ echoAvailable: null,
42
+ /** Does this user hold the monitor permission? */
43
+ canMonitor: null,
44
+ /** The private channel the pop subscribed (or tried) to. */
45
+ channel: null,
46
+ /** pusher-js connection state: connected/connecting/unavailable/… */
47
+ connectionState: null,
48
+ connectionChangedAt: null,
49
+ /** Has the channel reported pusher:subscription_succeeded? */
50
+ subscribed: false,
51
+ subscribedAt: null,
52
+ /** `{status, message}` from pusher:subscription_error, else null. */
53
+ subscriptionError: null,
54
+ /** The one-shot `/ajax/call-queue/live` snapshot. */
55
+ snapshotAt: null,
56
+ snapshotError: null,
57
+ snapshotCalls: 0,
58
+ /** Broadcast events received since the page loaded. */
59
+ eventsReceived: 0,
60
+ lastEventName: null,
61
+ lastEventAt: null,
62
+ /** `{nonce, receivedAt}` of the last diagnostic ping delivered here. */
63
+ lastPing: null,
64
+ /** Cards currently on screen. */
65
+ visibleCalls: 0,
66
+ /** Times the socket has left `connected`. */
67
+ disconnects: 0,
68
+ /** When any of the above last changed. */
69
+ updatedAt: null,
70
+ /** Ring buffer of `{at, level, message}`, newest last. */
71
+ log: [],
72
+ });
73
+
74
+ let status = emptyCallPopStatus();
75
+
76
+ /** Timestamps are ISO strings so they survive JSON round trips unchanged. */
77
+ const now = () => new Date().toISOString();
78
+
79
+ /**
80
+ * A defensive copy: callers (a React state setter, a "Copy status" button)
81
+ * must never be able to mutate the store by holding on to what they read.
82
+ */
83
+ const snapshot = () => ({ ...status, log: status.log.slice() });
84
+
85
+ /** Read the current status. */
86
+ export const getCallPopStatus = () => snapshot();
87
+
88
+ /** Drop everything back to the empty record. Used by tests. */
89
+ export const resetCallPopStatus = () => {
90
+ status = emptyCallPopStatus();
91
+
92
+ return snapshot();
93
+ };
94
+
95
+ /** Fire the window event, if there is a window and it will have it. */
96
+ const broadcast = (detail) => {
97
+ if (typeof window === 'undefined') {
98
+ return;
99
+ }
100
+
101
+ try {
102
+ const CustomEventCtor =
103
+ typeof window.CustomEvent === 'function'
104
+ ? window.CustomEvent
105
+ : typeof CustomEvent === 'function'
106
+ ? CustomEvent
107
+ : null;
108
+
109
+ if (!CustomEventCtor || typeof window.dispatchEvent !== 'function') {
110
+ return;
111
+ }
112
+
113
+ window.dispatchEvent(
114
+ new CustomEventCtor(CALL_POP_STATUS_EVENT, { detail })
115
+ );
116
+ } catch (error) {
117
+ // A listener that throws, or an environment with no CustomEvent, must
118
+ // not take the pop down with it.
119
+ }
120
+ };
121
+
122
+ /**
123
+ * Merge a patch into the status, stamp `updatedAt` and notify listeners.
124
+ *
125
+ * `patch.log`, when an array, replaces the buffer (trimmed to the limit);
126
+ * appending one line is `appendCallPopLog` below.
127
+ *
128
+ * @param {object} patch
129
+ * @returns {object} The new snapshot.
130
+ */
131
+ export const updateCallPopStatus = (patch = {}) => {
132
+ if (!patch || typeof patch !== 'object') {
133
+ return snapshot();
134
+ }
135
+
136
+ const next = { ...status, ...patch, updatedAt: now() };
137
+
138
+ next.log = Array.isArray(patch.log)
139
+ ? patch.log.slice(-CALL_POP_LOG_LIMIT)
140
+ : status.log;
141
+
142
+ status = next;
143
+
144
+ const detail = snapshot();
145
+
146
+ broadcast(detail);
147
+
148
+ return detail;
149
+ };
150
+
151
+ /**
152
+ * Append one line to the log (and optionally merge a patch alongside it, so a
153
+ * state change and its explanation are one update and one event).
154
+ *
155
+ * @param {string} message
156
+ * @param {string} [level] 'info' | 'warn' | 'error'
157
+ * @param {object} [patch] Merged in the same update.
158
+ *
159
+ * @returns {object} The new snapshot.
160
+ */
161
+ export const appendCallPopLog = (message, level = 'info', patch = {}) => {
162
+ const entry = {
163
+ at: now(),
164
+ level: typeof level === 'string' && level !== '' ? level : 'info',
165
+ message: String(message ?? ''),
166
+ };
167
+
168
+ return updateCallPopStatus({
169
+ ...patch,
170
+ log: [...status.log, entry].slice(-CALL_POP_LOG_LIMIT),
171
+ });
172
+ };
173
+
174
+ /**
175
+ * Record one received broadcast event: bumps the counter and remembers which
176
+ * event it was and when.
177
+ *
178
+ * @param {string} name Event name, e.g. '.queue.ringing'.
179
+ * @param {object} [patch] Anything else worth storing with it.
180
+ */
181
+ export const noteCallPopEvent = (name, patch = {}) =>
182
+ updateCallPopStatus({
183
+ ...patch,
184
+ eventsReceived: status.eventsReceived + 1,
185
+ lastEventName: name,
186
+ lastEventAt: now(),
187
+ });
188
+
189
+ /**
190
+ * Record a pusher connection state change, counting every departure from
191
+ * `connected` as a disconnect — that count is the single most useful number
192
+ * on the panel when staff say the pop "works for a while and then stops".
193
+ *
194
+ * @param {string} state Next connection state.
195
+ */
196
+ export const noteCallPopConnectionState = (state) => {
197
+ const wasConnected = status.connectionState === 'connected';
198
+ const isConnected = state === 'connected';
199
+
200
+ return updateCallPopStatus({
201
+ connectionState: state ?? null,
202
+ connectionChangedAt: now(),
203
+ disconnects:
204
+ wasConnected && !isConnected
205
+ ? status.disconnects + 1
206
+ : status.disconnects,
207
+ });
208
+ };
209
+
210
+ /**
211
+ * Expose the reader on `window` next to the demo hooks, so support can run
212
+ * `callPopStatus()` in a staff member's console without a build of anything.
213
+ *
214
+ * @returns {Function} Removes the hook again. Always safe to call.
215
+ */
216
+ export const installCallPopStatusHook = () => {
217
+ if (typeof window === 'undefined') {
218
+ return () => {};
219
+ }
220
+
221
+ try {
222
+ window.callPopStatus = getCallPopStatus;
223
+ } catch (error) {
224
+ return () => {};
225
+ }
226
+
227
+ return () => {
228
+ try {
229
+ delete window.callPopStatus;
230
+ } catch (error) {
231
+ // A sealed window: nothing to undo.
232
+ }
233
+ };
234
+ };
235
+
236
+ export default getCallPopStatus;
@@ -12,6 +12,32 @@ export const MONITOR_PERMISSION = 'Call Queue Monitor';
12
12
  /** Badge text for a queue Zoom sent without a name. */
13
13
  export const FALLBACK_QUEUE_NAME = 'Call Queue';
14
14
 
15
+ /**
16
+ * What rang. A 'queue' call arrived on one of the account's call queues; a
17
+ * 'direct' call rang a staff member's own extension (their DID, or a transfer
18
+ * to it) and has no queue at all — `queueId` is null on those.
19
+ */
20
+ export const KIND_QUEUE = 'queue';
21
+ export const KIND_DIRECT = 'direct';
22
+
23
+ /**
24
+ * Pickup codes are keyed by `pickupKey`, not by queue id: a direct call is
25
+ * intercepted through Zoom's call PICKUP GROUP, which is one code for the whole
26
+ * group rather than a per-queue one, and the snapshot files it under this
27
+ * literal key.
28
+ */
29
+ export const DIRECT_PICKUP_KEY = 'direct';
30
+
31
+ /** Badge text for a direct call, whatever the server called its "queue". */
32
+ export const DIRECT_BADGE_TEXT = 'Direct';
33
+
34
+ /**
35
+ * How old a card has to be before a snapshot that no longer lists it is taken
36
+ * as proof the call is over. Anything younger is left alone: a `.queue.ringing`
37
+ * that beat its own snapshot to the browser must not be reconciled away.
38
+ */
39
+ export const SNAPSHOT_MIN_AGE_MS = 30000;
40
+
15
41
  /**
16
42
  * Does this user hold the monitor permission?
17
43
  *
@@ -19,15 +45,30 @@ export const FALLBACK_QUEUE_NAME = 'Call Queue';
19
45
  * the Spatie shape `roles[].permissions[].name`. Direct (non-role) permissions
20
46
  * are checked too, since Spatie allows assigning a permission straight to a
21
47
  * user.
48
+ *
49
+ * NO PERMISSION CONFIGURED = EVERY SIGNED-IN USER MAY MONITOR. Passing `null`
50
+ * (or '') as `permission` switches the gate off rather than failing closed: a
51
+ * deployment that wants the whole practice to see the pop says so by naming no
52
+ * permission, in the component prop and in the server's channel authorisation
53
+ * alike. Only a truthy permission name is ever looked for on the profile, and
54
+ * only a signed-in user (a profile object) is ever let through.
55
+ *
56
+ * Omitting the argument entirely is NOT the same thing: the default parameter
57
+ * still asks for `MONITOR_PERMISSION`, which is what every existing caller of
58
+ * the one-argument form relies on.
22
59
  */
23
60
  export const hasMonitorPermission = (
24
61
  userProfile,
25
62
  permission = MONITOR_PERMISSION
26
63
  ) => {
27
- if (!userProfile) {
64
+ if (!userProfile || typeof userProfile !== 'object') {
28
65
  return false;
29
66
  }
30
67
 
68
+ if (permission === null || permission === '') {
69
+ return true;
70
+ }
71
+
31
72
  const roles = Array.isArray(userProfile.roles) ? userProfile.roles : [];
32
73
 
33
74
  const viaRole = roles.some((role) =>
@@ -236,13 +277,55 @@ export const normaliseCall = (raw) => {
236
277
  ? null
237
278
  : String(rawQueueId);
238
279
 
280
+ // 'direct' or 'queue'. Anything the server does not label is a queue call,
281
+ // which is what every payload was before direct calls existed.
282
+ const kind =
283
+ String(raw.kind ?? '')
284
+ .trim()
285
+ .toLowerCase() === KIND_DIRECT
286
+ ? KIND_DIRECT
287
+ : KIND_QUEUE;
288
+
239
289
  const rawQueueName = raw.queueName ?? raw.queue_name ?? '';
240
- const queueName = String(rawQueueName).trim() || FALLBACK_QUEUE_NAME;
290
+ const queueName =
291
+ String(rawQueueName).trim() ||
292
+ (kind === KIND_DIRECT ? DIRECT_BADGE_TEXT : FALLBACK_QUEUE_NAME);
293
+
294
+ // Which entry of `pickup_codes` this call's Pick up button reads. Defaults
295
+ // to the queue id, so a server that does not send the key behaves exactly
296
+ // as it did before; a direct call with no key falls back to the literal
297
+ // 'direct', which is where its group code is filed.
298
+ const rawPickupKey = raw.pickupKey ?? raw.pickup_key ?? null;
299
+ const suppliedPickupKey =
300
+ rawPickupKey === null || rawPickupKey === undefined
301
+ ? ''
302
+ : String(rawPickupKey).trim();
303
+ const pickupKey =
304
+ suppliedPickupKey !== ''
305
+ ? suppliedPickupKey
306
+ : kind === KIND_DIRECT
307
+ ? DIRECT_PICKUP_KEY
308
+ : queueId;
309
+
310
+ const text = (...values) => {
311
+ const found = values.find(
312
+ (value) => value !== null && value !== undefined
313
+ );
314
+
315
+ return found === undefined ? '' : String(found).trim();
316
+ };
241
317
 
242
318
  return {
243
319
  callId: String(callId),
320
+ kind,
244
321
  queueId,
245
322
  queueName,
323
+ pickupKey,
324
+ // Who the call is actually ringing. Only direct calls carry these; a
325
+ // queue call rings a queue, not a person, and leaves them ''.
326
+ calleeName: text(raw.calleeName, raw.callee_name),
327
+ calleeExtension: text(raw.calleeExtension, raw.callee_extension),
328
+ forwardedByName: text(raw.forwardedByName, raw.forwarded_by_name),
246
329
  callerNumber: raw.callerNumber ?? raw.caller_number ?? raw.from ?? '',
247
330
  callerName: raw.callerName ?? raw.caller_name ?? '',
248
331
  client: raw.client ?? null,
@@ -250,12 +333,211 @@ export const normaliseCall = (raw) => {
250
333
  // leaves it null and the expansion fetches the drill-down endpoint.
251
334
  tasks: Array.isArray(raw.tasks) ? raw.tasks : null,
252
335
  startedAt: raw.startedAt ?? raw.started_at ?? new Date().toISOString(),
336
+ // When THIS browser first saw the call, which is not when it started
337
+ // ringing: the snapshot reconcile ages cards by their arrival here.
338
+ receivedAt:
339
+ typeof raw.receivedAt === 'number' ? raw.receivedAt : Date.now(),
253
340
  status: 'ringing',
341
+ // Set when a `.queue.missed` says one ringing leg was declined or timed
342
+ // out. The call may still be ringing elsewhere, so this only starts a
343
+ // grace timer — it never removes the card by itself.
344
+ missedAt: typeof raw.missedAt === 'number' ? raw.missedAt : null,
254
345
  isDemo: raw.isDemo === true || raw.is_demo === true,
255
346
  leaving: false,
256
347
  };
257
348
  };
258
349
 
350
+ /** The badge's text: a direct call says so, a queue call names its queue. */
351
+ export const callBadgeLabel = (call) =>
352
+ call?.kind === KIND_DIRECT
353
+ ? DIRECT_BADGE_TEXT
354
+ : call?.queueName || FALLBACK_QUEUE_NAME;
355
+
356
+ /**
357
+ * Who a direct call is ringing, by name if Zoom knew one and by extension if it
358
+ * did not. '' when neither is known — the card then just says "Direct".
359
+ */
360
+ export const calleeLabel = (call) => {
361
+ const name = String(call?.calleeName ?? '').trim();
362
+
363
+ if (name !== '') {
364
+ return name;
365
+ }
366
+
367
+ const extension = String(call?.calleeExtension ?? '').trim();
368
+
369
+ return extension === '' ? '' : `ext ${extension}`;
370
+ };
371
+
372
+ /**
373
+ * The direct card's one extra line: "Ringing Jane Smith", plus who transferred
374
+ * it when the call reached that extension via someone else. '' for a queue
375
+ * call, or a direct call with no callee at all.
376
+ */
377
+ export const directRingingLine = (call) => {
378
+ if (call?.kind !== KIND_DIRECT) {
379
+ return '';
380
+ }
381
+
382
+ const who = calleeLabel(call);
383
+
384
+ if (who === '') {
385
+ return '';
386
+ }
387
+
388
+ const forwardedBy = String(call?.forwardedByName ?? '').trim();
389
+
390
+ return forwardedBy === ''
391
+ ? `Ringing ${who}`
392
+ : `Ringing ${who} · transferred by ${forwardedBy}`;
393
+ };
394
+
395
+ /** The `pickup_codes` key for a call, tolerating a payload from an older server. */
396
+ export const pickupKeyFor = (call) => {
397
+ const key = call?.pickupKey;
398
+
399
+ if (key !== null && key !== undefined && String(key).trim() !== '') {
400
+ return String(key).trim();
401
+ }
402
+
403
+ if (call?.kind === KIND_DIRECT) {
404
+ return DIRECT_PICKUP_KEY;
405
+ }
406
+
407
+ return call?.queueId ?? null;
408
+ };
409
+
410
+ /** A call's dial string, or '' when nothing is configured for its key. */
411
+ export const resolvePickupCode = (call, codes) => {
412
+ const key = pickupKeyFor(call);
413
+
414
+ if (key === null || !codes || typeof codes !== 'object') {
415
+ return '';
416
+ }
417
+
418
+ const code = codes[key];
419
+
420
+ return typeof code === 'string' && code.trim() !== '' ? code.trim() : '';
421
+ };
422
+
423
+ /**
424
+ * Mark one call as having had a leg declined. Idempotent: a second `.missed`
425
+ * for the same call leaves the ORIGINAL stamp in place, so the grace period is
426
+ * measured from the first decline rather than being extended by every later
427
+ * one. Returns the same array when nothing changed, so React can skip a render.
428
+ */
429
+ export const applyMissed = (calls, callId, now = Date.now()) => {
430
+ const list = Array.isArray(calls) ? calls : [];
431
+ const id = String(callId ?? '');
432
+
433
+ if (id === '') {
434
+ return list;
435
+ }
436
+
437
+ let changed = false;
438
+
439
+ const next = list.map((call) => {
440
+ if (call.callId !== id || call.leaving || call.missedAt) {
441
+ return call;
442
+ }
443
+
444
+ changed = true;
445
+
446
+ return { ...call, missedAt: now };
447
+ });
448
+
449
+ return changed ? next : list;
450
+ };
451
+
452
+ /**
453
+ * Ingest a ringing call: append it when it is new, and clear any missed mark
454
+ * when it is one already on screen — a fresh `.queue.ringing` for a call whose
455
+ * first leg was declined proves the call is still live, so its grace period is
456
+ * cancelled rather than allowed to expire.
457
+ *
458
+ * A card already animating out is left alone: its exit timer will drop it, and
459
+ * un-setting `leaving` here would leave a card that can never be removed.
460
+ */
461
+ export const applyRinging = (calls, call) => {
462
+ const list = Array.isArray(calls) ? calls : [];
463
+
464
+ if (!call) {
465
+ return list;
466
+ }
467
+
468
+ const existing = list.find((item) => item.callId === call.callId);
469
+
470
+ if (!existing) {
471
+ return [...list, call];
472
+ }
473
+
474
+ if (!existing.missedAt) {
475
+ return list;
476
+ }
477
+
478
+ return list.map((item) =>
479
+ item.callId === call.callId ? { ...item, missedAt: null } : item
480
+ );
481
+ };
482
+
483
+ /**
484
+ * Work out what a fresh `/ajax/call-queue/live` snapshot means for the cards
485
+ * already on screen. The server's liveness rule is authoritative, with one
486
+ * deliberate exception in each direction:
487
+ *
488
+ * - a call the snapshot lists and the screen does not is ADDED, unless the
489
+ * user has already dismissed it here (`skipIds`) — a dismiss is local, so
490
+ * the server keeps listing the call and a refresh would otherwise put the
491
+ * card straight back;
492
+ * - a card the snapshot does not list is REMOVED only once it is older than
493
+ * `minAgeMs` (30s), and never when it is a demo card or already leaving.
494
+ * Anything younger is presumed to be a ringing event that overtook the
495
+ * snapshot request rather than a call that has ended.
496
+ *
497
+ * Pure: it decides, and the caller applies the decision through the same
498
+ * add/remove paths every other code path uses (so removals still animate).
499
+ *
500
+ * @param {Array} current Cards on screen (normalised calls).
501
+ * @param {Array} snapshot Normalised calls from the snapshot.
502
+ * @param {number} now
503
+ * @param {object} [options] `{ minAgeMs, skipIds }`.
504
+ *
505
+ * @returns {{add: Array, remove: Array}} Calls to add, callIds to remove.
506
+ */
507
+ export const reconcileSnapshot = (
508
+ current,
509
+ snapshot,
510
+ now = Date.now(),
511
+ options = {}
512
+ ) => {
513
+ const live = Array.isArray(current) ? current : [];
514
+ const incoming = (Array.isArray(snapshot) ? snapshot : []).filter(Boolean);
515
+ const minAgeMs = options.minAgeMs ?? SNAPSHOT_MIN_AGE_MS;
516
+ const skipIds =
517
+ options.skipIds instanceof Set
518
+ ? options.skipIds
519
+ : new Set(options.skipIds ?? []);
520
+
521
+ const onScreen = new Set(live.map((call) => call.callId));
522
+ const listed = new Set(incoming.map((call) => call.callId));
523
+
524
+ const add = incoming.filter(
525
+ (call) => !onScreen.has(call.callId) && !skipIds.has(call.callId)
526
+ );
527
+
528
+ const remove = live
529
+ .filter(
530
+ (call) =>
531
+ !call.leaving &&
532
+ !call.isDemo &&
533
+ !listed.has(call.callId) &&
534
+ now - (call.receivedAt ?? 0) > minAgeMs
535
+ )
536
+ .map((call) => call.callId);
537
+
538
+ return { add, remove };
539
+ };
540
+
259
541
  /**
260
542
  * Coerce the snapshot's `pickup_codes` block into a plain `{ queueId: code }`
261
543
  * map, dropping anything that is not a non-empty string on both sides. A queue
@@ -15,6 +15,9 @@ import {
15
15
  } from '../../utils/relationshipSortingUtils';
16
16
  import { formatCellContent } from '../generic/shared/formatters';
17
17
  import { arrayCountFrom } from '../utils/displayValue';
18
+ // One Australian numbering table, shared with the card row — see
19
+ // `utils/contactChannels.js` for why it is not defined here any more.
20
+ import { formatPhoneNumber } from '../utils/contactChannels';
18
21
 
19
22
  // Stage Toggle Utility Functions
20
23
  const buildToggleUrl = (urlTemplate, stageItem, rowData) => {
@@ -3170,52 +3173,6 @@ export const renderTotalColumn = () => {
3170
3173
  };
3171
3174
  };
3172
3175
 
3173
- // Phone Number formatter
3174
- const formatPhoneNumber = (phoneNumber) => {
3175
- if (!phoneNumber) return '';
3176
-
3177
- // Remove all non-digit characters
3178
- let cleanNumber = phoneNumber.replace(/\D/g, '');
3179
-
3180
- // +61 spellings collapse to the local form so every branch below applies.
3181
- if (cleanNumber.match(/^61[2-9]\d{8}$/)) {
3182
- cleanNumber = '0' + cleanNumber.slice(2);
3183
- }
3184
-
3185
- // Check if it's an Australian mobile number (starts with 04 and has 10 digits)
3186
- if (cleanNumber.match(/^04\d{8}$/)) {
3187
- // Mobile format: 0400 000 000
3188
- return cleanNumber.replace(/(\d{4})(\d{3})(\d{3})/, '$1 $2 $3');
3189
- }
3190
-
3191
- // Check if it's a 1300/1800 number (10 digits starting with 1300 or 1800)
3192
- if (cleanNumber.match(/^1[38]00\d{6}$/)) {
3193
- // 1300/1800 format: 1300 000 000
3194
- return cleanNumber.replace(/(\d{4})(\d{3})(\d{3})/, '$1 $2 $3');
3195
- }
3196
-
3197
- // Check if it's an Australian landline number (8 digits with area code, or 10 digits total)
3198
- if (cleanNumber.match(/^0[2-9]\d{8}$/)) {
3199
- // Landline format: (02) 0000 0000
3200
- return cleanNumber.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2 $3');
3201
- }
3202
-
3203
- // If it doesn't match standard Australian patterns, return as is with some basic formatting
3204
- if (cleanNumber.length >= 8) {
3205
- // Generic formatting for longer numbers
3206
- if (cleanNumber.length === 8) {
3207
- return cleanNumber.replace(/(\d{4})(\d{4})/, '$1 $2');
3208
- } else if (cleanNumber.length === 9) {
3209
- return cleanNumber.replace(/(\d{1})(\d{4})(\d{4})/, '$1 $2 $3');
3210
- } else if (cleanNumber.length === 10) {
3211
- return cleanNumber.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2 $3');
3212
- }
3213
- }
3214
-
3215
- // Return original if no formatting pattern matches
3216
- return phoneNumber;
3217
- };
3218
-
3219
3176
  // ABN formatter
3220
3177
  const formatABN = (abn) => {
3221
3178
  if (!abn) return '';