@7365admin1/layer-common 3.1.4-staging.49 → 3.1.4-staging.51

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.
@@ -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,54 @@
184
175
  </v-btn>
185
176
  </div>
186
177
  </div>
178
+
179
+ <v-divider />
180
+
181
+ <div class="permission-section">
182
+ <div class="permission-row">
183
+ <div class="permission-copy">
184
+ <div class="section-label">Access Permission</div>
185
+ <p class="section-description">
186
+ Select who can use the enabled HID identification methods at this site.
187
+ </p>
188
+ </div>
189
+ <div class="permission-actions">
190
+ <v-btn
191
+ v-for="option in accessPermissionOptions"
192
+ :key="option.category"
193
+ class="permission-button text-none"
194
+ variant="outlined"
195
+ :disabled="!form.onlineMode"
196
+ @click="openPermissionDialog(option.category)"
197
+ >
198
+ <span class="permission-count">{{ permissionCount(option.category) }}</span>
199
+ <span>{{ option.label }}</span>
200
+ </v-btn>
201
+ </div>
202
+ </div>
203
+
204
+ <v-divider />
205
+
206
+ <div class="permission-row permission-row--intercom">
207
+ <div class="permission-copy">
208
+ <div class="section-label">Access Intercom</div>
209
+ <p class="section-description">
210
+ Choose which authorized people and providers may use HID intercom access.
211
+ </p>
212
+ </div>
213
+ <div class="permission-actions permission-actions--single">
214
+ <v-btn
215
+ class="permission-button text-none"
216
+ variant="outlined"
217
+ :disabled="!form.onlineMode || permissionState.assignments.length === 0"
218
+ @click="openPermissionDialog('intercom')"
219
+ >
220
+ <span class="permission-count">{{ permissionState.counts.intercom }}</span>
221
+ <span>Intercom Permissions</span>
222
+ </v-btn>
223
+ </div>
224
+ </div>
225
+ </div>
187
226
  </div>
188
227
  </v-card>
189
228
 
@@ -222,24 +261,109 @@
222
261
  </v-card>
223
262
  </v-dialog>
224
263
 
264
+ <v-dialog v-model="permissionDialog" max-width="640" persistent>
265
+ <v-card class="permission-dialog">
266
+ <v-card-title class="permission-dialog__header">
267
+ <div>
268
+ <div class="permission-dialog__title">{{ permissionDialogTitle }}</div>
269
+ <div class="permission-dialog__subtitle">{{ permissionDialogDescription }}</div>
270
+ </div>
271
+ <v-btn
272
+ icon="mdi-close"
273
+ variant="text"
274
+ density="comfortable"
275
+ aria-label="Close permission dialog"
276
+ @click="closePermissionDialog"
277
+ />
278
+ </v-card-title>
279
+
280
+ <v-card-text class="permission-dialog__body">
281
+ <v-text-field
282
+ v-model="permissionSearch"
283
+ prepend-inner-icon="mdi-magnify"
284
+ placeholder="Search by name or details"
285
+ variant="outlined"
286
+ density="compact"
287
+ hide-details
288
+ clearable
289
+ />
290
+
291
+ <div class="permission-list" :class="{ 'permission-list--loading': loadingPermissionCandidates }">
292
+ <div v-if="loadingPermissionCandidates" class="permission-empty">
293
+ <v-progress-circular indeterminate color="primary" size="28" />
294
+ <span>Loading permissions...</span>
295
+ </div>
296
+
297
+ <template v-else-if="visiblePermissionCandidates.length">
298
+ <label
299
+ v-for="candidate in visiblePermissionCandidates"
300
+ :key="`${candidate.category}:${candidate.subjectId}`"
301
+ class="permission-list-item"
302
+ >
303
+ <v-checkbox-btn
304
+ :model-value="selectedPermissionIds.has(candidate.subjectId)"
305
+ color="success"
306
+ @update:model-value="togglePermissionCandidate(candidate.subjectId, Boolean($event))"
307
+ />
308
+ <span class="permission-list-item__content">
309
+ <strong>{{ candidate.name }}</strong>
310
+ <small>{{ candidate.subtitle || permissionCategoryLabel(candidate.category) }}</small>
311
+ </span>
312
+ <span class="permission-list-item__group">{{ permissionCategoryLabel(candidate.category) }}</span>
313
+ </label>
314
+ </template>
315
+
316
+ <div v-else class="permission-empty">
317
+ <v-icon icon="mdi-account-search-outline" size="30" />
318
+ <span>{{ permissionEmptyMessage }}</span>
319
+ </div>
320
+ </div>
321
+ </v-card-text>
322
+
323
+ <v-card-actions class="permission-dialog__actions">
324
+ <v-btn class="text-none" variant="text" @click="closePermissionDialog">Cancel</v-btn>
325
+ <v-btn
326
+ class="text-none save-button"
327
+ variant="flat"
328
+ min-width="140"
329
+ :loading="savingPermissions"
330
+ :disabled="loadingPermissionCandidates"
331
+ @click="savePermissions"
332
+ >
333
+ Save Permissions
334
+ </v-btn>
335
+ </v-card-actions>
336
+ </v-card>
337
+ </v-dialog>
338
+
225
339
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
226
340
  </section>
