@7365admin1/layer-common 4.1.2 → 4.1.3-staging.249

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.
@@ -21,30 +21,6 @@
21
21
  <div class="config-body">
22
22
  <div class="section-label config-body__title">HID Configuration</div>
23
23
 
24
- <div class="form-section">
25
- <div class="section-label">Visitor QR Reader</div>
26
- <p class="section-description">
27
- Select the HID reader and portal used when creating a Visitor QR pass.
28
- </p>
29
- <div class="reader-grid">
30
- <AppSelect
31
- v-model="form.readerId"
32
- :items="visitorQrReaderOptions"
33
- placeholder="Select a HID reader"
34
- :disabled="Boolean(savingSection) || visitorQrReaderOptions.length === 0"
35
- />
36
- <AppButton
37
- icon="mdi-content-save-outline"
38
- :disabled="Boolean(savingSection) || !form.readerId"
39
- @click="saveSection('reader')"
40
- >
41
- Save Reader
42
- </AppButton>
43
- </div>
44
- </div>
45
-
46
- <v-divider />
47
-
48
24
  <div class="form-section identification-section">
49
25
  <div class="section-label">Enable/disable reader identification method</div>
50
26
  <p class="section-description">
@@ -137,79 +113,67 @@
137
113
  <v-divider />
138
114
 
139
115
  <div v-if="isPermissionManager" class="permission-section">
140
- <div class="permission-location">
141
- <div class="permission-copy">
142
- <div class="section-label">Permission Location</div>
143
- <p class="section-description">
144
- Permissions are scoped to the location configured on the HID Face Reader.
145
- </p>
146
- </div>
147
- <AppSelect
148
- v-model="permissionReaderId"
149
- class="permission-location__select"
150
- :items="permissionLocations"
151
- item-title="label"
152
- item-value="readerId"
153
- placeholder="Select a Face Reader location"
154
- :disabled="permissionLocations.length === 0"
155
- @update:model-value="loadPermissions"
156
- />
116
+ <div class="permission-copy">
117
+ <div class="section-label">Visitor QR Access Permission</div>
118
+ <p class="section-description">
119
+ Select the connected HID Reader and Portal gates where Visitor QR codes may be scanned.
120
+ </p>
157
121
  </div>
158
122
 
159
123
  <v-alert
160
- v-if="permissionLocations.length === 0"
124
+ v-if="visitorQrGateOptions.length === 0"
161
125
  class="mb-4"
162
126
  type="warning"
163
127
  variant="tonal"
164
128
  density="compact"
165
129
  >
166
- Add a Location to a Face Reader before assigning HID permissions.
130
+ Connect a HID Reader and select its portal before configuring Visitor QR gates.
167
131
  </v-alert>
168
132
 
169
- <v-divider />
170
-
171
133
  <div class="permission-row">
172
134
  <div class="permission-copy">
173
- <div class="section-label">Access Permission</div>
135
+ <div class="section-label">Resident QR Gates</div>
174
136
  <p class="section-description">
175
- Select who can use the enabled HID identification methods at this site.
137
+ QR codes created by a Resident app can be scanned only at these gates.
176
138
  </p>
177
139
  </div>
178
- <div class="permission-actions">
179
- <AppButton
180
- v-for="option in accessPermissionOptions"
181
- :key="option.category"
182
- variant="ghost"
183
- class="permission-button"
184
- :disabled="!form.onlineMode || !permissionReaderId"
185
- @click="openPermissionDialog(option.category)"
186
- >
187
- <span class="permission-count">{{ permissionCount(option.category) }}</span>
188
- <span>{{ option.label }}</span>
189
- </AppButton>
190
- </div>
140
+ <AppSelect
141
+ v-model="form.qrGatePermissions.resident"
142
+ class="permission-location__select"
143
+ :items="visitorQrGateOptions"
144
+ multiple
145
+ placeholder="Select Resident QR gates"
146
+ :disabled="Boolean(savingSection) || visitorQrGateOptions.length === 0"
147
+ />
191
148
  </div>
192
149
 
193
150
  <v-divider />
194
151
 
195
- <div class="permission-row permission-row--intercom">
152
+ <div class="permission-row">
196
153
  <div class="permission-copy">
197
- <div class="section-label">Access Intercom</div>
154
+ <div class="section-label">Property Management QR Gates</div>
198
155
  <p class="section-description">
199
- Choose which authorized people and providers may use HID intercom access.
156
+ QR codes created by the Property Management app can be scanned only at these gates.
200
157
  </p>
201
158
  </div>
