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

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 (55) 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/service-provider-invitation-actions.md +28 -0
  7. package/components/CameraWall.vue +1034 -0
  8. package/components/CameraWallTile.vue +818 -0
  9. package/components/DashboardMain.vue +84 -88
  10. package/components/DashboardPanel.vue +1 -1
  11. package/components/EntryPassInformation.vue +4 -4
  12. package/components/FeedbackMain.vue +7 -7
  13. package/components/HidAccessLogDashboard.vue +119 -44
  14. package/components/HidCameraCaptureDialog.vue +199 -0
  15. package/components/HidIntercomManagement.vue +808 -201
  16. package/components/HidQrCodeConfiguration.vue +907 -95
  17. package/components/HidServiceSettingsPanel.vue +20 -1
  18. package/components/HidUserEnrollment.vue +392 -81
  19. package/components/Layout/NavigationDrawer.vue +43 -2
  20. package/components/NavigationItem.vue +9 -0
  21. package/components/Nfc/NFCPatrolRouteMain.vue +52 -7
  22. package/components/OnlineFormConfigurationForm.vue +620 -224
  23. package/components/OnlineFormFill.vue +36 -8
  24. package/components/OnlineFormsConfiguration.vue +6 -2
  25. package/components/QrTemplate/CreditCardLandscape.vue +19 -12
  26. package/components/QrTemplate/PrintDialog.vue +209 -196
  27. package/components/ServiceProviderInvitationPrompt.vue +150 -0
  28. package/components/ServiceProviderMain.vue +258 -14
  29. package/components/SiteSettings.vue +4 -2
  30. package/components/VisitorForm.vue +201 -78
  31. package/components/VisitorManagement.vue +2 -3
  32. package/components/VisitorSocketPopUp.vue +56 -2
  33. package/composables/useComment.ts +3 -3
  34. package/composables/useHidAmico.ts +127 -0
  35. package/composables/useHidNavigation.ts +0 -7
  36. package/composables/useLocalAuth.ts +9 -36
  37. package/composables/useOnlineFormPages.ts +2 -0
  38. package/composables/useServiceProviderInvitation.ts +145 -0
  39. package/composables/useSipWebPhone.ts +311 -0
  40. package/composables/useSiteSettings.ts +50 -0
  41. package/composables/useVisitorSocket.ts +26 -0
  42. package/composables/useWebUsb.ts +127 -37
  43. package/package.json +3 -1
  44. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  45. package/plugins/secure-member.client.ts +21 -56
  46. package/plugins/vuetify.ts +37 -9
  47. package/test/visitor-socket.test.mjs +36 -0
  48. package/types/site.d.ts +71 -0
  49. package/utils/camera-wall.test.ts +571 -0
  50. package/utils/camera-wall.ts +926 -0
  51. package/utils/theme.test.ts +216 -0
  52. package/utils/theme.ts +163 -0
  53. package/components/HidIdentityMapping.vue +0 -975
  54. package/middleware/member.ts +0 -4
  55. package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +0 -23
@@ -9,65 +9,55 @@
9
9
  </div>
10
10
 
11
11
  <div class="config-body">
12
- <div class="section-head">
12
+ <div class="online-mode-row">
13
13
  <div>
14
- <h3>HID QR Code Pass</h3>
15
- <p>Allow users to generate and scan digital QR codes at compatible HID readers for touchless access control.</p>
14
+ <h3>Activate Online Mode</h3>
15
+ <p>Enables real-time server communication, synchronizing live credential verification and access data across the network.</p>
16
16
  </div>
17
17
  <v-switch
18
- v-model="form.enabled"
18
+ v-model="form.onlineMode"
19
+ class="hid-config-switch"
19
20
  color="success"
20
21
  hide-details
21
22
  inset
22
- :loading="savingSection === 'enabled'"
23
- @update:model-value="saveSection('enabled')"
24
- />
25
- </div>
26
-
27
- <div class="form-section">
28
- <div class="section-label">HID Reader</div>
29
- <v-select
30
- v-model="form.readerId"
31
- :items="readerOptions"
32
- item-title="title"
33
- item-value="value"
34
- placeholder="Select HID reader"
35
- variant="outlined"
36
- density="compact"
37
- hide-details
38
- class="reader-select"
23
+ :loading="savingSection === 'online-mode'"
24
+ :disabled="Boolean(savingSection) || !form.readerId"
25
+ @update:model-value="saveOnlineMode"
39
26
  />
40
27
  </div>
41
28
 
42
29
  <v-divider />
43
30
 
44
- <div class="form-section">
45
- <div class="section-label">QR Code Format</div>
46
- <div class="validity-grid">
47
- <v-select
48
- v-model="form.qrFormat"
49
- :items="qrFormatOptions"
50
- item-title="title"
51
- item-value="value"
52
- placeholder="Select format"
53
- variant="outlined"
54
- density="compact"
55
- hide-details
56
- />
57
- <v-btn
58
- class="text-none save-button"
59
- variant="flat"
60
- prepend-icon="mdi-qrcode-edit"
61
- height="40"
62
- :loading="savingSection === 'qr-format'"
63
- :disabled="!form.readerId"
64
- @click="saveSection('qr-format')"
31
+ <div class="form-section identification-section">
32
+ <div class="section-label">Enable/disable reader identification method</div>
33
+ <p class="section-description">
34
+ Toggles multi-factor access methods, allowing users to authenticate via biometric, physical card, or credential inputs.
35
+ </p>
36
+ <div class="methods-grid">
37
+ <div
38
+ v-for="method in identificationMethodOptions"
39
+ :key="method.key"
40
+ class="method-control"
41
+ :class="{ 'method-control--disabled': !form.onlineMode }"
65
42
  >
