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

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 (126) hide show
  1. package/.changeset/camera-wall-gated-endpoint.md +47 -0
  2. package/.changeset/camera-wall-plain-english.md +60 -0
  3. package/.changeset/camera-wall-selection-and-guidance.md +65 -0
  4. package/.changeset/dark-primary-contrast.md +39 -0
  5. package/.changeset/dashboard-dark-theme-contrast.md +15 -0
  6. package/.changeset/notification-preferences.md +17 -0
  7. package/.changeset/pm-sidebar-grouping-and-list-scaffold.md +27 -0
  8. package/.changeset/service-provider-invitation-actions.md +28 -0
  9. package/assets/css/primitives.css +680 -0
  10. package/assets/css/screens.css +541 -0
  11. package/assets/css/shell.css +258 -0
  12. package/assets/css/tokens.css +220 -0
  13. package/components/AccessCardQrTagging.vue +1 -1
  14. package/components/AccessManagement.vue +53 -72
  15. package/components/AppButton.vue +52 -0
  16. package/components/AppCard.vue +20 -0
  17. package/components/AppDateField.vue +35 -0
  18. package/components/AppField.vue +51 -0
  19. package/components/AppKpiTile.vue +65 -0
  20. package/components/AppSegmented.vue +38 -0
  21. package/components/AppSelect.vue +203 -0
  22. package/components/AreaChecklistHistoryLogs.vue +51 -44
  23. package/components/AreaChecklistHistoryMain.vue +6 -2
  24. package/components/AreaMain.vue +9 -17
  25. package/components/AttendanceMain.vue +4 -3
  26. package/components/Avatar/Main.vue +54 -2
  27. package/components/BillingMain.vue +26 -30
  28. package/components/BuildingManagement/buildings.vue +27 -38
  29. package/components/BuildingManagement/units.vue +26 -42
  30. package/components/BulletinBoardManagement.vue +23 -10
  31. package/components/CameraWall.vue +1034 -0
  32. package/components/CameraWallTile.vue +818 -0
  33. package/components/CardToolbar.vue +37 -0
  34. package/components/CleaningScheduleMain.vue +29 -46
  35. package/components/ClientMain.vue +90 -78
  36. package/components/DashboardEmptyState.vue +11 -1
  37. package/components/DashboardMain.vue +349 -275
  38. package/components/DashboardPanel.vue +1 -1
  39. package/components/DocumentManagement.vue +129 -93
  40. package/components/EntryPassInformation.vue +4 -4
  41. package/components/EntryPassMain.vue +49 -27
  42. package/components/EquipmentItemMain.vue +4 -6
  43. package/components/EquipmentManagementMain.vue +9 -7
  44. package/components/FeedbackMain.vue +42 -56
  45. package/components/FormDialog.vue +60 -21
  46. package/components/HidAccessLogDashboard.vue +119 -44
  47. package/components/HidCameraCaptureDialog.vue +199 -0
  48. package/components/HidIntercomManagement.vue +825 -202
  49. package/components/HidQrCodeConfiguration.vue +927 -93
  50. package/components/HidServiceSettingsPanel.vue +20 -1
  51. package/components/HidUserEnrollment.vue +454 -106
  52. package/components/InvitationMain.vue +46 -61
  53. package/components/Layout/Header.vue +289 -37
  54. package/components/Layout/NavigationDrawer.vue +340 -30
  55. package/components/ManageChecklistMain.vue +17 -17
  56. package/components/MemberMain.vue +79 -60
  57. package/components/MyAttendanceMain.vue +3 -5
  58. package/components/NavigationItem.vue +9 -0
  59. package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
  60. package/components/NotificationSettings.vue +361 -0
  61. package/components/OnlineFormConfigurationForm.vue +620 -224
  62. package/components/OnlineFormFill.vue +36 -8
  63. package/components/OnlineFormsConfiguration.vue +7 -3
  64. package/components/PageHeader.vue +43 -0
  65. package/components/PassInformation.vue +104 -45
  66. package/components/QrTemplate/CreditCardLandscape.vue +19 -12
  67. package/components/QrTemplate/PrintDialog.vue +209 -196
  68. package/components/RolePermissionMain.vue +184 -91
  69. package/components/ScanVisitorQRCode.vue +35 -7
  70. package/components/ScheduleAreaMain.vue +15 -49
  71. package/components/ScheduleTaskMain.vue +23 -26
  72. package/components/ScheduleTastTicketMain.vue +18 -43
  73. package/components/ServiceProviderInvitationPrompt.vue +150 -0
  74. package/components/ServiceProviderMain.vue +346 -157
  75. package/components/SiteSettings.vue +25 -10
  76. package/components/SiteSettingsWorkOrder.vue +61 -30
  77. package/components/SpecificAttr.vue +10 -2
  78. package/components/StatusChip.vue +93 -0
  79. package/components/StockCard.vue +17 -12
  80. package/components/SwitchContext.vue +33 -1
  81. package/components/TableHygiene.vue +29 -46
  82. package/components/TableList.vue +1 -1
  83. package/components/TableListSecondary.vue +1 -1
  84. package/components/TableMain.vue +161 -77
  85. package/components/TableV3.vue +61 -66
  86. package/components/UnitMain.vue +9 -17
  87. package/components/VehicleManagement.vue +20 -9
  88. package/components/VisitorForm.vue +294 -78
  89. package/components/VisitorManagement.vue +221 -71
  90. package/components/VisitorSocketPopUp.vue +56 -2
  91. package/components/WorkOrder/Main.vue +42 -51
  92. package/composables/useComment.ts +3 -3
  93. package/composables/useHidAmico.ts +151 -0
  94. package/composables/useHidNavigation.ts +0 -7
  95. package/composables/useLocalAuth.ts +9 -36
  96. package/composables/useNotificationPreference.ts +87 -0
  97. package/composables/useOnlineFormPages.ts +2 -0
  98. package/composables/useOrg.ts +1 -1
  99. package/composables/useRole.ts +22 -0
  100. package/composables/useServiceProviderInvitation.ts +145 -0
  101. package/composables/useSipWebPhone.ts +336 -0
  102. package/composables/useSiteSettings.ts +50 -0
  103. package/composables/useThemePreference.ts +63 -0
  104. package/composables/useVisitor.ts +6 -1
  105. package/composables/useVisitorSocket.ts +26 -0
  106. package/composables/useWebUsb.ts +127 -37
  107. package/nuxt.config.ts +31 -0
  108. package/package.json +4 -1
  109. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  110. package/pages/notification-settings.vue +19 -0
  111. package/plugins/secure-member.client.ts +21 -56
  112. package/plugins/vuetify.ts +68 -9
  113. package/test/visitor-socket.test.mjs +36 -0
  114. package/types/org.d.ts +2 -0
  115. package/types/site.d.ts +71 -0
  116. package/utils/camera-wall.test.ts +571 -0
  117. package/utils/camera-wall.ts +926 -0
  118. package/utils/nav-sections.test.ts +190 -0
  119. package/utils/nav-sections.ts +151 -0
  120. package/utils/status.test.ts +77 -0
  121. package/utils/status.ts +90 -0
  122. package/utils/theme.test.ts +534 -0
  123. package/utils/theme.ts +294 -0
  124. package/components/HidIdentityMapping.vue +0 -975
  125. package/middleware/member.ts +0 -4
  126. package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +0 -23