202
- <div class="permission-actions permission-actions--single">
203
- <AppButton
204
- variant="ghost"
205
- class="permission-button"
206
- :disabled="!form.onlineMode || !permissionReaderId || permissionState.assignments.length === 0"
207
- @click="openPermissionDialog('intercom')"
208
- >
209
- <span class="permission-count">{{ permissionState.counts.intercom }}</span>
210
- <span>Intercom Permissions</span>
211
- </AppButton>
212
- </div>
159
+ <AppSelect
160
+ v-model="form.qrGatePermissions.propertyManagement"
161
+ class="permission-location__select"
162
+ :items="visitorQrGateOptions"
163
+ multiple
164
+ placeholder="Select Property Management QR gates"
165
+ :disabled="Boolean(savingSection) || visitorQrGateOptions.length === 0"
166
+ />
167
+ </div>
168
+
169
+ <div class="permission-actions permission-actions--single mt-4">
170
+ <AppButton
171
+ icon="mdi-content-save-outline"
172
+ :disabled="Boolean(savingSection)"
173
+ @click="saveSection('qr-gates')"
174
+ >
175
+ Save QR Gate Permissions
176
+ </AppButton>
213
177
  </div>
214
178
  </div>
215
179
  </div>
@@ -254,81 +218,6 @@
254
218
  </v-card>
255
219
  </v-dialog>
256
220
 
257
- <v-dialog v-model="permissionDialog" max-width="680" persistent>
258
- <v-card class="permission-dialog">
259
- <v-card-title class="permission-dialog__header">
260
- <div>
261
- <div class="permission-dialog__title">{{ permissionDialogTitle }}</div>
262
- <div class="permission-dialog__subtitle">{{ permissionDialogDescription }}</div>
263
- </div>
264
- <AppButton
265
- variant="icon"
266
- icon="mdi-close"
267
- aria-label="Close permission dialog"
268
- @click="closePermissionDialog"
269
- />
270
- </v-card-title>
271
-
272
- <v-card-text class="permission-dialog__body">
273
- <AppField
274
- v-model="permissionSearch"
275
- search
276
- placeholder="Search by name or details"
277
- />
278
-
279
- <div class="permission-list-summary" aria-live="polite">
280
- <span>{{ visiblePermissionCandidates.length }} entries</span>
281
- <span class="permission-list-summary__selected">
282
- {{ selectedPermissionIds.size }} selected
283
- </span>
284
- </div>
285
-
286
- <div class="permission-list" :class="{ 'permission-list--loading': loadingPermissionCandidates }">
287
- <div v-if="loadingPermissionCandidates" class="permission-empty">
288
- <v-progress-circular indeterminate class="permission-spinner" size="28" />
289
- <span>Loading permissions...</span>
290
- </div>
291
-
292
- <template v-else-if="visiblePermissionCandidates.length">
293
- <label
294
- v-for="candidate in visiblePermissionCandidates"
295
- :key="`${candidate.category}:${candidate.subjectId}`"
296
- class="permission-list-item"
297
- :class="{ 'permission-list-item--selected': selectedPermissionIds.has(candidate.subjectId) }"
298
- >
299
- <span class="permission-list-item__content">
300
- <strong>{{ candidate.name }}</strong>
301
- <small>{{ candidate.subtitle || permissionCategoryLabel(candidate.category) }}</small>
302
- </span>
303
- <span class="permission-list-item__group">{{ permissionCategoryLabel(candidate.category) }}</span>
304
- <v-checkbox-btn
305
- class="permission-list-item__checkbox"
306
- :model-value="selectedPermissionIds.has(candidate.subjectId)"
307
- :aria-label="`Select ${candidate.name}`"
308
- @update:model-value="togglePermissionCandidate(candidate.subjectId, Boolean($event))"
309
- />
310
- </label>
311
- </template>
312
-
313
- <div v-else class="permission-empty">
314
- <v-icon icon="mdi-account-search-outline" size="30" />
315
- <span>{{ permissionEmptyMessage }}</span>
316
- </div>
317
- </div>
318
- </v-card-text>
319
-
320
- <v-card-actions class="permission-dialog__actions">
321
- <AppButton variant="ghost" @click="closePermissionDialog">Cancel</AppButton>
322
- <AppButton
323
- :disabled="loadingPermissionCandidates || savingPermissions"
324
- @click="savePermissions"
325
- >
326
- Save Permissions
327
- </AppButton>
328
- </v-card-actions>
329
- </v-card>
330
- </v-dialog>
331
-
332
221
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
333
222
  </section>
334
223
  </template>
