@7365admin1/layer-common 4.82.1-staging.476 → 4.83.0
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 +10 -4
- package/assets/css/primitives.css +0 -57
- package/assets/css/screens.css +4 -8
- package/components/AccessCardDetailsDialog.vue +4 -57
- package/components/AccessCardPreviewDialog.vue +50 -291
- package/components/AppSelect.vue +4 -143
- package/components/AttendanceDetailsDialog.vue +1 -48
- package/components/AttendanceMain.vue +10 -104
- package/components/AttendanceSettingsDialog.vue +1 -53
- package/components/BuildingUnitFormEdit.vue +0 -6
- package/components/BulletinBoardView.vue +1 -1
- package/components/Dialog/UpdateMoreAction.vue +1 -11
- package/components/DocumentForm.vue +112 -407
- package/components/DocumentManagement.vue +12 -213
- package/components/Facility/BookingSetup.vue +0 -63
- package/components/HidAccessLogDashboard.vue +17 -74
- package/components/HidIntercomManagement.vue +14 -183
- package/components/HidQrCodeConfiguration.vue +195 -17
- package/components/HidReaderManagement.vue +0 -398
- package/components/HidReaderUserRoster.vue +3 -44
- package/components/HidUserEnrollment.vue +250 -1573
- package/components/InventoryMain.vue +2 -11
- package/components/InvitationClientForm.vue +1 -19
- package/components/MemberMain.vue +1 -2
- package/components/MyAttendanceMain.vue +2 -60
- package/components/NotificationBell.vue +2 -14
- package/components/RolePermissionMain.vue +2 -4
- package/components/TableMain.vue +7 -22
- package/components/VisitorForm.vue +0 -12
- package/components/VisitorManagement.vue +5 -267
- package/composables/useAttendance.ts +0 -53
- package/composables/useAttendancePermission.ts +0 -25
- package/composables/useCleaningPermission.ts +0 -5
- package/composables/useDocument.ts +0 -7
- package/composables/useFacility.ts +0 -11
- package/composables/useHidAmico.ts +0 -165
- package/composables/useHidNavigation.ts +1 -25
- package/composables/useLandscapingPermission.ts +0 -5
- package/composables/useMember.ts +0 -12
- package/composables/usePeople.ts +0 -19
- package/composables/usePestControlPermission.ts +0 -5
- package/composables/usePoolMaintenancePermission.ts +0 -5
- package/composables/useSecurityPermission.ts +0 -5
- package/composables/useServiceProvider.ts +2 -40
- package/constants/permissions.ts +0 -1
- package/nuxt.config.ts +0 -18
- package/package.json +2 -4
- 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-readers/index.vue +1 -1
- package/plugins/API.ts +0 -16
- package/types/attendance.d.ts +1 -13
- package/types/document.d.ts +0 -14
- package/types/facility.d.ts +0 -4
- package/types/member.d.ts +0 -4
- package/types/people.d.ts +1 -28
- package/types/service-provider.d.ts +0 -5
- package/utils/notification-target.ts +4 -43
- package/utils/role.ts +2 -15
- package/components/AttendanceProxyDialog.vue +0 -391
- package/components/BuildingUnitManagement.vue +0 -298
- package/components/HidAccessPermissions.vue +0 -424
- package/components/HidProfileQrCode.vue +0 -332
- package/composables/useHidReaderSelection.ts +0 -50
- package/composables/useSiteActions.ts +0 -29
- package/composables/useSiteCategory.ts +0 -45
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
- package/server/plugins/no-powered-by.ts +0 -7
- package/utils/access-suspended.ts +0 -18
- package/utils/attendance-proxy.ts +0 -110
- package/utils/hid-enrolment-subject.ts +0 -144
- package/utils/hid-permission-assignments.ts +0 -190
- package/utils/hid-reader-selection.ts +0 -52
- package/utils/occupancy-role.ts +0 -109
- package/utils/sanitize-html.ts +0 -16
- package/utils/security-headers.ts +0 -52
- package/utils/service-type.ts +0 -30
- package/utils/site-actions.ts +0 -18
package/components/AppSelect.vue
CHANGED
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
<template>
|
|
19
19
|
<v-menu
|
|
20
20
|
v-if="!multiple"
|
|
21
|
-
|
|
22
|
-
:close-on-content-click="!searchable"
|
|
21
|
+
:close-on-content-click="true"
|
|
23
22
|
:disabled="disabled"
|
|
24
23
|
location="bottom start"
|
|
25
24
|
offset="4"
|
|
@@ -53,27 +52,9 @@
|
|
|
53
52
|
</div>
|
|
54
53
|
</template>
|
|
55
54
|
|
|
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>
|
|
55
|
+
<div class="app-select__menu" role="listbox">
|
|
75
56
|
<button
|
|
76
|
-
v-for="opt in
|
|
57
|
+
v-for="opt in normalized"
|
|
77
58
|
:key="String(opt.value)"
|
|
78
59
|
type="button"
|
|
79
60
|
class="app-select__option"
|
|
@@ -85,14 +66,6 @@
|
|
|
85
66
|
<span class="app-select__option-text">{{ opt.title }}</span>
|
|
86
67
|
<v-icon v-if="isSingleOn(opt)" icon="mdi-check" size="17" />
|
|
87
68
|
</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>
|
|
96
69
|
</div>
|
|
97
70
|
</v-menu>
|
|
98
71
|
|
|
@@ -149,18 +122,8 @@
|
|
|
149
122
|
</template>
|
|
150
123
|
|
|
151
124
|
<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>
|
|
162
125
|
<button
|
|
163
|
-
v-for="opt in
|
|
126
|
+
v-for="opt in normalized"
|
|
164
127
|
:key="String(opt.value)"
|
|
165
128
|
type="button"
|
|
166
129
|
class="app-select__option"
|
|
@@ -175,9 +138,6 @@
|
|
|
175
138
|
/>
|
|
176
139
|
<span class="app-select__option-text">{{ opt.title }}</span>
|
|
177
140
|
</button>
|
|
178
|
-
<p v-if="searchable && !visibleOptions.length" class="app-select__empty">
|
|
179
|
-
{{ normalized.length ? "No matches" : "Nothing to choose from" }}
|
|
180
|
-
</p>
|
|
181
141
|
</div>
|
|
182
142
|
</v-menu>
|
|
183
143
|
</template>
|
|
@@ -194,34 +154,6 @@ defineOptions({ inheritAttrs: false });
|
|
|
194
154
|
*/
|
|
195
155
|
const model = defineModel<any>({ default: "" });
|
|
196
156
|
|
|
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
|
-
|
|
225
157
|
const props = defineProps({
|
|
226
158
|
label: { type: String, default: "" },
|
|
227
159
|
/** Strings, or the `{ title, value }` shape `v-select` already takes. */
|
|
@@ -236,22 +168,6 @@ const props = defineProps({
|
|
|
236
168
|
returnObject: { type: Boolean, default: false },
|
|
237
169
|
clearable: { type: Boolean, default: false },
|
|
238
170
|
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" },
|
|
255
171
|
});
|
|
256
172
|
|
|
257
173
|
const normalized = computed(() =>
|
|
@@ -262,56 +178,6 @@ const normalized = computed(() =>
|
|
|
262
178
|
)
|
|
263
179
|
);
|
|
264
180
|
|
|
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
|
-
|
|
315
181
|
/* A model entry is either the plain value or - under `return-object` - the
|
|
316
182
|
source item, so everything compares on this one key. */
|
|
317
183
|
const keyOf = (v: any) =>
|
|
@@ -339,11 +205,6 @@ const isSingleOn = (opt: any) =>
|
|
|
339
205
|
function selectSingle(opt: any) {
|
|
340
206
|
if (props.disabled) return;
|
|
341
207
|
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;
|
|
347
208
|
}
|
|
348
209
|
|
|
349
210
|
function clearSingle() {
|
|
@@ -12,24 +12,6 @@
|
|
|
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>
|
|
33
15
|
<v-row v-if="attendance" dense>
|
|
34
16
|
<v-col :cols="attendance.checkOut?.timestamp ? 6 : 12">
|
|
35
17
|
<v-row dense>
|
|
@@ -66,9 +48,6 @@
|
|
|
66
48
|
cover
|
|
67
49
|
></v-img>
|
|
68
50
|
</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>
|
|
72
51
|
<div class="att-dlg__frame att-dlg__frame--empty pa-8 text-center" v-else>
|
|
73
52
|
<span class="screen-hint att-dlg__meta">No image</span>
|
|
74
53
|
</div>
|
|
@@ -105,9 +84,6 @@
|
|
|
105
84
|
cover
|
|
106
85
|
></v-img>
|
|
107
86
|
</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>
|
|
111
87
|
<div class="att-dlg__frame att-dlg__frame--empty pa-8 text-center" v-else>
|
|
112
88
|
<span class="screen-hint att-dlg__meta">No image</span>
|
|
113
89
|
</div>
|
|
@@ -143,27 +119,7 @@ const props = defineProps({
|
|
|
143
119
|
const emit = defineEmits(["close"]);
|
|
144
120
|
|
|
145
121
|
const attendance = ref<Record<string, any> | null>(null);
|
|
146
|
-
const { getMyAttendanceById
|
|
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
|
-
}
|
|
122
|
+
const { getMyAttendanceById } = useAttendance();
|
|
167
123
|
const { formatDate } = useUtils();
|
|
168
124
|
const { getFileUrl } = useFile();
|
|
169
125
|
|
|
@@ -180,8 +136,6 @@ watch(
|
|
|
180
136
|
try {
|
|
181
137
|
const res = await getMyAttendanceById(newId);
|
|
182
138
|
attendance.value = res?.data || res;
|
|
183
|
-
clearPhotos();
|
|
184
|
-
void loadPrivatePhotos(newId, attendance.value);
|
|
185
139
|
} catch (err: any) {
|
|
186
140
|
console.error("Failed to load attendance by id", err);
|
|
187
141
|
}
|
|
@@ -193,7 +147,6 @@ watch(
|
|
|
193
147
|
watch(showDialog, (newVal) => {
|
|
194
148
|
if (!newVal) {
|
|
195
149
|
attendance.value = null;
|
|
196
|
-
clearPhotos();
|
|
197
150
|
}
|
|
198
151
|
});
|
|
199
152
|
</script>
|
|
@@ -17,68 +17,15 @@
|
|
|
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
|
-
|
|
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>
|
|
20
|
+
<v-btn
|
|
21
|
+
v-if="canManageAttendanceSettings"
|
|
22
|
+
variant="outlined"
|
|
23
|
+
class="text-none screen-btn-ghost"
|
|
24
|
+
@click="dialogShowSettings = true"
|
|
25
|
+
>
|
|
26
|
+
<v-icon class="mr-2">mdi-cog</v-icon>
|
|
27
|
+
Settings
|
|
28
|
+
</v-btn>
|
|
82
29
|
</template>
|
|
83
30
|
<template #item.checkInTimestamp="{ item }">
|
|
84
31
|
<span>{{ formatDate(item.checkInTimestamp) }}</span>
|
|
@@ -117,12 +64,6 @@
|
|
|
117
64
|
import useAttendance from "../composables/useAttendance";
|
|
118
65
|
import useWorkerTerm from "../composables/useWorkerTerm";
|
|
119
66
|
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";
|
|
126
67
|
|
|
127
68
|
const props = defineProps({
|
|
128
69
|
orgId: { type: String, default: "" },
|
|
@@ -136,7 +77,6 @@ const props = defineProps({
|
|
|
136
77
|
const canViewAllAttendance = computed(() => props.canViewAllAttendance);
|
|
137
78
|
const canViewAttendanceDetails = computed(() => props.canViewAttendanceDetails);
|
|
138
79
|
const canManageAttendanceSettings = computed(() => props.canManageAttendanceSettings);
|
|
139
|
-
const { canManageSiteDeviceMode } = useAttendancePermission();
|
|
140
80
|
|
|
141
81
|
const submitting = ref(false);
|
|
142
82
|
const page = ref(1);
|
|
@@ -153,7 +93,7 @@ const headers = [
|
|
|
153
93
|
{ title: "Total Hours", value: "totalHours" },
|
|
154
94
|
];
|
|
155
95
|
|
|
156
|
-
const { getAttendances
|
|
96
|
+
const { getAttendances } = useAttendance();
|
|
157
97
|
const { formatDate } = useUtils();
|
|
158
98
|
|
|
159
99
|
function calculateTotalHours(attendance: any): string {
|
|
@@ -214,40 +154,6 @@ async function handleRowClick(data: any) {
|
|
|
214
154
|
dialogShowMoreActions.value = true;
|
|
215
155
|
}
|
|
216
156
|
|
|
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
|
-
|
|
251
157
|
const onSettingsSaved = async (payload: any) => {
|
|
252
158
|
// TODO: Implement API call to save settings
|
|
253
159
|
await getAttendancesRefresh();
|
|
@@ -18,29 +18,6 @@
|
|
|
18
18
|
{{ error }}
|
|
19
19
|
</v-alert>
|
|
20
20
|
|
|
21
|
-
<!-- Site device mode (owner request 2026-10-01). Saved on its own,
|
|
22
|
-
straight away, through its own endpoint - the Update button below
|
|
23
|
-
never touches it. -->
|
|
24
|
-
<div v-if="canManageSiteDeviceMode" class="attendance-settings__device mb-4">
|
|
25
|
-
<v-switch
|
|
26
|
-
v-model="siteDeviceMode"
|
|
27
|
-
label="Site device mode"
|
|
28
|
-
color="accent"
|
|
29
|
-
inset
|
|
30
|
-
hide-details
|
|
31
|
-
:loading="deviceModeSaving"
|
|
32
|
-
:disabled="deviceModeSaving || loading"
|
|
33
|
-
data-test="site-device-mode-switch"
|
|
34
|
-
@update:model-value="onDeviceModeToggle"
|
|
35
|
-
/>
|
|
36
|
-
<span class="attendance-settings__hint">
|
|
37
|
-
{{ siteDeviceMode ? "ON" : "OFF" }} for this site. When on, one shared
|
|
38
|
-
phone at this site can check staff in and out: each person takes a photo
|
|
39
|
-
and types their name and NRIC. Only the last 4 characters of the NRIC are
|
|
40
|
-
saved. This switch saves straight away.
|
|
41
|
-
</span>
|
|
42
|
-
</div>
|
|
43
|
-
|
|
44
21
|
<v-form ref="formRef" v-model="valid" @submit.prevent="onSubmit">
|
|
45
22
|
<v-row dense>
|
|
46
23
|
<v-col cols="12">
|
|
@@ -181,28 +158,7 @@ const isMapSearchVisible = ref(false);
|
|
|
181
158
|
|
|
182
159
|
const radiusInMeters = computed(() => radius.value * 1609.34);
|
|
183
160
|
|
|
184
|
-
const { getAttendanceSettings, updateAttendanceSettings
|
|
185
|
-
const { canManageSiteDeviceMode } = useAttendancePermission();
|
|
186
|
-
|
|
187
|
-
const siteDeviceMode = ref(false);
|
|
188
|
-
const deviceModeSaving = ref(false);
|
|
189
|
-
|
|
190
|
-
async function onDeviceModeToggle(next: boolean | null) {
|
|
191
|
-
const enabled = next === true;
|
|
192
|
-
deviceModeSaving.value = true;
|
|
193
|
-
try {
|
|
194
|
-
const res = await setSiteDeviceMode(props.site, enabled, props.serviceType);
|
|
195
|
-
siteDeviceMode.value = res?.siteDeviceMode === true;
|
|
196
|
-
snackbarText.value = res?.message || (enabled ? "Site device mode is ON." : "Site device mode is OFF.");
|
|
197
|
-
snackbarColor.value = "success";
|
|
198
|
-
snackbar.value = true;
|
|
199
|
-
} catch (err: any) {
|
|
200
|
-
siteDeviceMode.value = !enabled;
|
|
201
|
-
error.value = err?.data?.message || "Could not change Site device mode. Please try again.";
|
|
202
|
-
} finally {
|
|
203
|
-
deviceModeSaving.value = false;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
161
|
+
const { getAttendanceSettings, updateAttendanceSettings } = useAttendance();
|
|
206
162
|
|
|
207
163
|
const postalCode = ref("");
|
|
208
164
|
const country = ref("");
|
|
@@ -241,7 +197,6 @@ watchEffect(() => {
|
|
|
241
197
|
}
|
|
242
198
|
|
|
243
199
|
if (getAttendanceSettingsReq.value) {
|
|
244
|
-
siteDeviceMode.value = getAttendanceSettingsReq.value.siteDeviceMode === true;
|
|
245
200
|
isGeofencingEnabled.value =
|
|
246
201
|
getAttendanceSettingsReq.value.isGeofencingEnabled || false;
|
|
247
202
|
|
|
@@ -436,13 +391,6 @@ watch(showDialog, (newVal) => {
|
|
|
436
391
|
color: var(--text2);
|
|
437
392
|
}
|
|
438
393
|
|
|
439
|
-
/* Site device mode: its own framed block, because it saves on its own. */
|
|
440
|
-
.attendance-settings__device {
|
|
441
|
-
padding: 12px 14px;
|
|
442
|
-
border: 1px solid var(--border);
|
|
443
|
-
border-radius: var(--r-inner);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
394
|
/* Was a `variant="flat"` v-card, i.e. a second card inside the modal card. It
|
|
447
395
|
is a frame around the QR, so it takes the design's inner surface. */
|
|
448
396
|
.attendance-settings__qr {
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
<v-tab value="general">General</v-tab>
|
|
11
11
|
<v-tab value="residents">Residents/Tenants</v-tab>
|
|
12
12
|
<v-tab value="vehicles">Vehicles</v-tab>
|
|
13
|
-
<v-tab value="management">Management</v-tab>
|
|
14
13
|
<v-tab value="others">Others</v-tab>
|
|
15
14
|
</v-tabs>
|
|
16
15
|
</v-toolbar>
|
|
@@ -171,11 +170,6 @@
|
|
|
171
170
|
</TableMain>
|
|
172
171
|
</template>
|
|
173
172
|
|
|
174
|
-
<template v-else-if="tab === 'management'">
|
|
175
|
-
<BuildingUnitManagement :unit-id="buildingUnit._id" :site-id="buildingUnit?.site?._id"
|
|
176
|
-
:can-update="canUpdateUnit" />
|
|
177
|
-
</template>
|
|
178
|
-
|
|
179
173
|
<template v-else-if="tab === 'others'">
|
|
180
174
|
<v-row>
|
|
181
175
|
<!-- <v-col cols="12" md="6" class="mt-2">
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
<v-col class="font-weight-bold">{{ label }}:</v-col>
|
|
20
20
|
|
|
21
21
|
<v-col cols="9" class="px-10">
|
|
22
|
-
<span v-if="key === 'content'" v-html="
|
|
22
|
+
<span v-if="key === 'content'" v-html="getFieldValue(key)" />
|
|
23
23
|
<span v-else-if="key === 'noExpiration'">
|
|
24
24
|
<BulletinExpirationChip
|
|
25
25
|
:value="getFieldValue(key) as boolean"
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
Close
|
|
24
24
|
</v-btn>
|
|
25
25
|
|
|
26
|
-
<v-menu v-if="canUpdate || canDelete
|
|
26
|
+
<v-menu v-if="canUpdate || canDelete" contained>
|
|
27
27
|
<template #activator="{ props }">
|
|
28
28
|
<v-btn
|
|
29
29
|
variant="flat"
|
|
@@ -42,16 +42,6 @@
|
|
|
42
42
|
render-verified either. Only the row type and the destructive ink
|
|
43
43
|
change. -->
|
|
44
44
|
<v-list class="pa-0 update-more__list">
|
|
45
|
-
<!--
|
|
46
|
-
Screen-specific rows, above Edit and the destructive one.
|
|
47
|
-
|
|
48
|
-
Additive: a screen that passes nothing gets the menu it always
|
|
49
|
-
had, and `$slots.actions` is undefined unless a caller actually
|
|
50
|
-
defines the slot - so the button does not appear over an empty
|
|
51
|
-
list when a screen has neither permission nor extra rows.
|
|
52
|
-
-->
|
|
53
|
-
<slot name="actions" />
|
|
54
|
-
|
|
55
45
|
<v-list-item v-if="canUpdate" @click="emit('edit')">
|
|
56
46
|
<v-list-item-title>
|
|
57
47
|
{{ editButtonLabel }}
|