@7365admin1/layer-common 3.2.7 → 3.2.8-staging.201

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 (283) hide show
  1. package/assets/css/primitives.css +1113 -0
  2. package/assets/css/screens.css +1097 -0
  3. package/assets/css/shell.css +258 -0
  4. package/assets/css/tokens.css +236 -0
  5. package/components/AcceptDialog.vue +17 -18
  6. package/components/AccessCard/AvailableStats.vue +24 -16
  7. package/components/AccessCardAddForm.vue +42 -42
  8. package/components/AccessCardAssignToUnitForm.vue +13 -21
  9. package/components/AccessCardDeleteDialog.vue +41 -39
  10. package/components/AccessCardDetailsDialog.vue +125 -79
  11. package/components/AccessCardPreviewDialog.vue +58 -84
  12. package/components/AccessCardQrTagging.vue +60 -63
  13. package/components/AccessCardReplaceForm.vue +27 -23
  14. package/components/AccessManagement.vue +86 -85
  15. package/components/AddEqupmentForm.vue +18 -22
  16. package/components/AddPassKeyToVisitor.vue +95 -73
  17. package/components/AppButton.vue +62 -0
  18. package/components/AppCard.vue +20 -0
  19. package/components/AppDateField.vue +49 -0
  20. package/components/AppField.vue +76 -0
  21. package/components/AppKpiTile.vue +65 -0
  22. package/components/AppSegmented.vue +38 -0
  23. package/components/AppSelect.vue +247 -0
  24. package/components/AreaChecklistHistoryLogs.vue +51 -44
  25. package/components/AreaChecklistHistoryMain.vue +14 -7
  26. package/components/AreaFormDialog.vue +71 -59
  27. package/components/AreaMain.vue +73 -107
  28. package/components/AttachmentsViewer.vue +84 -22
  29. package/components/AttendanceCheckInOutDialog.vue +85 -88
  30. package/components/AttendanceDetailsDialog.vue +57 -63
  31. package/components/AttendanceMain.vue +4 -3
  32. package/components/AttendanceMapSearchDialog.vue +42 -44
  33. package/components/AttendanceSettingsDialog.vue +79 -63
  34. package/components/Avatar/Main.vue +54 -2
  35. package/components/BillingMain.vue +26 -30
  36. package/components/BtnUploadFile.vue +53 -11
  37. package/components/BuildingForm.vue +34 -27
  38. package/components/BuildingManagement/buildings.vue +39 -39
  39. package/components/BuildingManagement/units.vue +26 -42
  40. package/components/BuildingUnitFormAdd.vue +20 -23
  41. package/components/BuildingUnitFormEdit.vue +8 -4
  42. package/components/BulletinBoardForm.vue +19 -24
  43. package/components/BulletinBoardManagement.vue +49 -57
  44. package/components/BulletinBoardView.vue +9 -6
  45. package/components/BulletinExpirationChip.vue +13 -4
  46. package/components/Button/Close.vue +2 -2
  47. package/components/CameraForm.vue +21 -22
  48. package/components/CameraMain.vue +50 -49
  49. package/components/Card/DeleteConfirmation.vue +24 -22
  50. package/components/Card/MemberInfoSummary.vue +28 -3
  51. package/components/Card/Toggle.vue +15 -3
  52. package/components/CardToolbar.vue +37 -0
  53. package/components/Carousel.vue +56 -36
  54. package/components/Chat/Bubbles.vue +43 -14
  55. package/components/Chat/Information.vue +49 -32
  56. package/components/Chat/ListCard.vue +38 -9
  57. package/components/Chat/Message.vue +41 -15
  58. package/components/Chat/Navigation.vue +27 -15
  59. package/components/Chat/SkeletonLoader.vue +9 -2
  60. package/components/ChecklistItemRow.vue +25 -1
  61. package/components/CleaningScheduleMain.vue +44 -49
  62. package/components/ClientDetailForm.vue +81 -56
  63. package/components/ClientMain.vue +92 -82
  64. package/components/ConfirmDialog.vue +14 -8
  65. package/components/Container/Standard.vue +9 -7
  66. package/components/CreateSubsciptionPlan.vue +148 -89
  67. package/components/DashboardEmptyState.vue +48 -3
  68. package/components/DashboardMain.vue +924 -676
  69. package/components/DashboardPanel.vue +84 -21
  70. package/components/DeliveryCompany.vue +34 -20
  71. package/components/Dialog/DeleteConfirmation.vue +20 -21
  72. package/components/Dialog/ReplaceAutofillPrompt.vue +19 -26
  73. package/components/Dialog/ReusablePrompt.vue +30 -29
  74. package/components/Dialog/UpdateMoreAction.vue +62 -59
  75. package/components/DocumentForm.vue +14 -21
  76. package/components/DocumentManagement.vue +162 -108
  77. package/components/DocumentViewer.vue +29 -12
  78. package/components/DrawImage.vue +29 -21
  79. package/components/EntryPass/QrTemplatePreview.vue +27 -16
  80. package/components/EntryPassInformation.vue +40 -10
  81. package/components/EntryPassMain.vue +49 -27
  82. package/components/EquipmentItemMain.vue +84 -97
  83. package/components/EquipmentManagementMain.vue +81 -103
  84. package/components/Facility/BookingSetup.vue +43 -52
  85. package/components/Feedback/Form.vue +26 -12
  86. package/components/FeedbackMain.vue +35 -49
  87. package/components/FileInput.vue +75 -58
  88. package/components/FileInputWithList.vue +59 -12
  89. package/components/FormDialog.vue +60 -21
  90. package/components/HidAccessLogDashboard.vue +512 -490
  91. package/components/HidCameraCaptureDialog.vue +216 -0
  92. package/components/HidEnabledGate.vue +67 -9
  93. package/components/HidIntercomManagement.vue +1322 -653
  94. package/components/HidQrCodeConfiguration.vue +1030 -258
  95. package/components/HidReaderForm.vue +142 -59
  96. package/components/HidReaderManagement.vue +211 -323
  97. package/components/HidServiceSettingsPanel.vue +44 -13
  98. package/components/HidUserEnrollment.vue +1185 -412
  99. package/components/HygieneUpdateMoreAction.vue +44 -44
  100. package/components/IncidentReport/Authorities.vue +30 -18
  101. package/components/IncidentReport/IncidentInformation.vue +10 -10
  102. package/components/IncidentReport/IncidentInformationDownload.vue +3 -4
  103. package/components/IncidentReport/affectedEntities.vue +41 -20
  104. package/components/Input/Date.vue +3 -0
  105. package/components/Input/DatePicker.vue +1 -2
  106. package/components/Input/DateTimePicker.vue +1 -1
  107. package/components/Input/File.vue +55 -10
  108. package/components/Input/FileV2.vue +39 -6
  109. package/components/Input/InputPhoneNumberV2.vue +2 -1
  110. package/components/Input/ListGroupSelection.vue +56 -8
  111. package/components/Input/NRICNumber.vue +1 -0
  112. package/components/Input/NewDate.vue +1 -0
  113. package/components/Input/Number.vue +1 -0
  114. package/components/Input/Password.vue +1 -0
  115. package/components/Input/PhoneNumber.vue +21 -1
  116. package/components/Input/VehicleNumber.vue +1 -0
  117. package/components/InputLabel.vue +2 -2
  118. package/components/InvitationClientForm.vue +21 -19
  119. package/components/InvitationForm.vue +15 -21
  120. package/components/InvitationMain.vue +46 -61
  121. package/components/Layout/Header.vue +263 -36
  122. package/components/Layout/NavigationDrawer.vue +300 -30
  123. package/components/ListItem.vue +33 -3
  124. package/components/LocalPagination.vue +27 -3
  125. package/components/ManageChecklistMain.vue +136 -86
  126. package/components/MemberInformation.vue +32 -16
  127. package/components/MemberMain.vue +113 -93
  128. package/components/MyAttendanceMain.vue +3 -5
  129. package/components/NavigationItem.vue +83 -3
  130. package/components/Nfc/NFCPatrolReportMain.vue +47 -33
  131. package/components/Nfc/NFCPatrolRouteForm.vue +18 -20
  132. package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
  133. package/components/Nfc/NFCPatrolSettings.vue +14 -4
  134. package/components/Nfc/NFCTagForm.vue +22 -8
  135. package/components/Nfc/NFCTagMain.vue +41 -43
  136. package/components/Nfc/PatrolReport/DailyReportTab.vue +8 -1
  137. package/components/Nfc/PatrolReport/MonthlyReportTab.vue +8 -1
  138. package/components/Nfc/PatrolReport/MonthlyReportTable.vue +5 -3
  139. package/components/Nfc/PatrolReport/PatrolActivityTable.vue +26 -6
  140. package/components/Nfc/PatrolReport/PatrolReportTab.vue +8 -0
  141. package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +41 -9
  142. package/components/Nfc/PatrolReport/RemarksDialog.vue +39 -9
  143. package/components/Nfc/PatrolReport/ReportEmptyState.vue +29 -9
  144. package/components/Nfc/PatrolReport/ReportFilters.vue +19 -10
  145. package/components/Nfc/PatrolReport/ReportLocationHeader.vue +32 -6
  146. package/components/Nfc/PatrolReport/SummaryReportTable.vue +28 -6
  147. package/components/NotificationSettings.vue +373 -0
  148. package/components/NumberSettingField.vue +9 -2
  149. package/components/OnlineFormConfigurationForm.vue +725 -237
  150. package/components/OnlineFormFill.vue +213 -106
  151. package/components/OnlineFormRenderPreview.vue +25 -30
  152. package/components/OnlineFormsConfiguration.vue +113 -109
  153. package/components/OrgViewDialog.vue +34 -16
  154. package/components/OvernightParkingAvailability.vue +49 -26
  155. package/components/OvernightParkingManagement.vue +78 -18
  156. package/components/PageHeader.vue +43 -0
  157. package/components/PassInformation.vue +109 -53
  158. package/components/PasswordConfirmation.vue +44 -26
  159. package/components/PeopleForm.vue +41 -20
  160. package/components/PhotoUpload.vue +110 -48
  161. package/components/PlaceholderComponent.vue +35 -19
  162. package/components/PlanActionsMenu.vue +12 -2
  163. package/components/PlanStatusBadge.vue +7 -8
  164. package/components/PlateNumberDisplay.vue +9 -6
  165. package/components/QrTemplate/PrintDialog.vue +66 -43
  166. package/components/RolePermissionFormCreate.vue +104 -79
  167. package/components/RolePermissionFormPreviewUpdate.vue +111 -109
  168. package/components/RolePermissionMain.vue +183 -81
  169. package/components/ScanVisitorQRCode.vue +99 -35
  170. package/components/ScheduleAreaMain.vue +34 -59
  171. package/components/ScheduleTaskForm.vue +61 -42
  172. package/components/ScheduleTaskMain.vue +56 -69
  173. package/components/SearchVehicleNumberUser.vue +39 -37
  174. package/components/ServiceProviderFormCreate.vue +29 -20
  175. package/components/ServiceProviderInvitationPrompt.vue +155 -0
  176. package/components/ServiceProviderMain.vue +601 -432
  177. package/components/Signature.vue +40 -34
  178. package/components/SignaturePad.vue +5 -29
  179. package/components/SiteSettings.vue +41 -11
  180. package/components/SiteSettingsWorkOrder.vue +61 -30
  181. package/components/SlideCardGroup.vue +35 -11
  182. package/components/Snackbar.vue +36 -3
  183. package/components/SpecificAttr.vue +18 -8
  184. package/components/StatusChip.vue +156 -0
  185. package/components/StockCard.vue +17 -12
  186. package/components/SubscriptionPlanTable.vue +207 -140
  187. package/components/SwitchContext.vue +201 -9
  188. package/components/TableHygiene.vue +36 -46
  189. package/components/TableList.vue +10 -10
  190. package/components/TableListSecondary.vue +105 -54
  191. package/components/TableMain.vue +251 -78
  192. package/components/TableV3.vue +61 -70
  193. package/components/TiptapEditor.vue +33 -40
  194. package/components/TiptapEditorQr.vue +5 -2
  195. package/components/Tooltip/Info.vue +69 -24
  196. package/components/UnitMain.vue +40 -60
  197. package/components/UnitPersonCard.vue +42 -13
  198. package/components/VehicleAddSelection.vue +48 -32
  199. package/components/VehicleForm.vue +112 -129
  200. package/components/VehicleManagement.vue +26 -12
  201. package/components/VehicleUpdateMoreAction.vue +59 -46
  202. package/components/VisitorDataFromScannedQRCodeForm.vue +90 -65
  203. package/components/VisitorForm.vue +774 -210
  204. package/components/VisitorFormSelection.vue +40 -37
  205. package/components/VisitorManagement.vue +239 -82
  206. package/components/VisitorPassKeyQRScanner.vue +65 -18
  207. package/components/VisitorSearchResultSummary.vue +60 -16
  208. package/components/VisitorSocketPopUp.vue +22 -14
  209. package/components/VisitorsReportPreview.vue +44 -29
  210. package/components/WorkOrder/Create.vue +26 -17
  211. package/components/WorkOrder/ListView.vue +38 -7
  212. package/components/WorkOrder/Main.vue +62 -59
  213. package/composables/useFeedback.ts +3 -3
  214. package/composables/useHidAmico.ts +269 -0
  215. package/composables/useHidNavigation.ts +2 -9
  216. package/composables/useMember.ts +3 -3
  217. package/composables/useNFCPatrolDailyReport.ts +13 -0
  218. package/composables/useNFCPatrolMonthlyReport.ts +22 -56
  219. package/composables/useNFCPatrolReport.ts +13 -0
  220. package/composables/useNotificationPreference.ts +87 -0
  221. package/composables/useOnlineFormPages.ts +2 -0
  222. package/composables/useRole.ts +22 -0
  223. package/composables/useServiceProvider.ts +3 -3
  224. package/composables/useServiceProviderInvitation.ts +145 -0
  225. package/composables/useSipWebPhone.ts +336 -0
  226. package/composables/useSubscriptionPlan.ts +62 -0
  227. package/composables/useThemePreference.ts +63 -0
  228. package/composables/useVisitor.ts +6 -1
  229. package/composables/useWebUsb.ts +127 -37
  230. package/composables/useWorkOrder.ts +17 -3
  231. package/error.vue +30 -6
  232. package/nuxt.config.ts +36 -0
  233. package/package.json +25 -4
  234. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +2 -2
  235. package/pages/[org]/[site]/access-mgmt/{identity-mapping → hid-cards}/index.vue +2 -2
  236. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  237. package/pages/notification-settings.vue +19 -0
  238. package/pages/payment-method-linked.vue +34 -10
  239. package/pages/require-customer.vue +48 -10
  240. package/pages/require-organization-membership.vue +32 -8
  241. package/pages/unauthorized.vue +29 -4
  242. package/plugins/secure-member.client.ts +21 -56
  243. package/plugins/vuetify.ts +69 -2
  244. package/types/nfc-patrol-report.ts +3 -2
  245. package/types/site.d.ts +71 -0
  246. package/types/subscription.ts +18 -11
  247. package/utils/breadcrumb.test.ts +108 -0
  248. package/utils/breadcrumb.ts +82 -0
  249. package/utils/dashboard.test.ts +118 -0
  250. package/utils/dashboard.ts +53 -0
  251. package/utils/data.test.ts +95 -0
  252. package/utils/data.ts +40 -2
  253. package/utils/hid-discover.test.ts +102 -0
  254. package/utils/list-page.test.ts +60 -0
  255. package/utils/list-page.ts +38 -0
  256. package/utils/materialSymbols.ts +363 -0
  257. package/utils/nav-sections.test.ts +190 -0
  258. package/utils/nav-sections.ts +151 -0
  259. package/utils/nfc-patrol-report.test.ts +130 -0
  260. package/utils/nfc-patrol-report.ts +101 -0
  261. package/utils/role.test.ts +69 -0
  262. package/utils/role.ts +10 -0
  263. package/utils/route-name.test.ts +85 -0
  264. package/utils/route-name.ts +24 -0
  265. package/utils/status.test.ts +110 -0
  266. package/utils/status.ts +135 -0
  267. package/utils/table-card.test.ts +84 -0
  268. package/utils/theme-aa-ledger.ts +148 -0
  269. package/utils/theme.test.ts +485 -7
  270. package/utils/theme.ts +295 -110
  271. package/.changeset/README.md +0 -8
  272. package/.changeset/config.json +0 -11
  273. package/.editorconfig +0 -12
  274. package/.github/workflows/main.yml +0 -17
  275. package/.github/workflows/publish-staging.yml +0 -47
  276. package/.github/workflows/publish.yml +0 -39
  277. package/PUBLISHING.md +0 -269
  278. package/components/HidIdentityMapping.vue +0 -975
  279. package/components/ScheduleTaskAreaFormDialog.vue +0 -144
  280. package/components/ScheduleTaskAreaUpdateMoreAction.vue +0 -109
  281. package/components/ScheduleTastTicketMain.vue +0 -182
  282. package/components/TableWithButton.vue +0 -94
  283. package/test/visitor-socket.test.mjs +0 -36
