@7365admin1/layer-common 4.84.0 → 4.84.1-staging.482
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 +4 -4
- package/assets/css/primitives.css +107 -0
- package/assets/css/screens.css +8 -4
- package/components/AccessCardDetailsDialog.vue +57 -4
- package/components/AccessCardPreviewDialog.vue +291 -50
- package/components/AppSelect.vue +143 -4
- package/components/AttendanceDetailsDialog.vue +48 -1
- package/components/AttendanceMain.vue +104 -10
- package/components/AttendanceProxyDialog.vue +391 -0
- package/components/AttendanceSettingsDialog.vue +53 -1
- package/components/BuildingUnitFormEdit.vue +6 -0
- package/components/BuildingUnitManagement.vue +298 -0
- package/components/BulletinBoardView.vue +1 -1
- package/components/DashboardEmptyState.vue +8 -0
- package/components/DashboardMain.vue +14 -0
- package/components/DashboardPanel.vue +10 -0
- package/components/Dialog/UpdateMoreAction.vue +11 -1
- package/components/DocumentForm.vue +407 -112
- package/components/DocumentManagement.vue +213 -12
- package/components/Facility/BookingSetup.vue +63 -0
- package/components/HidAccessLogDashboard.vue +74 -17
- package/components/HidAccessPermissions.vue +424 -0
- package/components/HidIntercomManagement.vue +183 -14
- package/components/HidProfileQrCode.vue +332 -0
- package/components/HidQrCodeConfiguration.vue +17 -195
- package/components/HidReaderManagement.vue +398 -0
- package/components/HidReaderUserRoster.vue +44 -3
- package/components/HidUserEnrollment.vue +2239 -276
- package/components/InventoryMain.vue +11 -2
- package/components/InvitationClientForm.vue +19 -1
- package/components/MemberServiceProviders.vue +43 -45
- package/components/MyAttendanceMain.vue +60 -2
- package/components/NotificationBell.vue +14 -2
- package/components/TableMain.vue +22 -7
- package/components/VisitorForm.vue +12 -0
- package/components/VisitorManagement.vue +279 -7
- package/composables/useAttendance.ts +53 -0
- package/composables/useAttendancePermission.ts +25 -0
- package/composables/useCleaningPermission.ts +5 -0
- package/composables/useDocument.ts +7 -0
- package/composables/useFacility.ts +11 -0
- package/composables/useHidAmico.ts +165 -0
- package/composables/useHidNavigation.ts +25 -1
- package/composables/useHidReaderSelection.ts +50 -0
- package/composables/useLandscapingPermission.ts +5 -0
- package/composables/useMember.ts +12 -0
- package/composables/useNFCPatrolReportFilters.ts +21 -3
- package/composables/useNFCPatrolRoute.ts +8 -1
- package/composables/usePeople.ts +19 -0
- package/composables/usePestControlPermission.ts +5 -0
- package/composables/usePoolMaintenancePermission.ts +5 -0
- package/composables/useSecurityPermission.ts +5 -0
- package/composables/useServiceProvider.ts +45 -2
- package/composables/useSiteActions.ts +29 -0
- package/composables/useSiteCategory.ts +45 -0
- package/constants/permissions.ts +1 -0
- package/nuxt.config.ts +18 -0
- package/package.json +4 -2
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/plugins/vuetify.ts +6 -0
- package/server/plugins/no-powered-by.ts +7 -0
- package/types/attendance.d.ts +13 -1
- package/types/document.d.ts +14 -0
- package/types/facility.d.ts +4 -0
- package/types/member.d.ts +4 -0
- package/types/people.d.ts +28 -1
- package/types/service-provider.d.ts +5 -0
- package/types/site.d.ts +7 -1
- package/utils/attendance-proxy.ts +110 -0
- package/utils/hid-enrolment-subject.ts +344 -0
- package/utils/hid-permission-assignments.ts +198 -0
- package/utils/hid-reader-selection.ts +52 -0
- package/utils/notification-target.ts +43 -4
- package/utils/occupancy-role.ts +109 -0
- package/utils/sanitize-html.ts +16 -0
- package/utils/security-headers.ts +52 -0
- package/utils/service-type.ts +30 -0
- package/utils/site-actions.ts +18 -0
- package/utils/site-provider-members.ts +37 -0
package/components/AppSelect.vue
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
<template>
|
|
19
19
|
<v-menu
|
|
20
20
|
v-if="!multiple"
|
|
21
|
-
|
|
21
|
+
v-model="singleOpen"
|
|
22
|
+
:close-on-content-click="!searchable"
|
|
22
23
|
:disabled="disabled"
|
|
23
24
|
location="bottom start"
|
|
24
25
|
offset="4"
|
|
@@ -52,9 +53,27 @@
|
|
|
52
53
|
</div>
|
|
53
54
|
</template>
|
|
54
55
|
|
|
55
|
-
<div
|
|
56
|
+
<div
|
|
57
|
+
ref="singleMenu"
|
|
58
|
+
class="app-select__menu"
|
|
59
|
+
role="listbox"
|
|
60
|
+
@scroll.passive="onMenuScroll"
|
|
61
|
+
>
|
|
62
|
+
<div v-if="searchable" class="app-select__search">
|
|
63
|
+
<v-icon icon="mdi-magnify" size="16" />
|
|
64
|
+
<input
|
|
65
|
+
ref="singleSearchInput"
|
|
66
|
+
v-model="search"
|
|
67
|
+
type="text"
|
|
68
|
+
class="app-select__search-input"
|
|
69
|
+
:placeholder="searchPlaceholder"
|
|
70
|
+
aria-label="Search options"
|
|
71
|
+
@keydown.enter.prevent="selectFirstMatch"
|
|
72
|
+
@keydown.esc.stop="singleOpen = false"
|
|
73
|
+
>
|
|
74
|
+
</div>
|
|
56
75
|
<button
|
|
57
|
-
v-for="opt in
|
|
76
|
+
v-for="opt in visibleOptions"
|
|
58
77
|
:key="String(opt.value)"
|
|
59
78
|
type="button"
|
|
60
79
|
class="app-select__option"
|
|
@@ -66,6 +85,14 @@
|
|
|
66
85
|
<span class="app-select__option-text">{{ opt.title }}</span>
|
|
67
86
|
<v-icon v-if="isSingleOn(opt)" icon="mdi-check" size="17" />
|
|
68
87
|
</button>
|
|
88
|
+
<p v-if="searchable && !visibleOptions.length" class="app-select__empty">
|
|
89
|
+
{{ normalized.length ? "No matches" : "Nothing to choose from" }}
|
|
90
|
+
</p>
|
|
91
|
+
<!-- In the list, not under the field: the wait belongs where the reader's
|
|
92
|
+
eye already is, at the end of what they just scrolled through. -->
|
|
93
|
+
<p v-if="loadingMore" class="app-select__loading" role="status">
|
|
94
|
+
Loading more...
|
|
95
|
+
</p>
|
|
69
96
|
</div>
|
|
70
97
|
</v-menu>
|
|
71
98
|
|
|
@@ -122,8 +149,18 @@
|
|
|
122
149
|
</template>
|
|
123
150
|
|
|
124
151
|
<div class="app-select__menu">
|
|
152
|
+
<div v-if="searchable" class="app-select__search">
|
|
153
|
+
<v-icon icon="mdi-magnify" size="16" />
|
|
154
|
+
<input
|
|
155
|
+
v-model="search"
|
|
156
|
+
type="text"
|
|
157
|
+
class="app-select__search-input"
|
|
158
|
+
:placeholder="searchPlaceholder"
|
|
159
|
+
aria-label="Search options"
|
|
160
|
+
>
|
|
161
|
+
</div>
|
|
125
162
|
<button
|
|
126
|
-
v-for="opt in
|
|
163
|
+
v-for="opt in visibleOptions"
|
|
127
164
|
:key="String(opt.value)"
|
|
128
165
|
type="button"
|
|
129
166
|
class="app-select__option"
|
|
@@ -138,6 +175,9 @@
|
|
|
138
175
|
/>
|
|
139
176
|
<span class="app-select__option-text">{{ opt.title }}</span>
|
|
140
177
|
</button>
|
|
178
|
+
<p v-if="searchable && !visibleOptions.length" class="app-select__empty">
|
|
179
|
+
{{ normalized.length ? "No matches" : "Nothing to choose from" }}
|
|
180
|
+
</p>
|
|
141
181
|
</div>
|
|
142
182
|
</v-menu>
|
|
143
183
|
</template>
|
|
@@ -154,6 +194,34 @@ defineOptions({ inheritAttrs: false });
|
|
|
154
194
|
*/
|
|
155
195
|
const model = defineModel<any>({ default: "" });
|
|
156
196
|
|
|
197
|
+
/**
|
|
198
|
+
* ASKING FOR THE NEXT PAGE, FROM INSIDE THE LIST.
|
|
199
|
+
*
|
|
200
|
+
* A dropdown that pages should load as it is scrolled - a button under the
|
|
201
|
+
* field asks the reader to leave the list they are reading to fetch more of it.
|
|
202
|
+
* The menu is already the scroll container (`max-height: 280px; overflow-y:
|
|
203
|
+
* auto` in `primitives.css`), so the end of the list is the natural trigger.
|
|
204
|
+
*/
|
|
205
|
+
const emit = defineEmits<{ (e: "load-more"): void }>();
|
|
206
|
+
|
|
207
|
+
/** How close to the end counts as the end. About one row and a half. */
|
|
208
|
+
const LOAD_MORE_SLACK_PX = 48;
|
|
209
|
+
|
|
210
|
+
const singleMenu = ref<HTMLElement | null>(null);
|
|
211
|
+
/* Scroll fires per pixel. This makes the ask happen once per ARRIVAL at the
|
|
212
|
+
end, not once per event, and rearms when the list grows under the reader. */
|
|
213
|
+
const askedAtBottom = ref(false);
|
|
214
|
+
|
|
215
|
+
function onMenuScroll(event: Event) {
|
|
216
|
+
const el = event.target as HTMLElement | null;
|
|
217
|
+
if (!el || !props.hasMore || props.loadingMore) return;
|
|
218
|
+
const near = el.scrollHeight - el.scrollTop - el.clientHeight <= LOAD_MORE_SLACK_PX;
|
|
219
|
+
if (near && !askedAtBottom.value) emit("load-more");
|
|
220
|
+
askedAtBottom.value = near;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
|
|
157
225
|
const props = defineProps({
|
|
158
226
|
label: { type: String, default: "" },
|
|
159
227
|
/** Strings, or the `{ title, value }` shape `v-select` already takes. */
|
|
@@ -168,6 +236,22 @@ const props = defineProps({
|
|
|
168
236
|
returnObject: { type: Boolean, default: false },
|
|
169
237
|
clearable: { type: Boolean, default: false },
|
|
170
238
|
placeholder: { type: String, default: "" },
|
|
239
|
+
/**
|
|
240
|
+
* A filter box at the top of the popover. Opt-in, so the 44 consumers that
|
|
241
|
+
* do not ask for it render exactly the DOM they render today - a list short
|
|
242
|
+
* enough to read does not want a search field in front of it.
|
|
243
|
+
*/
|
|
244
|
+
searchable: { type: Boolean, default: false },
|
|
245
|
+
/**
|
|
246
|
+
* MORE OPTIONS EXIST THAN ARE LOADED - see the `load-more` emit.
|
|
247
|
+
*
|
|
248
|
+
* Opt-in, and single-select only for now. A caller that leaves these alone
|
|
249
|
+
* behaves exactly as it did: no footer, no listener, no emit.
|
|
250
|
+
*/
|
|
251
|
+
hasMore: { type: Boolean, default: false },
|
|
252
|
+
/** A page is on its way; shows the footer and stops further asking. */
|
|
253
|
+
loadingMore: { type: Boolean, default: false },
|
|
254
|
+
searchPlaceholder: { type: String, default: "Search" },
|
|
171
255
|
});
|
|
172
256
|
|
|
173
257
|
const normalized = computed(() =>
|
|
@@ -178,6 +262,56 @@ const normalized = computed(() =>
|
|
|
178
262
|
)
|
|
179
263
|
);
|
|
180
264
|
|
|
265
|
+
/* ---- search ---------------------------------------------------------- */
|
|
266
|
+
|
|
267
|
+
const search = ref("");
|
|
268
|
+
const singleOpen = ref(false);
|
|
269
|
+
const singleSearchInput = ref<HTMLInputElement | null>(null);
|
|
270
|
+
|
|
271
|
+
/* Case-insensitive substring over what the option READS AS, not its value:
|
|
272
|
+
the title is the only part the user can see to type. */
|
|
273
|
+
const visibleOptions = computed(() => {
|
|
274
|
+
const term = search.value.trim().toLowerCase();
|
|
275
|
+
if (!props.searchable || !term) return normalized.value;
|
|
276
|
+
return normalized.value.filter((opt) =>
|
|
277
|
+
String(opt.title ?? "").toLowerCase().includes(term)
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
/*
|
|
282
|
+
* A page that does not fill the menu leaves nothing to scroll, and the reader
|
|
283
|
+
* would be stranded with no way to reach the rest. That is not hypothetical
|
|
284
|
+
* here: a page of ten whose rows are mostly filtered out by the caller can draw
|
|
285
|
+
* four options in a box built for eight. So whenever the list settles short of
|
|
286
|
+
* its own container, the next page is asked for straight away.
|
|
287
|
+
*/
|
|
288
|
+
watch(
|
|
289
|
+
() => [visibleOptions.value.length, props.hasMore, props.loadingMore, singleOpen.value],
|
|
290
|
+
async () => {
|
|
291
|
+
if (!props.hasMore || props.loadingMore || !singleOpen.value) return;
|
|
292
|
+
await nextTick();
|
|
293
|
+
const el = singleMenu.value;
|
|
294
|
+
if (el && el.scrollHeight <= el.clientHeight) emit("load-more");
|
|
295
|
+
},
|
|
296
|
+
{ flush: "post" },
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
/* A stale term would hide the list the next time the popover opens, so it is
|
|
300
|
+
dropped on every open - and the caret goes into the box, since opening a
|
|
301
|
+
searchable select is almost always a prelude to typing. */
|
|
302
|
+
watch(singleOpen, async (open) => {
|
|
303
|
+
if (!props.searchable) return;
|
|
304
|
+
search.value = "";
|
|
305
|
+
if (!open) return;
|
|
306
|
+
await nextTick();
|
|
307
|
+
singleSearchInput.value?.focus();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
function selectFirstMatch() {
|
|
311
|
+
const [first] = visibleOptions.value;
|
|
312
|
+
if (first) selectSingle(first);
|
|
313
|
+
}
|
|
314
|
+
|
|
181
315
|
/* A model entry is either the plain value or - under `return-object` - the
|
|
182
316
|
source item, so everything compares on this one key. */
|
|
183
317
|
const keyOf = (v: any) =>
|
|
@@ -205,6 +339,11 @@ const isSingleOn = (opt: any) =>
|
|
|
205
339
|
function selectSingle(opt: any) {
|
|
206
340
|
if (props.disabled) return;
|
|
207
341
|
model.value = props.returnObject ? opt.raw : String(opt.value);
|
|
342
|
+
// `close-on-content-click` has to be OFF while searching - it fires on the
|
|
343
|
+
// click that puts the caret in the search box, so the popover would shut the
|
|
344
|
+
// moment it was typed into. With it off, picking an option has to close the
|
|
345
|
+
// popover by hand.
|
|
346
|
+
if (props.searchable) singleOpen.value = false;
|
|
208
347
|
}
|
|
209
348
|
|
|
210
349
|
function clearSingle() {
|
|
@@ -12,6 +12,24 @@
|
|
|
12
12
|
<v-card-title class="text-capitalize">Attendance Details</v-card-title>
|
|
13
13
|
|
|
14
14
|
<v-card-text class="screen-modal__body pa-6">
|
|
15
|
+
<!-- Site device mode: who was actually checked in, and which account's
|
|
16
|
+
shared phone took it. Only the masked NRIC is ever shown. -->
|
|
17
|
+
<v-alert
|
|
18
|
+
v-if="attendance?.isProxy"
|
|
19
|
+
type="info"
|
|
20
|
+
variant="tonal"
|
|
21
|
+
density="compact"
|
|
22
|
+
class="mb-4"
|
|
23
|
+
data-test="attendance-proxy-details"
|
|
24
|
+
>
|
|
25
|
+
<div class="font-weight-medium">{{ attendance.proxy?.name }}</div>
|
|
26
|
+
<div>
|
|
27
|
+
NRIC {{ attendance.proxy?.nricMasked }}<span v-if="attendance.proxy?.post"> · {{ attendance.proxy.post }}</span>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="text-caption">
|
|
30
|
+
Taken on site device by {{ attendance.takenByName || attendance.userName || "-" }}
|
|
31
|
+
</div>
|
|
32
|
+
</v-alert>
|
|
15
33
|
<v-row v-if="attendance" dense>
|
|
16
34
|
<v-col :cols="attendance.checkOut?.timestamp ? 6 : 12">
|
|
17
35
|
<v-row dense>
|
|
@@ -48,6 +66,9 @@
|
|
|
48
66
|
cover
|
|
49
67
|
></v-img>
|
|
50
68
|
</div>
|
|
69
|
+
<div v-else-if="photoUrls.checkIn" class="att-dlg__frame">
|
|
70
|
+
<v-img :src="photoUrls.checkIn" height="280" width="100%" cover></v-img>
|
|
71
|
+
</div>
|
|
51
72
|
<div class="att-dlg__frame att-dlg__frame--empty pa-8 text-center" v-else>
|
|
52
73
|
<span class="screen-hint att-dlg__meta">No image</span>
|
|
53
74
|
</div>
|
|
@@ -84,6 +105,9 @@
|
|
|
84
105
|
cover
|
|
85
106
|
></v-img>
|
|
86
107
|
</div>
|
|
108
|
+
<div v-else-if="photoUrls.checkOut" class="att-dlg__frame">
|
|
109
|
+
<v-img :src="photoUrls.checkOut" height="280" width="100%" cover></v-img>
|
|
110
|
+
</div>
|
|
87
111
|
<div class="att-dlg__frame att-dlg__frame--empty pa-8 text-center" v-else>
|
|
88
112
|
<span class="screen-hint att-dlg__meta">No image</span>
|
|
89
113
|
</div>
|
|
@@ -119,7 +143,27 @@ const props = defineProps({
|
|
|
119
143
|
const emit = defineEmits(["close"]);
|
|
120
144
|
|
|
121
145
|
const attendance = ref<Record<string, any> | null>(null);
|
|
122
|
-
const { getMyAttendanceById } = useAttendance();
|
|
146
|
+
const { getMyAttendanceById, getAttendancePhotoUrl } = useAttendance();
|
|
147
|
+
|
|
148
|
+
// Site device mode photos are private: fetched with the session and shown from
|
|
149
|
+
// a local object URL, never from a public link.
|
|
150
|
+
const photoUrls = reactive<{ checkIn: string; checkOut: string }>({ checkIn: "", checkOut: "" });
|
|
151
|
+
function clearPhotos() {
|
|
152
|
+
for (const k of ["checkIn", "checkOut"] as const) {
|
|
153
|
+
if (photoUrls[k]) URL.revokeObjectURL(photoUrls[k]);
|
|
154
|
+
photoUrls[k] = "";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function loadPrivatePhotos(id: string, row: Record<string, any> | null) {
|
|
158
|
+
for (const k of ["checkIn", "checkOut"] as const) {
|
|
159
|
+
if (!row?.[k]?.privatePhoto) continue;
|
|
160
|
+
try {
|
|
161
|
+
photoUrls[k] = await getAttendancePhotoUrl(id, k);
|
|
162
|
+
} catch {
|
|
163
|
+
photoUrls[k] = "";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
123
167
|
const { formatDate } = useUtils();
|
|
124
168
|
const { getFileUrl } = useFile();
|
|
125
169
|
|
|
@@ -136,6 +180,8 @@ watch(
|
|
|
136
180
|
try {
|
|
137
181
|
const res = await getMyAttendanceById(newId);
|
|
138
182
|
attendance.value = res?.data || res;
|
|
183
|
+
clearPhotos();
|
|
184
|
+
void loadPrivatePhotos(newId, attendance.value);
|
|
139
185
|
} catch (err: any) {
|
|
140
186
|
console.error("Failed to load attendance by id", err);
|
|
141
187
|
}
|
|
@@ -147,6 +193,7 @@ watch(
|
|
|
147
193
|
watch(showDialog, (newVal) => {
|
|
148
194
|
if (!newVal) {
|
|
149
195
|
attendance.value = null;
|
|
196
|
+
clearPhotos();
|
|
150
197
|
}
|
|
151
198
|
});
|
|
152
199
|
</script>
|
|
@@ -17,15 +17,68 @@
|
|
|
17
17
|
<template #actions>
|
|
18
18
|
<!-- Was `color="black"`: a black slab in both themes, and the only
|
|
19
19
|
black surface on the page. Same button, the design's ghost. -->
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
20
|
+
<div class="d-flex flex-wrap align-center ga-2 w-100">
|
|
21
|
+
<div class="filter-field">
|
|
22
|
+
<v-text-field
|
|
23
|
+
v-model="searchInput"
|
|
24
|
+
density="compact"
|
|
25
|
+
placeholder="Search name or NRIC last 4"
|
|
26
|
+
clearable
|
|
27
|
+
width="280"
|
|
28
|
+
append-inner-icon="mdi-magnify"
|
|
29
|
+
hide-details
|
|
30
|
+
data-test="attendance-person-search"
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
<v-spacer />
|
|
34
|
+
<v-btn
|
|
35
|
+
variant="outlined"
|
|
36
|
+
class="text-none screen-btn-ghost"
|
|
37
|
+
:loading="exporting"
|
|
38
|
+
data-test="attendance-export-csv"
|
|
39
|
+
@click="exportCsv"
|
|
40
|
+
>
|
|
41
|
+
<v-icon class="mr-2">mdi-download</v-icon>
|
|
42
|
+
Export CSV
|
|
43
|
+
</v-btn>
|
|
44
|
+
<v-btn
|
|
45
|
+
v-if="canManageAttendanceSettings || canManageSiteDeviceMode"
|
|
46
|
+
variant="outlined"
|
|
47
|
+
class="text-none screen-btn-ghost"
|
|
48
|
+
@click="dialogShowSettings = true"
|
|
49
|
+
>
|
|
50
|
+
<v-icon class="mr-2">mdi-cog</v-icon>
|
|
51
|
+
Settings
|
|
52
|
+
</v-btn>
|
|
53
|
+
</div>
|
|
54
|
+
</template>
|
|
55
|
+
<!-- Site device mode rows name the PERSON checked in, their masked
|
|
56
|
+
NRIC, and the shared phone's account that took it. -->
|
|
57
|
+
<template #item.userName="{ item }">
|
|
58
|
+
<div v-if="item.isProxy" class="py-1">
|
|
59
|
+
<div class="font-weight-medium">
|
|
60
|
+
{{ item.proxyName }}
|
|
61
|
+
<v-chip
|
|
62
|
+
v-if="item.suspicious"
|
|
63
|
+
size="x-small"
|
|
64
|
+
color="warning"
|
|
65
|
+
variant="tonal"
|
|
66
|
+
class="ml-1"
|
|
67
|
+
prepend-icon="mdi-alert"
|
|
68
|
+
:title="item.suspiciousReason"
|
|
69
|
+
data-test="attendance-suspicious"
|
|
70
|
+
>
|
|
71
|
+
Check this
|
|
72
|
+
</v-chip>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="text-caption text-medium-emphasis">
|
|
75
|
+
NRIC {{ item.nricMasked }}<span v-if="item.post"> · {{ item.post }}</span>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="text-caption text-medium-emphasis">
|
|
78
|
+
Taken on site device by {{ item.userName || "-" }}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
<span v-else>{{ item.userName }}</span>
|
|
29
82
|
</template>
|
|
30
83
|
<template #item.checkInTimestamp="{ item }">
|
|
31
84
|
<span>{{ formatDate(item.checkInTimestamp) }}</span>
|
|
@@ -64,6 +117,12 @@
|
|
|
64
117
|
import useAttendance from "../composables/useAttendance";
|
|
65
118
|
import useWorkerTerm from "../composables/useWorkerTerm";
|
|
66
119
|
import useUtils from "../composables/useUtils";
|
|
120
|
+
import { useAttendancePermission } from "../composables/useAttendancePermission";
|
|
121
|
+
import {
|
|
122
|
+
ATTENDANCE_CSV_HEADERS,
|
|
123
|
+
attendanceCsvRow,
|
|
124
|
+
toCsv,
|
|
125
|
+
} from "../utils/attendance-proxy";
|
|
67
126
|
|
|
68
127
|
const props = defineProps({
|
|
69
128
|
orgId: { type: String, default: "" },
|
|
@@ -77,6 +136,7 @@ const props = defineProps({
|
|
|
77
136
|
const canViewAllAttendance = computed(() => props.canViewAllAttendance);
|
|
78
137
|
const canViewAttendanceDetails = computed(() => props.canViewAttendanceDetails);
|
|
79
138
|
const canManageAttendanceSettings = computed(() => props.canManageAttendanceSettings);
|
|
139
|
+
const { canManageSiteDeviceMode } = useAttendancePermission();
|
|
80
140
|
|
|
81
141
|
const submitting = ref(false);
|
|
82
142
|
const page = ref(1);
|
|
@@ -93,7 +153,7 @@ const headers = [
|
|
|
93
153
|
{ title: "Total Hours", value: "totalHours" },
|
|
94
154
|
];
|
|
95
155
|
|
|
96
|
-
const { getAttendances } = useAttendance();
|
|
156
|
+
const { getAttendances, attendancePhotoPath } = useAttendance();
|
|
97
157
|
const { formatDate } = useUtils();
|
|
98
158
|
|
|
99
159
|
function calculateTotalHours(attendance: any): string {
|
|
@@ -154,6 +214,40 @@ async function handleRowClick(data: any) {
|
|
|
154
214
|
dialogShowMoreActions.value = true;
|
|
155
215
|
}
|
|
156
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Export: every row matching the current search (up to 2000), with the person,
|
|
219
|
+
* masked NRIC, the photo links (they open only for a signed-in account with the
|
|
220
|
+
* right to see them) and the duplicate warning.
|
|
221
|
+
*/
|
|
222
|
+
const exporting = ref(false);
|
|
223
|
+
async function exportCsv() {
|
|
224
|
+
exporting.value = true;
|
|
225
|
+
try {
|
|
226
|
+
const res: any = await getAttendances({
|
|
227
|
+
page: 1,
|
|
228
|
+
limit: 2000,
|
|
229
|
+
search: searchInput.value ?? "",
|
|
230
|
+
site: props.site,
|
|
231
|
+
serviceType: props.serviceType,
|
|
232
|
+
});
|
|
233
|
+
const rows = (res?.items ?? []).map((item: any) =>
|
|
234
|
+
attendanceCsvRow(
|
|
235
|
+
{ ...item, totalHours: calculateTotalHours(item) },
|
|
236
|
+
(id, which) => `${window.location.origin}${attendancePhotoPath(id, which)}`,
|
|
237
|
+
(d) => formatDate(d as any)
|
|
238
|
+
)
|
|
239
|
+
);
|
|
240
|
+
const blob = new Blob([toCsv(ATTENDANCE_CSV_HEADERS, rows)], { type: "text/csv;charset=utf-8" });
|
|
241
|
+
const link = document.createElement("a");
|
|
242
|
+
link.href = URL.createObjectURL(blob);
|
|
243
|
+
link.download = `attendance-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
244
|
+
link.click();
|
|
245
|
+
URL.revokeObjectURL(link.href);
|
|
246
|
+
} finally {
|
|
247
|
+
exporting.value = false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
157
251
|
const onSettingsSaved = async (payload: any) => {
|
|
158
252
|
// TODO: Implement API call to save settings
|
|
159
253
|
await getAttendancesRefresh();
|