@7365admin1/layer-common 3.2.0 → 3.2.1-staging.59

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 (33) hide show
  1. package/.changeset/camera-wall-gated-endpoint.md +47 -0
  2. package/components/CameraWall.vue +625 -0
  3. package/components/CameraWallTile.vue +333 -0
  4. package/components/EntryPassInformation.vue +4 -4
  5. package/components/HidAccessLogDashboard.vue +53 -8
  6. package/components/HidIntercomManagement.vue +842 -201
  7. package/components/HidQrCodeConfiguration.vue +907 -95
  8. package/components/HidServiceSettingsPanel.vue +20 -1
  9. package/components/HidUserEnrollment.vue +96 -2
  10. package/components/Nfc/NFCPatrolRouteMain.vue +52 -7
  11. package/components/OnlineFormConfigurationForm.vue +620 -224
  12. package/components/OnlineFormFill.vue +36 -8
  13. package/components/OnlineFormsConfiguration.vue +6 -2
  14. package/components/QrTemplate/CreditCardLandscape.vue +19 -12
  15. package/components/QrTemplate/PrintDialog.vue +209 -196
  16. package/components/SiteSettings.vue +4 -2
  17. package/components/VisitorForm.vue +201 -78
  18. package/components/VisitorManagement.vue +0 -1
  19. package/composables/useHidAmico.ts +127 -0
  20. package/composables/useHidNavigation.ts +0 -7
  21. package/composables/useOnlineFormPages.ts +2 -0
  22. package/composables/useSipWebPhone.ts +332 -0
  23. package/composables/useSiteSettings.ts +28 -0
  24. package/composables/useWebUsb.ts +127 -37
  25. package/package.json +2 -1
  26. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  27. package/plugins/secure-member.client.ts +21 -56
  28. package/types/site.d.ts +71 -0
  29. package/utils/camera-wall.test.ts +149 -0
  30. package/utils/camera-wall.ts +299 -0
  31. package/components/HidIdentityMapping.vue +0 -975
  32. package/middleware/member.ts +0 -4
  33. 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 and receive video during SIP 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,188 @@
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
+ <span>Enable video</span>
421
+ <v-switch
422
+ v-model="webPhoneForm.video"
423
+ color="success"
424
+ density="compact"
425
+ hide-details
426
+ :disabled="webPhone.isRegistered.value"
427
+ />
428
+ </div>
429
+ <v-btn
430
+ v-if="!webPhone.isRegistered.value"
431
+ block
432
+ class="text-none web-phone-connect"
433
+ variant="flat"
434
+ prepend-icon="mdi-lan-connect"
435
+ :loading="webPhoneConnecting"
436
+ :disabled="!sipAccount || sipAccountLoading"
437
+ @click="connectWebPhone"
438
+ >
439
+ Connect Web Phone
440
+ </v-btn>
441
+ <v-btn
442
+ v-else
443
+ block
444
+ class="text-none web-phone-disconnect"
445
+ variant="outlined"
446
+ prepend-icon="mdi-lan-disconnect"
447
+ :loading="webPhoneConnecting"
448
+ :disabled="webPhone.hasCall.value"
449
+ @click="disconnectWebPhone"
450
+ >
451
+ Disconnect
452
+ </v-btn>
453
+ </div>
454
+
455
+ <div class="web-phone-console">
456
+ <div
457
+ class="web-phone-media"
458
+ :class="{
459
+ 'has-video': webPhoneForm.video && webPhone.callState.value === 'active',
460
+ 'has-local-video': webPhone.localVideoAvailable.value,
461
+ 'has-remote-video': webPhone.remoteVideoAvailable.value,
462
+ }"
463
+ >
464
+ <video ref="remoteVideoEl" autoplay playsinline class="remote-video" />
465
+ <video ref="localVideoEl" autoplay muted playsinline class="local-video" />
466
+ <audio ref="remoteAudioEl" autoplay />
467
+ <div
468
+ v-if="!webPhoneForm.video || webPhone.callState.value !== 'active' || !webPhone.remoteVideoAvailable.value"
469
+ class="audio-call-state"
470
+ >
471
+ <v-icon size="54">{{ webPhoneCallIcon }}</v-icon>
472
+ <strong>{{ webPhoneCallLabel }}</strong>
473
+ <span v-if="webPhone.currentTarget.value">{{ webPhone.currentTarget.value }}</span>
474
+ <span v-if="webPhoneForm.video && webPhone.callState.value === 'active' && !webPhone.remoteVideoAvailable.value">
475
+ Waiting for video from the other device
476
+ </span>
477
+ </div>
478
+ </div>
479
+
480
+ <div v-if="webPhone.callState.value === 'incoming'" class="incoming-call-actions">
481
+ <v-btn
482
+ class="text-none answer-call"
483
+ variant="flat"
484
+ prepend-icon="mdi-phone"
485
+ :loading="webPhoneActionLoading"
486
+ @click="answerWebCall"
487
+ >
488
+ Answer
489
+ </v-btn>
490
+ <v-btn
491
+ class="text-none reject-call"
492
+ variant="flat"
493
+ prepend-icon="mdi-phone-hangup"
494
+ :disabled="webPhoneActionLoading"
495
+ @click="declineWebCall"
496
+ >
497
+ Decline
498
+ </v-btn>
499
+ </div>
500
+
501
+ <template v-else>
502
+ <div class="call-label">Extension or SIP Address</div>
503
+ <div class="web-phone-dial-row">
504
+ <v-text-field
505
+ v-model="webPhoneForm.destination"
506
+ placeholder="1001 or device@example.com"
507
+ variant="outlined"
508
+ density="compact"
509
+ hide-details="auto"
510
+ class="call-input mb-0"
511
+ :disabled="!webPhone.isRegistered.value || webPhone.hasCall.value"
512
+ @keyup.enter="startWebCall"
513
+ />
514
+ <v-btn
515
+ icon="mdi-phone"
516
+ class="start-web-call"
517
+ variant="flat"
518
+ :loading="webPhoneActionLoading && webPhone.callState.value === 'calling'"
519
+ :disabled="!webPhone.isRegistered.value || webPhone.hasCall.value || !webPhoneForm.destination.trim()"
520
+ @click="startWebCall"
521
+ />
522
+ </div>
523
+
524
+ <div v-if="webPhone.hasCall.value" class="active-call-controls">
525
+ <v-btn
526
+ :icon="webPhone.muted.value ? 'mdi-microphone-off' : 'mdi-microphone'"
527
+ variant="outlined"
528
+ :disabled="webPhone.callState.value !== 'active'"
529
+ :title="webPhone.muted.value ? 'Unmute' : 'Mute'"
530
+ @click="toggleWebMute"
531
+ />
532
+ <v-btn
533
+ v-if="webPhoneForm.video"
534
+ :icon="webPhone.cameraEnabled.value ? 'mdi-video' : 'mdi-video-off'"
535
+ variant="outlined"
536
+ :disabled="webPhone.callState.value !== 'active' || !webPhone.localVideoAvailable.value"
537
+ :title="webPhone.cameraEnabled.value ? 'Turn camera off' : 'Turn camera on'"
538
+ @click="toggleWebCamera"
539
+ />
540
+ <v-btn
541
+ icon="mdi-phone-hangup"
542
+ class="hangup-web-call"
543
+ variant="flat"
544
+ :loading="webPhoneActionLoading && webPhone.callState.value === 'ending'"
545
+ title="End call"
546
+ @click="hangupWebCall"
547
+ />
548
+ </div>
549
+ </template>
550
+
551
+ <v-alert v-if="webPhone.errorMessage.value" type="error" variant="tonal" density="compact" class="web-phone-error">
552
+ {{ webPhone.errorMessage.value }}
553
+ </v-alert>
554
+ </div>
555
+ </v-card-text>
556
+
557
+ <v-card-actions class="dialog-actions pa-0">
558
+ <v-btn class="text-none action-submit full-action" variant="flat" height="44" @click="webPhoneDialog = false">
559
+ Close
560
+ </v-btn>
393
561
  </v-card-actions>