@@ -1,40 +1,26 @@
1
+ <!--
2
+ HID INTERCOM MANAGEMENT - the readers' door intercoms and their contact list.
3
+
4
+ The largest of the six panels, and it was a private copy of the standard
5
+ table: an above-card toolbar, a `v-tabs` strip, a raw `v-table` with TWO
6
+ column sets, a refresh strip with a pager, a SECOND pager in the footer, an
7
+ empty state and its own chip palette - in about a hundred and forty literal
8
+ hexes. Those literals are why it had no dark theme: `#1f2937` on the dark
9
+ card measures 1.13:1, `white on #a7a7a7` 2.41:1 and the "Intercom" tab label
10
+ 2.54:1.
11
+
12
+ It is the design's standard table screen with tabs, so it is drawn with the
13
+ shared one: `TableMain` supplies the heading, the card, the in-card toolbar,
14
+ the `app-tab` strip, the header row, the rows and ONE pager. The two column
15
+ sets stay two column sets - `tableHeaders` and `visibleRows` switch on the
16
+ active tab exactly as the two `<thead>` blocks did.
17
+
18
+ Every dialog, handler, SIP call and web-phone control is unchanged. Only the
19
+ chrome and the colours move; no call, connect, hangup or device path is
20
+ touched.
21
+ -->
1
22
  <template>
2
23
  <section class="hid-intercom-management">
3
- <div class="top-bar">
4
- <v-btn
5
- v-if="activeTab === 'contacts'"
6
- rounded="xl"
7
- class="text-none add-button"
8
- variant="flat"
9
- prepend-icon="mdi-plus"
10
- @click="openContactDialog()"
11
- >
12
- Add Contacts
13
- </v-btn>
14
-
15
- <div v-else class="top-placeholder" />
16
-
17
- <div class="toolbar-spacer" />
18
-
19
- <v-text-field
20
- v-model="search"
21
- placeholder="Search"
22
- variant="outlined"
23
- density="compact"
24
- hide-details
25
- class="search-input"
26
- />
27
-
28
- <v-select
29
- v-model="statusFilter"
30
- :items="statusOptions"
31
- variant="outlined"
32
- density="compact"
33
- hide-details
34
- class="status-input"
35
- />
36
- </div>
37
-
38
24
  <v-alert
39
25
  v-if="message.text"
40
26
  :type="message.type"
@@ -45,108 +31,153 @@
45
31
  {{ message.text }}
46
32
  </v-alert>
47
33
 
48
- <v-card flat border class="table-card">
49
- <div class="table-refresh">
50
- <v-btn icon="mdi-refresh" variant="text" density="comfortable" :loading="loading" @click="reload" />
51
- <div class="toolbar-spacer" />
52
- <span>{{ pageRange }}</span>
53
- <v-btn icon="mdi-chevron-left" variant="text" density="compact" :disabled="page <= 1" @click="page--" />
54
- <v-btn icon="mdi-chevron-right" variant="text" density="compact" :disabled="page >= pages" @click="page++" />
55
- </div>
56
-
57
- <v-tabs v-model="activeTab" class="intercom-tabs" density="compact">
58
- <v-tab value="intercom" class="text-none">Intercom</v-tab>
59
- <v-tab value="contacts" class="text-none">Intercom Contacts</v-tab>
60
- </v-tabs>
61
-
62
- <div class="table-body">
63
- <v-table>
64
- <thead>
65
- <tr v-if="activeTab === 'intercom'">
66
- <th>Name</th>
67
- <th>Location</th>
68
- <th>SIP Server Address</th>
69
- <th>Operating Mode</th>
70
- <th>Dial Code</th>
71
- <th>SIP Status</th>
72
- <th class="text-right"></th>
73
- </tr>
74
- <tr v-else>
75
- <th>Name</th>
76
- <th>Location</th>
77
- <th>SIP Server Address</th>
78
- <th>Dial Code</th>
79
- <th class="text-right"></th>
80
- </tr>
81
- </thead>
82
- <tbody>
83
- <template v-if="activeTab === 'intercom'">
84
- <tr v-for="row in paginatedIntercomRows" :key="row.reader._id">
85
- <td>{{ row.name }}</td>
86
- <td>{{ row.location }}</td>
87
- <td>
88
- <span :class="{ 'link-text': row.serverAddress !== 'N/A' }">{{ row.serverAddress }}</span>
89
- </td>
90
- <td>{{ row.operatingMode }}</td>
91
- <td>{{ row.dialCode }}</td>
92
- <td>
93
- <v-chip size="small" variant="flat" :class="getStatusClass(row.status)">
94
- {{ row.status }}
95
- </v-chip>
96
- </td>
97
- <td class="text-right">
98
- <v-menu location="bottom end">
99
- <template #activator="{ props: menuProps }">
100
- <v-btn v-bind="menuProps" icon="mdi-dots-vertical" variant="text" density="comfortable" />
101
- </template>
102
- <v-list density="compact" class="row-menu">
103
- <v-list-item title="View" @click="openViewDialog(row)" />
104
- <v-list-item title="SIP Settings" @click="openSipDialog(row)" />
105
- <v-list-item title="Call Settings" @click="openCallDialog(row)" />
106
- <v-list-item title="Dial Code" @click="openDialCodeDialog(row)" />
107
- <v-list-item title="Make a call" @click="openMakeCallDialog(row)" />
108
- </v-list>
109
- </v-menu>
110
- </td>
111
- </tr>
112
- </template>
113
-
114
- <template v-else>
115
- <tr v-for="contact in paginatedContacts" :key="contact._rowKey">
116
- <td>{{ contact.name }}</td>
117
- <td>{{ contact.location }}</td>
118
- <td>
119
- <span :class="{ 'link-text': contact.serverAddress !== 'N/A' }">{{ contact.serverAddress }}</span>
120
- </td>
121
- <td>{{ contact.dialCode }}</td>
122
- <td class="text-right">
123
- <v-menu location="bottom end">
124
- <template #activator="{ props: menuProps }">
125
- <v-btn v-bind="menuProps" icon="mdi-dots-vertical" variant="text" density="comfortable" />
126
- </template>
127
- <v-list density="compact" class="row-menu">
128
- <v-list-item title="View" @click="openContactView(contact)" />
129
- <v-list-item title="Edit" @click="openContactDialog(contact)" />
130
- <v-list-item title="Delete" @click="openDeleteContact(contact)" />
131
- </v-list>
132
- </v-menu>
133
- </td>
134
- </tr>
135
- </template>
136
- </tbody>
137
- </v-table>
138
-
139
- <div v-if="!visibleRows.length" class="empty-state">
140
- No intercom records found.
34
+ <TableMain
35
+ title="Intercom"
36
+ :headers="tableHeaders"
37
+ :items="visiblePageRows"
38
+ :loading="loading"
39
+ :items-per-page="-1"
40
+ :page="page"
41
+ :pages="pages"
42
+ :page-range="pageRange"
43
+ item-value="_rowKey"
44
+ show-header
45
+ @refresh="reload"
46
+ @update:page="page = $event"
47
+ >
48
+ <!-- The action swaps with the tab, as it did in the old top bar. -->
49
+ <template #actions>
50
+ <AppButton
51
+ v-if="activeTab === 'contacts'"
52
+ icon="mdi-plus"
53
+ @click="openContactDialog()"
54
+ >
55
+ Add Contacts
56
+ </AppButton>
57
+ <AppButton
58
+ v-else
59
+ icon="mdi-phone-in-talk-outline"
60
+ class="web-call-button"
61
+ :class="webPhoneButtonClass"
62
+ @click="openWebPhoneDialog()"
63
+ >
64
+ {{ webPhone.callState.value === 'incoming' ? 'Incoming Call' : 'Web Call' }}
65
+ </AppButton>
66
+ </template>
67
+
68
+ <!-- Was `v-tabs`/`v-tab`, whose selected state is drawn from Vuetify's
69
+ `on-surface`. `.app-tab` is the shared strip. -->
70
+ <template #tabs>
71
+ <button
72
+ v-for="tab in tabOptions"
73
+ :key="tab.value"
74
+ type="button"
75
+ role="tab"
76
+ class="app-tab"
77
+ :class="{ 'app-tab--active': activeTab === tab.value }"
78
+ :aria-selected="activeTab === tab.value"
79
+ @click="selectTab(tab.value)"
80
+ >
81
+ {{ tab.label }}
82
+ </button>
83
+ </template>
84
+
85
+ <template #extension>
86
+ <div class="app-filter-row hid-intercom-filters">
87
+ <!-- Staging (1e6224e) added a reader picker here and filters both
88
+ tabs by it; it keeps that job, drawn as the shared select. -->
89
+ <AppSelect
90
+ v-if="readers.length"
91
+ v-model="selectedReaderId"
92
+ class="reader-select"
93
+ :items="readerOptions"
94
+ @update:model-value="page = 1"
95
+ />
96
+ <AppField v-model="search" search placeholder="Search" />
97
+ <AppSelect
98
+ v-model="statusFilter"
99
+ :items="statusOptions"
100
+ placeholder="Status"
101
+ clearable
102
+ />
141
103
  </div>
142
- </div>
143
-
144
- <div class="table-footer">
145
- <span>{{ pageRange }}</span>
146
- <v-btn icon="mdi-chevron-left" variant="text" density="comfortable" :disabled="page <= 1" @click="page--" />
147
- <v-btn icon="mdi-chevron-right" variant="text" density="comfortable" :disabled="page >= pages" @click="page++" />
148
- </div>
149
- </v-card>
104
+ </template>
105
+
106
+ <template #[`item.name`]="{ item }">
107
+ <span class="app-cell--strong">{{ item.name }}</span>
108
+ </template>
109
+
110
+ <template #[`item.location`]="{ item }">
111
+ <span class="app-cell--muted">{{ item.location }}</span>
112
+ </template>
113
+
114
+ <template #[`item.serverAddress`]="{ item }">
115
+ <span :class="item.serverAddress !== 'N/A' ? 'link-text' : 'app-cell--muted'">
116
+ {{ item.serverAddress }}
117
+ </span>
118
+ </template>
119
+
120
+ <template #[`item.operatingMode`]="{ item }">
121
+ <span class="app-cell--muted">{{ item.operatingMode }}</span>
122
+ </template>
123
+
124
+ <template #[`item.dialCode`]="{ item }">
125
+ <span class="app-cell--num">{{ item.dialCode }}</span>
126
+ </template>
127
+
128
+ <!-- The shared chip. This screen's `getStatusClass` was another private
129
+ copy of the status-to-colour map. -->
130
+ <template #[`item.status`]="{ item }">
131
+ <StatusChip :status="item.status" />
132
+ </template>
133
+
134
+ <template #[`item.contactStatus`]="{ item }">
135
+ <v-btn
136
+ size="small"
137
+ variant="outlined"
138
+ class="text-none"
139
+ :loading="testingContactReaderId === item.readerId"
140
+ @click="testContactSipStatus(item)"
141
+ >
142
+ {{ getContactReaderStatus(item) }}
143
+ </v-btn>
144
+ </template>
145
+
146
+ <template #[`item.action-table`]="{ item }">
147
+ <v-menu location="bottom end">
148
+ <template #activator="{ props: menuProps }">
149
+ <AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" />
150
+ </template>
151
+ <v-list v-if="activeTab === 'intercom'" density="compact" class="row-menu">
152
+ <v-list-item title="View" @click="openViewDialog(item)" />
153
+ <v-list-item
154
+ title="Test Connection with SIP"
155
+ :disabled="testingSipReaderId === item.reader._id"
156
+ @click="testSipConnection(item)"
157
+ />
158
+ <v-list-item title="Call Settings" @click="openCallDialog(item)" />
159
+ <v-list-item title="Dial Code" @click="openDialCodeDialog(item)" />
160
+ <v-list-item title="Make a call" @click="openMakeCallDialog(item)" />
161
+ <v-list-item title="Web call" @click="openWebPhoneDialog(item)" />
162
+ </v-list>
163
+ <v-list v-else density="compact" class="row-menu">
164
+ <v-list-item title="Call" prepend-icon="mdi-phone" @click="openContactCall(item)" />
165
+ <v-list-item title="Check reader SIP status" prepend-icon="mdi-lan-check" @click="testContactSipStatus(item)" />
166
+ <v-list-item title="View" @click="openContactView(item)" />
167
+ <v-list-item title="Edit" @click="openContactDialog(item)" />
168
+ <v-list-item title="Delete" @click="openDeleteContact(item)" />
169
+ </v-list>
170
+ </v-menu>
171
+ </template>
172
+
173
+ <!-- The screen's own sentence, kept. -->
174
+ <template #no-data>
175
+ <div class="table-card__empty">
176
+ <v-icon icon="mdi-deskphone" size="32" />
177
+ <span>No intercom records found.</span>
178
+ </div>
179
+ </template>
180
+ </TableMain>
150
181
 
151
182
  <v-dialog v-model="viewDialog" max-width="360">
152
183
  <v-card class="detail-dialog" rounded="lg">
@@ -176,43 +207,53 @@
176
207
  <v-card-text class="dialog-body">
177
208
  <div class="sip-switch-row">
178
209
  <span>Enable / Disable SIP</span>
179
- <v-switch v-model="sipForm.enabled" color="success" density="compact" hide-details />
210
+ <v-switch v-model="sipForm.enabled" class="app-switch" density="compact" hide-details />
180
211
  </div>
181
212
  <div class="sip-label">Server Address <span>*</span></div>
182
- <v-text-field v-model="sipForm.serverIp" :disabled="!sipForm.enabled" placeholder="SIP server address" variant="outlined" density="compact" hide-details="auto" class="sip-input" />
213
+ <AppField v-model="sipForm.serverIp" :disabled="!sipForm.enabled" placeholder="SIP server address" class="sip-input" />
183
214
  <div class="sip-label">Port <span>*</span></div>
184
- <v-text-field v-model="sipForm.serverPort" :disabled="!sipForm.enabled" placeholder="0000" variant="outlined" density="compact" hide-details="auto" class="sip-input" />
215
+ <AppField v-model="sipForm.serverPort" :disabled="!sipForm.enabled" placeholder="0000" class="sip-input" />
185
216
  <div class="form-two-col">
186
217
  <div>
187
218
  <div class="sip-label">Initial RTP Port <span>*</span></div>
188
- <v-text-field v-model="sipForm.initialRtpPort" :disabled="!sipForm.enabled" placeholder="0000" variant="outlined" density="compact" hide-details="auto" class="sip-input" />
219
+ <AppField v-model="sipForm.initialRtpPort" :disabled="!sipForm.enabled" placeholder="0000" class="sip-input" />
189
220
  </div>
190
221
  <div>
191
222
  <div class="sip-label">Final RTP Port <span>*</span></div>
192
- <v-text-field v-model="sipForm.finalRtpPort" :disabled="!sipForm.enabled" placeholder="0000" variant="outlined" density="compact" hide-details="auto" class="sip-input" />
223
+ <AppField v-model="sipForm.finalRtpPort" :disabled="!sipForm.enabled" placeholder="0000" class="sip-input" />
193
224
  </div>
194
225
  </div>
195
226
  <div class="sip-switch-row sip-section-switch">
196
227
  <span>Numeric Extension</span>
