@7365admin1/layer-common 3.2.1 → 3.2.2-staging.100

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 (123) 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/notification-preferences.md +17 -0
  7. package/.changeset/pm-sidebar-grouping-and-list-scaffold.md +27 -0
  8. package/.changeset/service-provider-invitation-actions.md +28 -0
  9. package/assets/css/primitives.css +680 -0
  10. package/assets/css/screens.css +541 -0
  11. package/assets/css/shell.css +258 -0
  12. package/assets/css/tokens.css +220 -0
  13. package/components/AccessCardQrTagging.vue +1 -1
  14. package/components/AccessManagement.vue +53 -72
  15. package/components/AppButton.vue +52 -0
  16. package/components/AppCard.vue +20 -0
  17. package/components/AppDateField.vue +35 -0
  18. package/components/AppField.vue +46 -0
  19. package/components/AppKpiTile.vue +65 -0
  20. package/components/AppSegmented.vue +38 -0
  21. package/components/AppSelect.vue +203 -0
  22. package/components/AreaChecklistHistoryMain.vue +6 -2
  23. package/components/AreaMain.vue +9 -17
  24. package/components/AttendanceMain.vue +4 -3
  25. package/components/Avatar/Main.vue +54 -2
  26. package/components/BillingMain.vue +26 -30
  27. package/components/BuildingManagement/buildings.vue +27 -38
  28. package/components/BuildingManagement/units.vue +26 -42
  29. package/components/BulletinBoardManagement.vue +23 -10
  30. package/components/CameraWall.vue +1034 -0
  31. package/components/CameraWallTile.vue +818 -0
  32. package/components/CardToolbar.vue +37 -0
  33. package/components/CleaningScheduleMain.vue +29 -46
  34. package/components/ClientMain.vue +90 -78
  35. package/components/DashboardEmptyState.vue +11 -1
  36. package/components/DashboardMain.vue +349 -275
  37. package/components/DashboardPanel.vue +1 -1
  38. package/components/DocumentManagement.vue +129 -93
  39. package/components/EntryPassInformation.vue +4 -4
  40. package/components/EntryPassMain.vue +49 -27
  41. package/components/EquipmentItemMain.vue +4 -6
  42. package/components/EquipmentManagementMain.vue +9 -7
  43. package/components/FeedbackMain.vue +42 -56
  44. package/components/FormDialog.vue +60 -21
  45. package/components/HidAccessLogDashboard.vue +119 -44
  46. package/components/HidCameraCaptureDialog.vue +199 -0
  47. package/components/HidIntercomManagement.vue +825 -202
  48. package/components/HidQrCodeConfiguration.vue +927 -93
  49. package/components/HidServiceSettingsPanel.vue +20 -1
  50. package/components/HidUserEnrollment.vue +454 -106
  51. package/components/InvitationMain.vue +46 -61
  52. package/components/Layout/Header.vue +289 -37
  53. package/components/Layout/NavigationDrawer.vue +340 -30
  54. package/components/ManageChecklistMain.vue +17 -17
  55. package/components/MemberMain.vue +79 -60
  56. package/components/MyAttendanceMain.vue +3 -5
  57. package/components/NavigationItem.vue +9 -0
  58. package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
  59. package/components/NotificationSettings.vue +361 -0
  60. package/components/OnlineFormConfigurationForm.vue +620 -224
  61. package/components/OnlineFormFill.vue +36 -8
  62. package/components/OnlineFormsConfiguration.vue +7 -3
  63. package/components/PageHeader.vue +43 -0
  64. package/components/PassInformation.vue +104 -45
  65. package/components/QrTemplate/CreditCardLandscape.vue +19 -12
  66. package/components/QrTemplate/PrintDialog.vue +209 -196
  67. package/components/RolePermissionMain.vue +184 -91
  68. package/components/ScanVisitorQRCode.vue +35 -7
  69. package/components/ScheduleAreaMain.vue +15 -49
  70. package/components/ScheduleTaskMain.vue +23 -26
  71. package/components/ScheduleTastTicketMain.vue +18 -43
  72. package/components/ServiceProviderInvitationPrompt.vue +150 -0
  73. package/components/ServiceProviderMain.vue +346 -157
  74. package/components/SiteSettings.vue +25 -10
  75. package/components/SpecificAttr.vue +10 -2
  76. package/components/StatusChip.vue +93 -0
  77. package/components/SwitchContext.vue +33 -1
  78. package/components/TableHygiene.vue +29 -46
  79. package/components/TableList.vue +1 -1
  80. package/components/TableListSecondary.vue +1 -1
  81. package/components/TableMain.vue +161 -77
  82. package/components/TableV3.vue +61 -66
  83. package/components/UnitMain.vue +9 -17
  84. package/components/VehicleManagement.vue +20 -9
  85. package/components/VisitorForm.vue +294 -78
  86. package/components/VisitorManagement.vue +221 -71
  87. package/components/VisitorSocketPopUp.vue +56 -2
  88. package/components/WorkOrder/Main.vue +42 -51
  89. package/composables/useComment.ts +3 -3
  90. package/composables/useHidAmico.ts +151 -0
  91. package/composables/useHidNavigation.ts +0 -7
  92. package/composables/useLocalAuth.ts +9 -36
  93. package/composables/useNotificationPreference.ts +87 -0
  94. package/composables/useOnlineFormPages.ts +2 -0
  95. package/composables/useOrg.ts +1 -1
  96. package/composables/useRole.ts +22 -0
  97. package/composables/useServiceProviderInvitation.ts +145 -0
  98. package/composables/useSipWebPhone.ts +336 -0
  99. package/composables/useSiteSettings.ts +50 -0
  100. package/composables/useThemePreference.ts +63 -0
  101. package/composables/useVisitor.ts +6 -1
  102. package/composables/useVisitorSocket.ts +26 -0
  103. package/composables/useWebUsb.ts +127 -37
  104. package/nuxt.config.ts +31 -0
  105. package/package.json +4 -1
  106. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  107. package/pages/notification-settings.vue +19 -0
  108. package/plugins/secure-member.client.ts +21 -56
  109. package/plugins/vuetify.ts +68 -9
  110. package/test/visitor-socket.test.mjs +36 -0
  111. package/types/org.d.ts +2 -0
  112. package/types/site.d.ts +71 -0
  113. package/utils/camera-wall.test.ts +571 -0
  114. package/utils/camera-wall.ts +926 -0
  115. package/utils/nav-sections.test.ts +190 -0
  116. package/utils/nav-sections.ts +151 -0
  117. package/utils/status.test.ts +77 -0
  118. package/utils/status.ts +90 -0
  119. package/utils/theme.test.ts +534 -0
  120. package/utils/theme.ts +294 -0
  121. package/components/HidIdentityMapping.vue +0 -975
  122. package/middleware/member.ts +0 -4
  123. 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">Extension, SIP address, or peer IP</div>
