@visns-studio/visns-components 6.6.4 → 6.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +1623 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +181 -38
  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/ColumnRenderers.jsx +53 -7
  32. package/src/components/generic/GenericAuth.jsx +163 -96
  33. package/src/components/generic/GenericDetail.jsx +215 -90
  34. package/src/components/generic/GenericIndex.jsx +20 -47
  35. package/src/components/generic/GenericMain.jsx +5 -0
  36. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  37. package/src/components/generic/StandardModal.jsx +17 -5
  38. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  39. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  40. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  41. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  42. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  44. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  45. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  46. package/src/components/navActive.js +60 -0
  47. package/src/components/notify/desktopNotifications.js +256 -0
  48. package/src/components/sketch/SketchField.jsx +12 -2
  49. package/src/components/sms/SmsComposeModal.jsx +495 -0
  50. package/src/components/sms/SmsInbox.jsx +596 -0
  51. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  52. package/src/components/sms/SmsLineSettings.jsx +854 -0
  53. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  54. package/src/components/sms/smsEndpoints.js +56 -0
  55. package/src/components/sms/smsHelpers.js +1075 -0
  56. package/src/components/sms/smsLiveState.js +132 -0
  57. package/src/components/sms/useSmsLive.js +307 -0
  58. package/src/components/styles/CallQueuePop.module.scss +646 -0
  59. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  60. package/src/components/styles/ClientAuth.module.scss +229 -0
  61. package/src/components/styles/GenericDetail.module.scss +153 -26
  62. package/src/components/styles/GenericIndex.module.scss +13 -0
  63. package/src/components/styles/GenericMain.module.scss +19 -2
  64. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  65. package/src/components/styles/Login.module.scss +13 -122
  66. package/src/components/styles/LogoutScreen.module.scss +89 -0
  67. package/src/components/styles/Navigation.module.scss +509 -223
  68. package/src/components/styles/Notification.module.scss +103 -0
  69. package/src/components/styles/Reset.module.scss +52 -186
  70. package/src/components/styles/Sms.module.scss +2382 -0
  71. package/src/components/styles/TableFilter.module.scss +118 -98
  72. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  73. package/src/components/styles/Vault.module.scss +1983 -0
  74. package/src/components/styles/Verify.module.scss +57 -180
  75. package/src/components/styles/_authBrandPanel.scss +163 -0
  76. package/src/components/styles/_authShell.scss +369 -0
  77. package/src/components/styles/global-datagrid.css +27 -2
  78. package/src/components/utils/buildEnv.js +67 -0
  79. package/src/components/utils/displayValue.js +94 -0
  80. package/src/components/utils/useDensity.js +345 -9
  81. package/src/components/vault/OtpChip.jsx +208 -0
  82. package/src/components/vault/PasswordGenerator.jsx +145 -0
  83. package/src/components/vault/QrScanner.jsx +326 -0
  84. package/src/components/vault/VaultAccessLog.jsx +225 -0
  85. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  86. package/src/components/vault/VaultEntryForm.jsx +740 -0
  87. package/src/components/vault/VaultManager.jsx +1000 -0
  88. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  89. package/src/components/vault/qrDecode.js +257 -0
  90. package/src/components/vault/useDebouncedValue.js +22 -0
  91. package/src/components/vault/useVaultReveal.js +160 -0
  92. package/src/components/vault/vaultClipboard.js +33 -0
  93. package/src/components/vault/vaultEndpoints.js +40 -0
  94. package/src/components/vault/vaultFit.js +116 -0
  95. package/src/components/vault/vaultHelpers.js +547 -0
  96. package/src/components/vault/vaultNavigation.jsx +58 -0
  97. package/src/components/vault/vaultOtp.js +105 -0
  98. package/src/index.js +233 -1
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Pure helpers for the call-queue components.
3
+ *
4
+ * Split out of CallQueuePop.jsx so they can be imported by `node --test`, which
5
+ * cannot parse JSX. Nothing here touches React or the DOM; CallQueuePop
6
+ * re-exports the lot, so the component's public surface is unchanged.
7
+ */
8
+
9
+ /** Spatie permission that gates the pop, unless `monitorPermission` overrides it. */
10
+ export const MONITOR_PERMISSION = 'Call Queue Monitor';
11
+
12
+ /** Badge text for a queue Zoom sent without a name. */
13
+ export const FALLBACK_QUEUE_NAME = 'Call Queue';
14
+
15
+ /**
16
+ * Does this user hold the monitor permission?
17
+ *
18
+ * `userProfile` comes from the host app's profile endpoint, which eager-loads
19
+ * the Spatie shape `roles[].permissions[].name`. Direct (non-role) permissions
20
+ * are checked too, since Spatie allows assigning a permission straight to a
21
+ * user.
22
+ */
23
+ export const hasMonitorPermission = (
24
+ userProfile,
25
+ permission = MONITOR_PERMISSION
26
+ ) => {
27
+ if (!userProfile) {
28
+ return false;
29
+ }
30
+
31
+ const roles = Array.isArray(userProfile.roles) ? userProfile.roles : [];
32
+
33
+ const viaRole = roles.some((role) =>
34
+ (Array.isArray(role?.permissions) ? role.permissions : []).some(
35
+ (item) => item?.name === permission
36
+ )
37
+ );
38
+
39
+ if (viaRole) {
40
+ return true;
41
+ }
42
+
43
+ const direct = Array.isArray(userProfile.permissions)
44
+ ? userProfile.permissions
45
+ : [];
46
+
47
+ return direct.some((item) => item?.name === permission);
48
+ };
49
+
50
+ /**
51
+ * Reduce any incoming number to Australian local digits (0xxxxxxxxx).
52
+ * `+61412345678` / `61412345678` / `0412 345 678` all collapse to `0412345678`.
53
+ * Display formatting only — the server mirrors this same normalisation for the
54
+ * client match.
55
+ */
56
+ export const toLocalDigits = (value) => {
57
+ if (!value) {
58
+ return '';
59
+ }
60
+
61
+ let digits = String(value).replace(/\D/g, '');
62
+
63
+ // +61 / 0011 61 style country code -> single leading zero.
64
+ if (digits.length === 11 && digits.indexOf('61') === 0) {
65
+ digits = `0${digits.slice(2)}`;
66
+ }
67
+
68
+ return digits;
69
+ };
70
+
71
+ /**
72
+ * Human AU formatting: mobiles as `04xx xxx xxx`, landlines as `0x xxxx xxxx`.
73
+ * libphonenumber-js is not a dependency, so this mirrors the formatter the
74
+ * legacy vendored call pop used (minus its `.replace('61', 0)` bug, which
75
+ * mangled numbers that merely contained "61").
76
+ */
77
+ export const formatAuPhone = (value) => {
78
+ const digits = toLocalDigits(value);
79
+
80
+ if (digits === '') {
81
+ return value ? String(value) : 'Unknown number';
82
+ }
83
+
84
+ if (digits.indexOf('04') === 0) {
85
+ const mobile = digits.match(/^(\d{4})(\d{3})(\d{3})$/);
86
+ if (mobile) {
87
+ return `${mobile[1]} ${mobile[2]} ${mobile[3]}`;
88
+ }
89
+ } else {
90
+ const landline = digits.match(/^(\d{2})(\d{4})(\d{4})$/);
91
+ if (landline) {
92
+ return `${landline[1]} ${landline[2]} ${landline[3]}`;
93
+ }
94
+ }
95
+
96
+ return digits;
97
+ };
98
+
99
+ /**
100
+ * A meeting date as `DD/MM h:mma` (e.g. `21/08 10:00am`). Empty string for an
101
+ * absent or unparseable date, so the caller can just skip it.
102
+ */
103
+ export const formatEventDate = (value) => {
104
+ if (!value) {
105
+ return '';
106
+ }
107
+
108
+ const date = new Date(value);
109
+
110
+ if (Number.isNaN(date.getTime())) {
111
+ return '';
112
+ }
113
+
114
+ const day = String(date.getDate()).padStart(2, '0');
115
+ const month = String(date.getMonth() + 1).padStart(2, '0');
116
+ const hours = date.getHours();
117
+ const minutes = String(date.getMinutes()).padStart(2, '0');
118
+ const suffix = hours < 12 ? 'am' : 'pm';
119
+ const hour12 = hours % 12 === 0 ? 12 : hours % 12;
120
+
121
+ return `${day}/${month} ${hour12}:${minutes}${suffix}`;
122
+ };
123
+
124
+ /** A task due date as `DD/MM/YYYY`; empty string when absent or unparseable. */
125
+ export const formatDueDate = (value) => {
126
+ if (!value) {
127
+ return '';
128
+ }
129
+
130
+ const date = new Date(value);
131
+
132
+ if (Number.isNaN(date.getTime())) {
133
+ return '';
134
+ }
135
+
136
+ const day = String(date.getDate()).padStart(2, '0');
137
+ const month = String(date.getMonth() + 1).padStart(2, '0');
138
+
139
+ return `${day}/${month}/${date.getFullYear()}`;
140
+ };
141
+
142
+ /**
143
+ * The `dataId` the Active Call workspace (`/call/:dataId`) expects: it rebuilds
144
+ * the number it searches on as `'0' + dataId.slice(2)`, so the param has to be
145
+ * the AU number in `61xxxxxxxxx` form. Anything that does not reduce to a local
146
+ * `0…` number has no workspace to open, and yields ''.
147
+ */
148
+ export const toCallWorkspaceId = (value) => {
149
+ const digits = toLocalDigits(value);
150
+
151
+ if (digits.indexOf('0') !== 0 || digits.length < 9) {
152
+ return '';
153
+ }
154
+
155
+ return `61${digits.slice(1)}`;
156
+ };
157
+
158
+ /**
159
+ * Tasks index filtered to one client: `/tasks/:param_task_type_id/:param_company_id`.
160
+ * Type 0 is the grid's own "All Current Tasks" option, which is the closest
161
+ * thing to "every open task for this client" the route can express — types 1-4
162
+ * each narrow it further.
163
+ */
164
+ export const defaultClientTasksUrl = (clientId) => `/tasks/0/${clientId}`;
165
+
166
+ /** Default popup target for a task row. */
167
+ export const defaultTaskUrl = (task) => `/tasks/detail/${task?.id}`;
168
+
169
+ /** Default detail route for a matched client. */
170
+ export const defaultClientUrl = (client) => `/clients/${client?.id}`;
171
+
172
+ /** The client fields shown as label/value rows, in card order. */
173
+ export const CLIENT_DETAIL_FIELDS = [
174
+ { key: 'adviser', label: 'Adviser' },
175
+ { key: 'coding', label: 'Coding' },
176
+ { key: 'age', label: 'Age' },
177
+ { key: 'city', label: 'City' },
178
+ ];
179
+
180
+ /**
181
+ * Detail rows for a client block, with the empty ones dropped — a card only
182
+ * ever shows what the CRM actually knows, never a row with a blank value.
183
+ */
184
+ export const clientDetails = (client) => {
185
+ if (!client || typeof client !== 'object') {
186
+ return [];
187
+ }
188
+
189
+ return CLIENT_DETAIL_FIELDS.map(({ key, label }) => ({
190
+ label,
191
+ value: client[key],
192
+ })).filter(
193
+ (row) =>
194
+ row.value !== null &&
195
+ row.value !== undefined &&
196
+ String(row.value).trim() !== ''
197
+ );
198
+ };
199
+
200
+ /** Seconds since `startedAt` rendered as m:ss (never negative). */
201
+ export const formatElapsed = (startedAt, now) => {
202
+ const started = new Date(startedAt).getTime();
203
+
204
+ if (Number.isNaN(started)) {
205
+ return '0:00';
206
+ }
207
+
208
+ const seconds = Math.max(0, Math.floor((now - started) / 1000));
209
+ const minutes = Math.floor(seconds / 60);
210
+ const remainder = seconds % 60;
211
+
212
+ return `${minutes}:${String(remainder).padStart(2, '0')}`;
213
+ };
214
+
215
+ /**
216
+ * Coerce a payload of unknown shape (snapshot endpoint or broadcast) into the
217
+ * component's call shape. Snake_case and camelCase keys are both accepted so a
218
+ * Laravel resource can be passed through untouched.
219
+ */
220
+ export const normaliseCall = (raw) => {
221
+ if (!raw || typeof raw !== 'object') {
222
+ return null;
223
+ }
224
+
225
+ const callId = raw.callId ?? raw.call_id ?? raw.id;
226
+
227
+ if (callId === undefined || callId === null || callId === '') {
228
+ return null;
229
+ }
230
+
231
+ // The Zoom call queue this rang on. `queueId` keys the pickup-code lookup
232
+ // and may legitimately be absent; `queueName` is what the badge prints.
233
+ const rawQueueId = raw.queueId ?? raw.queue_id ?? null;
234
+ const queueId =
235
+ rawQueueId === null || rawQueueId === undefined || rawQueueId === ''
236
+ ? null
237
+ : String(rawQueueId);
238
+
239
+ const rawQueueName = raw.queueName ?? raw.queue_name ?? '';
240
+ const queueName = String(rawQueueName).trim() || FALLBACK_QUEUE_NAME;
241
+
242
+ return {
243
+ callId: String(callId),
244
+ queueId,
245
+ queueName,
246
+ callerNumber: raw.callerNumber ?? raw.caller_number ?? raw.from ?? '',
247
+ callerName: raw.callerName ?? raw.caller_name ?? '',
248
+ client: raw.client ?? null,
249
+ // Pre-supplied open tasks. Only demo calls carry these; a real call
250
+ // leaves it null and the expansion fetches the drill-down endpoint.
251
+ tasks: Array.isArray(raw.tasks) ? raw.tasks : null,
252
+ startedAt: raw.startedAt ?? raw.started_at ?? new Date().toISOString(),
253
+ status: 'ringing',
254
+ isDemo: raw.isDemo === true || raw.is_demo === true,
255
+ leaving: false,
256
+ };
257
+ };
258
+
259
+ /**
260
+ * Coerce the snapshot's `pickup_codes` block into a plain `{ queueId: code }`
261
+ * map, dropping anything that is not a non-empty string on both sides. A queue
262
+ * absent from the map has no code, which is what suppresses its Pick up button.
263
+ */
264
+ export const normalisePickupCodes = (raw) => {
265
+ if (!raw || typeof raw !== 'object') {
266
+ return {};
267
+ }
268
+
269
+ const codes = {};
270
+
271
+ Object.keys(raw).forEach((queueId) => {
272
+ const code = raw[queueId];
273
+
274
+ if (queueId !== '' && typeof code === 'string' && code.trim() !== '') {
275
+ codes[queueId] = code.trim();
276
+ }
277
+ });
278
+
279
+ return codes;
280
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Pure helpers for the call-queue settings page.
3
+ *
4
+ * Split out of CallQueueSettings.jsx so they can be imported by `node --test`,
5
+ * which cannot parse JSX. Nothing here touches React or the DOM; the component
6
+ * re-exports the lot, so its public surface is unchanged.
7
+ */
8
+
9
+ /**
10
+ * Zoom's pickup code rules, mirrored from CallQueueSettingsController so a typo
11
+ * is caught before the round trip. Returns an error string, or '' when valid.
12
+ */
13
+ export const validateCode = (value) => {
14
+ const code = String(value ?? '').trim();
15
+
16
+ if (code === '') return '';
17
+ if (!/^\d{4}$/.test(code)) return 'Must be 4 digits.';
18
+ if (code[0] === '0') return 'Cannot start with 0.';
19
+ if (/^(\d)\1{3}$/.test(code)) return 'Cannot repeat one digit.';
20
+
21
+ const steps = [1, 2, 3].map((i) => Number(code[i]) - Number(code[i - 1]));
22
+
23
+ if (steps.every((s) => s === 1) || steps.every((s) => s === -1)) {
24
+ return 'Cannot be a sequence.';
25
+ }
26
+
27
+ return '';
28
+ };
29
+
30
+ /** Digits only — the `*` is the dial prefix, never part of the stored code. */
31
+ export const cleanCode = (value) =>
32
+ String(value ?? '')
33
+ .replace(/[^\d]/g, '')
34
+ .slice(0, 4);
35
+
36
+ /**
37
+ * Human AU formatting for the queue's own number, using the same digit logic as
38
+ * the call pop's formatter (callQueue/CallQueuePop.jsx): a +61 / 61 country code
39
+ * collapses to a single leading zero, then mobiles read as `04xx xxx xxx` and
40
+ * landlines as `(0x) xxxx xxxx`. Anything that does not fit a known shape is
41
+ * handed back untouched rather than mangled.
42
+ */
43
+ export const formatAuNumber = (value) => {
44
+ if (!value) return '';
45
+
46
+ let digits = String(value).replace(/\D/g, '');
47
+
48
+ if (digits.length === 11 && digits.indexOf('61') === 0) {
49
+ digits = `0${digits.slice(2)}`;
50
+ }
51
+
52
+ if (digits.indexOf('04') === 0) {
53
+ const mobile = digits.match(/^(\d{4})(\d{3})(\d{3})$/);
54
+
55
+ if (mobile) return `${mobile[1]} ${mobile[2]} ${mobile[3]}`;
56
+ } else if (/^1[38]00/.test(digits) && digits.length === 10) {
57
+ // 13/1800 service numbers group as 1300 123 456.
58
+ return `${digits.slice(0, 4)} ${digits.slice(4, 7)} ${digits.slice(7)}`;
59
+ } else {
60
+ const landline = digits.match(/^(\d{2})(\d{4})(\d{4})$/);
61
+
62
+ if (landline) return `(${landline[1]}) ${landline[2]} ${landline[3]}`;
63
+ }
64
+
65
+ return String(value);
66
+ };
67
+
68
+ /** Zoom sends the status lower case ("active"); the pill shows it title case. */
69
+ export const titleCase = (value) => {
70
+ const text = String(value ?? '').trim();
71
+
72
+ if (text === '') return 'Unknown';
73
+
74
+ return text.charAt(0).toUpperCase() + text.slice(1);
75
+ };
@@ -14,6 +14,7 @@ import {
14
14
  DEFAULT_INTELLIGENT_SORTING_CONFIG,
15
15
  } from '../../utils/relationshipSortingUtils';
16
16
  import { formatCellContent } from '../generic/shared/formatters';
17
+ import { arrayCountFrom } from '../utils/displayValue';
17
18
 
18
19
  // Stage Toggle Utility Functions
19
20
  const buildToggleUrl = (urlTemplate, stageItem, rowData) => {
@@ -380,18 +381,63 @@ export const renderDateTimeColumn = ({
380
381
  };
381
382
  };
382
383
 
383
- // Array Count Column Renderer
384
+ /**
385
+ * Array Count Column Renderer
386
+ *
387
+ * A count is a quantity, and a bare digit in a text column reads as a short
388
+ * piece of text — it takes a second look to tell "3" the count from "3" the
389
+ * label. The number goes in a small neutral pill so the eye can pick the
390
+ * column out and compare rows down it, and an empty list says so with a muted
391
+ * dash rather than an empty cell (an empty cell is what a column shows when it
392
+ * has no answer; here we have one — none).
393
+ *
394
+ * `arrayCountFrom` is what makes the zero case reachable at all: the old
395
+ * truthiness check sent `[]`, `"[]"` and a missing field down the same branch,
396
+ * and `.length` on a JSON string that was never decoded returned the character
397
+ * count.
398
+ */
384
399
  export const renderArrayCountColumn = ({ column, commonProps }) => {
385
400
  return {
386
401
  ...commonProps,
387
402
  name: `${column.type}.${column.id}`,
388
- sortable: true,
403
+ // Was hard-coded `true`. A count column is derived from a JSON field,
404
+ // so server-side ordering on it is lexicographic nonsense as often as
405
+ // not; a config can now turn the header control off. Default unchanged.
406
+ sortable: column.sortable ?? true,
389
407
  render: ({ data }) => {
390
- if (data && data[column.id]) {
391
- return data[column.id].length;
392
- } else {
393
- return null;
408
+ const count = arrayCountFrom(data ? data[column.id] : null);
409
+
410
+ if (count === 0) {
411
+ return (
412
+ <span style={{ color: 'var(--muted-color, #6e7276)' }}>
413
+ —
414
+ </span>
415
+ );
394
416
  }
417
+
418
+ return (
419
+ <span
420
+ style={{
421
+ display: 'inline-flex',
422
+ alignItems: 'center',
423
+ justifyContent: 'center',
424
+ minWidth: '22px',
425
+ height: '20px',
426
+ padding: '0 7px',
427
+ boxSizing: 'border-box',
428
+ borderRadius: '999px',
429
+ border: '1px solid var(--border-color, #e1e1e1)',
430
+ background: 'var(--hover-color, rgba(0, 0, 0, 0.04))',
431
+ color: 'var(--paragraph-color, #221e33)',
432
+ fontSize: '0.75rem',
433
+ fontWeight: 600,
434
+ lineHeight: 1,
435
+ fontVariantNumeric: 'tabular-nums',
436
+ }}
437
+ >
438
+ {count}
439
+ </span>
440
+ );
395
441
  },
396
442
  };
397
443
  };
@@ -2877,7 +2923,7 @@ export const renderStageCounterColumn = ({ column, commonProps, navigate, onUpda
2877
2923
  rawValue !== null &&
2878
2924
  rawValue !== undefined
2879
2925
  ) {
2880
- tooltipContent = `Stage ${i + 1})`;
2926
+ tooltipContent = `Stage ${i + 1}`;
2881
2927
  }
2882
2928
  }
2883
2929