@7365admin1/layer-common 3.0.7 → 3.0.9
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/CHANGELOG.md +13 -0
- package/components/CameraMain.vue +17 -1
- package/components/DashboardMain.vue +1854 -848
- package/components/EntryPassInformation.vue +60 -0
- package/components/HidAccessLogDashboard.vue +1691 -0
- package/components/HidEnabledGate.vue +51 -0
- package/components/HidIdentityMapping.vue +975 -0
- package/components/HidQrCodeConfiguration.vue +618 -0
- package/components/HidReaderForm.vue +246 -0
- package/components/HidReaderManagement.vue +1233 -0
- package/components/HidServiceSettingsPanel.vue +150 -0
- package/components/HidUserEnrollment.vue +1274 -0
- package/components/Layout/NavigationDrawer.vue +31 -1
- package/components/MemberInformation.vue +23 -15
- package/components/NavigationItem.vue +11 -4
- package/components/OnlineFormFill.vue +34 -0
- package/components/PassInformation.vue +73 -34
- package/components/SiteSettings.vue +60 -116
- package/components/VisitorForm.vue +815 -290
- package/components/VisitorFormSelection.vue +12 -2
- package/components/VisitorManagement.vue +652 -205
- package/composables/useCommonPermission.ts +59 -0
- package/composables/useDashboard.ts +46 -1
- package/composables/useHidAmico.ts +152 -0
- package/composables/useHidNavigation.ts +106 -0
- package/composables/useMember.ts +7 -1
- package/composables/useOptionalServices.ts +118 -0
- package/composables/useSettingsPermission.ts +138 -0
- package/composables/useSiteSettings.ts +1 -1
- package/composables/useUser.ts +1 -2
- package/nuxt.config.ts +1 -1
- package/package.json +3 -2
- package/pages/[org]/[site]/access-mgmt/access-logs/index.vue +21 -0
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +21 -0
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +21 -0
- package/pages/[org]/[site]/access-mgmt/hid-users/index.vue +21 -0
- package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +21 -0
- package/types/local.d.ts +2 -1
- package/types/visitor.d.ts +4 -2
|
@@ -0,0 +1,1274 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="hid-user-enrollment">
|
|
3
|
+
<div class="toolbar">
|
|
4
|
+
<v-btn
|
|
5
|
+
rounded="xl"
|
|
6
|
+
class="text-none enroll-button"
|
|
7
|
+
variant="flat"
|
|
8
|
+
prepend-icon="mdi-account-plus"
|
|
9
|
+
@click="openEnroll"
|
|
10
|
+
>
|
|
11
|
+
Enroll
|
|
12
|
+
</v-btn>
|
|
13
|
+
|
|
14
|
+
<v-select
|
|
15
|
+
v-if="readers.length > 1"
|
|
16
|
+
v-model="selectedReaderId"
|
|
17
|
+
:items="readerOptions"
|
|
18
|
+
item-title="title"
|
|
19
|
+
item-value="value"
|
|
20
|
+
variant="outlined"
|
|
21
|
+
density="compact"
|
|
22
|
+
hide-details
|
|
23
|
+
class="reader-input"
|
|
24
|
+
@update:model-value="loadUsers"
|
|
25
|
+
/>
|
|
26
|
+
|
|
27
|
+
<div class="toolbar-spacer" />
|
|
28
|
+
|
|
29
|
+
<v-text-field
|
|
30
|
+
v-model="search"
|
|
31
|
+
placeholder="Search"
|
|
32
|
+
variant="outlined"
|
|
33
|
+
density="compact"
|
|
34
|
+
hide-details
|
|
35
|
+
class="search-input"
|
|
36
|
+
prepend-inner-icon="mdi-magnify"
|
|
37
|
+
@keyup.enter="loadUsers"
|
|
38
|
+
/>
|
|
39
|
+
|
|
40
|
+
<v-select
|
|
41
|
+
v-model="status"
|
|
42
|
+
:items="statusOptions"
|
|
43
|
+
variant="outlined"
|
|
44
|
+
density="compact"
|
|
45
|
+
hide-details
|
|
46
|
+
class="status-input"
|
|
47
|
+
@update:model-value="loadUsers"
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<v-card flat border class="table-card">
|
|
52
|
+
<div class="table-refresh">
|
|
53
|
+
<v-btn icon="mdi-refresh" variant="text" density="comfortable" :loading="loading" @click="loadUsers" />
|
|
54
|
+
<div class="page-count">{{ pageRange }}</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div class="table-body">
|
|
58
|
+
<v-table>
|
|
59
|
+
<thead>
|
|
60
|
+
<tr>
|
|
61
|
+
<th>Name</th>
|
|
62
|
+
<th>Facial Data</th>
|
|
63
|
+
<th>UID</th>
|
|
64
|
+
<th>Status</th>
|
|
65
|
+
<th class="text-right"></th>
|
|
66
|
+
</tr>
|
|
67
|
+
</thead>
|
|
68
|
+
<tbody>
|
|
69
|
+
<tr v-for="user in users" :key="user._rowKey || user._id || user.hidUserId">
|
|
70
|
+
<td>{{ getName(user) }}</td>
|
|
71
|
+
<td>
|
|
72
|
+
<v-avatar v-if="getUserImageSrc(user)" size="34" rounded="lg" class="hid-face-avatar">
|
|
73
|
+
<v-img :src="getUserImageSrc(user)" cover />
|
|
74
|
+
</v-avatar>
|
|
75
|
+
<span v-else>{{ getFacialData(user) }}</span>
|
|
76
|
+
</td>
|
|
77
|
+
<td>{{ formatHidUid(user.hidUserId) || "N/A" }}</td>
|
|
78
|
+
<td>
|
|
79
|
+
<v-chip
|
|
80
|
+
size="small"
|
|
81
|
+
variant="flat"
|
|
82
|
+
class="status-chip"
|
|
83
|
+
:class="getMappingStatusClass(user)"
|
|
84
|
+
>
|
|
85
|
+
{{ getMappingStatus(user) }}
|
|
86
|
+
</v-chip>
|
|
87
|
+
</td>
|
|
88
|
+
<td class="text-right">
|
|
89
|
+
<v-menu>
|
|
90
|
+
<template #activator="{ props: menuProps }">
|
|
91
|
+
<v-btn v-bind="menuProps" icon="mdi-dots-vertical" variant="text" density="comfortable" />
|
|
92
|
+
</template>
|
|
93
|
+
|
|
94
|
+
<v-list density="compact" min-width="150">
|
|
95
|
+
<v-list-item title="View" @click="openView(user)" />
|
|
96
|
+
<v-list-item title="Edit" @click="openEdit(user)" />
|
|
97
|
+
<v-list-item title="Delete" class="text-error" @click="openDelete(user)" />
|
|
98
|
+
</v-list>
|
|
99
|
+
</v-menu>
|
|
100
|
+
</td>
|
|
101
|
+
</tr>
|
|
102
|
+
</tbody>
|
|
103
|
+
</v-table>
|
|
104
|
+
|
|
105
|
+
<div v-if="!users.length" class="empty-state">
|
|
106
|
+
No HID users enrolled yet.
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div class="table-footer">
|
|
111
|
+
<span>{{ pageRange }}</span>
|
|
112
|
+
<v-btn icon="mdi-chevron-left" variant="text" density="comfortable" :disabled="page <= 1" @click="page--; loadUsers()" />
|
|
113
|
+
<v-btn icon="mdi-chevron-right" variant="text" density="comfortable" :disabled="page >= pages" @click="page++; loadUsers()" />
|
|
114
|
+
</div>
|
|
115
|
+
</v-card>
|
|
116
|
+
|
|
117
|
+
<v-dialog v-model="formDialog" max-width="420" persistent>
|
|
118
|
+
<v-card rounded="lg">
|
|
119
|
+
<v-card-title class="small-title">
|
|
120
|
+
{{ selectedUser ? "Edit User Information" : "Enroll" }}
|
|
121
|
+
</v-card-title>
|
|
122
|
+
|
|
123
|
+
<v-card-text>
|
|
124
|
+
<div class="photo-wrap">
|
|
125
|
+
<v-menu>
|
|
126
|
+
<template #activator="{ props: menuProps }">
|
|
127
|
+
<button v-bind="menuProps" class="photo-button" type="button">
|
|
128
|
+
<v-img v-if="form.photoPreview" :src="form.photoPreview" cover class="photo-preview" />
|
|
129
|
+
<span v-if="form.photoPreview" class="photo-edit-icon">
|
|
130
|
+
<v-icon icon="mdi-pencil" size="16" />
|
|
131
|
+
</span>
|
|
132
|
+
<template v-else>
|
|
133
|
+
<v-icon icon="mdi-account-plus" size="40" color="#a4acb5" />
|
|
134
|
+
<span>Add Photo</span>
|
|
135
|
+
</template>
|
|
136
|
+
</button>
|
|
137
|
+
</template>
|
|
138
|
+
<v-list density="compact" min-width="190">
|
|
139
|
+
<v-list-item title="Browse" @click="triggerPhotoInput" />
|
|
140
|
+
<v-list-item title="Take Photo using Camera" @click="triggerPhotoInput" />
|
|
141
|
+
</v-list>
|
|
142
|
+
</v-menu>
|
|
143
|
+
<input ref="photoInput" type="file" accept="image/*" class="hidden-input" @change="onPhotoChange" />
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div class="field-label">Name <span>*</span></div>
|
|
147
|
+
<v-text-field
|
|
148
|
+
v-model="form.name"
|
|
149
|
+
aria-label="Name"
|
|
150
|
+
placeholder="Enter name"
|
|
151
|
+
density="compact"
|
|
152
|
+
variant="outlined"
|
|
153
|
+
hide-details="auto"
|
|
154
|
+
class="mb-3"
|
|
155
|
+
/>
|
|
156
|
+
|
|
157
|
+
<div class="field-label">ID</div>
|
|
158
|
+
<v-text-field
|
|
159
|
+
v-model="form.hidUserId"
|
|
160
|
+
aria-label="ID"
|
|
161
|
+
density="compact"
|
|
162
|
+
variant="outlined"
|
|
163
|
+
hide-details="auto"
|
|
164
|
+
readonly
|
|
165
|
+
class="mb-3"
|
|
166
|
+
/>
|
|
167
|
+
|
|
168
|
+
<div class="field-label">Registration No. <span>*</span></div>
|
|
169
|
+
<v-text-field
|
|
170
|
+
v-model="form.registration"
|
|
171
|
+
aria-label="Registration No."
|
|
172
|
+
placeholder="Enter registration no."
|
|
173
|
+
density="compact"
|
|
174
|
+
variant="outlined"
|
|
175
|
+
hide-details="auto"
|
|
176
|
+
:readonly="!selectedUser"
|
|
177
|
+
class="mb-3"
|
|
178
|
+
/>
|
|
179
|
+
|
|
180
|
+
<div class="field-label">Panic Pin (optional)</div>
|
|
181
|
+
<v-text-field
|
|
182
|
+
v-model="form.cardNo"
|
|
183
|
+
aria-label="Panic Pin"
|
|
184
|
+
placeholder="Enter panic pin"
|
|
185
|
+
density="compact"
|
|
186
|
+
variant="outlined"
|
|
187
|
+
hide-details="auto"
|
|
188
|
+
class="mb-3"
|
|
189
|
+
/>
|
|
190
|
+
|
|
191
|
+
<div class="field-label">Panic Password (optional)</div>
|
|
192
|
+
<v-text-field
|
|
193
|
+
v-model="form.pinPassword"
|
|
194
|
+
aria-label="Panic Password"
|
|
195
|
+
placeholder="Enter password"
|
|
196
|
+
density="compact"
|
|
197
|
+
variant="outlined"
|
|
198
|
+
hide-details="auto"
|
|
199
|
+
:type="showPassword ? 'text' : 'password'"
|
|
200
|
+
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
|
201
|
+
@click:append-inner="showPassword = !showPassword"
|
|
202
|
+
/>
|
|
203
|
+
</v-card-text>
|
|
204
|
+
|
|
205
|
+
<v-card-actions class="pa-0">
|
|
206
|
+
<v-btn class="text-none action-cancel" variant="flat" height="44" @click="formDialog = false">Cancel</v-btn>
|
|
207
|
+
<v-btn class="text-none action-submit" variant="flat" height="44" :loading="saving" @click="saveUser">
|
|
208
|
+
Save
|
|
209
|
+
</v-btn>
|
|
210
|
+
</v-card-actions>
|
|
211
|
+
</v-card>
|
|
212
|
+
</v-dialog>
|
|
213
|
+
|
|
214
|
+
<v-dialog v-model="viewDialog" max-width="430">
|
|
215
|
+
<v-card rounded="lg">
|
|
216
|
+
<v-card-title class="small-title">User Information</v-card-title>
|
|
217
|
+
<v-card-text>
|
|
218
|
+
<div class="detail-grid">
|
|
219
|
+
<span>Name</span><strong>{{ getName(selectedUser) }}</strong>
|
|
220
|
+
<span>User ID</span><strong>{{ selectedUser ? formatHidUid(selectedUser.hidUserId) : "N/A" }}</strong>
|
|
221
|
+
<span>Registration No.</span><strong>{{ selectedUser?.registration || "N/A" }}</strong>
|
|
222
|
+
<span>Last access time</span><strong>{{ formatDate(selectedUser?.metadata?.lastAccessAt) }}</strong>
|
|
223
|
+
<span>Status</span><strong>{{ selectedUser ? getMappingStatus(selectedUser) : "N/A" }}</strong>
|
|
224
|
+
<span>Panic Pin</span><strong>{{ selectedUser?.cardNo || "N/A" }}</strong>
|
|
225
|
+
<span>Private Password</span><strong>{{ selectedUser?.metadata?.pinPassword ? "********" : "N/A" }}</strong>
|
|
226
|
+
</div>
|
|
227
|
+
</v-card-text>
|
|
228
|
+
<v-card-actions>
|
|
229
|
+
<v-spacer />
|
|
230
|
+
<v-btn class="text-none" variant="text" @click="viewDialog = false">Close</v-btn>
|
|
231
|
+
</v-card-actions>
|
|
232
|
+
</v-card>
|
|
233
|
+
</v-dialog>
|
|
234
|
+
|
|
235
|
+
<v-dialog v-model="deleteDialog" max-width="360" persistent>
|
|
236
|
+
<v-card rounded="lg">
|
|
237
|
+
<v-card-title class="small-title">Delete</v-card-title>
|
|
238
|
+
<v-card-text class="text-body-2">
|
|
239
|
+
Are you sure you want to permanently delete this HID user?
|
|
240
|
+
<div class="text-caption text-medium-emphasis mt-2">
|
|
241
|
+
This action will remove the user from the HID reader and mark the identity as deleted.
|
|
242
|
+
</div>
|
|
243
|
+
</v-card-text>
|
|
244
|
+
<v-card-actions class="pa-0">
|
|
245
|
+
<v-btn class="text-none action-cancel" variant="flat" height="44" @click="deleteDialog = false">Cancel</v-btn>
|
|
246
|
+
<v-btn class="text-none action-submit" variant="flat" height="44" :loading="deleting" @click="deleteUser">Delete</v-btn>
|
|
247
|
+
</v-card-actions>
|
|
248
|
+
</v-card>
|
|
249
|
+
</v-dialog>
|
|
250
|
+
|
|
251
|
+
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3500">
|
|
252
|
+
{{ snackbar.message }}
|
|
253
|
+
</v-snackbar>
|
|
254
|
+
</section>
|
|
255
|
+
</template>
|
|
256
|
+
|
|
257
|
+
<script setup lang="ts">
|
|
258
|
+
const props = defineProps({
|
|
259
|
+
site: {
|
|
260
|
+
type: String,
|
|
261
|
+
required: true,
|
|
262
|
+
},
|
|
263
|
+
identityType: {
|
|
264
|
+
type: String,
|
|
265
|
+
default: "resident",
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const {
|
|
270
|
+
getReaders,
|
|
271
|
+
getIdentities,
|
|
272
|
+
getUserImage,
|
|
273
|
+
runObjectOperation,
|
|
274
|
+
createIdentity,
|
|
275
|
+
updateIdentity,
|
|
276
|
+
deleteIdentity,
|
|
277
|
+
} = useHidAmico();
|
|
278
|
+
|
|
279
|
+
const readers = ref<Record<string, any>[]>([]);
|
|
280
|
+
const users = ref<Record<string, any>[]>([]);
|
|
281
|
+
const userImages = ref<Record<string, string>>({});
|
|
282
|
+
const selectedReaderId = ref("");
|
|
283
|
+
const search = ref("");
|
|
284
|
+
const status = ref("Status");
|
|
285
|
+
const page = ref(1);
|
|
286
|
+
const limit = ref(10);
|
|
287
|
+
const pages = ref(1);
|
|
288
|
+
const total = ref(0);
|
|
289
|
+
const serverPageRange = ref("");
|
|
290
|
+
const loading = ref(false);
|
|
291
|
+
const saving = ref(false);
|
|
292
|
+
const deleting = ref(false);
|
|
293
|
+
const formDialog = ref(false);
|
|
294
|
+
const viewDialog = ref(false);
|
|
295
|
+
const deleteDialog = ref(false);
|
|
296
|
+
const selectedUser = ref<Record<string, any> | null>(null);
|
|
297
|
+
const showPassword = ref(false);
|
|
298
|
+
const photoInput = ref<HTMLInputElement | null>(null);
|
|
299
|
+
const snackbar = reactive({
|
|
300
|
+
show: false,
|
|
301
|
+
color: "success",
|
|
302
|
+
message: "",
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const form = reactive({
|
|
306
|
+
reader: "",
|
|
307
|
+
block: "",
|
|
308
|
+
level: "",
|
|
309
|
+
unit: "",
|
|
310
|
+
name: "",
|
|
311
|
+
hidUserId: "",
|
|
312
|
+
registration: "",
|
|
313
|
+
cardNo: "",
|
|
314
|
+
pinPassword: "",
|
|
315
|
+
photoPreview: "",
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const statusOptions = ["Status", "Mapped", "Unmapped"];
|
|
319
|
+
|
|
320
|
+
const readerOptions = computed(() =>
|
|
321
|
+
readers.value.map((reader) => ({
|
|
322
|
+
title: reader.name || reader.deviceId || reader._id,
|
|
323
|
+
value: reader._id,
|
|
324
|
+
})),
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const pageRange = computed(() => {
|
|
328
|
+
if (serverPageRange.value) return serverPageRange.value;
|
|
329
|
+
if (!total.value) return "0-0 of 0";
|
|
330
|
+
const start = (page.value - 1) * limit.value + 1;
|
|
331
|
+
const end = Math.min(page.value * limit.value, total.value);
|
|
332
|
+
return `${start}-${end} of ${total.value}`;
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
watch(
|
|
336
|
+
() => props.site,
|
|
337
|
+
() => init(),
|
|
338
|
+
{ immediate: true },
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
async function init() {
|
|
342
|
+
await loadReaders();
|
|
343
|
+
await loadUsers();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function loadReaders() {
|
|
347
|
+
if (!props.site) return;
|
|
348
|
+
const response = await getReaders({ site: props.site, page: 1, limit: 100 });
|
|
349
|
+
readers.value = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
|
|
350
|
+
if (!selectedReaderId.value && readers.value.length) {
|
|
351
|
+
selectedReaderId.value = readers.value[0]._id;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function loadUsers() {
|
|
356
|
+
if (!selectedReaderId.value) {
|
|
357
|
+
users.value = [];
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
loading.value = true;
|
|
362
|
+
try {
|
|
363
|
+
const identityQuery: Record<string, any> = {
|
|
364
|
+
page: page.value,
|
|
365
|
+
limit: 500,
|
|
366
|
+
type: props.identityType,
|
|
367
|
+
search: search.value,
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const [identityResponse, readerResponse, roleResponse] = await Promise.all([
|
|
371
|
+
getIdentities(selectedReaderId.value, identityQuery),
|
|
372
|
+
runObjectOperation(selectedReaderId.value, {
|
|
373
|
+
operation: "load",
|
|
374
|
+
object: "users",
|
|
375
|
+
limit: 500,
|
|
376
|
+
offset: 0,
|
|
377
|
+
}),
|
|
378
|
+
isAdministratorIdentity() ? loadAdministratorRoles(selectedReaderId.value) : Promise.resolve(null),
|
|
379
|
+
]);
|
|
380
|
+
|
|
381
|
+
const identities = identityResponse?.items ?? identityResponse?.data?.items ?? identityResponse?.data?.identities ?? [];
|
|
382
|
+
const adminRoleIds = getAdministratorRoleUserIds(roleResponse);
|
|
383
|
+
const readerUsers = normalizeHidUsers(readerResponse).filter((user) => {
|
|
384
|
+
if (!isAdministratorIdentity()) return true;
|
|
385
|
+
const hidUserId = toHidNumericId(user.hidUserId);
|
|
386
|
+
return Boolean(hidUserId && adminRoleIds.has(hidUserId));
|
|
387
|
+
});
|
|
388
|
+
const mergedUsers = mergeReaderUsersWithIdentities(readerUsers, identities);
|
|
389
|
+
const filteredUsers = filterUsers(mergedUsers);
|
|
390
|
+
total.value = filteredUsers.length;
|
|
391
|
+
pages.value = Math.max(1, Math.ceil(total.value / limit.value));
|
|
392
|
+
if (page.value > pages.value) page.value = pages.value;
|
|
393
|
+
const start = (page.value - 1) * limit.value;
|
|
394
|
+
users.value = filteredUsers.slice(start, start + limit.value);
|
|
395
|
+
serverPageRange.value = "";
|
|
396
|
+
await loadUserImages(users.value);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
console.error("Unable to load HID reader users:", error);
|
|
399
|
+
showToast("Unable to load HID users from reader.", "error");
|
|
400
|
+
users.value = [];
|
|
401
|
+
total.value = 0;
|
|
402
|
+
pages.value = 1;
|
|
403
|
+
serverPageRange.value = "";
|
|
404
|
+
} finally {
|
|
405
|
+
loading.value = false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function loadUserImages(items: Record<string, any>[]) {
|
|
410
|
+
if (!selectedReaderId.value) return;
|
|
411
|
+
|
|
412
|
+
await Promise.all(items.map(async (user) => {
|
|
413
|
+
const hidUserId = toHidNumericId(user.hidUserId);
|
|
414
|
+
const key = String(hidUserId || "");
|
|
415
|
+
if (!hidUserId || userImages.value[key] || user?.metadata?.photo) return;
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
const response = await getUserImage(selectedReaderId.value, hidUserId);
|
|
419
|
+
const image = response?.data || response;
|
|
420
|
+
userImages.value[key] = image?.base64
|
|
421
|
+
? `data:${image.contentType || "image/jpeg"};base64,${image.base64}`
|
|
422
|
+
: "";
|
|
423
|
+
} catch {
|
|
424
|
+
userImages.value[key] = "";
|
|
425
|
+
}
|
|
426
|
+
}));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function resetForm() {
|
|
430
|
+
form.reader = selectedReaderId.value || readers.value[0]?._id || "";
|
|
431
|
+
form.block = "";
|
|
432
|
+
form.level = "";
|
|
433
|
+
form.unit = "";
|
|
434
|
+
form.name = "";
|
|
435
|
+
form.hidUserId = "";
|
|
436
|
+
form.registration = "";
|
|
437
|
+
form.cardNo = "";
|
|
438
|
+
form.pinPassword = "";
|
|
439
|
+
form.photoPreview = "";
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function openEnroll() {
|
|
443
|
+
selectedUser.value = null;
|
|
444
|
+
resetForm();
|
|
445
|
+
const [hidUserId, registration] = await Promise.all([
|
|
446
|
+
getNextUid(),
|
|
447
|
+
getNextRegistration(),
|
|
448
|
+
]);
|
|
449
|
+
form.hidUserId = hidUserId;
|
|
450
|
+
form.registration = registration;
|
|
451
|
+
formDialog.value = true;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function openEdit(user: Record<string, any>) {
|
|
455
|
+
selectedUser.value = user;
|
|
456
|
+
form.reader = String(user.reader ?? selectedReaderId.value);
|
|
457
|
+
form.block = user.metadata?.block ?? "";
|
|
458
|
+
form.level = user.metadata?.level ?? "";
|
|
459
|
+
form.unit = user.metadata?.unit ?? "";
|
|
460
|
+
form.name = getName(user);
|
|
461
|
+
form.hidUserId = formatHidUid(user.hidUserId);
|
|
462
|
+
form.registration = user.registration ?? "";
|
|
463
|
+
form.cardNo = user.cardNo ?? "";
|
|
464
|
+
form.pinPassword = user.metadata?.pinPassword ?? "";
|
|
465
|
+
form.photoPreview = getUserImageSrc(user);
|
|
466
|
+
formDialog.value = true;
|
|
467
|
+
|
|
468
|
+
if (!form.photoPreview) {
|
|
469
|
+
await loadUserImages([user]);
|
|
470
|
+
if (selectedUser.value?._rowKey === user._rowKey) {
|
|
471
|
+
form.photoPreview = getUserImageSrc(user);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function openView(user: Record<string, any>) {
|
|
477
|
+
selectedUser.value = user;
|
|
478
|
+
viewDialog.value = true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function openDelete(user: Record<string, any>) {
|
|
482
|
+
selectedUser.value = user;
|
|
483
|
+
deleteDialog.value = true;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function triggerPhotoInput() {
|
|
487
|
+
photoInput.value?.click();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function onPhotoChange(event: Event) {
|
|
491
|
+
const file = (event.target as HTMLInputElement).files?.[0];
|
|
492
|
+
if (!file) return;
|
|
493
|
+
const reader = new FileReader();
|
|
494
|
+
reader.onload = () => {
|
|
495
|
+
form.photoPreview = String(reader.result || "");
|
|
496
|
+
};
|
|
497
|
+
reader.readAsDataURL(file);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function saveUser() {
|
|
501
|
+
const readerId = selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
502
|
+
if (!readerId || !form.name || !form.hidUserId || !form.registration) {
|
|
503
|
+
showToast("Please complete the required HID user fields.", "error");
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
saving.value = true;
|
|
508
|
+
try {
|
|
509
|
+
const hidUser = toHidUserObject({
|
|
510
|
+
hidUserId: form.hidUserId,
|
|
511
|
+
name: form.name,
|
|
512
|
+
registration: form.registration,
|
|
513
|
+
});
|
|
514
|
+
if (!hidUser.id) {
|
|
515
|
+
showToast("HID user ID must contain a valid numeric value.", "error");
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const payload = {
|
|
520
|
+
hidUserId: String(hidUser.id),
|
|
521
|
+
registration: form.registration,
|
|
522
|
+
cardNo: form.cardNo,
|
|
523
|
+
type: props.identityType as "resident" | "staff" | "contractor" | "visitor" | "admin" | "unknown",
|
|
524
|
+
status: "active" as const,
|
|
525
|
+
metadata: {
|
|
526
|
+
name: form.name,
|
|
527
|
+
block: form.block,
|
|
528
|
+
level: form.level,
|
|
529
|
+
unit: form.unit,
|
|
530
|
+
unitLabel: buildUnitLabel(),
|
|
531
|
+
facialData: form.photoPreview ? form.hidUserId : "",
|
|
532
|
+
photo: form.photoPreview,
|
|
533
|
+
pinPassword: form.pinPassword,
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
if (selectedUser.value) {
|
|
538
|
+
await updateHidUserOnReader(readerId, hidUser);
|
|
539
|
+
await syncHidUserRole(readerId, hidUser.id, isAdministratorIdentity());
|
|
540
|
+
if (selectedUser.value._id) {
|
|
541
|
+
await updateIdentity(selectedUser.value._id, payload);
|
|
542
|
+
} else {
|
|
543
|
+
await saveIdentityOnReader(readerId, payload);
|
|
544
|
+
}
|
|
545
|
+
} else {
|
|
546
|
+
await createHidUserOnReader(readerId, hidUser);
|
|
547
|
+
await syncHidUserRole(readerId, hidUser.id, isAdministratorIdentity());
|
|
548
|
+
await saveIdentityOnReader(readerId, payload);
|
|
549
|
+
selectedReaderId.value = readerId;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
formDialog.value = false;
|
|
553
|
+
await loadUsers();
|
|
554
|
+
showToast(selectedUser.value?._id ? "HID user updated on reader." : "HID user enrolled on reader.");
|
|
555
|
+
} catch (error: any) {
|
|
556
|
+
console.error("Unable to save HID user:", error);
|
|
557
|
+
showToast(getHidErrorMessage(error), "error");
|
|
558
|
+
} finally {
|
|
559
|
+
saving.value = false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function deleteUser() {
|
|
564
|
+
if (!selectedUser.value) return;
|
|
565
|
+
|
|
566
|
+
deleting.value = true;
|
|
567
|
+
try {
|
|
568
|
+
await syncHidUserRole(selectedReaderId.value, toHidNumericId(selectedUser.value.hidUserId), false);
|
|
569
|
+
await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
|
|
570
|
+
if (selectedUser.value._id) {
|
|
571
|
+
await deleteIdentity(selectedUser.value._id);
|
|
572
|
+
}
|
|
573
|
+
deleteDialog.value = false;
|
|
574
|
+
await loadUsers();
|
|
575
|
+
showToast("HID user deleted from reader.");
|
|
576
|
+
} catch (error: any) {
|
|
577
|
+
console.error("Unable to delete HID user:", error);
|
|
578
|
+
showToast(getHidErrorMessage(error), "error");
|
|
579
|
+
} finally {
|
|
580
|
+
deleting.value = false;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function saveIdentityOnReader(readerId: string, payload: Record<string, any>) {
|
|
585
|
+
const existingIdentity = await findExistingIdentityOnReader(readerId, payload);
|
|
586
|
+
if (existingIdentity?._id) {
|
|
587
|
+
await updateIdentity(existingIdentity._id, payload);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
await createIdentity(readerId, { ...payload, site: props.site });
|
|
593
|
+
} catch (error: any) {
|
|
594
|
+
const message = getHidErrorMessage(error);
|
|
595
|
+
if (!message.toLowerCase().includes("already exists")) throw error;
|
|
596
|
+
|
|
597
|
+
const duplicatedIdentity = await findExistingIdentityOnReader(readerId, payload);
|
|
598
|
+
if (!duplicatedIdentity?._id) throw error;
|
|
599
|
+
await updateIdentity(duplicatedIdentity._id, payload);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function findExistingIdentityOnReader(readerId: string, payload: Record<string, any>) {
|
|
604
|
+
const response = await getIdentities(readerId, {
|
|
605
|
+
page: 1,
|
|
606
|
+
limit: 500,
|
|
607
|
+
search: payload.hidUserId || payload.registration || payload.cardNo || "",
|
|
608
|
+
});
|
|
609
|
+
const identities = response?.items ?? response?.data?.items ?? response?.data?.identities ?? [];
|
|
610
|
+
const hidUserId = String(payload.hidUserId || "");
|
|
611
|
+
const registration = String(payload.registration || "");
|
|
612
|
+
const cardNo = String(payload.cardNo || "");
|
|
613
|
+
|
|
614
|
+
return identities.find((identity: Record<string, any>) =>
|
|
615
|
+
String(identity.hidUserId || "") === hidUserId ||
|
|
616
|
+
Boolean(registration && String(identity.registration || "") === registration) ||
|
|
617
|
+
Boolean(cardNo && String(identity.cardNo || "") === cardNo),
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function createHidUserOnReader(readerId: string, user: Record<string, any>) {
|
|
622
|
+
const existing = await getHidUserFromReader(readerId, user.id);
|
|
623
|
+
if (existing) {
|
|
624
|
+
await updateHidUserOnReader(readerId, user);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
await runObjectOperation(readerId, {
|
|
629
|
+
operation: "create",
|
|
630
|
+
object: "users",
|
|
631
|
+
values: [user],
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function getHidUserFromReader(readerId: string, id: unknown) {
|
|
636
|
+
if (!readerId || !id) return undefined;
|
|
637
|
+
const response = await runObjectOperation(readerId, {
|
|
638
|
+
operation: "load",
|
|
639
|
+
object: "users",
|
|
640
|
+
where: {
|
|
641
|
+
users: {
|
|
642
|
+
id,
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
limit: 1,
|
|
646
|
+
offset: 0,
|
|
647
|
+
});
|
|
648
|
+
return normalizeHidUsers(response)[0];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function updateHidUserOnReader(readerId: string, user: Record<string, any>) {
|
|
652
|
+
const { id, ...values } = user;
|
|
653
|
+
await runObjectOperation(readerId, {
|
|
654
|
+
operation: "modify",
|
|
655
|
+
object: "users",
|
|
656
|
+
where: {
|
|
657
|
+
users: {
|
|
658
|
+
id,
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
values,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function deleteHidUserFromReader(readerId: string, user: Record<string, any>) {
|
|
666
|
+
const id = toHidNumericId(user.hidUserId);
|
|
667
|
+
if (!readerId || !id) return;
|
|
668
|
+
|
|
669
|
+
await runObjectOperation(readerId, {
|
|
670
|
+
operation: "destroy",
|
|
671
|
+
object: "users",
|
|
672
|
+
where: {
|
|
673
|
+
users: {
|
|
674
|
+
id,
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function syncHidUserRole(readerId: string, hidUserId: unknown, isAdministrator: boolean) {
|
|
681
|
+
const userId = toHidNumericId(hidUserId);
|
|
682
|
+
if (!readerId || !userId) return;
|
|
683
|
+
|
|
684
|
+
const existingRole = await getHidUserRole(readerId, userId);
|
|
685
|
+
if (isAdministrator) {
|
|
686
|
+
if (existingRole) {
|
|
687
|
+
await runObjectOperation(readerId, {
|
|
688
|
+
operation: "modify",
|
|
689
|
+
object: "user_roles",
|
|
690
|
+
where: {
|
|
691
|
+
user_roles: {
|
|
692
|
+
user_id: userId,
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
values: {
|
|
696
|
+
role: 1,
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
await runObjectOperation(readerId, {
|
|
703
|
+
operation: "create",
|
|
704
|
+
object: "user_roles",
|
|
705
|
+
values: [
|
|
706
|
+
{
|
|
707
|
+
user_id: userId,
|
|
708
|
+
role: 1,
|
|
709
|
+
},
|
|
710
|
+
],
|
|
711
|
+
});
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (existingRole) {
|
|
716
|
+
await runObjectOperation(readerId, {
|
|
717
|
+
operation: "destroy",
|
|
718
|
+
object: "user_roles",
|
|
719
|
+
where: {
|
|
720
|
+
user_roles: {
|
|
721
|
+
user_id: userId,
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function getHidUserRole(readerId: string, userId: number) {
|
|
729
|
+
const response = await runObjectOperation(readerId, {
|
|
730
|
+
operation: "load",
|
|
731
|
+
object: "user_roles",
|
|
732
|
+
where: {
|
|
733
|
+
user_roles: {
|
|
734
|
+
user_id: userId,
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
limit: 1,
|
|
738
|
+
offset: 0,
|
|
739
|
+
});
|
|
740
|
+
const data = response?.data || response || {};
|
|
741
|
+
const roles =
|
|
742
|
+
data?.user_roles ||
|
|
743
|
+
data?.data?.user_roles ||
|
|
744
|
+
data?.result?.user_roles ||
|
|
745
|
+
[];
|
|
746
|
+
return Array.isArray(roles) ? roles[0] : undefined;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function loadAdministratorRoles(readerId: string) {
|
|
750
|
+
return runObjectOperation(readerId, {
|
|
751
|
+
operation: "load",
|
|
752
|
+
object: "user_roles",
|
|
753
|
+
where: {
|
|
754
|
+
user_roles: {
|
|
755
|
+
role: 1,
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
limit: 500,
|
|
759
|
+
offset: 0,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function getAdministratorRoleUserIds(response: Record<string, any> | null) {
|
|
764
|
+
const data = response?.data || response || {};
|
|
765
|
+
const roles =
|
|
766
|
+
data?.user_roles ||
|
|
767
|
+
data?.data?.user_roles ||
|
|
768
|
+
data?.result?.user_roles ||
|
|
769
|
+
[];
|
|
770
|
+
|
|
771
|
+
return new Set(
|
|
772
|
+
(Array.isArray(roles) ? roles : [])
|
|
773
|
+
.map((role) => toHidNumericId(role.user_id))
|
|
774
|
+
.filter((userId): userId is number => Boolean(userId)),
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function toHidUserObject(identity: Record<string, any>) {
|
|
779
|
+
return {
|
|
780
|
+
id: toHidNumericId(identity.hidUserId),
|
|
781
|
+
name: identity.metadata?.name || identity.name || `User ${identity.hidUserId || ""}`.trim(),
|
|
782
|
+
registration: identity.registration || "",
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function toHidNumericId(value: unknown) {
|
|
787
|
+
const raw = String(value ?? "").trim();
|
|
788
|
+
if (!raw) return undefined;
|
|
789
|
+
const numeric = Number(raw);
|
|
790
|
+
if (Number.isInteger(numeric) && numeric > 0) return numeric;
|
|
791
|
+
const match = raw.match(/(\d+)$/);
|
|
792
|
+
const parsed = match ? Number(match[1]) : NaN;
|
|
793
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function isAdministratorIdentity() {
|
|
797
|
+
return props.identityType === "admin";
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function buildUnitLabel() {
|
|
801
|
+
const parts = [form.block, form.level, form.unit].filter(Boolean);
|
|
802
|
+
return parts.length ? `BLK ${parts.join("/")}` : "";
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function getName(user?: Record<string, any> | null) {
|
|
806
|
+
return user?.metadata?.name || user?.name || "N/A";
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function getUnit(user?: Record<string, any> | null) {
|
|
810
|
+
return user?.metadata?.unitLabel || [user?.metadata?.block, user?.metadata?.level, user?.metadata?.unit].filter(Boolean).join("/") || "N/A";
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function getFacialData(user: Record<string, any>) {
|
|
814
|
+
return user?.metadata?.facialData || user?.metadata?.imageTimestamp || (user?.metadata?.photo ? user.hidUserId : "N/A");
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function getUserImageSrc(user: Record<string, any>) {
|
|
818
|
+
const hidUserId = toHidNumericId(user?.hidUserId);
|
|
819
|
+
return user?.metadata?.photo || (hidUserId ? userImages.value[String(hidUserId)] : "") || "";
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function getNextUid() {
|
|
823
|
+
const readerIds = await getExistingHidReaderIds();
|
|
824
|
+
const maxNumber = users.value.reduce((max, user) => {
|
|
825
|
+
const match = String(user.hidUserId ?? "").match(/^UID(\d+)$/i);
|
|
826
|
+
return Math.max(max, match ? Number(match[1]) : toHidNumericId(user.hidUserId) || 0);
|
|
827
|
+
}, 0);
|
|
828
|
+
const nextNumber = Math.max(maxNumber, ...readerIds, 0) + 1;
|
|
829
|
+
return `UID${String(nextNumber).padStart(6, "0")}`;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async function getNextRegistration() {
|
|
833
|
+
const existingUsers = await getExistingHidReaderUsers();
|
|
834
|
+
const registrations = existingUsers
|
|
835
|
+
.map((user) => String(user.registration || "").trim())
|
|
836
|
+
.filter(Boolean);
|
|
837
|
+
const numericRegistrations = registrations
|
|
838
|
+
.map((registration) => {
|
|
839
|
+
const match = registration.match(/^(\d+)$/);
|
|
840
|
+
return match ? { value: Number(match[1]), width: match[1].length } : undefined;
|
|
841
|
+
})
|
|
842
|
+
.filter((item): item is { value: number; width: number } => Boolean(item));
|
|
843
|
+
const maxRegistration = numericRegistrations.reduce((max, item) => Math.max(max, item.value), 0);
|
|
844
|
+
const width = Math.max(6, ...numericRegistrations.map((item) => item.width));
|
|
845
|
+
|
|
846
|
+
return String(maxRegistration + 1).padStart(width, "0");
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async function getExistingHidReaderIds() {
|
|
850
|
+
const users = await getExistingHidReaderUsers(["id"]);
|
|
851
|
+
return users
|
|
852
|
+
.map((user) => toHidNumericId(user.hidUserId) || 0)
|
|
853
|
+
.filter((value) => Number.isInteger(value) && value > 0);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function getExistingHidReaderUsers(fields?: string[]) {
|
|
857
|
+
if (!selectedReaderId.value) return [];
|
|
858
|
+
try {
|
|
859
|
+
const response = await runObjectOperation(selectedReaderId.value, {
|
|
860
|
+
operation: "load",
|
|
861
|
+
object: "users",
|
|
862
|
+
...(fields ? { fields } : {}),
|
|
863
|
+
limit: 500,
|
|
864
|
+
offset: 0,
|
|
865
|
+
});
|
|
866
|
+
return normalizeHidUsers(response);
|
|
867
|
+
} catch {
|
|
868
|
+
return [];
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function normalizeHidUsers(response: Record<string, any>) {
|
|
873
|
+
const items =
|
|
874
|
+
response?.data?.users ||
|
|
875
|
+
response?.data?.data?.users ||
|
|
876
|
+
response?.users ||
|
|
877
|
+
[];
|
|
878
|
+
|
|
879
|
+
return Array.isArray(items)
|
|
880
|
+
? items.map((user) => ({
|
|
881
|
+
_rowKey: `reader-${user.id}`,
|
|
882
|
+
hidUserId: String(user.id || ""),
|
|
883
|
+
rawHidUserId: user.id,
|
|
884
|
+
registration: user.registration,
|
|
885
|
+
cardNo: user.card_value || user.cardNo || "",
|
|
886
|
+
name: user.name,
|
|
887
|
+
metadata: {
|
|
888
|
+
name: user.name,
|
|
889
|
+
imageTimestamp: user.image_timestamp ? String(user.image_timestamp) : "",
|
|
890
|
+
lastAccessAt: fromUnixSeconds(user.last_access),
|
|
891
|
+
hidReaderSource: true,
|
|
892
|
+
},
|
|
893
|
+
}))
|
|
894
|
+
: [];
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], identities: Record<string, any>[]) {
|
|
898
|
+
const identityById = new Map<string, Record<string, any>>();
|
|
899
|
+
identities.forEach((identity) => {
|
|
900
|
+
const id = toHidNumericId(identity.hidUserId);
|
|
901
|
+
if (id) identityById.set(String(id), identity);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
const merged = readerUsers.map((readerUser) => {
|
|
905
|
+
const identity = identityById.get(String(toHidNumericId(readerUser.hidUserId)));
|
|
906
|
+
if (!identity) return readerUser;
|
|
907
|
+
|
|
908
|
+
return {
|
|
909
|
+
...readerUser,
|
|
910
|
+
...identity,
|
|
911
|
+
_rowKey: `mapped-${identity._id || readerUser.rawHidUserId}`,
|
|
912
|
+
hidUserId: identity.hidUserId || readerUser.hidUserId,
|
|
913
|
+
rawHidUserId: readerUser.rawHidUserId,
|
|
914
|
+
name: identity.metadata?.name || identity.name || readerUser.name,
|
|
915
|
+
registration: identity.registration || readerUser.registration,
|
|
916
|
+
cardNo: identity.cardNo || readerUser.cardNo,
|
|
917
|
+
metadata: {
|
|
918
|
+
...readerUser.metadata,
|
|
919
|
+
...identity.metadata,
|
|
920
|
+
imageTimestamp: identity.metadata?.imageTimestamp || readerUser.metadata?.imageTimestamp,
|
|
921
|
+
lastAccessAt: identity.metadata?.lastAccessAt || readerUser.metadata?.lastAccessAt,
|
|
922
|
+
},
|
|
923
|
+
};
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
return merged;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function filterUsers(items: Record<string, any>[]) {
|
|
930
|
+
const searchText = search.value.trim().toLowerCase();
|
|
931
|
+
return items.filter((item) => {
|
|
932
|
+
const matchesSearch = !searchText || [
|
|
933
|
+
getName(item),
|
|
934
|
+
item.hidUserId,
|
|
935
|
+
item.registration,
|
|
936
|
+
item.cardNo,
|
|
937
|
+
].some((value) => String(value || "").toLowerCase().includes(searchText));
|
|
938
|
+
const matchesStatus = status.value === "Status" || getMappingStatus(item) === status.value;
|
|
939
|
+
return matchesSearch && matchesStatus;
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function getTotalFromPageRange(value: string) {
|
|
944
|
+
const match = value.match(/\bof\s+(\d+)$/i);
|
|
945
|
+
return match ? Number(match[1]) : undefined;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function getMappingStatus(user: Record<string, any>) {
|
|
949
|
+
return hasMappedRecord(user) ? "Mapped" : "Unmapped";
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function getMappingStatusClass(user: Record<string, any>) {
|
|
953
|
+
return hasMappedRecord(user) ? "status-success" : "status-warning";
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function hasMappedRecord(user: Record<string, any>) {
|
|
957
|
+
return Boolean(user?.person || user?.user || user?.member || user?.visitor);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function formatDate(value?: string) {
|
|
961
|
+
if (!value) return "N/A";
|
|
962
|
+
const date = new Date(value);
|
|
963
|
+
return Number.isNaN(date.getTime()) ? "N/A" : date.toLocaleString();
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function formatHidUid(value: unknown) {
|
|
967
|
+
const id = toHidNumericId(value);
|
|
968
|
+
return id ? `UID${String(id).padStart(6, "0")}` : String(value || "");
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function fromUnixSeconds(value: unknown) {
|
|
972
|
+
const numeric = Number(value);
|
|
973
|
+
if (!Number.isFinite(numeric) || numeric <= 0) return "";
|
|
974
|
+
return new Date(numeric * 1000).toISOString();
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function showToast(message: string, color = "success") {
|
|
978
|
+
snackbar.message = message;
|
|
979
|
+
snackbar.color = color;
|
|
980
|
+
snackbar.show = true;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function getHidErrorMessage(error: any) {
|
|
984
|
+
const data =
|
|
985
|
+
error?.data ||
|
|
986
|
+
error?.response?._data ||
|
|
987
|
+
error?.response?.data ||
|
|
988
|
+
error?.cause?.data ||
|
|
989
|
+
error?.cause?.response?._data ||
|
|
990
|
+
{};
|
|
991
|
+
|
|
992
|
+
return String(
|
|
993
|
+
data?.message ||
|
|
994
|
+
data?.error ||
|
|
995
|
+
data?.statusMessage ||
|
|
996
|
+
error?.statusMessage ||
|
|
997
|
+
error?.message ||
|
|
998
|
+
"Unable to save HID user.",
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
</script>
|
|
1002
|
+
|
|
1003
|
+
<style scoped>
|
|
1004
|
+
.hid-user-enrollment {
|
|
1005
|
+
padding: 24px 0;
|
|
1006
|
+
width: 100%;
|
|
1007
|
+
min-width: 0;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
.toolbar {
|
|
1011
|
+
display: flex;
|
|
1012
|
+
flex-wrap: wrap;
|
|
1013
|
+
gap: 12px;
|
|
1014
|
+
align-items: center;
|
|
1015
|
+
margin-bottom: 16px;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
.toolbar-spacer {
|
|
1019
|
+
flex: 1;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
.enroll-button {
|
|
1023
|
+
min-width: 170px;
|
|
1024
|
+
background: #e4e4e4;
|
|
1025
|
+
color: #0a2638;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
.reader-input,
|
|
1029
|
+
.search-input {
|
|
1030
|
+
max-width: 240px;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
.status-input {
|
|
1034
|
+
max-width: 160px;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
.table-card {
|
|
1038
|
+
display: flex;
|
|
1039
|
+
flex-direction: column;
|
|
1040
|
+
min-height: 430px;
|
|
1041
|
+
overflow: hidden;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
.table-card :deep(table) {
|
|
1045
|
+
table-layout: fixed;
|
|
1046
|
+
width: 100%;
|
|
1047
|
+
min-width: 820px;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
.table-card :deep(th),
|
|
1051
|
+
.table-card :deep(td) {
|
|
1052
|
+
white-space: nowrap;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
.table-card :deep(th:nth-child(1)),
|
|
1056
|
+
.table-card :deep(td:nth-child(1)) {
|
|
1057
|
+
width: 24%;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
.table-card :deep(th:nth-child(2)),
|
|
1061
|
+
.table-card :deep(td:nth-child(2)) {
|
|
1062
|
+
width: 20%;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
.table-card :deep(th:nth-child(3)),
|
|
1066
|
+
.table-card :deep(td:nth-child(3)) {
|
|
1067
|
+
width: 12%;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
.table-card :deep(th:nth-child(4)),
|
|
1071
|
+
.table-card :deep(td:nth-child(4)) {
|
|
1072
|
+
width: 20%;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
.table-card :deep(th:nth-child(5)),
|
|
1076
|
+
.table-card :deep(td:nth-child(5)) {
|
|
1077
|
+
width: 16%;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
.table-card :deep(th:nth-child(6)),
|
|
1081
|
+
.table-card :deep(td:nth-child(6)) {
|
|
1082
|
+
width: 8%;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
.table-body {
|
|
1086
|
+
flex: 1;
|
|
1087
|
+
overflow-x: auto;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
.table-refresh,
|
|
1091
|
+
.table-footer {
|
|
1092
|
+
display: flex;
|
|
1093
|
+
align-items: center;
|
|
1094
|
+
gap: 8px;
|
|
1095
|
+
padding: 8px 10px;
|
|
1096
|
+
border-bottom: 1px solid #eeeeee;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
.table-footer {
|
|
1100
|
+
justify-content: flex-end;
|
|
1101
|
+
border-top: 1px solid #eeeeee;
|
|
1102
|
+
border-bottom: 0;
|
|
1103
|
+
background: #f6f6f6;
|
|
1104
|
+
min-height: 44px;
|
|
1105
|
+
padding: 4px 10px;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
.page-count {
|
|
1109
|
+
margin-left: auto;
|
|
1110
|
+
color: #475467;
|
|
1111
|
+
font-size: 12px;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
.empty-state {
|
|
1115
|
+
min-height: 280px;
|
|
1116
|
+
display: grid;
|
|
1117
|
+
place-items: center;
|
|
1118
|
+
color: #687382;
|
|
1119
|
+
font-size: 13px;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
.status-chip {
|
|
1123
|
+
min-width: 86px;
|
|
1124
|
+
justify-content: center;
|
|
1125
|
+
font-weight: 600;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
.status-success {
|
|
1129
|
+
background: #dff7e7 !important;
|
|
1130
|
+
color: #1b7f3a !important;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
.status-warning {
|
|
1134
|
+
background: #fff0cc !important;
|
|
1135
|
+
color: #9a6700 !important;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
.status-error {
|
|
1139
|
+
background: #fde2e1 !important;
|
|
1140
|
+
color: #b42318 !important;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
.small-title {
|
|
1144
|
+
font-size: 14px;
|
|
1145
|
+
font-weight: 700;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
.field-label {
|
|
1149
|
+
color: #4f5a66;
|
|
1150
|
+
font-size: 12px;
|
|
1151
|
+
font-weight: 600;
|
|
1152
|
+
margin-bottom: 6px;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
.field-label span {
|
|
1156
|
+
color: #e53935;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
.photo-wrap {
|
|
1160
|
+
display: grid;
|
|
1161
|
+
place-items: center;
|
|
1162
|
+
margin-bottom: 14px;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
.photo-button {
|
|
1166
|
+
width: 120px;
|
|
1167
|
+
height: 120px;
|
|
1168
|
+
border: 1px solid #d8dde4;
|
|
1169
|
+
border-radius: 8px;
|
|
1170
|
+
background: #ffffff;
|
|
1171
|
+
display: grid;
|
|
1172
|
+
place-items: center;
|
|
1173
|
+
color: #98a2b3;
|
|
1174
|
+
font-size: 12px;
|
|
1175
|
+
overflow: hidden;
|
|
1176
|
+
position: relative;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
.photo-preview {
|
|
1180
|
+
width: 100%;
|
|
1181
|
+
height: 100%;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
.photo-edit-icon {
|
|
1185
|
+
position: absolute;
|
|
1186
|
+
right: 8px;
|
|
1187
|
+
bottom: 8px;
|
|
1188
|
+
width: 28px;
|
|
1189
|
+
height: 28px;
|
|
1190
|
+
border-radius: 50%;
|
|
1191
|
+
background: #ffffff;
|
|
1192
|
+
color: #00324a;
|
|
1193
|
+
display: grid;
|
|
1194
|
+
place-items: center;
|
|
1195
|
+
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
.hid-face-avatar {
|
|
1199
|
+
border: 1px solid #d8dde4;
|
|
1200
|
+
background: #f2f4f7;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
.hidden-input {
|
|
1204
|
+
display: none;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
.detail-grid {
|
|
1208
|
+
display: grid;
|
|
1209
|
+
grid-template-columns: 1fr 1fr;
|
|
1210
|
+
gap: 8px 16px;
|
|
1211
|
+
font-size: 13px;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
.detail-grid span {
|
|
1215
|
+
color: #667085;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
.action-cancel,
|
|
1219
|
+
.action-submit {
|
|
1220
|
+
flex: 1;
|
|
1221
|
+
border-radius: 0;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
.action-submit {
|
|
1225
|
+
background: #062d42;
|
|
1226
|
+
color: #ffffff;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
@media (max-width: 960px) {
|
|
1230
|
+
.toolbar {
|
|
1231
|
+
align-items: stretch;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
.toolbar-spacer {
|
|
1235
|
+
display: none;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
.enroll-button,
|
|
1239
|
+
.reader-input,
|
|
1240
|
+
.search-input,
|
|
1241
|
+
.status-input {
|
|
1242
|
+
max-width: none;
|
|
1243
|
+
width: 100%;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
.table-card {
|
|
1247
|
+
min-height: 360px;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
@media (max-width: 600px) {
|
|
1252
|
+
.hid-user-enrollment {
|
|
1253
|
+
padding: 12px 0;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
.detail-grid {
|
|
1257
|
+
grid-template-columns: 1fr;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
.photo-button {
|
|
1261
|
+
width: 104px;
|
|
1262
|
+
height: 104px;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
.table-footer {
|
|
1266
|
+
justify-content: center;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
:global(.v-dialog > .v-overlay__content) {
|
|
1270
|
+
max-width: calc(100vw - 24px) !important;
|
|
1271
|
+
margin: 12px !important;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
</style>
|