354
342
  <v-text-field
355
343
  v-model="makeCallForm.target"
356
- placeholder="Enter extension, address, or contact"
344
+ placeholder="7100 or user@example.com"
357
345
  variant="outlined"
358
346
  density="compact"
359
347
  hide-details="auto"
@@ -388,12 +376,190 @@
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>
393
380
  </v-card-actions>
394
381
  </v-card>
395
382
  </v-dialog>
396
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
+ <div class="web-phone-title-actions">
389
+ <v-chip size="small" variant="flat" :class="webPhoneStatusClass">
390
+ {{ webPhoneStatusLabel }}
391
+ </v-chip>
392
+ <v-btn
393
+ icon="mdi-close"
394
+ size="small"
395
+ variant="text"
396
+ title="Close Web Call"
397
+ aria-label="Close Web Call"
398
+ @click="webPhoneDialog = false"
399
+ />
400
+ </div>
401
+ </v-card-title>
402
+
403
+ <v-card-text class="web-phone-body">
404
+ <div class="web-phone-settings">
405
+ <div class="web-phone-section-title">SIP Account</div>
406
+ <div v-if="sipAccountLoading" class="sip-account-state">
407
+ <v-progress-circular indeterminate color="primary" size="30" width="3" />
408
+ <span>Preparing your private SIP account...</span>
409
+ </div>
410
+ <div v-else-if="sipAccountError" class="sip-account-state is-error">
411
+ <v-icon icon="mdi-alert-circle-outline" size="28" />
412
+ <span>{{ sipAccountError }}</span>
413
+ <v-btn
414
+ class="text-none sip-account-retry"
415
+ variant="outlined"
416
+ prepend-icon="mdi-refresh"
417
+ @click="loadSipAccount"
418
+ >
419
+ Retry
420
+ </v-btn>
421
+ </div>
422
+ <div v-else-if="sipAccount" class="sip-account-summary">
423
+ <div>
424
+ <span class="sip-account-label">Assigned extension</span>
425
+ <strong class="sip-account-extension">{{ sipAccount.extension }}</strong>
426
+ </div>
427
+ <span class="sip-account-address">{{ sipAccount.sipAddress }}</span>
428
+ </div>
429
+ <div class="call-toggle-row web-video-toggle">
430
+ <div>
431
+ <span>Receive HID video</span>
432
+ <small>Show the HID camera feed without sharing this device's camera.</small>
433
+ </div>
434
+ <v-switch
435
+ v-model="webPhoneForm.video"
436
+ color="success"
437
+ density="compact"
438
+ hide-details
439
+ :disabled="webPhone.isRegistered.value"
440
+ />
441
+ </div>
442
+ <v-btn
443
+ v-if="!webPhone.isRegistered.value"
444
+ block
445
+ class="text-none web-phone-connect"
446
+ variant="flat"
447
+ prepend-icon="mdi-lan-connect"
448
+ :loading="webPhoneConnecting"
449
+ :disabled="!sipAccount || sipAccountLoading"
450
+ @click="connectWebPhone"
451
+ >
452
+ Connect Web Phone
453
+ </v-btn>
454
+ <v-btn
455
+ v-else
456
+ block
457
+ class="text-none web-phone-disconnect"
458
+ variant="outlined"
459
+ prepend-icon="mdi-lan-disconnect"
460
+ :loading="webPhoneConnecting"
461
+ :disabled="webPhone.hasCall.value"
462
+ @click="disconnectWebPhone"
463
+ >
464
+ Disconnect
465
+ </v-btn>
466
+ </div>
467
+
468
+ <div class="web-phone-console">
469
+ <div
470
+ class="web-phone-media"
471
+ :class="{
472
+ 'has-video': webPhoneForm.video && webPhone.callState.value === 'active',
473
+ 'has-remote-video': webPhone.remoteVideoAvailable.value,
474
+ }"
475
+ >
476
+ <video ref="remoteVideoEl" autoplay playsinline class="remote-video" />
477
+ <audio ref="remoteAudioEl" autoplay />
478
+ <div
479
+ v-if="!webPhoneForm.video || webPhone.callState.value !== 'active' || !webPhone.remoteVideoAvailable.value"
480
+ class="audio-call-state"
481
+ >
482
+ <v-icon size="54">{{ webPhoneCallIcon }}</v-icon>
483
+ <strong>{{ webPhoneCallLabel }}</strong>
484
+ <span v-if="webPhone.currentTarget.value">{{ webPhone.currentTarget.value }}</span>
485
+ <span v-if="webPhoneForm.video && webPhone.callState.value === 'active' && !webPhone.remoteVideoAvailable.value">
486
+ Waiting for video from the HID device
487
+ </span>
488
+ </div>
489
+ </div>
490
+
491
+ <div v-if="webPhone.callState.value === 'incoming'" class="incoming-call-actions">
492
+ <v-btn
493
+ class="text-none answer-call"
494
+ variant="flat"
495
+ prepend-icon="mdi-phone"
496
+ :loading="webPhoneActionLoading"
497
+ @click="answerWebCall"
498
+ >
499
+ Answer
500
+ </v-btn>
501
+ <v-btn
502
+ class="text-none reject-call"
503
+ variant="flat"
504
+ prepend-icon="mdi-phone-hangup"
505
+ :disabled="webPhoneActionLoading"
506
+ @click="declineWebCall"
507
+ >
508
+ Decline
509
+ </v-btn>
510
+ </div>
511
+
512
+ <template v-else>
513
+ <div class="call-label">Extension or SIP Address</div>
514
+ <div class="web-phone-dial-row">
515
+ <v-text-field
516
+ v-model="webPhoneForm.destination"
517
+ placeholder="1001 or device@example.com"
518
+ variant="outlined"
519
+ density="compact"
520
+ hide-details="auto"
521
+ class="call-input mb-0"
522
+ :disabled="!webPhone.isRegistered.value || webPhone.hasCall.value"
523
+ @keyup.enter="startWebCall"
524
+ />
525
+ <v-btn
526
+ icon="mdi-phone"
527
+ class="start-web-call"
528
+ variant="flat"
529
+ :loading="webPhoneActionLoading && webPhone.callState.value === 'calling'"
530
+ :disabled="!webPhone.isRegistered.value || webPhone.hasCall.value || !webPhoneForm.destination.trim()"
531
+ @click="startWebCall"
532
+ />
533
+ </div>
534
+
535
+ <div v-if="webPhone.hasCall.value" class="active-call-controls">
536
+ <v-btn
537
+ :icon="webPhone.muted.value ? 'mdi-microphone-off' : 'mdi-microphone'"
538
+ variant="outlined"
539
+ :disabled="webPhone.callState.value !== 'active'"
540
+ :title="webPhone.muted.value ? 'Unmute' : 'Mute'"
541
+ @click="toggleWebMute"
542
+ />
543
+ <v-btn
544
+ icon="mdi-phone-hangup"
545
+ class="hangup-web-call"
546
+ variant="flat"
547
+ :loading="webPhoneActionLoading && webPhone.callState.value === 'ending'"
548
+ title="End call"
549
+ @click="hangupWebCall"
550
+ />
551
+ </div>
552
+ </template>
553
+
554
+ <v-alert v-if="webPhone.errorMessage.value" type="error" variant="tonal" density="compact" class="web-phone-error">
555
+ {{ webPhone.errorMessage.value }}
556
+ </v-alert>
557
+ </div>
558
+ </v-card-text>
559
+
560
+ </v-card>
561
+ </v-dialog>
562
+
397
563
  <v-dialog v-model="contactDialog" max-width="360" persistent>
