@7365admin1/layer-common 4.149.2 → 4.151.0

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 (42) hide show
  1. package/CHANGELOG.md +16 -4
  2. package/components/EntryPassInformation.vue +13 -0
  3. package/components/HidAccessPermissions.vue +202 -40
  4. package/components/HidEnabledGate.vue +29 -10
  5. package/components/HidIntercomManagement.vue +26 -13
  6. package/components/HidProfileQrCode.vue +6 -2
  7. package/components/HidQrCodeConfiguration.vue +2 -2
  8. package/components/HidReaderForm.vue +239 -1
  9. package/components/HidReaderManagement.vue +26 -8
  10. package/components/HidReaderUserRoster.vue +3 -1
  11. package/components/HidServiceSettingsPanel.vue +2 -2
  12. package/components/HidUserEnrollment.vue +1662 -162
  13. package/components/OvernightParkingAvailability.vue +123 -11
  14. package/components/Robot/Detail.vue +23 -4
  15. package/components/SiteSettings.vue +5 -2
  16. package/components/VisitorForm.vue +39 -11
  17. package/composables/useCleaningPermission.ts +11 -0
  18. package/composables/useHidAccessPermission.ts +176 -81
  19. package/composables/useHidAmico.ts +37 -0
  20. package/composables/useHidNavigation.ts +44 -23
  21. package/composables/useLandscapingPermission.ts +11 -0
  22. package/composables/useMechanicalElectricalPermission.ts +5 -0
  23. package/composables/usePestControlPermission.ts +11 -0
  24. package/composables/usePoolMaintenancePermission.ts +11 -0
  25. package/composables/usePropertyPermission.ts +31 -7
  26. package/composables/useSecurityPermission.ts +33 -13
  27. package/composables/useServiceProvider.ts +9 -4
  28. package/constants/permissions.ts +73 -21
  29. package/package.json +1 -1
  30. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +3 -0
  31. package/types/overnight-parking.d.ts +14 -0
  32. package/types/site.d.ts +7 -1
  33. package/utils/hid-enrolment-subject.ts +226 -4
  34. package/utils/hid-permission-assignments.ts +75 -6
  35. package/utils/hid-qr-validity.ts +25 -0
  36. package/utils/hid-reader-audiences.ts +557 -0
  37. package/utils/hid-registration.ts +24 -0
  38. package/utils/module-routes.ts +15 -2
  39. package/utils/nav-sections.ts +1 -1
  40. package/utils/robot-credential-form.ts +4 -2
  41. package/utils/robot-display.ts +28 -6
  42. package/utils/robot-setup.ts +1 -0
@@ -6,17 +6,67 @@
6
6
  >
7
7
  <v-row no-gutters>
8
8
  <v-col cols="12">
9
- <v-row no-gutters class="d-flex align-center ga-2 justify-end">
10
- <span class="d-flex ga-2 align-center">
11
- <p>Allow Opening Barrier via Plate Number?</p>
9
+ <!-- Which QR code types a resident's visitor invitation can carry. -->
10
+ <template v-for="option in visitorQrCodes" :key="option.type">
11
+ <div class="d-flex align-center justify-space-between py-3">
12
+ <div>
13
+ <div class="text-body-2 font-weight-medium">{{ visitorQrLabels[option.type].title }}</div>
14
+ <p class="text-caption text-medium-emphasis mt-1 mb-0">
15
+ {{ visitorQrLabels[option.type].description }}
16
+ </p>
17
+ </div>
12
18
  <v-switch
13
- v-model="isAllowPlateNumberOpenBarrier"
14
- hide-details
19
+ v-model="option.isEnabled"
15
20
  color="success"
16
- size="large"
21
+ inset
22
+ density="compact"
23
+ hide-details
24
+ class="flex-grow-0 ml-4"
17
25
  />