66
- Save Format
67
- </v-btn>
43
+ <span>{{ method.label }}</span>
44
+ <v-switch
45
+ v-model="form.identificationMethods[method.key]"
46
+ class="hid-config-switch"
47
+ color="success"
48
+ hide-details
49
+ inset
50
+ density="compact"
51
+ :loading="savingSection === `method-${method.key}`"
52
+ :disabled="Boolean(savingSection) || !form.onlineMode"
53
+ @update:model-value="saveIdentificationMethod(method.key)"
54
+ />
55
+ </div>
68
56
  </div>
69
57
  </div>
70
58
 
59
+ <v-divider />
60
+
71
61
  <div class="form-section">
72
62
  <div class="section-label">Printer Setup</div>
73
63
  <div class="printer-grid">
@@ -99,6 +89,7 @@
99
89
  variant="outlined"
100
90
  prepend-icon="mdi-printer-check"
101
91
  height="40"
92
+ :loading="testingPrinter"
102
93
  :disabled="!hasPrinterConfig"
103
94
  @click="testPrint"
104
95
  >
@@ -184,6 +175,88 @@
184
175
  </v-btn>
185
176
  </div>
186
177
  </div>
178
+
179
+ <v-divider />
180
+
181
+ <div v-if="isPermissionManager" class="permission-section">
182
+ <div class="permission-location">
183
+ <div class="permission-copy">
184
+ <div class="section-label">Permission Location</div>
185
+ <p class="section-description">
186
+ Permissions are scoped to the location configured on the HID Face Reader.
187
+ </p>
188
+ </div>
189
+ <v-select
190
+ v-model="permissionReaderId"
191
+ class="permission-location__select"
192
+ :items="permissionLocations"
193
+ item-title="label"
194
+ item-value="readerId"
195
+ placeholder="Select a Face Reader location"
196
+ variant="outlined"
197
+ density="compact"
198
+ hide-details
199
+ :disabled="permissionLocations.length === 0"
200
+ @update:model-value="loadPermissions"
201
+ />
202
+ </div>
203
+
204
+ <v-alert
205
+ v-if="permissionLocations.length === 0"
206
+ class="mb-4"
207
+ type="warning"
208
+ variant="tonal"
209
+ density="compact"
210
+ >
211
+ Add a Location to a Face Reader before assigning HID permissions.
212
+ </v-alert>
213
+
214
+ <v-divider />
215
+
216
+ <div class="permission-row">
217
+ <div class="permission-copy">
218
+ <div class="section-label">Access Permission</div>
219
+ <p class="section-description">
220
+ Select who can use the enabled HID identification methods at this site.
221
+ </p>
222
+ </div>
223
+ <div class="permission-actions">
224
+ <v-btn
225
+ v-for="option in accessPermissionOptions"
226
+ :key="option.category"
227
+ class="permission-button text-none"
228
+ variant="outlined"
229
+ :disabled="!form.onlineMode || !permissionReaderId"
230
+ @click="openPermissionDialog(option.category)"
231
+ >
232
+ <span class="permission-count">{{ permissionCount(option.category) }}</span>
233
+ <span>{{ option.label }}</span>
234
+ </v-btn>
235
+ </div>
236
+ </div>
237
+
238
+ <v-divider />
239
+
240
+ <div class="permission-row permission-row--intercom">
241
+ <div class="permission-copy">
242
+ <div class="section-label">Access Intercom</div>
243
+ <p class="section-description">
244
+ Choose which authorized people and providers may use HID intercom access.
245
+ </p>
246
+ </div>
247
+ <div class="permission-actions permission-actions--single">
248
+ <v-btn
249
+ class="permission-button text-none"
250
+ variant="outlined"
251
+ :disabled="!form.onlineMode || !permissionReaderId || permissionState.assignments.length === 0"
252
+ @click="openPermissionDialog('intercom')"
253
+ >
254
+ <span class="permission-count">{{ permissionState.counts.intercom }}</span>
255
+ <span>Intercom Permissions</span>
256
+ </v-btn>
257
+ </div>
258
+ </div>
259
+ </div>
187
260
  </div>
188
261
  </v-card>
189
262
 
@@ -222,37 +295,158 @@
222
295
  </v-card>
223
296
  </v-dialog>
224
297
 
