@7365admin1/layer-common 4.83.0 → 4.84.1-staging.481
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 +84 -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/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/MemberMain.vue +2 -1
- package/components/MemberServiceProviders.vue +43 -45
- package/components/MyAttendanceMain.vue +60 -2
- package/components/NotificationBell.vue +14 -2
- package/components/RolePermissionMain.vue +4 -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/API.ts +16 -0
- 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/access-suspended.ts +18 -0
- 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/role.ts +15 -2
- 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
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-dialog v-model="showDialog" max-width="520" persistent>
|
|
3
|
+
<v-card class="screen-modal" data-test="attendance-proxy-dialog">
|
|
4
|
+
<v-card-title>
|
|
5
|
+
{{ action === "checkIn" ? "Check in for someone" : "Check out for someone" }}
|
|
6
|
+
</v-card-title>
|
|
7
|
+
|
|
8
|
+
<v-card-text class="screen-modal__body">
|
|
9
|
+
<!-- 1. What are we doing? Two big choices. -->
|
|
10
|
+
<v-btn-toggle
|
|
11
|
+
v-model="action"
|
|
12
|
+
mandatory
|
|
13
|
+
divided
|
|
14
|
+
class="w-100 mb-4 proxy__toggle"
|
|
15
|
+
:disabled="submitting"
|
|
16
|
+
>
|
|
17
|
+
<v-btn value="checkIn" class="flex-grow-1 text-none" prepend-icon="mdi-login">Check in</v-btn>
|
|
18
|
+
<v-btn value="checkOut" class="flex-grow-1 text-none" prepend-icon="mdi-logout">Check out</v-btn>
|
|
19
|
+
</v-btn-toggle>
|
|
20
|
+
|
|
21
|
+
<!-- Check-out: today's open check-ins on this device, tap to pick. -->
|
|
22
|
+
<div v-if="action === 'checkOut'" class="mb-4">
|
|
23
|
+
<InputLabel title="Checked in today on this device" />
|
|
24
|
+
<div v-if="openLoading" class="screen-hint">Loading...</div>
|
|
25
|
+
<div v-else-if="openItems.length === 0" class="screen-hint">
|
|
26
|
+
Nobody is checked in on this device today. You can still type the name and NRIC below.
|
|
27
|
+
</div>
|
|
28
|
+
<v-list v-else density="compact" class="proxy__open" data-test="attendance-proxy-open">
|
|
29
|
+
<v-list-item
|
|
30
|
+
v-for="item in openItems"
|
|
31
|
+
:key="item._id"
|
|
32
|
+
:active="picked === item._id"
|
|
33
|
+
@click="pick(item)"
|
|
34
|
+
>
|
|
35
|
+
<v-list-item-title>{{ item.name }} · {{ item.nricMasked }}</v-list-item-title>
|
|
36
|
+
<v-list-item-subtitle>
|
|
37
|
+
In at {{ formatTime(item.checkInTimestamp) }}<span v-if="item.post"> · {{ item.post }}</span>
|
|
38
|
+
</v-list-item-subtitle>
|
|
39
|
+
</v-list-item>
|
|
40
|
+
</v-list>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<!-- 2. Live photo (required). -->
|
|
44
|
+
<InputLabel title="Photo of the person" required />
|
|
45
|
+
<v-card variant="outlined" class="mb-1 proxy__stage">
|
|
46
|
+
<video
|
|
47
|
+
v-show="cameraOn && !photoPreview"
|
|
48
|
+
ref="videoEl"
|
|
49
|
+
autoplay
|
|
50
|
+
playsinline
|
|
51
|
+
muted
|
|
52
|
+
class="proxy__media"
|
|
53
|
+
/>
|
|
54
|
+
<img v-if="photoPreview" :src="photoPreview" class="proxy__media" alt="Captured photo" />
|
|
55
|
+
<div v-if="!cameraOn && !photoPreview" class="proxy__placeholder">
|
|
56
|
+
<v-icon size="48" class="mb-2">mdi-camera</v-icon>
|
|
57
|
+
<span>{{ cameraError || "Starting the camera..." }}</span>
|
|
58
|
+
</div>
|
|
59
|
+
</v-card>
|
|
60
|
+
<div class="d-flex ga-2 mb-4">
|
|
61
|
+
<v-btn
|
|
62
|
+
v-if="!photoPreview"
|
|
63
|
+
class="text-none screen-btn-primary flex-grow-1"
|
|
64
|
+
variant="flat"
|
|
65
|
+
prepend-icon="mdi-camera"
|
|
66
|
+
:disabled="!cameraOn || submitting"
|
|
67
|
+
data-test="attendance-proxy-capture"
|
|
68
|
+
@click="capture"
|
|
69
|
+
>
|
|
70
|
+
Take photo
|
|
71
|
+
</v-btn>
|
|
72
|
+
<v-btn
|
|
73
|
+
v-else
|
|
74
|
+
class="text-none screen-btn-ghost flex-grow-1"
|
|
75
|
+
variant="flat"
|
|
76
|
+
prepend-icon="mdi-camera-retake"
|
|
77
|
+
:disabled="submitting"
|
|
78
|
+
@click="retake"
|
|
79
|
+
>
|
|
80
|
+
Retake
|
|
81
|
+
</v-btn>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<!-- 3. Who. -->
|
|
85
|
+
<InputLabel title="Name" required />
|
|
86
|
+
<v-text-field
|
|
87
|
+
v-model="name"
|
|
88
|
+
density="comfortable"
|
|
89
|
+
placeholder="e.g. Tan Ah Kow"
|
|
90
|
+
:error-messages="touched ? nameError(name) : ''"
|
|
91
|
+
:disabled="submitting"
|
|
92
|
+
data-test="attendance-proxy-name"
|
|
93
|
+
/>
|
|
94
|
+
|
|
95
|
+
<InputLabel title="NRIC / FIN" required />
|
|
96
|
+
<v-text-field
|
|
97
|
+
v-model="nric"
|
|
98
|
+
density="comfortable"
|
|
99
|
+
placeholder="Full number or just the last 4 (e.g. 932P)"
|
|
100
|
+
autocomplete="off"
|
|
101
|
+
:error-messages="touched && !parsed.ok ? parsed.error : ''"
|
|
102
|
+
:hint="parsed.ok ? `Will be saved as ${parsed.masked}` : ''"
|
|
103
|
+
persistent-hint
|
|
104
|
+
:disabled="submitting"
|
|
105
|
+
data-test="attendance-proxy-nric"
|
|
106
|
+
/>
|
|
107
|
+
|
|
108
|
+
<template v-if="action === 'checkIn'">
|
|
109
|
+
<InputLabel title="Shift / post (optional)" class="mt-2" />
|
|
110
|
+
<v-text-field
|
|
111
|
+
v-model="post"
|
|
112
|
+
density="comfortable"
|
|
113
|
+
placeholder="e.g. Main gate, night shift"
|
|
114
|
+
:error-messages="touched ? postError(post) : ''"
|
|
115
|
+
:disabled="submitting"
|
|
116
|
+
/>
|
|
117
|
+
</template>
|
|
118
|
+
|
|
119
|
+
<v-alert type="info" variant="tonal" density="compact" icon="mdi-shield-lock-outline" class="mt-2">
|
|
120
|
+
Only the last 4 characters of the NRIC are saved. The photo is kept private and
|
|
121
|
+
only people allowed to see attendance can open it.
|
|
122
|
+
</v-alert>
|
|
123
|
+
|
|
124
|
+
<v-alert v-if="error" type="error" variant="tonal" density="compact" class="mt-3" closable @click:close="error = ''">
|
|
125
|
+
{{ error }}
|
|
126
|
+
</v-alert>
|
|
127
|
+
</v-card-text>
|
|
128
|
+
|
|
129
|
+
<div class="screen-modal__footer">
|
|
130
|
+
<v-btn class="text-none screen-btn-ghost" variant="flat" :disabled="submitting" @click="close">
|
|
131
|
+
Cancel
|
|
132
|
+
</v-btn>
|
|
133
|
+
<v-btn
|
|
134
|
+
class="text-none screen-btn-primary"
|
|
135
|
+
variant="flat"
|
|
136
|
+
:prepend-icon="action === 'checkIn' ? 'mdi-login' : 'mdi-logout'"
|
|
137
|
+
:loading="submitting"
|
|
138
|
+
data-test="attendance-proxy-submit"
|
|
139
|
+
@click="submit"
|
|
140
|
+
>
|
|
141
|
+
{{ action === "checkIn" ? "Check in" : "Check out" }}
|
|
142
|
+
</v-btn>
|
|
143
|
+
</div>
|
|
144
|
+
</v-card>
|
|
145
|
+
</v-dialog>
|
|
146
|
+
</template>
|
|
147
|
+
|
|
148
|
+
<script setup lang="ts">
|
|
149
|
+
import useAttendance from "../composables/useAttendance";
|
|
150
|
+
import { nameError, parseNric, postError, proxyPayload } from "../utils/attendance-proxy";
|
|
151
|
+
|
|
152
|
+
const showDialog = defineModel({ type: Boolean, default: false });
|
|
153
|
+
const props = defineProps({
|
|
154
|
+
site: { type: String, required: true },
|
|
155
|
+
serviceType: { type: String, default: "" },
|
|
156
|
+
initialAction: { type: String as () => "checkIn" | "checkOut", default: "checkIn" },
|
|
157
|
+
});
|
|
158
|
+
const emit = defineEmits<{ (e: "saved", message: string): void; (e: "close"): void }>();
|
|
159
|
+
|
|
160
|
+
const { uploadAttendancePhoto, checkIn, proxyCheckOut, getOpenProxyCheckIns } = useAttendance();
|
|
161
|
+
|
|
162
|
+
const action = ref<"checkIn" | "checkOut">(props.initialAction);
|
|
163
|
+
const name = ref("");
|
|
164
|
+
const nric = ref("");
|
|
165
|
+
const post = ref("");
|
|
166
|
+
const touched = ref(false);
|
|
167
|
+
const submitting = ref(false);
|
|
168
|
+
const error = ref("");
|
|
169
|
+
const parsed = computed(() => parseNric(nric.value));
|
|
170
|
+
|
|
171
|
+
const openItems = ref<Array<Record<string, any>>>([]);
|
|
172
|
+
const openLoading = ref(false);
|
|
173
|
+
const picked = ref("");
|
|
174
|
+
|
|
175
|
+
const videoEl = ref<HTMLVideoElement | null>(null);
|
|
176
|
+
const stream = ref<MediaStream | null>(null);
|
|
177
|
+
const cameraOn = ref(false);
|
|
178
|
+
const cameraError = ref("");
|
|
179
|
+
const photoFile = ref<File | null>(null);
|
|
180
|
+
const photoPreview = ref("");
|
|
181
|
+
|
|
182
|
+
function formatTime(ts: unknown) {
|
|
183
|
+
return ts ? new Date(String(ts)).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "-";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function pick(item: Record<string, any>) {
|
|
187
|
+
picked.value = item._id;
|
|
188
|
+
name.value = item.name ?? "";
|
|
189
|
+
nric.value = item.nricLast4 ?? "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function loadOpen() {
|
|
193
|
+
openLoading.value = true;
|
|
194
|
+
try {
|
|
195
|
+
const res = await getOpenProxyCheckIns(props.site, props.serviceType);
|
|
196
|
+
openItems.value = res?.items ?? [];
|
|
197
|
+
} catch {
|
|
198
|
+
openItems.value = [];
|
|
199
|
+
} finally {
|
|
200
|
+
openLoading.value = false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function startCamera() {
|
|
205
|
+
cameraError.value = "";
|
|
206
|
+
try {
|
|
207
|
+
if (!navigator.mediaDevices?.getUserMedia) throw new Error("unsupported");
|
|
208
|
+
stream.value = await navigator.mediaDevices.getUserMedia({
|
|
209
|
+
video: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } },
|
|
210
|
+
audio: false,
|
|
211
|
+
});
|
|
212
|
+
cameraOn.value = true;
|
|
213
|
+
await nextTick();
|
|
214
|
+
if (videoEl.value) {
|
|
215
|
+
videoEl.value.srcObject = stream.value;
|
|
216
|
+
await videoEl.value.play().catch(() => undefined);
|
|
217
|
+
}
|
|
218
|
+
} catch (err: any) {
|
|
219
|
+
cameraOn.value = false;
|
|
220
|
+
cameraError.value =
|
|
221
|
+
err?.name === "NotAllowedError"
|
|
222
|
+
? "Camera access is blocked. Allow the camera in your browser settings, then reopen this window."
|
|
223
|
+
: "No camera could be started on this device.";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function stopCamera() {
|
|
228
|
+
stream.value?.getTracks().forEach((t) => t.stop());
|
|
229
|
+
stream.value = null;
|
|
230
|
+
cameraOn.value = false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function capture() {
|
|
234
|
+
const video = videoEl.value;
|
|
235
|
+
if (!video) return;
|
|
236
|
+
const canvas = document.createElement("canvas");
|
|
237
|
+
canvas.width = video.videoWidth;
|
|
238
|
+
canvas.height = video.videoHeight;
|
|
239
|
+
canvas.getContext("2d")?.drawImage(video, 0, 0);
|
|
240
|
+
const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/jpeg", 0.8));
|
|
241
|
+
if (!blob) return;
|
|
242
|
+
photoFile.value = new File([blob], "attendance.jpg", { type: "image/jpeg" });
|
|
243
|
+
photoPreview.value = URL.createObjectURL(blob);
|
|
244
|
+
stopCamera();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function retake() {
|
|
248
|
+
if (photoPreview.value) URL.revokeObjectURL(photoPreview.value);
|
|
249
|
+
photoPreview.value = "";
|
|
250
|
+
photoFile.value = null;
|
|
251
|
+
void startCamera();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Location if the browser gives one within 4 s; never blocks the check-in. */
|
|
255
|
+
function bestEffortLocation(): Promise<{ latitude: number; longitude: number } | undefined> {
|
|
256
|
+
return new Promise((resolve) => {
|
|
257
|
+
if (!navigator.geolocation) return resolve(undefined);
|
|
258
|
+
const timer = setTimeout(() => resolve(undefined), 4000);
|
|
259
|
+
navigator.geolocation.getCurrentPosition(
|
|
260
|
+
(p) => {
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
resolve({ latitude: p.coords.latitude, longitude: p.coords.longitude });
|
|
263
|
+
},
|
|
264
|
+
() => {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
resolve(undefined);
|
|
267
|
+
},
|
|
268
|
+
{ enableHighAccuracy: true, timeout: 4000, maximumAge: 60000 }
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function submit() {
|
|
274
|
+
touched.value = true;
|
|
275
|
+
error.value = "";
|
|
276
|
+
if (!photoFile.value) return void (error.value = "Take the person's photo first.");
|
|
277
|
+
if (nameError(name.value) || !parsed.value.ok || (action.value === "checkIn" && postError(post.value))) return;
|
|
278
|
+
|
|
279
|
+
// Masked HERE: from this line on only the last 4 (+ prefix) exist.
|
|
280
|
+
const proxy = proxyPayload({ name: name.value, nric: nric.value, post: post.value });
|
|
281
|
+
const shown = parsed.value.ok ? parsed.value.masked : "";
|
|
282
|
+
nric.value = "";
|
|
283
|
+
|
|
284
|
+
submitting.value = true;
|
|
285
|
+
try {
|
|
286
|
+
const [{ id: privatePhoto }, location] = await Promise.all([
|
|
287
|
+
uploadAttendancePhoto(props.site, photoFile.value, props.serviceType),
|
|
288
|
+
bestEffortLocation(),
|
|
289
|
+
]);
|
|
290
|
+
const timestamp = new Date().toISOString();
|
|
291
|
+
if (action.value === "checkIn") {
|
|
292
|
+
await checkIn(
|
|
293
|
+
props.site,
|
|
294
|
+
{ checkIn: { timestamp, privatePhoto, ...(location ? { location } : {}) }, proxy } as any,
|
|
295
|
+
props.serviceType
|
|
296
|
+
);
|
|
297
|
+
} else {
|
|
298
|
+
await proxyCheckOut(
|
|
299
|
+
props.site,
|
|
300
|
+
{
|
|
301
|
+
proxy: { name: proxy.name, nricLast4: proxy.nricLast4 },
|
|
302
|
+
checkOut: { timestamp, privatePhoto, ...(location ? { location } : {}) },
|
|
303
|
+
},
|
|
304
|
+
props.serviceType
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
emit(
|
|
308
|
+
"saved",
|
|
309
|
+
`${proxy.name} (${shown}) ${action.value === "checkIn" ? "checked in" : "checked out"} at ${formatTime(timestamp)}.`
|
|
310
|
+
);
|
|
311
|
+
close();
|
|
312
|
+
} catch (err: any) {
|
|
313
|
+
error.value = err?.data?.message || "Could not save. Please check the connection and try again.";
|
|
314
|
+
} finally {
|
|
315
|
+
submitting.value = false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function reset() {
|
|
320
|
+
name.value = "";
|
|
321
|
+
nric.value = "";
|
|
322
|
+
post.value = "";
|
|
323
|
+
touched.value = false;
|
|
324
|
+
error.value = "";
|
|
325
|
+
picked.value = "";
|
|
326
|
+
if (photoPreview.value) URL.revokeObjectURL(photoPreview.value);
|
|
327
|
+
photoPreview.value = "";
|
|
328
|
+
photoFile.value = null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function close() {
|
|
332
|
+
stopCamera();
|
|
333
|
+
reset();
|
|
334
|
+
showDialog.value = false;
|
|
335
|
+
emit("close");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
watch(showDialog, (open) => {
|
|
339
|
+
if (open) {
|
|
340
|
+
action.value = props.initialAction;
|
|
341
|
+
void startCamera();
|
|
342
|
+
void loadOpen();
|
|
343
|
+
} else {
|
|
344
|
+
stopCamera();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
watch(action, (next) => {
|
|
349
|
+
if (next === "checkOut") void loadOpen();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
onBeforeUnmount(stopCamera);
|
|
353
|
+
</script>
|
|
354
|
+
|
|
355
|
+
<style scoped>
|
|
356
|
+
.proxy__stage {
|
|
357
|
+
position: relative;
|
|
358
|
+
height: 260px;
|
|
359
|
+
overflow: hidden;
|
|
360
|
+
border-radius: var(--r-inner);
|
|
361
|
+
border-color: var(--border);
|
|
362
|
+
}
|
|
363
|
+
.proxy__media {
|
|
364
|
+
width: 100%;
|
|
365
|
+
height: 100%;
|
|
366
|
+
object-fit: cover;
|
|
367
|
+
display: block;
|
|
368
|
+
background: #000;
|
|
369
|
+
}
|
|
370
|
+
.proxy__placeholder {
|
|
371
|
+
height: 100%;
|
|
372
|
+
display: flex;
|
|
373
|
+
flex-direction: column;
|
|
374
|
+
align-items: center;
|
|
375
|
+
justify-content: center;
|
|
376
|
+
text-align: center;
|
|
377
|
+
padding: 16px;
|
|
378
|
+
background: var(--thead);
|
|
379
|
+
color: var(--muted);
|
|
380
|
+
font-size: var(--fs-cell);
|
|
381
|
+
}
|
|
382
|
+
.proxy__open {
|
|
383
|
+
border: 1px solid var(--border);
|
|
384
|
+
border-radius: var(--r-inner);
|
|
385
|
+
max-height: 200px;
|
|
386
|
+
overflow-y: auto;
|
|
387
|
+
}
|
|
388
|
+
.proxy__toggle .v-btn {
|
|
389
|
+
height: 48px;
|
|
390
|
+
}
|
|
391
|
+
</style>
|
|
@@ -18,6 +18,29 @@
|
|
|
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
|
+
|
|
21
44
|
<v-form ref="formRef" v-model="valid" @submit.prevent="onSubmit">
|
|
22
45
|
<v-row dense>
|
|
23
46
|
<v-col cols="12">
|
|
@@ -158,7 +181,28 @@ const isMapSearchVisible = ref(false);
|
|
|
158
181
|
|
|
159
182
|
const radiusInMeters = computed(() => radius.value * 1609.34);
|
|
160
183
|
|
|
161
|
-
const { getAttendanceSettings, updateAttendanceSettings } = useAttendance();
|
|
184
|
+
const { getAttendanceSettings, updateAttendanceSettings, setSiteDeviceMode } = useAttendance();
|
|
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
|
+
}
|
|
162
206
|
|
|
163
207
|
const postalCode = ref("");
|
|
164
208
|
const country = ref("");
|
|
@@ -197,6 +241,7 @@ watchEffect(() => {
|
|
|
197
241
|
}
|
|
198
242
|
|
|
199
243
|
if (getAttendanceSettingsReq.value) {
|
|
244
|
+
siteDeviceMode.value = getAttendanceSettingsReq.value.siteDeviceMode === true;
|
|
200
245
|
isGeofencingEnabled.value =
|
|
201
246
|
getAttendanceSettingsReq.value.isGeofencingEnabled || false;
|
|
202
247
|
|
|
@@ -391,6 +436,13 @@ watch(showDialog, (newVal) => {
|
|
|
391
436
|
color: var(--text2);
|
|
392
437
|
}
|
|
393
438
|
|
|
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
|
+
|
|
394
446
|
/* Was a `variant="flat"` v-card, i.e. a second card inside the modal card. It
|
|
395
447
|
is a frame around the QR, so it takes the design's inner surface. */
|
|
396
448
|
.attendance-settings__qr {
|
|
@@ -10,6 +10,7 @@
|
|
|
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>
|
|
13
14
|
<v-tab value="others">Others</v-tab>
|
|
14
15
|
</v-tabs>
|
|
15
16
|
</v-toolbar>
|
|
@@ -170,6 +171,11 @@
|
|
|
170
171
|
</TableMain>
|
|
171
172
|
</template>
|
|
172
173
|
|
|
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
|
+
|
|
173
179
|
<template v-else-if="tab === 'others'">
|
|
174
180
|
<v-row>
|
|
175
181
|
<!-- <v-col cols="12" md="6" class="mt-2">
|