@7365admin1/layer-common 4.73.1-staging.464 → 4.73.1-staging.465
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.
- package/assets/css/primitives.css +14 -0
- package/components/AppSelect.vue +66 -1
- package/components/HidUserEnrollment.vue +754 -171
- package/package.json +1 -1
- package/utils/hid-enrolment-subject.ts +144 -0
|
@@ -25,7 +25,11 @@
|
|
|
25
25
|
the old table card, so it stays above the shared one. -->
|
|
26
26
|
<v-alert
|
|
27
27
|
v-if="selectedReader"
|
|
28
|
-
:type="
|
|
28
|
+
:type="
|
|
29
|
+
selectedReader.portalId && selectedReader.portalName
|
|
30
|
+
? 'info'
|
|
31
|
+
: 'warning'
|
|
32
|
+
"
|
|
29
33
|
variant="tonal"
|
|
30
34
|
density="compact"
|
|
31
35
|
class="mb-4"
|
|
@@ -50,7 +54,9 @@
|
|
|
50
54
|
@update:page="goToPage"
|
|
51
55
|
>
|
|
52
56
|
<template v-if="!props.cardManagement" #actions>
|
|
53
|
-
<AppButton icon="mdi-account-plus" @click="openEnroll"
|
|
57
|
+
<AppButton icon="mdi-account-plus" @click="openEnroll"
|
|
58
|
+
>Enroll</AppButton
|
|
59
|
+
>
|
|
54
60
|
</template>
|
|
55
61
|
|
|
56
62
|
<!-- The design puts filters on their own full-width row under the
|
|
@@ -93,7 +99,9 @@
|
|
|
93
99
|
</template>
|
|
94
100
|
|
|
95
101
|
<template #[`item.hidUserId`]="{ item }">
|
|
96
|
-
<span class="app-cell--num">{{
|
|
102
|
+
<span class="app-cell--num">{{
|
|
103
|
+
formatHidUid(item.hidUserId) || "N/A"
|
|
104
|
+
}}</span>
|
|
97
105
|
</template>
|
|
98
106
|
|
|
99
107
|
<!-- The reader allocates this alongside the UID. A row the reader holds
|
|
@@ -119,7 +127,10 @@
|
|
|
119
127
|
{{ formatPhysicalCard(card) }}
|
|
120
128
|
</span>
|
|
121
129
|
</div>
|
|
122
|
-
<span
|
|
130
|
+
<span
|
|
131
|
+
v-else-if="getUserCardLoadError(item)"
|
|
132
|
+
class="hid-card-load-error"
|
|
133
|
+
>
|
|
123
134
|
Unable to load
|
|
124
135
|
<v-tooltip activator="parent" location="top">
|
|
125
136
|
{{ getUserCardLoadError(item) }}
|
|
@@ -139,13 +150,20 @@
|
|
|
139
150
|
</AppButton>
|
|
140
151
|
<v-menu v-else>
|
|
141
152
|
<template #activator="{ props: menuProps }">
|
|
142
|
-
<AppButton
|
|
153
|
+
<AppButton
|
|
154
|
+
v-bind="menuProps"
|
|
155
|
+
variant="row"
|
|
156
|
+
icon="mdi-dots-vertical"
|
|
157
|
+
/>
|
|
143
158
|
</template>
|
|
144
159
|
|
|
145
160
|
<v-list density="compact" min-width="150">
|
|
146
161
|
<v-list-item title="View" @click="openView(item)" />
|
|
147
162
|
<v-list-item title="Edit" @click="openEdit(item)" />
|
|
148
|
-
<v-list-item
|
|
163
|
+
<v-list-item
|
|
164
|
+
title="Manage HID Cards"
|
|
165
|
+
@click="openCardManager(item)"
|
|
166
|
+
/>
|
|
149
167
|
<v-list-item
|
|
150
168
|
title="Delete"
|
|
151
169
|
class="text-error"
|
|
@@ -161,11 +179,19 @@
|
|
|
161
179
|
<template #no-data>
|
|
162
180
|
<div class="table-card__empty">
|
|
163
181
|
<v-icon
|
|
164
|
-
:icon="
|
|
182
|
+
:icon="
|
|
183
|
+
props.cardManagement
|
|
184
|
+
? 'mdi-card-account-details-outline'
|
|
185
|
+
: 'mdi-account-off-outline'
|
|
186
|
+
"
|
|
165
187
|
size="32"
|
|
166
188
|
/>
|
|
167
189
|
<span>
|
|
168
|
-
{{
|
|
190
|
+
{{
|
|
191
|
+
props.cardManagement
|
|
192
|
+
? "No HID users available for card assignment."
|
|
193
|
+
: "No HID users enrolled yet."
|
|
194
|
+
}}
|
|
169
195
|
</span>
|
|
170
196
|
</div>
|
|
171
197
|
</template>
|
|
@@ -237,6 +263,25 @@
|
|
|
237
263
|
/>
|
|
238
264
|
</div>
|
|
239
265
|
|
|
266
|
+
<!-- WHO IS BEING ENROLLED.
|
|
267
|
+
The write path has always carried three subject categories -
|
|
268
|
+
`subjectLink` below maps them onto `person`, `member` or
|
|
269
|
+
`serviceProvider` - but the form only ever offered residents.
|
|
270
|
+
Staff hold no unit, so choosing Member replaces the whole
|
|
271
|
+
block/level/unit cascade with a single picker.
|
|
272
|
+
Enrolment only: an identity's subject cannot change category
|
|
273
|
+
after the fact, so an edit STATES the type rather than offering
|
|
274
|
+
it. -->
|
|
275
|
+
<div class="field-label">Enrolling <span>*</span></div>
|
|
276
|
+
<AppSelect
|
|
277
|
+
v-if="!selectedUser"
|
|
278
|
+
v-model="form.subjectCategory"
|
|
279
|
+
:items="subjectCategoryOptions"
|
|
280
|
+
klass="mb-3"
|
|
281
|
+
@update:model-value="onSubjectCategoryChanged"
|
|
282
|
+
/>
|
|
283
|
+
<p v-else class="field-hint mb-3">{{ subjectCategoryLabel }}</p>
|
|
284
|
+
|
|
240
285
|
<!-- Block / Level / Unit / Resident. Enrolment only.
|
|
241
286
|
Enrollment is resident-only and the resident must already exist
|
|
242
287
|
in iService365 - you pick a person, you do not type one. Name is
|
|
@@ -252,7 +297,7 @@
|
|
|
252
297
|
asked for. A row that never had one keeps not having one; that
|
|
253
298
|
is not a reason to make somebody fill in a location before they
|
|
254
299
|
can change a PIN. -->
|
|
255
|
-
<template v-if="!selectedUser">
|
|
300
|
+
<template v-if="!selectedUser && isResidentSubject">
|
|
256
301
|
<div class="field-label">Block <span>*</span></div>
|
|
257
302
|
<!-- The listener sits on a wrapper, NOT on AppSelect: AppSelect
|
|
258
303
|
spreads `$attrs` over its v-menu activator AFTER the activator's
|
|
@@ -297,7 +342,7 @@
|
|
|
297
342
|
with no unit has no candidates to show, and the dialog is then
|
|
298
343
|
purely the reader's own credentials - photo, PIN, password -
|
|
299
344
|
which is exactly the edit that must not be blocked. -->
|
|
300
|
-
<template v-if="!selectedUser || form.unit">
|
|
345
|
+
<template v-if="isResidentSubject && (!selectedUser || form.unit)">
|
|
301
346
|
<div class="field-label">Resident <span>*</span></div>
|
|
302
347
|
<AppSelect
|
|
303
348
|
v-model="form.subjectId"
|
|
@@ -308,30 +353,96 @@
|
|
|
308
353
|
@update:model-value="onResidentChanged"
|
|
309
354
|
/>
|
|
310
355
|
</template>
|
|
356
|
+
|
|
357
|
+
<!-- STAFF. One picker, because a member has no unit to narrow by.
|
|
358
|
+
The candidates are the permission subjects the Access Permissions
|
|
359
|
+
screen already reads - active members of this site or its
|
|
360
|
+
organisation, each carrying its role as a subtitle. Filtering is
|
|
361
|
+
AppSelect's own, over the loaded page, so typing costs no request.
|
|
362
|
+
A member with no app account is not in the list at all: reader
|
|
363
|
+
access is bound by user id, so there is nothing to bind. The note
|
|
364
|
+
below says how many were left out rather than letting a missing
|
|
365
|
+
colleague read as a bug. -->
|
|
366
|
+
<template v-if="!selectedUser && !isResidentSubject">
|
|
367
|
+
<div class="field-label">Member <span>*</span></div>
|
|
368
|
+
<AppSelect
|
|
369
|
+
v-model="form.subjectId"
|
|
370
|
+
:items="memberOptions"
|
|
371
|
+
searchable
|
|
372
|
+
search-placeholder="Search staff by name or role"
|
|
373
|
+
:placeholder="memberPlaceholder"
|
|
374
|
+
:has-more="hasMoreMembers"
|
|
375
|
+
:loading-more="loadingMoreMembers"
|
|
376
|
+
klass="mb-3"
|
|
377
|
+
@update:model-value="onMemberChanged"
|
|
378
|
+
@load-more="loadMoreMembers"
|
|
379
|
+
/>
|
|
380
|
+
<!-- Ten at a time, because that page size shares the Members
|
|
381
|
+
menu's cache entry. The rest arrive as the list is scrolled, so
|
|
382
|
+
this only has to SAY where the list has got to - and warn that
|
|
383
|
+
the search box filters what is loaded, not the whole estate. -->
|
|
384
|
+
<p v-if="memberRangeNote" class="field-hint mb-3">
|
|
385
|
+
{{ memberRangeNote }}
|
|
386
|
+
</p>
|
|
387
|
+
<p v-if="hiddenMemberNote" class="field-hint mb-3">
|
|
388
|
+
{{ hiddenMemberNote }}
|
|
389
|
+
</p>
|
|
390
|
+
</template>
|
|
391
|
+
|
|
311
392
|
<!-- The duplicate check. It is reader-scoped: this resident holding a
|
|
312
393
|
HID user on another gate is normal and says nothing here. -->
|
|
313
|
-
<p v-if="checkingResident" class="field-hint">
|
|
314
|
-
|
|
394
|
+
<p v-if="checkingResident" class="field-hint">
|
|
395
|
+
Checking enrollment...
|
|
396
|
+
</p>
|
|
397
|
+
<p
|
|
398
|
+
v-else-if="residentConflict"
|
|
399
|
+
class="field-hint field-hint--error mb-3"
|
|
400
|
+
>
|
|
315
401
|
{{ residentConflictMessage }}
|
|
316
402
|
</p>
|
|
317
|
-
<p
|
|
403
|
+
<p
|
|
404
|
+
v-else-if="isResidentSubject && unitResidentNote"
|
|
405
|
+
class="field-hint mb-3"
|
|
406
|
+
>
|
|
318
407
|
{{ unitResidentNote }}
|
|
319
408
|
</p>
|
|
320
409
|
|
|
321
410
|
<!-- Read-only: it restates the chosen resident, it does not collect
|
|
322
411
|
anything. `selectedUser` is the edit case, where the name is
|
|
323
412
|
whatever the reader already holds. -->
|
|
413
|
+
<!-- A mirror of the chosen member's role, on the same rule as Name:
|
|
414
|
+
restated from the record, never collected. -->
|
|
415
|
+
<template v-if="!selectedUser && !isResidentSubject">
|
|
416
|
+
<div class="field-label">Role</div>
|
|
417
|
+
<AppField
|
|
418
|
+
v-model="form.subjectRole"
|
|
419
|
+
aria-label="Role"
|
|
420
|
+
placeholder="Select a member above"
|
|
421
|
+
readonly
|
|
422
|
+
klass="mb-3"
|
|
423
|
+
/>
|
|
424
|
+
</template>
|
|
425
|
+
|
|
324
426
|
<div class="field-label">Name</div>
|
|
325
427
|
<AppField
|
|
326
428
|
v-model="form.name"
|
|
327
429
|
aria-label="Name"
|
|
328
|
-
placeholder="
|
|
430
|
+
:placeholder="
|
|
431
|
+
isResidentSubject
|
|
432
|
+
? 'Select a resident above'
|
|
433
|
+
: 'Select a member above'
|
|
434
|
+
"
|
|
329
435
|
readonly
|
|
330
436
|
klass="mb-3"
|
|
331
437
|
/>
|
|
332
438
|
|
|
333
439
|
<div class="field-label">ID</div>
|
|
334
|
-
<AppField
|
|
440
|
+
<AppField
|
|
441
|
+
v-model="form.hidUserId"
|
|
442
|
+
aria-label="ID"
|
|
443
|
+
readonly
|
|
444
|
+
klass="mb-3"
|
|
445
|
+
/>
|
|
335
446
|
|
|
336
447
|
<div class="field-label">Registration No. <span>*</span></div>
|
|
337
448
|
<AppField
|
|
@@ -372,7 +483,11 @@
|
|
|
372
483
|
<!-- `persistent-hint` was Vuetify's; the design's settings module
|
|
373
484
|
draws an explanatory line as 12-13px/600 in `--muted`. -->
|
|
374
485
|
<p class="field-hint">
|
|
375
|
-
{{
|
|
486
|
+
{{
|
|
487
|
+
selectedUser && pinEnrolled
|
|
488
|
+
? "Leave blank to keep the current PIN."
|
|
489
|
+
: "PIN works as an alternative to face or QR."
|
|
490
|
+
}}
|
|
376
491
|
</p>
|
|
377
492
|
<v-checkbox
|
|
378
493
|
v-if="selectedUser && pinEnrolled"
|
|
@@ -389,7 +504,11 @@
|
|
|
389
504
|
<AppField
|
|
390
505
|
v-model="form.password"
|
|
391
506
|
aria-label="Password"
|
|
392
|
-
:placeholder="
|
|
507
|
+
:placeholder="
|
|
508
|
+
selectedUser && passwordSet
|
|
509
|
+
? 'Enter a new password'
|
|
510
|
+
: 'Enter password'
|
|
511
|
+
"
|
|
393
512
|
autocomplete="new-password"
|
|
394
513
|
:type="showPassword ? 'text' : 'password'"
|
|
395
514
|
>
|
|
@@ -400,12 +519,19 @@
|
|
|
400
519
|
:aria-label="showPassword ? 'Hide password' : 'Show password'"
|
|
401
520
|
@click="showPassword = !showPassword"
|
|
402
521
|
>
|
|
403
|
-
<v-icon
|
|
522
|
+
<v-icon
|
|
523
|
+
:icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
|
524
|
+
size="18"
|
|
525
|
+
/>
|
|
404
526
|
</button>
|
|
405
527
|
</template>
|
|
406
528
|
</AppField>
|
|
407
529
|
<p class="field-hint">
|
|
408
|
-
{{
|
|
530
|
+
{{
|
|
531
|
+
selectedUser && passwordSet
|
|
532
|
+
? "A password is set. Leave blank to keep it."
|
|
533
|
+
: "Used with the user ID on the reader keypad."
|
|
534
|
+
}}
|
|
409
535
|
</p>
|
|
410
536
|
<v-checkbox
|
|
411
537
|
v-if="selectedUser && passwordSet"
|
|
@@ -464,13 +590,19 @@
|
|
|
464
590
|
selectedUser ? getMappingStatus(selectedUser) : "N/A"
|
|
465
591
|
}}</strong>
|
|
466
592
|
<span>PIN</span
|
|
467
|
-
><strong>{{
|
|
593
|
+
><strong>{{
|
|
594
|
+
selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A"
|
|
595
|
+
}}</strong>
|
|
468
596
|
<span>Password</span
|
|
469
|
-
><strong>{{
|
|
597
|
+
><strong>{{
|
|
598
|
+
selectedUser?.metadata?.passwordSet ? "Configured" : "N/A"
|
|
599
|
+
}}</strong>
|
|
470
600
|
</div>
|
|
471
601
|
</v-card-text>
|
|
472
602
|
<v-card-actions class="screen-modal__footer">
|
|
473
|
-
<AppButton variant="ghost" @click="viewDialog = false"
|
|
603
|
+
<AppButton variant="ghost" @click="viewDialog = false"
|
|
604
|
+
>Close</AppButton
|
|
605
|
+
>
|
|
474
606
|
</v-card-actions>
|
|
475
607
|
</v-card>
|
|
476
608
|
</v-dialog>
|
|
@@ -486,7 +618,11 @@
|
|
|
486
618
|
|
|
487
619
|
<v-progress-linear v-if="loadingCards" indeterminate class="mb-3" />
|
|
488
620
|
<div v-else-if="physicalCards.length" class="hid-card-list mb-4">
|
|
489
|
-
<div
|
|
621
|
+
<div
|
|
622
|
+
v-for="card in physicalCards"
|
|
623
|
+
:key="card.id"
|
|
624
|
+
class="hid-card-row"
|
|
625
|
+
>
|
|
490
626
|
<v-icon icon="mdi-card-account-details-outline" size="22" />
|
|
491
627
|
<div class="hid-card-row__content">
|
|
492
628
|
<strong>{{ formatPhysicalCard(card) }}</strong>
|
|
@@ -500,7 +636,13 @@
|
|
|
500
636
|
/>
|
|
501
637
|
</div>
|
|
502
638
|
</div>
|
|
503
|
-
<v-alert
|
|
639
|
+
<v-alert
|
|
640
|
+
v-else
|
|
641
|
+
type="info"
|
|
642
|
+
variant="tonal"
|
|
643
|
+
density="compact"
|
|
644
|
+
class="mb-4"
|
|
645
|
+
>
|
|
504
646
|
No physical HID card is assigned to this user.
|
|
505
647
|
</v-alert>
|
|
506
648
|
|
|
@@ -514,7 +656,8 @@
|
|
|
514
656
|
{{ cardProfileLabel }}
|
|
515
657
|
</v-alert>
|
|
516
658
|
<p class="field-hint hid-card-type-hint">
|
|
517
|
-
PACS uses Facility Code + Card Number. CSN uses the raw serial
|
|
659
|
+
PACS uses Facility Code + Card Number. CSN uses the raw serial
|
|
660
|
+
number read from the physical card.
|
|
518
661
|
</p>
|
|
519
662
|
<template v-if="hybridCardProfile">
|
|
520
663
|
<div class="field-label mt-3">Presented Card Type</div>
|
|
@@ -525,11 +668,13 @@
|
|
|
525
668
|
class="mb-3"
|
|
526
669
|
/>
|
|
527
670
|
<p class="field-hint hid-card-type-hint">
|
|
528
|
-
Choose MIFARE CSN for a MIFARE card. Choose PACS for any other
|
|
671
|
+
Choose MIFARE CSN for a MIFARE card. Choose PACS for any other
|
|
672
|
+
card in this reader mode.
|
|
529
673
|
</p>
|
|
530
674
|
</template>
|
|
531
675
|
<p class="field-hint hid-card-access-hint">
|
|
532
|
-
The card uses this HID user's existing portal and access rules;
|
|
676
|
+
The card uses this HID user's existing portal and access rules;
|
|
677
|
+
assigning a card does not grant new access.
|
|
533
678
|
</p>
|
|
534
679
|
|
|
535
680
|
<div v-if="cardForm.cardType === 'pacs'" class="hid-card-fields">
|
|
@@ -566,7 +711,8 @@
|
|
|
566
711
|
density="compact"
|
|
567
712
|
class="mt-4"
|
|
568
713
|
>
|
|
569
|
-
Present the physical card to the selected Amico reader. The request
|
|
714
|
+
Present the physical card to the selected Amico reader. The request
|
|
715
|
+
will time out after 30 seconds.
|
|
570
716
|
</v-alert>
|
|
571
717
|
|
|
572
718
|
<!-- Reading the card at the reader is the primary way in: it is the
|
|
@@ -602,7 +748,11 @@
|
|
|
602
748
|
<v-card-actions class="screen-modal__footer">
|
|
603
749
|
<!-- Closing mid-read would leave the reader listening with nothing
|
|
604
750
|
to receive the card it reads. -->
|
|
605
|
-
<AppButton
|
|
751
|
+
<AppButton
|
|
752
|
+
variant="ghost"
|
|
753
|
+
:disabled="enrollingCard"
|
|
754
|
+
@click="cardDialog = false"
|
|
755
|
+
>
|
|
606
756
|
Close
|
|
607
757
|
</AppButton>
|
|
608
758
|
</v-card-actions>
|
|
@@ -646,6 +796,18 @@
|
|
|
646
796
|
</template>
|
|
647
797
|
|
|
648
798
|
<script setup lang="ts">
|
|
799
|
+
// Shared with the Access Permissions screen: `paginate` never echoes the page it
|
|
800
|
+
// was asked for, and `items` sits at the root on some of these endpoints and
|
|
801
|
+
// under `data` on others. This reads both, so the mistake is made once.
|
|
802
|
+
import {
|
|
803
|
+
HID_ENROLMENT_SUBJECTS,
|
|
804
|
+
hiddenSubjectNote,
|
|
805
|
+
memberCandidateOf,
|
|
806
|
+
memberHasAccount,
|
|
807
|
+
readMemberPage,
|
|
808
|
+
subjectCategoryLabel as labelForSubjectCategory,
|
|
809
|
+
} from "../utils/hid-enrolment-subject";
|
|
810
|
+
|
|
649
811
|
const props = defineProps({
|
|
650
812
|
site: {
|
|
651
813
|
type: String,
|
|
@@ -686,6 +848,8 @@ const {
|
|
|
686
848
|
updateIdentity,
|
|
687
849
|
deleteIdentity,
|
|
688
850
|
} = useHidAmico();
|
|
851
|
+
const { getAll: getAllMembers } = useMember();
|
|
852
|
+
const runtimeConfig = useRuntimeConfig();
|
|
689
853
|
|
|
690
854
|
type HidMetadata = Record<string, unknown> & {
|
|
691
855
|
name?: string;
|
|
@@ -799,8 +963,8 @@ const facialEnrolled = ref(false);
|
|
|
799
963
|
function hasFacialEnrollment(user?: HidUser | null) {
|
|
800
964
|
return Boolean(
|
|
801
965
|
user?.metadata?.facialEnrolled ||
|
|
802
|
-
|
|
803
|
-
|
|
966
|
+
user?.metadata?.facialData ||
|
|
967
|
+
user?.metadata?.imageTimestamp
|
|
804
968
|
);
|
|
805
969
|
}
|
|
806
970
|
|
|
@@ -858,6 +1022,20 @@ type UnitPerson = {
|
|
|
858
1022
|
user?: string | null;
|
|
859
1023
|
};
|
|
860
1024
|
|
|
1025
|
+
/**
|
|
1026
|
+
* THE SUBJECT TYPE THIS SCREEN WAS LAST USED FOR.
|
|
1027
|
+
*
|
|
1028
|
+
* Module scope, not component scope, and deliberately: the dialog is rebuilt on
|
|
1029
|
+
* every open and the component itself is unmounted when the operator moves
|
|
1030
|
+
* between HID screens, so a local ref would forget the choice immediately.
|
|
1031
|
+
* Enrolling ten guards in a row should not mean picking "Member" ten times.
|
|
1032
|
+
*
|
|
1033
|
+
* Not persisted. Surviving a hard refresh is a smaller benefit than surviving
|
|
1034
|
+
* the dialog, and `useHidReaderSelection` already set the precedent for where
|
|
1035
|
+
* that line sits.
|
|
1036
|
+
*/
|
|
1037
|
+
const REMEMBERED_SUBJECT_CATEGORY = ref<THidPermissionCategory>("resident");
|
|
1038
|
+
|
|
861
1039
|
const form = reactive({
|
|
862
1040
|
reader: "",
|
|
863
1041
|
block: "",
|
|
@@ -870,8 +1048,10 @@ const form = reactive({
|
|
|
870
1048
|
accessPin: "",
|
|
871
1049
|
password: "",
|
|
872
1050
|
photoPreview: "",
|
|
873
|
-
subjectCategory:
|
|
1051
|
+
subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value,
|
|
874
1052
|
subjectId: "",
|
|
1053
|
+
/** A mirror of the chosen member's role. Never sent; display only. */
|
|
1054
|
+
subjectRole: "",
|
|
875
1055
|
});
|
|
876
1056
|
|
|
877
1057
|
const route = useRoute();
|
|
@@ -917,13 +1097,17 @@ function formatBlockTitle(block?: OccupiedBlock | null) {
|
|
|
917
1097
|
: `Block ${block.block ?? ""}`.trim();
|
|
918
1098
|
}
|
|
919
1099
|
|
|
920
|
-
const blockOptions = computed(() =>
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1100
|
+
const blockOptions = computed(() =>
|
|
1101
|
+
occupiedBlocks.value.map((block) => ({
|
|
1102
|
+
title: formatBlockTitle(block),
|
|
1103
|
+
value: String(block._id ?? ""),
|
|
1104
|
+
}))
|
|
1105
|
+
);
|
|
924
1106
|
|
|
925
1107
|
const levelOptions = computed(() => {
|
|
926
|
-
const block = occupiedBlocks.value.find(
|
|
1108
|
+
const block = occupiedBlocks.value.find(
|
|
1109
|
+
(item) => String(item._id ?? "") === form.block
|
|
1110
|
+
);
|
|
927
1111
|
return (block?.levels ?? []).map((level) => ({
|
|
928
1112
|
title: String(level.name ?? ""),
|
|
929
1113
|
value: String(level._id ?? ""),
|
|
@@ -931,8 +1115,12 @@ const levelOptions = computed(() => {
|
|
|
931
1115
|
});
|
|
932
1116
|
|
|
933
1117
|
const unitOptions = computed(() => {
|
|
934
|
-
const block = occupiedBlocks.value.find(
|
|
935
|
-
|
|
1118
|
+
const block = occupiedBlocks.value.find(
|
|
1119
|
+
(item) => String(item._id ?? "") === form.block
|
|
1120
|
+
);
|
|
1121
|
+
const level = (block?.levels ?? []).find(
|
|
1122
|
+
(item) => String(item._id ?? "") === form.level
|
|
1123
|
+
);
|
|
936
1124
|
return (level?.units ?? []).map((unit) => ({
|
|
937
1125
|
title: String(unit.name ?? ""),
|
|
938
1126
|
value: String(unit._id ?? ""),
|
|
@@ -944,30 +1132,267 @@ const unitOptions = computed(() => {
|
|
|
944
1132
|
// this site." at the very end of enrollment, after a user had already been
|
|
945
1133
|
// written to the reader.
|
|
946
1134
|
const enrollableUnitPeople = computed(() =>
|
|
947
|
-
unitPeople.value.filter((person) => Boolean(person.user))
|
|
1135
|
+
unitPeople.value.filter((person) => Boolean(person.user))
|
|
948
1136
|
);
|
|
949
1137
|
|
|
950
1138
|
const unenrollableUnitPeopleCount = computed(
|
|
951
|
-
() => unitPeople.value.length - enrollableUnitPeople.value.length
|
|
1139
|
+
() => unitPeople.value.length - enrollableUnitPeople.value.length
|
|
952
1140
|
);
|
|
953
1141
|
|
|
954
1142
|
// `_id` here is `site.people._id`, which is exactly the `person` that
|
|
955
1143
|
// `createIdentity` maps a HID user to.
|
|
956
|
-
const subjectOptions = computed(() =>
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1144
|
+
const subjectOptions = computed(() =>
|
|
1145
|
+
enrollableUnitPeople.value.map((person) => ({
|
|
1146
|
+
title: String(person.name ?? "Resident"),
|
|
1147
|
+
value: String(person._id ?? ""),
|
|
1148
|
+
}))
|
|
1149
|
+
);
|
|
960
1150
|
|
|
961
1151
|
// Says why somebody the operator can see in People Management is missing here.
|
|
962
1152
|
// Without it the unit just looks empty and the screen looks broken.
|
|
963
1153
|
const unitResidentNote = computed(() => {
|
|
964
|
-
if (
|
|
1154
|
+
if (
|
|
1155
|
+
!form.unit ||
|
|
1156
|
+
loadingUnitPeople.value ||
|
|
1157
|
+
!unenrollableUnitPeopleCount.value
|
|
1158
|
+
)
|
|
1159
|
+
return "";
|
|
965
1160
|
const count = unenrollableUnitPeopleCount.value;
|
|
966
1161
|
const subject = count === 1 ? "1 resident" : `${count} residents`;
|
|
967
1162
|
const verb = count === 1 ? "is" : "are";
|
|
968
|
-
return `${subject} in this unit ${verb} not listed: HID enrollment needs a resident app account, and ${
|
|
1163
|
+
return `${subject} in this unit ${verb} not listed: HID enrollment needs a resident app account, and ${
|
|
1164
|
+
count === 1 ? "that person has" : "they have"
|
|
1165
|
+
} none yet.`;
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
/* ── THE SUBJECT TYPE ──────────────────────────────────────────────────── */
|
|
1169
|
+
|
|
1170
|
+
const subjectCategoryOptions = HID_ENROLMENT_SUBJECTS;
|
|
1171
|
+
|
|
1172
|
+
const isResidentSubject = computed(() => form.subjectCategory === "resident");
|
|
1173
|
+
|
|
1174
|
+
/* What an EDIT shows in place of the control, since the type cannot change.
|
|
1175
|
+
Looked up in the util, not in the switch's own options: records carry
|
|
1176
|
+
`service_provider` too, and the options list would have called those
|
|
1177
|
+
"Resident". */
|
|
1178
|
+
const subjectCategoryLabel = computed(() =>
|
|
1179
|
+
labelForSubjectCategory(form.subjectCategory)
|
|
1180
|
+
);
|
|
1181
|
+
|
|
1182
|
+
/* ── STAFF CANDIDATES ─────────────────────────────────────────────────────
|
|
1183
|
+
*
|
|
1184
|
+
* Read from `GET /api/members` — the SAME endpoint, with the same filters, that
|
|
1185
|
+
* the Members menu reads. That is deliberate and was the fix for a real bug: the
|
|
1186
|
+
* first version used the HID permission-candidate endpoint, which applies NO
|
|
1187
|
+
* `type` filter, so it offered every active members row at the site including
|
|
1188
|
+
* service providers and residents. A picker that disagrees with the Members menu
|
|
1189
|
+
* is a picker the operator cannot trust.
|
|
1190
|
+
*
|
|
1191
|
+
* `type` is the running app, the same way `HidQrCodeConfiguration` reads it, so
|
|
1192
|
+
* the property app lists its own staff and the security app lists its own. The
|
|
1193
|
+
* members endpoint expands legacy spellings of a type itself (`appTypeQuery`).
|
|
1194
|
+
*
|
|
1195
|
+
* Service providers get their own option next; they are not silently folded in
|
|
1196
|
+
* here.
|
|
1197
|
+
*/
|
|
1198
|
+
/*
|
|
1199
|
+
* TEN, to share the Members menu's cache key.
|
|
1200
|
+
*
|
|
1201
|
+
* `member.repo.ts` caches each page under a key that INCLUDES `limit`, so a
|
|
1202
|
+
* different limit is a different cache entry with its own 15-minute life. This
|
|
1203
|
+
* picker asked for 50 and got its own rarely-touched slot, which went stale and
|
|
1204
|
+
* showed six members where the Members menu showed nine - the same query, the
|
|
1205
|
+
* same instant, two answers. Asking for the menu's own page size means the two
|
|
1206
|
+
* read the same entry and cannot disagree.
|
|
1207
|
+
*/
|
|
1208
|
+
const MEMBER_PAGE_LIMIT = 10;
|
|
1209
|
+
|
|
1210
|
+
type MemberCandidate = { subjectId: string; name: string; subtitle: string };
|
|
1211
|
+
|
|
1212
|
+
const memberCandidates = ref<MemberCandidate[]>([]);
|
|
1213
|
+
const loadingMembers = ref(false);
|
|
1214
|
+
const loadingMoreMembers = ref(false);
|
|
1215
|
+
const membersLoaded = ref(false);
|
|
1216
|
+
/** The last page successfully loaded; 0 before the first. */
|
|
1217
|
+
const memberPage = ref(0);
|
|
1218
|
+
/** Rows fetched so far, INCLUDING those hidden for having no account. */
|
|
1219
|
+
const memberRowsLoaded = ref(0);
|
|
1220
|
+
/** What the server says matches the query, across every page. */
|
|
1221
|
+
const memberTotalRows = ref(0);
|
|
1222
|
+
/* Active staff dropped for having no app account. Counted here rather than
|
|
1223
|
+
* asked of the server, because this endpoint returns the `user` field and so
|
|
1224
|
+
* the answer is already in hand - see `hiddenMemberNote`. */
|
|
1225
|
+
const hiddenMemberCount = ref(0);
|
|
1226
|
+
|
|
1227
|
+
const hasMoreMembers = computed(
|
|
1228
|
+
() => memberRowsLoaded.value < memberTotalRows.value
|
|
1229
|
+
);
|
|
1230
|
+
|
|
1231
|
+
/*
|
|
1232
|
+
* Bumped on every request, so a response that is no longer the current one is
|
|
1233
|
+
* dropped. Scroll-loading makes this matter: a page still in flight when the
|
|
1234
|
+
* dialog reopens, or when the type is switched away and back, would otherwise
|
|
1235
|
+
* append onto a list that has since been replaced. Same guard, and the same
|
|
1236
|
+
* reason, as `cardRequestSeq` in `EntryPassInformation.vue`.
|
|
1237
|
+
*/
|
|
1238
|
+
let memberRequestSeq = 0;
|
|
1239
|
+
|
|
1240
|
+
const memberOptions = computed(() =>
|
|
1241
|
+
memberCandidates.value.map((candidate) => ({
|
|
1242
|
+
// Role in the title as well as the mirror below: two people can share a name,
|
|
1243
|
+
// and the popover is where they have to be told apart.
|
|
1244
|
+
title: candidate.subtitle
|
|
1245
|
+
? `${candidate.name} — ${candidate.subtitle}`
|
|
1246
|
+
: candidate.name,
|
|
1247
|
+
value: candidate.subjectId,
|
|
1248
|
+
}))
|
|
1249
|
+
);
|
|
1250
|
+
|
|
1251
|
+
const memberPlaceholder = computed(() => {
|
|
1252
|
+
if (loadingMembers.value) return "Loading staff...";
|
|
1253
|
+
if (!membersLoaded.value) return "Select a member";
|
|
1254
|
+
if (memberOptions.value.length) return "Select a member";
|
|
1255
|
+
return hiddenMemberCount.value
|
|
1256
|
+
? "No staff here can be enrolled yet"
|
|
1257
|
+
: "No staff at this site";
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
/* The same courtesy the resident cascade pays: say why somebody the operator
|
|
1261
|
+
* can see in the Members menu is not in this list. */
|
|
1262
|
+
const hiddenMemberNote = computed(() => {
|
|
1263
|
+
// Only once everything is loaded. Half a list gives half a count, and
|
|
1264
|
+
// "3 members at this site" would then be a number about this PAGE wearing the
|
|
1265
|
+
// word "site" - the range note below carries the partial truth instead.
|
|
1266
|
+
if (loadingMembers.value || hasMoreMembers.value) return "";
|
|
1267
|
+
return hiddenSubjectNote(hiddenMemberCount.value, "member");
|
|
969
1268
|
});
|
|
970
1269
|
|
|
1270
|
+
/* What is loaded against what exists - and the warning that the picker's own
|
|
1271
|
+
search only ever sees what has been fetched. */
|
|
1272
|
+
const memberRangeNote = computed(() =>
|
|
1273
|
+
hasMoreMembers.value
|
|
1274
|
+
? `Showing ${memberRowsLoaded.value} of ${memberTotalRows.value}. Search looks only at these.`
|
|
1275
|
+
: ""
|
|
1276
|
+
);
|
|
1277
|
+
|
|
1278
|
+
/** Back to "nothing loaded", so the next open starts from page one. */
|
|
1279
|
+
function resetMemberCandidates() {
|
|
1280
|
+
// Anything still in flight belongs to the list being thrown away.
|
|
1281
|
+
memberRequestSeq += 1;
|
|
1282
|
+
memberCandidates.value = [];
|
|
1283
|
+
hiddenMemberCount.value = 0;
|
|
1284
|
+
memberPage.value = 0;
|
|
1285
|
+
memberRowsLoaded.value = 0;
|
|
1286
|
+
memberTotalRows.value = 0;
|
|
1287
|
+
membersLoaded.value = false;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* One page of this site's active staff.
|
|
1292
|
+
*
|
|
1293
|
+
* Paged rather than fetched whole: a page of ten is what the Members menu asks
|
|
1294
|
+
* for, and sharing that page size shares its cache entry. `append` is what
|
|
1295
|
+
* separates the first load from Load more - the first replaces, the rest add.
|
|
1296
|
+
*
|
|
1297
|
+
* NEVER THROWS. The list is left as it is and the placeholder says so, because
|
|
1298
|
+
* choosing "Member" must not break the dialog.
|
|
1299
|
+
*/
|
|
1300
|
+
async function fetchMemberPage(page: number, append: boolean) {
|
|
1301
|
+
const memberType = String(runtimeConfig.public.APP ?? "").trim();
|
|
1302
|
+
// "App" is the nuxt config's own placeholder default. Without a real app there
|
|
1303
|
+
// is no member type to ask for, and a typeless query returns nothing anyway.
|
|
1304
|
+
if (!props.site || !props.org || !memberType || memberType === "App") {
|
|
1305
|
+
resetMemberCandidates();
|
|
1306
|
+
membersLoaded.value = true;
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const seq = (memberRequestSeq += 1);
|
|
1311
|
+
const busy = append ? loadingMoreMembers : loadingMembers;
|
|
1312
|
+
busy.value = true;
|
|
1313
|
+
try {
|
|
1314
|
+
const response = await getAllMembers({
|
|
1315
|
+
org: props.org,
|
|
1316
|
+
siteId: props.site,
|
|
1317
|
+
type: memberType,
|
|
1318
|
+
status: "active",
|
|
1319
|
+
page,
|
|
1320
|
+
limit: MEMBER_PAGE_LIMIT,
|
|
1321
|
+
});
|
|
1322
|
+
if (seq !== memberRequestSeq) return;
|
|
1323
|
+
const parsed = readMemberPage(response);
|
|
1324
|
+
const enrollable = parsed.items.filter(memberHasAccount);
|
|
1325
|
+
const candidates = enrollable.map(memberCandidateOf);
|
|
1326
|
+
|
|
1327
|
+
memberCandidates.value = append
|
|
1328
|
+
? [...memberCandidates.value, ...candidates]
|
|
1329
|
+
: candidates;
|
|
1330
|
+
const hiddenOnPage = parsed.items.length - enrollable.length;
|
|
1331
|
+
hiddenMemberCount.value = append
|
|
1332
|
+
? hiddenMemberCount.value + hiddenOnPage
|
|
1333
|
+
: hiddenOnPage;
|
|
1334
|
+
memberRowsLoaded.value = append
|
|
1335
|
+
? memberRowsLoaded.value + parsed.items.length
|
|
1336
|
+
: parsed.items.length;
|
|
1337
|
+
memberTotalRows.value = parsed.total;
|
|
1338
|
+
memberPage.value = page;
|
|
1339
|
+
membersLoaded.value = true;
|
|
1340
|
+
} catch (error: unknown) {
|
|
1341
|
+
if (seq !== memberRequestSeq) return;
|
|
1342
|
+
console.error("Unable to load staff for HID enrollment:", error);
|
|
1343
|
+
// An "append" that failed keeps what is already on screen; a first load has
|
|
1344
|
+
// nothing to keep. Either way the dialog stays usable.
|
|
1345
|
+
if (!append) resetMemberCandidates();
|
|
1346
|
+
membersLoaded.value = true;
|
|
1347
|
+
} finally {
|
|
1348
|
+
// Always its OWN flag, superseded or not: a request that leaves its spinner
|
|
1349
|
+
// set strands the list behind a wait that will never end.
|
|
1350
|
+
busy.value = false;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** The first page, replacing whatever was there. */
|
|
1355
|
+
function loadMemberCandidates() {
|
|
1356
|
+
return fetchMemberPage(1, false);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/** The next page, added to the list. */
|
|
1360
|
+
function loadMoreMembers() {
|
|
1361
|
+
if (loadingMembers.value || loadingMoreMembers.value || !hasMoreMembers.value)
|
|
1362
|
+
return;
|
|
1363
|
+
return fetchMemberPage(memberPage.value + 1, true);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* Switching type clears the chosen subject, and nothing else.
|
|
1368
|
+
*
|
|
1369
|
+
* The id spaces do not overlap - a `site.people._id` submitted as a `member`
|
|
1370
|
+
* would be refused by the server, and worse, a stale one could name a real
|
|
1371
|
+
* member - so it is cleared rather than carried. Name and Role mirror the
|
|
1372
|
+
* choice, so they clear with it.
|
|
1373
|
+
*/
|
|
1374
|
+
async function onSubjectCategoryChanged() {
|
|
1375
|
+
REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory;
|
|
1376
|
+
form.subjectId = "";
|
|
1377
|
+
form.subjectRole = "";
|
|
1378
|
+
form.name = "";
|
|
1379
|
+
residentConflict.value = null;
|
|
1380
|
+
|
|
1381
|
+
if (!isResidentSubject.value && !membersLoaded.value) {
|
|
1382
|
+
await loadMemberCandidates();
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/* The staff equivalent of `onResidentChanged`. */
|
|
1387
|
+
async function onMemberChanged() {
|
|
1388
|
+
const candidate = memberCandidates.value.find(
|
|
1389
|
+
(item) => String(item.subjectId) === String(form.subjectId)
|
|
1390
|
+
);
|
|
1391
|
+
form.name = String(candidate?.name ?? "");
|
|
1392
|
+
form.subjectRole = String(candidate?.subtitle ?? "");
|
|
1393
|
+
await checkSubjectEnrollment();
|
|
1394
|
+
}
|
|
1395
|
+
|
|
971
1396
|
const blockPlaceholder = computed(() => {
|
|
972
1397
|
if (loadingOccupancy.value) return "Loading...";
|
|
973
1398
|
if (occupancyLoaded.value && !blockOptions.value.length) {
|
|
@@ -1009,23 +1434,37 @@ const cardForm = reactive({
|
|
|
1009
1434
|
});
|
|
1010
1435
|
|
|
1011
1436
|
const cardReader = computed(() => {
|
|
1012
|
-
const readerId = String(
|
|
1013
|
-
|
|
1437
|
+
const readerId = String(
|
|
1438
|
+
cardUser.value?.reader || selectedReaderId.value || ""
|
|
1439
|
+
);
|
|
1440
|
+
return (
|
|
1441
|
+
readers.value.find((reader) => String(reader._id) === readerId) || null
|
|
1442
|
+
);
|
|
1014
1443
|
});
|
|
1015
|
-
const cardProfileConfigured = computed(() =>
|
|
1016
|
-
|
|
1017
|
-
)
|
|
1018
|
-
|
|
1444
|
+
const cardProfileConfigured = computed(() =>
|
|
1445
|
+
["pacs", "csn", "csn_for_mifare"].includes(
|
|
1446
|
+
String(cardReader.value?.cardReadingMode || "")
|
|
1447
|
+
)
|
|
1448
|
+
);
|
|
1449
|
+
const hybridCardProfile = computed(
|
|
1450
|
+
() => cardReader.value?.cardReadingMode === "csn_for_mifare"
|
|
1451
|
+
);
|
|
1019
1452
|
const hybridCardTypeOptions = [
|
|
1020
1453
|
{ title: "MIFARE card (CSN)", value: "csn" },
|
|
1021
1454
|
{ title: "Other card (PACS)", value: "pacs" },
|
|
1022
1455
|
];
|
|
1023
1456
|
const cardProfileLabel = computed(() => {
|
|
1024
1457
|
const mode = String(cardReader.value?.cardReadingMode || "unconfigured");
|
|
1025
|
-
if (mode === "pacs")
|
|
1026
|
-
|
|
1458
|
+
if (mode === "pacs")
|
|
1459
|
+
return `PACS · ${(cardReader.value?.pacsFormat || "raw").toUpperCase()}`;
|
|
1460
|
+
if (mode === "csn")
|
|
1461
|
+
return `CSN · ${(
|
|
1462
|
+
cardReader.value?.csnByteOrder || "msb"
|
|
1463
|
+
).toUpperCase()} first`;
|
|
1027
1464
|
if (mode === "csn_for_mifare") {
|
|
1028
|
-
return `PACS for non-MIFARE cards · CSN for MIFARE (${(
|
|
1465
|
+
return `PACS for non-MIFARE cards · CSN for MIFARE (${(
|
|
1466
|
+
cardReader.value?.csnByteOrder || "msb"
|
|
1467
|
+
).toUpperCase()} first)`;
|
|
1029
1468
|
}
|
|
1030
1469
|
return "Configure the card data mode on the HID Reader screen before assigning a card.";
|
|
1031
1470
|
});
|
|
@@ -1055,8 +1494,12 @@ const cardHeaders = [
|
|
|
1055
1494
|
{ title: "", value: "action-table", sortable: false },
|
|
1056
1495
|
];
|
|
1057
1496
|
|
|
1058
|
-
const tableTitle = computed(() =>
|
|
1059
|
-
|
|
1497
|
+
const tableTitle = computed(() =>
|
|
1498
|
+
props.cardManagement ? "HID Cards" : "HID Users"
|
|
1499
|
+
);
|
|
1500
|
+
const tableHeaders = computed(() =>
|
|
1501
|
+
props.cardManagement ? cardHeaders : userHeaders
|
|
1502
|
+
);
|
|
1060
1503
|
|
|
1061
1504
|
/**
|
|
1062
1505
|
* The list is paged on the SERVER, so the shared pager only moves the cursor -
|
|
@@ -1101,12 +1544,17 @@ onBeforeUnmount(() => clearTimeout(searchDebounce));
|
|
|
1101
1544
|
|
|
1102
1545
|
const readerOptions = computed(() =>
|
|
1103
1546
|
readers.value.map((reader) => ({
|
|
1104
|
-
title: `${reader.name || reader.deviceId || reader._id} — ${
|
|
1547
|
+
title: `${reader.name || reader.deviceId || reader._id} — ${
|
|
1548
|
+
reader.portalName || "Portal not configured"
|
|
1549
|
+
}`,
|
|
1105
1550
|
value: reader._id,
|
|
1106
1551
|
}))
|
|
1107
1552
|
);
|
|
1108
|
-
const selectedReader = computed(
|
|
1109
|
-
|
|
1553
|
+
const selectedReader = computed(
|
|
1554
|
+
() =>
|
|
1555
|
+
readers.value.find(
|
|
1556
|
+
(reader) => reader._id === (selectedReaderId.value || form.reader)
|
|
1557
|
+
) || null
|
|
1110
1558
|
);
|
|
1111
1559
|
|
|
1112
1560
|
const pageRange = computed(() => {
|
|
@@ -1133,7 +1581,8 @@ async function loadReaders() {
|
|
|
1133
1581
|
const response = await getReaders({ site: props.site, page: 1, limit: 100 });
|
|
1134
1582
|
const responseRecord = toRecord(response);
|
|
1135
1583
|
const responseData = toRecord(responseRecord.data);
|
|
1136
|
-
const items =
|
|
1584
|
+
const items =
|
|
1585
|
+
responseRecord.items ?? responseData.items ?? responseData.readers ?? [];
|
|
1137
1586
|
readers.value = (Array.isArray(items) ? items : [])
|
|
1138
1587
|
.map(toHidReader)
|
|
1139
1588
|
.filter((reader): reader is HidReader => reader !== null);
|
|
@@ -1202,7 +1651,9 @@ async function loadUsers() {
|
|
|
1202
1651
|
const responseRecord = toRecord(response);
|
|
1203
1652
|
const responseData = toRecord(responseRecord.data);
|
|
1204
1653
|
const responseItems = responseRecord.items ?? responseData.items ?? [];
|
|
1205
|
-
const allItems = (Array.isArray(responseItems) ? responseItems : []).map(
|
|
1654
|
+
const allItems = (Array.isArray(responseItems) ? responseItems : []).map(
|
|
1655
|
+
toHidUser
|
|
1656
|
+
);
|
|
1206
1657
|
const matchingItems = clientFiltersVisitors
|
|
1207
1658
|
? allItems.filter((user) => !isVisitorUser(user))
|
|
1208
1659
|
: allItems;
|
|
@@ -1215,12 +1666,16 @@ async function loadUsers() {
|
|
|
1215
1666
|
pageItems
|
|
1216
1667
|
.filter((user) => user.isAdministrator === true)
|
|
1217
1668
|
.map((user) => toHidNumericId(user.hidUserId))
|
|
1218
|
-
.filter((userId: number | undefined): userId is number =>
|
|
1669
|
+
.filter((userId: number | undefined): userId is number =>
|
|
1670
|
+
Boolean(userId)
|
|
1671
|
+
)
|
|
1219
1672
|
);
|
|
1220
1673
|
users.value = pageItems;
|
|
1221
1674
|
total.value = clientFiltersVisitors
|
|
1222
1675
|
? matchingItems.length
|
|
1223
|
-
: Number(
|
|
1676
|
+
: Number(
|
|
1677
|
+
responseRecord.total ?? responseData.total ?? users.value.length
|
|
1678
|
+
);
|
|
1224
1679
|
pages.value = clientFiltersVisitors
|
|
1225
1680
|
? Math.max(1, Math.ceil(total.value / limit.value))
|
|
1226
1681
|
: Number(responseRecord.pages ?? responseData.pages ?? 1);
|
|
@@ -1250,7 +1705,6 @@ async function loadUsers() {
|
|
|
1250
1705
|
}
|
|
1251
1706
|
}
|
|
1252
1707
|
|
|
1253
|
-
|
|
1254
1708
|
function resetForm() {
|
|
1255
1709
|
form.reader = selectedReaderId.value || readers.value[0]?._id || "";
|
|
1256
1710
|
form.block = "";
|
|
@@ -1272,19 +1726,25 @@ function resetForm() {
|
|
|
1272
1726
|
selectedPhotoFile.value = null;
|
|
1273
1727
|
removePhotoRequested.value = false;
|
|
1274
1728
|
facialEnrolled.value = false;
|
|
1275
|
-
|
|
1729
|
+
// The type the operator last enrolled, not a hard "resident": somebody adding
|
|
1730
|
+
// a shift of guards should not re-pick Member for each one.
|
|
1731
|
+
form.subjectCategory = REMEMBERED_SUBJECT_CATEGORY.value;
|
|
1276
1732
|
form.subjectId = "";
|
|
1733
|
+
form.subjectRole = "";
|
|
1277
1734
|
residentConflict.value = null;
|
|
1278
1735
|
checkingResident.value = false;
|
|
1279
1736
|
}
|
|
1280
1737
|
|
|
1281
|
-
|
|
1282
1738
|
/** Surface what the API actually said - a bare "unable to load" hides the
|
|
1283
1739
|
* difference between a permission refusal, a bad id and a server fault. */
|
|
1284
1740
|
function apiReason(error: unknown) {
|
|
1285
1741
|
const record = toRecord(error);
|
|
1286
1742
|
const data = toRecord(record.data);
|
|
1287
|
-
return
|
|
1743
|
+
return (
|
|
1744
|
+
toText(data.message) ||
|
|
1745
|
+
toText(record.message) ||
|
|
1746
|
+
toText(record.statusMessage)
|
|
1747
|
+
);
|
|
1288
1748
|
}
|
|
1289
1749
|
|
|
1290
1750
|
// Called when the Block select is first touched, not when the dialog opens.
|
|
@@ -1292,21 +1752,27 @@ async function ensureOccupancy() {
|
|
|
1292
1752
|
if (!props.site || occupancyLoaded.value || loadingOccupancy.value) return;
|
|
1293
1753
|
loadingOccupancy.value = true;
|
|
1294
1754
|
try {
|
|
1295
|
-
const response = toRecord(
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1755
|
+
const response = toRecord(
|
|
1756
|
+
await getOccupiedStructure(props.site, {
|
|
1757
|
+
type: OCCUPANT_TYPES,
|
|
1758
|
+
status: "active",
|
|
1759
|
+
})
|
|
1760
|
+
);
|
|
1299
1761
|
const data = toRecord(response.data);
|
|
1300
1762
|
const blocks = response.blocks ?? data.blocks ?? [];
|
|
1301
|
-
occupiedBlocks.value = (
|
|
1763
|
+
occupiedBlocks.value = (
|
|
1764
|
+
Array.isArray(blocks) ? blocks : []
|
|
1765
|
+
) as OccupiedBlock[];
|
|
1302
1766
|
occupancyLoaded.value = true;
|
|
1303
1767
|
} catch (error: unknown) {
|
|
1304
1768
|
console.error("Unable to load occupied units:", error);
|
|
1305
1769
|
occupiedBlocks.value = [];
|
|
1306
1770
|
const reason = apiReason(error);
|
|
1307
1771
|
showToast(
|
|
1308
|
-
reason
|
|
1309
|
-
|
|
1772
|
+
reason
|
|
1773
|
+
? `Unable to load units: ${reason}`
|
|
1774
|
+
: "Unable to load units for this site.",
|
|
1775
|
+
"error"
|
|
1310
1776
|
);
|
|
1311
1777
|
} finally {
|
|
1312
1778
|
loadingOccupancy.value = false;
|
|
@@ -1331,8 +1797,10 @@ async function loadUnitPeople() {
|
|
|
1331
1797
|
console.error("Unable to load residents for the unit:", error);
|
|
1332
1798
|
const reason = apiReason(error);
|
|
1333
1799
|
showToast(
|
|
1334
|
-
reason
|
|
1335
|
-
|
|
1800
|
+
reason
|
|
1801
|
+
? `Unable to load residents: ${reason}`
|
|
1802
|
+
: "Unable to load residents for this unit.",
|
|
1803
|
+
"error"
|
|
1336
1804
|
);
|
|
1337
1805
|
} finally {
|
|
1338
1806
|
loadingUnitPeople.value = false;
|
|
@@ -1372,10 +1840,10 @@ function clearResidentSelection() {
|
|
|
1372
1840
|
// The auto-fill the whole cascade exists for.
|
|
1373
1841
|
async function onResidentChanged() {
|
|
1374
1842
|
const person = unitPeople.value.find(
|
|
1375
|
-
(item) => String(item._id) === String(form.subjectId)
|
|
1843
|
+
(item) => String(item._id) === String(form.subjectId)
|
|
1376
1844
|
);
|
|
1377
1845
|
form.name = String(person?.name ?? "");
|
|
1378
|
-
await
|
|
1846
|
+
await checkSubjectEnrollment();
|
|
1379
1847
|
}
|
|
1380
1848
|
|
|
1381
1849
|
/**
|
|
@@ -1403,11 +1871,15 @@ async function findResidentIdentity(readerId: string, personId: string) {
|
|
|
1403
1871
|
const responseData = toRecord(responseRecord.data);
|
|
1404
1872
|
const responseItems =
|
|
1405
1873
|
responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
|
|
1406
|
-
const identities = (Array.isArray(responseItems) ? responseItems : []).map(
|
|
1874
|
+
const identities = (Array.isArray(responseItems) ? responseItems : []).map(
|
|
1875
|
+
toHidUser
|
|
1876
|
+
);
|
|
1407
1877
|
const editingId = String(selectedUser.value?._id ?? "");
|
|
1408
1878
|
|
|
1409
1879
|
return (
|
|
1410
|
-
identities.find(
|
|
1880
|
+
identities.find(
|
|
1881
|
+
(identity) => !editingId || String(identity._id ?? "") !== editingId
|
|
1882
|
+
) ?? null
|
|
1411
1883
|
);
|
|
1412
1884
|
}
|
|
1413
1885
|
|
|
@@ -1418,17 +1890,33 @@ async function findResidentIdentity(readerId: string, personId: string) {
|
|
|
1418
1890
|
* the server refuses the duplicate anyway. This is a courtesy check that keeps
|
|
1419
1891
|
* a doomed enrollment off the physical reader, not the guard itself.
|
|
1420
1892
|
*/
|
|
1421
|
-
|
|
1893
|
+
/**
|
|
1894
|
+
* Is this subject already enrolled on this reader?
|
|
1895
|
+
*
|
|
1896
|
+
* Category-agnostic, since staff can be enrolled too. `findResidentIdentity`
|
|
1897
|
+
* queries the server's `subject` parameter, which is matched against every link
|
|
1898
|
+
* field - `person`, `member`, `serviceProvider`, `user` - so a member needs no
|
|
1899
|
+
* separate lookup. The server refuses a duplicate either way; this only puts the
|
|
1900
|
+
* refusal in front of the operator before the device is touched.
|
|
1901
|
+
*/
|
|
1902
|
+
async function checkSubjectEnrollment() {
|
|
1422
1903
|
residentConflict.value = null;
|
|
1423
|
-
const readerId =
|
|
1424
|
-
|
|
1904
|
+
const readerId =
|
|
1905
|
+
selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
1906
|
+
if (!readerId || !form.subjectId) return null;
|
|
1425
1907
|
|
|
1426
1908
|
checkingResident.value = true;
|
|
1427
1909
|
try {
|
|
1428
|
-
residentConflict.value = await findResidentIdentity(
|
|
1910
|
+
residentConflict.value = await findResidentIdentity(
|
|
1911
|
+
readerId,
|
|
1912
|
+
form.subjectId
|
|
1913
|
+
);
|
|
1429
1914
|
return residentConflict.value;
|
|
1430
1915
|
} catch (error: unknown) {
|
|
1431
|
-
console.error(
|
|
1916
|
+
console.error(
|
|
1917
|
+
"Unable to check whether this resident is already enrolled:",
|
|
1918
|
+
error
|
|
1919
|
+
);
|
|
1432
1920
|
return null;
|
|
1433
1921
|
} finally {
|
|
1434
1922
|
checkingResident.value = false;
|
|
@@ -1445,6 +1933,13 @@ async function openEnroll() {
|
|
|
1445
1933
|
form.hidUserId = hidUserId;
|
|
1446
1934
|
form.registration = registration;
|
|
1447
1935
|
formDialog.value = true;
|
|
1936
|
+
|
|
1937
|
+
// `resetForm` restores the remembered type, so the dialog can open straight
|
|
1938
|
+
// onto Member with an empty list. Loaded after the dialog is up, not before:
|
|
1939
|
+
// the request must not delay it, and the picker carries its own loading
|
|
1940
|
+
// placeholder. Fresh each opening, because staff change between them.
|
|
1941
|
+
resetMemberCandidates();
|
|
1942
|
+
if (!isResidentSubject.value) await loadMemberCandidates();
|
|
1448
1943
|
}
|
|
1449
1944
|
|
|
1450
1945
|
async function openEdit(user: HidUser) {
|
|
@@ -1469,9 +1964,11 @@ async function openEdit(user: HidUser) {
|
|
|
1469
1964
|
form.subjectCategory = user.person
|
|
1470
1965
|
? "resident"
|
|
1471
1966
|
: user.serviceProvider
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
form.subjectId = String(
|
|
1967
|
+
? "service_provider"
|
|
1968
|
+
: "property_management";
|
|
1969
|
+
form.subjectId = String(
|
|
1970
|
+
user.person || user.member || user.serviceProvider || ""
|
|
1971
|
+
);
|
|
1475
1972
|
// The resident this user already holds is not a conflict with itself. A real
|
|
1476
1973
|
// one only appears if the operator reassigns the row to somebody else.
|
|
1477
1974
|
residentConflict.value = null;
|
|
@@ -1500,7 +1997,6 @@ async function openEdit(user: HidUser) {
|
|
|
1500
1997
|
} catch {
|
|
1501
1998
|
// Preserve the last known status if the reader is temporarily offline.
|
|
1502
1999
|
}
|
|
1503
|
-
|
|
1504
2000
|
}
|
|
1505
2001
|
|
|
1506
2002
|
function openView(user: HidUser) {
|
|
@@ -1583,24 +2079,35 @@ async function saveUser() {
|
|
|
1583
2079
|
const missing = !readerId
|
|
1584
2080
|
? "a reader"
|
|
1585
2081
|
: !selectedUser.value && !form.subjectId
|
|
2082
|
+
? isResidentSubject.value
|
|
1586
2083
|
? "a resident"
|
|
1587
|
-
:
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
2084
|
+
: "a member"
|
|
2085
|
+
: !form.name
|
|
2086
|
+
? "a name"
|
|
2087
|
+
: !form.hidUserId
|
|
2088
|
+
? "an ID"
|
|
2089
|
+
: !form.registration
|
|
2090
|
+
? "a registration number"
|
|
2091
|
+
: "";
|
|
1594
2092
|
if (missing) {
|
|
1595
|
-
showToast(
|
|
2093
|
+
showToast(
|
|
2094
|
+
`This HID user needs ${missing} before it can be saved.`,
|
|
2095
|
+
"error"
|
|
2096
|
+
);
|
|
1596
2097
|
return;
|
|
1597
2098
|
}
|
|
1598
2099
|
if (form.accessPin && !/^\d{1,32}$/.test(form.accessPin)) {
|
|
1599
2100
|
showToast("PIN must contain numbers only.", "error");
|
|
1600
2101
|
return;
|
|
1601
2102
|
}
|
|
1602
|
-
if (
|
|
1603
|
-
|
|
2103
|
+
if (
|
|
2104
|
+
selectedPhotoFile.value &&
|
|
2105
|
+
(!selectedReader.value?.portalId || !selectedReader.value?.portalName)
|
|
2106
|
+
) {
|
|
2107
|
+
showToast(
|
|
2108
|
+
"Reconnect this HID reader and select a portal before enrolling facial recognition.",
|
|
2109
|
+
"error"
|
|
2110
|
+
);
|
|
1604
2111
|
return;
|
|
1605
2112
|
}
|
|
1606
2113
|
|
|
@@ -1608,7 +2115,7 @@ async function saveUser() {
|
|
|
1608
2115
|
// somebody else enrolls the same resident, and the reader is only ever
|
|
1609
2116
|
// written to below this line. A duplicate caught here costs one request; the
|
|
1610
2117
|
// same duplicate caught by the server costs a created HID user and a rollback.
|
|
1611
|
-
const alreadyEnrolled = await
|
|
2118
|
+
const alreadyEnrolled = await checkSubjectEnrollment();
|
|
1612
2119
|
if (alreadyEnrolled) {
|
|
1613
2120
|
showToast(residentConflictMessage.value, "error");
|
|
1614
2121
|
return;
|
|
@@ -1634,20 +2141,24 @@ async function saveUser() {
|
|
|
1634
2141
|
// subject the record already has.
|
|
1635
2142
|
const subjectLink = form.subjectId
|
|
1636
2143
|
? {
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
:
|
|
2144
|
+
person: form.subjectCategory === "resident" ? form.subjectId : "",
|
|
2145
|
+
member:
|
|
2146
|
+
form.subjectCategory === "property_management"
|
|
2147
|
+
? form.subjectId
|
|
2148
|
+
: "",
|
|
2149
|
+
serviceProvider:
|
|
2150
|
+
form.subjectCategory === "service_provider" ? form.subjectId : "",
|
|
2151
|
+
type: (form.subjectCategory === "resident"
|
|
2152
|
+
? "resident"
|
|
2153
|
+
: form.subjectCategory === "service_provider"
|
|
1643
2154
|
? "contractor"
|
|
1644
2155
|
: "staff") as
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
2156
|
+
| "resident"
|
|
2157
|
+
| "staff"
|
|
2158
|
+
| "contractor"
|
|
2159
|
+
| "visitor"
|
|
2160
|
+
| "unknown",
|
|
2161
|
+
}
|
|
1651
2162
|
: {};
|
|
1652
2163
|
|
|
1653
2164
|
const payload = {
|
|
@@ -1716,7 +2227,10 @@ async function saveUser() {
|
|
|
1716
2227
|
where: { users: { id: newlyCreatedHidUserId } },
|
|
1717
2228
|
});
|
|
1718
2229
|
} catch (rollbackError) {
|
|
1719
|
-
console.error(
|
|
2230
|
+
console.error(
|
|
2231
|
+
"Unable to roll back the partially enrolled HID user:",
|
|
2232
|
+
rollbackError
|
|
2233
|
+
);
|
|
1720
2234
|
}
|
|
1721
2235
|
}
|
|
1722
2236
|
console.error("Unable to save HID user:", error);
|
|
@@ -1829,7 +2343,7 @@ async function deleteUser() {
|
|
|
1829
2343
|
);
|
|
1830
2344
|
await deleteUserPin(
|
|
1831
2345
|
selectedReaderId.value,
|
|
1832
|
-
toHidNumericId(selectedUser.value.hidUserId)
|
|
2346
|
+
toHidNumericId(selectedUser.value.hidUserId)
|
|
1833
2347
|
);
|
|
1834
2348
|
await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
|
|
1835
2349
|
if (selectedUser.value._id) {
|
|
@@ -1848,7 +2362,7 @@ async function deleteUser() {
|
|
|
1848
2362
|
|
|
1849
2363
|
async function saveIdentityOnReader(
|
|
1850
2364
|
readerId: string,
|
|
1851
|
-
payload: HidIdentityPayload
|
|
2365
|
+
payload: HidIdentityPayload
|
|
1852
2366
|
) {
|
|
1853
2367
|
const existingIdentity = await findExistingIdentityOnReader(
|
|
1854
2368
|
readerId,
|
|
@@ -1860,7 +2374,11 @@ async function saveIdentityOnReader(
|
|
|
1860
2374
|
}
|
|
1861
2375
|
|
|
1862
2376
|
try {
|
|
1863
|
-
await createIdentity(readerId, {
|
|
2377
|
+
await createIdentity(readerId, {
|
|
2378
|
+
...payload,
|
|
2379
|
+
site: props.site,
|
|
2380
|
+
...(props.org ? { orgId: props.org } : {}),
|
|
2381
|
+
});
|
|
1864
2382
|
} catch (error: unknown) {
|
|
1865
2383
|
const message = getHidErrorMessage(error);
|
|
1866
2384
|
if (!message.toLowerCase().includes("already exists")) throw error;
|
|
@@ -1876,7 +2394,7 @@ async function saveIdentityOnReader(
|
|
|
1876
2394
|
|
|
1877
2395
|
async function findExistingIdentityOnReader(
|
|
1878
2396
|
readerId: string,
|
|
1879
|
-
payload: HidIdentityPayload
|
|
2397
|
+
payload: HidIdentityPayload
|
|
1880
2398
|
) {
|
|
1881
2399
|
const response = await getIdentities(readerId, {
|
|
1882
2400
|
page: 1,
|
|
@@ -1885,8 +2403,11 @@ async function findExistingIdentityOnReader(
|
|
|
1885
2403
|
});
|
|
1886
2404
|
const responseRecord = toRecord(response);
|
|
1887
2405
|
const responseData = toRecord(responseRecord.data);
|
|
1888
|
-
const responseItems =
|
|
1889
|
-
|
|
2406
|
+
const responseItems =
|
|
2407
|
+
responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
|
|
2408
|
+
const identities = (Array.isArray(responseItems) ? responseItems : []).map(
|
|
2409
|
+
toHidUser
|
|
2410
|
+
);
|
|
1890
2411
|
const hidUserId = String(payload.hidUserId || "");
|
|
1891
2412
|
const registration = String(payload.registration || "");
|
|
1892
2413
|
const cardNo = String(payload.cardNo || "");
|
|
@@ -1903,7 +2424,7 @@ async function findExistingIdentityOnReader(
|
|
|
1903
2424
|
|
|
1904
2425
|
async function createHidUserOnReader(
|
|
1905
2426
|
readerId: string,
|
|
1906
|
-
user: { id?: number; name: string; registration: string }
|
|
2427
|
+
user: { id?: number; name: string; registration: string }
|
|
1907
2428
|
) {
|
|
1908
2429
|
const existing = await getHidUserFromReader(readerId, user.id);
|
|
1909
2430
|
if (existing) {
|
|
@@ -1937,7 +2458,7 @@ async function getHidUserFromReader(readerId: string, id: unknown) {
|
|
|
1937
2458
|
|
|
1938
2459
|
async function updateHidUserOnReader(
|
|
1939
2460
|
readerId: string,
|
|
1940
|
-
user: { id?: number; name: string; registration: string }
|
|
2461
|
+
user: { id?: number; name: string; registration: string }
|
|
1941
2462
|
) {
|
|
1942
2463
|
const { id, ...values } = user;
|
|
1943
2464
|
await runObjectOperation(readerId, {
|
|
@@ -1952,10 +2473,7 @@ async function updateHidUserOnReader(
|
|
|
1952
2473
|
});
|
|
1953
2474
|
}
|
|
1954
2475
|
|
|
1955
|
-
async function deleteHidUserFromReader(
|
|
1956
|
-
readerId: string,
|
|
1957
|
-
user: HidUser,
|
|
1958
|
-
) {
|
|
2476
|
+
async function deleteHidUserFromReader(readerId: string, user: HidUser) {
|
|
1959
2477
|
const id = toHidNumericId(user.hidUserId);
|
|
1960
2478
|
if (!readerId || !id) return;
|
|
1961
2479
|
|
|
@@ -2043,7 +2561,9 @@ async function getHidUserRole(readerId: string, userId: number) {
|
|
|
2043
2561
|
return Array.isArray(roles) ? roles[0] : undefined;
|
|
2044
2562
|
}
|
|
2045
2563
|
|
|
2046
|
-
function toHidUserObject(
|
|
2564
|
+
function toHidUserObject(
|
|
2565
|
+
identity: Pick<HidUser, "hidUserId" | "name" | "registration">
|
|
2566
|
+
) {
|
|
2047
2567
|
return {
|
|
2048
2568
|
id: toHidNumericId(identity.hidUserId),
|
|
2049
2569
|
name:
|
|
@@ -2065,8 +2585,10 @@ function isUnsupportedUserTypeFilter(error: unknown) {
|
|
|
2065
2585
|
const data = toRecord(record.data);
|
|
2066
2586
|
const message = toText(data.message) || toText(record.message);
|
|
2067
2587
|
const normalized = message.toLowerCase();
|
|
2068
|
-
return
|
|
2069
|
-
|
|
2588
|
+
return (
|
|
2589
|
+
normalized.includes("usertype") &&
|
|
2590
|
+
(normalized.includes("not allowed") || normalized.includes("unknown"))
|
|
2591
|
+
);
|
|
2070
2592
|
}
|
|
2071
2593
|
|
|
2072
2594
|
function toHidNumericId(value: unknown) {
|
|
@@ -2097,16 +2619,19 @@ async function loadVisibleUserCards(items: HidUser[]) {
|
|
|
2097
2619
|
const response = await getUserCards(selectedReaderId.value, hidUserId);
|
|
2098
2620
|
const responseData = toRecord(toRecord(response).data);
|
|
2099
2621
|
const cards = responseData.cards;
|
|
2100
|
-
return [
|
|
2622
|
+
return [
|
|
2623
|
+
String(hidUserId),
|
|
2624
|
+
(Array.isArray(cards) ? cards : []).map(toHidCard),
|
|
2625
|
+
] as const;
|
|
2101
2626
|
} catch (error: unknown) {
|
|
2102
2627
|
cardErrors[String(hidUserId)] = getHidErrorMessage(error);
|
|
2103
2628
|
return [String(hidUserId), []] as const;
|
|
2104
2629
|
}
|
|
2105
|
-
})
|
|
2630
|
+
})
|
|
2106
2631
|
);
|
|
2107
2632
|
|
|
2108
2633
|
physicalCardsByUserId.value = Object.fromEntries(
|
|
2109
|
-
entries.filter(([hidUserId]) => Boolean(hidUserId))
|
|
2634
|
+
entries.filter(([hidUserId]) => Boolean(hidUserId))
|
|
2110
2635
|
);
|
|
2111
2636
|
physicalCardErrorsByUserId.value = cardErrors;
|
|
2112
2637
|
}
|
|
@@ -2118,12 +2643,16 @@ function getUserPhysicalCards(user: HidUser) {
|
|
|
2118
2643
|
|
|
2119
2644
|
function getUserCardLoadError(user: HidUser) {
|
|
2120
2645
|
const hidUserId = toHidNumericId(user.hidUserId);
|
|
2121
|
-
return hidUserId
|
|
2646
|
+
return hidUserId
|
|
2647
|
+
? physicalCardErrorsByUserId.value[String(hidUserId)] || ""
|
|
2648
|
+
: "";
|
|
2122
2649
|
}
|
|
2123
2650
|
|
|
2124
2651
|
async function openCardManager(user: HidUser) {
|
|
2125
2652
|
cardUser.value = user;
|
|
2126
|
-
const reader = readers.value.find(
|
|
2653
|
+
const reader = readers.value.find(
|
|
2654
|
+
(item) => String(item._id) === String(user.reader || selectedReaderId.value)
|
|
2655
|
+
);
|
|
2127
2656
|
cardForm.cardType = reader?.cardReadingMode === "pacs" ? "pacs" : "csn";
|
|
2128
2657
|
cardForm.facilityCode = "";
|
|
2129
2658
|
cardForm.cardNumber = "";
|
|
@@ -2134,7 +2663,9 @@ async function openCardManager(user: HidUser) {
|
|
|
2134
2663
|
}
|
|
2135
2664
|
|
|
2136
2665
|
function getCardReaderAndUser() {
|
|
2137
|
-
const readerId = String(
|
|
2666
|
+
const readerId = String(
|
|
2667
|
+
cardUser.value?.reader || selectedReaderId.value || ""
|
|
2668
|
+
);
|
|
2138
2669
|
const hidUserId = toHidNumericId(cardUser.value?.hidUserId);
|
|
2139
2670
|
return { readerId, hidUserId };
|
|
2140
2671
|
}
|
|
@@ -2157,7 +2688,11 @@ async function loadPhysicalCards() {
|
|
|
2157
2688
|
}
|
|
2158
2689
|
}
|
|
2159
2690
|
|
|
2160
|
-
function parsePhysicalCardInteger(
|
|
2691
|
+
function parsePhysicalCardInteger(
|
|
2692
|
+
value: string,
|
|
2693
|
+
label: string,
|
|
2694
|
+
maximum: bigint
|
|
2695
|
+
) {
|
|
2161
2696
|
if (!/^\d+$/.test(value.trim())) {
|
|
2162
2697
|
showToast(`${label} must contain numbers only.`, "error");
|
|
2163
2698
|
return null;
|
|
@@ -2178,14 +2713,27 @@ async function assignPhysicalCard() {
|
|
|
2178
2713
|
| { cardType: "pacs"; facilityCode: string; cardNumber: string }
|
|
2179
2714
|
| { cardType: "csn"; cardValue: string };
|
|
2180
2715
|
if (cardForm.cardType === "pacs") {
|
|
2181
|
-
const facilityCode = parsePhysicalCardInteger(
|
|
2182
|
-
|
|
2716
|
+
const facilityCode = parsePhysicalCardInteger(
|
|
2717
|
+
cardForm.facilityCode,
|
|
2718
|
+
"Facility Code",
|
|
2719
|
+
4294967295n
|
|
2720
|
+
);
|
|
2721
|
+
const cardNumber = parsePhysicalCardInteger(
|
|
2722
|
+
cardForm.cardNumber,
|
|
2723
|
+
"Card Number",
|
|
2724
|
+
4294967295n
|
|
2725
|
+
);
|
|
2183
2726
|
if (facilityCode === null || cardNumber === null) return;
|
|
2184
2727
|
payload = { cardType: "pacs", facilityCode, cardNumber };
|
|
2185
2728
|
} else {
|
|
2186
|
-
const cardValue = parsePhysicalCardInteger(
|
|
2729
|
+
const cardValue = parsePhysicalCardInteger(
|
|
2730
|
+
cardForm.cardValue,
|
|
2731
|
+
"CSN",
|
|
2732
|
+
18446744073709551615n
|
|
2733
|
+
);
|
|
2187
2734
|
if (cardValue === null || BigInt(cardValue) <= 0n) {
|
|
2188
|
-
if (cardValue === "0")
|
|
2735
|
+
if (cardValue === "0")
|
|
2736
|
+
showToast("CSN must be greater than zero.", "error");
|
|
2189
2737
|
return;
|
|
2190
2738
|
}
|
|
2191
2739
|
payload = { cardType: "csn", cardValue };
|
|
@@ -2284,7 +2832,8 @@ async function cancelPhysicalCardEnrollment() {
|
|
|
2284
2832
|
}
|
|
2285
2833
|
|
|
2286
2834
|
async function removePhysicalCard(card: HidCard) {
|
|
2287
|
-
if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`))
|
|
2835
|
+
if (!window.confirm(`Remove physical HID card ${formatPhysicalCard(card)}?`))
|
|
2836
|
+
return;
|
|
2288
2837
|
const { readerId, hidUserId } = getCardReaderAndUser();
|
|
2289
2838
|
if (!readerId || !hidUserId || !card?.id) return;
|
|
2290
2839
|
|
|
@@ -2310,7 +2859,12 @@ function formatPhysicalCard(card: HidCard) {
|
|
|
2310
2859
|
}
|
|
2311
2860
|
|
|
2312
2861
|
function getPhysicalCardSubtitle(card: HidCard) {
|
|
2313
|
-
const source =
|
|
2862
|
+
const source =
|
|
2863
|
+
card?.source === "reader"
|
|
2864
|
+
? "Read at reader"
|
|
2865
|
+
: card?.source === "manual"
|
|
2866
|
+
? "Manual"
|
|
2867
|
+
: "On device";
|
|
2314
2868
|
return `${source} - Raw value ${card?.value || "N/A"}`;
|
|
2315
2869
|
}
|
|
2316
2870
|
|
|
@@ -2318,9 +2872,15 @@ function buildUnitLabel() {
|
|
|
2318
2872
|
// `form.block/level/unit` hold ObjectIds, so the label has to resolve them
|
|
2319
2873
|
// back to names through the occupancy tree. Joining the raw values would
|
|
2320
2874
|
// stamp "665f.../665f.../665f..." onto the record and into the Unit column.
|
|
2321
|
-
const block = occupiedBlocks.value.find(
|
|
2322
|
-
|
|
2323
|
-
|
|
2875
|
+
const block = occupiedBlocks.value.find(
|
|
2876
|
+
(item) => String(item._id ?? "") === form.block
|
|
2877
|
+
);
|
|
2878
|
+
const level = (block?.levels ?? []).find(
|
|
2879
|
+
(item) => String(item._id ?? "") === form.level
|
|
2880
|
+
);
|
|
2881
|
+
const unit = (level?.units ?? []).find(
|
|
2882
|
+
(item) => String(item._id ?? "") === form.unit
|
|
2883
|
+
);
|
|
2324
2884
|
const parts = [
|
|
2325
2885
|
formatBlockTitle(block),
|
|
2326
2886
|
String(level?.name ?? ""),
|
|
@@ -2328,7 +2888,8 @@ function buildUnitLabel() {
|
|
|
2328
2888
|
].filter(Boolean);
|
|
2329
2889
|
// Editing a record enrolled before the cascade existed: keep its stored
|
|
2330
2890
|
// label rather than blanking it.
|
|
2331
|
-
if (!parts.length)
|
|
2891
|
+
if (!parts.length)
|
|
2892
|
+
return String(selectedUser.value?.metadata?.unitLabel ?? "");
|
|
2332
2893
|
return parts.join(" / ");
|
|
2333
2894
|
}
|
|
2334
2895
|
|
|
@@ -2416,27 +2977,28 @@ function normalizeHidUsers(response: unknown): HidUser[] {
|
|
|
2416
2977
|
const responseRecord = toRecord(response);
|
|
2417
2978
|
const responseData = toRecord(responseRecord.data);
|
|
2418
2979
|
const nestedData = toRecord(responseData.data);
|
|
2419
|
-
const items =
|
|
2980
|
+
const items =
|
|
2981
|
+
responseData.users ?? nestedData.users ?? responseRecord.users ?? [];
|
|
2420
2982
|
|
|
2421
2983
|
return Array.isArray(items)
|
|
2422
2984
|
? items.map((value) => {
|
|
2423
2985
|
const user = toRecord(value);
|
|
2424
2986
|
return {
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
name: toText(user.name),
|
|
2432
|
-
metadata: {
|
|
2987
|
+
_rowKey: `reader-${user.id}`,
|
|
2988
|
+
hidUserId: String(user.id || ""),
|
|
2989
|
+
rawHidUserId: toText(user.id),
|
|
2990
|
+
registration: toText(user.registration),
|
|
2991
|
+
userTypeId: toText(user.user_type_id),
|
|
2992
|
+
cardNo: toText(user.card_value || user.cardNo),
|
|
2433
2993
|
name: toText(user.name),
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
:
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2994
|
+
metadata: {
|
|
2995
|
+
name: toText(user.name),
|
|
2996
|
+
imageTimestamp: user.image_timestamp
|
|
2997
|
+
? String(user.image_timestamp)
|
|
2998
|
+
: "",
|
|
2999
|
+
lastAccessAt: fromUnixSeconds(user.last_access),
|
|
3000
|
+
hidReaderSource: true,
|
|
3001
|
+
},
|
|
2440
3002
|
};
|
|
2441
3003
|
})
|
|
2442
3004
|
: [];
|
|
@@ -2444,7 +3006,7 @@ function normalizeHidUsers(response: unknown): HidUser[] {
|
|
|
2444
3006
|
|
|
2445
3007
|
function mergeReaderUsersWithIdentities(
|
|
2446
3008
|
readerUsers: HidUser[],
|
|
2447
|
-
identities: HidUser[]
|
|
3009
|
+
identities: HidUser[]
|
|
2448
3010
|
) {
|
|
2449
3011
|
const identityById = new Map<string, HidUser>();
|
|
2450
3012
|
identities.forEach((identity) => {
|
|
@@ -2487,7 +3049,13 @@ function getMappingStatus(user: HidUser) {
|
|
|
2487
3049
|
}
|
|
2488
3050
|
|
|
2489
3051
|
function hasMappedRecord(user: HidUser) {
|
|
2490
|
-
return Boolean(
|
|
3052
|
+
return Boolean(
|
|
3053
|
+
user?.person ||
|
|
3054
|
+
user?.user ||
|
|
3055
|
+
user?.member ||
|
|
3056
|
+
user?.serviceProvider ||
|
|
3057
|
+
user?.visitor
|
|
3058
|
+
);
|
|
2491
3059
|
}
|
|
2492
3060
|
|
|
2493
3061
|
function formatDate(value?: string) {
|
|
@@ -2519,18 +3087,26 @@ function getHidErrorMessage(error: unknown) {
|
|
|
2519
3087
|
const cause = toRecord(errorRecord.cause);
|
|
2520
3088
|
const causeResponse = toRecord(cause.response);
|
|
2521
3089
|
const data = toRecord(
|
|
2522
|
-
errorRecord.data ||
|
|
3090
|
+
errorRecord.data ||
|
|
3091
|
+
response._data ||
|
|
3092
|
+
response.data ||
|
|
3093
|
+
cause.data ||
|
|
3094
|
+
causeResponse._data
|
|
2523
3095
|
);
|
|
2524
3096
|
|
|
2525
3097
|
return toText(
|
|
2526
|
-
data.message ||
|
|
2527
|
-
|
|
3098
|
+
data.message ||
|
|
3099
|
+
data.error ||
|
|
3100
|
+
data.statusMessage ||
|
|
3101
|
+
errorRecord.statusMessage ||
|
|
3102
|
+
errorRecord.message,
|
|
3103
|
+
"Unable to save HID user."
|
|
2528
3104
|
);
|
|
2529
3105
|
}
|
|
2530
3106
|
|
|
2531
3107
|
function toRecord(value: unknown): Record<string, unknown> {
|
|
2532
3108
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
2533
|
-
? value as Record<string, unknown>
|
|
3109
|
+
? (value as Record<string, unknown>)
|
|
2534
3110
|
: {};
|
|
2535
3111
|
}
|
|
2536
3112
|
|
|
@@ -2576,8 +3152,16 @@ function toHidCard(value: unknown): HidCard {
|
|
|
2576
3152
|
return {
|
|
2577
3153
|
...card,
|
|
2578
3154
|
id: toText(card.id) || undefined,
|
|
2579
|
-
cardType:
|
|
2580
|
-
|
|
3155
|
+
cardType:
|
|
3156
|
+
card.cardType === "pacs" || card.cardType === "csn"
|
|
3157
|
+
? card.cardType
|
|
3158
|
+
: undefined,
|
|
3159
|
+
source:
|
|
3160
|
+
card.source === "reader" ||
|
|
3161
|
+
card.source === "manual" ||
|
|
3162
|
+
card.source === "device"
|
|
3163
|
+
? card.source
|
|
3164
|
+
: undefined,
|
|
2581
3165
|
};
|
|
2582
3166
|
}
|
|
2583
3167
|
|
|
@@ -2712,7 +3296,6 @@ watch(selectedReaderId, (value) => {
|
|
|
2712
3296
|
box-shadow: var(--shadow-menu);
|
|
2713
3297
|
}
|
|
2714
3298
|
|
|
2715
|
-
|
|
2716
3299
|
.admin-rule-indicator {
|
|
2717
3300
|
display: inline-flex;
|
|
2718
3301
|
vertical-align: middle;
|