@@ -0,0 +1,336 @@
1
+ import { computed, onScopeDispose, readonly, ref, shallowRef } from "vue";
2
+ import type { SimpleUser, SimpleUserDelegate, SimpleUserOptions } from "sip.js/lib/platform/web";
3
+ import type { SessionDescriptionHandlerOptions } from "sip.js/lib/platform/web/session-description-handler/session-description-handler-options";
4
+
5
+ export type SipWebPhoneConfig = {
6
+ webSocketServer: string;
7
+ aor: string;
8
+ authorizationUsername: string;
9
+ authorizationPassword: string;
10
+ displayName?: string;
11
+ video?: boolean;
12
+ };
13
+
14
+ export type SipWebPhoneMedia = {
15
+ remoteAudio?: HTMLAudioElement | null;
16
+ remoteVideo?: HTMLVideoElement | null;
17
+ };
18
+
19
+ type ConnectionState = "disconnected" | "connecting" | "registered" | "error";
20
+ type CallState = "idle" | "calling" | "incoming" | "active" | "ending";
21
+
22
+ export default function useSipWebPhone() {
23
+ const simpleUser = shallowRef<SimpleUser>();
24
+ const connectionState = ref<ConnectionState>("disconnected");
25
+ const callState = ref<CallState>("idle");
26
+ const errorMessage = ref("");
27
+ const muted = ref(false);
28
+ const remoteVideoAvailable = ref(false);
29
+ const currentTarget = ref("");
30
+ const activeConfig = shallowRef<SipWebPhoneConfig>();
31
+
32
+ const isRegistered = computed(() => connectionState.value === "registered");
33
+ const hasCall = computed(() => callState.value !== "idle");
34
+ const audioConstraints: MediaTrackConstraints = {
35
+ echoCancellation: true,
36
+ noiseSuppression: true,
37
+ autoGainControl: true,
38
+ channelCount: 1,
39
+ };
40
+
41
+ async function connect(config: SipWebPhoneConfig, media: SipWebPhoneMedia) {
42
+ validateConfig(config);
43
+ await disconnect();
44
+
45
+ connectionState.value = "connecting";
46
+ errorMessage.value = "";
47
+ activeConfig.value = { ...config };
48
+ let clearRegistrationTimer = () => {};
49
+
50
+ try {
51
+ const { SimpleUser: SimpleUserConstructor } = await import("sip.js/lib/platform/web");
52
+ let registrationSettled = false;
53
+ let resolveRegistration: (() => void) | undefined;
54
+ let rejectRegistration: ((error: Error) => void) | undefined;
55
+ const registrationPromise = new Promise<void>((resolve, reject) => {
56
+ resolveRegistration = resolve;
57
+ rejectRegistration = reject;
58
+ });
59
+ const registrationTimeout = globalThis.setTimeout(() => {
60
+ if (registrationSettled) return;
61
+ registrationSettled = true;
62
+ rejectRegistration?.(new Error("SIP registration timed out. Check the WebSocket server and account credentials."));
63
+ }, 15000);
64
+ clearRegistrationTimer = () => globalThis.clearTimeout(registrationTimeout);
65
+ const settleRegistration = (error?: Error) => {
66
+ if (registrationSettled) return;
67
+ registrationSettled = true;
68
+ clearRegistrationTimer();
69
+ if (error) rejectRegistration?.(error);
70
+ else resolveRegistration?.();
71
+ };
72
+ const delegate: SimpleUserDelegate = {
73
+ onCallCreated: () => {
74
+ if (callState.value !== "incoming") callState.value = "calling";
75
+ },
76
+ onCallReceived: () => {
77
+ callState.value = "incoming";
78
+ currentTarget.value = "Incoming call";
79
+ },
80
+ onCallAnswered: () => {
81
+ callState.value = "active";
82
+ ensureMicrophoneSending();
83
+ syncVideoState();
84
+ },
85
+ onCallHangup: () => {
86
+ const previousState = callState.value;
87
+ resetCall();
88
+ if (previousState === "calling") {
89
+ errorMessage.value = "The call was not answered or was rejected by the destination.";
90
+ }
91
+ },
92
+ onRegistered: () => {
93
+ connectionState.value = "registered";
94
+ settleRegistration();
95
+ },
96
+ onUnregistered: () => {
97
+ if (connectionState.value === "connecting") {
98
+ settleRegistration(new Error("SIP registration was rejected. Check the username and password."));
99
+ }
100
+ connectionState.value = "disconnected";
101
+ },
102
+ onServerDisconnect: () => {
103
+ const error = new Error("The SIP WebSocket server disconnected during registration.");
104
+ connectionState.value = "error";
105
+ errorMessage.value = error.message;
106
+ settleRegistration(error);
107
+ resetCall();
108
+ },
109
+ };
110
+ const options: SimpleUserOptions = {
111
+ aor: normalizeSipUri(config.aor),
112
+ delegate,
113
+ media: {
114
+ // The browser sends microphone audio and receives HID audio/video.
115
+ // Do not request a local camera merely because the HID video leg is enabled.
116
+ constraints: { audio: audioConstraints, video: false },
117
+ remote: {
118
+ audio: media.remoteAudio || undefined,
119
+ video: media.remoteVideo || undefined,
120
+ },
121
+ },
122
+ userAgentOptions: {
123
+ authorizationUsername: config.authorizationUsername,
124
+ authorizationPassword: config.authorizationPassword,
125
+ displayName: config.displayName || config.authorizationUsername,
126
+ },
127
+ };
128
+
129
+ const user = new SimpleUserConstructor(config.webSocketServer.trim(), options);
130
+ simpleUser.value = user;
131
+ await user.connect();
132
+ await user.register();
133
+ await registrationPromise;
134
+ } catch (error) {
135
+ clearRegistrationTimer();
136
+ await disconnect(false);
137
+ connectionState.value = "error";
138
+ errorMessage.value = getErrorMessage(error);
139
+ throw error;
140
+ }
141
+ }
142
+
143
+ async function disconnect(clearError = true) {
144
+ const user = simpleUser.value;
145
+ simpleUser.value = undefined;
146
+
147
+ if (user) {
148
+ try {
149
+ if (hasCall.value) await user.hangup();
150
+ } catch {
151
+ // The session may already have ended remotely.
152
+ }
153
+ try {
154
+ await user.unregister();
155
+ } catch {
156
+ // Registration may not have completed.
157
+ }
158
+ try {
159
+ await user.disconnect();
160
+ } catch {
161
+ // The WebSocket may already be closed.
162
+ }
163
+ }
164
+
165
+ connectionState.value = "disconnected";
166
+ activeConfig.value = undefined;
167
+ resetCall();
168
+ if (clearError) errorMessage.value = "";
169
+ }
170
+
171
+ async function call(target: string) {
172
+ const user = requireRegisteredUser();
173
+ const destination = normalizeDestination(target, activeConfig.value?.aor || "");
174
+ errorMessage.value = "";
175
+ currentTarget.value = destination;
176
+ callState.value = "calling";
177
+ try {
178
+ const sessionDescriptionHandlerOptions: SessionDescriptionHandlerOptions = {
179
+ constraints: { audio: audioConstraints, video: false },
180
+ offerOptions: {
181
+ offerToReceiveAudio: true,
182
+ offerToReceiveVideo: Boolean(activeConfig.value?.video),
183
+ },
184
+ };
185
+ await user.call(destination, { sessionDescriptionHandlerOptions });
186
+ } catch (error) {
187
+ resetCall();
188
+ errorMessage.value = getErrorMessage(error);
189
+ throw error;
190
+ }
191
+ }
192
+
193
+ async function answer() {
194
+ const user = requireRegisteredUser();
195
+ const sessionDescriptionHandlerOptions: SessionDescriptionHandlerOptions = {
196
+ constraints: { audio: audioConstraints, video: false },
197
+ offerOptions: {
198
+ offerToReceiveAudio: true,
199
+ offerToReceiveVideo: Boolean(activeConfig.value?.video),
200
+ },
201
+ };
202
+ await user.answer({ sessionDescriptionHandlerOptions });
203
+ }
204
+
205
+ async function decline() {
206
+ const user = requireUser();
207
+ callState.value = "ending";
208
+ await user.decline();
209
+ resetCall();
210
+ }
211
+
212
+ async function hangup() {
213
+ const user = requireUser();
214
+ callState.value = "ending";
215
+ await user.hangup();
216
+ resetCall();
217
+ }
218
+
219
+ function toggleMute() {
220
+ const user = requireUser();
221
+ const tracks = user.localMediaStream?.getAudioTracks() || [];
222
+ if (!tracks.length) throw new Error("No microphone track is available for this call.");
223
+ const enable = muted.value;
224
+ tracks.forEach((track) => {
225
+ track.enabled = enable;
226
+ });
227
+ muted.value = !enable;
228
+ }
229
+
230
+ function ensureMicrophoneSending() {
231
+ const tracks = simpleUser.value?.localMediaStream?.getAudioTracks() || [];
232
+ if (!tracks.length) {
233
+ errorMessage.value = "The call connected without a microphone track. Reconnect and allow microphone access.";
234
+ return;
235
+ }
236
+ tracks.forEach((track) => {
237
+ track.enabled = true;
238
+ });
239
+ muted.value = false;
240
+ }
241
+
242
+ function syncVideoState() {
243
+ const user = simpleUser.value;
244
+ const remoteStream = user?.remoteMediaStream;
245
+ const remoteTrack = remoteStream?.getVideoTracks()[0];
246
+
247
+ const updateState = () => {
248
+ const currentRemoteTrack = remoteStream?.getVideoTracks()[0];
249
+ remoteVideoAvailable.value = Boolean(currentRemoteTrack && currentRemoteTrack.readyState === "live");
250
+ };
251
+
252
+ updateState();
253
+ if (remoteStream) remoteStream.onaddtrack = updateState;
254
+
255
+ if (remoteTrack) {
256
+ remoteTrack.onended = () => {
257
+ remoteVideoAvailable.value = false;
258
+ };
259
+ }
260
+ }
261
+
262
+ function resetCall() {
263
+ callState.value = "idle";
264
+ currentTarget.value = "";
265
+ muted.value = false;
266
+ remoteVideoAvailable.value = false;
267
+ }
268
+
269
+ function requireRegisteredUser() {
270
+ const user = requireUser();
271
+ if (!isRegistered.value) throw new Error("Connect the web phone before making a call.");
272
+ return user;
273
+ }
274
+
275
+ function requireUser() {
276
+ if (!simpleUser.value) throw new Error("Web phone is not connected.");
277
+ return simpleUser.value;
278
+ }
279
+
280
+ onScopeDispose(() => {
281
+ void disconnect();
282
+ });
283
+
284
+ return {
285
+ connectionState: readonly(connectionState),
286
+ callState: readonly(callState),
287
+ errorMessage: readonly(errorMessage),
288
+ muted: readonly(muted),
289
+ remoteVideoAvailable: readonly(remoteVideoAvailable),
290
+ currentTarget: readonly(currentTarget),
291
+ isRegistered,
292
+ hasCall,
293
+ connect,
294
+ disconnect,
295
+ call,
296
+ answer,
297
+ decline,
298
+ hangup,
299
+ toggleMute,
300
+ };
301
+ }
302
+
303
+ function validateConfig(config: SipWebPhoneConfig) {
304
+ if (!/^wss:\/\//i.test(config.webSocketServer.trim())) {
305
+ throw new Error("SIP WebSocket URL must use WSS.");
306
+ }
307
+ if (!config.aor.trim() || !config.authorizationUsername.trim() || !config.authorizationPassword) {
308
+ throw new Error("SIP address, username, and password are required.");
309
+ }
310
+ }
311
+
312
+ function normalizeSipUri(value: string) {
313
+ const trimmed = value.trim();
314
+ return trimmed.toLowerCase().startsWith("sip:") ? trimmed : `sip:${trimmed}`;
315
+ }
316
+
317
+ function normalizeDestination(target: string, aor: string) {
318
+ const trimmed = target.trim();
319
+ if (!trimmed) throw new Error("Enter an extension or SIP address to call.");
320
+ if (trimmed.toLowerCase().startsWith("sip:")) return trimmed;
321
+ if (trimmed.includes("@")) return `sip:${trimmed}`;
322
+
323
+ const domain = normalizeSipUri(aor).split("@")[1];
324
+ if (!domain) throw new Error("Enter a full SIP address for the destination.");
325
+ return `sip:${trimmed}@${domain}`;
326
+ }
327
+
328
+ function getErrorMessage(error: unknown) {
329
+ if (error instanceof DOMException && error.name === "NotAllowedError") {
330
+ return "Microphone permission was denied. Allow microphone access in the browser and reconnect the web phone.";
331
+ }
332
+ if (error instanceof DOMException && error.name === "NotFoundError") {
333
+ return "No microphone was found for this call.";
334
+ }
335
+ return error instanceof Error ? error.message : "SIP connection failed.";
336
+ }
@@ -99,6 +99,54 @@ export default function () {
99
99
  });