197
- <v-switch v-model="sipForm.numericExtension" :disabled="!sipForm.enabled" color="success" density="compact" hide-details />
228
+ <v-switch v-model="sipForm.numericExtension" :disabled="!sipForm.enabled" class="app-switch" density="compact" hide-details />
198
229
  </div>
199
230
  <div class="sip-label">Extension <span>*</span></div>
200
- <v-text-field v-model="sipForm.extension" :disabled="areSipCredentialFieldsDisabled" placeholder="0000" variant="outlined" density="compact" hide-details="auto" class="sip-input" />
231
+ <AppField v-model="sipForm.extension" :disabled="areSipCredentialFieldsDisabled" placeholder="0000" class="sip-input" />
201
232
  <div class="sip-label">Login <span>*</span></div>
202
- <v-text-field v-model="sipForm.login" :disabled="areSipCredentialFieldsDisabled" placeholder="Enter username" variant="outlined" density="compact" hide-details="auto" class="sip-input" />
233
+ <AppField v-model="sipForm.login" :disabled="areSipCredentialFieldsDisabled" placeholder="Enter username" class="sip-input" />
203
234
  <div class="sip-label">Password <span>*</span></div>
204
- <v-text-field
235
+ <!-- The reveal eye moves into `AppField`'s `trailing` slot - the same
236
+ slot panel 4's access PIN uses, which is why it is a shared slot
237
+ rather than a private hack in two screens. -->
238
+ <AppField
205
239
  v-model="sipForm.password"
240
+ :disabled="areSipCredentialFieldsDisabled"
206
241
  placeholder="Enter password"
207
242
  :type="showSipPassword ? 'text' : 'password'"
208
- :disabled="areSipCredentialFieldsDisabled"
209
- variant="outlined"
210
- density="compact"
211
- hide-details="auto"
243
+ autocomplete="new-password"
212
244
  class="sip-input"
213
- :append-inner-icon="showSipPassword ? 'mdi-eye-off' : 'mdi-eye'"
214
- @click:append-inner="showSipPassword = !showSipPassword"
215
- />
245
+ >
246
+ <template #trailing>
247
+ <button
248
+ type="button"
249
+ class="app-field__trailing"
250
+ :aria-label="showSipPassword ? 'Hide SIP password' : 'Show SIP password'"
251
+ @click="showSipPassword = !showSipPassword"
252
+ >
253
+ <v-icon :icon="showSipPassword ? 'mdi-eye-off' : 'mdi-eye'" size="18" />
254
+ </button>
255
+ </template>
256
+ </AppField>
216
257
  <div class="sip-label">Operating Mode <span>*</span></div>
217
258
  <v-btn-toggle v-model="sipForm.operatingMode" :disabled="!sipForm.enabled" mandatory divided class="mode-toggle">
218
259
  <v-btn value="sip-client" class="text-none">SIP Client</v-btn>
@@ -223,6 +264,13 @@
223
264
  <v-radio label="UDP" value="UDP" />
224
265
  <v-radio label="TCP" value="TCP" />
225
266
  </v-radio-group>
267
+ <div class="sip-switch-row sip-section-switch">
268
+ <div>
269
+ <span>Enable video</span>
270
+ <small>Allow this reader to send its camera feed during SIP intercom calls.</small>
271
+ </div>
272
+ <v-switch v-model="sipForm.videoEnabled" :disabled="!sipForm.enabled" class="app-switch" density="compact" hide-details />
273
+ </div>
226
274
  </v-card-text>
227
275
  <v-card-actions class="dialog-actions pa-0">
228
276
  <v-btn class="text-none action-cancel" variant="flat" height="44" @click="sipDialog = false">Cancel</v-btn>
@@ -231,36 +279,6 @@
231
279
  </v-card>
232
280
  </v-dialog>
233
281
 
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
282
  <v-dialog v-model="callDialog" max-width="360" persistent>
265
283
  <v-card class="form-dialog call-dialog" rounded="lg">
266
284
  <v-card-title class="dialog-title">Call Settings</v-card-title>
@@ -273,37 +291,37 @@
273
291
  </v-radio-group>
274
292
  <div class="call-toggle-row">
275
293
  <span>Automatic Answering</span>
276
- <v-switch v-model="callForm.autoAnswer" color="success" density="compact" hide-details />
294
+ <v-switch v-model="callForm.autoAnswer" class="app-switch" density="compact" hide-details />
277
295
  </div>
278
296
  <div class="call-label">Automatic answering delay (seconds)</div>
279
- <v-text-field v-model="callForm.autoAnswerDelay" :disabled="!callForm.autoAnswer" placeholder="0" variant="outlined" density="compact" hide-details="auto" class="call-input" />
297
+ <AppField v-model="callForm.autoAnswerDelay" :disabled="!callForm.autoAnswer" placeholder="0" class="call-input" />
280
298
  <div class="form-two-col call-two-col">
281
299
  <div>
282
300
  <div class="call-label">Registration time (seconds)</div>
283
- <v-text-field v-model="callForm.registrationTime" :disabled="!callForm.autoAnswer" placeholder="0" variant="outlined" density="compact" hide-details="auto" class="call-input" />
301
+ <AppField v-model="callForm.registrationTime" :disabled="!callForm.autoAnswer" placeholder="0" class="call-input" />
284
302
  </div>
285
303
  <div>
286
304
  <div class="call-label">Keep-alive (seconds)</div>
287
- <v-text-field v-model="callForm.keepAlive" :disabled="!callForm.autoAnswer" placeholder="0" variant="outlined" density="compact" hide-details="auto" class="call-input" />
305
+ <AppField v-model="callForm.keepAlive" :disabled="!callForm.autoAnswer" placeholder="0" class="call-input" />
288
306
  </div>
289
307
  </div>
290
308
  <div class="call-label">Maximum call time (seconds)</div>
291
- <v-text-field v-model="callForm.maxCallTime" :disabled="!callForm.autoAnswer" placeholder="0" variant="outlined" density="compact" hide-details="auto" class="call-input" />
309
+ <AppField v-model="callForm.maxCallTime" :disabled="!callForm.autoAnswer" placeholder="0" class="call-input" />
292
310
  <div class="call-toggle-row">
293
311
  <span>Dial Button</span>
294
- <v-switch v-model="callForm.dialButton" color="success" density="compact" hide-details />
312
+ <v-switch v-model="callForm.dialButton" class="app-switch" density="compact" hide-details />
295
313
  </div>
296
314
  <div class="call-toggle-row">
297
315
  <span>External Dial Button</span>
298
- <v-switch v-model="callForm.externalDialButton" color="success" density="compact" hide-details />
316
+ <v-switch v-model="callForm.externalDialButton" class="app-switch" density="compact" hide-details />
299
317
  </div>
300
318
  <div class="call-toggle-row">
301
319
  <span>Facial identification during the call</span>
302
- <v-switch v-model="callForm.faceDuringCall" color="success" density="compact" hide-details />
320
+ <v-switch v-model="callForm.faceDuringCall" class="app-switch" density="compact" hide-details />
303
321
  </div>
304
322
  <div class="call-toggle-row">
305
323
  <span>Custom Icon</span>
306
- <v-switch v-model="callForm.customIcon" color="success" density="compact" hide-details />
324
+ <v-switch v-model="callForm.customIcon" class="app-switch" density="compact" hide-details />
307
325
  </div>
308
326
  <div class="upload-row call-upload-row">
309
327
  <div class="upload-drop" :class="{ 'is-disabled': !callForm.customIcon }"><v-icon size="16">mdi-cloud-upload-outline</v-icon> Drag and drop files here</div>
@@ -315,10 +333,10 @@
315
333
  </div>
316
334
  <div class="call-toggle-row">
317
335
  <span>Automatic Call with unknown contact</span>
318
- <v-switch v-model="callForm.unknownContact" color="success" density="compact" hide-details />
336
+ <v-switch v-model="callForm.unknownContact" class="app-switch" density="compact" hide-details />
319
337
  </div>
320
338
  <div class="call-label">Call after (seconds)</div>
321
- <v-text-field v-model="callForm.callAfter" :disabled="!callForm.unknownContact" placeholder="0" variant="outlined" density="compact" hide-details="auto" class="call-input" />
339
+ <AppField v-model="callForm.callAfter" :disabled="!callForm.unknownContact" placeholder="0" class="call-input" />
322
340
  </v-card-text>
323
341
  <v-card-actions class="dialog-actions pa-0">
324
342
  <v-btn class="text-none action-cancel" variant="flat" height="44" @click="callDialog = false">Cancel</v-btn>
@@ -333,10 +351,10 @@
333
351
  <v-card-text class="dialog-body">
334
352
  <div class="call-toggle-row dial-toggle-row">
335
353
  <span>Dial Code</span>
336
- <v-switch v-model="dialForm.enabled" color="success" density="compact" hide-details />
354
+ <v-switch v-model="dialForm.enabled" class="app-switch" density="compact" hide-details />
337
355
  </div>
338
356
  <div class="call-label">Dial code to unlock</div>
339
- <v-text-field v-model="dialForm.code" :disabled="!dialForm.enabled" placeholder="0" variant="outlined" density="compact" hide-details="auto" class="call-input dial-input" />
357
+ <AppField v-model="dialForm.code" :disabled="!dialForm.enabled" placeholder="0" class="call-input dial-input" />
340
358
  </v-card-text>
341
359
  <v-card-actions class="dialog-actions pa-0">
342
360
  <v-btn class="text-none action-cancel" variant="flat" height="44" @click="dialCodeDialog = false">Cancel</v-btn>
@@ -350,16 +368,8 @@
350
368
  <v-card-title class="dialog-title">Make a Call</v-card-title>
351
369
  <v-card-text class="dialog-body make-call-body">
352
370
  <div class="make-call-field">
353
- <div class="call-label">Address to dial</div>
354
- <v-text-field
355
- v-model="makeCallForm.target"
356
- placeholder="Enter extension, address, or contact"
357
- variant="outlined"
358
- density="compact"
359
- hide-details="auto"
360
- class="call-input"
361
- @keyup.enter="runMakeCall"
362
- />
371
+ <div class="call-label">Extension, SIP address, or peer IP</div>
372
+ <AppField v-model="makeCallForm.target" placeholder="7100 or user@example.com" @keyup.enter="runMakeCall" class="call-input" />
363
373
  </div>
364
374
  <div class="make-call-actions">
365
375
  <v-btn
@@ -388,22 +398,195 @@
388
398
  </div>
389
399
  </v-card-text>
390
400
  <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>
401
+ <v-btn class="text-none action-submit full-action" variant="flat" height="44" @click="makeCallDialog = false">Close</v-btn>
393
402
  </v-card-actions>
394
403
  </v-card>
395
404
  </v-dialog>
396
405
 
406
+ <v-dialog v-model="webPhoneDialog" max-width="820" persistent eager>
407
+ <v-card class="web-phone-dialog" rounded="lg">
408
+ <v-card-title class="dialog-title web-phone-title">
409
+ <span>Web Call</span>
410
+ <div class="web-phone-title-actions">
411
+ <v-chip size="small" variant="flat" :class="webPhoneStatusClass">
412
+ {{ webPhoneStatusLabel }}
413
+ </v-chip>
414
+ <v-btn
415
+ icon="mdi-close"
416
+ size="small"
417
+ variant="text"
418
+ title="Close Web Call"
419
+ aria-label="Close Web Call"
420
+ @click="webPhoneDialog = false"
421
+ />
422
+ </div>
423
+ </v-card-title>
424
+
425
+ <v-card-text class="web-phone-body">
426
+ <div class="web-phone-settings">
427
+ <div class="web-phone-section-title">SIP Account</div>
428
+ <div v-if="sipAccountLoading" class="sip-account-state">
429
+ <v-progress-circular indeterminate color="primary" size="30" width="3" />
430
+ <span>Preparing your private SIP account...</span>
431
+ </div>
432
+ <div v-else-if="sipAccountError" class="sip-account-state is-error">
433
+ <v-icon icon="mdi-alert-circle-outline" size="28" />
434
+ <span>{{ sipAccountError }}</span>
435
+ <v-btn
436
+ class="text-none sip-account-retry"
437
+ variant="outlined"
438
+ prepend-icon="mdi-refresh"
439
+ @click="loadSipAccount"
440
+ >
441
+ Retry
442
+ </v-btn>
443
+ </div>
444
+ <div v-else-if="sipAccount" class="sip-account-summary">
445
+ <div>
446
+ <span class="sip-account-label">Assigned extension</span>
447
+ <strong class="sip-account-extension">{{ sipAccount.extension }}</strong>
448
+ </div>
449
+ <span class="sip-account-address">{{ sipAccount.sipAddress }}</span>
450
+ </div>
451
+ <div class="call-toggle-row web-video-toggle">
452
+ <div>
453
+ <span>Receive HID video</span>
454
+ <small>Show the HID camera feed without sharing this device's camera.</small>
455
+ </div>
456
+ <v-switch
457
+ v-model="webPhoneForm.video"
458
+ class="app-switch"
459
+ density="compact"
460
+ hide-details
461
+ :disabled="webPhone.isRegistered.value"
462
+ />
463
+ </div>
464
+ <v-btn
465
+ v-if="!webPhone.isRegistered.value"
466
+ block
467
+ class="text-none web-phone-connect"
468
+ variant="flat"
469
+ prepend-icon="mdi-lan-connect"
470
+ :loading="webPhoneConnecting"
471
+ :disabled="!sipAccount || sipAccountLoading"
472
+ @click="connectWebPhone"
473
+ >
474
+ Connect Web Phone
475
+ </v-btn>
476
+ <v-btn
477
+ v-else
478
+ block
479
+ class="text-none web-phone-disconnect"
480
+ variant="outlined"
481
+ prepend-icon="mdi-lan-disconnect"
482
+ :loading="webPhoneConnecting"
483
+ :disabled="webPhone.hasCall.value"
484
+ @click="disconnectWebPhone"
485
+ >
486
+ Disconnect
487
+ </v-btn>
488
+ </div>
489
+
490
+ <div class="web-phone-console">
491
+ <div
492
+ class="web-phone-media"
493
+ :class="{
494
+ 'has-video': webPhoneForm.video && webPhone.callState.value === 'active',
495
+ 'has-remote-video': webPhone.remoteVideoAvailable.value,
496
+ }"
497
+ >
498
+ <video ref="remoteVideoEl" autoplay playsinline class="remote-video" />
499
+ <audio ref="remoteAudioEl" autoplay />
500
+ <div
501
+ v-if="!webPhoneForm.video || webPhone.callState.value !== 'active' || !webPhone.remoteVideoAvailable.value"
502
+ class="audio-call-state"
503
+ >
504
+ <v-icon size="54">{{ webPhoneCallIcon }}</v-icon>
505
+ <strong>{{ webPhoneCallLabel }}</strong>
506
+ <span v-if="webPhone.currentTarget.value">{{ webPhone.currentTarget.value }}</span>
507
+ <span v-if="webPhoneForm.video && webPhone.callState.value === 'active' && !webPhone.remoteVideoAvailable.value">
508
+ Waiting for video from the HID device
509
+ </span>
510
+ </div>
511
+ </div>
512
+
513
+ <div v-if="webPhone.callState.value === 'incoming'" class="incoming-call-actions">
514
+ <v-btn
515
+ class="text-none answer-call"
516
+ variant="flat"
517
+ prepend-icon="mdi-phone"
518
+ :loading="webPhoneActionLoading"
519
+ @click="answerWebCall"
520
+ >
521
+ Answer
522
+ </v-btn>
523
+ <v-btn
524
+ class="text-none reject-call"
525
+ variant="flat"
526
+ prepend-icon="mdi-phone-hangup"
527
+ :disabled="webPhoneActionLoading"
528
+ @click="declineWebCall"
529
+ >
530
+ Decline
531
+ </v-btn>
532
+ </div>
533
+
534
+ <template v-else>
535
+ <div class="call-label">Extension or SIP Address</div>
536
+ <div class="web-phone-dial-row">
537
+ <AppField v-model="webPhoneForm.destination" :disabled="!webPhone.isRegistered.value || webPhone.hasCall.value" placeholder="1001 or device@example.com" @keyup.enter="startWebCall" class="call-input mb-0" />
538
+ <v-btn
539
+ icon="mdi-phone"
540
+ class="start-web-call"
541
+ variant="flat"
542
+ :loading="webPhoneActionLoading && webPhone.callState.value === 'calling'"
543
+ :disabled="!webPhone.isRegistered.value || webPhone.hasCall.value || !webPhoneForm.destination.trim()"
544
+ @click="startWebCall"
545
+ />
546
+ </div>
547
+
548
+ <div v-if="webPhone.hasCall.value" class="active-call-controls">
549
+ <v-btn
550
+ :icon="webPhone.muted.value ? 'mdi-microphone-off' : 'mdi-microphone'"
551
+ variant="outlined"
552
+ :disabled="webPhone.callState.value !== 'active'"
553
+ :title="webPhone.muted.value ? 'Unmute' : 'Mute'"
554
+ @click="toggleWebMute"
555
+ />
556
+ <v-btn
557
+ icon="mdi-phone-hangup"
558
+ class="hangup-web-call"
559
+ variant="flat"
560
+ :loading="webPhoneActionLoading && webPhone.callState.value === 'ending'"
561
+ title="End call"
562
+ @click="hangupWebCall"
563
+ />
564
+ </div>
565
+ </template>
566
+
567
+ <v-alert v-if="webPhone.errorMessage.value" type="error" variant="tonal" density="compact" class="web-phone-error">
568
+ {{ webPhone.errorMessage.value }}
569
+ </v-alert>
570
+ </div>
571
+ </v-card-text>
572
+
573
+ </v-card>
574
+ </v-dialog>
575
+
397
576
  <v-dialog v-model="contactDialog" max-width="360" persistent>
