@7365admin1/layer-common 3.0.8 → 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 +6 -0
- package/components/DashboardMain.vue +1894 -844
- 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/NavigationItem.vue +11 -4
- package/components/SiteSettings.vue +24 -1
- package/components/VisitorForm.vue +195 -4
- package/components/VisitorFormSelection.vue +12 -2
- package/composables/useDashboard.ts +29 -2
- 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/useSiteSettings.ts +1 -1
- package/composables/useUser.ts +1 -2
- package/nuxt.config.ts +1 -1
- package/package.json +2 -1
- 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
|
@@ -0,0 +1,1233 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="hid-reader-management">
|
|
3
|
+
<div class="toolbar">
|
|
4
|
+
<v-btn
|
|
5
|
+
rounded="xl"
|
|
6
|
+
class="text-none add-button"
|
|
7
|
+
variant="flat"
|
|
8
|
+
prepend-icon="mdi-plus"
|
|
9
|
+
@click="openAdd"
|
|
10
|
+
>
|
|
11
|
+
Add HID Reader
|
|
12
|
+
</v-btn>
|
|
13
|
+
|
|
14
|
+
<div class="toolbar-spacer" />
|
|
15
|
+
|
|
16
|
+
<v-text-field
|
|
17
|
+
v-model="search"
|
|
18
|
+
placeholder="Search"
|
|
19
|
+
variant="outlined"
|
|
20
|
+
density="compact"
|
|
21
|
+
hide-details
|
|
22
|
+
class="search-input"
|
|
23
|
+
prepend-inner-icon="mdi-magnify"
|
|
24
|
+
/>
|
|
25
|
+
|
|
26
|
+
<v-select
|
|
27
|
+
v-model="status"
|
|
28
|
+
:items="statusOptions"
|
|
29
|
+
variant="outlined"
|
|
30
|
+
density="compact"
|
|
31
|
+
hide-details
|
|
32
|
+
class="status-input"
|
|
33
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<v-card flat border class="table-card">
|
|
37
|
+
<div class="table-refresh">
|
|
38
|
+
<v-btn icon="mdi-refresh" variant="text" density="comfortable" :loading="loading" @click="loadReaders" />
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<v-table>
|
|
42
|
+
<thead>
|
|
43
|
+
<tr>
|
|
44
|
+
<th>Name</th>
|
|
45
|
+
<th>Location</th>
|
|
46
|
+
<th>Base URL</th>
|
|
47
|
+
<th>Device ID</th>
|
|
48
|
+
<th>Status</th>
|
|
49
|
+
<th>Last sync at</th>
|
|
50
|
+
<th>Last sync message</th>
|
|
51
|
+
<th class="text-right"></th>
|
|
52
|
+
</tr>
|
|
53
|
+
</thead>
|
|
54
|
+
<tbody>
|
|
55
|
+
<tr v-for="reader in filteredReaders" :key="reader._id">
|
|
56
|
+
<td>{{ reader.name || "N/A" }}</td>
|
|
57
|
+
<td>{{ reader.location || "N/A" }}</td>
|
|
58
|
+
<td>
|
|
59
|
+
<a v-if="reader.baseUrl" :href="reader.baseUrl" target="_blank" rel="noopener">
|
|
60
|
+
{{ reader.baseUrl }}
|
|
61
|
+
</a>
|
|
62
|
+
<span v-else>N/A</span>
|
|
63
|
+
</td>
|
|
64
|
+
<td>{{ reader.deviceId || "N/A" }}</td>
|
|
65
|
+
<td>
|
|
66
|
+
<v-chip
|
|
67
|
+
size="small"
|
|
68
|
+
variant="flat"
|
|
69
|
+
class="status-chip"
|
|
70
|
+
:class="getStatusClass(resolveReaderStatus(reader))"
|
|
71
|
+
>
|
|
72
|
+
{{ formatReaderStatus(resolveReaderStatus(reader)) }}
|
|
73
|
+
</v-chip>
|
|
74
|
+
</td>
|
|
75
|
+
<td>{{ formatDate(reader.lastSyncAt) }}</td>
|
|
76
|
+
<td>{{ reader.lastSyncMessage || "N/A" }}</td>
|
|
77
|
+
<td class="text-right">
|
|
78
|
+
<v-menu>
|
|
79
|
+
<template #activator="{ props: menuProps }">
|
|
80
|
+
<v-btn v-bind="menuProps" icon="mdi-dots-vertical" variant="text" density="comfortable" />
|
|
81
|
+
</template>
|
|
82
|
+
|
|
83
|
+
<v-list density="compact" min-width="180">
|
|
84
|
+
<v-list-item title="View" @click="openView(reader)" />
|
|
85
|
+
<v-list-item title="Sync HID Reader" @click="openSyncConfirm(reader)" />
|
|
86
|
+
<v-list-item title="Test Connection" @click="openTestConfirm(reader)" />
|
|
87
|
+
<v-list-item title="Edit" @click="openEdit(reader)" />
|
|
88
|
+
<v-list-item title="Delete" class="text-error" @click="openDeleteConfirm(reader)" />
|
|
89
|
+
</v-list>
|
|
90
|
+
</v-menu>
|
|
91
|
+
</td>
|
|
92
|
+
</tr>
|
|
93
|
+
</tbody>
|
|
94
|
+
</v-table>
|
|
95
|
+
|
|
96
|
+
<div v-if="!filteredReaders.length" class="empty-state">
|
|
97
|
+
No HID devices added yet.
|
|
98
|
+
</div>
|
|
99
|
+
</v-card>
|
|
100
|
+
|
|
101
|
+
<HidReaderForm
|
|
102
|
+
v-model="formDialog"
|
|
103
|
+
:mode="selectedReader ? 'edit' : 'add'"
|
|
104
|
+
:reader="selectedReader"
|
|
105
|
+
:loading="saving"
|
|
106
|
+
@submit="saveReader"
|
|
107
|
+
/>
|
|
108
|
+
|
|
109
|
+
<v-dialog v-model="viewDialog" max-width="390">
|
|
110
|
+
<v-card rounded="lg">
|
|
111
|
+
<v-card-title class="small-title">{{ selectedReader?.name || "HID Reader" }}</v-card-title>
|
|
112
|
+
<v-card-text>
|
|
113
|
+
<div class="detail-grid">
|
|
114
|
+
<span>HID reader name</span><strong>{{ selectedReader?.name || "N/A" }}</strong>
|
|
115
|
+
<span>Base URL / IP address</span><strong>{{ selectedReader?.baseUrl || "N/A" }}</strong>
|
|
116
|
+
<span>Device ID</span><strong>{{ selectedReader?.deviceId || "N/A" }}</strong>
|
|
117
|
+
<span>Location</span><strong>{{ selectedReader?.location || "N/A" }}</strong>
|
|
118
|
+
<span>Monitor Path</span><strong>{{ selectedReader?.monitorPath || "N/A" }}</strong>
|
|
119
|
+
<span>Status</span><strong>{{ formatReaderStatus(resolveReaderStatus(selectedReader)) }}</strong>
|
|
120
|
+
<span>Username</span><strong>{{ selectedReader?.username || "N/A" }}</strong>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div class="dialog-actions-inline">
|
|
124
|
+
<v-btn rounded="xl" class="text-none" variant="tonal" @click="openTestConfirm(selectedReader)">Test Connection</v-btn>
|
|
125
|
+
<v-btn rounded="xl" class="text-none" variant="tonal" @click="openSyncConfirm(selectedReader)">Sync Reader</v-btn>
|
|
126
|
+
</div>
|
|
127
|
+
</v-card-text>
|
|
128
|
+
<v-card-actions>
|
|
129
|
+
<v-spacer />
|
|
130
|
+
<v-btn class="text-none" variant="text" @click="viewDialog = false">Close</v-btn>
|
|
131
|
+
</v-card-actions>
|
|
132
|
+
</v-card>
|
|
133
|
+
</v-dialog>
|
|
134
|
+
|
|
135
|
+
<v-dialog v-model="confirmDialog" max-width="390" persistent>
|
|
136
|
+
<v-card rounded="lg" class="reader-action-dialog">
|
|
137
|
+
<v-card-title class="small-title">{{ confirmTitle }}</v-card-title>
|
|
138
|
+
|
|
139
|
+
<v-card-text v-if="pendingAction === 'test'" class="reader-action-content">
|
|
140
|
+
<v-icon icon="mdi-database-sync-outline" color="#1976d2" size="48" />
|
|
141
|
+
<h3>Test Device Connection</h3>
|
|
142
|
+
<p>You are about to test the connection for {{ selectedReader?.name || "this HID Reader" }}.</p>
|
|
143
|
+
<div class="reader-action-meta">
|
|
144
|
+
<span>IP Address:</span>
|
|
145
|
+
<strong>{{ readerIpAddress }}</strong>
|
|
146
|
+
</div>
|
|
147
|
+
</v-card-text>
|
|
148
|
+
|
|
149
|
+
<v-card-text v-else-if="pendingAction === 'sync' && actionStage === 'confirm'" class="reader-action-content">
|
|
150
|
+
<v-icon icon="mdi-database-sync-outline" color="#1976d2" size="48" />
|
|
151
|
+
<h3>Sync Data</h3>
|
|
152
|
+
<p>You're about to sync data with {{ selectedReader?.name || "this HID Reader" }}.</p>
|
|
153
|
+
</v-card-text>
|
|
154
|
+
|
|
155
|
+
<v-card-text v-else-if="pendingAction === 'sync' && actionStage === 'review'" class="reader-action-content reader-sync-review">
|
|
156
|
+
<v-icon icon="mdi-database-sync-outline" color="#1976d2" size="48" />
|
|
157
|
+
<h3>Ready to Synchronize</h3>
|
|
158
|
+
<div class="sync-summary-list">
|
|
159
|
+
<div>
|
|
160
|
+
<span>New Enrolled</span>
|
|
161
|
+
<strong>{{ syncPreviewStats.newEnrolled }}</strong>
|
|
162
|
+
</div>
|
|
163
|
+
<div>
|
|
164
|
+
<span>Updated Information</span>
|
|
165
|
+
<strong>{{ syncPreviewStats.updatedInformation }}</strong>
|
|
166
|
+
</div>
|
|
167
|
+
<div>
|
|
168
|
+
<span>Facial Data</span>
|
|
169
|
+
<strong>{{ syncPreviewStats.facialData }}</strong>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
<v-textarea
|
|
173
|
+
v-model="syncMessage"
|
|
174
|
+
label="Sync Message"
|
|
175
|
+
variant="outlined"
|
|
176
|
+
density="compact"
|
|
177
|
+
hide-details
|
|
178
|
+
rows="4"
|
|
179
|
+
auto-grow
|
|
180
|
+
/>
|
|
181
|
+
</v-card-text>
|
|
182
|
+
|
|
183
|
+
<v-card-text v-else-if="pendingAction === 'sync' && actionStage === 'loading'" class="reader-action-content">
|
|
184
|
+
<v-progress-circular indeterminate color="#8a8f98" size="42" width="5" />
|
|
185
|
+
<h3>Sync Data to {{ selectedReader?.name || "HID Reader" }}...</h3>
|
|
186
|
+
</v-card-text>
|
|
187
|
+
|
|
188
|
+
<v-card-text v-else class="text-body-2">
|
|
189
|
+
{{ confirmMessage }}
|
|
190
|
+
</v-card-text>
|
|
191
|
+
|
|
192
|
+
<v-card-actions class="pa-0">
|
|
193
|
+
<v-btn class="text-none action-cancel" variant="flat" height="44" :disabled="actionLoading" @click="closeConfirmDialog">Cancel</v-btn>
|
|
194
|
+
<v-btn
|
|
195
|
+
class="text-none action-submit"
|
|
196
|
+
variant="flat"
|
|
197
|
+
height="44"
|
|
198
|
+
:loading="actionLoading && actionStage !== 'loading'"
|
|
199
|
+
:disabled="actionStage === 'loading'"
|
|
200
|
+
@click="runConfirmedAction"
|
|
201
|
+
>
|
|
202
|
+
{{ confirmActionLabel }}
|
|
203
|
+
</v-btn>
|
|
204
|
+
</v-card-actions>
|
|
205
|
+
</v-card>
|
|
206
|
+
</v-dialog>
|
|
207
|
+
|
|
208
|
+
<v-dialog v-model="resultDialog" max-width="390">
|
|
209
|
+
<v-card rounded="lg">
|
|
210
|
+
<v-card-title v-if="resultReaderName" class="small-title">{{ resultReaderName }}</v-card-title>
|
|
211
|
+
<v-card-text class="result-content" :class="{ 'result-with-stats': resultStats }">
|
|
212
|
+
<v-icon :icon="resultSuccess ? 'mdi-check-circle' : 'mdi-alert-circle'" :color="resultSuccess ? 'success' : 'warning'" size="42" />
|
|
213
|
+
<h3>{{ resultTitle }}</h3>
|
|
214
|
+
<p>{{ resultMessage }}</p>
|
|
215
|
+
<div v-if="resultStats" class="sync-summary-list">
|
|
216
|
+
<div>
|
|
217
|
+
<span>New Enrolled</span>
|
|
218
|
+
<strong>{{ resultStats.newEnrolled }}</strong>
|
|
219
|
+
</div>
|
|
220
|
+
<div>
|
|
221
|
+
<span>Updated Information</span>
|
|
222
|
+
<strong>{{ resultStats.updatedInformation }}</strong>
|
|
223
|
+
</div>
|
|
224
|
+
<div>
|
|
225
|
+
<span>New Photos</span>
|
|
226
|
+
<strong>{{ resultStats.facialData }}</strong>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</v-card-text>
|
|
230
|
+
<v-card-actions>
|
|
231
|
+
<v-spacer />
|
|
232
|
+
<v-btn class="text-none" variant="text" @click="resultDialog = false">Close</v-btn>
|
|
233
|
+
</v-card-actions>
|
|
234
|
+
</v-card>
|
|
235
|
+
</v-dialog>
|
|
236
|
+
</section>
|
|
237
|
+
</template>
|
|
238
|
+
|
|
239
|
+
<script setup lang="ts">
|
|
240
|
+
const props = defineProps({
|
|
241
|
+
site: {
|
|
242
|
+
type: String,
|
|
243
|
+
required: true,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const {
|
|
248
|
+
getReaders,
|
|
249
|
+
createReader,
|
|
250
|
+
updateReader,
|
|
251
|
+
deleteReader,
|
|
252
|
+
testReader,
|
|
253
|
+
getIdentities,
|
|
254
|
+
createIdentity,
|
|
255
|
+
runObjectOperation,
|
|
256
|
+
} = useHidAmico();
|
|
257
|
+
|
|
258
|
+
const readers = ref<Record<string, any>[]>([]);
|
|
259
|
+
const search = ref("");
|
|
260
|
+
const status = ref("Status");
|
|
261
|
+
const loading = ref(false);
|
|
262
|
+
const saving = ref(false);
|
|
263
|
+
const actionLoading = ref(false);
|
|
264
|
+
const formDialog = ref(false);
|
|
265
|
+
const viewDialog = ref(false);
|
|
266
|
+
const confirmDialog = ref(false);
|
|
267
|
+
const resultDialog = ref(false);
|
|
268
|
+
const selectedReader = ref<Record<string, any> | null>(null);
|
|
269
|
+
const pendingAction = ref<"test" | "sync" | "delete" | null>(null);
|
|
270
|
+
const actionStage = ref<"confirm" | "review" | "loading">("confirm");
|
|
271
|
+
const resultSuccess = ref(true);
|
|
272
|
+
const resultTitle = ref("");
|
|
273
|
+
const resultMessage = ref("");
|
|
274
|
+
const resultReaderName = ref("");
|
|
275
|
+
const syncMessage = ref("");
|
|
276
|
+
const syncPreviewPayload = ref<Record<string, any> | null>(null);
|
|
277
|
+
const syncPreviewStats = ref({
|
|
278
|
+
newEnrolled: 0,
|
|
279
|
+
updatedInformation: 0,
|
|
280
|
+
facialData: 0,
|
|
281
|
+
});
|
|
282
|
+
const resultStats = ref<typeof syncPreviewStats.value | null>(null);
|
|
283
|
+
|
|
284
|
+
const statusOptions = ["Status", "active", "inactive", "deleted", "offline"];
|
|
285
|
+
|
|
286
|
+
const filteredReaders = computed(() => {
|
|
287
|
+
const query = search.value.trim().toLowerCase();
|
|
288
|
+
return readers.value.filter((reader) => {
|
|
289
|
+
const matchesSearch = !query || [
|
|
290
|
+
reader.name,
|
|
291
|
+
reader.location,
|
|
292
|
+
reader.baseUrl,
|
|
293
|
+
reader.deviceId,
|
|
294
|
+
resolveReaderStatus(reader),
|
|
295
|
+
].some((value) => String(value ?? "").toLowerCase().includes(query));
|
|
296
|
+
|
|
297
|
+
const matchesStatus = status.value === "Status" || resolveReaderStatus(reader) === status.value;
|
|
298
|
+
|
|
299
|
+
return matchesSearch && matchesStatus;
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const confirmTitle = computed(() => {
|
|
304
|
+
if (pendingAction.value === "sync" && actionStage.value === "loading") return "Test Connection...";
|
|
305
|
+
if (pendingAction.value === "sync") return "Sync Data";
|
|
306
|
+
if (pendingAction.value === "test") return "Test Connection";
|
|
307
|
+
return "Delete";
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const confirmMessage = computed(() => {
|
|
311
|
+
if (pendingAction.value === "sync") {
|
|
312
|
+
return `You're about to sync data with ${selectedReader.value?.name || "this HID Reader"}.`;
|
|
313
|
+
}
|
|
314
|
+
if (pendingAction.value === "test") {
|
|
315
|
+
return `You are about to test the connection to ${selectedReader.value?.baseUrl || "this HID Reader"}.`;
|
|
316
|
+
}
|
|
317
|
+
return "Are you sure you want to permanently delete reader?";
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const confirmActionLabel = computed(() => {
|
|
321
|
+
if (pendingAction.value === "delete") return "Delete";
|
|
322
|
+
if (pendingAction.value === "sync" && actionStage.value === "confirm") return "Continue";
|
|
323
|
+
if (pendingAction.value === "sync" && actionStage.value === "review") return "Sync Now";
|
|
324
|
+
if (pendingAction.value === "test") return "Test Connection";
|
|
325
|
+
return "Confirm";
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const readerIpAddress = computed(() => getReaderHost(selectedReader.value?.baseUrl));
|
|
329
|
+
|
|
330
|
+
watch(
|
|
331
|
+
() => props.site,
|
|
332
|
+
() => loadReaders(),
|
|
333
|
+
{ immediate: true },
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
async function loadReaders() {
|
|
337
|
+
if (!props.site) return;
|
|
338
|
+
|
|
339
|
+
loading.value = true;
|
|
340
|
+
try {
|
|
341
|
+
const response = await getReaders({ site: props.site, page: 1, limit: 100 });
|
|
342
|
+
const items = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
|
|
343
|
+
readers.value = items.map(normalizeReaderStatus);
|
|
344
|
+
} finally {
|
|
345
|
+
loading.value = false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function openAdd() {
|
|
350
|
+
selectedReader.value = null;
|
|
351
|
+
formDialog.value = true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function openEdit(reader: Record<string, any>) {
|
|
355
|
+
selectedReader.value = reader;
|
|
356
|
+
formDialog.value = true;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function openView(reader: Record<string, any>) {
|
|
360
|
+
selectedReader.value = reader;
|
|
361
|
+
viewDialog.value = true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function openTestConfirm(reader?: Record<string, any> | null) {
|
|
365
|
+
if (reader) selectedReader.value = reader;
|
|
366
|
+
pendingAction.value = "test";
|
|
367
|
+
resetActionFlow();
|
|
368
|
+
confirmDialog.value = true;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function openSyncConfirm(reader?: Record<string, any> | null) {
|
|
372
|
+
if (reader) selectedReader.value = reader;
|
|
373
|
+
pendingAction.value = "sync";
|
|
374
|
+
resetActionFlow();
|
|
375
|
+
confirmDialog.value = true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function openDeleteConfirm(reader: Record<string, any>) {
|
|
379
|
+
selectedReader.value = reader;
|
|
380
|
+
pendingAction.value = "delete";
|
|
381
|
+
resetActionFlow();
|
|
382
|
+
confirmDialog.value = true;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function resetActionFlow() {
|
|
386
|
+
actionStage.value = "confirm";
|
|
387
|
+
syncMessage.value = "";
|
|
388
|
+
syncPreviewPayload.value = null;
|
|
389
|
+
syncPreviewStats.value = {
|
|
390
|
+
newEnrolled: 0,
|
|
391
|
+
updatedInformation: 0,
|
|
392
|
+
facialData: 0,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function closeConfirmDialog() {
|
|
397
|
+
confirmDialog.value = false;
|
|
398
|
+
resetActionFlow();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function saveReader(payload: Record<string, any>) {
|
|
402
|
+
saving.value = true;
|
|
403
|
+
try {
|
|
404
|
+
if (selectedReader.value?._id) {
|
|
405
|
+
await updateReader(selectedReader.value._id, payload);
|
|
406
|
+
} else {
|
|
407
|
+
await createReader({ ...payload, site: props.site });
|
|
408
|
+
}
|
|
409
|
+
formDialog.value = false;
|
|
410
|
+
await loadReaders();
|
|
411
|
+
} finally {
|
|
412
|
+
saving.value = false;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function runConfirmedAction() {
|
|
417
|
+
if (!selectedReader.value?._id || !pendingAction.value) return;
|
|
418
|
+
|
|
419
|
+
actionLoading.value = true;
|
|
420
|
+
try {
|
|
421
|
+
if (pendingAction.value === "test") {
|
|
422
|
+
const response = await testReader(selectedReader.value._id);
|
|
423
|
+
const message = response?.data?.message || response?.message || "Device connection successful.";
|
|
424
|
+
await applyReaderSyncState(selectedReader.value._id, "completed", message);
|
|
425
|
+
showResult(true, "Test Device Connection", message);
|
|
426
|
+
}
|
|
427
|
+
if (pendingAction.value === "sync") {
|
|
428
|
+
if (actionStage.value === "confirm") {
|
|
429
|
+
const payload = await buildSyncPayload(selectedReader.value._id);
|
|
430
|
+
syncPreviewPayload.value = payload;
|
|
431
|
+
syncPreviewStats.value = payload.stats;
|
|
432
|
+
syncMessage.value = payload.defaultMessage || "";
|
|
433
|
+
actionStage.value = "review";
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (actionStage.value !== "review" || !syncPreviewPayload.value) return;
|
|
438
|
+
|
|
439
|
+
actionStage.value = "loading";
|
|
440
|
+
const payload = syncPreviewPayload.value;
|
|
441
|
+
const hidUsersToImport = payload.hidUsersToImport || [];
|
|
442
|
+
const importedCount = hidUsersToImport.length
|
|
443
|
+
? await importHidUsers(selectedReader.value._id, hidUsersToImport)
|
|
444
|
+
: 0;
|
|
445
|
+
|
|
446
|
+
if (!payload.objects.length) {
|
|
447
|
+
if (importedCount) {
|
|
448
|
+
const stats = {
|
|
449
|
+
...syncPreviewStats.value,
|
|
450
|
+
newEnrolled: importedCount,
|
|
451
|
+
};
|
|
452
|
+
const message = syncMessage.value || `Synced ${importedCount} HID user(s) from reader.`;
|
|
453
|
+
await applyReaderSyncState(selectedReader.value._id, "completed", message);
|
|
454
|
+
showResult(true, "Sync Successful", message, {
|
|
455
|
+
readerName: selectedReader.value.name || "HID Reader",
|
|
456
|
+
stats,
|
|
457
|
+
});
|
|
458
|
+
confirmDialog.value = false;
|
|
459
|
+
resetActionFlow();
|
|
460
|
+
await loadReaders();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const message = syncMessage.value || "No active HID users found to sync.";
|
|
465
|
+
await applyReaderSyncState(selectedReader.value._id, "failed", message);
|
|
466
|
+
showResult(false, "Sync Device Failed", message);
|
|
467
|
+
confirmDialog.value = false;
|
|
468
|
+
resetActionFlow();
|
|
469
|
+
await loadReaders();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const result = await syncHidUsers(selectedReader.value._id, payload.users);
|
|
473
|
+
const importMessage = importedCount ? `${importedCount} HID user(s) imported from reader.` : "";
|
|
474
|
+
const message = syncMessage.value || [result.message, importMessage].filter(Boolean).join(" ");
|
|
475
|
+
await applyReaderSyncState(selectedReader.value._id, "completed", message);
|
|
476
|
+
showResult(true, "Sync Successful", message, {
|
|
477
|
+
readerName: selectedReader.value.name || "HID Reader",
|
|
478
|
+
stats: syncPreviewStats.value,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
if (pendingAction.value === "delete") {
|
|
482
|
+
await deleteReader(selectedReader.value._id);
|
|
483
|
+
showResult(true, "Reader Deleted", "HID reader has been deleted.");
|
|
484
|
+
}
|
|
485
|
+
confirmDialog.value = false;
|
|
486
|
+
resetActionFlow();
|
|
487
|
+
await loadReaders();
|
|
488
|
+
} catch (error: any) {
|
|
489
|
+
console.error("HID reader action failed:", error);
|
|
490
|
+
const friendlyMessage = getFriendlyHidError(error);
|
|
491
|
+
if ((pendingAction.value === "sync" || pendingAction.value === "test") && selectedReader.value?._id) {
|
|
492
|
+
await applyReaderSyncState(selectedReader.value._id, "failed", friendlyMessage);
|
|
493
|
+
}
|
|
494
|
+
showResult(
|
|
495
|
+
false,
|
|
496
|
+
pendingAction.value === "sync" ? "Sync Device Failed" : "Test Connection Device Failed",
|
|
497
|
+
friendlyMessage,
|
|
498
|
+
);
|
|
499
|
+
confirmDialog.value = false;
|
|
500
|
+
resetActionFlow();
|
|
501
|
+
} finally {
|
|
502
|
+
actionLoading.value = false;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function applyReaderSyncState(readerId: string, status: "completed" | "failed", message: string) {
|
|
507
|
+
const lastSyncAt = new Date().toISOString();
|
|
508
|
+
const patch: Record<string, any> = {
|
|
509
|
+
lastSyncAt,
|
|
510
|
+
lastSyncStatus: status,
|
|
511
|
+
lastSyncMessage: message,
|
|
512
|
+
};
|
|
513
|
+
if (status === "failed") {
|
|
514
|
+
patch.status = "offline";
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
await updateReader(readerId, patch);
|
|
519
|
+
} catch (error) {
|
|
520
|
+
console.error("Unable to persist HID reader sync state:", error);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
readers.value = readers.value.map((reader) =>
|
|
524
|
+
reader._id === readerId
|
|
525
|
+
? {
|
|
526
|
+
...reader,
|
|
527
|
+
...patch,
|
|
528
|
+
}
|
|
529
|
+
: reader,
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
if (selectedReader.value?._id === readerId) {
|
|
533
|
+
selectedReader.value = {
|
|
534
|
+
...selectedReader.value,
|
|
535
|
+
...patch,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function buildSyncPayload(readerId: string) {
|
|
541
|
+
const response = await getIdentities(readerId, {
|
|
542
|
+
page: 1,
|
|
543
|
+
limit: 500,
|
|
544
|
+
status: "active",
|
|
545
|
+
});
|
|
546
|
+
let identities = response?.items ?? response?.data?.items ?? response?.data?.identities ?? [];
|
|
547
|
+
|
|
548
|
+
if (!identities.length) {
|
|
549
|
+
const hidResponse = await runObjectOperation(readerId, {
|
|
550
|
+
operation: "load",
|
|
551
|
+
object: "users",
|
|
552
|
+
limit: 500,
|
|
553
|
+
offset: 0,
|
|
554
|
+
});
|
|
555
|
+
const hidUsers = normalizeHidUsers(hidResponse);
|
|
556
|
+
return {
|
|
557
|
+
objects: [],
|
|
558
|
+
importedCount: 0,
|
|
559
|
+
users: [],
|
|
560
|
+
hidUsersToImport: hidUsers,
|
|
561
|
+
stats: {
|
|
562
|
+
newEnrolled: hidUsers.length,
|
|
563
|
+
updatedInformation: 0,
|
|
564
|
+
facialData: countFacialData(hidUsers),
|
|
565
|
+
},
|
|
566
|
+
defaultMessage: hidUsers.length ? `Ready to import ${hidUsers.length} HID user(s) from reader.` : "",
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const users = identities
|
|
571
|
+
.map(toHidUserObject)
|
|
572
|
+
.filter((user: Record<string, any>) => user.id && user.name);
|
|
573
|
+
const existingHidUsers = await getExistingHidUsers(readerId);
|
|
574
|
+
const existingIds = new Set(existingHidUsers.map((user) => Number(user.hidUserId)));
|
|
575
|
+
const localIds = new Set(
|
|
576
|
+
users
|
|
577
|
+
.map((user: Record<string, any>) => Number(user.id))
|
|
578
|
+
.filter((id) => Number.isInteger(id) && id > 0),
|
|
579
|
+
);
|
|
580
|
+
const hidUsersToImport = existingHidUsers.filter((user) => {
|
|
581
|
+
const hidUserId = Number(user.hidUserId);
|
|
582
|
+
return Number.isInteger(hidUserId) && hidUserId > 0 && !localIds.has(hidUserId);
|
|
583
|
+
});
|
|
584
|
+
const newEnrolled = users.filter((user: Record<string, any>) => !existingIds.has(Number(user.id))).length;
|
|
585
|
+
const updatedInformation = users.filter((user: Record<string, any>) => existingIds.has(Number(user.id))).length;
|
|
586
|
+
const totalUsersToSync = users.length + hidUsersToImport.length;
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
objects: users.length
|
|
590
|
+
? [
|
|
591
|
+
{
|
|
592
|
+
object: "users",
|
|
593
|
+
values: users,
|
|
594
|
+
},
|
|
595
|
+
]
|
|
596
|
+
: [],
|
|
597
|
+
users,
|
|
598
|
+
hidUsersToImport,
|
|
599
|
+
importedCount: hidUsersToImport.length,
|
|
600
|
+
stats: {
|
|
601
|
+
newEnrolled: newEnrolled + hidUsersToImport.length,
|
|
602
|
+
updatedInformation,
|
|
603
|
+
facialData: countFacialData([...users, ...hidUsersToImport]),
|
|
604
|
+
},
|
|
605
|
+
defaultMessage: totalUsersToSync ? `Ready to sync ${totalUsersToSync} HID user(s).` : "",
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function syncHidUsers(readerId: string, users: Record<string, any>[]) {
|
|
610
|
+
const existingIds = await getExistingHidUserIds(readerId);
|
|
611
|
+
const usersToCreate = users.filter((user) => !existingIds.has(Number(user.id)));
|
|
612
|
+
const usersToModify = users.filter((user) => existingIds.has(Number(user.id)));
|
|
613
|
+
const results: Record<string, any>[] = [];
|
|
614
|
+
|
|
615
|
+
if (usersToModify.length) {
|
|
616
|
+
const modifyResults = [];
|
|
617
|
+
for (const user of usersToModify) {
|
|
618
|
+
const result = await runObjectOperation(readerId, {
|
|
619
|
+
operation: "modify",
|
|
620
|
+
object: "users",
|
|
621
|
+
where: {
|
|
622
|
+
users: {
|
|
623
|
+
id: user.id,
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
values: toHidUserValues(user),
|
|
627
|
+
});
|
|
628
|
+
modifyResults.push(result);
|
|
629
|
+
}
|
|
630
|
+
results.push({ operation: "modify", count: usersToModify.length, result: modifyResults });
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (usersToCreate.length) {
|
|
634
|
+
const result = await runObjectOperation(readerId, {
|
|
635
|
+
operation: "create",
|
|
636
|
+
object: "users",
|
|
637
|
+
values: usersToCreate.map(toHidUserCreateValues),
|
|
638
|
+
});
|
|
639
|
+
results.push({ operation: "create", count: usersToCreate.length, result });
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
for (const user of users) {
|
|
643
|
+
await syncHidUserRole(readerId, user.id, user._identityType === "admin");
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return {
|
|
647
|
+
results,
|
|
648
|
+
message: `Synced ${users.length} HID user(s). ${usersToModify.length} updated, ${usersToCreate.length} created.`,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function getExistingHidUserIds(readerId: string) {
|
|
653
|
+
const users = await getExistingHidUsers(readerId);
|
|
654
|
+
return new Set(users.map((user) => Number(user.hidUserId)));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function getExistingHidUsers(readerId: string) {
|
|
658
|
+
const hidResponse = await runObjectOperation(readerId, {
|
|
659
|
+
operation: "load",
|
|
660
|
+
object: "users",
|
|
661
|
+
limit: 500,
|
|
662
|
+
offset: 0,
|
|
663
|
+
});
|
|
664
|
+
return normalizeHidUsers(hidResponse);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function normalizeHidUsers(response: Record<string, any>) {
|
|
668
|
+
const users =
|
|
669
|
+
response?.data?.users ||
|
|
670
|
+
response?.data?.data?.users ||
|
|
671
|
+
response?.users ||
|
|
672
|
+
[];
|
|
673
|
+
|
|
674
|
+
return Array.isArray(users)
|
|
675
|
+
? users.map((user) => ({
|
|
676
|
+
hidUserId: user.id,
|
|
677
|
+
registration: user.registration,
|
|
678
|
+
cardNo: user.card_value || user.cardNo,
|
|
679
|
+
name: user.name,
|
|
680
|
+
metadata: {
|
|
681
|
+
name: user.name,
|
|
682
|
+
},
|
|
683
|
+
}))
|
|
684
|
+
: [];
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function importHidUsers(readerId: string, hidUsers: Record<string, any>[]) {
|
|
688
|
+
let importedCount = 0;
|
|
689
|
+
for (const user of hidUsers) {
|
|
690
|
+
if (!user.hidUserId) continue;
|
|
691
|
+
try {
|
|
692
|
+
await createIdentity(readerId, {
|
|
693
|
+
site: props.site,
|
|
694
|
+
hidUserId: user.hidUserId,
|
|
695
|
+
registration: user.registration || "",
|
|
696
|
+
cardNo: user.cardNo || "",
|
|
697
|
+
type: "resident",
|
|
698
|
+
status: "active",
|
|
699
|
+
metadata: {
|
|
700
|
+
name: user.metadata?.name || user.name || `HID User ${user.hidUserId}`,
|
|
701
|
+
hidImported: true,
|
|
702
|
+
importedAt: new Date().toISOString(),
|
|
703
|
+
},
|
|
704
|
+
});
|
|
705
|
+
importedCount += 1;
|
|
706
|
+
} catch {
|
|
707
|
+
// Existing users may already be mapped; keep syncing the remaining HID users.
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return importedCount;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function toHidUserObject(identity: Record<string, any>) {
|
|
714
|
+
return {
|
|
715
|
+
id: toHidNumericId(identity.hidUserId),
|
|
716
|
+
name: identity.metadata?.name || identity.name || `User ${identity.hidUserId || ""}`.trim(),
|
|
717
|
+
registration: identity.registration || "",
|
|
718
|
+
_identityType: identity.type || "resident",
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function toHidUserCreateValues(user: Record<string, any>) {
|
|
723
|
+
return {
|
|
724
|
+
id: user.id,
|
|
725
|
+
...toHidUserValues(user),
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function toHidUserValues(user: Record<string, any>) {
|
|
730
|
+
return {
|
|
731
|
+
name: user.name,
|
|
732
|
+
registration: user.registration || "",
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function syncHidUserRole(readerId: string, hidUserId: unknown, isAdministrator: boolean) {
|
|
737
|
+
const userId = toHidNumericId(hidUserId);
|
|
738
|
+
if (!readerId || !userId) return;
|
|
739
|
+
|
|
740
|
+
const existingRole = await getHidUserRole(readerId, userId);
|
|
741
|
+
if (isAdministrator) {
|
|
742
|
+
if (existingRole) {
|
|
743
|
+
await runObjectOperation(readerId, {
|
|
744
|
+
operation: "modify",
|
|
745
|
+
object: "user_roles",
|
|
746
|
+
where: {
|
|
747
|
+
user_roles: {
|
|
748
|
+
user_id: userId,
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
values: {
|
|
752
|
+
role: 1,
|
|
753
|
+
},
|
|
754
|
+
});
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
await runObjectOperation(readerId, {
|
|
759
|
+
operation: "create",
|
|
760
|
+
object: "user_roles",
|
|
761
|
+
values: [
|
|
762
|
+
{
|
|
763
|
+
user_id: userId,
|
|
764
|
+
role: 1,
|
|
765
|
+
},
|
|
766
|
+
],
|
|
767
|
+
});
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (existingRole) {
|
|
772
|
+
await runObjectOperation(readerId, {
|
|
773
|
+
operation: "destroy",
|
|
774
|
+
object: "user_roles",
|
|
775
|
+
where: {
|
|
776
|
+
user_roles: {
|
|
777
|
+
user_id: userId,
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function getHidUserRole(readerId: string, userId: number) {
|
|
785
|
+
const response = await runObjectOperation(readerId, {
|
|
786
|
+
operation: "load",
|
|
787
|
+
object: "user_roles",
|
|
788
|
+
where: {
|
|
789
|
+
user_roles: {
|
|
790
|
+
user_id: userId,
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
limit: 1,
|
|
794
|
+
offset: 0,
|
|
795
|
+
});
|
|
796
|
+
const data = response?.data || response || {};
|
|
797
|
+
const roles =
|
|
798
|
+
data?.user_roles ||
|
|
799
|
+
data?.data?.user_roles ||
|
|
800
|
+
data?.result?.user_roles ||
|
|
801
|
+
[];
|
|
802
|
+
return Array.isArray(roles) ? roles[0] : undefined;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function toHidNumericId(value: unknown) {
|
|
806
|
+
const raw = String(value ?? "").trim();
|
|
807
|
+
if (!raw) return undefined;
|
|
808
|
+
const numeric = Number(raw);
|
|
809
|
+
if (Number.isInteger(numeric) && numeric > 0) return numeric;
|
|
810
|
+
const match = raw.match(/(\d+)$/);
|
|
811
|
+
const parsed = match ? Number(match[1]) : NaN;
|
|
812
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function showResult(
|
|
816
|
+
success: boolean,
|
|
817
|
+
title: string,
|
|
818
|
+
message: string,
|
|
819
|
+
options: { readerName?: string; stats?: typeof syncPreviewStats.value | null } = {},
|
|
820
|
+
) {
|
|
821
|
+
resultSuccess.value = success;
|
|
822
|
+
resultTitle.value = title;
|
|
823
|
+
resultMessage.value = message;
|
|
824
|
+
resultReaderName.value = options.readerName || "";
|
|
825
|
+
resultStats.value = options.stats || null;
|
|
826
|
+
resultDialog.value = true;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function countFacialData(users: Record<string, any>[]) {
|
|
830
|
+
return users.filter((user) => user.face || user.photo || user.image || user.imageUrl || user.faceImage).length;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function getReaderHost(value?: string) {
|
|
834
|
+
if (!value) return "N/A";
|
|
835
|
+
try {
|
|
836
|
+
return new URL(value).hostname || value;
|
|
837
|
+
} catch {
|
|
838
|
+
return value.replace(/^https?:\/\//, "").split("/")[0] || value;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function getFriendlyHidError(error: any) {
|
|
843
|
+
const message = getHidErrorMessage(error);
|
|
844
|
+
const lowerMessage = message.toLowerCase();
|
|
845
|
+
|
|
846
|
+
if (message.includes("UNIQUE constraint failed: users.id")) {
|
|
847
|
+
return "This HID user already exists on the device. Please try Sync Reader again so the system can update the existing user instead of creating a duplicate.";
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (message.includes("Invalid member 'values'") || message.includes("object expected")) {
|
|
851
|
+
return "The HID device rejected the sync payload format. The user data exists, but the device expects a different update format. Please contact the developer to adjust the HID sync payload.";
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (
|
|
855
|
+
message.includes("Unable to connect") ||
|
|
856
|
+
message.includes("ECONNREFUSED") ||
|
|
857
|
+
message.includes("ETIMEDOUT") ||
|
|
858
|
+
message.includes("ENETUNREACH") ||
|
|
859
|
+
message.includes("EHOSTUNREACH") ||
|
|
860
|
+
message.includes("ECONNRESET") ||
|
|
861
|
+
message.includes("ECONNABORTED") ||
|
|
862
|
+
lowerMessage.includes("timeout") ||
|
|
863
|
+
lowerMessage.includes("network error") ||
|
|
864
|
+
lowerMessage.includes("no route to host")
|
|
865
|
+
) {
|
|
866
|
+
return "Unable to connect to the HID device. Please check that the reader is online, reachable from the server, and the Base URL/IP address is correct.";
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (lowerMessage.includes("login failed") || lowerMessage.includes("session")) {
|
|
870
|
+
return "Unable to verify the HID reader session. Please check the reader username and password, then try again.";
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (message.includes("reader is not active")) {
|
|
874
|
+
return "This HID reader is not active. Please enable the reader before syncing.";
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (message.includes("HID Amico request failed:")) {
|
|
878
|
+
return message;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
return message || "The HID device rejected the request. Please check the reader connection and try again.";
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function getHidErrorMessage(error: any) {
|
|
885
|
+
const data =
|
|
886
|
+
error?.data ||
|
|
887
|
+
error?.response?._data ||
|
|
888
|
+
error?.response?.data ||
|
|
889
|
+
error?.cause?.data ||
|
|
890
|
+
error?.cause?.response?._data ||
|
|
891
|
+
{};
|
|
892
|
+
|
|
893
|
+
return String(
|
|
894
|
+
data?.message ||
|
|
895
|
+
data?.error ||
|
|
896
|
+
data?.statusMessage ||
|
|
897
|
+
error?.statusMessage ||
|
|
898
|
+
error?.message ||
|
|
899
|
+
"Request failed.",
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function formatDate(value?: string) {
|
|
904
|
+
if (!value) return "N/A";
|
|
905
|
+
const date = new Date(value);
|
|
906
|
+
return Number.isNaN(date.getTime()) ? "N/A" : date.toLocaleString();
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function getStatusClass(value?: string) {
|
|
910
|
+
if (value === "active") return "status-success";
|
|
911
|
+
if (value === "inactive") return "status-warning";
|
|
912
|
+
if (value === "deleted") return "status-error";
|
|
913
|
+
return "status-muted";
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function resolveReaderStatus(reader?: Record<string, any> | null) {
|
|
917
|
+
if (!reader) return "";
|
|
918
|
+
if (reader.lastSyncStatus === "failed" && reader.status === "active") return "offline";
|
|
919
|
+
return reader.status || "offline";
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function normalizeReaderStatus(reader: Record<string, any>) {
|
|
923
|
+
return {
|
|
924
|
+
...reader,
|
|
925
|
+
status: resolveReaderStatus(reader),
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function formatReaderStatus(value?: string) {
|
|
930
|
+
if (!value) return "N/A";
|
|
931
|
+
if (value === "offline") return "Offline";
|
|
932
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
933
|
+
}
|
|
934
|
+
</script>
|
|
935
|
+
|
|
936
|
+
<style scoped>
|
|
937
|
+
.hid-reader-management {
|
|
938
|
+
padding: 24px 0;
|
|
939
|
+
width: 100%;
|
|
940
|
+
min-width: 0;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
.toolbar {
|
|
944
|
+
display: flex;
|
|
945
|
+
flex-wrap: wrap;
|
|
946
|
+
gap: 12px;
|
|
947
|
+
align-items: center;
|
|
948
|
+
margin-bottom: 16px;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
.toolbar-spacer {
|
|
952
|
+
flex: 1;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
.add-button {
|
|
956
|
+
min-width: 170px;
|
|
957
|
+
background: #e4e4e4;
|
|
958
|
+
color: #0a2638;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
.search-input {
|
|
962
|
+
max-width: 240px;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
.status-input {
|
|
966
|
+
max-width: 160px;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
.table-card {
|
|
970
|
+
min-height: 430px;
|
|
971
|
+
overflow-x: auto;
|
|
972
|
+
overflow-y: hidden;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
.table-card :deep(table) {
|
|
976
|
+
table-layout: fixed;
|
|
977
|
+
width: 100%;
|
|
978
|
+
min-width: 1120px;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
.table-card :deep(th),
|
|
982
|
+
.table-card :deep(td) {
|
|
983
|
+
white-space: nowrap;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
.table-card :deep(th:nth-child(1)),
|
|
987
|
+
.table-card :deep(td:nth-child(1)) {
|
|
988
|
+
width: 14%;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
.table-card :deep(th:nth-child(2)),
|
|
992
|
+
.table-card :deep(td:nth-child(2)) {
|
|
993
|
+
width: 12%;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
.table-card :deep(th:nth-child(3)),
|
|
997
|
+
.table-card :deep(td:nth-child(3)) {
|
|
998
|
+
width: 18%;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
.table-card :deep(th:nth-child(4)),
|
|
1002
|
+
.table-card :deep(td:nth-child(4)) {
|
|
1003
|
+
width: 12%;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
.table-card :deep(th:nth-child(5)),
|
|
1007
|
+
.table-card :deep(td:nth-child(5)) {
|
|
1008
|
+
width: 10%;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
.table-card :deep(th:nth-child(6)),
|
|
1012
|
+
.table-card :deep(td:nth-child(6)) {
|
|
1013
|
+
width: 14%;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
.table-card :deep(th:nth-child(7)),
|
|
1017
|
+
.table-card :deep(td:nth-child(7)) {
|
|
1018
|
+
width: 14%;
|
|
1019
|
+
overflow: hidden;
|
|
1020
|
+
text-overflow: ellipsis;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
.table-card :deep(th:nth-child(8)),
|
|
1024
|
+
.table-card :deep(td:nth-child(8)) {
|
|
1025
|
+
width: 6%;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
.table-refresh {
|
|
1029
|
+
display: flex;
|
|
1030
|
+
padding: 8px 10px;
|
|
1031
|
+
border-bottom: 1px solid #eeeeee;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
.empty-state {
|
|
1035
|
+
min-height: 280px;
|
|
1036
|
+
display: grid;
|
|
1037
|
+
place-items: center;
|
|
1038
|
+
color: #687382;
|
|
1039
|
+
font-size: 13px;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
.status-chip {
|
|
1043
|
+
min-width: 86px;
|
|
1044
|
+
justify-content: center;
|
|
1045
|
+
font-weight: 600;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
.status-success {
|
|
1049
|
+
background: #dff7e7 !important;
|
|
1050
|
+
color: #1b7f3a !important;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
.status-warning {
|
|
1054
|
+
background: #fff0cc !important;
|
|
1055
|
+
color: #9a6700 !important;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
.status-error {
|
|
1059
|
+
background: #fde2e1 !important;
|
|
1060
|
+
color: #b42318 !important;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
.status-muted {
|
|
1064
|
+
background: #e9edf2 !important;
|
|
1065
|
+
color: #475467 !important;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
.small-title {
|
|
1069
|
+
font-size: 14px;
|
|
1070
|
+
font-weight: 700;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
.detail-grid {
|
|
1074
|
+
display: grid;
|
|
1075
|
+
grid-template-columns: 1fr 1fr;
|
|
1076
|
+
gap: 8px 16px;
|
|
1077
|
+
font-size: 13px;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
.detail-grid span {
|
|
1081
|
+
color: #667085;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
.dialog-actions-inline {
|
|
1085
|
+
display: flex;
|
|
1086
|
+
flex-wrap: wrap;
|
|
1087
|
+
gap: 10px;
|
|
1088
|
+
margin-top: 20px;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
.reader-action-dialog {
|
|
1092
|
+
overflow: hidden;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
.reader-action-content {
|
|
1096
|
+
display: grid;
|
|
1097
|
+
justify-items: center;
|
|
1098
|
+
gap: 10px;
|
|
1099
|
+
min-height: 160px;
|
|
1100
|
+
padding: 28px 28px 26px;
|
|
1101
|
+
text-align: center;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
.reader-action-content h3 {
|
|
1105
|
+
margin: 4px 0 0;
|
|
1106
|
+
color: #111827;
|
|
1107
|
+
font-size: 15px;
|
|
1108
|
+
font-weight: 700;
|
|
1109
|
+
line-height: 1.3;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
.reader-action-content p {
|
|
1113
|
+
margin: 0;
|
|
1114
|
+
color: #667085;
|
|
1115
|
+
font-size: 13px;
|
|
1116
|
+
line-height: 1.45;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
.reader-action-meta {
|
|
1120
|
+
display: inline-flex;
|
|
1121
|
+
gap: 8px;
|
|
1122
|
+
align-items: center;
|
|
1123
|
+
color: #344054;
|
|
1124
|
+
font-size: 13px;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
.reader-action-meta strong {
|
|
1128
|
+
font-weight: 700;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
.reader-sync-review {
|
|
1132
|
+
justify-items: stretch;
|
|
1133
|
+
text-align: left;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
.reader-sync-review > .v-icon,
|
|
1137
|
+
.reader-sync-review > h3 {
|
|
1138
|
+
justify-self: center;
|
|
1139
|
+
text-align: center;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
.sync-summary-list {
|
|
1143
|
+
display: grid;
|
|
1144
|
+
gap: 10px;
|
|
1145
|
+
width: 100%;
|
|
1146
|
+
margin: 4px 0 10px;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
.sync-summary-list div {
|
|
1150
|
+
display: flex;
|
|
1151
|
+
align-items: center;
|
|
1152
|
+
justify-content: space-between;
|
|
1153
|
+
gap: 16px;
|
|
1154
|
+
color: #667085;
|
|
1155
|
+
font-size: 13px;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
.sync-summary-list strong {
|
|
1159
|
+
color: #111827;
|
|
1160
|
+
font-weight: 700;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
.action-cancel,
|
|
1164
|
+
.action-submit {
|
|
1165
|
+
flex: 1;
|
|
1166
|
+
border-radius: 0;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
.action-submit {
|
|
1170
|
+
background: #062d42;
|
|
1171
|
+
color: #ffffff;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
.result-content {
|
|
1175
|
+
display: grid;
|
|
1176
|
+
place-items: center;
|
|
1177
|
+
gap: 10px;
|
|
1178
|
+
text-align: center;
|
|
1179
|
+
min-height: 180px;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
.result-with-stats {
|
|
1183
|
+
place-items: stretch;
|
|
1184
|
+
text-align: left;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
.result-with-stats > .v-icon,
|
|
1188
|
+
.result-with-stats > h3,
|
|
1189
|
+
.result-with-stats > p {
|
|
1190
|
+
justify-self: center;
|
|
1191
|
+
text-align: center;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
@media (max-width: 960px) {
|
|
1195
|
+
.toolbar {
|
|
1196
|
+
align-items: stretch;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
.toolbar-spacer {
|
|
1200
|
+
display: none;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
.add-button,
|
|
1204
|
+
.search-input,
|
|
1205
|
+
.status-input {
|
|
1206
|
+
max-width: none;
|
|
1207
|
+
width: 100%;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
.table-card {
|
|
1211
|
+
min-height: 360px;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
@media (max-width: 600px) {
|
|
1216
|
+
.hid-reader-management {
|
|
1217
|
+
padding: 12px 0;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
.detail-grid {
|
|
1221
|
+
grid-template-columns: 1fr;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
.dialog-actions-inline .v-btn {
|
|
1225
|
+
width: 100%;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
:global(.v-dialog > .v-overlay__content) {
|
|
1229
|
+
max-width: calc(100vw - 24px) !important;
|
|
1230
|
+
margin: 12px !important;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
</style>
|