@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.
Files changed (55) hide show
  1. package/.changeset/camera-wall-gated-endpoint.md +47 -0
  2. package/.changeset/camera-wall-plain-english.md +60 -0
  3. package/.changeset/camera-wall-selection-and-guidance.md +65 -0
  4. package/.changeset/dark-primary-contrast.md +39 -0
  5. package/.changeset/dashboard-dark-theme-contrast.md +15 -0
  6. package/.changeset/service-provider-invitation-actions.md +28 -0
  7. package/components/CameraWall.vue +1034 -0
  8. package/components/CameraWallTile.vue +818 -0
  9. package/components/DashboardMain.vue +84 -88
  10. package/components/DashboardPanel.vue +1 -1
  11. package/components/EntryPassInformation.vue +4 -4
  12. package/components/FeedbackMain.vue +7 -7
  13. package/components/HidAccessLogDashboard.vue +119 -44
  14. package/components/HidCameraCaptureDialog.vue +199 -0
  15. package/components/HidIntercomManagement.vue +808 -201
  16. package/components/HidQrCodeConfiguration.vue +907 -95
  17. package/components/HidServiceSettingsPanel.vue +20 -1
  18. package/components/HidUserEnrollment.vue +392 -81
  19. package/components/Layout/NavigationDrawer.vue +43 -2
  20. package/components/NavigationItem.vue +9 -0
  21. package/components/Nfc/NFCPatrolRouteMain.vue +52 -7
  22. package/components/OnlineFormConfigurationForm.vue +620 -224
  23. package/components/OnlineFormFill.vue +36 -8
  24. package/components/OnlineFormsConfiguration.vue +6 -2
  25. package/components/QrTemplate/CreditCardLandscape.vue +19 -12
  26. package/components/QrTemplate/PrintDialog.vue +209 -196
  27. package/components/ServiceProviderInvitationPrompt.vue +150 -0
  28. package/components/ServiceProviderMain.vue +258 -14
  29. package/components/SiteSettings.vue +4 -2
  30. package/components/VisitorForm.vue +201 -78
  31. package/components/VisitorManagement.vue +2 -3
  32. package/components/VisitorSocketPopUp.vue +56 -2
  33. package/composables/useComment.ts +3 -3
  34. package/composables/useHidAmico.ts +127 -0
  35. package/composables/useHidNavigation.ts +0 -7
  36. package/composables/useLocalAuth.ts +9 -36
  37. package/composables/useOnlineFormPages.ts +2 -0
  38. package/composables/useServiceProviderInvitation.ts +145 -0
  39. package/composables/useSipWebPhone.ts +311 -0
  40. package/composables/useSiteSettings.ts +50 -0
  41. package/composables/useVisitorSocket.ts +26 -0
  42. package/composables/useWebUsb.ts +127 -37
  43. package/package.json +3 -1
  44. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  45. package/plugins/secure-member.client.ts +21 -56
  46. package/plugins/vuetify.ts +37 -9
  47. package/test/visitor-socket.test.mjs +36 -0
  48. package/types/site.d.ts +71 -0
  49. package/utils/camera-wall.test.ts +571 -0
  50. package/utils/camera-wall.ts +926 -0
  51. package/utils/theme.test.ts +216 -0
  52. package/utils/theme.ts +163 -0
  53. package/components/HidIdentityMapping.vue +0 -975
  54. package/middleware/member.ts +0 -4
  55. 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
- <div v-else class="top-placeholder" />
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">Address to dial</div>
341
+ <div class="call-label">IP address to dial</div>
354
342
  <v-text-field
355
343
  v-model="makeCallForm.target"
356
- placeholder="Enter extension, address, or contact"
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-cancel" variant="flat" height="44" @click="makeCallDialog = false">Cancel</v-btn>
392
- <v-btn class="text-none action-submit" variant="flat" height="44" @click="makeCallDialog = false">Close</v-btn>
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, any>;
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, any>;
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: "1000",
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
- () => reload(),
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
- onUnmounted(() => {
736
- clearRebootTimer();
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, any>, pjsip: Record<string, any>, status: string): IntercomRow {
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 || "HID Reader",
808
- location: reader.location || "N/A",
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: toText(getPjsipValue(pjsip, ["server_outbound_port", "initialRtpPort", "initial_rtp_port"]), "N/A"),
812
- finalRtpPort: toText(getPjsipValue(pjsip, ["server_outbound_port_range", "finalRtpPort", "final_rtp_port"]), "N/A"),
813
- monitorPath: reader.monitorPath || "N/A",
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, any>[]) {
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, any> | undefined): Record<string, any> | undefined {
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.configuration?.pjsip,
851
- source.result?.pjsip,
852
- source.result?.configuration?.pjsip,
853
- source.data?.pjsip,
854
- source.data?.configuration?.pjsip,
855
- source.data?.result?.pjsip,
856
- source.data?.result?.configuration?.pjsip,
857
- source.data?.data?.pjsip,
858
- source.data?.data?.configuration?.pjsip,
859
- source.configuration,
860
- source.data?.configuration,
861
- source.data?.data?.configuration,
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, any> {
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, any>, keys: 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, any>, pjsip: Record<string, any>) {
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" ? "1000" : row.finalRtpPort;
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.extension !== "N/A" ? row.extension : "";
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: sipForm.initialRtpPort,
982
- server_outbound_port_range: sipForm.finalRtpPort,
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
- }, { showSuccessMessage: false });
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 getErrorMessage(error: any) {
1186
- return error?.data?.message || error?.response?._data?.message || error?.message || "HID intercom request failed.";
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
- .rebooting-card {
2068
- grid-template-columns: 1fr;
2069
- padding: 28px;
2665
+ .web-call-button {
2666
+ width: 100%;
2070
2667
  }
2071
2668
 
2072
- .wait-word {
2073
- font-size: 80px;
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>