398
564
  <v-card class="form-dialog" rounded="lg">
399
565
  <v-card-title class="dialog-title">{{ contactForm.id ? 'Edit Contact' : 'Add Contact' }}</v-card-title>
@@ -448,6 +614,10 @@
448
614
 
449
615
  <script setup lang="ts">
450
616
  const props = defineProps({
617
+ org: {
618
+ type: String,
619
+ required: true,
620
+ },
451
621
  site: {
452
622
  type: String,
453
623
  required: true,
@@ -466,6 +636,19 @@ type ContactRow = {
466
636
  dialCode: string;
467
637
  };
468
638
 
639
+ type SipAccount = {
640
+ orgId: string;
641
+ site: string;
642
+ userId: string;
643
+ extension: string;
644
+ username: string;
645
+ password: string;
646
+ sipAddress: string;
647
+ websocketUrl: string;
648
+ status: "active";
649
+ provisionedAt: string;
650
+ };
651
+
469
652
  type DetailItem = {
470
653
  label: string;
471
654
  value?: string | number | boolean;
@@ -474,7 +657,7 @@ type DetailItem = {
474
657
  };
475
658
 
476
659
  type IntercomRow = {
477
- reader: Record<string, any>;
660
+ reader: Record<string, unknown>;
478
661
  name: string;
479
662
  location: string;
480
663
  serverAddress: string;
@@ -490,6 +673,7 @@ type IntercomRow = {
490
673
  password: string;
491
674
  operatingMode: string;
492
675
  protocol: string;
676
+ videoEnabled: boolean;
493
677
  dialMode: string;
494
678
  dialModeLabel: string;
495
679
  dialCode: string;
@@ -502,7 +686,7 @@ type IntercomRow = {
502
686
  externalDialButton: boolean;
503
687
  unknownContact: boolean;
504
688
  callAfter: string;
505
- pjsip: Record<string, any>;
689
+ pjsip: Record<string, unknown>;
506
690
  };
507
691
 
508
692
  const {
@@ -513,6 +697,7 @@ const {
513
697
  getIntercomStatus,
514
698
  makeIntercomCall,
515
699
  finalizeIntercomCall,
700
+ ensureSipAccount,
516
701
  } = useHidAmico();
517
702
 
518
703
  const activeTab = ref<IntercomTab>("intercom");
@@ -531,21 +716,26 @@ const limit = 20;
531
716
  const selectedRow = ref<IntercomRow | null>(null);
532
717
  const selectedContact = ref<ContactRow | null>(null);
533
718
  const showSipPassword = ref(false);
719
+ const webPhoneConnecting = ref(false);
720
+ const webPhoneActionLoading = ref(false);
721
+ const sipAccount = ref<SipAccount | null>(null);
722
+ const sipAccountLoading = ref(false);
723
+ const sipAccountError = ref("");
724
+ const remoteVideoEl = ref<HTMLVideoElement | null>(null);
725
+ const remoteAudioEl = ref<HTMLAudioElement | null>(null);
726
+ const webPhone = useSipWebPhone();
534
727
 
535
728
  const viewDialog = ref(false);
536
729
  const sipDialog = ref(false);
537
- const rebootConfirmDialog = ref(false);
538
- const rebootingDialog = ref(false);
539
730
  const callDialog = ref(false);
540
731
  const dialCodeDialog = ref(false);
541
732
  const makeCallDialog = ref(false);
733
+ const webPhoneDialog = ref(false);
542
734
  const contactDialog = ref(false);
543
735
  const contactViewDialog = ref(false);
544
736
  const deleteContactDialog = ref(false);
545
737
 
546
738
  const message = reactive<{ type: MessageType; text: string }>({ type: "info", text: "" });
547
- const rebootCountdown = ref(59);
548
- let rebootTimer: ReturnType<typeof setInterval> | undefined;
549
739
 
550
740
  const statusOptions = ["Status", "Connected", "Not connected", "Connecting", "Disabled", "Failed"];
551
741
 
@@ -582,13 +772,14 @@ const sipForm = reactive({
582
772
  serverIp: "",
583
773
  serverPort: "5060",
584
774
  initialRtpPort: "10000",
585
- finalRtpPort: "1000",
775
+ finalRtpPort: "10100",
586
776
  numericExtension: false,
587
777
  extension: "",
588
778
  login: "",
589
779
  password: "",
590
780
  operatingMode: "sip-client",
591
781
  protocol: "UDP",
782
+ videoEnabled: false,
592
783
  });
593
784
 
594
785
  const callForm = reactive({
@@ -617,6 +808,11 @@ const makeCallForm = reactive({
617
808
  statusType: "info" as MessageType,
618
809
  });
619
810
 
811
+ const webPhoneForm = reactive({
812
+ destination: "",
813
+ video: false,
814
+ });
815
+
620
816
  const contactForm = reactive({
621
817
  id: undefined as number | undefined,
622
818
  readerId: "",
@@ -694,6 +890,7 @@ const intercomDetailSections = computed(() => {
694
890
  items: [
695
891
  { label: "Operating Mode", value: row?.operatingMode },
696
892
  { label: "Protocol", value: row?.protocol },
893
+ { label: "Video", value: row?.videoEnabled ? "Enabled" : "Disabled" },
697
894
  ],
698
895
  },
699
896
  {
@@ -717,10 +914,45 @@ const contactDetailRows = computed<DetailItem[]>(() => [
717
914
  { label: "Server Address", value: selectedContact.value?.serverAddress, link: true },
718
915
  { label: "Location", value: selectedContact.value?.location },
719
916
  ]);
917
+ const webPhoneStatusLabel = computed(() => {
918
+ if (webPhone.callState.value === "incoming") return "Incoming call";
919
+ if (webPhone.callState.value === "calling") return "Calling";
920
+ if (webPhone.callState.value === "active") return "In call";
921
+ if (webPhone.callState.value === "ending") return "Ending call";
922
+ if (webPhone.connectionState.value === "connecting") return "Connecting";
923
+ if (webPhone.connectionState.value === "registered") return "Ready";
924
+ if (webPhone.connectionState.value === "error") return "Connection failed";
925
+ return "Disconnected";
926
+ });
927
+ const webPhoneStatusClass = computed(() => {
928
+ if (webPhone.callState.value === "incoming") return "web-phone-status incoming";
929
+ if (webPhone.callState.value === "active" || webPhone.connectionState.value === "registered") return "web-phone-status ready";
930
+ if (webPhone.connectionState.value === "error") return "web-phone-status failed";
931
+ return "web-phone-status idle";
932
+ });
933
+ const webPhoneCallLabel = computed(() => {
934
+ if (webPhone.callState.value === "incoming") return "Incoming call";
935
+ if (webPhone.callState.value === "calling") return "Calling...";
936
+ if (webPhone.callState.value === "active") return "Call connected";
937
+ if (webPhone.callState.value === "ending") return "Ending call...";
938
+ return webPhone.isRegistered.value ? "Ready to call" : "Connect a SIP account";
939
+ });
940
+ const webPhoneCallIcon = computed(() => {
941
+ if (webPhone.callState.value === "incoming") return "mdi-phone-incoming";
942
+ if (webPhone.callState.value === "calling") return "mdi-phone-outgoing";
943
+ if (webPhone.callState.value === "active") return "mdi-phone-in-talk";
944
+ return "mdi-phone-outline";
945
+ });
720
946
 
721
947
  watch(
722
- () => props.site,
723
- () => reload(),
948
+ [() => props.org, () => props.site],
949
+ () => {
950
+ if (webPhone.isRegistered.value) void webPhone.disconnect();
951
+ sipAccount.value = null;
952
+ sipAccountError.value = "";
953
+ webPhoneForm.destination = "";
954
+ void reload();
955
+ },
724
956
  { immediate: true },
725
957
  );
726
958
 
@@ -732,9 +964,12 @@ watch(pages, () => {
732
964
  if (page.value > pages.value) page.value = pages.value;
733
965
  });
734
966
 
735
- onUnmounted(() => {
736
- clearRebootTimer();
737
- });
967
+ watch(
968
+ () => webPhone.callState.value,
969
+ (state) => {
970
+ if (state === "incoming") webPhoneDialog.value = true;
971
+ },
972
+ );
738
973
 
739
974
  async function reload() {
740
975
  if (!props.site) return;
@@ -798,19 +1033,27 @@ function paginate<T>(items: T[]) {
798
1033
  return items.slice(start, start + limit);
799
1034
  }
800
1035
 
801
- function toIntercomRow(reader: Record<string, any>, pjsip: Record<string, any>, status: string): IntercomRow {
1036
+ function toIntercomRow(reader: Record<string, unknown>, pjsip: Record<string, unknown>, status: string): IntercomRow {
802
1037
  const peerToPeer = toFlag(getPjsipValue(pjsip, ["peer_to_peer_enabled", "peerToPeerEnabled"])) === "1";
803
1038
  const dialMode = toText(getPjsipValue(pjsip, ["dialing_display_mode", "dialingDisplayMode"]), "0");
1039
+ const initialRtpPort = toText(getPjsipValue(pjsip, ["server_outbound_port", "initialRtpPort", "initial_rtp_port"]), "N/A");
1040
+ const rtpPortRange = getPjsipValue(pjsip, ["server_outbound_port_range"]);
1041
+ const explicitFinalRtpPort = getPjsipValue(pjsip, ["finalRtpPort", "final_rtp_port"]);
1042
+ const initialRtpPortNumber = Number(initialRtpPort);
1043
+ const rtpPortRangeNumber = Number(rtpPortRange);
1044
+ const finalRtpPort = rtpPortRange !== undefined && Number.isFinite(initialRtpPortNumber) && Number.isFinite(rtpPortRangeNumber)
1045
+ ? String(initialRtpPortNumber + rtpPortRangeNumber)
1046
+ : toText(explicitFinalRtpPort, "N/A");
804
1047
 
805
1048
  return {
806
1049
  reader,
807
- name: reader.name || reader.deviceId || "HID Reader",
808
- location: reader.location || "N/A",
1050
+ name: toText(reader.name || reader.deviceId, "HID Reader"),
1051
+ location: toText(reader.location, "N/A"),
809
1052
  serverAddress: toText(getPjsipValue(pjsip, ["server_ip", "serverAddress", "server_address", "serverIp"]), "N/A"),
810
1053
  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",
1054
+ initialRtpPort,
1055
+ finalRtpPort,
1056
+ monitorPath: toText(reader.monitorPath, "N/A"),
814
1057
  status,
815
1058
  sipEnabled: toFlag(getPjsipValue(pjsip, ["enabled"])) === "1",
816
1059
  numericExtension: toFlag(getPjsipValue(pjsip, ["numeric_branch_enabled", "numericExtension", "numericExtensionEnabled"])) === "1",
@@ -819,6 +1062,7 @@ function toIntercomRow(reader: Record<string, any>, pjsip: Record<string, any>,
819
1062
  password: toText(getPjsipValue(pjsip, ["password"]), ""),
820
1063
  operatingMode: peerToPeer ? "Peer-to-Peer" : "SIP Client",
821
1064
  protocol: toText(getPjsipValue(pjsip, ["protocol"]), "UDP"),
1065
+ videoEnabled: toFlag(getPjsipValue(pjsip, ["video_enabled", "videoEnabled"])) === "1",
822
1066
  dialMode,
823
1067
  dialModeLabel: getDialModeLabel(dialMode),
824
1068
  dialCode: toText(getPjsipValue(pjsip, ["open_door_command", "dialCode"]), "N/A"),
@@ -835,7 +1079,7 @@ function toIntercomRow(reader: Record<string, any>, pjsip: Record<string, any>,
835
1079
  };
836
1080
  }
837
1081
 
838
- function normalizePjsip(...sources: Record<string, any>[]) {
1082
+ function normalizePjsip(...sources: Record<string, unknown>[]) {
839
1083
  for (const source of sources) {
840
1084
  const candidate = findPjsipCandidate(source);
841
1085
  if (candidate) return candidate;
@@ -843,45 +1087,46 @@ function normalizePjsip(...sources: Record<string, any>[]) {
843
1087
  return {};
844
1088
  }
845
1089
 
846
- function findPjsipCandidate(source: Record<string, any> | undefined): Record<string, any> | undefined {
1090
+ function findPjsipCandidate(source: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
847
1091
  if (!source || typeof source !== "object") return undefined;
848
1092
  const candidates = [
849
1093
  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,
1094
+ toRecord(source.pjsip),
1095
+ toRecord(toRecord(source.configuration).pjsip),
1096
+ toRecord(toRecord(source.result).pjsip),
1097
+ toRecord(toRecord(toRecord(source.result).configuration).pjsip),
1098
+ toRecord(toRecord(source.data).pjsip),
1099
+ toRecord(toRecord(toRecord(source.data).configuration).pjsip),
1100
+ toRecord(toRecord(toRecord(source.data).result).pjsip),
1101
+ toRecord(toRecord(toRecord(toRecord(source.data).result).configuration).pjsip),
1102
+ toRecord(toRecord(toRecord(source.data).data).pjsip),
1103
+ toRecord(toRecord(toRecord(toRecord(source.data).data).configuration).pjsip),
1104
+ toRecord(source.configuration),
1105
+ toRecord(toRecord(source.data).configuration),
1106
+ toRecord(toRecord(toRecord(source.data).data).configuration),
862
1107
  source,
863
1108
  ];
864
1109
 
865
1110
  return candidates.find((candidate) => isPjsipCandidate(candidate));
866
1111
  }
867
1112
 
868
- function isPjsipCandidate(value: unknown): value is Record<string, any> {
1113
+ function isPjsipCandidate(value: unknown): value is Record<string, unknown> {
869
1114
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
870
1115
  return pjsipKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key)) ||
871
1116
  ["serverAddress", "serverIp", "serverPort", "extension", "peerToPeerEnabled"].some((key) => Object.prototype.hasOwnProperty.call(value, key));
872
1117
  }
873
1118
 
874
- function getPjsipValue(source: Record<string, any>, keys: string[]) {
1119
+ function getPjsipValue(source: Record<string, unknown>, keys: string[]) {
875
1120
  for (const key of keys) {
876
1121
  if (source?.[key] !== undefined && source?.[key] !== null && source?.[key] !== "") return source[key];
877
1122
  }
878
1123
  return undefined;
879
1124
  }
880
1125
 
881
- function normalizeStatus(response: Record<string, any>, pjsip: Record<string, any>) {
1126
+ function normalizeStatus(response: Record<string, unknown>, pjsip: Record<string, unknown>) {
882
1127
  const status = Number(response?.status);
1128
+ if (toFlag(pjsip.enabled) === "0" || status === -1) return "Disabled";
883
1129
  if (status === 200) return "Connected";
884
- if (status === -1 || toFlag(pjsip.enabled) === "0") return "Disabled";
885
1130
  if (status === 0 || status === 100) return "Connecting";
886
1131
  if ([401, 403, 408, 503].includes(status)) return "Failed";
887
1132
  return "Not connected";
@@ -911,13 +1156,14 @@ function openSipDialog(row: IntercomRow) {
911
1156
  sipForm.serverIp = row.serverAddress === "N/A" ? "" : row.serverAddress;
912
1157
  sipForm.serverPort = row.serverPort === "N/A" ? "5060" : row.serverPort;
913
1158
  sipForm.initialRtpPort = row.initialRtpPort === "N/A" ? "10000" : row.initialRtpPort;
914
- sipForm.finalRtpPort = row.finalRtpPort === "N/A" ? "1000" : row.finalRtpPort;
1159
+ sipForm.finalRtpPort = row.finalRtpPort === "N/A" ? "10100" : row.finalRtpPort;
915
1160
  sipForm.numericExtension = row.numericExtension;
916
1161
  sipForm.extension = row.extension === "N/A" ? "" : row.extension;
917
1162
  sipForm.login = row.login === "N/A" ? "" : row.login;
918
1163
  sipForm.password = row.password;
919
1164
  sipForm.operatingMode = row.operatingMode === "Peer-to-Peer" ? "peer-to-peer" : "sip-client";
920
1165
  sipForm.protocol = row.protocol || "UDP";
1166
+ sipForm.videoEnabled = row.videoEnabled;
921
1167
  sipDialog.value = true;
922
1168
  }
923
1169
 
@@ -943,12 +1189,140 @@ function openDialCodeDialog(row: IntercomRow) {
943
1189
  dialCodeDialog.value = true;
944
1190
  }
945
1191
 
946
- function openMakeCallDialog(row: IntercomRow) {
1192
+ async function openMakeCallDialog(row: IntercomRow) {
947
1193
  selectedRow.value = row;
948
- makeCallForm.target = row.extension !== "N/A" ? row.extension : "";
1194
+ makeCallForm.target = sipAccount.value?.extension || "";
949
1195
  makeCallForm.status = "";
950
1196
  makeCallForm.statusType = "info";
951
1197
  makeCallDialog.value = true;
1198
+ if (!sipAccount.value && !sipAccountLoading.value) {
1199
+ await loadSipAccount();
1200
+ if (makeCallDialog.value && !makeCallForm.target) {
1201
+ makeCallForm.target = sipAccount.value?.extension || "";
1202
+ }
1203
+ }
1204
+ }
1205
+
1206
+ function openWebPhoneDialog(row?: IntercomRow) {
1207
+ const activeRow = row || selectedRow.value || intercomRows.value[0];
1208
+ if (activeRow) {
1209
+ selectedRow.value = activeRow;
1210
+ if (!webPhone.isRegistered.value && activeRow.extension !== "N/A") {
1211
+ webPhoneForm.destination = activeRow.extension;
1212
+ webPhoneForm.video = activeRow.videoEnabled;
1213
+ }
1214
+ }
1215
+ webPhoneDialog.value = true;
1216
+ if (!sipAccount.value && !sipAccountLoading.value) void loadSipAccount();
1217
+ }
1218
+
1219
+ async function loadSipAccount() {
1220
+ if (!props.org || !props.site) return;
1221
+
1222
+ sipAccountLoading.value = true;
1223
+ sipAccountError.value = "";
1224
+ try {
1225
+ const response = await ensureSipAccount(props.site, props.org);
1226
+ sipAccount.value = normalizeSipAccount(response);
1227
+ } catch (error: unknown) {
1228
+ sipAccount.value = null;
1229
+ sipAccountError.value = getErrorMessage(error);
1230
+ } finally {
1231
+ sipAccountLoading.value = false;
1232
+ }
1233
+ }
1234
+
1235
+ async function connectWebPhone() {
1236
+ const account = sipAccount.value;
1237
+ if (!account) {
1238
+ await loadSipAccount();
1239
+ if (!sipAccount.value) return;
1240
+ }
1241
+
1242
+ const activeAccount = sipAccount.value;
1243
+ if (!activeAccount) return;
1244
+
1245
+ webPhoneConnecting.value = true;
1246
+ try {
1247
+ await webPhone.connect({
1248
+ webSocketServer: activeAccount.websocketUrl,
1249
+ aor: activeAccount.sipAddress,
1250
+ authorizationUsername: activeAccount.username,
1251
+ authorizationPassword: activeAccount.password,
1252
+ displayName: "iService365 Web",
1253
+ video: webPhoneForm.video,
1254
+ }, {
1255
+ remoteVideo: remoteVideoEl.value,
1256
+ remoteAudio: remoteAudioEl.value,
1257
+ });
1258
+ showMessage("Web phone connected and ready to receive calls.", "success");
1259
+ } catch (error: unknown) {
1260
+ showMessage(getErrorMessage(error), "error");
1261
+ } finally {
1262
+ webPhoneConnecting.value = false;
1263
+ }
1264
+ }
1265
+
1266
+ async function disconnectWebPhone() {
1267
+ webPhoneConnecting.value = true;
1268
+ try {
1269
+ await webPhone.disconnect();
1270
+ showMessage("Web phone disconnected.", "info");
1271
+ } finally {
1272
+ webPhoneConnecting.value = false;
1273
+ }
1274
+ }
1275
+
1276
+ async function startWebCall() {
1277
+ webPhoneActionLoading.value = true;
1278
+ try {
1279
+ await webPhone.call(webPhoneForm.destination);
1280
+ } catch (error: unknown) {
1281
+ showMessage(getErrorMessage(error), "error");
1282
+ } finally {
1283
+ webPhoneActionLoading.value = false;
1284
+ }
1285
+ }
1286
+
1287
+ async function answerWebCall() {
1288
+ webPhoneActionLoading.value = true;
1289
+ try {
1290
+ await webPhone.answer();
1291
+ } catch (error: unknown) {
1292
+ showMessage(getErrorMessage(error), "error");
1293
+ } finally {
1294
+ webPhoneActionLoading.value = false;
1295
+ }
1296
+ }
1297
+
1298
+ async function declineWebCall() {
1299
+ webPhoneActionLoading.value = true;
1300
+ try {
1301
+ await webPhone.decline();
1302
+ } catch (error: unknown) {
1303
+ showMessage(getErrorMessage(error), "error");
1304
+ } finally {
1305
+ webPhoneActionLoading.value = false;
1306
+ }
1307
+ }
1308
+
1309
+ async function hangupWebCall() {
1310
+ webPhoneActionLoading.value = true;
1311
+ try {
1312
+ await webPhone.hangup();
1313
+ } catch (error: unknown) {
1314
+ showMessage(getErrorMessage(error), "error");
1315
+ } finally {
1316
+ webPhoneActionLoading.value = false;
1317
+ }
1318
+ }
1319
+
1320
+ function toggleWebMute() {
1321
+ try {
1322
+ webPhone.toggleMute();
1323
+ } catch (error: unknown) {
1324
+ showMessage(getErrorMessage(error), "error");
1325
+ }
952
1326
  }
953
1327
 
954
1328
  function openContactDialog(contact?: ContactRow) {
@@ -974,21 +1348,35 @@ function openDeleteContact(contact: ContactRow) {
974
1348
  async function saveSipSettings() {
975
1349
  if (!selectedRow.value) return;
976
1350
  const isPeerToPeer = sipForm.operatingMode === "peer-to-peer";
1351
+ const initialRtpPort = Number(sipForm.initialRtpPort);
1352
+ const finalRtpPort = Number(sipForm.finalRtpPort);
1353
+
1354
+ if (
1355
+ !Number.isInteger(initialRtpPort) ||
1356
+ !Number.isInteger(finalRtpPort) ||
1357
+ initialRtpPort < 1 ||
1358
+ finalRtpPort > 65535 ||
1359
+ finalRtpPort <= initialRtpPort
1360
+ ) {
1361
+ showMessage("Final RTP Port must be greater than Initial RTP Port and both ports must be between 1 and 65535.", "warning");
1362
+ return;
1363
+ }
1364
+
977
1365
  const saved = await savePjsip(selectedRow.value.reader._id, {
978
1366
  enabled: sipForm.enabled ? "1" : "0",
979
1367
  server_ip: sipForm.serverIp,
980
1368
  server_port: sipForm.serverPort,
981
- server_outbound_port: sipForm.initialRtpPort,
982
- server_outbound_port_range: sipForm.finalRtpPort,
1369
+ server_outbound_port: String(initialRtpPort),
1370
+ server_outbound_port_range: String(finalRtpPort - initialRtpPort),
983
1371
  numeric_branch_enabled: sipForm.numericExtension ? "1" : "0",
984
1372
  branch: isPeerToPeer ? "" : sipForm.extension,
985
1373
  login: isPeerToPeer ? "" : sipForm.login,
986
1374
  password: isPeerToPeer ? "" : sipForm.password,
987
1375
  peer_to_peer_enabled: isPeerToPeer ? "1" : "0",
988
- }, { showSuccessMessage: false });
1376
+ video_enabled: sipForm.videoEnabled ? "1" : "0",
1377
+ });
989
1378
  if (saved) {
990
1379
  sipDialog.value = false;
991
- rebootConfirmDialog.value = true;
992
1380
  }
993
1381
  }
994
1382
 
@@ -1020,7 +1408,13 @@ async function runMakeCall() {
1020
1408
  if (!selectedRow.value) return;
1021
1409
  const target = makeCallForm.target.trim();
1022
1410
  if (!target) {
1023
- makeCallForm.status = "Please enter an address to dial.";
1411
+ makeCallForm.status = "Please enter an extension, SIP address, or peer IP.";
1412
+ makeCallForm.statusType = "warning";
1413
+ return;
1414
+ }
1415
+
1416
+ if (!isSipCallTarget(target)) {
1417
+ makeCallForm.status = "Enter a valid extension, SIP address, or IPv4 address.";
1024
1418
  makeCallForm.statusType = "warning";
1025
1419
  return;
1026
1420
  }
@@ -1042,6 +1436,10 @@ async function runMakeCall() {
1042
1436
  }
1043
1437
  }
1044
1438
 
1439
+ function isSipCallTarget(value = "") {
1440
+ return /^(?:sip:)?[A-Za-z0-9_.+-]+(?:@[A-Za-z0-9.-]+(?::\d{1,5})?)?$/.test(value.trim());
1441
+ }
1442
+
1045
1443
  async function runHangupCall() {
1046
1444
  if (!selectedRow.value) return;
1047
1445
  hangingUp.value = true;
@@ -1078,30 +1476,6 @@ async function savePjsip(readerId: string, payload: Record<string, string>, opti
1078
1476
  }
1079
1477
  }
1080
1478
 
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
1479
  async function saveContact() {
1106
1480
  if (!contactForm.readerId || !contactForm.name.trim() || !contactForm.serverAddress.trim()) {
1107
1481
  showMessage("Please complete the required contact fields.", "warning");
@@ -1182,8 +1556,42 @@ function showMessage(text: string, type: MessageType = "info") {
1182
1556
  message.type = type;
1183
1557
  }
1184
1558
 
1185
- function getErrorMessage(error: any) {
1186
- return error?.data?.message || error?.response?._data?.message || error?.message || "HID intercom request failed.";
1559
+ function normalizeSipAccount(response: unknown): SipAccount {
1560
+ const responseRecord = toRecord(response);
1561
+ const payload = toRecord(responseRecord.data || responseRecord);
1562
+ const requiredFields = ["extension", "username", "password", "sipAddress", "websocketUrl"] as const;
1563
+
1564
+ if (requiredFields.some((field) => !toText(payload[field]))) {
1565
+ throw new Error("The SIP account response is incomplete. Please try again.");
1566
+ }
1567
+
1568
+ return {
1569
+ orgId: toText(payload.orgId, props.org),
1570
+ site: toText(payload.site, props.site),
1571
+ userId: toText(payload.userId),
1572
+ extension: toText(payload.extension),
1573
+ username: toText(payload.username),
1574
+ password: toText(payload.password),
1575
+ sipAddress: toText(payload.sipAddress),
1576
+ websocketUrl: toText(payload.websocketUrl),
1577
+ status: "active",
1578
+ provisionedAt: toText(payload.provisionedAt),
1579
+ };
1580
+ }
1581
+
1582
+ function getErrorMessage(error: unknown) {
1583
+ const errorRecord = toRecord(error);
1584
+ const data = toRecord(errorRecord.data);
1585
+ const response = toRecord(errorRecord.response);
1586
+ const responseData = toRecord(response._data);
1587
+ return toText(data.message)
1588
+ || toText(responseData.message)
1589
+ || toText(errorRecord.message)
1590
+ || "HID intercom request failed.";
1591
+ }
1592
+
1593
+ function toRecord(value: unknown): Record<string, unknown> {
1594
+ return typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
1187
1595
  }
1188
1596
 
1189
1597
  function toText(value: unknown, fallback = "") {
@@ -1259,6 +1667,23 @@ export default {
1259
1667
  width: 160px;
1260
1668
  }
1261
1669
 
1670
+ .web-call-button {
1671
+ min-width: 160px;
1672
+ background: #e5e7eb;
1673
+ color: #0b2c40;
1674
+ font-size: 12px;
1675
+ }
1676
+
1677
+ .web-call-button.is-connected {
1678
+ background: #d9f2e1;
1679
+ color: #16743a;
1680
+ }
1681
+
1682
+ .web-call-button.has-incoming-call {
1683
+ background: #1769aa;
1684
+ color: #fff;
1685
+ }
1686
+
1262
1687
  .add-button {
1263
1688
  min-width: 160px;
1264
1689
  background: #dedede;
@@ -1468,6 +1893,20 @@ export default {
1468
1893
  margin-bottom: 12px;
1469
1894
  }
1470
1895
 
1896
+ .sip-section-switch > div {
1897
+ display: flex;
1898
+ min-width: 0;
1899
+ padding: 8px 12px 8px 0;
1900
+ flex-direction: column;
1901
+ gap: 2px;
1902
+ }
1903
+
1904
+ .sip-section-switch small {
1905
+ color: #667085;
1906
+ font-size: 10px;
1907
+ line-height: 1.4;
1908
+ }
1909
+
1471
1910
  .sip-switch-row :deep(.v-switch) {
1472
1911
  flex: 0 0 auto;
1473
1912
  }
@@ -1630,98 +2069,6 @@ export default {
1630
2069
  overflow: hidden;
1631
2070
  }
1632
2071
 
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
2072
  .call-dialog .dialog-body {
1726
2073
  padding: 18px 24px 24px;
1727
2074
  }
@@ -1931,6 +2278,274 @@ export default {
1931
2278
  line-height: 1.45;
1932
2279
  }
1933
2280
 
2281
+ .web-phone-dialog {
2282
+ display: flex;
2283
+ width: 820px;
2284
+ max-height: min(90vh, 760px);
2285
+ max-width: 94vw;
2286
+ overflow: hidden;
2287
+ background: #fff;
2288
+ flex-direction: column;
2289
+ }
2290
+
2291
+ .web-phone-title {
2292
+ flex: 0 0 auto;
2293
+ display: flex;
2294
+ align-items: center;
2295
+ justify-content: space-between;
2296
+ gap: 16px;
2297
+ }
2298
+
2299
+ .web-phone-title-actions {
2300
+ display: flex;
2301
+ align-items: center;
2302
+ gap: 8px;
2303
+ }
2304
+
2305
+ .web-phone-status {
2306
+ min-width: 92px;
2307
+ justify-content: center;
2308
+ font-size: 10px;
2309
+ }
2310
+
2311
+ .web-phone-status.ready {
2312
+ background: #d9f2e1;
2313
+ color: #16743a;
2314
+ }
2315
+
2316
+ .web-phone-status.incoming {
2317
+ background: #dcecff;
2318
+ color: #125c9a;
2319
+ }
2320
+
2321
+ .web-phone-status.failed {
2322
+ background: #fce2e2;
2323
+ color: #a92222;
2324
+ }
2325
+
2326
+ .web-phone-status.idle {
2327
+ background: #e5e7eb;
2328
+ color: #5d6673;
2329
+ }
2330
+
2331
+ .web-phone-body {
2332
+ display: grid;
2333
+ grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.1fr);
2334
+ gap: 24px;
2335
+ min-height: 440px;
2336
+ overflow: auto;
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
+ .web-phone-media.has-remote-video .audio-call-state {
2466
+ display: none;
2467
+ }
2468
+
2469
+ .audio-call-state {
2470
+ display: flex;
2471
+ align-items: center;
2472
+ justify-content: center;
2473
+ padding: 28px;
2474
+ color: #fff;
2475
+ flex-direction: column;
2476
+ gap: 10px;
2477
+ text-align: center;
2478
+ }
2479
+
2480
+ .audio-call-state strong {
2481
+ font-size: 16px;
2482
+ font-weight: 600;
2483
+ }
2484
+
2485
+ .audio-call-state span {
2486
+ max-width: 100%;
2487
+ overflow: hidden;
2488
+ color: #c9d7e1;
2489
+ font-size: 11px;
2490
+ text-overflow: ellipsis;
2491
+ white-space: nowrap;
2492
+ }
2493
+
2494
+ .web-phone-dial-row {
2495
+ display: grid;
2496
+ grid-template-columns: minmax(0, 1fr) 42px;
2497
+ gap: 8px;
2498
+ align-items: start;
2499
+ }
2500
+
2501
+ .start-web-call,
2502
+ .hangup-web-call {
2503
+ width: 42px;
2504
+ height: 38px;
2505
+ border-radius: 4px;
2506
+ background: #2a9b50;
2507
+ color: #fff;
2508
+ }
2509
+
2510
+ .hangup-web-call,
2511
+ .reject-call {
2512
+ background: #c9293d;
2513
+ }
2514
+
2515
+ .incoming-call-actions {
2516
+ display: grid;
2517
+ grid-template-columns: 1fr 1fr;
2518
+ gap: 10px;
2519
+ margin-top: 14px;
2520
+ }
2521
+
2522
+ .answer-call,
2523
+ .reject-call {
2524
+ min-height: 42px;
2525
+ color: #fff;
2526
+ }
2527
+
2528
+ .answer-call {
2529
+ background: #2a9b50;
2530
+ }
2531
+
2532
+ .active-call-controls {
2533
+ display: flex;
2534
+ justify-content: center;
2535
+ gap: 12px;
2536
+ margin-top: 16px;
2537
+ }
2538
+
2539
+ .active-call-controls .v-btn {
2540
+ width: 46px;
2541
+ height: 46px;
2542
+ }
2543
+
2544
+ .web-phone-error {
2545
+ margin-top: 14px;
2546
+ font-size: 11px;
2547
+ }
2548
+
1934
2549
  .call-two-col {
1935
2550
  gap: 12px;
1936
2551
  }
@@ -2064,13 +2679,21 @@ export default {
2064
2679
  max-width: none;
2065
2680
  }
2066
2681
 
2067
- .rebooting-card {
2068
- grid-template-columns: 1fr;
2069
- padding: 28px;
2682
+ .web-call-button {
2683
+ width: 100%;
2684
+ }
2685
+
2686
+ .web-phone-body {
2687
+ grid-template-columns: minmax(0, 1fr);
2688
+ min-height: 0;
2070
2689
  }
2071
2690
 
2072
- .wait-word {
2073
- font-size: 80px;
2691
+ .web-phone-settings {
2692
+ padding-right: 0;
2693
+ padding-bottom: 20px;
2694
+ border-right: 0;
2695
+ border-bottom: 1px solid #e5e7eb;
2074
2696
  }
2697
+
2075
2698
  }
2076
2699
  </style>