298
+ <v-dialog v-model="permissionDialog" max-width="680" persistent>
299
+ <v-card class="permission-dialog">
300
+ <v-card-title class="permission-dialog__header">
301
+ <div>
302
+ <div class="permission-dialog__title">{{ permissionDialogTitle }}</div>
303
+ <div class="permission-dialog__subtitle">{{ permissionDialogDescription }}</div>
304
+ </div>
305
+ <v-btn
306
+ icon="mdi-close"
307
+ variant="text"
308
+ density="comfortable"
309
+ aria-label="Close permission dialog"
310
+ @click="closePermissionDialog"
311
+ />
312
+ </v-card-title>
313
+
314
+ <v-card-text class="permission-dialog__body">
315
+ <v-text-field
316
+ v-model="permissionSearch"
317
+ class="permission-search"
318
+ prepend-inner-icon="mdi-magnify"
319
+ placeholder="Search by name or details"
320
+ variant="outlined"
321
+ density="compact"
322
+ hide-details
323
+ clearable
324
+ />
325
+
326
+ <div class="permission-list-summary" aria-live="polite">
327
+ <span>{{ visiblePermissionCandidates.length }} entries</span>
328
+ <span class="permission-list-summary__selected">
329
+ {{ selectedPermissionIds.size }} selected
330
+ </span>
331
+ </div>
332
+
333
+ <div class="permission-list" :class="{ 'permission-list--loading': loadingPermissionCandidates }">
334
+ <div v-if="loadingPermissionCandidates" class="permission-empty">
335
+ <v-progress-circular indeterminate color="primary" size="28" />
336
+ <span>Loading permissions...</span>
337
+ </div>
338
+
339
+ <template v-else-if="visiblePermissionCandidates.length">
340
+ <label
341
+ v-for="candidate in visiblePermissionCandidates"
342
+ :key="`${candidate.category}:${candidate.subjectId}`"
343
+ class="permission-list-item"
344
+ :class="{ 'permission-list-item--selected': selectedPermissionIds.has(candidate.subjectId) }"
345
+ >
346
+ <span class="permission-list-item__content">
347
+ <strong>{{ candidate.name }}</strong>
348
+ <small>{{ candidate.subtitle || permissionCategoryLabel(candidate.category) }}</small>
349
+ </span>
350
+ <span class="permission-list-item__group">{{ permissionCategoryLabel(candidate.category) }}</span>
351
+ <v-checkbox-btn
352
+ class="permission-list-item__checkbox"
353
+ :model-value="selectedPermissionIds.has(candidate.subjectId)"
354
+ :aria-label="`Select ${candidate.name}`"
355
+ color="success"
356
+ @update:model-value="togglePermissionCandidate(candidate.subjectId, Boolean($event))"
357
+ />
358
+ </label>
359
+ </template>
360
+
361
+ <div v-else class="permission-empty">
362
+ <v-icon icon="mdi-account-search-outline" size="30" />
363
+ <span>{{ permissionEmptyMessage }}</span>
364
+ </div>
365
+ </div>
366
+ </v-card-text>
367
+
368
+ <v-card-actions class="permission-dialog__actions">
369
+ <v-btn class="text-none" variant="text" @click="closePermissionDialog">Cancel</v-btn>
370
+ <v-btn
371
+ class="text-none save-button"
372
+ variant="flat"
373
+ min-width="140"
374
+ :loading="savingPermissions"
375
+ :disabled="loadingPermissionCandidates"
376
+ @click="savePermissions"
377
+ >
378
+ Save Permissions
379
+ </v-btn>
380
+ </v-card-actions>
381
+ </v-card>
382
+ </v-dialog>
383
+
225
384
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
226
385
  </section>
227
386
  </template>
228
387
 
229
388
  <script setup lang="ts">