398
577
  <v-card class="form-dialog" rounded="lg">
399
578
  <v-card-title class="dialog-title">{{ contactForm.id ? 'Edit Contact' : 'Add Contact' }}</v-card-title>
400
579
  <v-card-text class="dialog-body">
580
+ <FieldLabel text="HID Reader" required />
581
+ <AppSelect
582
+ v-model="contactForm.readerId"
583
+ :items="readerOptions"
584
+ :disabled="Boolean(contactForm.id)"
585
+ />
401
586
  <FieldLabel text="Name" required />
402
- <v-text-field v-model="contactForm.name" placeholder="Enter name" variant="outlined" density="compact" hide-details="auto" />
403
- <FieldLabel text="Location" />
404
- <v-text-field v-model="contactForm.location" placeholder="Location" variant="outlined" density="compact" hide-details="auto" />
405
- <FieldLabel text="Server Address" required />
406
- <v-text-field v-model="contactForm.serverAddress" placeholder="192.168.1.1" variant="outlined" density="compact" hide-details="auto" />
587
+ <AppField v-model="contactForm.name" placeholder="Enter name" />
588
+ <FieldLabel text="SIP Number / Address" required />
589
+ <AppField v-model="contactForm.serverAddress" placeholder="7100 or user@example.com" />
407
590
  </v-card-text>
408
591
  <v-card-actions class="dialog-actions pa-0">
409
592
  <v-btn class="text-none action-cancel" variant="flat" height="44" @click="contactDialog = false">Cancel</v-btn>
@@ -448,6 +631,10 @@
448
631
 
449
632
  <script setup lang="ts">
