@7365admin1/layer-common 3.2.2-staging.181 → 3.2.2-staging.183

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.
@@ -36,8 +36,8 @@
36
36
  </v-alert>
37
37
 
38
38
  <TableMain
39
- title="HID Users"
40
- :headers="userHeaders"
39
+ :title="tableTitle"
40
+ :headers="tableHeaders"
41
41
  :items="users"
42
42
  :loading="loading"
43
43
  :items-per-page="-1"
@@ -49,7 +49,7 @@
49
49
  @refresh="loadUsers"
50
50
  @update:page="goToPage"
51
51
  >
52
- <template #actions>
52
+ <template v-if="!props.cardManagement" #actions>
53
53
  <AppButton icon="mdi-account-plus" @click="openEnroll">Enroll</AppButton>
54
54
  </template>
55
55
 
@@ -71,8 +71,11 @@
71
71
  @keyup.enter="loadUsers"
72
72
  />
73
73
  <AppSelect
74
+ v-if="!props.cardManagement"
74
75
  v-model="status"
75
76
  :items="statusOptions"
77
+ placeholder="Status"
78
+ clearable
76
79
  @update:model-value="loadUsers"
77
80
  />
78
81
  </div>
@@ -107,8 +110,43 @@
107
110
  <StatusChip :status="getMappingStatus(item)" />
108
111
  </template>
109
112
 
113
+ <template #[`item.isAdministrator`]="{ item }">
114
+ <v-icon
115
+ :icon="item.isAdministrator ? 'mdi-check-circle' : 'mdi-close-circle'"
116
+ :class="[
117
+ 'admin-rule-indicator',
118
+ item.isAdministrator
119
+ ? 'admin-rule-indicator--enabled'
120
+ : 'admin-rule-indicator--disabled',
121
+ ]"
122
+ :aria-label="item.isAdministrator ? 'Administrator' : 'Not administrator'"
123
+ size="22"
124
+ />
125
+ </template>
126
+
127
+ <template #[`item.cards`]="{ item }">
128
+ <div v-if="getUserPhysicalCards(item).length" class="hid-card-cell">
129
+ <span
130
+ v-for="card in getUserPhysicalCards(item)"
131
+ :key="card.id"
132
+ class="hid-card-cell__item"
133
+ >
134
+ {{ formatPhysicalCard(card) }}
135
+ </span>
136
+ </div>
137
+ <span v-else class="app-cell--muted">No card</span>
138
+ </template>
139
+
110
140
  <template #[`item.action-table`]="{ item }">
111
- <v-menu>
141
+ <AppButton
142
+ v-if="props.cardManagement"
143
+ variant="secondary"
144
+ icon="mdi-card-account-details-outline"
145
+ @click="openCardManager(item)"
146
+ >
147
+ Manage
148
+ </AppButton>
149
+ <v-menu v-else>
112
150
  <template #activator="{ props: menuProps }">
113
151
  <AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" />
114
152
  </template>
@@ -116,6 +154,7 @@
116
154
  <v-list density="compact" min-width="150">
117
155
  <v-list-item title="View" @click="openView(item)" />
118
156
  <v-list-item title="Edit" @click="openEdit(item)" />
157
+ <v-list-item title="Manage HID Cards" @click="openCardManager(item)" />
119
158
  <v-list-item
120
159
  title="Delete"
121
160
  class="text-error"
@@ -130,8 +169,13 @@
130
169
  that nobody has been enrolled yet. -->
131
170
  <template #no-data>
132
171
  <div class="table-card__empty">
133
- <v-icon icon="mdi-account-off-outline" size="32" />
134
- <span>No HID users enrolled yet.</span>
172
+ <v-icon
173
+ :icon="props.cardManagement ? 'mdi-card-account-details-outline' : 'mdi-account-off-outline'"
174
+ size="32"
175
+ />
176
+ <span>
177
+ {{ props.cardManagement ? "No HID users available for card assignment." : "No HID users enrolled yet." }}
178
+ </span>
135
179
  </div>
136
180
  </template>
137
181
  </TableMain>
@@ -207,6 +251,17 @@
207
251
  klass="mb-3"