230
- type HidQrConfig = {
231
- enabled: boolean;
232
- readerId: string;
233
- qrFormat: "0" | "1" | "2";
234
- printer: {
235
- vendorId: string;
236
- productId: string;
237
- };
238
- template: {
239
- header: string;
240
- subtext: string;
389
+ type HidIdentificationMethod = keyof NonNullable<THidQrCodePassConfig["identificationMethods"]>;
390
+
391
+ type HidQrConfig = Omit<
392
+ THidQrCodePassConfig,
393
+ "identificationMethods" | "onlineMode" | "updatedAt"
394
+ > & {
395
+ onlineMode: boolean;
396
+ identificationMethods: Record<HidIdentificationMethod, boolean>;
397
+ };
398
+
399
+ type HidReaderOption = {
400
+ _id: string;
401
+ name?: string;
402
+ deviceId?: string;
403
+ location?: string;
404
+ };
405
+
406
+ type HidReaderListResponse = {
407
+ items?: HidReaderOption[];
408
+ readers?: HidReaderOption[];
409
+ data?: {
410
+ items?: HidReaderOption[];
411
+ readers?: HidReaderOption[];
241
412
  };
242
- validityMinutes: number | null;
243
413
  };
244
414
 
245
415
  const props = defineProps<{
246
416
  site: string;
417
+ org: string;
247
418
  }>();
248
419
 
420
+ const runtimeConfig = useRuntimeConfig();
421
+ const isPermissionManager = computed(() => runtimeConfig.public.APP === "security_agency");
422
+
249
423
  const { getSiteById, updateSitebyId } = useSiteSettings();
250
- const { getReaders, setReaderConfiguration } = useHidAmico();
424
+ const {
425
+ getReaders,
426
+ getReaderConfiguration,
427
+ setReaderConfiguration,
428
+ getSitePermissions,
429
+ getPermissionCandidates,
430
+ updateSitePermissions,
431
+ } = useHidAmico();
432
+ const {
433
+ requestDevice: requestPrinterDevice,
434
+ testConnection: testPrinterConnection,
435
+ } = useWebUsb();
251
436
 
252
437
  const defaultConfig: HidQrConfig = {
253
438
  enabled: false,
439
+ onlineMode: false,
254
440
  readerId: "",
255
441
  qrFormat: "0",
442
+ identificationMethods: {
443
+ facial: true,
444
+ card: true,
445
+ qrCode: false,
446
+ idPassword: true,
447
+ pin: false,
448
+ bluetooth: false,
449
+ },
256
450
  printer: {
257
451
  vendorId: "",
258
452
  productId: "",
@@ -264,36 +458,111 @@ const defaultConfig: HidQrConfig = {
264
458
  validityMinutes: null,
265
459
  };
266
460
 
267
- const siteData = ref<Record<string, any> | null>(null);
268
- const readers = ref<Record<string, any>[]>([]);
461
+ const siteData = ref<TSite | null>(null);
462
+ const readers = ref<HidReaderOption[]>([]);
269
463
  const form = reactive<HidQrConfig>(structuredClone(defaultConfig));
270
464
  const previewDialog = ref(false);
271
465
  const savingSection = ref("");
466
+ const testingPrinter = ref(false);
272
467
  const message = ref("");
273
468
  const messageColor = ref("success");
274
469
  const messageSnackbar = ref(false);
470
+ const permissionState = reactive<THidSitePermissions>({
471
+ orgId: props.org,
472
+ site: props.site,
473
+ readerId: "",
474
+ location: "",
475
+ assignments: [],
476
+ counts: {
477
+ resident: 0,
478
+ propertyManagement: 0,
479
+ serviceProvider: 0,
480
+ intercom: 0,
481
+ },
482
+ });
483
+ const permissionDialog = ref(false);
484
+ const permissionReaderId = ref("");
485
+ const permissionDialogCategory = ref<THidPermissionCategory | "intercom">("resident");
486
+ const permissionCandidates = ref<THidPermissionCandidate[]>([]);
487
+ const selectedPermissionIds = ref(new Set<string>());
488
+ const permissionSearch = ref("");
489
+ const loadingPermissionCandidates = ref(false);
490
+ const savingPermissions = ref(false);
275
491
 
276
492
  const hasPrinterConfig = computed(() =>
277
493
  Boolean(form.printer.vendorId && form.printer.productId),
278
494
  );
279
- const readerOptions = computed(() => readers.value.map((reader) => ({
280
- title: reader.name || reader.deviceId || reader._id,
281
- value: reader._id,
282
- })));
283
- const qrFormatOptions = [
284
- { title: "Alphanumeric", value: "0" },
285
- { title: "Numeric only", value: "1" },
286
- { title: "Hexadecimal numeric", value: "2" },
495
+ const identificationMethodOptions: Array<{
496
+ key: HidIdentificationMethod;
497
+ label: string;
498
+ }> = [
499
+ { key: "facial", label: "Facial" },
500
+ { key: "card", label: "Card" },
501
+ { key: "qrCode", label: "QR Code" },
502
+ { key: "idPassword", label: "ID Password" },
503
+ { key: "pin", label: "PIN" },
504
+ { key: "bluetooth", label: "Bluetooth" },
505
+ ];
506
+ const accessPermissionOptions: Array<{
507
+ category: THidPermissionCategory;
508
+ label: string;
509
+ }> = [
510
+ { category: "resident", label: "Resident" },
511
+ { category: "property_management", label: "Property Management" },
512
+ { category: "service_provider", label: "Service Provider" },
287
513
  ];
288
514
 
515
+ const permissionLocations = computed(() => {
516
+ const seen = new Set<string>();
517
+ return readers.value.reduce<Array<{ readerId: string; location: string; label: string }>>((items, reader) => {
518
+ const location = reader.location?.trim();
519
+ const key = location?.toLocaleLowerCase();
520
+ if (!location || !key || seen.has(key)) return items;
521
+ seen.add(key);
522
+ items.push({
523
+ readerId: reader._id,
524
+ location,
525
+ label: location,
526
+ });
527
+ return items;
528
+ }, []);
529
+ });
530
+ const selectedPermissionLocation = computed(() => (
531
+ permissionLocations.value.find((item) => item.readerId === permissionReaderId.value)?.location
532
+ || "the selected location"
533
+ ));
534
+
289
535
  const siteName = computed(() => siteData.value?.name || "Site");
290
536
  const siteLabel = computed(() => siteData.value?.code || siteData.value?.block || props.site);
291
537
  const validityText = computed(() =>
292
538
  form.validityMinutes ? `${form.validityMinutes} minutes` : "Not set",
293
539
  );
540
+ const visiblePermissionCandidates = computed(() => {
541
+ const search = permissionSearch.value.trim().toLowerCase();
542
+ if (!search) return permissionCandidates.value;
543
+ return permissionCandidates.value.filter((candidate) => (
544
+ candidate.name.toLowerCase().includes(search)
545
+ || candidate.subtitle?.toLowerCase().includes(search)
546
+ ));
547
+ });
548
+ const permissionDialogTitle = computed(() => (
549
+ permissionDialogCategory.value === "intercom"
550
+ ? "Intercom Permissions"
551
+ : `${permissionCategoryLabel(permissionDialogCategory.value)} Access Permission`
552
+ ));
553
+ const permissionDialogDescription = computed(() => (
554
+ permissionDialogCategory.value === "intercom"
555
+ ? `Select from people and providers who already have HID access permission at ${selectedPermissionLocation.value}.`
556
+ : `Select the ${permissionCategoryLabel(permissionDialogCategory.value).toLowerCase()} entries allowed to use HID access at ${selectedPermissionLocation.value}.`
557
+ ));
558
+ const permissionEmptyMessage = computed(() => (
559
+ permissionDialogCategory.value === "intercom" && permissionState.assignments.length === 0
560
+ ? "Add an access permission before assigning intercom access."
561
+ : "No matching entries found."
562
+ ));
294
563
 
295
564
  watch(
296
- () => props.site,
565
+ () => [props.org, props.site],
297
566
  () => loadConfig(),
298
567
  { immediate: true },
299
568
  );
@@ -305,13 +574,24 @@ async function loadConfig() {
305
574
  getSiteById(props.site),
306
575
  getReaders({ site: props.site, page: 1, limit: 100 }),
307
576
  ]);
308
- siteData.value = site;
309
- readers.value = readerResponse?.items ?? readerResponse?.data?.items ?? readerResponse?.data?.readers ?? [];
577
+ siteData.value = site as TSite;
578
+ const readerList = readerResponse as HidReaderListResponse;
579
+ readers.value = readerList.items ?? readerList.data?.items ?? readerList.data?.readers ?? [];
310
580
  const config = siteData.value?.metadata?.hidQrCodePass || {};
581
+ const configuredMethods = config.identificationMethods;
311
582
  Object.assign(form, {
312
583
  enabled: Boolean(config.enabled),
584
+ onlineMode: Boolean(config.onlineMode),
313
585
  readerId: String(config.readerId || readers.value[0]?._id || ""),
314
586
  qrFormat: ["0", "1", "2"].includes(String(config.qrFormat)) ? String(config.qrFormat) as "0" | "1" | "2" : "0",
587
+ identificationMethods: {
588
+ facial: configuredMethods?.facial ?? true,
589
+ card: configuredMethods?.card ?? true,
590
+ qrCode: configuredMethods?.qrCode ?? Boolean(config.enabled),
591
+ idPassword: configuredMethods?.idPassword ?? true,
592
+ pin: configuredMethods?.pin ?? false,
593
+ bluetooth: configuredMethods?.bluetooth ?? false,
594
+ },
315
595
  printer: {
316
596
  vendorId: String(config.printer?.vendorId || ""),
317
597
  productId: String(config.printer?.productId || ""),
@@ -322,15 +602,175 @@ async function loadConfig() {
322
602
  },
323
603
  validityMinutes: config.validityMinutes ? Number(config.validityMinutes) : null,
324
604
  });
605
+
606
+ permissionReaderId.value = permissionLocations.value.find((item) => item.readerId === form.readerId)?.readerId
607
+ || permissionLocations.value[0]?.readerId
608
+ || "";
609
+
610
+ await Promise.all([
611
+ loadReaderConfiguration(),
612
+ isPermissionManager.value ? loadPermissions() : Promise.resolve(),
613
+ ]);
614
+ }
615
+
616
+ async function loadPermissions() {
617
+ if (!props.org || !props.site || !permissionReaderId.value) {
618
+ Object.assign(permissionState, {
619
+ orgId: props.org,
620
+ site: props.site,
621
+ readerId: "",
622
+ location: "",
623
+ assignments: [],
624
+ counts: { resident: 0, propertyManagement: 0, serviceProvider: 0, intercom: 0 },
625
+ });
626
+ return;
627
+ }
628
+ try {
629
+ const response = await getSitePermissions(props.site, props.org, permissionReaderId.value);
630
+ Object.assign(permissionState, response.data);
631
+ } catch (error: unknown) {
632
+ console.error("Unable to load HID permissions:", errorConverter(error));
633
+ }
634
+ }
635
+
636
+ function permissionCategoryLabel(category: THidPermissionCategory) {
637
+ return accessPermissionOptions.find((option) => option.category === category)?.label || "Permission";
638
+ }
639
+
640
+ function permissionCount(category: THidPermissionCategory) {
641
+ if (category === "property_management") return permissionState.counts.propertyManagement;
642
+ if (category === "service_provider") return permissionState.counts.serviceProvider;
643
+ return permissionState.counts.resident;
644
+ }
645
+
646
+ async function openPermissionDialog(category: THidPermissionCategory | "intercom") {
647
+ if (!props.org || !permissionReaderId.value) return;
648
+ permissionDialogCategory.value = category;
649
+ permissionSearch.value = "";
650
+ permissionCandidates.value = [];
651
+ selectedPermissionIds.value = new Set<string>();
652
+ permissionDialog.value = true;
653
+ loadingPermissionCandidates.value = true;
654
+
655
+ try {
656
+ const response = await getPermissionCandidates(props.site, {
657
+ orgId: props.org,
658
+ readerId: permissionReaderId.value,
659
+ category,
660
+ page: 1,
661
+ limit: 500,
662
+ });
663
+ permissionCandidates.value = response.items || [];
664
+ selectedPermissionIds.value = new Set(
665
+ permissionCandidates.value
666
+ .filter((candidate) => category === "intercom" ? candidate.intercom : candidate.selected)
667
+ .map((candidate) => candidate.subjectId),
668
+ );
669
+ } catch (error: unknown) {
670
+ showMessage(errorConverter(error), "error");
671
+ } finally {
672
+ loadingPermissionCandidates.value = false;
673
+ }
674
+ }
675
+
676
+ function closePermissionDialog() {
677
+ if (savingPermissions.value) return;
678
+ permissionDialog.value = false;
679
+ }
680
+
681
+ function togglePermissionCandidate(subjectId: string, selected: boolean) {
682
+ const next = new Set(selectedPermissionIds.value);
683
+ if (selected) next.add(subjectId);
684
+ else next.delete(subjectId);
685
+ selectedPermissionIds.value = next;
686
+ }
687
+
688
+ async function savePermissions() {
689
+ const category = permissionDialogCategory.value;
690
+ let assignments: Array<Pick<THidPermissionAssignment, "subjectId" | "category" | "intercom">>;
691
+
692
+ if (category === "intercom") {
693
+ assignments = permissionState.assignments.map((assignment) => ({
694
+ subjectId: assignment.subjectId,
695
+ category: assignment.category,
696
+ intercom: selectedPermissionIds.value.has(assignment.subjectId),
697
+ }));
698
+ } else {
699
+ const retained = permissionState.assignments
700
+ .filter((assignment) => assignment.category !== category)
701
+ .map((assignment) => ({
702
+ subjectId: assignment.subjectId,
703
+ category: assignment.category,
704
+ intercom: assignment.intercom,
705
+ }));
706
+ const selected = permissionCandidates.value
707
+ .filter((candidate) => selectedPermissionIds.value.has(candidate.subjectId))
708
+ .map((candidate) => ({
709
+ subjectId: candidate.subjectId,
710
+ category: candidate.category,
711
+ intercom: candidate.intercom,
712
+ }));
713
+ assignments = [...retained, ...selected];
714
+ }
715
+
716
+ savingPermissions.value = true;
717
+ try {
718
+ const response = await updateSitePermissions(
719
+ props.site,
720
+ props.org,
721
+ permissionReaderId.value,
722
+ assignments,
723
+ );
724
+ Object.assign(permissionState, response.data);
725
+ permissionDialog.value = false;
726
+ showMessage("HID permissions updated.", "success");
727
+ } catch (error: unknown) {
728
+ showMessage(errorConverter(error), "error");
729
+ } finally {
730
+ savingPermissions.value = false;
731
+ }
732
+ }
733
+
734
+ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
735
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
736
+ }
737
+
738
+ function getConfigurationData(response: unknown) {
739
+ if (!isUnknownRecord(response)) return {};
740
+ return isUnknownRecord(response.data) ? response.data : response;
741
+ }
742
+
743
+ function isEnabledConfiguration(value: unknown) {
744
+ return value === true || value === 1 || value === "1";
745
+ }
746
+
747
+ async function loadReaderConfiguration() {
748
+ if (!form.readerId) return;
749
+
750
+ try {
751
+ const response = await getReaderConfiguration(form.readerId, {
752
+ general: ["online"],
753
+ });
754
+ const configuration = getConfigurationData(response);
755
+ const general = isUnknownRecord(configuration.general) ? configuration.general : {};
756
+
757
+ if (general.online !== undefined) {
758
+ form.onlineMode = isEnabledConfiguration(general.online);
759
+ }
760
+ } catch (error: unknown) {
761
+ console.warn("Unable to load HID reader configuration:", errorConverter(error));
762
+ }
325
763
  }
326
764
 
327
765
  function buildPayload() {
328
766
  const metadata = {
329
767
  ...(siteData.value?.metadata || {}),
330
768
  hidQrCodePass: {
331
- enabled: form.enabled,
769
+ enabled: form.identificationMethods.qrCode,
770
+ onlineMode: form.onlineMode,
332
771
  readerId: form.readerId,
333
772
  qrFormat: form.qrFormat,
773
+ identificationMethods: { ...form.identificationMethods },
334
774
  printer: {
335
775
  vendorId: form.printer.vendorId.trim(),
336
776
  productId: form.printer.productId.trim(),
@@ -352,17 +792,45 @@ async function saveSection(section: string) {
352
792
 
353
793
  savingSection.value = section;
354
794
  try {
355
- if (section === "qr-format") {
356
- await setReaderConfiguration(form.readerId, {
357
- face_id: {
358
- qrcode_legacy_mode_enabled: form.qrFormat,
359
- },
360
- });
361
- }
362
795
  await updateSitebyId(props.site, buildPayload());
363
796
  await loadConfig();
364
- showMessage("HID QR code settings saved.", "success");
365
- } catch (error: any) {
797
+ showMessage("HID configuration saved.", "success");
798
+ } catch (error: unknown) {
799
+ showMessage(errorConverter(error), "error");
800
+ } finally {
801
+ savingSection.value = "";
802
+ }
803
+ }
804
+
805
+ async function saveOnlineMode() {
806
+ if (!props.site || !form.readerId) return;
807
+
808
+ savingSection.value = "online-mode";
809
+ try {
810
+ await setReaderConfiguration(form.readerId, {
811
+ general: { online: form.onlineMode ? "1" : "0" },
812
+ });
813
+ await updateSitebyId(props.site, buildPayload());
814
+ showMessage("Online mode updated.", "success");
815
+ } catch (error: unknown) {
816
+ await loadConfig();
817
+ showMessage(errorConverter(error), "error");
818
+ } finally {
819
+ savingSection.value = "";
820
+ }
821
+ }
822
+
823
+ async function saveIdentificationMethod(method: HidIdentificationMethod) {
824
+ if (!props.site) return;
825
+
826
+ form.enabled = form.identificationMethods.qrCode;
827
+
828
+ savingSection.value = `method-${method}`;
829
+ try {
830
+ await updateSitebyId(props.site, buildPayload());
831
+ showMessage(`${identificationMethodOptions.find((item) => item.key === method)?.label || "Identification method"} updated.`, "success");
832
+ } catch (error: unknown) {
833
+ await loadConfig();
366
834
  showMessage(errorConverter(error), "error");
367
835
  } finally {
368
836
  savingSection.value = "";
@@ -376,19 +844,36 @@ async function scanPrinter() {
376
844
  }
377
845
 
378
846
  try {
379
- const device = await (navigator as any).usb.requestDevice({ filters: [] });
847
+ const device = await requestPrinterDevice();
380
848
  form.printer.vendorId = `0x${Number(device.vendorId).toString(16).padStart(4, "0")}`;
381
849
  form.printer.productId = `0x${Number(device.productId).toString(16).padStart(4, "0")}`;
382
850
  showMessage("Printer selected. Save settings to keep it.", "success");
383
- } catch (error: any) {
384
- if (error?.name !== "NotFoundError") {
385
- showMessage(error?.message || "Unable to scan printer.", "error");
851
+ } catch (error: unknown) {
852
+ const message = errorConverter(error);
853
+ if (message !== "No device selected") {
854
+ showMessage(message || "Unable to scan printer.", "error");
386
855
  }
387
856
  }
388
857
  }
389
858
 
390
- function testPrint() {
391
- showMessage("Printer settings are ready. Connect print command when HID QR payload generation is available.", "success");
859
+ async function testPrint() {
860
+ const vendorId = Number(form.printer.vendorId);
861
+ const productId = Number(form.printer.productId);
862
+ if (!vendorId || !productId) {
863
+ showMessage("Please scan a valid USB printer first.", "error");
864
+ return;
865
+ }
866
+
867
+ testingPrinter.value = true;
868
+ try {
869
+ const result = await testPrinterConnection(vendorId, productId);
870
+ showMessage(
871
+ result.success ? result.message : result.error || result.message,
872
+ result.success ? "success" : "error",
873
+ );
874
+ } finally {
875
+ testingPrinter.value = false;
876
+ }
392
877
  }
393
878
 
394
879
  function showMessage(text: string, color: string) {
@@ -432,15 +917,15 @@ function showMessage(text: string, color: string) {
432
917
  padding: 30px 38px;
433
918
  }
434
919
 
435
- .section-head {
920
+ .online-mode-row {
436
921
  display: flex;
437
- align-items: flex-start;
922
+ align-items: center;
438
923
  justify-content: space-between;
439
924
  gap: 24px;
440
- margin-bottom: 24px;
925
+ padding: 0 0 20px;
441
926
  }
442
927
 
443
- .section-head h3,
928
+ .online-mode-row h3,
444
929
  .section-label {
445
930
  margin: 0 0 6px;
446
931
  color: #233545;
@@ -448,16 +933,83 @@ function showMessage(text: string, color: string) {
448
933
  font-weight: 700;
449
934
  }
450
935
 
451
- .section-head p {
936
+ .online-mode-row p,
937
+ .section-description {
452
938
  margin: 0;
453
939
  color: #677280;
454
940
  font-size: 12px;
941
+ line-height: 1.5;
455
942
  }
456
943
 
457
944
  .form-section {
458
945
  padding: 16px 0;
459
946
  }
460
947
 
948
+ .identification-section {
949
+ padding-bottom: 20px;
950
+ }
951
+
952
+ .methods-grid {
953
+ display: grid;
954
+ grid-template-columns: repeat(6, minmax(120px, 1fr));
955
+ gap: 18px;
956
+ margin-top: 16px;
957
+ }
958
+
959
+ .method-control {
960
+ display: flex;
961
+ align-items: center;
962
+ justify-content: space-between;
963
+ min-width: 0;
964
+ min-height: 42px;
965
+ padding: 0 10px 0 14px;
966
+ border: 1px solid #dce2e7;
967
+ border-radius: 6px;
968
+ color: #334454;
969
+ font-size: 12px;
970
+ }
971
+
972
+ .method-control span {
973
+ overflow: hidden;
974
+ text-overflow: ellipsis;
975
+ white-space: nowrap;
976
+ }
977
+
978
+ .method-control--disabled {
979
+ background-color: #f5f6f7;
980
+ color: #8b9297;
981
+ }
982
+
983
+ .method-control :deep(.v-switch) {
984
+ flex: 0 0 auto;
985
+ }
986
+
987
+ .method-control :deep(.v-selection-control) {
988
+ min-height: 32px;
989
+ }
990
+
991
+ .hid-config-switch :deep(.v-switch__track) {
992
+ background-color: #8b9297;
993
+ opacity: 1;
994
+ }
995
+
996
+ .hid-config-switch :deep(.v-selection-control--dirty .v-switch__track) {
997
+ background-color: #2eaf55 !important;
998
+ color: #2eaf55 !important;
999
+ opacity: 1;
1000
+ }
1001
+
1002
+ .hid-config-switch :deep(.v-switch__thumb) {
1003
+ background-color: #fff !important;
1004
+ color: #fff !important;
1005
+ }
1006
+
1007
+ .hid-config-switch.v-input--disabled :deep(.v-switch__track) {
1008
+ background-color: #aeb4b8 !important;
1009
+ color: #aeb4b8 !important;
1010
+ opacity: 1;
1011
+ }
1012
+
461
1013
  .printer-grid {
462
1014
  display: grid;
463
1015
  grid-template-columns: minmax(140px, 1fr) minmax(140px, 1fr) minmax(140px, 1fr) minmax(140px, 1fr) minmax(140px, 1fr);
@@ -483,6 +1035,231 @@ function showMessage(text: string, color: string) {
483
1035
  align-items: center;
484
1036
  }
485
1037
 
1038
+ .permission-section {
1039
+ padding-top: 2px;
1040
+ }
1041
+
1042
+ .permission-location {
1043
+ display: grid;
1044
+ grid-template-columns: minmax(260px, 1fr) minmax(320px, 1.35fr);
1045
+ gap: 36px;
1046
+ align-items: center;
1047
+ padding: 18px 0;
1048
+ }
1049
+
1050
+ .permission-location__select {
1051
+ width: 100%;
1052
+ }
1053
+
1054
+ .permission-row {
1055
+ display: grid;
1056
+ grid-template-columns: minmax(260px, 1fr) minmax(520px, 1.35fr);
1057
+ gap: 36px;
1058
+ align-items: center;
1059
+ padding: 18px 0;
1060
+ }
1061
+
1062
+ .permission-row--intercom {
1063
+ padding-bottom: 4px;
1064
+ }
1065
+
1066
+ .permission-copy {
1067
+ min-width: 0;
1068
+ }
1069
+
1070
+ .permission-actions {
1071
+ display: grid;
1072
+ grid-template-columns: repeat(3, minmax(150px, 1fr));
1073
+ gap: 12px;
1074
+ }
1075
+
1076
+ .permission-actions--single {
1077
+ grid-template-columns: minmax(180px, 240px);
1078
+ justify-content: end;
1079
+ }
1080
+
1081
+ .permission-button {
1082
+ justify-content: flex-start;
1083
+ min-height: 42px;
1084
+ padding-inline: 12px;
1085
+ border-color: #dce2e7;
1086
+ color: #334454;
1087
+ }
1088
+
1089
+ .permission-count {
1090
+ display: inline-grid;
1091
+ place-items: center;
1092
+ width: 20px;
1093
+ height: 20px;
1094
+ margin-right: 8px;
1095
+ border-radius: 50%;
1096
+ background: #dce2e7;
1097
+ color: #526170;
1098
+ font-size: 11px;
1099
+ font-weight: 700;
1100
+ }
1101
+
1102
+ .permission-button:not(:disabled):hover {
1103
+ border-color: #1473c9;
1104
+ color: #0a5da7;
1105
+ }
1106
+
1107
+ .permission-dialog {
1108
+ overflow: hidden;
1109
+ border-radius: 6px;
1110
+ }
1111
+
1112
+ .permission-dialog__header {
1113
+ display: flex;
1114
+ align-items: flex-start;
1115
+ justify-content: space-between;
1116
+ gap: 16px;
1117
+ padding: 16px 18px 14px;
1118
+ border-bottom: 1px solid #e5e9ed;
1119
+ }
1120
+
1121
+ .permission-dialog__title {
1122
+ color: #172534;
1123
+ font-size: 18px;
1124
+ font-weight: 700;
1125
+ }
1126
+
1127
+ .permission-dialog__subtitle {
1128
+ margin-top: 4px;
1129
+ color: #687583;
1130
+ font-size: 12px;
1131
+ white-space: normal;
1132
+ }
1133
+
1134
+ .permission-dialog__body {
1135
+ display: grid;
1136
+ gap: 10px;
1137
+ padding: 14px 18px 16px;
1138
+ }
1139
+
1140
+ .permission-search :deep(.v-field) {
1141
+ min-height: 44px;
1142
+ }
1143
+
1144
+ .permission-list-summary {
1145
+ display: flex;
1146
+ align-items: center;
1147
+ justify-content: space-between;
1148
+ min-height: 22px;
1149
+ color: #778391;
1150
+ font-size: 11px;
1151
+ }
1152
+
1153
+ .permission-list-summary__selected {
1154
+ color: #286c3c;
1155
+ font-weight: 700;
1156
+ }
1157
+
1158
+ .permission-list {
1159
+ min-height: 180px;
1160
+ max-height: min(360px, 48vh);
1161
+ overflow-y: auto;
1162
+ border: 1px solid #dfe5ea;
1163
+ border-radius: 6px;
1164
+ background: #ffffff;
1165
+ }
1166
+
1167
+ .permission-list--loading {
1168
+ display: grid;
1169
+ place-items: center;
1170
+ }
1171
+
1172
+ .permission-list-item {
1173
+ display: grid;
1174
+ grid-template-columns: minmax(0, 1fr) max-content 32px;
1175
+ gap: 12px;
1176
+ align-items: center;
1177
+ min-height: 56px;
1178
+ padding: 7px 12px;
1179
+ border-bottom: 1px solid #edf0f3;
1180
+ cursor: pointer;
1181
+ transition: background-color 120ms ease;
1182
+ }
1183
+
1184
+ .permission-list-item:last-child {
1185
+ border-bottom: 0;
1186
+ }
1187
+
1188
+ .permission-list-item:hover {
1189
+ background: #f7fafc;
1190
+ }
1191
+
1192
+ .permission-list-item--selected {
1193
+ background: #f1f8f3;
1194
+ }
1195
+
1196
+ .permission-list-item--selected:hover {
1197
+ background: #eaf5ed;
1198
+ }
1199
+
1200
+ .permission-list-item__content {
1201
+ display: grid;
1202
+ gap: 2px;
1203
+ min-width: 0;
1204
+ }
1205
+
1206
+ .permission-list-item__content strong,
1207
+ .permission-list-item__content small {
1208
+ overflow: hidden;
1209
+ text-overflow: ellipsis;
1210
+ white-space: nowrap;
1211
+ }
1212
+
1213
+ .permission-list-item__content strong {
1214
+ color: #243443;
1215
+ font-size: 13px;
1216
+ line-height: 1.35;
1217
+ }
1218
+
1219
+ .permission-list-item__content small,
1220
+ .permission-list-item__group {
1221
+ color: #778391;
1222
+ font-size: 11px;
1223
+ }
1224
+
1225
+ .permission-list-item__group {
1226
+ max-width: 170px;
1227
+ overflow: hidden;
1228
+ padding: 4px 9px;
1229
+ border-radius: 4px;
1230
+ background: #eef2f5;
1231
+ text-overflow: ellipsis;
1232
+ white-space: nowrap;
1233
+ }
1234
+
1235
+ .permission-list-item__checkbox {
1236
+ justify-self: end;
1237
+ }
1238
+
1239
+ .permission-list-item__checkbox :deep(.v-selection-control) {
1240
+ min-height: 32px;
1241
+ }
1242
+
1243
+ .permission-empty {
1244
+ display: flex;
1245
+ flex-direction: column;
1246
+ align-items: center;
1247
+ justify-content: center;
1248
+ gap: 10px;
1249
+ min-height: 180px;
1250
+ padding: 24px;
1251
+ color: #75818c;
1252
+ font-size: 13px;
1253
+ text-align: center;
1254
+ }
1255
+
1256
+ .permission-dialog__actions {
1257
+ justify-content: flex-end;
1258
+ gap: 8px;
1259
+ padding: 12px 18px;
1260
+ border-top: 1px solid #e5e9ed;
1261
+ }
1262
+
486
1263
  .save-button {
487
1264
  background: #082e43;
488
1265
  color: #ffffff;
@@ -592,11 +1369,21 @@ function showMessage(text: string, color: string) {
592
1369
  }
593
1370
 
594
1371
  @media (max-width: 1100px) {
1372
+ .methods-grid {
1373
+ grid-template-columns: repeat(3, minmax(140px, 1fr));
1374
+ }
1375
+
595
1376
  .printer-grid,
596
1377
  .template-grid,
597
- .validity-grid {
1378
+ .validity-grid,
1379
+ .permission-location,
1380
+ .permission-row {
598
1381
  grid-template-columns: 1fr 1fr;
599
1382
  }
1383
+
1384
+ .permission-actions {
1385
+ grid-template-columns: 1fr;
1386
+ }
600
1387
  }
601
1388
 
602
1389
  @media (max-width: 700px) {
@@ -604,15 +1391,40 @@ function showMessage(text: string, color: string) {
604
1391
  padding: 22px 16px;
605
1392
  }
606
1393
 
607
- .section-head,
1394
+ .online-mode-row,
608
1395
  .printer-grid,
609
1396
  .template-grid,
610
- .validity-grid {
1397
+ .validity-grid,
1398
+ .methods-grid,
1399
+ .permission-location,
1400
+ .permission-row,
1401
+ .permission-actions {
611
1402
  grid-template-columns: 1fr;
612
1403
  }
613
1404
 
614
- .section-head {
1405
+ .online-mode-row {
615
1406
  display: grid;
616
1407
  }
1408
+
1409
+ .permission-actions--single {
1410
+ justify-content: stretch;
1411
+ }
1412
+
1413
+ .permission-list-item {
1414
+ grid-template-columns: minmax(0, 1fr) 32px;
1415
+ gap: 8px;
1416
+ }
1417
+
1418
+ .permission-list-item__group {
1419
+ grid-column: 1;
1420
+ grid-row: 2;
1421
+ justify-self: start;
1422
+ max-width: 100%;
1423
+ }
1424
+
1425
+ .permission-list-item__checkbox {
1426
+ grid-column: 2;
1427
+ grid-row: 1 / span 2;
1428
+ }
617
1429
  }
618
1430
  </style>