394
562
  </v-card>
395
563
  </v-dialog>
@@ -448,6 +616,10 @@
448
616
 
449
617
  <script setup lang="ts">
450
618
  const props = defineProps({
619
+ org: {
620
+ type: String,
621
+ required: true,
622
+ },
451
623
  site: {
452
624
  type: String,
453
625
  required: true,
@@ -466,6 +638,19 @@ type ContactRow = {
466
638
  dialCode: string;
467
639
  };
468
640
 
641
+ type SipAccount = {
642
+ orgId: string;
643
+ site: string;
644
+ userId: string;
645
+ extension: string;
646
+ username: string;
647
+ password: string;
648
+ sipAddress: string;
649
+ websocketUrl: string;
650
+ status: "active";
651
+ provisionedAt: string;
652
+ };
653
+
469
654
  type DetailItem = {
470
655
  label: string;
471
656
  value?: string | number | boolean;
@@ -474,7 +659,7 @@ type DetailItem = {
474
659
  };
475
660
 
476
661
  type IntercomRow = {
477
- reader: Record<string, any>;
662
+ reader: Record<string, unknown>;
478
663
  name: string;
479
664
  location: string;
480
665
  serverAddress: string;
@@ -490,6 +675,7 @@ type IntercomRow = {
490
675
  password: string;
491
676
  operatingMode: string;
492
677
  protocol: string;
678
+ videoEnabled: boolean;
493
679
  dialMode: string;
494
680
  dialModeLabel: string;
495
681
  dialCode: string;
@@ -502,7 +688,7 @@ type IntercomRow = {
502
688
  externalDialButton: boolean;
503
689
  unknownContact: boolean;
504
690
  callAfter: string;
505
- pjsip: Record<string, any>;
691
+ pjsip: Record<string, unknown>;
506
692
  };
507
693
 
508
694
  const {
@@ -513,6 +699,7 @@ const {
513
699
  getIntercomStatus,
514
700
  makeIntercomCall,
515
701
  finalizeIntercomCall,
702
+ ensureSipAccount,
516
703
  } = useHidAmico();
517
704
 
518
705
  const activeTab = ref<IntercomTab>("intercom");
@@ -531,21 +718,27 @@ const limit = 20;
531
718
  const selectedRow = ref<IntercomRow | null>(null);
532
719
  const selectedContact = ref<ContactRow | null>(null);
533
720
  const showSipPassword = ref(false);
721
+ const webPhoneConnecting = ref(false);
722
+ const webPhoneActionLoading = ref(false);
723
+ const sipAccount = ref<SipAccount | null>(null);
724
+ const sipAccountLoading = ref(false);
725
+ const sipAccountError = ref("");
726
+ const localVideoEl = ref<HTMLVideoElement | null>(null);
727
+ const remoteVideoEl = ref<HTMLVideoElement | null>(null);
728
+ const remoteAudioEl = ref<HTMLAudioElement | null>(null);
729
+ const webPhone = useSipWebPhone();
534
730
 
535
731
  const viewDialog = ref(false);
536
732
  const sipDialog = ref(false);
537
- const rebootConfirmDialog = ref(false);
538
- const rebootingDialog = ref(false);
539
733
  const callDialog = ref(false);
540
734
  const dialCodeDialog = ref(false);
541
735
  const makeCallDialog = ref(false);
736
+ const webPhoneDialog = ref(false);
542
737
  const contactDialog = ref(false);
543
738
  const contactViewDialog = ref(false);
544
739
  const deleteContactDialog = ref(false);
545
740
 
546
741
  const message = reactive<{ type: MessageType; text: string }>({ type: "info", text: "" });
547
- const rebootCountdown = ref(59);
548
- let rebootTimer: ReturnType<typeof setInterval> | undefined;
549
742
 
550
743
  const statusOptions = ["Status", "Connected", "Not connected", "Connecting", "Disabled", "Failed"];
551
744
 
@@ -582,13 +775,14 @@ const sipForm = reactive({
582
775
  serverIp: "",
583
776
  serverPort: "5060",
584
777
  initialRtpPort: "10000",
585
- finalRtpPort: "1000",
778
+ finalRtpPort: "10100",
586
779
  numericExtension: false,
587
780
  extension: "",
588
781
  login: "",
589
782
  password: "",
590
783
  operatingMode: "sip-client",
591
784
  protocol: "UDP",
785
+ videoEnabled: false,
592
786
  });
593
787
 
594
788
  const callForm = reactive({
@@ -617,6 +811,11 @@ const makeCallForm = reactive({
617
811
  statusType: "info" as MessageType,
618
812
  });
619
813
 
814
+ const webPhoneForm = reactive({
815
+ destination: "",
816
+ video: false,
817
+ });
818
+
620
819
  const contactForm = reactive({
621
820
  id: undefined as number | undefined,
622
821
  readerId: "",
@@ -694,6 +893,7 @@ const intercomDetailSections = computed(() => {
694
893
  items: [
695
894
  { label: "Operating Mode", value: row?.operatingMode },
696
895
  { label: "Protocol", value: row?.protocol },
896
+ { label: "Video", value: row?.videoEnabled ? "Enabled" : "Disabled" },
697
897
  ],
698
898
  },
699
899
  {
@@ -717,10 +917,45 @@ const contactDetailRows = computed<DetailItem[]>(() => [
717
917
  { label: "Server Address", value: selectedContact.value?.serverAddress, link: true },
718
918
  { label: "Location", value: selectedContact.value?.location },
719
919
  ]);
920
+ const webPhoneStatusLabel = computed(() => {
921
+ if (webPhone.callState.value === "incoming") return "Incoming call";
922
+ if (webPhone.callState.value === "calling") return "Calling";
923
+ if (webPhone.callState.value === "active") return "In call";
924
+ if (webPhone.callState.value === "ending") return "Ending call";
925
+ if (webPhone.connectionState.value === "connecting") return "Connecting";
926
+ if (webPhone.connectionState.value === "registered") return "Ready";
927
+ if (webPhone.connectionState.value === "error") return "Connection failed";
928
+ return "Disconnected";
929
+ });
930
+ const webPhoneStatusClass = computed(() => {
931
+ if (webPhone.callState.value === "incoming") return "web-phone-status incoming";
932
+ if (webPhone.callState.value === "active" || webPhone.connectionState.value === "registered") return "web-phone-status ready";
933
+ if (webPhone.connectionState.value === "error") return "web-phone-status failed";
934
+ return "web-phone-status idle";
935
+ });
936
+ const webPhoneCallLabel = computed(() => {
937
+ if (webPhone.callState.value === "incoming") return "Incoming call";
938
+ if (webPhone.callState.value === "calling") return "Calling...";
939
+ if (webPhone.callState.value === "active") return "Call connected";
940
+ if (webPhone.callState.value === "ending") return "Ending call...";
941
+ return webPhone.isRegistered.value ? "Ready to call" : "Connect a SIP account";
942
+ });
943
+ const webPhoneCallIcon = computed(() => {
944
+ if (webPhone.callState.value === "incoming") return "mdi-phone-incoming";
945
+ if (webPhone.callState.value === "calling") return "mdi-phone-outgoing";
946
+ if (webPhone.callState.value === "active") return "mdi-phone-in-talk";
947
+ return "mdi-phone-outline";
948
+ });
720
949
 
721
950
  watch(
722
- () => props.site,
723
- () => reload(),
951
+ [() => props.org, () => props.site],
952
+ () => {
953
+ if (webPhone.isRegistered.value) void webPhone.disconnect();
954
+ sipAccount.value = null;
955
+ sipAccountError.value = "";
956
+ webPhoneForm.destination = "";
957
+ void reload();
958
+ },
724
959
  { immediate: true },
725
960
  );
726
961
 
@@ -732,9 +967,12 @@ watch(pages, () => {
732
967
  if (page.value > pages.value) page.value = pages.value;
733
968
  });
734
969
 
735
- onUnmounted(() => {
736
- clearRebootTimer();
737
- });
970
+ watch(
971
+ () => webPhone.callState.value,
972
+ (state) => {
973
+ if (state === "incoming") webPhoneDialog.value = true;
974
+ },
975
+ );
738
976
 
739
977
  async function reload() {
740
978
  if (!props.site) return;
@@ -798,19 +1036,27 @@ function paginate<T>(items: T[]) {
798
1036
  return items.slice(start, start + limit);
799
1037
  }
800
1038
 
801
- function toIntercomRow(reader: Record<string, any>, pjsip: Record<string, any>, status: string): IntercomRow {
1039
+ function toIntercomRow(reader: Record<string, unknown>, pjsip: Record<string, unknown>, status: string): IntercomRow {
802
1040
  const peerToPeer = toFlag(getPjsipValue(pjsip, ["peer_to_peer_enabled", "peerToPeerEnabled"])) === "1";
803
1041
  const dialMode = toText(getPjsipValue(pjsip, ["dialing_display_mode", "dialingDisplayMode"]), "0");
1042
+ const initialRtpPort = toText(getPjsipValue(pjsip, ["server_outbound_port", "initialRtpPort", "initial_rtp_port"]), "N/A");
1043
+ const rtpPortRange = getPjsipValue(pjsip, ["server_outbound_port_range"]);
1044
+ const explicitFinalRtpPort = getPjsipValue(pjsip, ["finalRtpPort", "final_rtp_port"]);
1045
+ const initialRtpPortNumber = Number(initialRtpPort);
1046
+ const rtpPortRangeNumber = Number(rtpPortRange);
1047
+ const finalRtpPort = rtpPortRange !== undefined && Number.isFinite(initialRtpPortNumber) && Number.isFinite(rtpPortRangeNumber)
1048
+ ? String(initialRtpPortNumber + rtpPortRangeNumber)
1049
+ : toText(explicitFinalRtpPort, "N/A");
804
1050
 
805
1051
  return {
806
1052
  reader,
807
- name: reader.name || reader.deviceId || "HID Reader",
808
- location: reader.location || "N/A",
1053
+ name: toText(reader.name || reader.deviceId, "HID Reader"),
1054
+ location: toText(reader.location, "N/A"),
809
1055
  serverAddress: toText(getPjsipValue(pjsip, ["server_ip", "serverAddress", "server_address", "serverIp"]), "N/A"),
810
1056
  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",
1057
+ initialRtpPort,
1058
+ finalRtpPort,
1059
+ monitorPath: toText(reader.monitorPath, "N/A"),
814
1060
  status,
815
1061
  sipEnabled: toFlag(getPjsipValue(pjsip, ["enabled"])) === "1",
816
1062
  numericExtension: toFlag(getPjsipValue(pjsip, ["numeric_branch_enabled", "numericExtension", "numericExtensionEnabled"])) === "1",
@@ -819,6 +1065,7 @@ function toIntercomRow(reader: Record<string, any>, pjsip: Record<string, any>,
819
1065
  password: toText(getPjsipValue(pjsip, ["password"]), ""),
820
1066
  operatingMode: peerToPeer ? "Peer-to-Peer" : "SIP Client",
821
1067
  protocol: toText(getPjsipValue(pjsip, ["protocol"]), "UDP"),
1068
+ videoEnabled: toFlag(getPjsipValue(pjsip, ["video_enabled", "videoEnabled"])) === "1",
822
1069
  dialMode,
823
1070
  dialModeLabel: getDialModeLabel(dialMode),
824
1071
  dialCode: toText(getPjsipValue(pjsip, ["open_door_command", "dialCode"]), "N/A"),
@@ -835,7 +1082,7 @@ function toIntercomRow(reader: Record<string, any>, pjsip: Record<string, any>,
835
1082
  };
836
1083
  }
837
1084
 
838
- function normalizePjsip(...sources: Record<string, any>[]) {
1085
+ function normalizePjsip(...sources: Record<string, unknown>[]) {
839
1086
  for (const source of sources) {
840
1087
  const candidate = findPjsipCandidate(source);
841
1088
  if (candidate) return candidate;
@@ -843,45 +1090,46 @@ function normalizePjsip(...sources: Record<string, any>[]) {
843
1090
  return {};
844
1091
  }
845
1092
 
846
- function findPjsipCandidate(source: Record<string, any> | undefined): Record<string, any> | undefined {
1093
+ function findPjsipCandidate(source: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
847
1094
  if (!source || typeof source !== "object") return undefined;
848
1095
  const candidates = [
849
1096
  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,
1097
+ toRecord(source.pjsip),
1098
+ toRecord(toRecord(source.configuration).pjsip),
1099
+ toRecord(toRecord(source.result).pjsip),
1100
+ toRecord(toRecord(toRecord(source.result).configuration).pjsip),
1101
+ toRecord(toRecord(source.data).pjsip),
1102
+ toRecord(toRecord(toRecord(source.data).configuration).pjsip),
1103
+ toRecord(toRecord(toRecord(source.data).result).pjsip),
1104
+ toRecord(toRecord(toRecord(toRecord(source.data).result).configuration).pjsip),
1105
+ toRecord(toRecord(toRecord(source.data).data).pjsip),
1106
+ toRecord(toRecord(toRecord(toRecord(source.data).data).configuration).pjsip),
1107
+ toRecord(source.configuration),
1108
+ toRecord(toRecord(source.data).configuration),
1109
+ toRecord(toRecord(toRecord(source.data).data).configuration),
862
1110
  source,
863
1111
  ];
864
1112
 
865
1113
  return candidates.find((candidate) => isPjsipCandidate(candidate));
866
1114
  }
867
1115
 
868
- function isPjsipCandidate(value: unknown): value is Record<string, any> {
1116
+ function isPjsipCandidate(value: unknown): value is Record<string, unknown> {
869
1117
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
870
1118
  return pjsipKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key)) ||
871
1119
  ["serverAddress", "serverIp", "serverPort", "extension", "peerToPeerEnabled"].some((key) => Object.prototype.hasOwnProperty.call(value, key));
872
1120
  }
873
1121
 
874
- function getPjsipValue(source: Record<string, any>, keys: string[]) {
1122
+ function getPjsipValue(source: Record<string, unknown>, keys: string[]) {
875
1123
  for (const key of keys) {
876
1124
  if (source?.[key] !== undefined && source?.[key] !== null && source?.[key] !== "") return source[key];
877
1125
  }
878
1126
  return undefined;
879
1127
  }
880
1128
 
881
- function normalizeStatus(response: Record<string, any>, pjsip: Record<string, any>) {
1129
+ function normalizeStatus(response: Record<string, unknown>, pjsip: Record<string, unknown>) {
882
1130
  const status = Number(response?.status);
1131
+ if (toFlag(pjsip.enabled) === "0" || status === -1) return "Disabled";
883
1132
  if (status === 200) return "Connected";
884
- if (status === -1 || toFlag(pjsip.enabled) === "0") return "Disabled";
885
1133
  if (status === 0 || status === 100) return "Connecting";
886
1134
  if ([401, 403, 408, 503].includes(status)) return "Failed";
887
1135
  return "Not connected";
@@ -911,13 +1159,14 @@ function openSipDialog(row: IntercomRow) {
911
1159
  sipForm.serverIp = row.serverAddress === "N/A" ? "" : row.serverAddress;
912
1160
  sipForm.serverPort = row.serverPort === "N/A" ? "5060" : row.serverPort;
913
1161
  sipForm.initialRtpPort = row.initialRtpPort === "N/A" ? "10000" : row.initialRtpPort;
914
- sipForm.finalRtpPort = row.finalRtpPort === "N/A" ? "1000" : row.finalRtpPort;
1162
+ sipForm.finalRtpPort = row.finalRtpPort === "N/A" ? "10100" : row.finalRtpPort;
915
1163
  sipForm.numericExtension = row.numericExtension;
916
1164
  sipForm.extension = row.extension === "N/A" ? "" : row.extension;
917
1165
  sipForm.login = row.login === "N/A" ? "" : row.login;
918
1166
  sipForm.password = row.password;
919
1167
  sipForm.operatingMode = row.operatingMode === "Peer-to-Peer" ? "peer-to-peer" : "sip-client";
920
1168
  sipForm.protocol = row.protocol || "UDP";
1169
+ sipForm.videoEnabled = row.videoEnabled;
921
1170
  sipDialog.value = true;
922
1171
  }
923
1172
 
@@ -945,12 +1194,143 @@ function openDialCodeDialog(row: IntercomRow) {
945
1194
 
946
1195
  function openMakeCallDialog(row: IntercomRow) {
947
1196
  selectedRow.value = row;
948
- makeCallForm.target = row.extension !== "N/A" ? row.extension : "";
1197
+ makeCallForm.target = isIpAddress(row.serverAddress) ? row.serverAddress : "";
949
1198
  makeCallForm.status = "";
950
1199
  makeCallForm.statusType = "info";
951
1200
  makeCallDialog.value = true;
952
1201
  }
953
1202
 
1203
+ function openWebPhoneDialog(row?: IntercomRow) {
1204
+ const activeRow = row || selectedRow.value || intercomRows.value[0];
1205
+ if (activeRow) {
1206
+ selectedRow.value = activeRow;
1207
+ if (!webPhone.isRegistered.value && activeRow.extension !== "N/A") {
1208
+ webPhoneForm.destination = activeRow.extension;
1209
+ webPhoneForm.video = activeRow.videoEnabled;
1210
+ }
1211
+ }
1212
+ webPhoneDialog.value = true;
1213
+ if (!sipAccount.value && !sipAccountLoading.value) void loadSipAccount();
1214
+ }
1215
+
1216
+ async function loadSipAccount() {
1217
+ if (!props.org || !props.site) return;
1218
+
1219
+ sipAccountLoading.value = true;
1220
+ sipAccountError.value = "";
1221
+ try {
1222
+ const response = await ensureSipAccount(props.site, props.org);
1223
+ sipAccount.value = normalizeSipAccount(response);
1224
+ } catch (error: unknown) {
1225
+ sipAccount.value = null;
1226
+ sipAccountError.value = getErrorMessage(error);
1227
+ } finally {
1228
+ sipAccountLoading.value = false;
1229
+ }
1230
+ }
1231
+
1232
+ async function connectWebPhone() {
1233
+ const account = sipAccount.value;
1234
+ if (!account) {
1235
+ await loadSipAccount();
1236
+ if (!sipAccount.value) return;
1237
+ }
1238
+
1239
+ const activeAccount = sipAccount.value;
1240
+ if (!activeAccount) return;
1241
+
1242
+ webPhoneConnecting.value = true;
1243
+ try {
1244
+ await webPhone.connect({
1245
+ webSocketServer: activeAccount.websocketUrl,
1246
+ aor: activeAccount.sipAddress,
1247
+ authorizationUsername: activeAccount.username,
1248
+ authorizationPassword: activeAccount.password,
1249
+ displayName: "iService365 Web",
1250
+ video: webPhoneForm.video,
1251
+ }, {
1252
+ localVideo: localVideoEl.value,
1253
+ remoteVideo: remoteVideoEl.value,
1254
+ remoteAudio: remoteAudioEl.value,
1255
+ });
1256
+ showMessage("Web phone connected and ready to receive calls.", "success");
1257
+ } catch (error: unknown) {
1258
+ showMessage(getErrorMessage(error), "error");
1259
+ } finally {
1260
+ webPhoneConnecting.value = false;
1261
+ }
1262
+ }
1263
+
1264
+ async function disconnectWebPhone() {
1265
+ webPhoneConnecting.value = true;
1266
+ try {
1267
+ await webPhone.disconnect();
1268
+ showMessage("Web phone disconnected.", "info");
1269
+ } finally {
1270
+ webPhoneConnecting.value = false;
1271
+ }
1272
+ }
1273
+
1274
+ async function startWebCall() {
1275
+ webPhoneActionLoading.value = true;
1276
+ try {
1277
+ await webPhone.call(webPhoneForm.destination);
1278
+ } catch (error: unknown) {
1279
+ showMessage(getErrorMessage(error), "error");
1280
+ } finally {
1281
+ webPhoneActionLoading.value = false;
1282
+ }
1283
+ }
1284
+
1285
+ async function answerWebCall() {
1286
+ webPhoneActionLoading.value = true;
1287
+ try {
1288
+ await webPhone.answer();
1289
+ } catch (error: unknown) {
1290
+ showMessage(getErrorMessage(error), "error");
1291
+ } finally {
1292
+ webPhoneActionLoading.value = false;
1293
+ }
1294
+ }
1295
+
1296
+ async function declineWebCall() {
1297
+ webPhoneActionLoading.value = true;
1298
+ try {
1299
+ await webPhone.decline();
1300
+ } catch (error: unknown) {
1301
+ showMessage(getErrorMessage(error), "error");
1302
+ } finally {
1303
+ webPhoneActionLoading.value = false;
1304
+ }
1305
+ }
1306
+
1307
+ async function hangupWebCall() {
1308
+ webPhoneActionLoading.value = true;
1309
+ try {
1310
+ await webPhone.hangup();
1311
+ } catch (error: unknown) {
1312
+ showMessage(getErrorMessage(error), "error");
1313
+ } finally {
1314
+ webPhoneActionLoading.value = false;
1315
+ }
1316
+ }
1317
+
1318
+ function toggleWebCamera() {
1319
+ try {
1320
+ webPhone.toggleCamera();
1321
+ } catch (error: unknown) {
1322
+ showMessage(getErrorMessage(error), "error");
1323
+ }
1324
+ }
1325
+
1326
+ function toggleWebMute() {
1327
+ try {
1328
+ webPhone.toggleMute();
1329
+ } catch (error: unknown) {
1330
+ showMessage(getErrorMessage(error), "error");
1331
+ }
1332
+ }
1333
+
954
1334
  function openContactDialog(contact?: ContactRow) {
955
1335
  selectedContact.value = contact || null;
956
1336
  contactForm.id = contact?.id;
@@ -974,21 +1354,35 @@ function openDeleteContact(contact: ContactRow) {
974
1354
  async function saveSipSettings() {
975
1355
  if (!selectedRow.value) return;
976
1356
  const isPeerToPeer = sipForm.operatingMode === "peer-to-peer";
1357
+ const initialRtpPort = Number(sipForm.initialRtpPort);
1358
+ const finalRtpPort = Number(sipForm.finalRtpPort);
1359
+
1360
+ if (
1361
+ !Number.isInteger(initialRtpPort) ||
1362
+ !Number.isInteger(finalRtpPort) ||
1363
+ initialRtpPort < 1 ||
1364
+ finalRtpPort > 65535 ||
1365
+ finalRtpPort <= initialRtpPort
1366
+ ) {
1367
+ showMessage("Final RTP Port must be greater than Initial RTP Port and both ports must be between 1 and 65535.", "warning");
1368
+ return;
1369
+ }
1370
+
977
1371
  const saved = await savePjsip(selectedRow.value.reader._id, {
978
1372
  enabled: sipForm.enabled ? "1" : "0",
979
1373
  server_ip: sipForm.serverIp,
980
1374
  server_port: sipForm.serverPort,
981
- server_outbound_port: sipForm.initialRtpPort,
982
- server_outbound_port_range: sipForm.finalRtpPort,
1375
+ server_outbound_port: String(initialRtpPort),
1376
+ server_outbound_port_range: String(finalRtpPort - initialRtpPort),
983
1377
  numeric_branch_enabled: sipForm.numericExtension ? "1" : "0",
984
1378
  branch: isPeerToPeer ? "" : sipForm.extension,
985
1379
  login: isPeerToPeer ? "" : sipForm.login,
986
1380
  password: isPeerToPeer ? "" : sipForm.password,
987
1381
  peer_to_peer_enabled: isPeerToPeer ? "1" : "0",
988
- }, { showSuccessMessage: false });
1382
+ video_enabled: sipForm.videoEnabled ? "1" : "0",
1383
+ });
989
1384
  if (saved) {
990
1385
  sipDialog.value = false;
991
- rebootConfirmDialog.value = true;
992
1386
  }
993
1387
  }
994
1388
 
@@ -1020,7 +1414,13 @@ async function runMakeCall() {
1020
1414
  if (!selectedRow.value) return;
1021
1415
  const target = makeCallForm.target.trim();
1022
1416
  if (!target) {
1023
- makeCallForm.status = "Please enter an address to dial.";
1417
+ makeCallForm.status = "Please enter an IP address to dial.";
1418
+ makeCallForm.statusType = "warning";
1419
+ return;
1420
+ }
1421
+
1422
+ if (!isIpAddress(target)) {
1423
+ makeCallForm.status = "Address to dial must be an IP address.";
1024
1424
  makeCallForm.statusType = "warning";
1025
1425
  return;
1026
1426
  }
@@ -1042,6 +1442,15 @@ async function runMakeCall() {
1042
1442
  }
1043
1443
  }
1044
1444
 
1445
+ function isIpAddress(value = "") {
1446
+ const parts = value.trim().split(".");
1447
+ return parts.length === 4 && parts.every((part) => {
1448
+ if (!/^\d{1,3}$/.test(part)) return false;
1449
+ const value = Number(part);
1450
+ return value >= 0 && value <= 255;
1451
+ });
1452
+ }
1453
+
1045
1454
  async function runHangupCall() {
1046
1455
  if (!selectedRow.value) return;
1047
1456
  hangingUp.value = true;
@@ -1078,30 +1487,6 @@ async function savePjsip(readerId: string, payload: Record<string, string>, opti
1078
1487
  }
1079
1488
  }
1080
1489
 
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
1490
  async function saveContact() {
1106
1491
  if (!contactForm.readerId || !contactForm.name.trim() || !contactForm.serverAddress.trim()) {
1107
1492
  showMessage("Please complete the required contact fields.", "warning");
@@ -1182,8 +1567,42 @@ function showMessage(text: string, type: MessageType = "info") {
1182
1567
  message.type = type;
1183
1568
  }
1184
1569
 
1185
- function getErrorMessage(error: any) {
1186
- return error?.data?.message || error?.response?._data?.message || error?.message || "HID intercom request failed.";
1570
+ function normalizeSipAccount(response: unknown): SipAccount {
1571
+ const responseRecord = toRecord(response);
1572
+ const payload = toRecord(responseRecord.data || responseRecord);
1573
+ const requiredFields = ["extension", "username", "password", "sipAddress", "websocketUrl"] as const;
1574
+
1575
+ if (requiredFields.some((field) => !toText(payload[field]))) {
1576
+ throw new Error("The SIP account response is incomplete. Please try again.");
1577
+ }
1578
+
1579
+ return {
1580
+ orgId: toText(payload.orgId, props.org),
1581
+ site: toText(payload.site, props.site),
1582
+ userId: toText(payload.userId),
1583
+ extension: toText(payload.extension),
1584
+ username: toText(payload.username),
1585
+ password: toText(payload.password),
1586
+ sipAddress: toText(payload.sipAddress),
1587
+ websocketUrl: toText(payload.websocketUrl),
1588
+ status: "active",
1589
+ provisionedAt: toText(payload.provisionedAt),
1590
+ };
1591
+ }
1592
+
1593
+ function getErrorMessage(error: unknown) {
1594
+ const errorRecord = toRecord(error);
1595
+ const data = toRecord(errorRecord.data);
1596
+ const response = toRecord(errorRecord.response);
1597
+ const responseData = toRecord(response._data);
1598
+ return toText(data.message)
1599
+ || toText(responseData.message)
1600
+ || toText(errorRecord.message)
1601
+ || "HID intercom request failed.";
1602
+ }
1603
+
1604
+ function toRecord(value: unknown): Record<string, unknown> {
1605
+ return typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
1187
1606
  }
1188
1607
 
1189
1608
  function toText(value: unknown, fallback = "") {
@@ -1259,6 +1678,23 @@ export default {
1259
1678
  width: 160px;
1260
1679
  }
1261
1680
 
1681
+ .web-call-button {
1682
+ min-width: 160px;
1683
+ background: #e5e7eb;
1684
+ color: #0b2c40;
1685
+ font-size: 12px;
1686
+ }
1687
+
1688
+ .web-call-button.is-connected {
1689
+ background: #d9f2e1;
1690
+ color: #16743a;
1691
+ }
1692
+
1693
+ .web-call-button.has-incoming-call {
1694
+ background: #1769aa;
1695
+ color: #fff;
1696
+ }
1697
+
1262
1698
  .add-button {
1263
1699
  min-width: 160px;
1264
1700
  background: #dedede;
@@ -1468,6 +1904,20 @@ export default {
1468
1904
  margin-bottom: 12px;
1469
1905
  }
1470
1906
 
1907
+ .sip-section-switch > div {
1908
+ display: flex;
1909
+ min-width: 0;
1910
+ padding: 8px 12px 8px 0;
1911
+ flex-direction: column;
1912
+ gap: 2px;
1913
+ }
1914
+
1915
+ .sip-section-switch small {
1916
+ color: #667085;
1917
+ font-size: 10px;
1918
+ line-height: 1.4;
1919
+ }
1920
+
1471
1921
  .sip-switch-row :deep(.v-switch) {
1472
1922
  flex: 0 0 auto;
1473
1923
  }
@@ -1630,98 +2080,6 @@ export default {
1630
2080
  overflow: hidden;
1631
2081
  }
1632
2082
 
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
2083
  .call-dialog .dialog-body {
1726
2084
  padding: 18px 24px 24px;
1727
2085
  }
@@ -1931,6 +2289,280 @@ export default {
1931
2289
  line-height: 1.45;
1932
2290
  }
1933
2291
 
2292
+ .web-phone-dialog {
2293
+ width: 820px;
2294
+ max-width: 94vw;
2295
+ overflow: hidden;
2296
+ background: #fff;
2297
+ }
2298
+
2299
+ .web-phone-title {
2300
+ display: flex;
2301
+ align-items: center;
2302
+ justify-content: space-between;
2303
+ gap: 16px;
2304
+ }
2305
+
2306
+ .web-phone-status {
2307
+ min-width: 92px;
2308
+ justify-content: center;
2309
+ font-size: 10px;
2310
+ }
2311
+
2312
+ .web-phone-status.ready {
2313
+ background: #d9f2e1;
2314
+ color: #16743a;
2315
+ }
2316
+
2317
+ .web-phone-status.incoming {
2318
+ background: #dcecff;
2319
+ color: #125c9a;
2320
+ }
2321
+
2322
+ .web-phone-status.failed {
2323
+ background: #fce2e2;
2324
+ color: #a92222;
2325
+ }
2326
+
2327
+ .web-phone-status.idle {
2328
+ background: #e5e7eb;
2329
+ color: #5d6673;
2330
+ }
2331
+
2332
+ .web-phone-body {
2333
+ display: grid;
2334
+ grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.1fr);
2335
+ gap: 24px;
2336
+ min-height: 440px;
2337
+ padding: 22px 24px 24px;
2338
+ }
2339
+
2340
+ .web-phone-settings {
2341
+ padding-right: 24px;
2342
+ border-right: 1px solid #e5e7eb;
2343
+ }
2344
+
2345
+ .web-phone-section-title {
2346
+ margin-bottom: 14px;
2347
+ color: #0d1720;
2348
+ font-size: 13px;
2349
+ font-weight: 700;
2350
+ }
2351
+
2352
+ .sip-account-state {
2353
+ display: flex;
2354
+ min-height: 126px;
2355
+ align-items: center;
2356
+ justify-content: center;
2357
+ padding: 18px;
2358
+ border: 1px solid #dce2e8;
2359
+ border-radius: 6px;
2360
+ color: #5d6673;
2361
+ flex-direction: column;
2362
+ font-size: 12px;
2363
+ gap: 12px;
2364
+ text-align: center;
2365
+ }
2366
+
2367
+ .sip-account-state.is-error {
2368
+ border-color: #f0c7c7;
2369
+ background: #fff8f8;
2370
+ color: #a92222;
2371
+ }
2372
+
2373
+ .sip-account-retry {
2374
+ min-width: 104px;
2375
+ border-color: #aeb8c2;
2376
+ color: #344054;
2377
+ }
2378
+
2379
+ .sip-account-summary {
2380
+ display: grid;
2381
+ min-height: 96px;
2382
+ align-items: center;
2383
+ padding: 16px;
2384
+ border: 1px solid #dce2e8;
2385
+ border-radius: 6px;
2386
+ background: #f8fafc;
2387
+ grid-template-columns: minmax(0, 1fr) auto;
2388
+ gap: 18px;
2389
+ }
2390
+
2391
+ .sip-account-summary > div {
2392
+ display: flex;
2393
+ min-width: 0;
2394
+ flex-direction: column;
2395
+ gap: 4px;
2396
+ }
2397
+
2398
+ .sip-account-label {
2399
+ color: #667085;
2400
+ font-size: 11px;
2401
+ }
2402
+
2403
+ .sip-account-extension {
2404
+ color: #0d1720;
2405
+ font-size: 24px;
2406
+ line-height: 1.1;
2407
+ }
2408
+
2409
+ .sip-account-address {
2410
+ max-width: 170px;
2411
+ overflow: hidden;
2412
+ color: #536273;
2413
+ font-size: 11px;
2414
+ text-align: right;
2415
+ text-overflow: ellipsis;
2416
+ white-space: nowrap;
2417
+ }
2418
+
2419
+ .web-video-toggle {
2420
+ margin-top: 12px;
2421
+ }
2422
+
2423
+ .web-phone-connect {
2424
+ min-height: 42px;
2425
+ margin-top: 14px;
2426
+ background: #082b40;
2427
+ color: #fff;
2428
+ }
2429
+
2430
+ .web-phone-disconnect {
2431
+ min-height: 42px;
2432
+ margin-top: 14px;
2433
+ border-color: #aeb8c2;
2434
+ color: #344054;
2435
+ }
2436
+
2437
+ .web-phone-console {
2438
+ display: flex;
2439
+ min-width: 0;
2440
+ flex-direction: column;
2441
+ }
2442
+
2443
+ .web-phone-media {
2444
+ position: relative;
2445
+ display: grid;
2446
+ min-height: 260px;
2447
+ overflow: hidden;
2448
+ place-items: center;
2449
+ border: 1px solid #d7dde4;
2450
+ border-radius: 6px;
2451
+ background: #102a3b;
2452
+ }
2453
+
2454
+ .remote-video {
2455
+ display: none;
2456
+ width: 100%;
2457
+ height: 100%;
2458
+ object-fit: cover;
2459
+ }
2460
+
2461
+ .web-phone-media.has-remote-video .remote-video {
2462
+ display: block;
2463
+ }
2464
+
2465
+ .local-video {
2466
+ position: absolute;
2467
+ right: 12px;
2468
+ bottom: 12px;
2469
+ display: none;
2470
+ width: 108px;
2471
+ aspect-ratio: 3 / 4;
2472
+ border: 2px solid #fff;
2473
+ border-radius: 4px;
2474
+ background: #263b49;
2475
+ object-fit: cover;
2476
+ }
2477
+
2478
+ .web-phone-media.has-local-video .local-video {
2479
+ display: block;
2480
+ }
2481
+
2482
+ .web-phone-media.has-remote-video .audio-call-state {
2483
+ display: none;
2484
+ }
2485
+
2486
+ .audio-call-state {
2487
+ display: flex;
2488
+ align-items: center;
2489
+ justify-content: center;
2490
+ padding: 28px;
2491
+ color: #fff;
2492
+ flex-direction: column;
2493
+ gap: 10px;
2494
+ text-align: center;
2495
+ }
2496
+
2497
+ .audio-call-state strong {
2498
+ font-size: 16px;
2499
+ font-weight: 600;
2500
+ }
2501
+
2502
+ .audio-call-state span {
2503
+ max-width: 100%;
2504
+ overflow: hidden;
2505
+ color: #c9d7e1;
2506
+ font-size: 11px;
2507
+ text-overflow: ellipsis;
2508
+ white-space: nowrap;
2509
+ }
2510
+
2511
+ .web-phone-dial-row {
2512
+ display: grid;
2513
+ grid-template-columns: minmax(0, 1fr) 42px;
2514
+ gap: 8px;
2515
+ align-items: start;
2516
+ }
2517
+
2518
+ .start-web-call,
2519
+ .hangup-web-call {
2520
+ width: 42px;
2521
+ height: 38px;
2522
+ border-radius: 4px;
2523
+ background: #2a9b50;
2524
+ color: #fff;
2525
+ }
2526
+
2527
+ .hangup-web-call,
2528
+ .reject-call {
2529
+ background: #c9293d;
2530
+ }
2531
+
2532
+ .incoming-call-actions {
2533
+ display: grid;
2534
+ grid-template-columns: 1fr 1fr;
2535
+ gap: 10px;
2536
+ margin-top: 14px;
2537
+ }
2538
+
2539
+ .answer-call,
2540
+ .reject-call {
2541
+ min-height: 42px;
2542
+ color: #fff;
2543
+ }
2544
+
2545
+ .answer-call {
2546
+ background: #2a9b50;
2547
+ }
2548
+
2549
+ .active-call-controls {
2550
+ display: flex;
2551
+ justify-content: center;
2552
+ gap: 12px;
2553
+ margin-top: 16px;
2554
+ }
2555
+
2556
+ .active-call-controls .v-btn {
2557
+ width: 46px;
2558
+ height: 46px;
2559
+ }
2560
+
2561
+ .web-phone-error {
2562
+ margin-top: 14px;
2563
+ font-size: 11px;
2564
+ }
2565
+
1934
2566
  .call-two-col {
1935
2567
  gap: 12px;
1936
2568
  }
@@ -2064,13 +2696,22 @@ export default {
2064
2696
  max-width: none;
2065
2697
  }
2066
2698
 
2067
- .rebooting-card {
2068
- grid-template-columns: 1fr;
2069
- padding: 28px;
2699
+ .web-call-button {
2700
+ width: 100%;
2701
+ }
2702
+
2703
+ .web-phone-body {
2704
+ grid-template-columns: minmax(0, 1fr);
2705
+ max-height: 78vh;
2706
+ overflow: auto;
2070
2707
  }
2071
2708
 
2072
- .wait-word {
2073
- font-size: 80px;
2709
+ .web-phone-settings {
2710
+ padding-right: 0;
2711
+ padding-bottom: 20px;
2712
+ border-right: 0;
2713
+ border-bottom: 1px solid #e5e7eb;
2074
2714
  }
2715
+
2075
2716
  }
2076
2717
  </style>