18
- </span>
19
- </v-row>
26
+ </div>
27
+ <!-- How long an HID visitor pass lasts. Only asked once HID is on. -->
28
+ <v-radio-group
29
+ v-if="option.type === 'hid' && option.isEnabled"
30
+ v-model="option.usage"
31
+ inline
32
+ hide-details
33
+ density="compact"
34
+ color="success"
35
+ class="pb-3"
36
+ >
37
+ <v-radio
38
+ v-for="usage in hidQrUsageOptions"
39
+ :key="usage.value"
40
+ :value="usage.value"
41
+ class="mr-6"
42
+ >
43
+ <template #label>
44
+ <div>
45
+ <div class="text-body-2">{{ usage.title }}</div>
46
+ <div class="text-caption text-medium-emphasis">{{ usage.description }}</div>
47
+ </div>
48
+ </template>
49
+ </v-radio>
50
+ </v-radio-group>
51
+ <v-divider />
52
+ </template>
53
+ <div class="d-flex align-center justify-space-between py-3">
54
+ <div>
55
+ <div class="text-body-2 font-weight-medium">Allow Opening Barrier via Plate Number?</div>
56
+ <p class="text-caption text-medium-emphasis mt-1 mb-0">
57
+ When enabled, the barrier can be opened for visitors by matching their vehicle plate number.
58
+ </p>
59
+ </div>
60
+ <v-switch
61
+ v-model="isAllowPlateNumberOpenBarrier"
62
+ color="success"
63
+ inset
64
+ density="compact"
65
+ hide-details
66
+ class="flex-grow-0 ml-4"
67
+ />
68
+ </div>
69
+ <v-divider class="mb-4" />
20
70
  <v-row
21
71
  no-gutters
22
72
  class="d-flex align-center ga-2 justify-space-between"
@@ -220,6 +270,41 @@ const isAllowPlateNumberOpenBarrier = ref(false);
220
270
  const autoApproveOvernightParking = ref(false);
221
271
  const isHydrating = ref(true);
222
272
 
273
+ const visitorQrLabels: Record<TVisitorQrCodeType, { title: string; description: string }> = {
274
+ hid: {
275
+ title: "HID QR Code Visitor Invitation",
276
+ description: "When enabled, HID QR codes will be generated for visitor invitations instead of standard entry pass QR codes.",
277
+ },
278
+ entryPass: {
279
+ title: "Entry Pass QR Code Visitor Invitation",
280
+ description: "When enabled, entry pass QR codes will be generated for visitor invitations.",
281
+ },
282
+ security: {
283
+ title: "Security QR Code Visitor Invitation",
284
+ description: "When enabled, security QR codes will be generated for visitor invitations.",
285
+ },
286
+ };
287
+ // Whole day until the end of the visit day, or one entry and then it expires.
288
+ // Sites that saved before this existed have none, and read as whole day.
289
+ const DEFAULT_HID_QR_USAGE: TVisitorQrUsage = "wholeDay";
290
+ const hidQrUsageOptions: { value: TVisitorQrUsage; title: string; description: string }[] = [
291
+ {
292
+ value: "wholeDay",
293
+ title: "Whole day usage",
294
+ description: "The visitor can use the QR code as many times as needed until the end of the visit day.",
295
+ },
296
+ {
297
+ value: "singleUse",
298
+ title: "Single usage",
299
+ description: "The QR code expires after the visitor's first entry.",
300
+ },
301
+ ];
302
+ const visitorQrCodes = ref<TVisitorQrCode[]>([
303
+ { type: "hid", isEnabled: false, usage: DEFAULT_HID_QR_USAGE },
304
+ { type: "security", isEnabled: false },
305
+ { type: "entryPass", isEnabled: false },
306
+ ]);
307
+
223
308
  const { requiredRule } = useUtils();
224
309
  const { generateTimeSlots, generateTimeSlotsFromStart } = useFacilityUtils();
225
310
  const { currentUser } = useLocalAuth();