450
633
  const props = defineProps({
634
+ org: {
635
+ type: String,
636
+ required: true,
637
+ },
451
638
  site: {
452
639
  type: String,
453
640
  required: true,
@@ -466,6 +653,19 @@ type ContactRow = {
466
653
  dialCode: string;
467
654
  };
468
655
 
656
+ type SipAccount = {
657
+ orgId: string;
658
+ site: string;
659
+ userId: string;
660
+ extension: string;
661
+ username: string;
662
+ password: string;
663
+ sipAddress: string;
664
+ websocketUrl: string;
665
+ status: "active";
666
+ provisionedAt: string;
667
+ };
668
+
469
669
  type DetailItem = {
470
670
  label: string;
471
671
  value?: string | number | boolean;
@@ -474,7 +674,7 @@ type DetailItem = {
474
674
  };
475
675
 
476
676
  type IntercomRow = {
477
- reader: Record<string, any>;
677
+ reader: Record<string, unknown>;
478
678
  name: string;
479
679
  location: string;
480
680
  serverAddress: string;
@@ -490,6 +690,7 @@ type IntercomRow = {
490
690
  password: string;
491
691
  operatingMode: string;
492
692
  protocol: string;
693
+ videoEnabled: boolean;
493
694
  dialMode: string;
494
695
  dialModeLabel: string;
495
696
  dialCode: string;
@@ -502,7 +703,7 @@ type IntercomRow = {
502
703
  externalDialButton: boolean;
503
704
  unknownContact: boolean;
504
705
  callAfter: string;
505
- pjsip: Record<string, any>;
706
+ pjsip: Record<string, unknown>;
506
707
  };
507
708
 
508
709
  const {
@@ -513,41 +714,52 @@ const {
513
714
  getIntercomStatus,
514
715
  makeIntercomCall,
515
716
  finalizeIntercomCall,
717
+ ensureSipAccount,
516
718
  } = useHidAmico();
517
719
 
518
720
  const activeTab = ref<IntercomTab>("intercom");
519
721
  const readers = ref<Record<string, any>[]>([]);
722
+ const selectedReaderId = ref("");
723
+ const testingSipReaderId = ref("");
724
+ const testingContactReaderId = ref("");
520
725
  const intercomRows = ref<IntercomRow[]>([]);
521
726
  const contacts = ref<ContactRow[]>([]);
727
+ const intercomLoaded = ref(false);
728
+ const contactsLoaded = ref(false);
522
729
  const loading = ref(false);
523
730
  const saving = ref(false);
524
731
  const contactsSaving = ref(false);
525
732
  const calling = ref(false);
526
733
  const hangingUp = ref(false);
527
734
  const search = ref("");
528
- const statusFilter = ref("Status");
735
+ const statusFilter = ref("");
529
736
  const page = ref(1);
530
737
  const limit = 20;
531
738
  const selectedRow = ref<IntercomRow | null>(null);
532
739
  const selectedContact = ref<ContactRow | null>(null);
533
740
  const showSipPassword = ref(false);
741
+ const webPhoneConnecting = ref(false);
742
+ const webPhoneActionLoading = ref(false);
743
+ const sipAccount = ref<SipAccount | null>(null);
744
+ const sipAccountLoading = ref(false);
745
+ const sipAccountError = ref("");
746
+ const remoteVideoEl = ref<HTMLVideoElement | null>(null);
747
+ const remoteAudioEl = ref<HTMLAudioElement | null>(null);
748
+ const webPhone = useSipWebPhone();
534
749
 
535
750
  const viewDialog = ref(false);
536
751
  const sipDialog = ref(false);
537
- const rebootConfirmDialog = ref(false);
538
- const rebootingDialog = ref(false);
539
752
  const callDialog = ref(false);
540
753
  const dialCodeDialog = ref(false);
541
754
  const makeCallDialog = ref(false);
755
+ const webPhoneDialog = ref(false);
542
756
  const contactDialog = ref(false);
543
757
  const contactViewDialog = ref(false);
544
758
  const deleteContactDialog = ref(false);
545
759
 
546
760
  const message = reactive<{ type: MessageType; text: string }>({ type: "info", text: "" });
547
- const rebootCountdown = ref(59);
548
- let rebootTimer: ReturnType<typeof setInterval> | undefined;
549
761
 
550
- const statusOptions = ["Status", "Connected", "Not connected", "Connecting", "Disabled", "Failed"];
762
+ const statusOptions = ["Connected", "Not connected", "Connecting", "Disabled", "Failed"];
551
763
 
552
764
  const pjsipKeys = [
553
765
  "enabled",
@@ -582,13 +794,14 @@ const sipForm = reactive({
582
794
  serverIp: "",
583
795
  serverPort: "5060",
584
796
  initialRtpPort: "10000",
585
- finalRtpPort: "1000",
797
+ finalRtpPort: "10100",
586
798
  numericExtension: false,
587
799
  extension: "",
588
800
  login: "",
589
801
  password: "",
590
802
  operatingMode: "sip-client",
591
803
  protocol: "UDP",
804
+ videoEnabled: false,
592
805
  });
593
806
 
594
807
  const callForm = reactive({
@@ -617,6 +830,11 @@ const makeCallForm = reactive({
617
830
  statusType: "info" as MessageType,
618
831
  });
619
832
 
833
+ const webPhoneForm = reactive({
834
+ destination: "",
835
+ video: false,
836
+ });
837
+
620
838
  const contactForm = reactive({
621
839
  id: undefined as number | undefined,
622
840
  readerId: "",
@@ -625,6 +843,11 @@ const contactForm = reactive({
625
843
  serverAddress: "",
626
844
  });
627
845
 
846
+ const readerOptions = computed(() => readers.value.map((reader) => ({
847
+ title: `${reader.name || reader.deviceId || reader._id} — ${reader.portalName || "Portal not configured"}`,
848
+ value: reader._id,
849
+ })));
850
+
628
851
  const visibleRows = computed(() => activeTab.value === "intercom" ? filteredIntercomRows.value : filteredContacts.value);
629
852
  const pages = computed(() => Math.max(1, Math.ceil(visibleRows.value.length / limit)));
630
853
  const pageRange = computed(() => {
@@ -638,6 +861,7 @@ const pageRange = computed(() => {
638
861
  const filteredIntercomRows = computed(() => {
639
862
  const query = search.value.trim().toLowerCase();
640
863
  return intercomRows.value.filter((row) => {
864
+ const matchesReader = !selectedReaderId.value || row.reader._id === selectedReaderId.value;
641
865
  const matchesSearch = !query || [
642
866
  row.name,
643
867
  row.location,
@@ -646,23 +870,83 @@ const filteredIntercomRows = computed(() => {
646
870
  row.dialCode,
647
871
  row.status,
648
872
  ].some((value) => String(value || "").toLowerCase().includes(query));
649
- const matchesStatus = statusFilter.value === "Status" || row.status === statusFilter.value;
650
- return matchesSearch && matchesStatus;
873
+ const matchesStatus = !statusFilter.value || row.status === statusFilter.value;
874
+ return matchesReader && matchesSearch && matchesStatus;
651
875
  });
652
876
  });
653
877
 
654
878
  const filteredContacts = computed(() => {
655
879
  const query = search.value.trim().toLowerCase();
656
- return contacts.value.filter((contact) => !query || [
657
- contact.name,
658
- contact.location,
659
- contact.serverAddress,
660
- contact.dialCode,
661
- ].some((value) => String(value || "").toLowerCase().includes(query)));
880
+ return contacts.value.filter((contact) => {
881
+ const matchesReader = !selectedReaderId.value || contact.readerId === selectedReaderId.value;
882
+ const readerStatus = getContactReaderStatus(contact);
883
+ const matchesSearch = !query || [
884
+ contact.name,
885
+ contact.location,
886
+ contact.serverAddress,
887
+ contact.dialCode,
888
+ readerStatus,
889
+ ].some((value) => String(value || "").toLowerCase().includes(query));
890
+ const matchesStatus = !statusFilter.value || readerStatus === statusFilter.value;
891
+ return matchesReader && matchesSearch && matchesStatus;
892
+ });
662
893
  });
663
894
 
664
895
  const paginatedIntercomRows = computed(() => paginate(filteredIntercomRows.value));
665
896
  const paginatedContacts = computed(() => paginate(filteredContacts.value));
897
+
898
+ /**
899
+ * The two tabs, as data rather than two `v-tab` elements, so the shared
900
+ * `app-tab` strip can render them. Same values, same order.
901
+ */
902
+ const tabOptions: Array<{ label: string; value: IntercomTab }> = [
903
+ { label: "Intercom", value: "intercom" },
904
+ { label: "Intercom Contacts", value: "contacts" },
905
+ ];
906
+
907
+ /**
908
+ * The screen has TWO column sets, and it keeps them - this is the same pair the
909
+ * two `<thead>` blocks drew. `sortable: false` preserves the raw `v-table`'s
910
+ * behaviour, and no `weight` is set, so the layout is `TableMain`'s automatic
911
+ * one: the handoff draws no HID table and states no proportions.
912
+ */
913
+ const tableHeaders = computed(() =>
914
+ activeTab.value === "intercom"
915
+ ? [
916
+ { title: "Name", value: "name", sortable: false },
917
+ { title: "Location", value: "location", sortable: false },
918
+ { title: "SIP Server Address", value: "serverAddress", sortable: false },
919
+ { title: "Operating Mode", value: "operatingMode", sortable: false },
920
+ { title: "Dial Code", value: "dialCode", sortable: false },
921
+ { title: "SIP Status", value: "status", sortable: false },
922
+ { title: "", value: "action-table", sortable: false },
923
+ ]
924
+ : [
925
+ { title: "Name", value: "name", sortable: false },
926
+ { title: "Location", value: "location", sortable: false },
927
+ { title: "SIP Number / Address", value: "serverAddress", sortable: false },
928
+ { title: "Reader SIP Status", value: "contactStatus", sortable: false },
929
+ { title: "", value: "action-table", sortable: false },
930
+ ]
931
+ );
932
+
933
+ /**
934
+ * `TableMain` takes ONE item list, so the two `v-for` blocks become one. The
935
+ * intercom rows are keyed on their reader id (they have no `_rowKey` of their
936
+ * own, where contacts do), so one is supplied for the table's `item-value`.
937
+ */
938
+ const visiblePageRows = computed(() =>
939
+ activeTab.value === "intercom"
940
+ ? paginatedIntercomRows.value.map((row) => ({ ...row, _rowKey: row.reader?._id }))
941
+ : paginatedContacts.value
942
+ );
943
+
944
+ /** The web-call button turns green when registered and amber on an incoming
945
+ call - the same two states the old `.web-call-button` modifiers drew. */
946
+ const webPhoneButtonClass = computed(() => ({
947
+ "is-connected": webPhone.isRegistered.value,
948
+ "has-incoming-call": webPhone.callState.value === "incoming",
949
+ }));
666
950
  const isPeerToPeerMode = computed(() => sipForm.operatingMode === "peer-to-peer");
667
951
  const areSipCredentialFieldsDisabled = computed(() => !sipForm.enabled || isPeerToPeerMode.value);
668
952
  const intercomDetailSections = computed(() => {
@@ -694,6 +978,7 @@ const intercomDetailSections = computed(() => {
694
978
  items: [
695
979
  { label: "Operating Mode", value: row?.operatingMode },
696
980
  { label: "Protocol", value: row?.protocol },
981
+ { label: "Video", value: row?.videoEnabled ? "Enabled" : "Disabled" },
697
982
  ],
698
983
  },
699
984
  {
@@ -717,10 +1002,45 @@ const contactDetailRows = computed<DetailItem[]>(() => [
717
1002
  { label: "Server Address", value: selectedContact.value?.serverAddress, link: true },
718
1003
  { label: "Location", value: selectedContact.value?.location },
719
1004
  ]);
1005
+ const webPhoneStatusLabel = computed(() => {
1006
+ if (webPhone.callState.value === "incoming") return "Incoming call";
1007
+ if (webPhone.callState.value === "calling") return "Calling";
1008
+ if (webPhone.callState.value === "active") return "In call";
1009
+ if (webPhone.callState.value === "ending") return "Ending call";
1010
+ if (webPhone.connectionState.value === "connecting") return "Connecting";
1011
+ if (webPhone.connectionState.value === "registered") return "Ready";
1012
+ if (webPhone.connectionState.value === "error") return "Connection failed";
1013
+ return "Disconnected";
1014
+ });
1015
+ const webPhoneStatusClass = computed(() => {
1016
+ if (webPhone.callState.value === "incoming") return "web-phone-status incoming";
1017
+ if (webPhone.callState.value === "active" || webPhone.connectionState.value === "registered") return "web-phone-status ready";
1018
+ if (webPhone.connectionState.value === "error") return "web-phone-status failed";
1019
+ return "web-phone-status idle";
1020
+ });
1021
+ const webPhoneCallLabel = computed(() => {
1022
+ if (webPhone.callState.value === "incoming") return "Incoming call";
1023
+ if (webPhone.callState.value === "calling") return "Calling...";
1024
+ if (webPhone.callState.value === "active") return "Call connected";
1025
+ if (webPhone.callState.value === "ending") return "Ending call...";
1026
+ return webPhone.isRegistered.value ? "Ready to call" : "Connect a SIP account";
1027
+ });
1028
+ const webPhoneCallIcon = computed(() => {
1029
+ if (webPhone.callState.value === "incoming") return "mdi-phone-incoming";
1030
+ if (webPhone.callState.value === "calling") return "mdi-phone-outgoing";
1031
+ if (webPhone.callState.value === "active") return "mdi-phone-in-talk";
1032
+ return "mdi-phone-outline";
1033
+ });
720
1034
 
721
1035
  watch(
722
- () => props.site,
723
- () => reload(),
1036
+ [() => props.org, () => props.site],
1037
+ () => {
1038
+ if (webPhone.isRegistered.value) void webPhone.disconnect();
1039
+ sipAccount.value = null;
1040
+ sipAccountError.value = "";
1041
+ webPhoneForm.destination = "";
1042
+ void reload();
1043
+ },
724
1044
  { immediate: true },
725
1045
  );
726
1046
 
@@ -732,9 +1052,12 @@ watch(pages, () => {
732
1052
  if (page.value > pages.value) page.value = pages.value;
733
1053
  });
734
1054
 
735
- onUnmounted(() => {
736
- clearRebootTimer();
737
- });
1055
+ watch(
1056
+ () => webPhone.callState.value,
1057
+ (state) => {
1058
+ if (state === "incoming") webPhoneDialog.value = true;
1059
+ },
1060
+ );
738
1061
 
739
1062
  async function reload() {
740
1063
  if (!props.site) return;
@@ -742,8 +1065,12 @@ async function reload() {
742
1065
  try {
743
1066
  const response = await getReaders({ site: props.site, page: 1, limit: 100 });
744
1067
  readers.value = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
745
- await loadIntercomRows();
746
- await loadContacts();
1068
+ if (!readers.value.some((reader) => reader._id === selectedReaderId.value)) {
1069
+ selectedReaderId.value = readers.value[0]?._id || "";
1070
+ }
1071
+ intercomLoaded.value = false;
1072
+ contactsLoaded.value = false;
1073
+ await loadActiveTab();
747
1074
  } catch (error: any) {
748
1075
  showMessage(getErrorMessage(error), "error");
749
1076
  } finally {
@@ -751,10 +1078,36 @@ async function reload() {
751
1078
  }
752
1079
  }
753
1080
 
754
- async function loadIntercomRows() {
755
- const rows: IntercomRow[] = [];
1081
+ async function loadActiveTab() {
1082
+ if (activeTab.value === "intercom") {
1083
+ await loadIntercomRows();
1084
+ return;
1085
+ }
1086
+
1087
+ await Promise.all([
1088
+ intercomLoaded.value ? Promise.resolve() : loadIntercomRows(),
1089
+ loadContacts(),
1090
+ ]);
1091
+ }
1092
+
1093
+ async function selectTab(tab: IntercomTab) {
1094
+ activeTab.value = tab;
1095
+ page.value = 1;
1096
+
1097
+ if ((tab === "intercom" && intercomLoaded.value) || (tab === "contacts" && contactsLoaded.value)) return;
1098
+
1099
+ loading.value = true;
1100
+ try {
1101
+ await loadActiveTab();
1102
+ } catch (error: unknown) {
1103
+ showMessage(getErrorMessage(error), "error");
1104
+ } finally {
1105
+ loading.value = false;
1106
+ }
1107
+ }
756
1108
 
757
- for (const reader of readers.value) {
1109
+ async function loadIntercomRows() {
1110
+ const rows = await Promise.all(readers.value.map(async (reader): Promise<IntercomRow> => {
758
1111
  const [configurationResult, statusResult] = await Promise.allSettled([
759
1112
  getReaderConfiguration(reader._id, { pjsip: pjsipKeys }),
760
1113
  getIntercomStatus(reader._id),
@@ -766,16 +1119,15 @@ async function loadIntercomRows() {
766
1119
  const statusPayload = statusResponse?.data ?? statusResponse;
767
1120
  const pjsip = normalizePjsip(configurationResponse, statusPayload);
768
1121
  const status = normalizeStatus(statusPayload, pjsip);
769
- rows.push(toIntercomRow(reader, pjsip, status));
770
- }
1122
+ return toIntercomRow(reader, pjsip, status);
1123
+ }));
771
1124
 
772
1125
  intercomRows.value = rows;
1126
+ intercomLoaded.value = true;
773
1127
  }
774
1128
 
775
1129
  async function loadContacts() {
776
- const rows: ContactRow[] = [];
777
-
778
- for (const reader of readers.value) {
1130
+ const readerContacts = await Promise.all(readers.value.map(async (reader): Promise<ContactRow[]> => {
779
1131
  try {
780
1132
  const response = await runObjectOperation(reader._id, {
781
1133
  operation: "load",
@@ -783,13 +1135,15 @@ async function loadContacts() {
783
1135
  fields: ["id", "name", "number"],
784
1136
  limit: 100,
785
1137
  });
786
- rows.push(...normalizeContacts(reader, response?.data ?? response));
1138
+ return normalizeContacts(reader, response?.data ?? response);
787
1139
  } catch {
788
1140
  // Some firmware builds may not expose contacts until intercom is configured.
1141
+ return [];
789
1142
  }
790
- }
1143
+ }));
791
1144
 
792
- contacts.value = rows;
1145
+ contacts.value = readerContacts.flat();
1146
+ contactsLoaded.value = true;
793
1147
  }
794
1148
 
795
1149
  function paginate<T>(items: T[]) {
@@ -798,19 +1152,27 @@ function paginate<T>(items: T[]) {
798
1152
  return items.slice(start, start + limit);
799
1153
  }
800
1154
 
801
- function toIntercomRow(reader: Record<string, any>, pjsip: Record<string, any>, status: string): IntercomRow {
1155
+ function toIntercomRow(reader: Record<string, unknown>, pjsip: Record<string, unknown>, status: string): IntercomRow {
802
1156
  const peerToPeer = toFlag(getPjsipValue(pjsip, ["peer_to_peer_enabled", "peerToPeerEnabled"])) === "1";
803
1157
  const dialMode = toText(getPjsipValue(pjsip, ["dialing_display_mode", "dialingDisplayMode"]), "0");
1158
+ const initialRtpPort = toText(getPjsipValue(pjsip, ["server_outbound_port", "initialRtpPort", "initial_rtp_port"]), "N/A");
1159
+ const rtpPortRange = getPjsipValue(pjsip, ["server_outbound_port_range"]);
1160
+ const explicitFinalRtpPort = getPjsipValue(pjsip, ["finalRtpPort", "final_rtp_port"]);
1161
+ const initialRtpPortNumber = Number(initialRtpPort);
1162
+ const rtpPortRangeNumber = Number(rtpPortRange);
1163
+ const finalRtpPort = rtpPortRange !== undefined && Number.isFinite(initialRtpPortNumber) && Number.isFinite(rtpPortRangeNumber)
1164
+ ? String(initialRtpPortNumber + rtpPortRangeNumber)
1165
+ : toText(explicitFinalRtpPort, "N/A");
804
1166
 
805
1167
  return {
806
1168
  reader,
807
- name: reader.name || reader.deviceId || "HID Reader",
808
- location: reader.location || "N/A",
1169
+ name: toText(reader.name || reader.deviceId, "HID Reader"),
1170
+ location: toText(reader.location, "N/A"),
809
1171
  serverAddress: toText(getPjsipValue(pjsip, ["server_ip", "serverAddress", "server_address", "serverIp"]), "N/A"),
810
1172
  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",
1173
+ initialRtpPort,
1174
+ finalRtpPort,
1175
+ monitorPath: toText(reader.monitorPath, "N/A"),
814
1176
  status,
815
1177
  sipEnabled: toFlag(getPjsipValue(pjsip, ["enabled"])) === "1",
816
1178
  numericExtension: toFlag(getPjsipValue(pjsip, ["numeric_branch_enabled", "numericExtension", "numericExtensionEnabled"])) === "1",
@@ -819,6 +1181,7 @@ function toIntercomRow(reader: Record<string, any>, pjsip: Record<string, any>,
819
1181
  password: toText(getPjsipValue(pjsip, ["password"]), ""),
820
1182
  operatingMode: peerToPeer ? "Peer-to-Peer" : "SIP Client",
821
1183
  protocol: toText(getPjsipValue(pjsip, ["protocol"]), "UDP"),
1184
+ videoEnabled: toFlag(getPjsipValue(pjsip, ["video_enabled", "videoEnabled"])) === "1",
822
1185
  dialMode,
823
1186
  dialModeLabel: getDialModeLabel(dialMode),
824
1187
  dialCode: toText(getPjsipValue(pjsip, ["open_door_command", "dialCode"]), "N/A"),
@@ -835,7 +1198,7 @@ function toIntercomRow(reader: Record<string, any>, pjsip: Record<string, any>,
835
1198
  };
836
1199
  }
837
1200
 
838
- function normalizePjsip(...sources: Record<string, any>[]) {
1201
+ function normalizePjsip(...sources: Record<string, unknown>[]) {
839
1202
  for (const source of sources) {
840
1203
  const candidate = findPjsipCandidate(source);
841
1204
  if (candidate) return candidate;
@@ -843,45 +1206,46 @@ function normalizePjsip(...sources: Record<string, any>[]) {
843
1206
  return {};
844
1207
  }
845
1208
 
846
- function findPjsipCandidate(source: Record<string, any> | undefined): Record<string, any> | undefined {
1209
+ function findPjsipCandidate(source: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
847
1210
  if (!source || typeof source !== "object") return undefined;
848
1211
  const candidates = [
849
1212
  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,
1213
+ toRecord(source.pjsip),
1214
+ toRecord(toRecord(source.configuration).pjsip),
1215
+ toRecord(toRecord(source.result).pjsip),
1216
+ toRecord(toRecord(toRecord(source.result).configuration).pjsip),
1217
+ toRecord(toRecord(source.data).pjsip),
1218
+ toRecord(toRecord(toRecord(source.data).configuration).pjsip),
1219
+ toRecord(toRecord(toRecord(source.data).result).pjsip),
1220
+ toRecord(toRecord(toRecord(toRecord(source.data).result).configuration).pjsip),
1221
+ toRecord(toRecord(toRecord(source.data).data).pjsip),
1222
+ toRecord(toRecord(toRecord(toRecord(source.data).data).configuration).pjsip),
1223
+ toRecord(source.configuration),
1224
+ toRecord(toRecord(source.data).configuration),
1225
+ toRecord(toRecord(toRecord(source.data).data).configuration),
862
1226
  source,
863
1227
  ];
864
1228
 
865
1229
  return candidates.find((candidate) => isPjsipCandidate(candidate));
866
1230
  }
867
1231
 
868
- function isPjsipCandidate(value: unknown): value is Record<string, any> {
1232
+ function isPjsipCandidate(value: unknown): value is Record<string, unknown> {
869
1233
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
870
1234
  return pjsipKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key)) ||
871
1235
  ["serverAddress", "serverIp", "serverPort", "extension", "peerToPeerEnabled"].some((key) => Object.prototype.hasOwnProperty.call(value, key));
872
1236
  }
873
1237
 
874
- function getPjsipValue(source: Record<string, any>, keys: string[]) {
1238
+ function getPjsipValue(source: Record<string, unknown>, keys: string[]) {
875
1239
  for (const key of keys) {
876
1240
  if (source?.[key] !== undefined && source?.[key] !== null && source?.[key] !== "") return source[key];
877
1241
  }
878
1242
  return undefined;
879
1243
  }
880
1244
 
881
- function normalizeStatus(response: Record<string, any>, pjsip: Record<string, any>) {
1245
+ function normalizeStatus(response: Record<string, unknown>, pjsip: Record<string, unknown>) {
882
1246
  const status = Number(response?.status);
1247
+ if (toFlag(pjsip.enabled) === "0" || status === -1) return "Disabled";
883
1248
  if (status === 200) return "Connected";
884
- if (status === -1 || toFlag(pjsip.enabled) === "0") return "Disabled";
885
1249
  if (status === 0 || status === 100) return "Connecting";
886
1250
  if ([401, 403, 408, 503].includes(status)) return "Failed";
887
1251
  return "Not connected";
@@ -911,13 +1275,14 @@ function openSipDialog(row: IntercomRow) {
911
1275
  sipForm.serverIp = row.serverAddress === "N/A" ? "" : row.serverAddress;
912
1276
  sipForm.serverPort = row.serverPort === "N/A" ? "5060" : row.serverPort;
913
1277
  sipForm.initialRtpPort = row.initialRtpPort === "N/A" ? "10000" : row.initialRtpPort;
914
- sipForm.finalRtpPort = row.finalRtpPort === "N/A" ? "1000" : row.finalRtpPort;
1278
+ sipForm.finalRtpPort = row.finalRtpPort === "N/A" ? "10100" : row.finalRtpPort;
915
1279
  sipForm.numericExtension = row.numericExtension;
916
1280
  sipForm.extension = row.extension === "N/A" ? "" : row.extension;
917
1281
  sipForm.login = row.login === "N/A" ? "" : row.login;
918
1282
  sipForm.password = row.password;
919
1283
  sipForm.operatingMode = row.operatingMode === "Peer-to-Peer" ? "peer-to-peer" : "sip-client";
920
1284
  sipForm.protocol = row.protocol || "UDP";
1285
+ sipForm.videoEnabled = row.videoEnabled;
921
1286
  sipDialog.value = true;
922
1287
  }
923
1288
 
@@ -943,20 +1308,149 @@ function openDialCodeDialog(row: IntercomRow) {
943
1308
  dialCodeDialog.value = true;
944
1309
  }
945
1310
 
946
- function openMakeCallDialog(row: IntercomRow) {
1311
+ async function openMakeCallDialog(row: IntercomRow) {
947
1312
  selectedRow.value = row;
948
- makeCallForm.target = row.extension !== "N/A" ? row.extension : "";
1313
+ makeCallForm.target = sipAccount.value?.extension || "";
949
1314
  makeCallForm.status = "";
950
1315
  makeCallForm.statusType = "info";
951
1316
  makeCallDialog.value = true;
1317
+ if (!sipAccount.value && !sipAccountLoading.value) {
1318
+ await loadSipAccount();
1319
+ if (makeCallDialog.value && !makeCallForm.target) {
1320
+ makeCallForm.target = sipAccount.value?.extension || "";
1321
+ }
1322
+ }
1323
+ }
1324
+
1325
+ function openWebPhoneDialog(row?: IntercomRow) {
1326
+ const activeRow = row || selectedRow.value || intercomRows.value[0];
1327
+ if (activeRow) {
1328
+ selectedRow.value = activeRow;
1329
+ if (!webPhone.isRegistered.value && activeRow.extension !== "N/A") {
1330
+ webPhoneForm.destination = activeRow.extension;
1331
+ webPhoneForm.video = activeRow.videoEnabled;
1332
+ }
1333
+ }
1334
+ webPhoneDialog.value = true;
1335
+ if (!sipAccount.value && !sipAccountLoading.value) void loadSipAccount();
1336
+ }
1337
+
1338
+ async function loadSipAccount() {
1339
+ if (!props.org || !props.site) return;
1340
+
1341
+ sipAccountLoading.value = true;
1342
+ sipAccountError.value = "";
1343
+ try {
1344
+ const response = await ensureSipAccount(props.site, props.org);
1345
+ sipAccount.value = normalizeSipAccount(response);
1346
+ } catch (error: unknown) {
1347
+ sipAccount.value = null;
1348
+ sipAccountError.value = getErrorMessage(error);
1349
+ } finally {
1350
+ sipAccountLoading.value = false;
1351
+ }
1352
+ }
1353
+
1354
+ async function connectWebPhone() {
1355
+ const account = sipAccount.value;
1356
+ if (!account) {
1357
+ await loadSipAccount();
1358
+ if (!sipAccount.value) return;
1359
+ }
1360
+
1361
+ const activeAccount = sipAccount.value;
1362
+ if (!activeAccount) return;
1363
+
1364
+ webPhoneConnecting.value = true;
1365
+ try {
1366
+ await webPhone.connect({
1367
+ webSocketServer: activeAccount.websocketUrl,
1368
+ aor: activeAccount.sipAddress,
1369
+ authorizationUsername: activeAccount.username,
1370
+ authorizationPassword: activeAccount.password,
1371
+ displayName: "iService365 Web",
1372
+ video: webPhoneForm.video,
1373
+ }, {
1374
+ remoteVideo: remoteVideoEl.value,
1375
+ remoteAudio: remoteAudioEl.value,
1376
+ });
1377
+ showMessage("Web phone connected and ready to receive calls.", "success");
1378
+ } catch (error: unknown) {
1379
+ showMessage(getErrorMessage(error), "error");
1380
+ } finally {
1381
+ webPhoneConnecting.value = false;
1382
+ }
1383
+ }
1384
+
1385
+ async function disconnectWebPhone() {
1386
+ webPhoneConnecting.value = true;
1387
+ try {
1388
+ await webPhone.disconnect();
1389
+ showMessage("Web phone disconnected.", "info");
1390
+ } finally {
1391
+ webPhoneConnecting.value = false;
1392
+ }
1393
+ }
1394
+
1395
+ async function startWebCall() {
1396
+ webPhoneActionLoading.value = true;
1397
+ try {
1398
+ await webPhone.call(webPhoneForm.destination);
1399
+ } catch (error: unknown) {
1400
+ showMessage(getErrorMessage(error), "error");
1401
+ } finally {
1402
+ webPhoneActionLoading.value = false;
1403
+ }
1404
+ }
1405
+
1406
+ async function answerWebCall() {
1407
+ webPhoneActionLoading.value = true;
1408
+ try {
1409
+ await webPhone.answer();
1410
+ } catch (error: unknown) {
1411
+ showMessage(getErrorMessage(error), "error");
1412
+ } finally {
1413
+ webPhoneActionLoading.value = false;
1414
+ }
1415
+ }
1416
+
1417
+ async function declineWebCall() {
1418
+ webPhoneActionLoading.value = true;
1419
+ try {
1420
+ await webPhone.decline();
1421
+ } catch (error: unknown) {
1422
+ showMessage(getErrorMessage(error), "error");
1423
+ } finally {
1424
+ webPhoneActionLoading.value = false;
1425
+ }
1426
+ }
1427
+
1428
+ async function hangupWebCall() {
1429
+ webPhoneActionLoading.value = true;
1430
+ try {
1431
+ await webPhone.hangup();
1432
+ } catch (error: unknown) {
1433
+ showMessage(getErrorMessage(error), "error");
1434
+ } finally {
1435
+ webPhoneActionLoading.value = false;
1436
+ }
1437
+ }
1438
+
1439
+ function toggleWebMute() {
1440
+ try {
1441
+ webPhone.toggleMute();
1442
+ } catch (error: unknown) {
1443
+ showMessage(getErrorMessage(error), "error");
1444
+ }
952
1445
  }
953
1446
 
954
1447
  function openContactDialog(contact?: ContactRow) {
955
1448
  selectedContact.value = contact || null;
956
1449
  contactForm.id = contact?.id;
957
- contactForm.readerId = contact?.readerId || readers.value[0]?._id || "";
1450
+ contactForm.readerId = contact?.readerId || selectedReaderId.value || readers.value[0]?._id || "";
958
1451
  contactForm.name = contact?.name === "N/A" ? "" : contact?.name || "";
959
- contactForm.location = contact?.location === "N/A" ? "" : contact?.location || readers.value[0]?.location || "";
1452
+ const selectedReader = readers.value.find((reader) => reader._id === contactForm.readerId);
1453
+ contactForm.location = contact?.location === "N/A" ? "" : contact?.location || selectedReader?.location || "";
960
1454
  contactForm.serverAddress = contact?.serverAddress === "N/A" ? "" : contact?.serverAddress || "";
961
1455
  contactDialog.value = true;
962
1456
  }
@@ -966,6 +1460,39 @@ function openContactView(contact: ContactRow) {
966
1460
  contactViewDialog.value = true;
967
1461
  }
968
1462
 
1463
+ function getContactReaderStatus(contact: ContactRow) {
1464
+ return intercomRows.value.find((row) => row.reader._id === contact.readerId)?.status || "Check status";
1465
+ }
1466
+
1467
+ async function testContactSipStatus(contact: ContactRow) {
1468
+ const reader = intercomRows.value.find((row) => row.reader._id === contact.readerId);
1469
+ if (!reader) {
1470
+ showMessage("The HID reader for this contact is not available.", "error");
1471
+ return;
1472
+ }
1473
+
1474
+ testingContactReaderId.value = contact.readerId;
1475
+ try {
1476
+ await testSipConnection(reader);
1477
+ } finally {
1478
+ testingContactReaderId.value = "";
1479
+ }
1480
+ }
1481
+
1482
+ function openContactCall(contact: ContactRow) {
1483
+ const reader = intercomRows.value.find((row) => row.reader._id === contact.readerId);
1484
+ if (!reader) {
1485
+ showMessage("The HID reader for this contact is not available.", "error");
1486
+ return;
1487
+ }
1488
+
1489
+ selectedRow.value = reader;
1490
+ makeCallForm.target = contact.serverAddress === "N/A" ? "" : contact.serverAddress;
1491
+ makeCallForm.status = "";
1492
+ makeCallForm.statusType = "info";
1493
+ makeCallDialog.value = true;
1494
+ }
1495
+
969
1496
  function openDeleteContact(contact: ContactRow) {
970
1497
  selectedContact.value = contact;
971
1498
  deleteContactDialog.value = true;
@@ -974,21 +1501,35 @@ function openDeleteContact(contact: ContactRow) {
974
1501
  async function saveSipSettings() {
975
1502
  if (!selectedRow.value) return;
976
1503
  const isPeerToPeer = sipForm.operatingMode === "peer-to-peer";
1504
+ const initialRtpPort = Number(sipForm.initialRtpPort);
1505
+ const finalRtpPort = Number(sipForm.finalRtpPort);
1506
+
1507
+ if (
1508
+ !Number.isInteger(initialRtpPort) ||
1509
+ !Number.isInteger(finalRtpPort) ||
1510
+ initialRtpPort < 1 ||
1511
+ finalRtpPort > 65535 ||
1512
+ finalRtpPort <= initialRtpPort
1513
+ ) {
1514
+ showMessage("Final RTP Port must be greater than Initial RTP Port and both ports must be between 1 and 65535.", "warning");
1515
+ return;
1516
+ }
1517
+
977
1518
  const saved = await savePjsip(selectedRow.value.reader._id, {
978
1519
  enabled: sipForm.enabled ? "1" : "0",
979
1520
  server_ip: sipForm.serverIp,
980
1521
  server_port: sipForm.serverPort,
981
- server_outbound_port: sipForm.initialRtpPort,
982
- server_outbound_port_range: sipForm.finalRtpPort,
1522
+ server_outbound_port: String(initialRtpPort),
1523
+ server_outbound_port_range: String(finalRtpPort - initialRtpPort),
983
1524
  numeric_branch_enabled: sipForm.numericExtension ? "1" : "0",
984
1525
  branch: isPeerToPeer ? "" : sipForm.extension,
985
1526
  login: isPeerToPeer ? "" : sipForm.login,
986
1527
  password: isPeerToPeer ? "" : sipForm.password,
987
1528
  peer_to_peer_enabled: isPeerToPeer ? "1" : "0",
988
- }, { showSuccessMessage: false });
1529
+ video_enabled: sipForm.videoEnabled ? "1" : "0",
1530
+ });
989
1531
  if (saved) {
990
1532
  sipDialog.value = false;
991
- rebootConfirmDialog.value = true;
992
1533
  }
993
1534
  }
994
1535
 
@@ -1020,7 +1561,13 @@ async function runMakeCall() {
1020
1561
  if (!selectedRow.value) return;
1021
1562
  const target = makeCallForm.target.trim();
1022
1563
  if (!target) {
1023
- makeCallForm.status = "Please enter an address to dial.";
1564
+ makeCallForm.status = "Please enter an extension, SIP address, or peer IP.";
1565
+ makeCallForm.statusType = "warning";
1566
+ return;
1567
+ }
1568
+
1569
+ if (!isSipCallTarget(target)) {
1570
+ makeCallForm.status = "Enter a valid extension, SIP address, or IPv4 address.";
1024
1571
  makeCallForm.statusType = "warning";
1025
1572
  return;
1026
1573
  }
@@ -1042,6 +1589,10 @@ async function runMakeCall() {
1042
1589
  }
1043
1590
  }
1044
1591
 
1592
+ function isSipCallTarget(value = "") {
1593
+ return /^(?:sip:)?[A-Za-z0-9_.+-]+(?:@[A-Za-z0-9.-]+(?::\d{1,5})?)?$/.test(value.trim());
1594
+ }
1595
+
1045
1596
  async function runHangupCall() {
1046
1597
  if (!selectedRow.value) return;
1047
1598
  hangingUp.value = true;
@@ -1061,6 +1612,52 @@ async function runHangupCall() {
1061
1612
  }
1062
1613
  }
1063
1614
 
1615
+ async function testSipConnection(row: IntercomRow) {
1616
+ const readerId = toText(row.reader._id);
1617
+ if (!readerId || testingSipReaderId.value) return;
1618
+
1619
+ testingSipReaderId.value = readerId;
1620
+ try {
1621
+ const response = await getIntercomStatus(readerId);
1622
+ const responseRecord = toRecord(response);
1623
+ const statusPayload = toRecord(responseRecord.data || responseRecord);
1624
+ const status = Number(statusPayload.status);
1625
+
1626
+ await reload();
1627
+
1628
+ if (status === 200) {
1629
+ showMessage(`${row.name}: SIP is connected.`, "success");
1630
+ return;
1631
+ }
1632
+ if (status === -1) {
1633
+ showMessage(`${row.name}: SIP is disabled on the HID reader.`, "warning");
1634
+ return;
1635
+ }
1636
+ if (status === 0 || status === 100) {
1637
+ showMessage(`${row.name}: SIP is still connecting. Check the SIP settings on the HID reader.`, "warning");
1638
+ return;
1639
+ }
1640
+ if (status === 401 || status === 403) {
1641
+ showMessage(`${row.name}: SIP authentication failed. Check the account configured on the HID reader.`, "error");
1642
+ return;
1643
+ }
1644
+ if (status === 408) {
1645
+ showMessage(`${row.name}: The HID reader cannot reach the SIP server.`, "error");
1646
+ return;
1647
+ }
1648
+ if (status === 503) {
1649
+ showMessage(`${row.name}: The HID reader reported a SIP network failure.`, "error");
1650
+ return;
1651
+ }
1652
+
1653
+ showMessage(`${row.name}: SIP is not connected. Configure SIP on the HID reader and test again.`, "warning");
1654
+ } catch (error: unknown) {
1655
+ showMessage(getErrorMessage(error), "error");
1656
+ } finally {
1657
+ testingSipReaderId.value = "";
1658
+ }
1659
+ }
1660
+
1064
1661
  async function savePjsip(readerId: string, payload: Record<string, string>, options: { showSuccessMessage?: boolean } = {}) {
1065
1662
  saving.value = true;
1066
1663
  try {
@@ -1078,36 +1675,17 @@ async function savePjsip(readerId: string, payload: Record<string, string>, opti
1078
1675
  }
1079
1676
  }
1080
1677
 
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
1678
  async function saveContact() {
1106
1679
  if (!contactForm.readerId || !contactForm.name.trim() || !contactForm.serverAddress.trim()) {
1107
1680
  showMessage("Please complete the required contact fields.", "warning");
1108
1681
  return;
1109
1682
  }
1110
1683
 
1684
+ if (!isSipCallTarget(contactForm.serverAddress)) {
1685
+ showMessage("Enter a valid extension, SIP address, or IPv4 address.", "warning");
1686
+ return;
1687
+ }
1688
+
1111
1689
  contactsSaving.value = true;
1112
1690
  try {
1113
1691
  if (contactForm.id) {
@@ -1159,13 +1737,6 @@ async function deleteContact() {
1159
1737
  }
1160
1738
  }
1161
1739
 
1162
- function getStatusClass(status: string) {
1163
- if (status === "Connected") return "status-success";
1164
- if (status === "Disabled" || status === "Not connected") return "status-muted";
1165
- if (status === "Failed") return "status-error";
1166
- return "status-warning";
1167
- }
1168
-
1169
1740
  function getDialModeLabel(value: string) {
1170
1741
  if (value === "1") return "From the contact list";
1171
1742
  if (value === "2") return "Numeric Keypad + Contacts";
@@ -1182,8 +1753,42 @@ function showMessage(text: string, type: MessageType = "info") {
1182
1753
  message.type = type;
1183
1754
  }
1184
1755
 
1185
- function getErrorMessage(error: any) {
1186
- return error?.data?.message || error?.response?._data?.message || error?.message || "HID intercom request failed.";
1756
+ function normalizeSipAccount(response: unknown): SipAccount {
1757
+ const responseRecord = toRecord(response);
1758
+ const payload = toRecord(responseRecord.data || responseRecord);
1759
+ const requiredFields = ["extension", "username", "password", "sipAddress", "websocketUrl"] as const;
1760
+
1761
+ if (requiredFields.some((field) => !toText(payload[field]))) {
1762
+ throw new Error("The SIP account response is incomplete. Please try again.");
1763
+ }
1764
+
1765
+ return {
1766
+ orgId: toText(payload.orgId, props.org),
1767
+ site: toText(payload.site, props.site),
1768
+ userId: toText(payload.userId),
1769
+ extension: toText(payload.extension),
1770
+ username: toText(payload.username),
1771
+ password: toText(payload.password),
1772
+ sipAddress: toText(payload.sipAddress),
1773
+ websocketUrl: toText(payload.websocketUrl),
1774
+ status: "active",
1775
+ provisionedAt: toText(payload.provisionedAt),
1776
+ };
1777
+ }
1778
+
1779
+ function getErrorMessage(error: unknown) {
1780
+ const errorRecord = toRecord(error);
1781
+ const data = toRecord(errorRecord.data);
1782
+ const response = toRecord(errorRecord.response);
1783
+ const responseData = toRecord(response._data);
1784
+ return toText(data.message)
1785
+ || toText(responseData.message)
1786
+ || toText(errorRecord.message)
1787
+ || "HID intercom request failed.";
1788
+ }
1789
+
1790
+ function toRecord(value: unknown): Record<string, unknown> {
1791
+ return typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
1187
1792
  }
1188
1793
 
1189
1794
  function toText(value: unknown, fallback = "") {
@@ -1225,7 +1830,7 @@ export default {
1225
1830
  <span>{{ label }}</span>
1226
1831
  <v-switch
1227
1832
  :model-value="modelValue"
1228
- color="success"
1833
+ class="app-switch"
1229
1834
  density="compact"
1230
1835
  hide-details
1231
1836
  @update:model-value="$emit('update:modelValue', $event)"
@@ -1244,116 +1849,66 @@ export default {
1244
1849
  padding: 12px;
1245
1850
  }
1246
1851
 
1247
- .top-bar {
1248
- display: flex;
1249
- align-items: center;
1250
- gap: 12px;
1251
- margin-bottom: 12px;
1252
- }
1253
1852
 
1254
1853
  .toolbar-spacer {
1255
1854
  flex: 1 1 auto;
1256
1855
  }
1257
1856
 
1258
- .top-placeholder {
1259
- width: 160px;
1260
- }
1261
1857
 
1262
- .add-button {
1263
- min-width: 160px;
1264
- background: #dedede;
1265
- color: #0b2c40;
1266
- font-size: 12px;
1858
+ /*
1859
+ The filter row sits INSIDE the card's extension, so it needs the card's own
1860
+ side padding rather than the row's default bottom margin - same block as
1861
+ panels 2, 4 and 5.
1862
+ */
1863
+ .hid-intercom-filters {
1864
+ margin: 0;
1865
+ padding: 10px 14px;
1267
1866
  }
1268
1867
 
1269
- .search-input {
1270
- max-width: 220px;
1868
+ .hid-intercom-filters :deep(.app-field) {
1869
+ max-width: 240px;
1271
1870
  }
1272
1871
 
1273
- .status-input {
1274
- max-width: 160px;
1872
+ /* The reader option now reads "Reader - Portal", so it needs more room than
1873
+ the other filters. `AppSelect`'s root class IS `app-field`, so this has to
1874
+ be more specific than the rule above rather than a `.app-select` one. */
1875
+ .hid-intercom-filters :deep(.app-field.reader-select) {
1876
+ max-width: 320px;
1275
1877
  }
1276
1878
 
1277
- .table-card {
1278
- display: flex;
1279
- flex-direction: column;
1280
- min-height: 520px;
1281
- overflow: hidden;
1282
- background: #fff;
1283
- border-color: #e2e5e9;
1284
- border-radius: 6px;
1879
+ /*
1880
+ The web-call button is the card's PRIMARY action, so its resting state is
1881
+ `AppButton`'s own accent - it was a flat `#e5e7eb` grey, which read as
1882
+ disabled next to every other screen's primary. Only the two STATE colours
1883
+ are the screen's own, because the handoff draws no such states.
1884
+ */
1885
+ .web-call-button {
1886
+ min-width: 160px;
1285
1887
  }
1286
1888
 
1287
- .table-refresh {
1288
- display: flex;
1289
- align-items: center;
1290
- min-height: 42px;
1291
- padding: 0 12px;
1292
- background: #f6f6f6;
1293
- color: #0b2c40;
1294
- font-size: 12px;
1889
+ .web-call-button.is-connected {
1890
+ background: var(--ok);
1891
+ color: var(--on-accent-strong);
1295
1892
  }
1296
1893
 
1297
- .intercom-tabs {
1298
- border-bottom: 1px solid #e5e7eb;
1894
+ .web-call-button.has-incoming-call {
1895
+ background: var(--warn);
1896
+ color: var(--on-accent-strong);
1299
1897
  }
1300
1898
 
1301
- .intercom-tabs :deep(.v-tab) {
1302
- flex: 1 1 0;
1303
- min-width: 0;
1304
- font-size: 12px;
1305
- text-transform: none;
1306
- }
1307
1899
 
1308
- .intercom-tabs :deep(.v-tab--selected) {
1309
- color: #0b4ea2;
1310
- }
1311
1900
 
1312
- .table-body {
1313
- position: relative;
1314
- flex: 1 1 auto;
1315
- min-height: 0;
1316
- overflow: auto;
1317
- }
1318
1901
 
1319
- .table-body :deep(table) {
1320
- table-layout: fixed;
1321
- min-width: 840px;
1322
- }
1323
1902
 
1324
- .table-body :deep(th) {
1325
- height: 48px;
1326
- color: #0b2c40;
1327
- font-size: 12px;
1328
- font-weight: 500;
1329
- }
1330
1903
 
1331
- .table-body :deep(td) {
1332
- height: 54px;
1333
- color: #1f2937;
1334
- font-size: 12px;
1335
- }
1336
1904
 
1337
- .table-footer {
1338
- display: flex;
1339
- flex: 0 0 auto;
1340
- align-items: center;
1341
- justify-content: flex-end;
1342
- gap: 4px;
1343
- min-height: 42px;
1344
- padding: 0 12px;
1345
- background: #f6f6f6;
1346
- color: #0b2c40;
1347
- font-size: 12px;
1348
- }
1349
1905
 
1350
- .empty-state {
1351
- display: grid;
1352
- min-height: 180px;
1353
- place-items: center;
1354
- color: #667085;
1355
- font-size: 13px;
1356
- }
1906
+
1907
+
1908
+
1909
+
1910
+
1911
+
1357
1912
 
1358
1913
  .row-menu {
1359
1914
  min-width: 160px;
@@ -1361,37 +1916,21 @@ export default {
1361
1916
  }
1362
1917
 
1363
1918
  .link-text {
1364
- color: #1565c0;
1919
+ color: var(--accent-text);
1365
1920
  }
1366
1921
 
1367
1922
  .success-text {
1368
- color: #2faf4a;
1923
+ color: var(--ok);
1369
1924
  }
1370
1925
 
1371
- .status-success {
1372
- background: #46b85a;
1373
- color: #fff;
1374
- }
1375
1926
 
1376
- .status-muted {
1377
- background: #a7a7a7;
1378
- color: #fff;
1379
- }
1380
1927
 
1381
- .status-warning {
1382
- background: #fff2cc;
1383
- color: #8a5b00;
1384
- }
1385
1928
 
1386
- .status-error {
1387
- background: #f9d0d0;
1388
- color: #991b1b;
1389
- }
1390
1929
 
1391
1930
  .form-dialog,
1392
1931
  .detail-dialog {
1393
1932
  overflow: hidden;
1394
- background: #fff;
1933
+ background: var(--card);
1395
1934
  }
1396
1935
 
1397
1936
  .sip-settings-dialog {
@@ -1402,8 +1941,8 @@ export default {
1402
1941
  .dialog-title {
1403
1942
  min-height: 48px;
1404
1943
  padding: 14px 16px;
1405
- background: #f4f4f4;
1406
- color: #0d1720;
1944
+ background: var(--thead);
1945
+ color: var(--text);
1407
1946
  font-size: 14px;
1408
1947
  font-weight: 700;
1409
1948
  }
@@ -1424,13 +1963,13 @@ export default {
1424
1963
 
1425
1964
  .field-label {
1426
1965
  margin: 12px 0 7px;
1427
- color: #344054;
1966
+ color: var(--text2);
1428
1967
  font-size: 10px;
1429
1968
  font-weight: 500;
1430
1969
  }
1431
1970
 
1432
1971
  .field-label span {
1433
- color: #d92d20;
1972
+ color: var(--err);
1434
1973
  }
1435
1974
 
1436
1975
  .sip-settings-dialog .field-label {
@@ -1439,14 +1978,14 @@ export default {
1439
1978
 
1440
1979
  .sip-label {
1441
1980
  margin: 12px 0 6px;
1442
- color: #263542;
1981
+ color: var(--text);
1443
1982
  font-size: 10px;
1444
1983
  font-weight: 500;
1445
1984
  line-height: 1.2;
1446
1985
  }
1447
1986
 
1448
1987
  .sip-label span {
1449
- color: #e5484d;
1988
+ color: var(--err);
1450
1989
  }
1451
1990
 
1452
1991
  .sip-switch-row {
@@ -1456,10 +1995,10 @@ export default {
1456
1995
  min-height: 40px;
1457
1996
  margin-bottom: 12px;
1458
1997
  padding: 0 12px;
1459
- border: 1px solid #dfe3e8;
1998
+ border: 1px solid var(--border);
1460
1999
  border-radius: 3px;
1461
- background: #fff;
1462
- color: #25313b;
2000
+ background: var(--card);
2001
+ color: var(--text);
1463
2002
  font-size: 12px;
1464
2003
  }
1465
2004
 
@@ -1468,39 +2007,26 @@ export default {
1468
2007
  margin-bottom: 12px;
1469
2008
  }
1470
2009
 
1471
- .sip-switch-row :deep(.v-switch) {
1472
- flex: 0 0 auto;
2010
+ .sip-section-switch > div {
2011
+ display: flex;
2012
+ min-width: 0;
2013
+ padding: 8px 12px 8px 0;
2014
+ flex-direction: column;
2015
+ gap: 2px;
1473
2016
  }
1474
2017
 
1475
- .sip-switch-row :deep(.v-selection-control) {
1476
- min-height: 32px;
2018
+ .sip-section-switch small {
2019
+ color: var(--muted);
2020
+ font-size: 10px;
2021
+ line-height: 1.4;
1477
2022
  }
1478
2023
 
1479
- .sip-switch-row :deep(.v-selection-control__wrapper) {
1480
- height: 28px;
1481
- }
1482
2024
 
1483
- .sip-switch-row :deep(.v-switch__track) {
1484
- height: 24px;
1485
- min-width: 42px;
1486
- opacity: 1;
1487
- }
1488
2025
 
1489
- .sip-switch-row :deep(.v-switch__thumb) {
1490
- background-color: #fff !important;
1491
- color: #fff !important;
1492
- height: 20px;
1493
- width: 20px;
1494
- }
1495
2026
 
1496
- .sip-switch-row :deep(.v-selection-control--dirty .v-switch__thumb) {
1497
- background-color: #fff !important;
1498
- color: #fff !important;
1499
- }
1500
2027
 
1501
- .sip-switch-row :deep(.v-selection-control--dirty .v-switch__thumb::before) {
1502
- background-color: #fff !important;
1503
- }
2028
+
2029
+
1504
2030
 
1505
2031
  .toggle-field {
1506
2032
  display: flex;
@@ -1509,27 +2035,27 @@ export default {
1509
2035
  min-height: 38px;
1510
2036
  margin-bottom: 8px;
1511
2037
  padding: 0 10px;
1512
- border: 1px solid #e5e7eb;
2038
+ border: 1px solid var(--border);
1513
2039
  border-radius: 3px;
1514
- color: #344054;
2040
+ color: var(--text2);
1515
2041
  font-size: 12px;
1516
2042
  }
1517
2043
 
1518
2044
  .sip-input :deep(.v-field) {
1519
2045
  min-height: 40px;
1520
2046
  border-radius: 3px;
1521
- background: #fff;
2047
+ background: var(--card);
1522
2048
  font-size: 12px;
1523
2049
  box-shadow: none;
1524
2050
  }
1525
2051
 
1526
2052
  .sip-input :deep(.v-field__outline) {
1527
2053
  --v-field-border-opacity: 1;
1528
- color: #dfe3e8;
2054
+ color: var(--border);
1529
2055
  }
1530
2056
 
1531
2057
  .sip-input :deep(.v-field--focused .v-field__outline) {
1532
- color: #9db7d1;
2058
+ color: var(--border);
1533
2059
  }
1534
2060
 
1535
2061
  .sip-input :deep(.v-field__outline__start),
@@ -1542,28 +2068,28 @@ export default {
1542
2068
  padding-top: 0;
1543
2069
  padding-bottom: 0;
1544
2070
  padding-inline: 12px;
1545
- color: #4a5562;
2071
+ color: var(--text2);
1546
2072
  font-size: 12px;
1547
2073
  }
1548
2074
 
1549
2075
  .sip-input :deep(.v-field__append-inner) {
1550
2076
  min-height: 40px;
1551
- color: #697586;
2077
+ color: var(--muted);
1552
2078
  }
1553
2079
 
1554
2080
  .sip-input :deep(.v-field--disabled) {
1555
- background: #f7f8fa;
2081
+ background: var(--hover);
1556
2082
  opacity: 1;
1557
2083
  }
1558
2084
 
1559
2085
  .sip-input :deep(.v-field--disabled .v-field__input),
1560
2086
  .sip-input :deep(.v-field--disabled input::placeholder) {
1561
- color: #98a2b3;
2087
+ color: var(--muted);
1562
2088
  opacity: 1;
1563
2089
  }
1564
2090
 
1565
2091
  .sip-input :deep(input::placeholder) {
1566
- color: #697586;
2092
+ color: var(--muted);
1567
2093
  opacity: 1;
1568
2094
  }
1569
2095
 
@@ -1578,7 +2104,7 @@ export default {
1578
2104
  width: 100%;
1579
2105
  grid-template-columns: 1fr 1fr;
1580
2106
  margin-bottom: 6px;
1581
- border: 1px solid #dfe3e8;
2107
+ border: 1px solid var(--border);
1582
2108
  border-radius: 3px;
1583
2109
  overflow: hidden;
1584
2110
  }
@@ -1587,20 +2113,20 @@ export default {
1587
2113
  min-width: 0;
1588
2114
  min-height: 40px;
1589
2115
  border-radius: 0;
1590
- color: #667085;
2116
+ color: var(--muted);
1591
2117
  font-size: 12px;
1592
2118
  font-weight: 400;
1593
2119
  letter-spacing: 0;
1594
2120
  }
1595
2121
 
1596
2122
  .mode-toggle :deep(.v-btn--active) {
1597
- background: #082b40;
1598
- color: #fff;
2123
+ background: var(--text);
2124
+ color: var(--card);
1599
2125
  }
1600
2126
 
1601
2127
  .mode-toggle :deep(.v-btn--disabled) {
1602
- background: #f7f8fa;
1603
- color: #98a2b3;
2128
+ background: var(--hover);
2129
+ color: var(--muted);
1604
2130
  opacity: 1;
1605
2131
  }
1606
2132
 
@@ -1608,13 +2134,9 @@ export default {
1608
2134
  margin-top: 0;
1609
2135
  }
1610
2136
 
1611
- .sip-settings-dialog :deep(.v-selection-control-group--inline) {
1612
- gap: 34px;
1613
- min-height: 34px;
1614
- }
1615
2137
 
1616
2138
  .sip-settings-dialog :deep(.v-radio .v-label) {
1617
- color: #344054;
2139
+ color: var(--text2);
1618
2140
  font-size: 12px;
1619
2141
  }
1620
2142
 
@@ -1630,112 +2152,20 @@ export default {
1630
2152
  overflow: hidden;
1631
2153
  }
1632
2154
 
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
2155
  .call-dialog .dialog-body {
1726
2156
  padding: 18px 24px 24px;
1727
2157
  }
1728
2158
 
1729
2159
  .call-label {
1730
2160
  margin: 8px 0 6px;
1731
- color: #1f2937;
2161
+ color: var(--text);
1732
2162
  font-size: 10px;
1733
2163
  font-weight: 500;
1734
2164
  letter-spacing: 0;
1735
2165
  }
1736
2166
 
1737
2167
  .call-label span {
1738
- color: #d92d20;
2168
+ color: var(--err);
1739
2169
  }
1740
2170
 
1741
2171
  .call-radio-list {
@@ -1746,29 +2176,12 @@ export default {
1746
2176
  width: 100%;
1747
2177
  }
1748
2178
 
1749
- .call-radio-list :deep(.v-selection-control-group) {
1750
- gap: 8px;
1751
- }
1752
2179
 
1753
- .call-radio-list :deep(.v-selection-control) {
1754
- min-height: 36px;
1755
- margin-bottom: 0;
1756
- padding: 0 10px;
1757
- border: 1px solid #dfe3e8;
1758
- border-radius: 3px;
1759
- background: #fff;
1760
- }
1761
2180
 
1762
- .call-radio-list :deep(.v-selection-control__wrapper) {
1763
- width: 24px;
1764
- }
1765
2181
 
1766
- .call-radio-list :deep(.v-selection-control__input) {
1767
- transform: scale(0.9);
1768
- }
1769
2182
 
1770
2183
  .call-radio-list :deep(.v-label) {
1771
- color: #475467;
2184
+ color: var(--text2);
1772
2185
  font-size: 11px;
1773
2186
  letter-spacing: 0;
1774
2187
  opacity: 1;
@@ -1781,34 +2194,17 @@ export default {
1781
2194
  min-height: 36px;
1782
2195
  margin-bottom: 10px;
1783
2196
  padding: 0 10px;
1784
- border: 1px solid #e5e7eb;
2197
+ border: 1px solid var(--border);
1785
2198
  border-radius: 3px;
1786
- background: #fff;
1787
- color: #1f2937;
2199
+ background: var(--card);
2200
+ color: var(--text);
1788
2201
  font-size: 11px;
1789
2202
  letter-spacing: 0;
1790
2203
  }
1791
2204
 
1792
- .call-toggle-row :deep(.v-selection-control) {
1793
- min-height: 28px;
1794
- }
1795
2205
 
1796
- .call-toggle-row :deep(.v-selection-control__wrapper) {
1797
- height: 28px;
1798
- }
1799
2206
 
1800
- .call-toggle-row :deep(.v-switch__track) {
1801
- height: 22px;
1802
- min-width: 40px;
1803
- opacity: 1;
1804
- }
1805
2207
 
1806
- .call-toggle-row :deep(.v-switch__thumb) {
1807
- width: 18px;
1808
- height: 18px;
1809
- background-color: #fff !important;
1810
- color: #fff !important;
1811
- }
1812
2208
 
1813
2209
  .call-input {
1814
2210
  margin-bottom: 8px;
@@ -1817,13 +2213,13 @@ export default {
1817
2213
  .call-input :deep(.v-field) {
1818
2214
  min-height: 38px;
1819
2215
  border-radius: 3px;
1820
- background: #fff;
2216
+ background: var(--card);
1821
2217
  box-shadow: none;
1822
2218
  }
1823
2219
 
1824
2220
  .call-input :deep(.v-field__outline) {
1825
2221
  --v-field-border-opacity: 1;
1826
- color: #cfd5dc;
2222
+ color: var(--border);
1827
2223
  }
1828
2224
 
1829
2225
  .call-input :deep(.v-field__input) {
@@ -1831,19 +2227,19 @@ export default {
1831
2227
  padding-top: 0;
1832
2228
  padding-bottom: 0;
1833
2229
  padding-inline: 12px;
1834
- color: #4a4f55;
2230
+ color: var(--text2);
1835
2231
  font-size: 12px;
1836
2232
  letter-spacing: 0;
1837
2233
  }
1838
2234
 
1839
2235
  .call-input :deep(.v-field--disabled) {
1840
- background: #f7f8fa;
2236
+ background: var(--hover);
1841
2237
  opacity: 1;
1842
2238
  }
1843
2239
 
1844
2240
  .call-input :deep(.v-field--disabled .v-field__input),
1845
2241
  .call-input :deep(.v-field--disabled input::placeholder) {
1846
- color: #98a2b3;
2242
+ color: var(--muted);
1847
2243
  opacity: 1;
1848
2244
  }
1849
2245
 
@@ -1885,17 +2281,17 @@ export default {
1885
2281
  .make-call-danger {
1886
2282
  min-height: 46px;
1887
2283
  border-radius: 0;
1888
- color: #fff;
2284
+ color: var(--card);
1889
2285
  font-size: 14px;
1890
2286
  letter-spacing: 0;
1891
2287
  }
1892
2288
 
1893
2289
  .make-call-primary {
1894
- background: #06324f;
2290
+ background: var(--text);
1895
2291
  }
1896
2292
 
1897
2293
  .make-call-danger {
1898
- background: #d91345;
2294
+ background: var(--err);
1899
2295
  }
1900
2296
 
1901
2297
  .make-call-status {
@@ -1906,31 +2302,299 @@ export default {
1906
2302
  }
1907
2303
 
1908
2304
  .make-call-status.is-success {
1909
- background: #e7f7ec;
1910
- color: #17833b;
2305
+ background: var(--ok-bg);
2306
+ color: var(--ok);
1911
2307
  }
1912
2308
 
1913
2309
  .make-call-status.is-warning {
1914
- background: #fff7db;
1915
- color: #8a5b00;
2310
+ background: var(--warn-bg);
2311
+ color: var(--warn);
1916
2312
  }
1917
2313
 
1918
2314
  .make-call-status.is-error {
1919
- background: #fde8e8;
1920
- color: #b42318;
2315
+ background: var(--err-bg);
2316
+ color: var(--err);
1921
2317
  }
1922
2318
 
1923
2319
  .make-call-status.is-info {
1924
- background: #edf4ff;
1925
- color: #075fb7;
2320
+ background: var(--accent-soft);
2321
+ color: var(--accent-text);
1926
2322
  }
1927
2323
 
1928
2324
  .make-call-note {
1929
- color: #5d6673;
2325
+ color: var(--text2);
1930
2326
  font-size: 13px;
1931
2327
  line-height: 1.45;
1932
2328
  }
1933
2329
 
2330
+ .web-phone-dialog {
2331
+ display: flex;
2332
+ width: 820px;
2333
+ max-height: min(90vh, 760px);
2334
+ max-width: 94vw;
2335
+ overflow: hidden;
2336
+ background: var(--card);
2337
+ flex-direction: column;
2338
+ }
2339
+
2340
+ .web-phone-title {
2341
+ flex: 0 0 auto;
2342
+ display: flex;
2343
+ align-items: center;
2344
+ justify-content: space-between;
2345
+ gap: 16px;
2346
+ }
2347
+
2348
+ .web-phone-title-actions {
2349
+ display: flex;
2350
+ align-items: center;
2351
+ gap: 8px;
2352
+ }
2353
+
2354
+ .web-phone-status {
2355
+ min-width: 92px;
2356
+ justify-content: center;
2357
+ font-size: 10px;
2358
+ }
2359
+
2360
+ .web-phone-status.ready {
2361
+ background: var(--ok-bg);
2362
+ color: var(--ok);
2363
+ }
2364
+
2365
+ .web-phone-status.incoming {
2366
+ background: var(--accent-soft);
2367
+ color: var(--accent-text);
2368
+ }
2369
+
2370
+ .web-phone-status.failed {
2371
+ background: var(--err-bg);
2372
+ color: var(--err);
2373
+ }
2374
+
2375
+ .web-phone-status.idle {
2376
+ background: var(--border);
2377
+ color: var(--text2);
2378
+ }
2379
+
2380
+ .web-phone-body {
2381
+ display: grid;
2382
+ grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.1fr);
2383
+ gap: 24px;
2384
+ min-height: 440px;
2385
+ overflow: auto;
2386
+ padding: 22px 24px 24px;
2387
+ }
2388
+
2389
+ .web-phone-settings {
2390
+ padding-right: 24px;
2391
+ border-right: 1px solid var(--border);
2392
+ }
2393
+
2394
+ .web-phone-section-title {
2395
+ margin-bottom: 14px;
2396
+ color: var(--text);
2397
+ font-size: 13px;
2398
+ font-weight: 700;
2399
+ }
2400
+
2401
+ .sip-account-state {
2402
+ display: flex;
2403
+ min-height: 126px;
2404
+ align-items: center;
2405
+ justify-content: center;
2406
+ padding: 18px;
2407
+ border: 1px solid var(--border);
2408
+ border-radius: 6px;
2409
+ color: var(--text2);
2410
+ flex-direction: column;
2411
+ font-size: 12px;
2412
+ gap: 12px;
2413
+ text-align: center;
2414
+ }
2415
+
2416
+ .sip-account-state.is-error {
2417
+ border-color: var(--err-bg);
2418
+ background: var(--err-bg);
2419
+ color: var(--err);
2420
+ }
2421
+
2422
+ .sip-account-retry {
2423
+ min-width: 104px;
2424
+ border-color: var(--muted);
2425
+ color: var(--text2);
2426
+ }
2427
+
2428
+ .sip-account-summary {
2429
+ display: grid;
2430
+ min-height: 96px;
2431
+ align-items: center;
2432
+ padding: 16px;
2433
+ border: 1px solid var(--border);
2434
+ border-radius: 6px;
2435
+ background: var(--hover);
2436
+ grid-template-columns: minmax(0, 1fr) auto;
2437
+ gap: 18px;
2438
+ }
2439
+
2440
+ .sip-account-summary > div {
2441
+ display: flex;
2442
+ min-width: 0;
2443
+ flex-direction: column;
2444
+ gap: 4px;
2445
+ }
2446
+
2447
+ .sip-account-label {
2448
+ color: var(--muted);
2449
+ font-size: 11px;
2450
+ }
2451
+
2452
+ .sip-account-extension {
2453
+ color: var(--text);
2454
+ font-size: 24px;
2455
+ line-height: 1.1;
2456
+ }
2457
+
2458
+ .sip-account-address {
2459
+ max-width: 170px;
2460
+ overflow: hidden;
2461
+ color: var(--text2);
2462
+ font-size: 11px;
2463
+ text-align: right;
2464
+ text-overflow: ellipsis;
2465
+ white-space: nowrap;
2466
+ }
2467
+
2468
+ .web-video-toggle {
2469
+ margin-top: 12px;
2470
+ }
2471
+
2472
+ .web-phone-connect {
2473
+ min-height: 42px;
2474
+ margin-top: 14px;
2475
+ background: var(--text);
2476
+ color: var(--card);
2477
+ }
2478
+
2479
+ .web-phone-disconnect {
2480
+ min-height: 42px;
2481
+ margin-top: 14px;
2482
+ border-color: var(--muted);
2483
+ color: var(--text2);
2484
+ }
2485
+
2486
+ .web-phone-console {
2487
+ display: flex;
2488
+ min-width: 0;
2489
+ flex-direction: column;
2490
+ }
2491
+
2492
+ .web-phone-media {
2493
+ position: relative;
2494
+ display: grid;
2495
+ min-height: 260px;
2496
+ overflow: hidden;
2497
+ place-items: center;
2498
+ border: 1px solid var(--border);
2499
+ border-radius: 6px;
2500
+ background: var(--text);
2501
+ }
2502
+
2503
+ .remote-video {
2504
+ display: none;
2505
+ width: 100%;
2506
+ height: 100%;
2507
+ object-fit: cover;
2508
+ }
2509
+
2510
+ .web-phone-media.has-remote-video .remote-video {
2511
+ display: block;
2512
+ }
2513
+
2514
+ .web-phone-media.has-remote-video .audio-call-state {
2515
+ display: none;
2516
+ }
2517
+
2518
+ .audio-call-state {
2519
+ display: flex;
2520
+ align-items: center;
2521
+ justify-content: center;
2522
+ padding: 28px;
2523
+ color: var(--card);
2524
+ flex-direction: column;
2525
+ gap: 10px;
2526
+ text-align: center;
2527
+ }
2528
+
2529
+ .audio-call-state strong {
2530
+ font-size: 16px;
2531
+ font-weight: 600;
2532
+ }
2533
+
2534
+ .audio-call-state span {
2535
+ max-width: 100%;
2536
+ overflow: hidden;
2537
+ color: var(--border);
2538
+ font-size: 11px;
2539
+ text-overflow: ellipsis;
2540
+ white-space: nowrap;
2541
+ }
2542
+
2543
+ .web-phone-dial-row {
2544
+ display: grid;
2545
+ grid-template-columns: minmax(0, 1fr) 42px;
2546
+ gap: 8px;
2547
+ align-items: start;
2548
+ }
2549
+
2550
+ .start-web-call,
2551
+ .hangup-web-call {
2552
+ width: 42px;
2553
+ height: 38px;
2554
+ border-radius: 4px;
2555
+ background: var(--ok);
2556
+ color: var(--card);
2557
+ }
2558
+
2559
+ .hangup-web-call,
2560
+ .reject-call {
2561
+ background: var(--err);
2562
+ }
2563
+
2564
+ .incoming-call-actions {
2565
+ display: grid;
2566
+ grid-template-columns: 1fr 1fr;
2567
+ gap: 10px;
2568
+ margin-top: 14px;
2569
+ }
2570
+
2571
+ .answer-call,
2572
+ .reject-call {
2573
+ min-height: 42px;
2574
+ color: var(--card);
2575
+ }
2576
+
2577
+ .answer-call {
2578
+ background: var(--ok);
2579
+ }
2580
+
2581
+ .active-call-controls {
2582
+ display: flex;
2583
+ justify-content: center;
2584
+ gap: 12px;
2585
+ margin-top: 16px;
2586
+ }
2587
+
2588
+ .active-call-controls .v-btn {
2589
+ width: 46px;
2590
+ height: 46px;
2591
+ }
2592
+
2593
+ .web-phone-error {
2594
+ margin-top: 14px;
2595
+ font-size: 11px;
2596
+ }
2597
+
1934
2598
  .call-two-col {
1935
2599
  gap: 12px;
1936
2600
  }
@@ -1953,28 +2617,28 @@ export default {
1953
2617
  justify-content: center;
1954
2618
  gap: 6px;
1955
2619
  min-height: 38px;
1956
- border: 1px dashed #c9d2dc;
1957
- color: #475467;
2620
+ border: 1px dashed var(--border);
2621
+ color: var(--text2);
1958
2622
  font-size: 11px;
1959
2623
  letter-spacing: 0;
1960
2624
  }
1961
2625
 
1962
2626
  .upload-drop.is-disabled {
1963
- background: #fafafa;
1964
- color: #98a2b3;
2627
+ background: var(--thead);
2628
+ color: var(--muted);
1965
2629
  }
1966
2630
 
1967
2631
  .upload-button {
1968
2632
  width: 40px;
1969
2633
  height: 38px;
1970
2634
  border-radius: 3px;
1971
- background: #1976d2;
1972
- color: #fff;
2635
+ background: var(--accent-text);
2636
+ color: var(--card);
1973
2637
  }
1974
2638
 
1975
2639
  .upload-button.v-btn--disabled {
1976
- background: #eceff3;
1977
- color: #c0c6cf;
2640
+ background: var(--hover);
2641
+ color: var(--border);
1978
2642
  opacity: 1;
1979
2643
  }
1980
2644
 
@@ -1984,7 +2648,7 @@ export default {
1984
2648
  justify-content: space-between;
1985
2649
  min-height: 32px;
1986
2650
  margin: 0 0 10px;
1987
- color: #475467;
2651
+ color: var(--text2);
1988
2652
  font-size: 11px;
1989
2653
  }
1990
2654
 
@@ -1995,12 +2659,12 @@ export default {
1995
2659
  }
1996
2660
 
1997
2661
  .call-file-name .v-icon {
1998
- color: #2fb344;
2662
+ color: var(--ok);
1999
2663
  }
2000
2664
 
2001
2665
  .detail-section-title {
2002
2666
  margin: 14px 0 8px;
2003
- color: #0d1720;
2667
+ color: var(--text);
2004
2668
  font-size: 13px;
2005
2669
  font-weight: 700;
2006
2670
  }
@@ -2015,12 +2679,12 @@ export default {
2015
2679
  justify-content: space-between;
2016
2680
  gap: 16px;
2017
2681
  min-height: 26px;
2018
- color: #667085;
2682
+ color: var(--muted);
2019
2683
  font-size: 11px;
2020
2684
  }
2021
2685
 
2022
2686
  .detail-row strong {
2023
- color: #101828;
2687
+ color: var(--text);
2024
2688
  font-weight: 500;
2025
2689
  text-align: right;
2026
2690
  }
@@ -2028,22 +2692,25 @@ export default {
2028
2692
  .dialog-actions {
2029
2693
  display: grid;
2030
2694
  grid-template-columns: 1fr 1fr;
2031
- border-top: 1px solid #e5e7eb;
2695
+ border-top: 1px solid var(--border);
2032
2696
  }
2033
2697
 
2034
2698
  .sip-settings-dialog .dialog-actions {
2035
2699
  min-height: 46px;
2036
2700
  }
2037
2701
 
2702
+ /* The split footer bar, as panels 2, 4 and 5 keep it: the SHAPE is the
2703
+ screen's own (the handoff draws no such bar); only the colours change. The
2704
+ navy was a fixed hex that sat on the dark card unchanged. */
2038
2705
  .action-cancel {
2039
- background: #fff;
2040
- color: #0d1720;
2706
+ background: var(--card);
2707
+ color: var(--text2);
2041
2708
  border-radius: 0;
2042
2709
  }
2043
2710
 
2044
2711
  .action-submit {
2045
- background: #082b40;
2046
- color: #fff;
2712
+ background: var(--accent-strong);
2713
+ color: var(--on-accent-strong);
2047
2714
  border-radius: 0;
2048
2715
  }
2049
2716
 
@@ -2052,25 +2719,27 @@ export default {
2052
2719
  }
2053
2720
 
2054
2721
  @media (max-width: 900px) {
2055
- .top-bar {
2056
- align-items: stretch;
2057
- flex-direction: column;
2722
+ .hid-intercom-filters :deep(.app-field),
2723
+ .hid-intercom-filters :deep(.app-select) {
2724
+ width: 100%;
2725
+ max-width: none;
2058
2726
  }
2059
2727
 
2060
- .top-placeholder,
2061
- .search-input,
2062
- .status-input {
2728
+ .web-call-button {
2063
2729
  width: 100%;
2064
- max-width: none;
2065
2730
  }
2066
2731
 
2067
- .rebooting-card {
2068
- grid-template-columns: 1fr;
2069
- padding: 28px;
2732
+ .web-phone-body {
2733
+ grid-template-columns: minmax(0, 1fr);
2734
+ min-height: 0;
2070
2735
  }
2071
2736
 
2072
- .wait-word {
2073
- font-size: 80px;
2737
+ .web-phone-settings {
2738
+ padding-right: 0;
2739
+ padding-bottom: 20px;
2740
+ border-right: 0;
2741
+ border-bottom: 1px solid var(--border);
2074
2742
  }
2743
+
2075
2744
  }
2076
2745
  </style>