@@ -339,10 +228,14 @@ type HidIdentificationMethod = keyof NonNullable<THidQrCodePassConfig["identific
339
228
 
340
229
  type HidQrConfig = Omit<
341
230
  THidQrCodePassConfig,
342
- "identificationMethods" | "onlineMode" | "updatedAt"
231
+ "identificationMethods" | "onlineMode" | "updatedAt" | "qrGatePermissions"
343
232
  > & {
344
233
  onlineMode: boolean;
345
234
  identificationMethods: Record<HidIdentificationMethod, boolean>;
235
+ qrGatePermissions: {
236
+ resident: string[];
237
+ propertyManagement: string[];
238
+ };
346
239
  };
347
240
 
348
241
  type HidReaderOption = {
@@ -350,6 +243,8 @@ type HidReaderOption = {
350
243
  name?: string;
351
244
  deviceId?: string;
352
245
  location?: string;
246
+ portalId?: number;
247
+ portalName?: string;
353
248
  };
354
249
 
355
250
  type HidReaderListResponse = {
@@ -374,9 +269,6 @@ const {
374
269
  getReaders,
375
270
  getReaderConfiguration,
376
271
  setReaderConfiguration,
377
- getSitePermissions,
378
- getPermissionCandidates,
379
- updateSitePermissions,
380
272
  } = useHidAmico();
381
273
  const {
382
274
  requestDevice: requestPrinterDevice,
@@ -387,6 +279,10 @@ const defaultConfig: HidQrConfig = {
387
279
  enabled: false,
388
280
  onlineMode: false,
389
281
  readerId: "",
282
+ qrGatePermissions: {
283
+ resident: [],
284
+ propertyManagement: [],
285
+ },
390
286
  qrFormat: "0",
391
287
  identificationMethods: {
392
288
  facial: true,
@@ -416,35 +312,15 @@ const testingPrinter = ref(false);
416
312
  const message = ref("");
417
313
  const messageColor = ref("success");
418
314
  const messageSnackbar = ref(false);
419
- const permissionState = reactive<THidSitePermissions>({
420
- orgId: props.org,
421
- site: props.site,
422
- readerId: "",
423
- location: "",
424
- assignments: [],
425
- counts: {
426
- resident: 0,
427
- propertyManagement: 0,
428
- serviceProvider: 0,
429
- intercom: 0,
430
- },
431
- });
432
- const permissionDialog = ref(false);
433
- const permissionReaderId = ref("");
434
- const permissionDialogCategory = ref<THidPermissionCategory | "intercom">("resident");
435
- const permissionCandidates = ref<THidPermissionCandidate[]>([]);
436
- const selectedPermissionIds = ref(new Set<string>());
437
- const permissionSearch = ref("");
438
- const loadingPermissionCandidates = ref(false);
439
- const savingPermissions = ref(false);
440
-
441
315
  const hasPrinterConfig = computed(() =>
442
316
  Boolean(form.printer.vendorId && form.printer.productId),
443
317
  );
444
- const visitorQrReaderOptions = computed(() => readers.value.map((reader) => ({
445
- title: String(reader.name || reader.deviceId || reader._id) + " - " + (reader.portalName || "Portal not configured"),
446
- value: reader._id,
447
- })));
318
+ const visitorQrGateOptions = computed(() => readers.value
319
+ .filter((reader) => Boolean(reader.portalId))
320
+ .map((reader) => ({
321
+ title: String(reader.name || reader.deviceId || reader._id) + " - " + String(reader.portalName || "Portal"),
322
+ value: reader._id,
323
+ })));
448
324
  const identificationMethodOptions: Array<{
449
325
  key: HidIdentificationMethod;
450
326
  label: string;
@@ -455,37 +331,13 @@ const identificationMethodOptions: Array<{
455
331
  { key: "idPassword", label: "ID Password" },
456
332
  { key: "pin", label: "PIN" },
457
333
  ];
458
- const accessPermissionOptions: Array<{
459
- category: THidPermissionCategory;
460
- label: string;
461
- }> = [
462
- { category: "resident", label: "Resident" },
463
- { category: "property_management", label: "Property Management" },
464
- { category: "service_provider", label: "Service Provider" },
465
- ];
466
-
467
- const permissionLocations = computed(() => {
468
- const seen = new Set<string>();
469
- return readers.value.reduce<Array<{ readerId: string; location: string; label: string }>>((items, reader) => {
470
- const location = reader.location?.trim();
471
- const key = location?.toLocaleLowerCase();
472
- if (!location || !key || seen.has(key)) return items;
473
- seen.add(key);
474
- items.push({
475
- readerId: reader._id,
476
- location,
477
- label: location,
478
- });
479
- return items;
480
- }, []);
481
- });
482
- const selectedPermissionLocation = computed(() => (
483
- permissionLocations.value.find((item) => item.readerId === permissionReaderId.value)?.location
484
- || "the selected location"
485
- ));
486
-
487
334
  const siteName = computed(() => siteData.value?.name || "Site");
488
335
  const siteLabel = computed(() => siteData.value?.code || siteData.value?.block || props.site);
336
+ const primaryQrGateReaderId = computed(() => (
337
+ form.qrGatePermissions.resident[0]
338
+ || form.qrGatePermissions.propertyManagement[0]
339
+ || ""
340
+ ));
489
341
  const validityText = computed(() =>
490
342
  form.validityMinutes ? `${form.validityMinutes} minutes` : "Not set",
491
343
  );
@@ -502,30 +354,6 @@ const validityInput = computed({
502
354
  form.validityMinutes = value === "" ? null : Number(value);
503
355
  },
504
356
  });
505
- const visiblePermissionCandidates = computed(() => {
506
- const search = permissionSearch.value.trim().toLowerCase();
507
- if (!search) return permissionCandidates.value;
508
- return permissionCandidates.value.filter((candidate) => (
509
- candidate.name.toLowerCase().includes(search)
510
- || candidate.subtitle?.toLowerCase().includes(search)
511
- ));
512
- });
513
- const permissionDialogTitle = computed(() => (
514
- permissionDialogCategory.value === "intercom"
515
- ? "Intercom Permissions"
516
- : `${permissionCategoryLabel(permissionDialogCategory.value)} Access Permission`
517
- ));
518
- const permissionDialogDescription = computed(() => (
519
- permissionDialogCategory.value === "intercom"
520
- ? `Select from people and providers who already have HID access permission at ${selectedPermissionLocation.value}.`
521
- : `Select the ${permissionCategoryLabel(permissionDialogCategory.value).toLowerCase()} entries allowed to use HID access at ${selectedPermissionLocation.value}.`
522
- ));
523
- const permissionEmptyMessage = computed(() => (
524
- permissionDialogCategory.value === "intercom" && permissionState.assignments.length === 0
525
- ? "Add an access permission before assigning intercom access."
526
- : "No matching entries found."
527
- ));
528
-
529
357
  watch(
530
358
  () => [props.org, props.site],
531
359
  () => loadConfig(),
@@ -549,7 +377,20 @@ async function loadConfig() {
549
377
  // Visitor QR is reader-local. Do not restore a legacy Online Mode value
550
378
  // from the site record and accidentally make a scan depend on Monitor.
551
379
  onlineMode: false,
552
- readerId: String(config.readerId || readers.value[0]?._id || ""),
380
+ qrGatePermissions: {
381
+ resident: Array.isArray(config.qrGatePermissions?.resident)
382
+ ? config.qrGatePermissions.resident.map(String)
383
+ : [],
384
+ propertyManagement: Array.isArray(config.qrGatePermissions?.propertyManagement)
385
+ ? config.qrGatePermissions.propertyManagement.map(String)
386
+ : [],
387
+ },
388
+ readerId: String(
389
+ config.qrGatePermissions?.resident?.[0]
390
+ || config.qrGatePermissions?.propertyManagement?.[0]
391
+ || config.readerId
392
+ || "",
393
+ ),
553
394
  qrFormat: "0",
554
395
  identificationMethods: {
555
396
  facial: configuredMethods?.facial ?? true,
@@ -570,132 +411,7 @@ async function loadConfig() {
570
411
  validityMinutes: config.validityMinutes ? Number(config.validityMinutes) : null,
571
412
  });
572
413
 
573
- permissionReaderId.value = permissionLocations.value.find((item) => item.readerId === form.readerId)?.readerId
574
- || permissionLocations.value[0]?.readerId
575
- || "";
576
-
577
- await Promise.all([
578
- loadReaderConfiguration(),
579
- isPermissionManager.value ? loadPermissions() : Promise.resolve(),
580
- ]);
581
- }
582
-
583
- async function loadPermissions() {
584
- if (!props.org || !props.site || !permissionReaderId.value) {
585
- Object.assign(permissionState, {
586
- orgId: props.org,
587
- site: props.site,
588
- readerId: "",
589
- location: "",
590
- assignments: [],
591
- counts: { resident: 0, propertyManagement: 0, serviceProvider: 0, intercom: 0 },
592
- });
593
- return;
594
- }
595
- try {
596
- const response = await getSitePermissions(props.site, props.org, permissionReaderId.value);
597
- Object.assign(permissionState, response.data);
598
- } catch (error: unknown) {
599
- console.error("Unable to load HID permissions:", errorConverter(error));
600
- }
601
- }
602
-
603
- function permissionCategoryLabel(category: THidPermissionCategory) {
604
- return accessPermissionOptions.find((option) => option.category === category)?.label || "Permission";
605
- }
606
-
607
- function permissionCount(category: THidPermissionCategory) {
608
- if (category === "property_management") return permissionState.counts.propertyManagement;
609
- if (category === "service_provider") return permissionState.counts.serviceProvider;
610
- return permissionState.counts.resident;
611
- }
612
-
613
- async function openPermissionDialog(category: THidPermissionCategory | "intercom") {
614
- if (!props.org || !permissionReaderId.value) return;
615
- permissionDialogCategory.value = category;
616
- permissionSearch.value = "";
617
- permissionCandidates.value = [];
618
- selectedPermissionIds.value = new Set<string>();
619
- permissionDialog.value = true;
620
- loadingPermissionCandidates.value = true;
621
-
622
- try {
623
- const response = await getPermissionCandidates(props.site, {
624
- orgId: props.org,
625
- readerId: permissionReaderId.value,
626
- category,
627
- page: 1,
628
- limit: 500,
629
- });
630
- permissionCandidates.value = response.items ?? response.data?.items ?? [];
631
- selectedPermissionIds.value = new Set(
632
- permissionCandidates.value
633
- .filter((candidate) => category === "intercom" ? candidate.intercom : candidate.selected)
634
- .map((candidate) => candidate.subjectId),
635
- );
636
- } catch (error: unknown) {
637
- showMessage(errorConverter(error), "error");
638
- } finally {
639
- loadingPermissionCandidates.value = false;
640
- }
641
- }
642
-
643
- function closePermissionDialog() {
644
- if (savingPermissions.value) return;
645
- permissionDialog.value = false;
646
- }
647
-
648
- function togglePermissionCandidate(subjectId: string, selected: boolean) {
649
- const next = new Set(selectedPermissionIds.value);
650
- if (selected) next.add(subjectId);
651
- else next.delete(subjectId);
652
- selectedPermissionIds.value = next;
653
- }
654
-
655
- async function savePermissions() {
656
- const category = permissionDialogCategory.value;
657
- let assignments: Array<Pick<THidPermissionAssignment, "subjectId" | "category" | "intercom">>;
658
-
659
- if (category === "intercom") {
660
- assignments = permissionState.assignments.map((assignment) => ({
661
- subjectId: assignment.subjectId,
662
- category: assignment.category,
663
- intercom: selectedPermissionIds.value.has(assignment.subjectId),
664
- }));
665
- } else {
666
- const retained = permissionState.assignments
667
- .filter((assignment) => assignment.category !== category)
668
- .map((assignment) => ({
669
- subjectId: assignment.subjectId,
670
- category: assignment.category,
671
- intercom: assignment.intercom,
672
- }));
673
- const selected = permissionCandidates.value
674
- .filter((candidate) => selectedPermissionIds.value.has(candidate.subjectId))
675
- .map((candidate) => ({
676
- subjectId: candidate.subjectId,
677
- category: candidate.category,
678
- intercom: candidate.intercom,
679
- }));
680
- assignments = [...retained, ...selected];
681
- }
682
-
683
- savingPermissions.value = true;
684
- try {
685
- const response = await updateSitePermissions(
686
- props.site,
687
- props.org,
688
- permissionReaderId.value,
689
- assignments,
690
- );
691
- Object.assign(permissionState, response.data);
692
- permissionDialog.value = false;
693
- showMessage("HID permissions updated.", "success");
694
- } catch (error: unknown) {
695
- showMessage(errorConverter(error), "error");
696
- } finally {
697
- savingPermissions.value = false;
698
- }
414
+ await loadReaderConfiguration();
699
415
  }
700
416
 
701
417
  function isUnknownRecord(value: unknown): value is Record<string, unknown> {
@@ -746,7 +462,13 @@ function buildPayload() {
746
462
  hidQrCodePass: {
747
463
  enabled: form.identificationMethods.qrCode,
748
464
  onlineMode: false,
749
- readerId: form.readerId,
465
+ // Kept only for old background/mobile flows. Gate permissions, not this
466
+ // compatibility value, decide where a new Visitor QR may be scanned.
467
+ readerId: primaryQrGateReaderId.value,
468
+ qrGatePermissions: {
469
+ resident: [...new Set(form.qrGatePermissions.resident)],
470
+ propertyManagement: [...new Set(form.qrGatePermissions.propertyManagement)],
471
+ },
750
472
  // Visitor QR passes always use Amico alphanumeric qrcodes.
751
473
  qrFormat: "0",
752
474
  identificationMethods: { ...form.identificationMethods },
@@ -768,6 +490,10 @@ function buildPayload() {
768
490
 
769
491
  async function saveSection(section: string) {
770
492
  if (!props.site) return;
493
+ if (section === "qr-gates" && !primaryQrGateReaderId.value) {
494
+ showMessage("Select at least one Resident or Property Management QR gate.", "error");
495
+ return;
496
+ }
771
497
 
772
498
  savingSection.value = section;
773
499
  try {
@@ -1025,10 +751,6 @@ function showMessage(text: string, color: string) {
1025
751
  padding: 18px 0;
1026
752
  }
1027
753
 
1028
- .permission-row--intercom {
1029
- padding-bottom: 4px;
1030
- }
1031
-
1032
754
  .permission-copy {
1033
755
  min-width: 0;
1034
756
  }
@@ -1044,190 +766,6 @@ function showMessage(text: string, color: string) {
1044
766
  justify-content: end;
1045
767
  }
1046
768
 
1047
- /* A ghost button carrying a count badge. `AppButton --ghost` supplies the
1048
- height, radius, border and type; only the alignment is local. */
1049
- .permission-button {
1050
- justify-content: flex-start;
1051
- min-height: 42px;
1052
- }
1053
-
1054
- .permission-count {
1055
- display: inline-grid;
1056
- place-items: center;
1057
- width: 20px;
1058
- height: 20px;
1059
- margin-right: 8px;
1060
- border-radius: 50%;
1061
- background: var(--accent-soft);
1062
- color: var(--accent-text);
1063
- font-size: 11px;
1064
- font-weight: 700;
1065
- }
1066
-
1067
- .permission-button:not(:disabled):hover {
1068
- border-color: var(--accent);
1069
- color: var(--accent-text);
1070
- }
1071
-
1072
- .permission-dialog {
1073
- overflow: hidden;
1074
- border-radius: var(--r-modal);
1075
- background: var(--card);
1076
- }
1077
-
1078
- .permission-dialog__header {
1079
- display: flex;
1080
- align-items: flex-start;
1081
- justify-content: space-between;
1082
- gap: 16px;
1083
- padding: 16px 18px 14px;
1084
- border-bottom: 1px solid var(--border);
1085
- }
1086
-
1087
- .permission-dialog__title {
1088
- color: var(--text);
1089
- font-family: var(--font-sans);
1090
- font-size: 18px;
1091
- font-weight: 800;
1092
- }
1093
-
1094
- .permission-dialog__subtitle {
1095
- margin-top: 4px;
1096
- color: var(--muted);
1097
- font-size: 12px;
1098
- font-weight: 600;
1099
- white-space: normal;
1100
- }
1101
-
1102
- .permission-dialog__body {
1103
- display: grid;
1104
- gap: 10px;
1105
- padding: 14px 18px 16px;
1106
- }
1107
-
1108
- .permission-list-summary {
1109
- display: flex;
1110
- align-items: center;
1111
- justify-content: space-between;
1112
- min-height: 22px;
1113
- color: var(--muted);
1114
- font-size: 11px;
1115
- font-weight: 600;
1116
- }
1117
-
1118
- .permission-list-summary__selected {
1119
- color: var(--ok);
1120
- font-weight: 700;
1121
- }
1122
-
1123
- .permission-list {
1124
- min-height: 180px;
1125
- max-height: min(360px, 48vh);
1126
- overflow-y: auto;
1127
- border: 1px solid var(--border);
1128
- border-radius: var(--r-inner);
1129
- background: var(--card);
1130
- }
1131
-
1132
- .permission-list--loading {
1133
- display: grid;
1134
- place-items: center;
1135
- }
1136
-
1137
- .permission-list-item {
1138
- display: grid;
1139
- grid-template-columns: minmax(0, 1fr) max-content 32px;
1140
- gap: 12px;
1141
- align-items: center;
1142
- min-height: 56px;
1143
- padding: 7px 12px;
1144
- border-bottom: 1px solid var(--border);
1145
- cursor: pointer;
1146
- transition: background-color var(--motion) ease;
1147
- }
1148
-
1149
- .permission-list-item:last-child {
1150
- border-bottom: 0;
1151
- }
1152
-
1153
- .permission-list-item:hover {
1154
- background: var(--hover);
1155
- }
1156
-
1157
- .permission-list-item--selected,
1158
- .permission-list-item--selected:hover {
1159
- background: var(--accent-soft);
1160
- }
1161
-
1162
- .permission-list-item__content {
1163
- display: grid;
1164
- gap: 2px;
1165
- min-width: 0;
1166
- }
1167
-
1168
- .permission-list-item__content strong,
1169
- .permission-list-item__content small {
1170
- overflow: hidden;
1171
- text-overflow: ellipsis;
1172
- white-space: nowrap;
1173
- }
1174
-
1175
- .permission-list-item__content strong {
1176
- color: var(--text);
1177
- font-size: 13px;
1178
- font-weight: 700;
1179
- line-height: 1.35;
1180
- }
1181
-
1182
- .permission-list-item__content small,
1183
- .permission-list-item__group {
1184
- color: var(--muted);
1185
- font-size: 11px;
1186
- font-weight: 600;
1187
- }
1188
-
1189
- .permission-list-item__group {
1190
- max-width: 170px;
1191
- overflow: hidden;
1192
- padding: 4px 9px;
1193
- border-radius: var(--r-tag);
1194
- background: var(--hover);
1195
- text-overflow: ellipsis;
1196
- white-space: nowrap;
1197
- }
1198
-
1199
- .permission-list-item__checkbox {
1200
- justify-self: end;
1201
- }
1202
-
1203
- .permission-list-item__checkbox :deep(.v-selection-control) {
1204
- min-height: 32px;
1205
- }
1206
-
1207
- .permission-empty {
1208
- display: flex;
1209
- flex-direction: column;
1210
- align-items: center;
1211
- justify-content: center;
1212
- gap: 10px;
1213
- min-height: 180px;
1214
- padding: 24px;
1215
- color: var(--muted);
1216
- font-size: 13px;
1217
- text-align: center;
1218
- }
1219
-
1220
- .permission-spinner {
1221
- color: var(--accent);
1222
- }
1223
-
1224
- .permission-dialog__actions {
1225
- justify-content: flex-end;
1226
- gap: 8px;
1227
- padding: 12px 18px;
1228
- border-top: 1px solid var(--border);
1229
- }
1230
-
1231
769
  /*
1232
770
  THE PRINTED PASS - the one thing here that is NOT themed.
1233
771
 
@@ -1368,21 +906,5 @@ function showMessage(text: string, color: string) {
1368
906
  justify-content: stretch;
1369
907
  }
1370
908
 
1371
- .permission-list-item {
1372
- grid-template-columns: minmax(0, 1fr) 32px;
1373
- gap: 8px;
1374
- }
1375
-
1376
- .permission-list-item__group {
1377
- grid-column: 1;
1378
- grid-row: 2;
1379
- justify-self: start;
1380
- max-width: 100%;
1381
- }
1382
-
1383
- .permission-list-item__checkbox {
1384
- grid-column: 2;
1385
- grid-row: 1 / span 2;
1386
- }
1387
909
  }
1388
910
  </style>
@@ -122,6 +122,7 @@
122
122
  <div class="detail-grid">
123
123
  <span>HID reader name</span><strong>{{ selectedReader?.name || "N/A" }}</strong>
124
124
  <span>Device ID</span><strong>{{ selectedReader?.deviceId || "N/A" }}</strong>
125
+ <span>Firmware</span><strong>{{ selectedReader?.firmwareVersion || "Not reported" }}</strong>
125
126
  <span>Location</span><strong>{{ selectedReader?.location || "N/A" }}</strong>
126
127
  <span>HID Portal</span><strong>{{ selectedReader?.portalName || selectedReader?.portalId || "N/A" }}</strong>
127
128
  <span>Monitor Path</span><strong>{{ selectedReader?.monitorPath || "N/A" }}</strong>
@@ -573,7 +574,12 @@ async function runConfirmedAction() {
573
574
  if (pendingAction.value === "test") {
574
575
  const response = await testReader(selectedReader.value._id);
575
576
  const message = response?.data?.message || response?.message || "Device connection successful.";
576
- await applyReaderSyncState(selectedReader.value._id, "completed", message);
577
+ const firmwareVersion = String(
578
+ response?.data?.firmwareVersion ?? response?.firmwareVersion ?? "",
579
+ ).trim();
580
+ await applyReaderSyncState(selectedReader.value._id, "completed", message, {
581
+ ...(firmwareVersion ? { firmwareVersion } : {}),
582
+ });
577
583
  showResult(true, "Test Device Connection", message);
578
584
  }
579
585
  if (pendingAction.value === "sync") {
@@ -622,12 +628,18 @@ async function runConfirmedAction() {
622
628
  }
623
629
  }
624
630
 
625
- async function applyReaderSyncState(readerId: string, status: "completed" | "failed", message: string) {
631
+ async function applyReaderSyncState(
632
+ readerId: string,
633
+ status: "completed" | "failed",
634
+ message: string,
635
+ extra: Pick<HidRecord, "firmwareVersion"> = {},
636
+ ) {
626
637
  const lastSyncAt = new Date().toISOString();
627
638
  const patch: HidRecord = {
628
639
  lastSyncAt,
629
640
  lastSyncStatus: status,
630
641
  lastSyncMessage: message,
642
+ ...extra,
631
643
  };
632
644
  if (status === "failed") {
633
645
  patch.status = "offline";
@@ -35,9 +35,15 @@ const dateFormattedReadOnly = ref<string | null>(null)
35
35
 
36
36
  const dateInput = ref<HTMLInputElement | null>(null)
37
37
  const datePickerRef = ref<HTMLInputElement | null>(null)
38
+ let nativeInput: HTMLInputElement | null = null
38
39
 
39
40
  const isInitialLoad = ref(true)
40
41
 
42
+ function handleNativeInputClick(e: MouseEvent) {
43
+ e.stopPropagation()
44
+ openDatePicker()
45
+ }
46
+
41
47
  function clearDate() {
42
48
  date.value = null
43
49
  dateFormattedReadOnly.value = null
@@ -88,15 +94,17 @@ watch(date, () => {
88
94
  onMounted(async () => {
89
95
  await nextTick()
90
96
  isInitialLoad.value = false
91
- const nativeInput = (datePickerRef.value as any)?.$el?.querySelector('input')
97
+ nativeInput = (datePickerRef.value as any)?.$el?.querySelector('input')
92
98
  if (nativeInput) {
93
- nativeInput.addEventListener('click', (e: MouseEvent) => {
94
- e.stopPropagation()
95
- openDatePicker()
96
- })
99
+ nativeInput.addEventListener('click', handleNativeInputClick)
97
100
  }
98
101
  })
99
102
 
103
+ onBeforeUnmount(() => {
104
+ nativeInput?.removeEventListener('click', handleNativeInputClick)
105
+ nativeInput = null
106
+ })
107
+
100
108
  defineExpose({
101
109
  validate
102
110
  })
@@ -70,9 +70,15 @@ const inputPlaceholder = computed(
70
70
 
71
71
  const dateInput = ref<HTMLInputElement | null>(null);
72
72
  const dateTimePickerRef = ref<HTMLInputElement | null>(null);
73
+ let nativeInput: HTMLInputElement | null = null;
73
74
 
74
75
  const isInitialLoad = ref(true);
75
76
 
77
+ function handleNativeInputClick(e: MouseEvent) {
78
+ e.stopPropagation();
79
+ openDatePicker();
80
+ }
81
+
76
82
  function openDatePicker() {
77
83
  setTimeout(() => {
78
84
  dateInput.value?.showPicker?.();
@@ -177,17 +183,19 @@ onMounted(async () => {
177
183
  await nextTick();
178
184
  isInitialLoad.value = false;
179
185
  // Wait until Vuetify renders its internal input
180
- const nativeInput = (dateTimePickerRef.value as any)?.$el?.querySelector(
186
+ nativeInput = (dateTimePickerRef.value as any)?.$el?.querySelector(
181
187
  "input"
182
188
  );
183
189
  if (nativeInput) {
184
- nativeInput.addEventListener("click", (e: MouseEvent) => {
185
- e.stopPropagation();
186
- openDatePicker();
187
- });
190
+ nativeInput.addEventListener("click", handleNativeInputClick);
188
191
  }
189
192
  });
190
193
 
194
+ onBeforeUnmount(() => {
195
+ nativeInput?.removeEventListener("click", handleNativeInputClick);
196
+ nativeInput = null;
197
+ });
198
+
191
199
  defineExpose({
192
200
  validate,
193
201
  });
@@ -1758,9 +1758,14 @@ function closeApproveRejectVisitorDialog() {
1758
1758
  async function updateVisitorApprovalStatus() {
1759
1759
  isApprovingRejectingVisitor.value = true;
1760
1760
  try {
1761
- await updateVisitor(selectedVisitorForApproval.value._id, {
1761
+ const approval = {
1762
+ approvedBy: currentUser.value?._id,
1762
1763
  status: approveRejectVisitorDialog.value.status,
1763
1764
  remarks: visitorApproveRejectRemarks.value,
1765
+ approvedAt: new Date(),
1766
+ };
1767
+ await updateVisitor(selectedVisitorForApproval.value._id, {
1768
+ approval,
1764
1769
  updatedBy: currentUser.value?._id,
1765
1770
  } as any);
1766
1771
  showMessage(
@@ -1891,7 +1896,9 @@ const {
1891
1896
  // A self-service submission awaiting Approve/Reject (Self-Service
1892
1897
  // Auto-Approvals off) lands here as "pending" alongside already
1893
1898
  // "approved" guests, rather than being hidden until someone acts on it.
1894
- params.status = "approved,pending";
1899
+ // "rejected" is included too, so a rejected submission stays visible
1900
+ // on this tab instead of disappearing once acted on.
1901
+ params.status = "approved,pending,rejected";
1895
1902
  } else if (activeTab.value === "resident-transactions") {
1896
1903
  params.status = "registered";
1897
1904
  params.type = "resident,tenant";
@@ -2429,7 +2436,7 @@ function buildReportParams(): any {
2429
2436
  // report generated from this tab would silently omit contractors and
2430
2437
  // pending rows the screen is actually showing.
2431
2438
  params.type = "guest,contractor";
2432
- params.status = "approved,pending";
2439
+ params.status = "approved,pending,rejected";
2433
2440
  } else if (activeTab.value === "resident-transactions") {
2434
2441
  params.status = "registered";
2435
2442
  params.type = "resident,tenant";
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.1.2",
5
+ "version": "4.1.3-staging.249",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
package/types/site.d.ts CHANGED
@@ -40,6 +40,10 @@ declare type THidQrCodePassConfig = {
40
40
  enabled: boolean;
41
41
  onlineMode?: boolean;
42
42
  readerId: string;
43
+ qrGatePermissions?: {
44
+ resident: string[];
45
+ propertyManagement: string[];
46
+ };
43
47
  qrFormat: THidQrCodeFormat;
44
48
  identificationMethods?: {
45
49
  facial: boolean;