@@ -290,6 +375,16 @@ watch(
290
375
  autoApproveOvernightParking.value =
291
376
  data?.autoApproveOvernightParking === true ||
292
377
  data?.isAutoApproved === true;
378
+ // Set in place so the toggle watcher only fires on a real change.
379
+ visitorQrCodes.value.forEach((option) => {
380
+ const saved = (data?.visitorQrCodes as TVisitorQrCode[] | undefined)?.find(
381
+ (q) => q.type === option.type
382
+ );
383
+ option.isEnabled = saved?.isEnabled === true;
384
+ if (option.type === "hid") {
385
+ option.usage = saved?.usage ?? DEFAULT_HID_QR_USAGE;
386
+ }
387
+ });
293
388
  setTimeout(() => {
294
389
  isHydrating.value = false;
295
390
  }, 500);
@@ -374,7 +469,7 @@ watch(
374
469
  isAllowPlateNumberOpenBarrier,
375
470
  (newValue, oldValue) => {
376
471
  if (oldValue === newValue || isHydrating.value) return;
377
- handleSave("toggle");
472
+ handleSave("toggle", "Plate number barrier setting updated successfully.");
378
473
  },
379
474
  { immediate: false }
380
475
  );
@@ -388,7 +483,19 @@ watch(
388
483
  { immediate: false }
389
484
  );
390
485
 
391
- async function handleSave(action: "toggle" | "save") {
486
+ watch(
487
+ visitorQrCodes,
488
+ () => {
489
+ if (isHydrating.value) return;
490
+ handleSave("toggle", "Visitor QR settings updated successfully.");
491
+ },
492
+ { deep: true }
493
+ );
494
+
495
+ async function handleSave(
496
+ action: "toggle" | "save",
497
+ successMessage = "Overnight parking settings updated successfully."
498
+ ) {
392
499
  if (!isValid.value && action === "save") {
393
500
  message.value = "Please fill in all required fields.";
394
501
  messageColor.value = "error";
@@ -403,6 +510,11 @@ async function handleSave(action: "toggle" | "save") {
403
510
  isAllowPlateNumberOpenBarrier: isAllowPlateNumberOpenBarrier.value,
404
511
  autoApproveOvernightParking: autoApproveOvernightParking.value,
405
512
  isAutoApproved: autoApproveOvernightParking.value,
513
+ visitorQrCodes: visitorQrCodes.value.map(({ type, isEnabled, usage }) => ({
514
+ type,
515
+ isEnabled,
516
+ ...(type === "hid" ? { usage: usage ?? DEFAULT_HID_QR_USAGE } : {}),
517
+ })),
406
518
  updatedBy: updatedByValue.value,
407
519
  };
408
520
 
@@ -414,7 +526,7 @@ async function handleSave(action: "toggle" | "save") {
414
526
  userType.value
415
527
  );
416
528
  refreshAvailability();
417
- message.value = "Overnight parking settings updated successfully.";
529
+ message.value = successMessage;
418
530
  messageColor.value = "success";
419
531
  messageSnackbar.value = true;
420
532
  } catch (error) {
@@ -255,6 +255,11 @@
255
255
  <v-select v-model="jobOpts.levelFlag" :items="CLEAN_POWER_CHOICES" label="Power" density="compact" hide-details class="mb-2" />
256
256
  <v-select v-model="jobOpts.cleanTimes" :items="CLEAN_TIMES_CHOICES" label="Times to clean" density="compact" hide-details class="mb-2" />
257
257
  </template>
258
+ <!-- A brand with its own named modes (VIGGO): the robot's list + times. -->
259
+ <template v-if="offered.modes">
260
+ <v-select v-model="jobOpts.cleanModeId" :items="robotModeChoices(robotModes)" label="Cleaning mode" density="compact" hide-details class="mb-2" />
261
+ <v-select v-model="jobOpts.cleanTimes" :items="CLEAN_TIMES_CHOICES" label="Times to clean" density="compact" hide-details class="mb-2" />
262
+ </template>
258
263
  <v-select v-if="offered.rounds" v-model="jobOpts.rounds" :items="PATROL_ROUND_CHOICES" label="Rounds" density="compact" hide-details class="mb-2" />
259
264
  <div class="hero-dialog__body">{{ confirming.text.body }}</div>
260
265
  <div v-if="optionsLine" class="hero-dialog__body">{{ optionsLine }}</div>
@@ -390,8 +395,10 @@ import {
390
395
  PATROL_ROUND_CHOICES,
391
396
  jobOptionsLine,
392
397
  jobOptionsOffered,
398
+ robotModeChoices,
393
399
  withJobOptions,
394
400
  type TJobOptions,
401
+ type TRobotCleanMode,
395
402
  } from "../../utils/robot-display";
396
403
 
397
404
  const props = withDefaults(
@@ -431,6 +438,7 @@ const {
431
438
  resolveRobotAlerts,
432
439
  getPatrolHistory,
433
440
  getSiteFaults,
441
+ getRobotRecords,
434
442
  } = useRobots();
435
443
 
436
444
  const record = ref<Record<string, any> | null>(null);
@@ -824,10 +832,21 @@ function pickRoute(id: unknown) {
824
832
  }
825
833
  /* Job options on a start: reset each time the confirm opens. */
826
834
  const jobOpts = ref<TJobOptions>({ ...JOB_OPTION_DEFAULTS });
827
- const offered = computed(() => jobOptionsOffered(confirming.value?.kind, robotType.value, caps.value));
828
- const optionsLine = computed(() => jobOptionsLine(offered.value, jobOpts.value));
829
- watch(confirmOpen, (open) => {
830
- if (open) jobOpts.value = { ...JOB_OPTION_DEFAULTS };
835
+ /** The robot's own cleaning modes (VIGGO reports them in its records), read when a start is confirmed. */
836
+ const robotModes = ref<TRobotCleanMode[]>([]);
837
+ const offered = computed(() => jobOptionsOffered(confirming.value?.kind, robotType.value, caps.value, robotModes.value));
838
+ const optionsLine = computed(() => jobOptionsLine(offered.value, jobOpts.value, robotModes.value));
839
+ watch(confirmOpen, async (open) => {
840
+ if (!open) return;
841
+ jobOpts.value = { ...JOB_OPTION_DEFAULTS };
842
+ const kind = confirming.value?.kind;
843
+ if (record.value?.brand !== "viggo" || (kind !== "full" && kind !== "area")) return;
844
+ try {
845
+ const rec = await getRobotRecords(props.robotId);
846
+ robotModes.value = Array.isArray(rec?.cleanModes) ? rec.cleanModes : [];
847
+ } catch {
848
+ robotModes.value = []; // the job still goes, in the robot's default mode
849
+ }
831
850
  });
832
851
  function confirmSend(stayHere = false) {
833
852
  const c = confirming.value;
@@ -186,10 +186,12 @@
186
186
  </v-expansion-panel-text>
187
187
  </v-expansion-panel>
188
188
 
189
- <v-expansion-panel>
189
+ <!-- HID on or off for the site and the HID QR configuration:
190
+ `hid-mgmt:manage-hid-settings` (`useHidAccessPermission`). -->
191
+ <v-expansion-panel v-if="canManageHidSettings">
190
192
  <v-expansion-panel-title>
191
193
  <v-icon class="mr-2">mdi-face-recognition</v-icon>
192
- HID Face Reader
194
+ HID Reader
193
195
  </v-expansion-panel-title>
194
196
  <v-expansion-panel-text>
195
197
  <HidServiceSettingsPanel
@@ -312,6 +314,7 @@ const { canManageSiteInformation,
312
314
  canManageVehicleQr,
313
315
  canManageSiteSettings: canManageAnySiteSetting,
314
316
  } = useSettingsPermission();
317
+ const { canManageHidSettings } = useHidAccessPermission();
315
318
 
316
319
  const { getSiteById, updateSitebyId, updateSiteInformation } =
317
320
  useSiteSettings();
@@ -350,6 +350,7 @@
350
350
  :hid-qr-printer-configured="hasHidQrPrinterConfig"
351
351
  :hid-reader-name="hidQrReader?.name || ''"
352
352
  :hid-portal-name="hidQrReader?.portalName || ''"
353
+ :hid-qr-usage="hidQrUsage"
353
354
  @update:available-qr-count="(val) => availableQrCount = val" />
354
355
  </v-col>
355
356
 
@@ -838,6 +839,7 @@ import useLocalAuth from "../composables/useLocalAuth";
838
839
  import useBuilding from "../composables/useBuilding";
839
840
  import useVisitor, { getVisitorFieldSwitches, NRIC_LAST4_HINT, VEHICLE_REQUIRED_MESSAGE } from "../composables/useVisitor";
840
841
  import useSiteEntryPassSettings from "../composables/useSiteEntryPassSettings";
842
+ import { hidQrUsageFromSettings, minutesUntilEndOfDay } from "../utils/hid-qr-validity";
841
843
  import useAccessManagement from "../composables/useAccessManagement";
842
844
  import usePeople from "../composables/usePeople";
843
845
  import useHidAmico from "../composables/useHidAmico";
@@ -912,7 +914,7 @@ const currentAutofillSource = ref<AutofillSource>(null);
912
914
 
913
915
  const { requiredRule, debounce, UTCToLocalTIme, maskNRIC, getImage } = useUtils();
914
916
  const { addFile } = useFile();
915
- const { getSiteById, updateSitebyId } = useSiteSettings();
917
+ const { getSiteById, getOvernightParkingAvailabilityV2, updateSitebyId } = useSiteSettings();
916
918
  const { currentUser } = useLocalAuth();
917
919
  const { getBlocksSelection, getLevelsSelection, getUnitSelection } =
918
920
  useBuilding();
@@ -927,7 +929,7 @@ const {
927
929
  const { getBySiteId: getEntryPassSettingsBySiteId } =
928
930
  useSiteEntryPassSettings();
929
931
  const { createVisitorPass, signQr, visitorCheckout } = useAccessManagement();
930
- const { getReaders, issueVisitorQr } = useHidAmico();
932
+ const { getEnrollmentReaders, issueVisitorQr } = useHidAmico();
931
933
  const { testConnection } = useWebUsb();
932
934
  const { findVisitorPeopleAnyType, deletePersonRecords } = usePeople();
933
935
  const { getPassKeysByPageSearch } = usePassKey();
@@ -1230,11 +1232,6 @@ const hidQrTemplate = computed(() => {
1230
1232
  return hidQrCodePassConfig.value?.template || {};
1231
1233
  });
1232
1234
 
1233
- const hidQrValidityMinutes = computed(() => {
1234
- const value = Number(hidQrCodePassConfig.value?.validityMinutes);
1235
- return Number.isFinite(value) && value > 0 ? value : 60;
1236
- });
1237
-
1238
1235
  const hidQrConfiguredReaderId = computed(() => String(hidQrCodePassConfig.value?.readerId || ""));
1239
1236
  type HidQrReaderSummary = {
1240
1237
  _id: string;
@@ -1256,7 +1253,7 @@ const hidQrReaderId = computed(() => (
1256
1253
  * `hidQrCodePassAllowed`, which is why the switch belongs here and not in each
1257
1254
  * of them.
1258
1255
  */
1259
- const HID_VISITOR_QR_ENABLED = false;
1256
+ const HID_VISITOR_QR_ENABLED = true;
1260
1257
 
1261
1258
  const hidQrCodePassAllowed = computed(() => {
1262
1259
  return (
@@ -1266,6 +1263,12 @@ const hidQrCodePassAllowed = computed(() => {
1266
1263
  );
1267
1264
  });
1268
1265
 
1266
+ // The site's Whole day / Single usage choice for HID visitor passes, shown
1267
+ // under the HID QR Code card as the resident app's invite form shows it.
1268
+ // Null until read, and when it cannot be read: then no hint, not a guess.
1269
+ // Loaded by the watcher below `siteData`.
1270
+ const hidQrUsage = ref<"wholeDay" | "singleUse" | null>(null);
1271
+
1269
1272
  // The entry pass is a contractor's, with the site's NFC pass switch on, as
1270
1273
  // the security mobile app (the web-only HID QR pass, switched off above, keeps
1271
1274
  // its own types).
@@ -2298,8 +2301,14 @@ watch(
2298
2301
  hidQrReader.value = null;
2299
2302
  if (!site) return;
2300
2303
  try {
2301
- const response = await getReaders({ site, page: 1, limit: 100 });
2302
- const readers: unknown = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
2304
+ /* The member's list, not the admin one. `POST /readers/:id/visitor-qr`
2305
+ asks for membership plus `hid-mgmt:use-hid-access` (API-core #1772),
2306
+ while `GET /readers` asks for the HID view right, so a member who may
2307
+ issue a visitor pass but holds no admin right got the pass route and
2308
+ not the reader it needs. `enrollment-readers` is the site's enabled
2309
+ readers with a portal, `{ data: { items } }`, for any member. */
2310
+ const response = await getEnrollmentReaders(site);
2311
+ const readers: unknown = response?.data?.items ?? [];
2303
2312
  const availableReaders = Array.isArray(readers)
2304
2313
  ? readers.filter((reader: unknown): reader is HidQrReaderSummary => (
2305
2314
  typeof reader === "object"
@@ -2326,6 +2335,23 @@ watch(
2326
2335
  { immediate: true },
2327
2336
  );
2328
2337
 
2338
+ // Below `siteData` for the same reason as the reader watcher above:
2339
+ // `hidQrCodePassAllowed` reads it. Only fetched where HID QR is offered.
2340
+ watch(
2341
+ [hidQrCodePassAllowed, () => prop.site],
2342
+ async ([allowed, site]) => {
2343
+ hidQrUsage.value = null;
2344
+ if (!allowed || !site) return;
2345
+ try {
2346
+ const settings = await getOvernightParkingAvailabilityV2(String(site), "site");
2347
+ hidQrUsage.value = hidQrUsageFromSettings(settings);
2348
+ } catch {
2349
+ hidQrUsage.value = null;
2350
+ }
2351
+ },
2352
+ { immediate: true },
2353
+ );
2354
+
2329
2355
  const {
2330
2356
  data: blocksListData,
2331
2357
  refresh: refreshBlockListData,
@@ -3388,7 +3414,9 @@ async function prepareHidQrCodePreview(visitorId: string) {
3388
3414
  hidQrVisitorId.value = visitorId;
3389
3415
  const response = await issueVisitorQr(hidQrReaderId.value, {
3390
3416
  visitorId,
3391
- validityMinutes: hidQrValidityMinutes.value,
3417
+ // The whole visit day, as the resident app issues it. Worked out per
3418
+ // call so a regenerate later in the day still ends at midnight.
3419
+ validityMinutes: minutesUntilEndOfDay(),
3392
3420
  });
3393
3421
  const credential = "data" in response ? response.data : response;
3394
3422
  hidQrPrintPayload.value = credential.qrValue;
@@ -84,6 +84,17 @@ export default function useCleaningPermission() {
84
84
  description: "Allows the user to manage and update site settings.",
85
85
  },
86
86
  },
87
+ // HID access, user side only (decision of 2026-10-16). The service apps
88
+ // have no HID admin screens yet, so a role here can be prepared with the
89
+ // one string that lets a member use their own QR code, face, PIN, card and
90
+ // intercom. Mirrored from core `src/utils/hid-access.util.ts`.
91
+ "hid-mgmt": {
92
+ "use-hid-access": {
93
+ check: true,
94
+ description:
95
+ "Allows the user to see HID Access in the app and use their own QR code, face, PIN, card and intercom at this site.",
96
+ },
97
+ },
87
98
  toilet_locations: {
88
99
  "create-toilet-location": {
89
100
  check: true,