227
341
  </template>
228
342
 
229
343
  <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;
344
+ type HidIdentificationMethod = keyof NonNullable<THidQrCodePassConfig["identificationMethods"]>;
345
+
346
+ type HidQrConfig = Omit<
347
+ THidQrCodePassConfig,
348
+ "identificationMethods" | "onlineMode" | "updatedAt"
349
+ > & {
350
+ onlineMode: boolean;
351
+ identificationMethods: Record<HidIdentificationMethod, boolean>;
352
+ };
353
+
354
+ type HidReaderOption = {
355
+ _id: string;
356
+ name?: string;
357
+ deviceId?: string;
358
+ };
359
+
360
+ type HidReaderListResponse = {
361
+ items?: HidReaderOption[];
362
+ readers?: HidReaderOption[];
363
+ data?: {
364
+ items?: HidReaderOption[];
365
+ readers?: HidReaderOption[];
241
366
  };
242
- validityMinutes: number | null;
243
367
  };
244
368
 
245
369
  const props = defineProps<{
@@ -247,12 +371,32 @@ const props = defineProps<{
247
371
  }>();
248
372
 
249
373
  const { getSiteById, updateSitebyId } = useSiteSettings();
250
- const { getReaders, setReaderConfiguration } = useHidAmico();
374
+ const {
375
+ getReaders,
376
+ getReaderConfiguration,
377
+ setReaderConfiguration,
378
+ getSitePermissions,
379
+ getPermissionCandidates,
380
+ updateSitePermissions,
381
+ } = useHidAmico();
382
+ const {
383
+ requestDevice: requestPrinterDevice,
384
+ testConnection: testPrinterConnection,
385
+ } = useWebUsb();
251
386
 
252
387
  const defaultConfig: HidQrConfig = {
253
388
  enabled: false,
389
+ onlineMode: false,
254
390
  readerId: "",
255
391
  qrFormat: "0",
392
+ identificationMethods: {
393
+ facial: true,
394
+ card: true,
395
+ qrCode: false,
396
+ idPassword: true,
397
+ pin: false,
398
+ bluetooth: false,
399
+ },
256
400
  printer: {
257
401
  vendorId: "",
258
402
  productId: "",
@@ -264,26 +408,54 @@ const defaultConfig: HidQrConfig = {
264
408
  validityMinutes: null,
265
409
  };
266
410
 
267
- const siteData = ref<Record<string, any> | null>(null);
268
- const readers = ref<Record<string, any>[]>([]);
411
+ const siteData = ref<TSite | null>(null);
412
+ const readers = ref<HidReaderOption[]>([]);
269
413
  const form = reactive<HidQrConfig>(structuredClone(defaultConfig));
270
414
  const previewDialog = ref(false);
271
415
  const savingSection = ref("");
416
+ const testingPrinter = ref(false);
272
417
  const message = ref("");
273
418
  const messageColor = ref("success");
274
419
  const messageSnackbar = ref(false);
420
+ const permissionState = reactive<THidSitePermissions>({
421
+ site: props.site,
422
+ assignments: [],
423
+ counts: {
424
+ resident: 0,
425
+ propertyManagement: 0,
426
+ serviceProvider: 0,
427
+ intercom: 0,
428
+ },
429
+ });
430
+ const permissionDialog = ref(false);
431
+ const permissionDialogCategory = ref<THidPermissionCategory | "intercom">("resident");
432
+ const permissionCandidates = ref<THidPermissionCandidate[]>([]);
433
+ const selectedPermissionIds = ref(new Set<string>());
434
+ const permissionSearch = ref("");
435
+ const loadingPermissionCandidates = ref(false);
436
+ const savingPermissions = ref(false);
275
437
 
276
438
  const hasPrinterConfig = computed(() =>
277
439
  Boolean(form.printer.vendorId && form.printer.productId),
278
440
  );
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" },
441
+ const identificationMethodOptions: Array<{
442
+ key: HidIdentificationMethod;
443
+ label: string;
444
+ }> = [
445
+ { key: "facial", label: "Facial" },
446
+ { key: "card", label: "Card" },
447
+ { key: "qrCode", label: "QR Code" },
448
+ { key: "idPassword", label: "ID Password" },
449
+ { key: "pin", label: "PIN" },
450
+ { key: "bluetooth", label: "Bluetooth" },
451
+ ];
452
+ const accessPermissionOptions: Array<{
453
+ category: THidPermissionCategory;
454
+ label: string;
455
+ }> = [
456
+ { category: "resident", label: "Resident" },
457
+ { category: "property_management", label: "Property Management" },
458
+ { category: "service_provider", label: "Service Provider" },
287
459
  ];
288
460
 
289
461
  const siteName = computed(() => siteData.value?.name || "Site");
@@ -291,6 +463,29 @@ const siteLabel = computed(() => siteData.value?.code || siteData.value?.block |
291
463
  const validityText = computed(() =>
292
464
  form.validityMinutes ? `${form.validityMinutes} minutes` : "Not set",
293
465
  );
466
+ const visiblePermissionCandidates = computed(() => {
467
+ const search = permissionSearch.value.trim().toLowerCase();
468
+ if (!search) return permissionCandidates.value;
469
+ return permissionCandidates.value.filter((candidate) => (
470
+ candidate.name.toLowerCase().includes(search)
471
+ || candidate.subtitle?.toLowerCase().includes(search)
472
+ ));
473
+ });
474
+ const permissionDialogTitle = computed(() => (
475
+ permissionDialogCategory.value === "intercom"
476
+ ? "Intercom Permissions"
477
+ : `${permissionCategoryLabel(permissionDialogCategory.value)} Access Permission`
478
+ ));
479
+ const permissionDialogDescription = computed(() => (
480
+ permissionDialogCategory.value === "intercom"
481
+ ? "Select from people and providers who already have HID access permission."
482
+ : `Select the ${permissionCategoryLabel(permissionDialogCategory.value).toLowerCase()} entries allowed to use HID access at this site.`
483
+ ));
484
+ const permissionEmptyMessage = computed(() => (
485
+ permissionDialogCategory.value === "intercom" && permissionState.assignments.length === 0
486
+ ? "Add an access permission before assigning intercom access."
487
+ : "No matching entries found."
488
+ ));
294
489
 
295
490
  watch(
296
491
  () => props.site,
@@ -305,13 +500,24 @@ async function loadConfig() {
305
500
  getSiteById(props.site),
306
501
  getReaders({ site: props.site, page: 1, limit: 100 }),
307
502
  ]);
308
- siteData.value = site;
309
- readers.value = readerResponse?.items ?? readerResponse?.data?.items ?? readerResponse?.data?.readers ?? [];
503
+ siteData.value = site as TSite;
504
+ const readerList = readerResponse as HidReaderListResponse;
505
+ readers.value = readerList.items ?? readerList.data?.items ?? readerList.data?.readers ?? [];
310
506
  const config = siteData.value?.metadata?.hidQrCodePass || {};
507
+ const configuredMethods = config.identificationMethods;
311
508
  Object.assign(form, {
312
509
  enabled: Boolean(config.enabled),
510
+ onlineMode: Boolean(config.onlineMode),
313
511
  readerId: String(config.readerId || readers.value[0]?._id || ""),
314
512
  qrFormat: ["0", "1", "2"].includes(String(config.qrFormat)) ? String(config.qrFormat) as "0" | "1" | "2" : "0",
513
+ identificationMethods: {
514
+ facial: configuredMethods?.facial ?? true,
515
+ card: configuredMethods?.card ?? true,
516
+ qrCode: configuredMethods?.qrCode ?? Boolean(config.enabled),
517
+ idPassword: configuredMethods?.idPassword ?? true,
518
+ pin: configuredMethods?.pin ?? false,
519
+ bluetooth: configuredMethods?.bluetooth ?? false,
520
+ },
315
521
  printer: {
316
522
  vendorId: String(config.printer?.vendorId || ""),
317
523
  productId: String(config.printer?.productId || ""),
@@ -322,15 +528,150 @@ async function loadConfig() {
322
528
  },
323
529
  validityMinutes: config.validityMinutes ? Number(config.validityMinutes) : null,
324
530
  });
531
+
532
+ await Promise.all([loadReaderConfiguration(), loadPermissions()]);
533
+ }
534
+
535
+ async function loadPermissions() {
536
+ if (!props.site) return;
537
+ try {
538
+ const response = await getSitePermissions(props.site);
539
+ Object.assign(permissionState, response.data);
540
+ } catch (error: unknown) {
541
+ console.error("Unable to load HID permissions:", errorConverter(error));
542
+ }
543
+ }
544
+
545
+ function permissionCategoryLabel(category: THidPermissionCategory) {
546
+ return accessPermissionOptions.find((option) => option.category === category)?.label || "Permission";
547
+ }
548
+
549
+ function permissionCount(category: THidPermissionCategory) {
550
+ if (category === "property_management") return permissionState.counts.propertyManagement;
551
+ if (category === "service_provider") return permissionState.counts.serviceProvider;
552
+ return permissionState.counts.resident;
553
+ }
554
+
555
+ async function openPermissionDialog(category: THidPermissionCategory | "intercom") {
556
+ permissionDialogCategory.value = category;
557
+ permissionSearch.value = "";
558
+ permissionCandidates.value = [];
559
+ selectedPermissionIds.value = new Set<string>();
560
+ permissionDialog.value = true;
561
+ loadingPermissionCandidates.value = true;
562
+
563
+ try {
564
+ const response = await getPermissionCandidates(props.site, {
565
+ category,
566
+ page: 1,
567
+ limit: 500,
568
+ });
569
+ permissionCandidates.value = response.items || [];
570
+ selectedPermissionIds.value = new Set(
571
+ permissionCandidates.value
572
+ .filter((candidate) => category === "intercom" ? candidate.intercom : candidate.selected)
573
+ .map((candidate) => candidate.subjectId),
574
+ );
575
+ } catch (error: unknown) {
576
+ showMessage(errorConverter(error), "error");
577
+ } finally {
578
+ loadingPermissionCandidates.value = false;
579
+ }
580
+ }
581
+
582
+ function closePermissionDialog() {
583
+ if (savingPermissions.value) return;
584
+ permissionDialog.value = false;
585
+ }
586
+
587
+ function togglePermissionCandidate(subjectId: string, selected: boolean) {
588
+ const next = new Set(selectedPermissionIds.value);
589
+ if (selected) next.add(subjectId);
590
+ else next.delete(subjectId);
591
+ selectedPermissionIds.value = next;
592
+ }
593
+
594
+ async function savePermissions() {
595
+ const category = permissionDialogCategory.value;
596
+ let assignments: Array<Pick<THidPermissionAssignment, "subjectId" | "category" | "intercom">>;
597
+
598
+ if (category === "intercom") {
599
+ assignments = permissionState.assignments.map((assignment) => ({
600
+ subjectId: assignment.subjectId,
601
+ category: assignment.category,
602
+ intercom: selectedPermissionIds.value.has(assignment.subjectId),
603
+ }));
604
+ } else {
605
+ const retained = permissionState.assignments
606
+ .filter((assignment) => assignment.category !== category)
607
+ .map((assignment) => ({
608
+ subjectId: assignment.subjectId,
609
+ category: assignment.category,
610
+ intercom: assignment.intercom,
611
+ }));
612
+ const selected = permissionCandidates.value
613
+ .filter((candidate) => selectedPermissionIds.value.has(candidate.subjectId))
614
+ .map((candidate) => ({
615
+ subjectId: candidate.subjectId,
616
+ category: candidate.category,
617
+ intercom: candidate.intercom,
618
+ }));
619
+ assignments = [...retained, ...selected];
620
+ }
621
+
622
+ savingPermissions.value = true;
623
+ try {
624
+ const response = await updateSitePermissions(props.site, assignments);
625
+ Object.assign(permissionState, response.data);
626
+ permissionDialog.value = false;
627
+ showMessage("HID permissions updated.", "success");
628
+ } catch (error: unknown) {
629
+ showMessage(errorConverter(error), "error");
630
+ } finally {
631
+ savingPermissions.value = false;
632
+ }
633
+ }
634
+
635
+ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
636
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
637
+ }
638
+
639
+ function getConfigurationData(response: unknown) {
640
+ if (!isUnknownRecord(response)) return {};
641
+ return isUnknownRecord(response.data) ? response.data : response;
642
+ }
643
+
644
+ function isEnabledConfiguration(value: unknown) {
645
+ return value === true || value === 1 || value === "1";
646
+ }
647
+
648
+ async function loadReaderConfiguration() {
649
+ if (!form.readerId) return;
650
+
651
+ try {
652
+ const response = await getReaderConfiguration(form.readerId, {
653
+ general: ["online"],
654
+ });
655
+ const configuration = getConfigurationData(response);
656
+ const general = isUnknownRecord(configuration.general) ? configuration.general : {};
657
+
658
+ if (general.online !== undefined) {
659
+ form.onlineMode = isEnabledConfiguration(general.online);
660
+ }
661
+ } catch (error: unknown) {
662
+ console.warn("Unable to load HID reader configuration:", errorConverter(error));
663
+ }
325
664
  }
326
665
 
327
666
  function buildPayload() {
328
667
  const metadata = {
329
668
  ...(siteData.value?.metadata || {}),
330
669
  hidQrCodePass: {
331
- enabled: form.enabled,
670
+ enabled: form.identificationMethods.qrCode,
671
+ onlineMode: form.onlineMode,
332
672
  readerId: form.readerId,
333
673
  qrFormat: form.qrFormat,
674
+ identificationMethods: { ...form.identificationMethods },
334
675
  printer: {
335
676
  vendorId: form.printer.vendorId.trim(),
336
677
  productId: form.printer.productId.trim(),
@@ -352,17 +693,45 @@ async function saveSection(section: string) {
352
693
 
353
694
  savingSection.value = section;
354
695
  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
696
  await updateSitebyId(props.site, buildPayload());
363
697
  await loadConfig();
364
- showMessage("HID QR code settings saved.", "success");
365
- } catch (error: any) {
698
+ showMessage("HID configuration saved.", "success");
699
+ } catch (error: unknown) {
700
+ showMessage(errorConverter(error), "error");
701
+ } finally {
702
+ savingSection.value = "";
703
+ }
704
+ }
705
+
706
+ async function saveOnlineMode() {
707
+ if (!props.site || !form.readerId) return;
708
+
709
+ savingSection.value = "online-mode";
710
+ try {
711
+ await setReaderConfiguration(form.readerId, {
712
+ general: { online: form.onlineMode ? "1" : "0" },
713
+ });
714
+ await updateSitebyId(props.site, buildPayload());
715
+ showMessage("Online mode updated.", "success");
716
+ } catch (error: unknown) {
717
+ await loadConfig();
718
+ showMessage(errorConverter(error), "error");
719
+ } finally {
720
+ savingSection.value = "";
721
+ }
722
+ }
723
+
724
+ async function saveIdentificationMethod(method: HidIdentificationMethod) {
725
+ if (!props.site) return;
726
+
727
+ form.enabled = form.identificationMethods.qrCode;
728
+
729
+ savingSection.value = `method-${method}`;
730
+ try {
731
+ await updateSitebyId(props.site, buildPayload());
732
+ showMessage(`${identificationMethodOptions.find((item) => item.key === method)?.label || "Identification method"} updated.`, "success");
733
+ } catch (error: unknown) {
734
+ await loadConfig();
366
735
  showMessage(errorConverter(error), "error");
367
736
  } finally {
368
737
  savingSection.value = "";
@@ -376,19 +745,36 @@ async function scanPrinter() {
376
745
  }
377
746
 
378
747
  try {
379
- const device = await (navigator as any).usb.requestDevice({ filters: [] });
748
+ const device = await requestPrinterDevice();
380
749
  form.printer.vendorId = `0x${Number(device.vendorId).toString(16).padStart(4, "0")}`;
381
750
  form.printer.productId = `0x${Number(device.productId).toString(16).padStart(4, "0")}`;
382
751
  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");
752
+ } catch (error: unknown) {
753
+ const message = errorConverter(error);
754
+ if (message !== "No device selected") {
755
+ showMessage(message || "Unable to scan printer.", "error");
386
756
  }
387
757
  }
388
758
  }
389
759
 
390
- function testPrint() {
391
- showMessage("Printer settings are ready. Connect print command when HID QR payload generation is available.", "success");
760
+ async function testPrint() {
761
+ const vendorId = Number(form.printer.vendorId);
762
+ const productId = Number(form.printer.productId);
763
+ if (!vendorId || !productId) {
764
+ showMessage("Please scan a valid USB printer first.", "error");
765
+ return;
766
+ }
767
+
768
+ testingPrinter.value = true;
769
+ try {
770
+ const result = await testPrinterConnection(vendorId, productId);
771
+ showMessage(
772
+ result.success ? result.message : result.error || result.message,
773
+ result.success ? "success" : "error",
774
+ );
775
+ } finally {
776
+ testingPrinter.value = false;
777
+ }
392
778
  }
393
779
 
394
780
  function showMessage(text: string, color: string) {
@@ -432,15 +818,15 @@ function showMessage(text: string, color: string) {
432
818
  padding: 30px 38px;
433
819
  }
434
820
 
435
- .section-head {
821
+ .online-mode-row {
436
822
  display: flex;
437
- align-items: flex-start;
823
+ align-items: center;
438
824
  justify-content: space-between;
439
825
  gap: 24px;
440
- margin-bottom: 24px;
826
+ padding: 0 0 20px;
441
827
  }
442
828
 
443
- .section-head h3,
829
+ .online-mode-row h3,
444
830
  .section-label {
445
831
  margin: 0 0 6px;
446
832
  color: #233545;
@@ -448,16 +834,83 @@ function showMessage(text: string, color: string) {
448
834
  font-weight: 700;
449
835
  }
450
836
 
451
- .section-head p {
837
+ .online-mode-row p,
838
+ .section-description {
452
839
  margin: 0;
453
840
  color: #677280;
454
841
  font-size: 12px;
842
+ line-height: 1.5;
455
843
  }
456
844
 
457
845
  .form-section {
458
846
  padding: 16px 0;
459
847
  }
460
848
 
849
+ .identification-section {
850
+ padding-bottom: 20px;
851
+ }
852
+
853
+ .methods-grid {
854
+ display: grid;
855
+ grid-template-columns: repeat(6, minmax(120px, 1fr));
856
+ gap: 18px;
857
+ margin-top: 16px;
858
+ }
859
+
860
+ .method-control {
861
+ display: flex;
862
+ align-items: center;
863
+ justify-content: space-between;
864
+ min-width: 0;
865
+ min-height: 42px;
866
+ padding: 0 10px 0 14px;
867
+ border: 1px solid #dce2e7;
868
+ border-radius: 6px;
869
+ color: #334454;
870
+ font-size: 12px;
871
+ }
872
+
873
+ .method-control span {
874
+ overflow: hidden;
875
+ text-overflow: ellipsis;
876
+ white-space: nowrap;
877
+ }
878
+
879
+ .method-control--disabled {
880
+ background-color: #f5f6f7;
881
+ color: #8b9297;
882
+ }
883
+
884
+ .method-control :deep(.v-switch) {
885
+ flex: 0 0 auto;
886
+ }
887
+
888
+ .method-control :deep(.v-selection-control) {
889
+ min-height: 32px;
890
+ }
891
+
892
+ .hid-config-switch :deep(.v-switch__track) {
893
+ background-color: #8b9297;
894
+ opacity: 1;
895
+ }
896
+
897
+ .hid-config-switch :deep(.v-selection-control--dirty .v-switch__track) {
898
+ background-color: #2eaf55 !important;
899
+ color: #2eaf55 !important;
900
+ opacity: 1;
901
+ }
902
+
903
+ .hid-config-switch :deep(.v-switch__thumb) {
904
+ background-color: #fff !important;
905
+ color: #fff !important;
906
+ }
907
+
908
+ .hid-config-switch.v-input--disabled :deep(.v-switch__track) {
909
+ background-color: #aeb4b8 !important;
910
+ color: #aeb4b8 !important;
911
+ opacity: 1;
912
+ }
913
+
461
914
  .printer-grid {
462
915
  display: grid;
463
916
  grid-template-columns: minmax(140px, 1fr) minmax(140px, 1fr) minmax(140px, 1fr) minmax(140px, 1fr) minmax(140px, 1fr);
@@ -483,6 +936,177 @@ function showMessage(text: string, color: string) {
483
936
  align-items: center;
484
937
  }
485
938
 
939
+ .permission-section {
940
+ padding-top: 2px;
941
+ }
942
+
943
+ .permission-row {
944
+ display: grid;
945
+ grid-template-columns: minmax(260px, 1fr) minmax(520px, 1.35fr);
946
+ gap: 36px;
947
+ align-items: center;
948
+ padding: 18px 0;
949
+ }
950
+
951
+ .permission-row--intercom {
952
+ padding-bottom: 4px;
953
+ }
954
+
955
+ .permission-copy {
956
+ min-width: 0;
957
+ }
958
+
959
+ .permission-actions {
960
+ display: grid;
961
+ grid-template-columns: repeat(3, minmax(150px, 1fr));
962
+ gap: 12px;
963
+ }
964
+
965
+ .permission-actions--single {
966
+ grid-template-columns: minmax(180px, 240px);
967
+ justify-content: end;
968
+ }
969
+
970
+ .permission-button {
971
+ justify-content: flex-start;
972
+ min-height: 42px;
973
+ padding-inline: 12px;
974
+ border-color: #dce2e7;
975
+ color: #334454;
976
+ }
977
+
978
+ .permission-count {
979
+ display: inline-grid;
980
+ place-items: center;
981
+ width: 20px;
982
+ height: 20px;
983
+ margin-right: 8px;
984
+ border-radius: 50%;
985
+ background: #dce2e7;
986
+ color: #526170;
987
+ font-size: 11px;
988
+ font-weight: 700;
989
+ }
990
+
991
+ .permission-button:not(:disabled):hover {
992
+ border-color: #1473c9;
993
+ color: #0a5da7;
994
+ }
995
+
996
+ .permission-dialog {
997
+ overflow: hidden;
998
+ border-radius: 6px;
999
+ }
1000
+
1001
+ .permission-dialog__header {
1002
+ display: flex;
1003
+ align-items: flex-start;
1004
+ justify-content: space-between;
1005
+ gap: 20px;
1006
+ padding: 20px 22px 16px;
1007
+ border-bottom: 1px solid #e5e9ed;
1008
+ }
1009
+
1010
+ .permission-dialog__title {
1011
+ color: #172534;
1012
+ font-size: 18px;
1013
+ font-weight: 700;
1014
+ }
1015
+
1016
+ .permission-dialog__subtitle {
1017
+ margin-top: 4px;
1018
+ color: #687583;
1019
+ font-size: 12px;
1020
+ white-space: normal;
1021
+ }
1022
+
1023
+ .permission-dialog__body {
1024
+ display: grid;
1025
+ gap: 16px;
1026
+ padding: 18px 22px;
1027
+ }
1028
+
1029
+ .permission-list {
1030
+ min-height: 240px;
1031
+ max-height: 390px;
1032
+ overflow-y: auto;
1033
+ border: 1px solid #dfe5ea;
1034
+ border-radius: 6px;
1035
+ }
1036
+
1037
+ .permission-list--loading {
1038
+ display: grid;
1039
+ place-items: center;
1040
+ }
1041
+
1042
+ .permission-list-item {
1043
+ display: grid;
1044
+ grid-template-columns: 34px minmax(0, 1fr) auto;
1045
+ gap: 10px;
1046
+ align-items: center;
1047
+ min-height: 62px;
1048
+ padding: 8px 14px 8px 8px;
1049
+ border-bottom: 1px solid #edf0f3;
1050
+ cursor: pointer;
1051
+ }
1052
+
1053
+ .permission-list-item:last-child {
1054
+ border-bottom: 0;
1055
+ }
1056
+
1057
+ .permission-list-item:hover {
1058
+ background: #f7fafc;
1059
+ }
1060
+
1061
+ .permission-list-item__content {
1062
+ display: grid;
1063
+ min-width: 0;
1064
+ }
1065
+
1066
+ .permission-list-item__content strong,
1067
+ .permission-list-item__content small {
1068
+ overflow: hidden;
1069
+ text-overflow: ellipsis;
1070
+ white-space: nowrap;
1071
+ }
1072
+
1073
+ .permission-list-item__content strong {
1074
+ color: #243443;
1075
+ font-size: 13px;
1076
+ }
1077
+
1078
+ .permission-list-item__content small,
1079
+ .permission-list-item__group {
1080
+ color: #778391;
1081
+ font-size: 11px;
1082
+ }
1083
+
1084
+ .permission-list-item__group {
1085
+ padding: 4px 8px;
1086
+ border-radius: 4px;
1087
+ background: #eef2f5;
1088
+ }
1089
+
1090
+ .permission-empty {
1091
+ display: flex;
1092
+ flex-direction: column;
1093
+ align-items: center;
1094
+ justify-content: center;
1095
+ gap: 10px;
1096
+ min-height: 240px;
1097
+ padding: 24px;
1098
+ color: #75818c;
1099
+ font-size: 13px;
1100
+ text-align: center;
1101
+ }
1102
+
1103
+ .permission-dialog__actions {
1104
+ justify-content: flex-end;
1105
+ gap: 8px;
1106
+ padding: 14px 22px;
1107
+ border-top: 1px solid #e5e9ed;
1108
+ }
1109
+
486
1110
  .save-button {
487
1111
  background: #082e43;
488
1112
  color: #ffffff;
@@ -592,11 +1216,20 @@ function showMessage(text: string, color: string) {
592
1216
  }
593
1217
 
594
1218
  @media (max-width: 1100px) {
1219
+ .methods-grid {
1220
+ grid-template-columns: repeat(3, minmax(140px, 1fr));
1221
+ }
1222
+
595
1223
  .printer-grid,
596
1224
  .template-grid,
597
- .validity-grid {
1225
+ .validity-grid,
1226
+ .permission-row {
598
1227
  grid-template-columns: 1fr 1fr;
599
1228
  }
1229
+
1230
+ .permission-actions {
1231
+ grid-template-columns: 1fr;
1232
+ }
600
1233
  }
601
1234
 
602
1235
  @media (max-width: 700px) {
@@ -604,15 +1237,30 @@ function showMessage(text: string, color: string) {
604
1237
  padding: 22px 16px;
605
1238
  }
606
1239
 
607
- .section-head,
1240
+ .online-mode-row,
608
1241
  .printer-grid,
609
1242
  .template-grid,
610
- .validity-grid {
1243
+ .validity-grid,
1244
+ .methods-grid,
1245
+ .permission-row,
1246
+ .permission-actions {
611
1247
  grid-template-columns: 1fr;
612
1248
  }
613
1249
 
614
- .section-head {
1250
+ .online-mode-row {
615
1251
  display: grid;
616
1252
  }
1253
+
1254
+ .permission-actions--single {
1255
+ justify-content: stretch;
1256
+ }
1257
+
1258
+ .permission-list-item {
1259
+ grid-template-columns: 34px minmax(0, 1fr);
1260
+ }
1261
+
1262
+ .permission-list-item__group {
1263
+ display: none;
1264
+ }
617
1265
  }
618
1266
  </style>