@7365admin1/layer-common 3.2.1 → 3.2.2-staging.77
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/.changeset/camera-wall-gated-endpoint.md +47 -0
- package/.changeset/camera-wall-plain-english.md +60 -0
- package/.changeset/camera-wall-selection-and-guidance.md +65 -0
- package/.changeset/dark-primary-contrast.md +39 -0
- package/.changeset/dashboard-dark-theme-contrast.md +15 -0
- package/.changeset/service-provider-invitation-actions.md +28 -0
- package/components/CameraWall.vue +1034 -0
- package/components/CameraWallTile.vue +818 -0
- package/components/DashboardMain.vue +84 -88
- package/components/DashboardPanel.vue +1 -1
- package/components/EntryPassInformation.vue +4 -4
- package/components/FeedbackMain.vue +7 -7
- package/components/HidAccessLogDashboard.vue +119 -44
- package/components/HidCameraCaptureDialog.vue +199 -0
- package/components/HidIntercomManagement.vue +808 -201
- package/components/HidQrCodeConfiguration.vue +907 -95
- package/components/HidServiceSettingsPanel.vue +20 -1
- package/components/HidUserEnrollment.vue +392 -81
- package/components/Layout/NavigationDrawer.vue +43 -2
- package/components/NavigationItem.vue +9 -0
- package/components/Nfc/NFCPatrolRouteMain.vue +52 -7
- package/components/OnlineFormConfigurationForm.vue +620 -224
- package/components/OnlineFormFill.vue +36 -8
- package/components/OnlineFormsConfiguration.vue +6 -2
- package/components/QrTemplate/CreditCardLandscape.vue +19 -12
- package/components/QrTemplate/PrintDialog.vue +209 -196
- package/components/ServiceProviderInvitationPrompt.vue +150 -0
- package/components/ServiceProviderMain.vue +258 -14
- package/components/SiteSettings.vue +4 -2
- package/components/VisitorForm.vue +201 -78
- package/components/VisitorManagement.vue +2 -3
- package/components/VisitorSocketPopUp.vue +56 -2
- package/composables/useComment.ts +3 -3
- package/composables/useHidAmico.ts +127 -0
- package/composables/useHidNavigation.ts +0 -7
- package/composables/useLocalAuth.ts +9 -36
- package/composables/useOnlineFormPages.ts +2 -0
- package/composables/useServiceProviderInvitation.ts +145 -0
- package/composables/useSipWebPhone.ts +311 -0
- package/composables/useSiteSettings.ts +50 -0
- package/composables/useVisitorSocket.ts +26 -0
- package/composables/useWebUsb.ts +127 -37
- package/package.json +3 -1
- package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
- package/plugins/secure-member.client.ts +21 -56
- package/plugins/vuetify.ts +37 -9
- package/test/visitor-socket.test.mjs +36 -0
- package/types/site.d.ts +71 -0
- package/utils/camera-wall.test.ts +571 -0
- package/utils/camera-wall.ts +926 -0
- package/utils/theme.test.ts +216 -0
- package/utils/theme.ts +163 -0
- package/components/HidIdentityMapping.vue +0 -975
- package/middleware/member.ts +0 -4
- package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +0 -23
|
@@ -12,7 +12,17 @@
|
|
|
12
12
|
Add Contacts
|
|
13
13
|
</v-btn>
|
|
14
14
|
|
|
15
|
-
<
|
|
15
|
+
<v-btn
|
|
16
|
+
v-else
|
|
17
|
+
rounded="xl"
|
|
18
|
+
class="text-none web-call-button"
|
|
19
|
+
:class="{ 'is-connected': webPhone.isRegistered.value, 'has-incoming-call': webPhone.callState.value === 'incoming' }"
|
|
20
|
+
variant="flat"
|
|
21
|
+
prepend-icon="mdi-phone-in-talk-outline"
|
|
22
|
+
@click="openWebPhoneDialog()"
|
|
23
|
+
>
|
|
24
|
+
{{ webPhone.callState.value === 'incoming' ? 'Incoming Call' : 'Web Call' }}
|
|
25
|
+
</v-btn>
|
|
16
26
|
|
|
17
27
|
<div class="toolbar-spacer" />
|
|
18
28
|
|
|
@@ -105,6 +115,7 @@
|
|
|
105
115
|
<v-list-item title="Call Settings" @click="openCallDialog(row)" />
|
|
106
116
|
<v-list-item title="Dial Code" @click="openDialCodeDialog(row)" />
|
|
107
117
|
<v-list-item title="Make a call" @click="openMakeCallDialog(row)" />
|
|
118
|
+
<v-list-item title="Web call" @click="openWebPhoneDialog(row)" />
|
|
108
119
|
</v-list>
|
|
109
120
|
</v-menu>
|
|
110
121
|
</td>
|
|
@@ -223,6 +234,13 @@
|
|
|
223
234
|
<v-radio label="UDP" value="UDP" />
|
|
224
235
|
<v-radio label="TCP" value="TCP" />
|
|
225
236
|
</v-radio-group>
|
|
237
|
+
<div class="sip-switch-row sip-section-switch">
|
|
238
|
+
<div>
|
|
239
|
+
<span>Enable video</span>
|
|
240
|
+
<small>Allow this reader to send its camera feed during SIP intercom calls.</small>
|
|
241
|
+
</div>
|
|
242
|
+
<v-switch v-model="sipForm.videoEnabled" :disabled="!sipForm.enabled" color="success" density="compact" hide-details />
|
|
243
|
+
</div>
|
|
226
244
|
</v-card-text>
|
|
227
245
|
<v-card-actions class="dialog-actions pa-0">
|
|
228
246
|
<v-btn class="text-none action-cancel" variant="flat" height="44" @click="sipDialog = false">Cancel</v-btn>
|
|
@@ -231,36 +249,6 @@
|
|
|
231
249
|
</v-card>
|
|
232
250
|
</v-dialog>
|
|
233
251
|
|
|
234
|
-
<v-dialog v-model="rebootConfirmDialog" max-width="700" persistent>
|
|
235
|
-
<v-card class="reboot-dialog" rounded="0">
|
|
236
|
-
<button class="reboot-close" type="button" aria-label="Close" @click="rebootConfirmDialog = false">
|
|
237
|
-
<v-icon icon="mdi-close" />
|
|
238
|
-
</button>
|
|
239
|
-
<v-card-title class="reboot-title">Reboot is needed</v-card-title>
|
|
240
|
-
<v-card-text class="reboot-message">
|
|
241
|
-
It is necessary to restart the device after applying the configurations. Do you want to proceed?
|
|
242
|
-
</v-card-text>
|
|
243
|
-
<v-card-actions class="reboot-actions">
|
|
244
|
-
<v-btn class="text-none reboot-cancel" variant="text" @click="rebootConfirmDialog = false">
|
|
245
|
-
Cancel
|
|
246
|
-
</v-btn>
|
|
247
|
-
<v-btn class="text-none reboot-ok" variant="flat" @click="startRebootWait">
|
|
248
|
-
OK
|
|
249
|
-
</v-btn>
|
|
250
|
-
</v-card-actions>
|
|
251
|
-
</v-card>
|
|
252
|
-
</v-dialog>
|
|
253
|
-
|
|
254
|
-
<v-dialog v-model="rebootingDialog" max-width="900" persistent>
|
|
255
|
-
<v-card class="rebooting-card" rounded="0">
|
|
256
|
-
<div class="wait-word">Wait!</div>
|
|
257
|
-
<div class="rebooting-copy">
|
|
258
|
-
<h3>Rebooting Equipment</h3>
|
|
259
|
-
<p>Wait {{ rebootCountdown }} seconds for the equipment to reboot.</p>
|
|
260
|
-
</div>
|
|
261
|
-
</v-card>
|
|
262
|
-
</v-dialog>
|
|
263
|
-
|
|
264
252
|
<v-dialog v-model="callDialog" max-width="360" persistent>
|
|
265
253
|
<v-card class="form-dialog call-dialog" rounded="lg">
|
|
266
254
|
<v-card-title class="dialog-title">Call Settings</v-card-title>
|
|
@@ -350,10 +338,10 @@
|
|
|
350
338
|
<v-card-title class="dialog-title">Make a Call</v-card-title>
|
|
351
339
|
<v-card-text class="dialog-body make-call-body">
|
|
352
340
|
<div class="make-call-field">
|
|
353
|
-
<div class="call-label">
|
|
341
|
+
<div class="call-label">IP address to dial</div>
|
|
354
342
|
<v-text-field
|
|
355
343
|
v-model="makeCallForm.target"
|
|
356
|
-
placeholder="
|
|
344
|
+
placeholder="000.000.0.0"
|
|
357
345
|
variant="outlined"
|
|
358
346
|
density="compact"
|
|
359
347
|
hide-details="auto"
|
|
@@ -388,8 +376,181 @@
|
|
|
388
376
|
</div>
|
|
389
377
|
</v-card-text>
|
|
390
378
|
<v-card-actions class="dialog-actions pa-0">
|
|
391
|
-
<v-btn class="text-none action-
|
|
392
|
-
|
|
379
|
+
<v-btn class="text-none action-submit full-action" variant="flat" height="44" @click="makeCallDialog = false">Close</v-btn>
|
|
380
|
+
</v-card-actions>
|
|
381
|
+
</v-card>
|
|
382
|
+
</v-dialog>
|
|
383
|
+
|
|
384
|
+
<v-dialog v-model="webPhoneDialog" max-width="820" persistent eager>
|
|
385
|
+
<v-card class="web-phone-dialog" rounded="lg">
|
|
386
|
+
<v-card-title class="dialog-title web-phone-title">
|
|
387
|
+
<span>Web Call</span>
|
|
388
|
+
<v-chip size="small" variant="flat" :class="webPhoneStatusClass">
|
|
389
|
+
{{ webPhoneStatusLabel }}
|
|
390
|
+
</v-chip>
|
|
391
|
+
</v-card-title>
|
|
392
|
+
|
|
393
|
+
<v-card-text class="web-phone-body">
|
|
394
|
+
<div class="web-phone-settings">
|
|
395
|
+
<div class="web-phone-section-title">SIP Account</div>
|
|
396
|
+
<div v-if="sipAccountLoading" class="sip-account-state">
|
|
397
|
+
<v-progress-circular indeterminate color="primary" size="30" width="3" />
|
|
398
|
+
<span>Preparing your private SIP account...</span>
|
|
399
|
+
</div>
|
|
400
|
+
<div v-else-if="sipAccountError" class="sip-account-state is-error">
|
|
401
|
+
<v-icon icon="mdi-alert-circle-outline" size="28" />
|
|
402
|
+
<span>{{ sipAccountError }}</span>
|
|
403
|
+
<v-btn
|
|
404
|
+
class="text-none sip-account-retry"
|
|
405
|
+
variant="outlined"
|
|
406
|
+
prepend-icon="mdi-refresh"
|
|
407
|
+
@click="loadSipAccount"
|
|
408
|
+
>
|
|
409
|
+
Retry
|
|
410
|
+
</v-btn>
|
|
411
|
+
</div>
|
|
412
|
+
<div v-else-if="sipAccount" class="sip-account-summary">
|
|
413
|
+
<div>
|
|
414
|
+
<span class="sip-account-label">Assigned extension</span>
|
|
415
|
+
<strong class="sip-account-extension">{{ sipAccount.extension }}</strong>
|
|
416
|
+
</div>
|
|
417
|
+
<span class="sip-account-address">{{ sipAccount.sipAddress }}</span>
|
|
418
|
+
</div>
|
|
419
|
+
<div class="call-toggle-row web-video-toggle">
|
|
420
|
+
<div>
|
|
421
|
+
<span>Receive HID video</span>
|
|
422
|
+
<small>Show the HID camera feed without sharing this device's camera.</small>
|
|
423
|
+
</div>
|
|
424
|
+
<v-switch
|
|
425
|
+
v-model="webPhoneForm.video"
|
|
426
|
+
color="success"
|
|
427
|
+
density="compact"
|
|
428
|
+
hide-details
|
|
429
|
+
:disabled="webPhone.isRegistered.value"
|
|
430
|
+
/>
|
|
431
|
+
</div>
|
|
432
|
+
<v-btn
|
|
433
|
+
v-if="!webPhone.isRegistered.value"
|
|
434
|
+
block
|
|
435
|
+
class="text-none web-phone-connect"
|
|
436
|
+
variant="flat"
|
|
437
|
+
prepend-icon="mdi-lan-connect"
|
|
438
|
+
:loading="webPhoneConnecting"
|
|
439
|
+
:disabled="!sipAccount || sipAccountLoading"
|
|
440
|
+
@click="connectWebPhone"
|
|
441
|
+
>
|
|
442
|
+
Connect Web Phone
|
|
443
|
+
</v-btn>
|
|
444
|
+
<v-btn
|
|
445
|
+
v-else
|
|
446
|
+
block
|
|
447
|
+
class="text-none web-phone-disconnect"
|
|
448
|
+
variant="outlined"
|
|
449
|
+
prepend-icon="mdi-lan-disconnect"
|
|
450
|
+
:loading="webPhoneConnecting"
|
|
451
|
+
:disabled="webPhone.hasCall.value"
|
|
452
|
+
@click="disconnectWebPhone"
|
|
453
|
+
>
|
|
454
|
+
Disconnect
|
|
455
|
+
</v-btn>
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
<div class="web-phone-console">
|
|
459
|
+
<div
|
|
460
|
+
class="web-phone-media"
|
|
461
|
+
:class="{
|
|
462
|
+
'has-video': webPhoneForm.video && webPhone.callState.value === 'active',
|
|
463
|
+
'has-remote-video': webPhone.remoteVideoAvailable.value,
|
|
464
|
+
}"
|
|
465
|
+
>
|
|
466
|
+
<video ref="remoteVideoEl" autoplay playsinline class="remote-video" />
|
|
467
|
+
<audio ref="remoteAudioEl" autoplay />
|
|
468
|
+
<div
|
|
469
|
+
v-if="!webPhoneForm.video || webPhone.callState.value !== 'active' || !webPhone.remoteVideoAvailable.value"
|
|
470
|
+
class="audio-call-state"
|
|
471
|
+
>
|
|
472
|
+
<v-icon size="54">{{ webPhoneCallIcon }}</v-icon>
|
|
473
|
+
<strong>{{ webPhoneCallLabel }}</strong>
|
|
474
|
+
<span v-if="webPhone.currentTarget.value">{{ webPhone.currentTarget.value }}</span>
|
|
475
|
+
<span v-if="webPhoneForm.video && webPhone.callState.value === 'active' && !webPhone.remoteVideoAvailable.value">
|
|
476
|
+
Waiting for video from the HID device
|
|
477
|
+
</span>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
<div v-if="webPhone.callState.value === 'incoming'" class="incoming-call-actions">
|
|
482
|
+
<v-btn
|
|
483
|
+
class="text-none answer-call"
|
|
484
|
+
variant="flat"
|
|
485
|
+
prepend-icon="mdi-phone"
|
|
486
|
+
:loading="webPhoneActionLoading"
|
|
487
|
+
@click="answerWebCall"
|
|
488
|
+
>
|
|
489
|
+
Answer
|
|
490
|
+
</v-btn>
|
|
491
|
+
<v-btn
|
|
492
|
+
class="text-none reject-call"
|
|
493
|
+
variant="flat"
|
|
494
|
+
prepend-icon="mdi-phone-hangup"
|
|
495
|
+
:disabled="webPhoneActionLoading"
|
|
496
|
+
@click="declineWebCall"
|
|
497
|
+
>
|
|
498
|
+
Decline
|
|
499
|
+
</v-btn>
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
<template v-else>
|
|
503
|
+
<div class="call-label">Extension or SIP Address</div>
|
|
504
|
+
<div class="web-phone-dial-row">
|
|
505
|
+
<v-text-field
|
|
506
|
+
v-model="webPhoneForm.destination"
|
|
507
|
+
placeholder="1001 or device@example.com"
|
|
508
|
+
variant="outlined"
|
|
509
|
+
density="compact"
|
|
510
|
+
hide-details="auto"
|
|
511
|
+
class="call-input mb-0"
|
|
512
|
+
:disabled="!webPhone.isRegistered.value || webPhone.hasCall.value"
|
|
513
|
+
@keyup.enter="startWebCall"
|
|
514
|
+
/>
|
|
515
|
+
<v-btn
|
|
516
|
+
icon="mdi-phone"
|
|
517
|
+
class="start-web-call"
|
|
518
|
+
variant="flat"
|
|
519
|
+
:loading="webPhoneActionLoading && webPhone.callState.value === 'calling'"
|
|
520
|
+
:disabled="!webPhone.isRegistered.value || webPhone.hasCall.value || !webPhoneForm.destination.trim()"
|
|
521
|
+
@click="startWebCall"
|
|
522
|
+
/>
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
<div v-if="webPhone.hasCall.value" class="active-call-controls">
|
|
526
|
+
<v-btn
|
|
527
|
+
:icon="webPhone.muted.value ? 'mdi-microphone-off' : 'mdi-microphone'"
|
|
528
|
+
variant="outlined"
|
|
529
|
+
:disabled="webPhone.callState.value !== 'active'"
|
|
530
|
+
:title="webPhone.muted.value ? 'Unmute' : 'Mute'"
|
|
531
|
+
@click="toggleWebMute"
|
|
532
|
+
/>
|
|
533
|
+
<v-btn
|
|
534
|
+
icon="mdi-phone-hangup"
|
|
535
|
+
class="hangup-web-call"
|
|
536
|
+
variant="flat"
|
|
537
|
+
:loading="webPhoneActionLoading && webPhone.callState.value === 'ending'"
|
|
538
|
+
title="End call"
|
|
539
|
+
@click="hangupWebCall"
|
|
540
|
+
/>
|
|
541
|
+
</div>
|
|
542
|
+
</template>
|
|
543
|
+
|
|
544
|
+
<v-alert v-if="webPhone.errorMessage.value" type="error" variant="tonal" density="compact" class="web-phone-error">
|
|
545
|
+
{{ webPhone.errorMessage.value }}
|
|
546
|
+
</v-alert>
|
|
547
|
+
</div>
|
|
548
|
+
</v-card-text>
|
|
549
|
+
|
|
550
|
+
<v-card-actions class="dialog-actions pa-0">
|
|
551
|
+
<v-btn class="text-none action-submit full-action" variant="flat" height="44" @click="webPhoneDialog = false">
|
|
552
|
+
Close
|
|
553
|
+
</v-btn>
|
|
393
554
|
</v-card-actions>
|
|
394
555
|
</v-card>
|
|
395
556
|
</v-dialog>
|
|
@@ -448,6 +609,10 @@
|
|
|
448
609
|
|
|
449
610
|
<script setup lang="ts">
|
|
450
611
|
const props = defineProps({
|
|
612
|
+
org: {
|
|
613
|
+
type: String,
|
|
614
|
+
required: true,
|
|
615
|
+
},
|
|
451
616
|
site: {
|
|
452
617
|
type: String,
|
|
453
618
|
required: true,
|
|
@@ -466,6 +631,19 @@ type ContactRow = {
|
|
|
466
631
|
dialCode: string;
|
|
467
632
|
};
|
|
468
633
|
|
|
634
|
+
type SipAccount = {
|
|
635
|
+
orgId: string;
|
|
636
|
+
site: string;
|
|
637
|
+
userId: string;
|
|
638
|
+
extension: string;
|
|
639
|
+
username: string;
|
|
640
|
+
password: string;
|
|
641
|
+
sipAddress: string;
|
|
642
|
+
websocketUrl: string;
|
|
643
|
+
status: "active";
|
|
644
|
+
provisionedAt: string;
|
|
645
|
+
};
|
|
646
|
+
|
|
469
647
|
type DetailItem = {
|
|
470
648
|
label: string;
|
|
471
649
|
value?: string | number | boolean;
|
|
@@ -474,7 +652,7 @@ type DetailItem = {
|
|
|
474
652
|
};
|
|
475
653
|
|
|
476
654
|
type IntercomRow = {
|
|
477
|
-
reader: Record<string,
|
|
655
|
+
reader: Record<string, unknown>;
|
|
478
656
|
name: string;
|
|
479
657
|
location: string;
|
|
480
658
|
serverAddress: string;
|
|
@@ -490,6 +668,7 @@ type IntercomRow = {
|
|
|
490
668
|
password: string;
|
|
491
669
|
operatingMode: string;
|
|
492
670
|
protocol: string;
|
|
671
|
+
videoEnabled: boolean;
|
|
493
672
|
dialMode: string;
|
|
494
673
|
dialModeLabel: string;
|
|
495
674
|
dialCode: string;
|
|
@@ -502,7 +681,7 @@ type IntercomRow = {
|
|
|
502
681
|
externalDialButton: boolean;
|
|
503
682
|
unknownContact: boolean;
|
|
504
683
|
callAfter: string;
|
|
505
|
-
pjsip: Record<string,
|
|
684
|
+
pjsip: Record<string, unknown>;
|
|
506
685
|
};
|
|
507
686
|
|
|
508
687
|
const {
|
|
@@ -513,6 +692,7 @@ const {
|
|
|
513
692
|
getIntercomStatus,
|
|
514
693
|
makeIntercomCall,
|
|
515
694
|
finalizeIntercomCall,
|
|
695
|
+
ensureSipAccount,
|
|
516
696
|
} = useHidAmico();
|
|
517
697
|
|
|
518
698
|
const activeTab = ref<IntercomTab>("intercom");
|
|
@@ -531,21 +711,26 @@ const limit = 20;
|
|
|
531
711
|
const selectedRow = ref<IntercomRow | null>(null);
|
|
532
712
|
const selectedContact = ref<ContactRow | null>(null);
|
|
533
713
|
const showSipPassword = ref(false);
|
|
714
|
+
const webPhoneConnecting = ref(false);
|
|
715
|
+
const webPhoneActionLoading = ref(false);
|
|
716
|
+
const sipAccount = ref<SipAccount | null>(null);
|
|
717
|
+
const sipAccountLoading = ref(false);
|
|
718
|
+
const sipAccountError = ref("");
|
|
719
|
+
const remoteVideoEl = ref<HTMLVideoElement | null>(null);
|
|
720
|
+
const remoteAudioEl = ref<HTMLAudioElement | null>(null);
|
|
721
|
+
const webPhone = useSipWebPhone();
|
|
534
722
|
|
|
535
723
|
const viewDialog = ref(false);
|
|
536
724
|
const sipDialog = ref(false);
|
|
537
|
-
const rebootConfirmDialog = ref(false);
|
|
538
|
-
const rebootingDialog = ref(false);
|
|
539
725
|
const callDialog = ref(false);
|
|
540
726
|
const dialCodeDialog = ref(false);
|
|
541
727
|
const makeCallDialog = ref(false);
|
|
728
|
+
const webPhoneDialog = ref(false);
|
|
542
729
|
const contactDialog = ref(false);
|
|
543
730
|
const contactViewDialog = ref(false);
|
|
544
731
|
const deleteContactDialog = ref(false);
|
|
545
732
|
|
|
546
733
|
const message = reactive<{ type: MessageType; text: string }>({ type: "info", text: "" });
|
|
547
|
-
const rebootCountdown = ref(59);
|
|
548
|
-
let rebootTimer: ReturnType<typeof setInterval> | undefined;
|
|
549
734
|
|
|
550
735
|
const statusOptions = ["Status", "Connected", "Not connected", "Connecting", "Disabled", "Failed"];
|
|
551
736
|
|
|
@@ -582,13 +767,14 @@ const sipForm = reactive({
|
|
|
582
767
|
serverIp: "",
|
|
583
768
|
serverPort: "5060",
|
|
584
769
|
initialRtpPort: "10000",
|
|
585
|
-
finalRtpPort: "
|
|
770
|
+
finalRtpPort: "10100",
|
|
586
771
|
numericExtension: false,
|
|
587
772
|
extension: "",
|
|
588
773
|
login: "",
|
|
589
774
|
password: "",
|
|
590
775
|
operatingMode: "sip-client",
|
|
591
776
|
protocol: "UDP",
|
|
777
|
+
videoEnabled: false,
|
|
592
778
|
});
|
|
593
779
|
|
|
594
780
|
const callForm = reactive({
|
|
@@ -617,6 +803,11 @@ const makeCallForm = reactive({
|
|
|
617
803
|
statusType: "info" as MessageType,
|
|
618
804
|
});
|
|
619
805
|
|
|
806
|
+
const webPhoneForm = reactive({
|
|
807
|
+
destination: "",
|
|
808
|
+
video: false,
|
|
809
|
+
});
|
|
810
|
+
|
|
620
811
|
const contactForm = reactive({
|
|
621
812
|
id: undefined as number | undefined,
|
|
622
813
|
readerId: "",
|
|
@@ -694,6 +885,7 @@ const intercomDetailSections = computed(() => {
|
|
|
694
885
|
items: [
|
|
695
886
|
{ label: "Operating Mode", value: row?.operatingMode },
|
|
696
887
|
{ label: "Protocol", value: row?.protocol },
|
|
888
|
+
{ label: "Video", value: row?.videoEnabled ? "Enabled" : "Disabled" },
|
|
697
889
|
],
|
|
698
890
|
},
|
|
699
891
|
{
|
|
@@ -717,10 +909,45 @@ const contactDetailRows = computed<DetailItem[]>(() => [
|
|
|
717
909
|
{ label: "Server Address", value: selectedContact.value?.serverAddress, link: true },
|
|
718
910
|
{ label: "Location", value: selectedContact.value?.location },
|
|
719
911
|
]);
|
|
912
|
+
const webPhoneStatusLabel = computed(() => {
|
|
913
|
+
if (webPhone.callState.value === "incoming") return "Incoming call";
|
|
914
|
+
if (webPhone.callState.value === "calling") return "Calling";
|
|
915
|
+
if (webPhone.callState.value === "active") return "In call";
|
|
916
|
+
if (webPhone.callState.value === "ending") return "Ending call";
|
|
917
|
+
if (webPhone.connectionState.value === "connecting") return "Connecting";
|
|
918
|
+
if (webPhone.connectionState.value === "registered") return "Ready";
|
|
919
|
+
if (webPhone.connectionState.value === "error") return "Connection failed";
|
|
920
|
+
return "Disconnected";
|
|
921
|
+
});
|
|
922
|
+
const webPhoneStatusClass = computed(() => {
|
|
923
|
+
if (webPhone.callState.value === "incoming") return "web-phone-status incoming";
|
|
924
|
+
if (webPhone.callState.value === "active" || webPhone.connectionState.value === "registered") return "web-phone-status ready";
|
|
925
|
+
if (webPhone.connectionState.value === "error") return "web-phone-status failed";
|
|
926
|
+
return "web-phone-status idle";
|
|
927
|
+
});
|
|
928
|
+
const webPhoneCallLabel = computed(() => {
|
|
929
|
+
if (webPhone.callState.value === "incoming") return "Incoming call";
|
|
930
|
+
if (webPhone.callState.value === "calling") return "Calling...";
|
|
931
|
+
if (webPhone.callState.value === "active") return "Call connected";
|
|
932
|
+
if (webPhone.callState.value === "ending") return "Ending call...";
|
|
933
|
+
return webPhone.isRegistered.value ? "Ready to call" : "Connect a SIP account";
|
|
934
|
+
});
|
|
935
|
+
const webPhoneCallIcon = computed(() => {
|
|
936
|
+
if (webPhone.callState.value === "incoming") return "mdi-phone-incoming";
|
|
937
|
+
if (webPhone.callState.value === "calling") return "mdi-phone-outgoing";
|
|
938
|
+
if (webPhone.callState.value === "active") return "mdi-phone-in-talk";
|
|
939
|
+
return "mdi-phone-outline";
|
|
940
|
+
});
|
|
720
941
|
|
|
721
942
|
watch(
|
|
722
|
-
() => props.site,
|
|
723
|
-
() =>
|
|
943
|
+
[() => props.org, () => props.site],
|
|
944
|
+
() => {
|
|
945
|
+
if (webPhone.isRegistered.value) void webPhone.disconnect();
|
|
946
|
+
sipAccount.value = null;
|
|
947
|
+
sipAccountError.value = "";
|
|
948
|
+
webPhoneForm.destination = "";
|
|
949
|
+
void reload();
|
|
950
|
+
},
|
|
724
951
|
{ immediate: true },
|
|
725
952
|
);
|
|
726
953
|
|
|
@@ -732,9 +959,12 @@ watch(pages, () => {
|
|
|
732
959
|
if (page.value > pages.value) page.value = pages.value;
|
|
733
960
|
});
|
|
734
961
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
962
|
+
watch(
|
|
963
|
+
() => webPhone.callState.value,
|
|
964
|
+
(state) => {
|
|
965
|
+
if (state === "incoming") webPhoneDialog.value = true;
|
|
966
|
+
},
|
|
967
|
+
);
|
|
738
968
|
|
|
739
969
|
async function reload() {
|
|
740
970
|
if (!props.site) return;
|
|
@@ -798,19 +1028,27 @@ function paginate<T>(items: T[]) {
|
|
|
798
1028
|
return items.slice(start, start + limit);
|
|
799
1029
|
}
|
|
800
1030
|
|
|
801
|
-
function toIntercomRow(reader: Record<string,
|
|
1031
|
+
function toIntercomRow(reader: Record<string, unknown>, pjsip: Record<string, unknown>, status: string): IntercomRow {
|
|
802
1032
|
const peerToPeer = toFlag(getPjsipValue(pjsip, ["peer_to_peer_enabled", "peerToPeerEnabled"])) === "1";
|
|
803
1033
|
const dialMode = toText(getPjsipValue(pjsip, ["dialing_display_mode", "dialingDisplayMode"]), "0");
|
|
1034
|
+
const initialRtpPort = toText(getPjsipValue(pjsip, ["server_outbound_port", "initialRtpPort", "initial_rtp_port"]), "N/A");
|
|
1035
|
+
const rtpPortRange = getPjsipValue(pjsip, ["server_outbound_port_range"]);
|
|
1036
|
+
const explicitFinalRtpPort = getPjsipValue(pjsip, ["finalRtpPort", "final_rtp_port"]);
|
|
1037
|
+
const initialRtpPortNumber = Number(initialRtpPort);
|
|
1038
|
+
const rtpPortRangeNumber = Number(rtpPortRange);
|
|
1039
|
+
const finalRtpPort = rtpPortRange !== undefined && Number.isFinite(initialRtpPortNumber) && Number.isFinite(rtpPortRangeNumber)
|
|
1040
|
+
? String(initialRtpPortNumber + rtpPortRangeNumber)
|
|
1041
|
+
: toText(explicitFinalRtpPort, "N/A");
|
|
804
1042
|
|
|
805
1043
|
return {
|
|
806
1044
|
reader,
|
|
807
|
-
name: reader.name || reader.deviceId
|
|
808
|
-
location: reader.location
|
|
1045
|
+
name: toText(reader.name || reader.deviceId, "HID Reader"),
|
|
1046
|
+
location: toText(reader.location, "N/A"),
|
|
809
1047
|
serverAddress: toText(getPjsipValue(pjsip, ["server_ip", "serverAddress", "server_address", "serverIp"]), "N/A"),
|
|
810
1048
|
serverPort: toText(getPjsipValue(pjsip, ["server_port", "serverPort", "port"]), "N/A"),
|
|
811
|
-
initialRtpPort
|
|
812
|
-
finalRtpPort
|
|
813
|
-
monitorPath: reader.monitorPath
|
|
1049
|
+
initialRtpPort,
|
|
1050
|
+
finalRtpPort,
|
|
1051
|
+
monitorPath: toText(reader.monitorPath, "N/A"),
|
|
814
1052
|
status,
|
|
815
1053
|
sipEnabled: toFlag(getPjsipValue(pjsip, ["enabled"])) === "1",
|
|
816
1054
|
numericExtension: toFlag(getPjsipValue(pjsip, ["numeric_branch_enabled", "numericExtension", "numericExtensionEnabled"])) === "1",
|
|
@@ -819,6 +1057,7 @@ function toIntercomRow(reader: Record<string, any>, pjsip: Record<string, any>,
|
|
|
819
1057
|
password: toText(getPjsipValue(pjsip, ["password"]), ""),
|
|
820
1058
|
operatingMode: peerToPeer ? "Peer-to-Peer" : "SIP Client",
|
|
821
1059
|
protocol: toText(getPjsipValue(pjsip, ["protocol"]), "UDP"),
|
|
1060
|
+
videoEnabled: toFlag(getPjsipValue(pjsip, ["video_enabled", "videoEnabled"])) === "1",
|
|
822
1061
|
dialMode,
|
|
823
1062
|
dialModeLabel: getDialModeLabel(dialMode),
|
|
824
1063
|
dialCode: toText(getPjsipValue(pjsip, ["open_door_command", "dialCode"]), "N/A"),
|
|
@@ -835,7 +1074,7 @@ function toIntercomRow(reader: Record<string, any>, pjsip: Record<string, any>,
|
|
|
835
1074
|
};
|
|
836
1075
|
}
|
|
837
1076
|
|
|
838
|
-
function normalizePjsip(...sources: Record<string,
|
|
1077
|
+
function normalizePjsip(...sources: Record<string, unknown>[]) {
|
|
839
1078
|
for (const source of sources) {
|
|
840
1079
|
const candidate = findPjsipCandidate(source);
|
|
841
1080
|
if (candidate) return candidate;
|
|
@@ -843,45 +1082,46 @@ function normalizePjsip(...sources: Record<string, any>[]) {
|
|
|
843
1082
|
return {};
|
|
844
1083
|
}
|
|
845
1084
|
|
|
846
|
-
function findPjsipCandidate(source: Record<string,
|
|
1085
|
+
function findPjsipCandidate(source: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
|
|
847
1086
|
if (!source || typeof source !== "object") return undefined;
|
|
848
1087
|
const candidates = [
|
|
849
1088
|
source.pjsip,
|
|
850
|
-
source.
|
|
851
|
-
source.
|
|
852
|
-
source.result
|
|
853
|
-
source.
|
|
854
|
-
source.data
|
|
855
|
-
source.data
|
|
856
|
-
source.data
|
|
857
|
-
source.data
|
|
858
|
-
source.data
|
|
859
|
-
source.configuration,
|
|
860
|
-
source.
|
|
861
|
-
source.data
|
|
1089
|
+
toRecord(source.pjsip),
|
|
1090
|
+
toRecord(toRecord(source.configuration).pjsip),
|
|
1091
|
+
toRecord(toRecord(source.result).pjsip),
|
|
1092
|
+
toRecord(toRecord(toRecord(source.result).configuration).pjsip),
|
|
1093
|
+
toRecord(toRecord(source.data).pjsip),
|
|
1094
|
+
toRecord(toRecord(toRecord(source.data).configuration).pjsip),
|
|
1095
|
+
toRecord(toRecord(toRecord(source.data).result).pjsip),
|
|
1096
|
+
toRecord(toRecord(toRecord(toRecord(source.data).result).configuration).pjsip),
|
|
1097
|
+
toRecord(toRecord(toRecord(source.data).data).pjsip),
|
|
1098
|
+
toRecord(toRecord(toRecord(toRecord(source.data).data).configuration).pjsip),
|
|
1099
|
+
toRecord(source.configuration),
|
|
1100
|
+
toRecord(toRecord(source.data).configuration),
|
|
1101
|
+
toRecord(toRecord(toRecord(source.data).data).configuration),
|
|
862
1102
|
source,
|
|
863
1103
|
];
|
|
864
1104
|
|
|
865
1105
|
return candidates.find((candidate) => isPjsipCandidate(candidate));
|
|
866
1106
|
}
|
|
867
1107
|
|
|
868
|
-
function isPjsipCandidate(value: unknown): value is Record<string,
|
|
1108
|
+
function isPjsipCandidate(value: unknown): value is Record<string, unknown> {
|
|
869
1109
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
870
1110
|
return pjsipKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key)) ||
|
|
871
1111
|
["serverAddress", "serverIp", "serverPort", "extension", "peerToPeerEnabled"].some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
872
1112
|
}
|
|
873
1113
|
|
|
874
|
-
function getPjsipValue(source: Record<string,
|
|
1114
|
+
function getPjsipValue(source: Record<string, unknown>, keys: string[]) {
|
|
875
1115
|
for (const key of keys) {
|
|
876
1116
|
if (source?.[key] !== undefined && source?.[key] !== null && source?.[key] !== "") return source[key];
|
|
877
1117
|
}
|
|
878
1118
|
return undefined;
|
|
879
1119
|
}
|
|
880
1120
|
|
|
881
|
-
function normalizeStatus(response: Record<string,
|
|
1121
|
+
function normalizeStatus(response: Record<string, unknown>, pjsip: Record<string, unknown>) {
|
|
882
1122
|
const status = Number(response?.status);
|
|
1123
|
+
if (toFlag(pjsip.enabled) === "0" || status === -1) return "Disabled";
|
|
883
1124
|
if (status === 200) return "Connected";
|
|
884
|
-
if (status === -1 || toFlag(pjsip.enabled) === "0") return "Disabled";
|
|
885
1125
|
if (status === 0 || status === 100) return "Connecting";
|
|
886
1126
|
if ([401, 403, 408, 503].includes(status)) return "Failed";
|
|
887
1127
|
return "Not connected";
|
|
@@ -911,13 +1151,14 @@ function openSipDialog(row: IntercomRow) {
|
|
|
911
1151
|
sipForm.serverIp = row.serverAddress === "N/A" ? "" : row.serverAddress;
|
|
912
1152
|
sipForm.serverPort = row.serverPort === "N/A" ? "5060" : row.serverPort;
|
|
913
1153
|
sipForm.initialRtpPort = row.initialRtpPort === "N/A" ? "10000" : row.initialRtpPort;
|
|
914
|
-
sipForm.finalRtpPort = row.finalRtpPort === "N/A" ? "
|
|
1154
|
+
sipForm.finalRtpPort = row.finalRtpPort === "N/A" ? "10100" : row.finalRtpPort;
|
|
915
1155
|
sipForm.numericExtension = row.numericExtension;
|
|
916
1156
|
sipForm.extension = row.extension === "N/A" ? "" : row.extension;
|
|
917
1157
|
sipForm.login = row.login === "N/A" ? "" : row.login;
|
|
918
1158
|
sipForm.password = row.password;
|
|
919
1159
|
sipForm.operatingMode = row.operatingMode === "Peer-to-Peer" ? "peer-to-peer" : "sip-client";
|
|
920
1160
|
sipForm.protocol = row.protocol || "UDP";
|
|
1161
|
+
sipForm.videoEnabled = row.videoEnabled;
|
|
921
1162
|
sipDialog.value = true;
|
|
922
1163
|
}
|
|
923
1164
|
|
|
@@ -945,12 +1186,134 @@ function openDialCodeDialog(row: IntercomRow) {
|
|
|
945
1186
|
|
|
946
1187
|
function openMakeCallDialog(row: IntercomRow) {
|
|
947
1188
|
selectedRow.value = row;
|
|
948
|
-
makeCallForm.target = row.
|
|
1189
|
+
makeCallForm.target = isIpAddress(row.serverAddress) ? row.serverAddress : "";
|
|
949
1190
|
makeCallForm.status = "";
|
|
950
1191
|
makeCallForm.statusType = "info";
|
|
951
1192
|
makeCallDialog.value = true;
|
|
952
1193
|
}
|
|
953
1194
|
|
|
1195
|
+
function openWebPhoneDialog(row?: IntercomRow) {
|
|
1196
|
+
const activeRow = row || selectedRow.value || intercomRows.value[0];
|
|
1197
|
+
if (activeRow) {
|
|
1198
|
+
selectedRow.value = activeRow;
|
|
1199
|
+
if (!webPhone.isRegistered.value && activeRow.extension !== "N/A") {
|
|
1200
|
+
webPhoneForm.destination = activeRow.extension;
|
|
1201
|
+
webPhoneForm.video = activeRow.videoEnabled;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
webPhoneDialog.value = true;
|
|
1205
|
+
if (!sipAccount.value && !sipAccountLoading.value) void loadSipAccount();
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
async function loadSipAccount() {
|
|
1209
|
+
if (!props.org || !props.site) return;
|
|
1210
|
+
|
|
1211
|
+
sipAccountLoading.value = true;
|
|
1212
|
+
sipAccountError.value = "";
|
|
1213
|
+
try {
|
|
1214
|
+
const response = await ensureSipAccount(props.site, props.org);
|
|
1215
|
+
sipAccount.value = normalizeSipAccount(response);
|
|
1216
|
+
} catch (error: unknown) {
|
|
1217
|
+
sipAccount.value = null;
|
|
1218
|
+
sipAccountError.value = getErrorMessage(error);
|
|
1219
|
+
} finally {
|
|
1220
|
+
sipAccountLoading.value = false;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async function connectWebPhone() {
|
|
1225
|
+
const account = sipAccount.value;
|
|
1226
|
+
if (!account) {
|
|
1227
|
+
await loadSipAccount();
|
|
1228
|
+
if (!sipAccount.value) return;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const activeAccount = sipAccount.value;
|
|
1232
|
+
if (!activeAccount) return;
|
|
1233
|
+
|
|
1234
|
+
webPhoneConnecting.value = true;
|
|
1235
|
+
try {
|
|
1236
|
+
await webPhone.connect({
|
|
1237
|
+
webSocketServer: activeAccount.websocketUrl,
|
|
1238
|
+
aor: activeAccount.sipAddress,
|
|
1239
|
+
authorizationUsername: activeAccount.username,
|
|
1240
|
+
authorizationPassword: activeAccount.password,
|
|
1241
|
+
displayName: "iService365 Web",
|
|
1242
|
+
video: webPhoneForm.video,
|
|
1243
|
+
}, {
|
|
1244
|
+
remoteVideo: remoteVideoEl.value,
|
|
1245
|
+
remoteAudio: remoteAudioEl.value,
|
|
1246
|
+
});
|
|
1247
|
+
showMessage("Web phone connected and ready to receive calls.", "success");
|
|
1248
|
+
} catch (error: unknown) {
|
|
1249
|
+
showMessage(getErrorMessage(error), "error");
|
|
1250
|
+
} finally {
|
|
1251
|
+
webPhoneConnecting.value = false;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
async function disconnectWebPhone() {
|
|
1256
|
+
webPhoneConnecting.value = true;
|
|
1257
|
+
try {
|
|
1258
|
+
await webPhone.disconnect();
|
|
1259
|
+
showMessage("Web phone disconnected.", "info");
|
|
1260
|
+
} finally {
|
|
1261
|
+
webPhoneConnecting.value = false;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
async function startWebCall() {
|
|
1266
|
+
webPhoneActionLoading.value = true;
|
|
1267
|
+
try {
|
|
1268
|
+
await webPhone.call(webPhoneForm.destination);
|
|
1269
|
+
} catch (error: unknown) {
|
|
1270
|
+
showMessage(getErrorMessage(error), "error");
|
|
1271
|
+
} finally {
|
|
1272
|
+
webPhoneActionLoading.value = false;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
async function answerWebCall() {
|
|
1277
|
+
webPhoneActionLoading.value = true;
|
|
1278
|
+
try {
|
|
1279
|
+
await webPhone.answer();
|
|
1280
|
+
} catch (error: unknown) {
|
|
1281
|
+
showMessage(getErrorMessage(error), "error");
|
|
1282
|
+
} finally {
|
|
1283
|
+
webPhoneActionLoading.value = false;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
async function declineWebCall() {
|
|
1288
|
+
webPhoneActionLoading.value = true;
|
|
1289
|
+
try {
|
|
1290
|
+
await webPhone.decline();
|
|
1291
|
+
} catch (error: unknown) {
|
|
1292
|
+
showMessage(getErrorMessage(error), "error");
|
|
1293
|
+
} finally {
|
|
1294
|
+
webPhoneActionLoading.value = false;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
async function hangupWebCall() {
|
|
1299
|
+
webPhoneActionLoading.value = true;
|
|
1300
|
+
try {
|
|
1301
|
+
await webPhone.hangup();
|
|
1302
|
+
} catch (error: unknown) {
|
|
1303
|
+
showMessage(getErrorMessage(error), "error");
|
|
1304
|
+
} finally {
|
|
1305
|
+
webPhoneActionLoading.value = false;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function toggleWebMute() {
|
|
1310
|
+
try {
|
|
1311
|
+
webPhone.toggleMute();
|
|
1312
|
+
} catch (error: unknown) {
|
|
1313
|
+
showMessage(getErrorMessage(error), "error");
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
954
1317
|
function openContactDialog(contact?: ContactRow) {
|
|
955
1318
|
selectedContact.value = contact || null;
|
|
956
1319
|
contactForm.id = contact?.id;
|
|
@@ -974,21 +1337,35 @@ function openDeleteContact(contact: ContactRow) {
|
|
|
974
1337
|
async function saveSipSettings() {
|
|
975
1338
|
if (!selectedRow.value) return;
|
|
976
1339
|
const isPeerToPeer = sipForm.operatingMode === "peer-to-peer";
|
|
1340
|
+
const initialRtpPort = Number(sipForm.initialRtpPort);
|
|
1341
|
+
const finalRtpPort = Number(sipForm.finalRtpPort);
|
|
1342
|
+
|
|
1343
|
+
if (
|
|
1344
|
+
!Number.isInteger(initialRtpPort) ||
|
|
1345
|
+
!Number.isInteger(finalRtpPort) ||
|
|
1346
|
+
initialRtpPort < 1 ||
|
|
1347
|
+
finalRtpPort > 65535 ||
|
|
1348
|
+
finalRtpPort <= initialRtpPort
|
|
1349
|
+
) {
|
|
1350
|
+
showMessage("Final RTP Port must be greater than Initial RTP Port and both ports must be between 1 and 65535.", "warning");
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
977
1354
|
const saved = await savePjsip(selectedRow.value.reader._id, {
|
|
978
1355
|
enabled: sipForm.enabled ? "1" : "0",
|
|
979
1356
|
server_ip: sipForm.serverIp,
|
|
980
1357
|
server_port: sipForm.serverPort,
|
|
981
|
-
server_outbound_port:
|
|
982
|
-
server_outbound_port_range:
|
|
1358
|
+
server_outbound_port: String(initialRtpPort),
|
|
1359
|
+
server_outbound_port_range: String(finalRtpPort - initialRtpPort),
|
|
983
1360
|
numeric_branch_enabled: sipForm.numericExtension ? "1" : "0",
|
|
984
1361
|
branch: isPeerToPeer ? "" : sipForm.extension,
|
|
985
1362
|
login: isPeerToPeer ? "" : sipForm.login,
|
|
986
1363
|
password: isPeerToPeer ? "" : sipForm.password,
|
|
987
1364
|
peer_to_peer_enabled: isPeerToPeer ? "1" : "0",
|
|
988
|
-
|
|
1365
|
+
video_enabled: sipForm.videoEnabled ? "1" : "0",
|
|
1366
|
+
});
|
|
989
1367
|
if (saved) {
|
|
990
1368
|
sipDialog.value = false;
|
|
991
|
-
rebootConfirmDialog.value = true;
|
|
992
1369
|
}
|
|
993
1370
|
}
|
|
994
1371
|
|
|
@@ -1020,7 +1397,13 @@ async function runMakeCall() {
|
|
|
1020
1397
|
if (!selectedRow.value) return;
|
|
1021
1398
|
const target = makeCallForm.target.trim();
|
|
1022
1399
|
if (!target) {
|
|
1023
|
-
makeCallForm.status = "Please enter an address to dial.";
|
|
1400
|
+
makeCallForm.status = "Please enter an IP address to dial.";
|
|
1401
|
+
makeCallForm.statusType = "warning";
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
if (!isIpAddress(target)) {
|
|
1406
|
+
makeCallForm.status = "Address to dial must be an IP address.";
|
|
1024
1407
|
makeCallForm.statusType = "warning";
|
|
1025
1408
|
return;
|
|
1026
1409
|
}
|
|
@@ -1042,6 +1425,15 @@ async function runMakeCall() {
|
|
|
1042
1425
|
}
|
|
1043
1426
|
}
|
|
1044
1427
|
|
|
1428
|
+
function isIpAddress(value = "") {
|
|
1429
|
+
const parts = value.trim().split(".");
|
|
1430
|
+
return parts.length === 4 && parts.every((part) => {
|
|
1431
|
+
if (!/^\d{1,3}$/.test(part)) return false;
|
|
1432
|
+
const value = Number(part);
|
|
1433
|
+
return value >= 0 && value <= 255;
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1045
1437
|
async function runHangupCall() {
|
|
1046
1438
|
if (!selectedRow.value) return;
|
|
1047
1439
|
hangingUp.value = true;
|
|
@@ -1078,30 +1470,6 @@ async function savePjsip(readerId: string, payload: Record<string, string>, opti
|
|
|
1078
1470
|
}
|
|
1079
1471
|
}
|
|
1080
1472
|
|
|
1081
|
-
function startRebootWait() {
|
|
1082
|
-
rebootConfirmDialog.value = false;
|
|
1083
|
-
rebootCountdown.value = 59;
|
|
1084
|
-
rebootingDialog.value = true;
|
|
1085
|
-
clearRebootTimer();
|
|
1086
|
-
|
|
1087
|
-
rebootTimer = setInterval(() => {
|
|
1088
|
-
rebootCountdown.value -= 1;
|
|
1089
|
-
if (rebootCountdown.value <= 0) {
|
|
1090
|
-
clearRebootTimer();
|
|
1091
|
-
rebootingDialog.value = false;
|
|
1092
|
-
showMessage("Equipment reboot wait completed.", "success");
|
|
1093
|
-
reload();
|
|
1094
|
-
}
|
|
1095
|
-
}, 1000);
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
function clearRebootTimer() {
|
|
1099
|
-
if (rebootTimer) {
|
|
1100
|
-
clearInterval(rebootTimer);
|
|
1101
|
-
rebootTimer = undefined;
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
1473
|
async function saveContact() {
|
|
1106
1474
|
if (!contactForm.readerId || !contactForm.name.trim() || !contactForm.serverAddress.trim()) {
|
|
1107
1475
|
showMessage("Please complete the required contact fields.", "warning");
|
|
@@ -1182,8 +1550,42 @@ function showMessage(text: string, type: MessageType = "info") {
|
|
|
1182
1550
|
message.type = type;
|
|
1183
1551
|
}
|
|
1184
1552
|
|
|
1185
|
-
function
|
|
1186
|
-
|
|
1553
|
+
function normalizeSipAccount(response: unknown): SipAccount {
|
|
1554
|
+
const responseRecord = toRecord(response);
|
|
1555
|
+
const payload = toRecord(responseRecord.data || responseRecord);
|
|
1556
|
+
const requiredFields = ["extension", "username", "password", "sipAddress", "websocketUrl"] as const;
|
|
1557
|
+
|
|
1558
|
+
if (requiredFields.some((field) => !toText(payload[field]))) {
|
|
1559
|
+
throw new Error("The SIP account response is incomplete. Please try again.");
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
return {
|
|
1563
|
+
orgId: toText(payload.orgId, props.org),
|
|
1564
|
+
site: toText(payload.site, props.site),
|
|
1565
|
+
userId: toText(payload.userId),
|
|
1566
|
+
extension: toText(payload.extension),
|
|
1567
|
+
username: toText(payload.username),
|
|
1568
|
+
password: toText(payload.password),
|
|
1569
|
+
sipAddress: toText(payload.sipAddress),
|
|
1570
|
+
websocketUrl: toText(payload.websocketUrl),
|
|
1571
|
+
status: "active",
|
|
1572
|
+
provisionedAt: toText(payload.provisionedAt),
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function getErrorMessage(error: unknown) {
|
|
1577
|
+
const errorRecord = toRecord(error);
|
|
1578
|
+
const data = toRecord(errorRecord.data);
|
|
1579
|
+
const response = toRecord(errorRecord.response);
|
|
1580
|
+
const responseData = toRecord(response._data);
|
|
1581
|
+
return toText(data.message)
|
|
1582
|
+
|| toText(responseData.message)
|
|
1583
|
+
|| toText(errorRecord.message)
|
|
1584
|
+
|| "HID intercom request failed.";
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function toRecord(value: unknown): Record<string, unknown> {
|
|
1588
|
+
return typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
|
|
1187
1589
|
}
|
|
1188
1590
|
|
|
1189
1591
|
function toText(value: unknown, fallback = "") {
|
|
@@ -1259,6 +1661,23 @@ export default {
|
|
|
1259
1661
|
width: 160px;
|
|
1260
1662
|
}
|
|
1261
1663
|
|
|
1664
|
+
.web-call-button {
|
|
1665
|
+
min-width: 160px;
|
|
1666
|
+
background: #e5e7eb;
|
|
1667
|
+
color: #0b2c40;
|
|
1668
|
+
font-size: 12px;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
.web-call-button.is-connected {
|
|
1672
|
+
background: #d9f2e1;
|
|
1673
|
+
color: #16743a;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
.web-call-button.has-incoming-call {
|
|
1677
|
+
background: #1769aa;
|
|
1678
|
+
color: #fff;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1262
1681
|
.add-button {
|
|
1263
1682
|
min-width: 160px;
|
|
1264
1683
|
background: #dedede;
|
|
@@ -1468,6 +1887,20 @@ export default {
|
|
|
1468
1887
|
margin-bottom: 12px;
|
|
1469
1888
|
}
|
|
1470
1889
|
|
|
1890
|
+
.sip-section-switch > div {
|
|
1891
|
+
display: flex;
|
|
1892
|
+
min-width: 0;
|
|
1893
|
+
padding: 8px 12px 8px 0;
|
|
1894
|
+
flex-direction: column;
|
|
1895
|
+
gap: 2px;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
.sip-section-switch small {
|
|
1899
|
+
color: #667085;
|
|
1900
|
+
font-size: 10px;
|
|
1901
|
+
line-height: 1.4;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1471
1904
|
.sip-switch-row :deep(.v-switch) {
|
|
1472
1905
|
flex: 0 0 auto;
|
|
1473
1906
|
}
|
|
@@ -1630,98 +2063,6 @@ export default {
|
|
|
1630
2063
|
overflow: hidden;
|
|
1631
2064
|
}
|
|
1632
2065
|
|
|
1633
|
-
.reboot-dialog {
|
|
1634
|
-
position: relative;
|
|
1635
|
-
min-height: 220px;
|
|
1636
|
-
overflow: hidden;
|
|
1637
|
-
background: #fff;
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
.reboot-close {
|
|
1641
|
-
position: absolute;
|
|
1642
|
-
top: 14px;
|
|
1643
|
-
right: 14px;
|
|
1644
|
-
display: grid;
|
|
1645
|
-
width: 40px;
|
|
1646
|
-
height: 40px;
|
|
1647
|
-
padding: 0;
|
|
1648
|
-
border: 0;
|
|
1649
|
-
place-items: center;
|
|
1650
|
-
background: transparent;
|
|
1651
|
-
color: #c7c7c7;
|
|
1652
|
-
cursor: pointer;
|
|
1653
|
-
}
|
|
1654
|
-
|
|
1655
|
-
.reboot-title {
|
|
1656
|
-
padding: 24px 20px 12px;
|
|
1657
|
-
color: #111;
|
|
1658
|
-
font-size: 28px;
|
|
1659
|
-
font-weight: 400;
|
|
1660
|
-
letter-spacing: 0;
|
|
1661
|
-
line-height: 1.2;
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
|
-
.reboot-message {
|
|
1665
|
-
padding: 24px 20px 28px;
|
|
1666
|
-
color: #222;
|
|
1667
|
-
font-size: 17px;
|
|
1668
|
-
line-height: 1.45;
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
.reboot-actions {
|
|
1672
|
-
display: flex;
|
|
1673
|
-
justify-content: flex-end;
|
|
1674
|
-
gap: 20px;
|
|
1675
|
-
padding: 0 20px 20px;
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
.reboot-cancel {
|
|
1679
|
-
color: #075fb7;
|
|
1680
|
-
font-size: 16px;
|
|
1681
|
-
letter-spacing: 0;
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
.reboot-ok {
|
|
1685
|
-
min-width: 64px;
|
|
1686
|
-
border-radius: 5px;
|
|
1687
|
-
background: #062f4d;
|
|
1688
|
-
color: #fff;
|
|
1689
|
-
font-size: 16px;
|
|
1690
|
-
letter-spacing: 0;
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
.rebooting-card {
|
|
1694
|
-
display: grid;
|
|
1695
|
-
min-height: 240px;
|
|
1696
|
-
grid-template-columns: minmax(0, 1.1fr) minmax(280px, 0.9fr);
|
|
1697
|
-
align-items: center;
|
|
1698
|
-
gap: 24px;
|
|
1699
|
-
padding: 20px 54px;
|
|
1700
|
-
background: #fff;
|
|
1701
|
-
}
|
|
1702
|
-
|
|
1703
|
-
.wait-word {
|
|
1704
|
-
color: #347bd6;
|
|
1705
|
-
font-size: 120px;
|
|
1706
|
-
font-weight: 400;
|
|
1707
|
-
letter-spacing: 0;
|
|
1708
|
-
line-height: 0.95;
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
.rebooting-copy h3 {
|
|
1712
|
-
margin: 0 0 18px;
|
|
1713
|
-
color: #000;
|
|
1714
|
-
font-size: 30px;
|
|
1715
|
-
font-weight: 400;
|
|
1716
|
-
letter-spacing: 0;
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
.rebooting-copy p {
|
|
1720
|
-
margin: 0;
|
|
1721
|
-
color: #000;
|
|
1722
|
-
font-size: 16px;
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
2066
|
.call-dialog .dialog-body {
|
|
1726
2067
|
padding: 18px 24px 24px;
|
|
1727
2068
|
}
|
|
@@ -1931,6 +2272,263 @@ export default {
|
|
|
1931
2272
|
line-height: 1.45;
|
|
1932
2273
|
}
|
|
1933
2274
|
|
|
2275
|
+
.web-phone-dialog {
|
|
2276
|
+
width: 820px;
|
|
2277
|
+
max-width: 94vw;
|
|
2278
|
+
overflow: hidden;
|
|
2279
|
+
background: #fff;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
.web-phone-title {
|
|
2283
|
+
display: flex;
|
|
2284
|
+
align-items: center;
|
|
2285
|
+
justify-content: space-between;
|
|
2286
|
+
gap: 16px;
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
.web-phone-status {
|
|
2290
|
+
min-width: 92px;
|
|
2291
|
+
justify-content: center;
|
|
2292
|
+
font-size: 10px;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
.web-phone-status.ready {
|
|
2296
|
+
background: #d9f2e1;
|
|
2297
|
+
color: #16743a;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
.web-phone-status.incoming {
|
|
2301
|
+
background: #dcecff;
|
|
2302
|
+
color: #125c9a;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
.web-phone-status.failed {
|
|
2306
|
+
background: #fce2e2;
|
|
2307
|
+
color: #a92222;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
.web-phone-status.idle {
|
|
2311
|
+
background: #e5e7eb;
|
|
2312
|
+
color: #5d6673;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
.web-phone-body {
|
|
2316
|
+
display: grid;
|
|
2317
|
+
grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.1fr);
|
|
2318
|
+
gap: 24px;
|
|
2319
|
+
min-height: 440px;
|
|
2320
|
+
padding: 22px 24px 24px;
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
.web-phone-settings {
|
|
2324
|
+
padding-right: 24px;
|
|
2325
|
+
border-right: 1px solid #e5e7eb;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
.web-phone-section-title {
|
|
2329
|
+
margin-bottom: 14px;
|
|
2330
|
+
color: #0d1720;
|
|
2331
|
+
font-size: 13px;
|
|
2332
|
+
font-weight: 700;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
.sip-account-state {
|
|
2336
|
+
display: flex;
|
|
2337
|
+
min-height: 126px;
|
|
2338
|
+
align-items: center;
|
|
2339
|
+
justify-content: center;
|
|
2340
|
+
padding: 18px;
|
|
2341
|
+
border: 1px solid #dce2e8;
|
|
2342
|
+
border-radius: 6px;
|
|
2343
|
+
color: #5d6673;
|
|
2344
|
+
flex-direction: column;
|
|
2345
|
+
font-size: 12px;
|
|
2346
|
+
gap: 12px;
|
|
2347
|
+
text-align: center;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
.sip-account-state.is-error {
|
|
2351
|
+
border-color: #f0c7c7;
|
|
2352
|
+
background: #fff8f8;
|
|
2353
|
+
color: #a92222;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
.sip-account-retry {
|
|
2357
|
+
min-width: 104px;
|
|
2358
|
+
border-color: #aeb8c2;
|
|
2359
|
+
color: #344054;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
.sip-account-summary {
|
|
2363
|
+
display: grid;
|
|
2364
|
+
min-height: 96px;
|
|
2365
|
+
align-items: center;
|
|
2366
|
+
padding: 16px;
|
|
2367
|
+
border: 1px solid #dce2e8;
|
|
2368
|
+
border-radius: 6px;
|
|
2369
|
+
background: #f8fafc;
|
|
2370
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
2371
|
+
gap: 18px;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
.sip-account-summary > div {
|
|
2375
|
+
display: flex;
|
|
2376
|
+
min-width: 0;
|
|
2377
|
+
flex-direction: column;
|
|
2378
|
+
gap: 4px;
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
.sip-account-label {
|
|
2382
|
+
color: #667085;
|
|
2383
|
+
font-size: 11px;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
.sip-account-extension {
|
|
2387
|
+
color: #0d1720;
|
|
2388
|
+
font-size: 24px;
|
|
2389
|
+
line-height: 1.1;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
.sip-account-address {
|
|
2393
|
+
max-width: 170px;
|
|
2394
|
+
overflow: hidden;
|
|
2395
|
+
color: #536273;
|
|
2396
|
+
font-size: 11px;
|
|
2397
|
+
text-align: right;
|
|
2398
|
+
text-overflow: ellipsis;
|
|
2399
|
+
white-space: nowrap;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
.web-video-toggle {
|
|
2403
|
+
margin-top: 12px;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
.web-phone-connect {
|
|
2407
|
+
min-height: 42px;
|
|
2408
|
+
margin-top: 14px;
|
|
2409
|
+
background: #082b40;
|
|
2410
|
+
color: #fff;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
.web-phone-disconnect {
|
|
2414
|
+
min-height: 42px;
|
|
2415
|
+
margin-top: 14px;
|
|
2416
|
+
border-color: #aeb8c2;
|
|
2417
|
+
color: #344054;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
.web-phone-console {
|
|
2421
|
+
display: flex;
|
|
2422
|
+
min-width: 0;
|
|
2423
|
+
flex-direction: column;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
.web-phone-media {
|
|
2427
|
+
position: relative;
|
|
2428
|
+
display: grid;
|
|
2429
|
+
min-height: 260px;
|
|
2430
|
+
overflow: hidden;
|
|
2431
|
+
place-items: center;
|
|
2432
|
+
border: 1px solid #d7dde4;
|
|
2433
|
+
border-radius: 6px;
|
|
2434
|
+
background: #102a3b;
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
.remote-video {
|
|
2438
|
+
display: none;
|
|
2439
|
+
width: 100%;
|
|
2440
|
+
height: 100%;
|
|
2441
|
+
object-fit: cover;
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
.web-phone-media.has-remote-video .remote-video {
|
|
2445
|
+
display: block;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
.web-phone-media.has-remote-video .audio-call-state {
|
|
2449
|
+
display: none;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
.audio-call-state {
|
|
2453
|
+
display: flex;
|
|
2454
|
+
align-items: center;
|
|
2455
|
+
justify-content: center;
|
|
2456
|
+
padding: 28px;
|
|
2457
|
+
color: #fff;
|
|
2458
|
+
flex-direction: column;
|
|
2459
|
+
gap: 10px;
|
|
2460
|
+
text-align: center;
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
.audio-call-state strong {
|
|
2464
|
+
font-size: 16px;
|
|
2465
|
+
font-weight: 600;
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
.audio-call-state span {
|
|
2469
|
+
max-width: 100%;
|
|
2470
|
+
overflow: hidden;
|
|
2471
|
+
color: #c9d7e1;
|
|
2472
|
+
font-size: 11px;
|
|
2473
|
+
text-overflow: ellipsis;
|
|
2474
|
+
white-space: nowrap;
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
.web-phone-dial-row {
|
|
2478
|
+
display: grid;
|
|
2479
|
+
grid-template-columns: minmax(0, 1fr) 42px;
|
|
2480
|
+
gap: 8px;
|
|
2481
|
+
align-items: start;
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
.start-web-call,
|
|
2485
|
+
.hangup-web-call {
|
|
2486
|
+
width: 42px;
|
|
2487
|
+
height: 38px;
|
|
2488
|
+
border-radius: 4px;
|
|
2489
|
+
background: #2a9b50;
|
|
2490
|
+
color: #fff;
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
.hangup-web-call,
|
|
2494
|
+
.reject-call {
|
|
2495
|
+
background: #c9293d;
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
.incoming-call-actions {
|
|
2499
|
+
display: grid;
|
|
2500
|
+
grid-template-columns: 1fr 1fr;
|
|
2501
|
+
gap: 10px;
|
|
2502
|
+
margin-top: 14px;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
.answer-call,
|
|
2506
|
+
.reject-call {
|
|
2507
|
+
min-height: 42px;
|
|
2508
|
+
color: #fff;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
.answer-call {
|
|
2512
|
+
background: #2a9b50;
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
.active-call-controls {
|
|
2516
|
+
display: flex;
|
|
2517
|
+
justify-content: center;
|
|
2518
|
+
gap: 12px;
|
|
2519
|
+
margin-top: 16px;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
.active-call-controls .v-btn {
|
|
2523
|
+
width: 46px;
|
|
2524
|
+
height: 46px;
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
.web-phone-error {
|
|
2528
|
+
margin-top: 14px;
|
|
2529
|
+
font-size: 11px;
|
|
2530
|
+
}
|
|
2531
|
+
|
|
1934
2532
|
.call-two-col {
|
|
1935
2533
|
gap: 12px;
|
|
1936
2534
|
}
|
|
@@ -2064,13 +2662,22 @@ export default {
|
|
|
2064
2662
|
max-width: none;
|
|
2065
2663
|
}
|
|
2066
2664
|
|
|
2067
|
-
.
|
|
2068
|
-
|
|
2069
|
-
padding: 28px;
|
|
2665
|
+
.web-call-button {
|
|
2666
|
+
width: 100%;
|
|
2070
2667
|
}
|
|
2071
2668
|
|
|
2072
|
-
.
|
|
2073
|
-
|
|
2669
|
+
.web-phone-body {
|
|
2670
|
+
grid-template-columns: minmax(0, 1fr);
|
|
2671
|
+
max-height: 78vh;
|
|
2672
|
+
overflow: auto;
|
|
2074
2673
|
}
|
|
2674
|
+
|
|
2675
|
+
.web-phone-settings {
|
|
2676
|
+
padding-right: 0;
|
|
2677
|
+
padding-bottom: 20px;
|
|
2678
|
+
border-right: 0;
|
|
2679
|
+
border-bottom: 1px solid #e5e7eb;
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2075
2682
|
}
|
|
2076
2683
|
</style>
|