100
100
  }
101
101
 
102
+ /**
103
+ * The monitoring wall for one site.
104
+ *
105
+ * A different endpoint from `getAllSiteCameras`, and the difference is the
106
+ * point. That one is the Settings panel's paginated CRUD list; this one is
107
+ * purpose-built for a wall and is what the React Native app already uses:
108
+ *
109
+ * - it is **site-scoped and authorised server-side** — the caller must be a
110
+ * member of the site, of its owning organisation, or work for an
111
+ * organisation engaged to serve it;
112
+ * - it returns `type: "ip"` cameras only, so an ANPR unit can never land on a
113
+ * monitoring wall;
114
+ * - it returns each camera's **capability descriptor** and the server's own
115
+ * `unavailableReason`, so a tile explains itself instead of showing a black
116
+ * rectangle;
117
+ * - it never returns a credential, and it contacts no device.
118
+ *
119
+ * Unpaginated by design: a wall shows a site's cameras, and paging them was
120
+ * an artefact of borrowing the CRUD list.
121
+ */
122
+ async function getSiteWall(siteId: string) {
123
+ return await useNuxtApp().$api<Record<string, any>>(
124
+ `/api/site-cameras/site/${siteId}/wall`,
125
+ { method: "GET" }
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Health for the cameras currently on screen.
131
+ *
132
+ * Same site scoping as the wall, and the ids are narrowed to the visible
133
+ * tiles on purpose: this is the one camera call that can reach a device, and
134
+ * asking about cameras nobody is looking at spends a real budget for nothing.
135
+ *
136
+ * **Reachability is answered per RECORDER and cached there**, so a wall of
137
+ * twelve channels on one device is one connect rather than twelve, and
138
+ * several supervisors watching the same wall is still one. That is what makes
139
+ * it safe to ask on a timer at all — but only a slow one: the server caches
140
+ * for `healthCacheSeconds`, and asking faster than the cache buys nothing and
141
+ * costs round trips.
142
+ */
143
+ async function getSiteHealth(siteId: string, cameraIds: string[]) {
144
+ return await useNuxtApp().$api<Record<string, any>>(
145
+ `/api/site-cameras/site/${siteId}/health`,
146
+ { method: "GET", query: { ids: cameraIds.join(",") } }
147
+ );
148
+ }
149
+
102
150
  async function updateSiteInformation(
103
151
  siteId: string,
104
152
  payload: { bgImage: string; description: string; docs: { id: string; name: string }[] }
@@ -175,6 +223,8 @@ export default function () {
175
223
  updateSite,
176
224
  addCamera,
177
225
  getAllSiteCameras,
226
+ getSiteWall,
227
+ getSiteHealth,
178
228
  setSiteGuardPosts,
179
229
  updateSiteCamera,
180
230
  deleteSiteCameraById,
@@ -0,0 +1,63 @@
1
+ /**
2
+ * THE USER'S LIGHT/DARK CHOICE, REMEMBERED.
3
+ *
4
+ * Until now `Layout/Header.vue` flipped `theme.global.name` and wrote nothing,
5
+ * so the choice was gone on the next reload.
6
+ *
7
+ * A COOKIE, NOT localStorage. The handoff says "persists in localStorage", and
8
+ * for a single-page prototype that is right - but this product is ELEVEN
9
+ * applications on eleven hostnames. localStorage is per-origin, so a user who
10
+ * picked dark mode in Security would land on a white screen in Property
11
+ * Management, and again in Account, and would have to set it eleven times.
12
+ *
13
+ * A cookie written with the layer's own `cookieConfig` carries the parent
14
+ * domain, which is exactly how `sid`, `user` and `landing-page` already travel
15
+ * between these applications. So this is the estate's existing preference
16
+ * mechanism rather than a new one, and it is also the only one that makes the
17
+ * setting mean what a user expects it to mean.
18
+ *
19
+ * NO FLASH OF THE WRONG THEME. Nothing here corrects the theme after mount -
20
+ * `plugins/vuetify.ts` reads the same cookie BEFORE `createVuetify` and boots
21
+ * on it, so the first paint is already right. The apps are `ssr: false`, so
22
+ * there is no server render to disagree with.
23
+ */
24
+ import { useTheme } from "vuetify";
25
+ import type { CookieOptions } from "#app";
26
+
27
+ export type TThemeName = "light" | "dark";
28
+
29
+ /** The one place the cookie is named. `plugins/vuetify.ts` reads the same one. */
30
+ export const THEME_COOKIE = "theme";
31
+
32
+ export default function useThemePreference() {
33
+ // `runtimeConfig.public.cookieConfig` is typed `unknown`, so it is narrowed
34
+ // here rather than at every call site. Same object `sid`, `user` and
35
+ // `landing-page` are written with - it carries the shared parent domain.
36
+ const cookieConfig = useRuntimeConfig().public.cookieConfig as CookieOptions<
37
+ TThemeName | null
38
+ > & { readonly?: false };
39
+
40
+ const vuetifyTheme = useTheme();
41
+
42
+ const stored = useCookie<TThemeName | null>(THEME_COOKIE, cookieConfig);
43
+
44
+ const current = computed<TThemeName>(() =>
45
+ vuetifyTheme.global.current.value.dark ? "dark" : "light"
46
+ );
47
+
48
+ /**
49
+ * Sets the theme AND remembers it. The write is the whole point - a caller
50
+ * that only wants a temporary theme for one subtree should use
51
+ * `<v-theme-provider>` instead, which is what `plain-dark` does.
52
+ */
53
+ function setTheme(name: TThemeName) {
54
+ vuetifyTheme.global.name.value = name;
55
+ stored.value = name;
56
+ }
57
+
58
+ function toggleTheme() {
59
+ setTheme(current.value === "dark" ? "light" : "dark");
60
+ }
61
+
62
+ return { current, setTheme, toggleTheme, stored };
63
+ }
@@ -92,6 +92,7 @@ export default function () {
92
92
  checkedOut?: boolean;
93
93
  plateNumber?: string;
94
94
  tab?: string;
95
+ passOrKey?: string;
95
96
  };
96
97
 
97
98
  type SearchUncheckedOutUnregisteredVisitorParams = {
@@ -130,6 +131,7 @@ export default function () {
130
131
  plateNumber = "",
131
132
  checkedOut,
132
133
  tab,
134
+ passOrKey,
133
135
  }: GetVisitorsParams = {}) {
134
136
  const query = Object.fromEntries(
135
137
  Object.entries({
@@ -146,7 +148,10 @@ export default function () {
146
148
  plateNumber,
147
149
  order,
148
150
  tab,
149
- }).filter(([, value]) => value !== undefined && value !== null && value !== "")
151
+ passOrKey,
152
+ }).filter(
153
+ ([, value]) => value !== undefined && value !== null && value !== ""
154
+ )
150
155
  );
151
156
 
152
157
  return await useNuxtApp().$api<Record<string, any>>(
@@ -5,6 +5,32 @@ export type TVisitorSocketData = {
5
5
  message?: string;
6
6
  messagePermanent?: string;
7
7
  reload?: boolean;
8
+ /**
9
+ * Camera health. Sent alongside `message` by API-core so the client can group
10
+ * or clear an alert per camera instead of matching on the message text.
11
+ * Absent on older API-core builds — treat a bare `message` as a fault.
12
+ */
13
+ event?: "camera-fault" | "camera-recovered";
14
+ /** The camera the `event` is about. */
15
+ camera?: string;
16
+ };
17
+
18
+ /**
19
+ * One line for however many cameras are currently down.
20
+ *
21
+ * A site with several ANPR cameras could otherwise raise one alert per camera
22
+ * at the same moment. The single-camera case shows the server's own message,
23
+ * which names the camera; beyond that, naming them all in a snackbar is worse
24
+ * than sending the operator to the page that lists them.
25
+ */
26
+ export const cameraAlertMessage = (messages: string[]): string => {
27
+ if (!messages.length) return "";
28
+ if (messages.length === 1) return messages[0] as string;
29
+ return (
30
+ `${messages.length} cameras at this site are not responding. Plate reads ` +
31
+ `and automatic barrier opening are stopped for them. Open Site Settings > ` +
32
+ `Cameras to see which.`
33
+ );
8
34
  };
9
35
 
10
36
  export const useVisitorSocket = () => {