208
252
  />
209
253
 
254
+ <v-checkbox
255
+ v-model="form.isAdministrator"
256
+ label="Administrator"
257
+ density="compact"
258
+ hide-details
259
+ class="admin-rule-checkbox"
260
+ />
261
+ <p class="field-hint admin-rule-hint">
262
+ Allow this HID user to access the reader as an administrator.
263
+ </p>
264
+
210
265
  <div class="field-label">Access PIN (optional)</div>
211
266
  <!--
212
267
  The handoff draws no password field anywhere (the sign-in screens
@@ -294,6 +349,17 @@
294
349
  ><strong>{{
295
350
  selectedUser ? getMappingStatus(selectedUser) : "N/A"
296
351
  }}</strong>
352
+ <span>Administrator</span>
353
+ <strong class="admin-rule-detail">
354
+ <v-icon
355
+ :icon="selectedUser?.isAdministrator ? 'mdi-check-circle' : 'mdi-close-circle'"
356
+ :class="selectedUser?.isAdministrator
357
+ ? 'admin-rule-indicator--enabled'
358
+ : 'admin-rule-indicator--disabled'"
359
+ size="18"
360
+ />
361
+ {{ selectedUser?.isAdministrator ? "Yes" : "No" }}
362
+ </strong>
297
363
  <span>Access PIN</span
298
364
  ><strong>{{ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A" }}</strong>
299
365
  </div>
@@ -305,6 +371,118 @@
305
371
  </v-card>
306
372
  </v-dialog>
307
373
 
374
+ <v-dialog v-model="cardDialog" max-width="560" persistent>
375
+ <v-card rounded="lg">
376
+ <v-card-title class="small-title">Physical HID Cards</v-card-title>
377
+ <v-card-text>
378
+ <p class="hid-card-user">
379
+ {{ getName(cardUser) }}
380
+ <span>{{ cardUser ? formatHidUid(cardUser.hidUserId) : "" }}</span>
381
+ </p>
382
+
383
+ <v-progress-linear v-if="loadingCards" indeterminate class="mb-3" />
384
+ <div v-else-if="physicalCards.length" class="hid-card-list mb-4">
385
+ <div v-for="card in physicalCards" :key="card.id" class="hid-card-row">
386
+ <v-icon icon="mdi-card-account-details-outline" size="22" />
387
+ <div class="hid-card-row__content">
388
+ <strong>{{ formatPhysicalCard(card) }}</strong>
389
+ <span>{{ getPhysicalCardSubtitle(card) }}</span>
390
+ </div>
391
+ <AppButton
392
+ variant="row"
393
+ icon="mdi-delete-outline"
394
+ :disabled="deletingCardId === String(card.id)"
395
+ @click="removePhysicalCard(card)"
396
+ />
397
+ </div>
398
+ </div>
399
+ <v-alert v-else type="info" variant="tonal" density="compact" class="mb-4">
400
+ No physical HID card is assigned to this user.
401
+ </v-alert>
402
+
403
+ <div class="field-label">Card data type</div>
404
+ <AppSelect
405
+ v-model="cardForm.cardType"
406
+ :items="physicalCardTypeOptions"
407
+ class="mb-3"
408
+ />
409
+ <p class="field-hint hid-card-type-hint">
410
+ PACS uses Facility Code + Card Number. CSN uses the raw serial number read from the physical card.
411
+ </p>
412
+ <p class="field-hint hid-card-access-hint">
413
+ The card uses this HID user's existing portal and access rules; assigning a card does not grant new access.
414
+ </p>
415
+
416
+ <div v-if="cardForm.cardType === 'pacs'" class="hid-card-fields">
417
+ <div>
418
+ <div class="field-label">Facility Code</div>
419
+ <AppField
420
+ v-model="cardForm.facilityCode"
421
+ inputmode="numeric"
422
+ placeholder="e.g. 123"
423
+ />
424
+ </div>
425
+ <div>
426
+ <div class="field-label">Card Number</div>
427
+ <AppField
428
+ v-model="cardForm.cardNumber"
429
+ inputmode="numeric"
430
+ placeholder="e.g. 45678"
431
+ />
432
+ </div>
433
+ </div>
434
+ <template v-else>
435
+ <div class="field-label">CSN / Raw Card Value</div>
436
+ <AppField
437
+ v-model="cardForm.cardValue"
438
+ inputmode="numeric"
439
+ placeholder="Enter the decimal card value"
440
+ />
441
+ </template>
442
+
443
+ <v-alert
444
+ v-if="enrollingCard"
445
+ type="info"
446
+ variant="tonal"
447
+ density="compact"
448
+ class="mt-4"
449
+ >
450
+ Present the physical card to the selected Amico reader. The request will time out after 30 seconds.
451
+ </v-alert>
452
+
453
+ <div class="hid-card-actions mt-4">
454
+ <AppButton
455
+ variant="secondary"
456
+ :disabled="savingCard || enrollingCard"
457
+ @click="assignPhysicalCard"
458
+ >
459
+ Assign Manually
460
+ </AppButton>
461
+ <AppButton
462
+ icon="mdi-contactless-payment"
463
+ :disabled="savingCard || enrollingCard"
464
+ @click="enrollPhysicalCard"
465
+ >
466
+ Read at Reader
467
+ </AppButton>
468
+ <AppButton
469
+ v-if="enrollingCard"
470
+ variant="ghost"
471
+ @click="cancelPhysicalCardEnrollment"
472
+ >
473
+ Cancel Reading
474
+ </AppButton>
475
+ </div>
476
+ </v-card-text>
477
+ <v-card-actions>
478
+ <v-spacer />
479
+ <AppButton variant="ghost" :disabled="enrollingCard" @click="cardDialog = false">
480
+ Close
481
+ </AppButton>
482
+ </v-card-actions>
483
+ </v-card>
484
+ </v-dialog>
485
+
308
486
  <v-dialog v-model="deleteDialog" max-width="360" persistent>
309
487
  <v-card rounded="lg">
310
488
  <v-card-title class="small-title">Delete</v-card-title>
@@ -347,9 +525,9 @@ const props = defineProps({
347
525
  type: String,
348
526
  required: true,
349
527
  },
350
- identityType: {
351
- type: String,
352
- default: "resident",
528
+ cardManagement: {
529
+ type: Boolean,
530
+ default: false,
353
531
  },
354
532
  });
355
533
 
@@ -359,6 +537,11 @@ const {
359
537
  getUserImage,
360
538
  setUserImage,
361
539
  deleteUserImage,
540
+ getUserCards,
541
+ assignUserCard,
542
+ enrollUserCard,
543
+ cancelUserCardEnrollment,
544
+ deleteUserCard,
362
545
  getUserPinStatus,
363
546
  setUserPin,
364
547
  deleteUserPin,
@@ -371,9 +554,10 @@ const {
371
554
  const readers = ref<Record<string, any>[]>([]);
372
555
  const users = ref<Record<string, any>[]>([]);
373
556
  const userImages = ref<Record<string, string>>({});
557
+ const administratorUserIds = ref<Set<number>>(new Set());
374
558
  const selectedReaderId = ref("");
375
559
  const search = ref("");
376
- const status = ref("Status");
560
+ const status = ref("");
377
561
  const page = ref(1);
378
562
  const limit = ref(10);
379
563
  const pages = ref(1);
@@ -385,7 +569,16 @@ const deleting = ref(false);
385
569
  const formDialog = ref(false);
386
570
  const viewDialog = ref(false);
387
571
  const deleteDialog = ref(false);
572
+ const cardDialog = ref(false);
388
573
  const selectedUser = ref<Record<string, any> | null>(null);
574
+ const cardUser = ref<Record<string, any> | null>(null);
575
+ const physicalCards = ref<Record<string, any>[]>([]);
576
+ const physicalCardsByUserId = ref<Record<string, Record<string, any>[]>>({});
577
+ const loadingCards = ref(false);
578
+ const savingCard = ref(false);
579
+ const enrollingCard = ref(false);
580
+ const cardEnrollmentCancelled = ref(false);
581
+ const deletingCardId = ref("");
389
582
  const showPin = ref(false);
390
583
  const pinEnrolled = ref(false);
391
584
  const photoInput = ref<HTMLInputElement | null>(null);
@@ -407,11 +600,24 @@ const form = reactive({
407
600
  name: "",
408
601
  hidUserId: "",
409
602
  registration: "",
603
+ isAdministrator: false,
410
604
  accessPin: "",
411
605
  photoPreview: "",
412
606
  });
413
607
 
414
- const statusOptions = ["Status", "Mapped", "Unmapped"];
608
+ const cardForm = reactive({
609
+ cardType: "pacs" as "pacs" | "csn",
610
+ facilityCode: "",
611
+ cardNumber: "",
612
+ cardValue: "",
613
+ });
614
+
615
+ const physicalCardTypeOptions = [
616
+ { title: "PACS (Facility Code + Card Number)", value: "pacs" },
617
+ { title: "CSN (Raw Card Serial Number)", value: "csn" },
618
+ ];
619
+
620
+ const statusOptions = ["Mapped", "Unmapped"];
415
621
 
416
622
  /**
417
623
  * `sortable: false` keeps the previous behaviour - the raw `v-table` never
@@ -424,9 +630,21 @@ const userHeaders = [
424
630
  { title: "Facial Data", value: "facialData", sortable: false },
425
631
  { title: "UID", value: "hidUserId", sortable: false },
426
632
  { title: "Status", value: "status", sortable: false },
633
+ { title: "Admin", value: "isAdministrator", sortable: false },
634
+ { title: "", value: "action-table", sortable: false },
635
+ ];
636
+
637
+ const cardHeaders = [
638
+ { title: "Name", value: "name", sortable: false },
639
+ { title: "UID", value: "hidUserId", sortable: false },
640
+ { title: "Physical HID Cards", value: "cards", sortable: false },
641
+ { title: "Admin", value: "isAdministrator", sortable: false },
427
642
  { title: "", value: "action-table", sortable: false },
428
643
  ];
429
644
 
645
+ const tableTitle = computed(() => props.cardManagement ? "HID Cards" : "HID Users");
646
+ const tableHeaders = computed(() => props.cardManagement ? cardHeaders : userHeaders);
647
+
430
648
  /**
431
649
  * The list is paged on the SERVER, so the shared pager only moves the cursor -
432
650
  * the rows still come from `loadUsers`. This is the same pair of statements
@@ -479,16 +697,16 @@ async function loadReaders() {
479
697
  async function loadUsers() {
480
698
  if (!selectedReaderId.value) {
481
699
  users.value = [];
700
+ administratorUserIds.value = new Set();
701
+ physicalCardsByUserId.value = {};
482
702
  return;
483
703
  }
484
704
 
485
705
  loading.value = true;
486
706
  try {
487
707
  const identityQuery: Record<string, any> = {
488
- page: page.value,
708
+ page: 1,
489
709
  limit: 500,
490
- type: props.identityType,
491
- search: search.value,
492
710
  };
493
711
 
494
712
  const [identityResponse, readerResponse, roleResponse] = await Promise.all([
@@ -499,9 +717,7 @@ async function loadUsers() {
499
717
  limit: 500,
500
718
  offset: 0,
501
719
  }),
502
- isAdministratorIdentity()
503
- ? loadAdministratorRoles(selectedReaderId.value)
504
- : Promise.resolve(null),
720
+ loadAdministratorRoles(selectedReaderId.value),
505
721
  ]);
506
722
 
507
723
  const identities =
@@ -509,14 +725,16 @@ async function loadUsers() {
509
725
  identityResponse?.data?.items ??
510
726
  identityResponse?.data?.identities ??
511
727
  [];
512
- const adminRoleIds = getAdministratorRoleUserIds(roleResponse);
513
- const readerUsers = normalizeHidUsers(readerResponse).filter((user) => {
514
- if (Number(user.userTypeId) === 1) return false;
515
- if (!isAdministratorIdentity()) return true;
516
- const hidUserId = toHidNumericId(user.hidUserId);
517
- return Boolean(hidUserId && adminRoleIds.has(hidUserId));
518
- });
519
- const mergedUsers = mergeReaderUsersWithIdentities(readerUsers, identities);
728
+ administratorUserIds.value = getAdministratorRoleUserIds(roleResponse);
729
+ const readerUsers = normalizeHidUsers(readerResponse).filter(
730
+ (user) => Number(user.userTypeId) !== 1,
731
+ );
732
+ const mergedUsers = mergeReaderUsersWithIdentities(readerUsers, identities).map(
733
+ (user) => ({
734
+ ...user,
735
+ isAdministrator: hasAdministratorRule(user),
736
+ }),
737
+ );
520
738
  const filteredUsers = filterUsers(mergedUsers);
521
739
  total.value = filteredUsers.length;
522
740
  pages.value = Math.max(1, Math.ceil(total.value / limit.value));
@@ -524,11 +742,17 @@ async function loadUsers() {
524
742
  const start = (page.value - 1) * limit.value;
525
743
  users.value = filteredUsers.slice(start, start + limit.value);
526
744
  serverPageRange.value = "";
527
- await loadUserImages(users.value);
745
+ if (props.cardManagement) {
746
+ await loadVisibleUserCards(users.value);
747
+ } else {
748
+ await loadUserImages(users.value);
749
+ }
528
750
  } catch (error) {
529
751
  console.error("Unable to load HID reader users:", error);
530
752
  showToast("Unable to load HID users from reader.", "error");
531
753
  users.value = [];
754
+ administratorUserIds.value = new Set();
755
+ physicalCardsByUserId.value = {};
532
756
  total.value = 0;
533
757
  pages.value = 1;
534
758
  serverPageRange.value = "";
@@ -567,6 +791,7 @@ function resetForm() {
567
791
  form.name = "";
568
792
  form.hidUserId = "";
569
793
  form.registration = "";
794
+ form.isAdministrator = false;
570
795
  form.accessPin = "";
571
796
  pinEnrolled.value = false;
572
797
  showPin.value = false;
@@ -597,6 +822,7 @@ async function openEdit(user: Record<string, any>) {
597
822
  form.name = getName(user);
598
823
  form.hidUserId = formatHidUid(user.hidUserId);
599
824
  form.registration = user.registration ?? "";
825
+ form.isAdministrator = hasAdministratorRule(user);
600
826
  form.accessPin = "";
601
827
  pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
602
828
  removePinRequested.value = false;
@@ -722,7 +948,7 @@ async function saveUser() {
722
948
  const payload = {
723
949
  hidUserId: String(hidUser.id),
724
950
  registration: form.registration,
725
- type: props.identityType as
951
+ type: (selectedUser.value?.type || "resident") as
726
952
  | "resident"
727
953
  | "staff"
728
954
  | "contractor"
@@ -741,12 +967,13 @@ async function saveUser() {
741
967
  imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
742
968
  facialScores: selectedUser.value?.metadata?.facialScores || {},
743
969
  pinEnrolled: pinEnrolled.value,
970
+ hidPhysicalCards: selectedUser.value?.metadata?.hidPhysicalCards || [],
744
971
  },
745
972
  };
746
973
 
747
974
  if (selectedUser.value) {
748
975
  await updateHidUserOnReader(readerId, hidUser);
749
- await syncHidUserRole(readerId, hidUser.id, isAdministratorIdentity());
976
+ await syncHidUserRole(readerId, hidUser.id, form.isAdministrator);
750
977
  const facialResult = await syncFacialImage(readerId, hidUser.id);
751
978
  applyFacialResult(payload.metadata, facialResult);
752
979
  payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
@@ -757,7 +984,7 @@ async function saveUser() {
757
984
  }
758
985
  } else {
759
986
  await createHidUserOnReader(readerId, hidUser);
760
- await syncHidUserRole(readerId, hidUser.id, isAdministratorIdentity());
987
+ await syncHidUserRole(readerId, hidUser.id, form.isAdministrator);
761
988
  const facialResult = await syncFacialImage(readerId, hidUser.id);
762
989
  applyFacialResult(payload.metadata, facialResult);
763
990
  payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
@@ -1122,8 +1349,182 @@ function toHidNumericId(value: unknown) {
1122
1349
  return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
1123
1350
  }
1124
1351
 
1125
- function isAdministratorIdentity() {
1126
- return props.identityType === "admin";
1352
+ function hasAdministratorRule(user?: Record<string, any> | null) {
1353
+ const hidUserId = toHidNumericId(user?.hidUserId);
1354
+ return Boolean(hidUserId && administratorUserIds.value.has(hidUserId));
1355
+ }
1356
+
1357
+ async function loadVisibleUserCards(items: Record<string, any>[]) {
1358
+ if (!selectedReaderId.value) return;
1359
+
1360
+ const entries = await Promise.all(
1361
+ items.map(async (user) => {
1362
+ const hidUserId = toHidNumericId(user.hidUserId);
1363
+ if (!hidUserId) return ["", []] as const;
1364
+
1365
+ try {
1366
+ const response = await getUserCards(selectedReaderId.value, hidUserId);
1367
+ return [String(hidUserId), response?.data?.cards || []] as const;
1368
+ } catch {
1369
+ return [String(hidUserId), []] as const;
1370
+ }
1371
+ }),
1372
+ );
1373
+
1374
+ physicalCardsByUserId.value = Object.fromEntries(
1375
+ entries.filter(([hidUserId]) => Boolean(hidUserId)),
1376
+ );
1377
+ }
1378
+
1379
+ function getUserPhysicalCards(user: Record<string, any>) {
1380
+ const hidUserId = toHidNumericId(user.hidUserId);
1381
+ return hidUserId ? physicalCardsByUserId.value[String(hidUserId)] || [] : [];
1382
+ }
1383
+
1384
+ async function openCardManager(user: Record<string, any>) {
1385
+ cardUser.value = user;
1386
+ cardForm.cardType = "pacs";
1387
+ cardForm.facilityCode = "";
1388
+ cardForm.cardNumber = "";
1389
+ cardForm.cardValue = "";
1390
+ physicalCards.value = [];
1391
+ cardDialog.value = true;
1392
+ await loadPhysicalCards();
1393
+ }
1394
+
1395
+ function getCardReaderAndUser() {
1396
+ const readerId = String(cardUser.value?.reader || selectedReaderId.value || "");
1397
+ const hidUserId = toHidNumericId(cardUser.value?.hidUserId);
1398
+ return { readerId, hidUserId };
1399
+ }
1400
+
1401
+ async function loadPhysicalCards() {
1402
+ const { readerId, hidUserId } = getCardReaderAndUser();
1403
+ if (!readerId || !hidUserId) return;
1404
+
1405
+ loadingCards.value = true;
1406
+ try {
1407
+ const response = await getUserCards(readerId, hidUserId);
1408
+ physicalCards.value = response?.data?.cards || [];
1409
+ } catch (error: unknown) {
1410
+ physicalCards.value = [];
1411
+ showToast(getHidErrorMessage(error), "error");
1412
+ } finally {
1413
+ loadingCards.value = false;
1414
+ }
1415
+ }
1416
+
1417
+ function parsePhysicalCardInteger(value: string, label: string) {
1418
+ if (!/^\d+$/.test(value.trim())) {
1419
+ showToast(`${label} must contain numbers only.`, "error");
1420
+ return null;
1421
+ }
1422
+ const number = Number(value);
1423
+ if (!Number.isSafeInteger(number) || number < 0) {
1424
+ showToast(`${label} is outside the supported range.`, "error");
1425
+ return null;
1426
+ }
1427
+ return number;
1428
+ }
1429
+
1430
+ async function assignPhysicalCard() {
1431
+ const { readerId, hidUserId } = getCardReaderAndUser();
1432
+ if (!readerId || !hidUserId) return;
1433
+
1434
+ let payload:
1435
+ | { cardType: "pacs"; facilityCode: number; cardNumber: number }
1436
+ | { cardType: "csn"; cardValue: string };
1437
+ if (cardForm.cardType === "pacs") {
1438
+ const facilityCode = parsePhysicalCardInteger(cardForm.facilityCode, "Facility Code");
1439
+ const cardNumber = parsePhysicalCardInteger(cardForm.cardNumber, "Card Number");
1440
+ if (facilityCode === null || cardNumber === null) return;
1441
+ payload = { cardType: "pacs", facilityCode, cardNumber };
1442
+ } else {
1443
+ const cardValue = parsePhysicalCardInteger(cardForm.cardValue, "CSN");
1444
+ if (cardValue === null || cardValue <= 0) {
1445
+ if (cardValue === 0) showToast("CSN must be greater than zero.", "error");
1446
+ return;
1447
+ }
1448
+ payload = { cardType: "csn", cardValue: String(cardValue) };
1449
+ }
1450
+
1451
+ savingCard.value = true;
1452
+ try {
1453
+ await assignUserCard(readerId, hidUserId, payload);
1454
+ cardForm.facilityCode = "";
1455
+ cardForm.cardNumber = "";
1456
+ cardForm.cardValue = "";
1457
+ await loadPhysicalCards();
1458
+ await loadUsers();
1459
+ showToast("Physical HID card assigned and verified on the reader.");
1460
+ } catch (error: unknown) {
1461
+ showToast(getHidErrorMessage(error), "error");
1462
+ } finally {
1463
+ savingCard.value = false;
1464
+ }
1465
+ }
1466
+
1467
+ async function enrollPhysicalCard() {
1468
+ const { readerId, hidUserId } = getCardReaderAndUser();
1469
+ if (!readerId || !hidUserId) return;
1470
+
1471
+ enrollingCard.value = true;
1472
+ cardEnrollmentCancelled.value = false;
1473
+ try {
1474
+ await enrollUserCard(readerId, hidUserId, cardForm.cardType, 30);
1475
+ await loadPhysicalCards();
1476
+ await loadUsers();
1477
+ showToast("Physical HID card read and enrolled on the reader.");
1478
+ } catch (error: unknown) {
1479
+ if (!cardEnrollmentCancelled.value) {
1480
+ showToast(getHidErrorMessage(error), "error");
1481
+ }
1482
+ } finally {
1483
+ enrollingCard.value = false;
1484
+ }
1485
+ }
1486
+
1487
+ async function cancelPhysicalCardEnrollment() {
1488
+ const { readerId } = getCardReaderAndUser();
1489
+ if (!readerId) return;
1490
+ cardEnrollmentCancelled.value = true;
1491
+ try {
1492
+ await cancelUserCardEnrollment(readerId);
1493
+ showToast("Physical HID card reading cancelled.");
1494
+ } catch (error: unknown) {
1495
+ showToast(getHidErrorMessage(error), "error");
1496
+ }
1497
+ }
1498
+
1499
+ async function removePhysicalCard(card: Record<string, any>) {
1500
+ if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`)) return;
1501
+ const { readerId, hidUserId } = getCardReaderAndUser();
1502
+ if (!readerId || !hidUserId || !card?.id) return;
1503
+
1504
+ deletingCardId.value = String(card.id);
1505
+ try {
1506
+ await deleteUserCard(readerId, hidUserId, card.id);
1507
+ await loadPhysicalCards();
1508
+ await loadUsers();
1509
+ showToast("Physical HID card removed from the reader.");
1510
+ } catch (error: unknown) {
1511
+ showToast(getHidErrorMessage(error), "error");
1512
+ } finally {
1513
+ deletingCardId.value = "";
1514
+ }
1515
+ }
1516
+
1517
+ function formatPhysicalCard(card: Record<string, any>) {
1518
+ if (card?.cardType === "pacs") {
1519
+ return `PACS ${card.facilityCode}.${card.cardNumber}`;
1520
+ }
1521
+ if (card?.cardType === "csn") return `CSN ${card.value}`;
1522
+ return `Card ${card?.value || "N/A"}`;
1523
+ }
1524
+
1525
+ function getPhysicalCardSubtitle(card: Record<string, any>) {
1526
+ const source = card?.source === "reader" ? "Read at reader" : card?.source === "manual" ? "Manual" : "On device";
1527
+ return `${source} - Raw value ${card?.value || "N/A"}`;
1127
1528
  }
1128
1529
 
1129
1530
  function buildUnitLabel() {
@@ -1299,8 +1700,7 @@ function filterUsers(items: Record<string, any>[]) {
1299
1700
  .toLowerCase()
1300
1701
  .includes(searchText)
1301
1702
  );
1302
- const matchesStatus =
1303
- status.value === "Status" || getMappingStatus(item) === status.value;
1703
+ const matchesStatus = !status.value || getMappingStatus(item) === status.value;
1304
1704
  return matchesSearch && matchesStatus;
1305
1705
  });
1306
1706
  }
@@ -1466,6 +1866,122 @@ function getHidErrorMessage(error: any) {
1466
1866
  background: var(--hover);
1467
1867
  }
1468
1868
 
1869
+ .admin-rule-indicator {
1870
+ display: inline-flex;
1871
+ vertical-align: middle;
1872
+ }
1873
+
1874
+ .hid-card-user {
1875
+ display: flex;
1876
+ align-items: center;
1877
+ justify-content: space-between;
1878
+ gap: 12px;
1879
+ margin: 0 0 14px;
1880
+ color: var(--text);
1881
+ font-weight: 700;
1882
+ }
1883
+
1884
+ .hid-card-user span,
1885
+ .hid-card-row__content span {
1886
+ color: var(--muted);
1887
+ font-size: 12px;
1888
+ font-weight: 600;
1889
+ }
1890
+
1891
+ .hid-card-cell {
1892
+ display: flex;
1893
+ flex-wrap: wrap;
1894
+ gap: 6px;
1895
+ }
1896
+
1897
+ .hid-card-cell__item {
1898
+ padding: 4px 9px;
1899
+ border-radius: 999px;
1900
+ background: color-mix(in srgb, var(--primary) 10%, transparent);
1901
+ color: var(--primary);
1902
+ font-size: 12px;
1903
+ font-weight: 700;
1904
+ }
1905
+
1906
+ .hid-card-list {
1907
+ overflow: hidden;
1908
+ border: 1px solid var(--border);
1909
+ border-radius: var(--r-inner);
1910
+ }
1911
+
1912
+ .hid-card-row {
1913
+ display: flex;
1914
+ align-items: center;
1915
+ gap: 12px;
1916
+ min-height: 58px;
1917
+ padding: 8px 10px 8px 14px;
1918
+ color: var(--text2);
1919
+ }
1920
+
1921
+ .hid-card-row + .hid-card-row {
1922
+ border-top: 1px solid var(--border);
1923
+ }
1924
+
1925
+ .hid-card-row__content {
1926
+ display: flex;
1927
+ flex: 1;
1928
+ flex-direction: column;
1929
+ min-width: 0;
1930
+ }
1931
+
1932
+ .hid-card-row__content strong {
1933
+ color: var(--text);
1934
+ font-size: 13px;
1935
+ }
1936
+
1937
+ .hid-card-fields {
1938
+ display: grid;
1939
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1940
+ gap: 12px;
1941
+ }
1942
+
1943
+ .hid-card-type-hint {
1944
+ margin-bottom: 6px;
1945
+ }
1946
+
1947
+ .hid-card-access-hint {
1948
+ margin-bottom: 14px;
1949
+ }
1950
+
1951
+ .hid-card-actions {
1952
+ display: flex;
1953
+ flex-wrap: wrap;
1954
+ gap: 8px;
1955
+ }
1956
+
1957
+ @media (max-width: 520px) {
1958
+ .hid-card-fields {
1959
+ grid-template-columns: 1fr;
1960
+ }
1961
+ }
1962
+
1963
+ .admin-rule-indicator--enabled {
1964
+ color: var(--ok);
1965
+ }
1966
+
1967
+ .admin-rule-indicator--disabled {
1968
+ color: var(--err);
1969
+ }
1970
+
1971
+ .admin-rule-checkbox {
1972
+ margin-top: -4px;
1973
+ }
1974
+
1975
+ .admin-rule-hint {
1976
+ margin: -2px 0 14px;
1977
+ }
1978
+
1979
+ .admin-rule-detail {
1980
+ display: inline-flex;
1981
+ align-items: center;
1982
+ gap: 6px;
1983
+ }
1984
+
1469
1985
  .hidden-input {
1470
1986
  display: none;
1471
1987
  }