@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.
- package/package.json +4 -2
- package/src/components/Autocomplete.jsx +189 -119
- package/src/components/DataGrid.jsx +472 -31
- package/src/components/Navigation.jsx +475 -51
- package/src/components/auth/ClientAuthFrame.jsx +5 -0
- package/src/components/auth/ClientAuthScreen.jsx +29 -0
- package/src/components/callQueue/CallQueueDiagnostics.jsx +1043 -0
- package/src/components/callQueue/CallQueuePop.jsx +713 -90
- package/src/components/callQueue/CallQueueSettings.jsx +308 -146
- package/src/components/callQueue/callPopStatus.js +236 -0
- package/src/components/callQueue/callQueueHelpers.js +284 -2
- package/src/components/columns/ColumnRenderers.jsx +3 -46
- package/src/components/columns/StackedRow.jsx +186 -0
- package/src/components/controls/DataGridSearch.jsx +110 -2
- package/src/components/controls/DataGridSortSheet.jsx +155 -0
- package/src/components/generic/GenericAuth.jsx +50 -18
- package/src/components/generic/GenericDashboard.jsx +20 -1
- package/src/components/generic/GenericDetail.jsx +446 -259
- package/src/components/mapboxSearchBox.js +640 -0
- package/src/components/navBadges.js +63 -1
- package/src/components/navDrawer.js +147 -0
- package/src/components/sms/SmsThreadPanel.jsx +34 -6
- package/src/components/sms/smsHelpers.js +15 -0
- package/src/components/styles/CallQueueDiagnostics.module.scss +398 -0
- package/src/components/styles/CallQueuePop.module.scss +29 -0
- package/src/components/styles/CallQueueSettings.module.scss +93 -0
- package/src/components/styles/ClientAuth.module.scss +39 -0
- package/src/components/styles/DataGrid.module.scss +158 -5
- package/src/components/styles/Field.module.scss +52 -1
- package/src/components/styles/Form.module.scss +82 -0
- package/src/components/styles/GenericClientPortal.module.scss +72 -20
- package/src/components/styles/GenericDashboard.module.scss +50 -0
- package/src/components/styles/GenericDetail.module.scss +63 -1
- package/src/components/styles/GenericDynamic.module.scss +23 -0
- package/src/components/styles/GenericFormBuilder.module.scss +11 -0
- package/src/components/styles/GenericIndex.module.scss +6 -1
- package/src/components/styles/Navigation.module.scss +460 -7
- package/src/components/styles/Sms.module.scss +92 -0
- package/src/components/styles/StackedRow.module.scss +182 -0
- package/src/components/styles/TicketConversation.module.scss +76 -0
- package/src/components/styles/Vault.module.scss +192 -0
- package/src/components/styles/density.css +10 -0
- package/src/components/styles/global-datagrid.css +163 -0
- package/src/components/styles/global.css +20 -0
- package/src/components/tickets/TicketConversation.jsx +13 -8
- package/src/components/utils/ConfirmDialog.js +22 -3
- package/src/components/utils/cardLayout.js +666 -0
- package/src/components/utils/contactChannels.js +130 -0
- package/src/components/utils/editPlacement.js +95 -0
- package/src/components/utils/useDensity.js +303 -7
- 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 =
|
|
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 '';
|