@7365admin1/layer-common 4.83.0 → 4.85.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.85.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e46f7cb: Attendance: Site device mode. Settings switch (new permission attendance-mgmt:manage-site-device-mode, or manage-attendance-settings), "Check in / Check out for someone" on My Attendance with live photo, name and NRIC masked in the browser (only the last 4 characters are sent), private photos in the details dialog, person/NRIC column with duplicate warning, person search and CSV export on the Attendance page.
8
+
9
+ ## 4.84.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 57abba1: Suspended access: when the API answers ACCESS_SUSPENDED (every membership of the person is suspended), the app signs out and the sign-in page shows the server's message. Role names are unique per site, so role pickers and the roles table show "Admin · St. Regis Residences" (`roleLabel`), and the Members role chip uses the same "Role · Site" form.
14
+
3
15
  ## 4.83.0
4
16
 
5
17
  ### Minor Changes
@@ -1968,11 +1980,11 @@
1968
1980
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1969
1981
  own rule from two endpoints the console already calls, unprojected:
1970
1982
 
1971
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1972
- GET /api/roles/id/:role that membership's role document
1983
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1984
+ GET /api/roles/id/:role that membership's role document
1973
1985
 
1974
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1975
- staff = member.type === "admin" && role.type === "admin"
1986
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1987
+ staff = member.type === "admin" && role.type === "admin"
1976
1988
 
1977
1989
  `role.default` is the marker because it is the only property of a platform
1978
1990
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -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
- <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>
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();
@@ -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 {
@@ -374,7 +374,7 @@
374
374
  v-model="selectedRole"
375
375
  :items="pickerRoles"
376
376
  item-value="_id"
377
- item-title="name"
377
+ :item-title="roleLabel"
378
378
  density="comfortable"
379
379
  :rules="[requiredRule]"
380
380
  :error-messages="
@@ -427,6 +427,7 @@ import {
427
427
  safeReturnTo,
428
428
  limitedSites,
429
429
  withCurrentRole,
430
+ roleLabel,
430
431
  } from "../utils/role";
431
432
  import { formatLastActive } from "../utils/last-active";
432
433
  import { formatMemberDate, memberAccess, memberPerson } from "../utils/member-row";
@@ -1,5 +1,31 @@
1
1
  <template>
2
2
  <v-row no-gutters align="center" justify="center">
3
+ <!-- Site device mode is ON for this site: this shared phone can check other
4
+ people in and out (owner request 2026-10-01). -->
5
+ <v-col v-if="siteDeviceMode && canCheckInOut" cols="12" class="mb-4">
6
+ <v-alert
7
+ type="info"
8
+ variant="tonal"
9
+ icon="mdi-cellphone-check"
10
+ data-test="site-device-mode-banner"
11
+ >
12
+ <div class="font-weight-medium">Site device mode is on</div>
13
+ <div class="mb-3">
14
+ This phone can check staff in and out for them: take their photo, type their
15
+ name and NRIC. Only the last 4 characters of the NRIC are saved.
16
+ </div>
17
+ <v-btn
18
+ class="text-none screen-btn-primary"
19
+ size="large"
20
+ variant="flat"
21
+ prepend-icon="mdi-account-clock"
22
+ data-test="attendance-proxy-open-btn"
23
+ @click="dialogShowProxy = true"
24
+ >
25
+ Check in / Check out for someone
26
+ </v-btn>
27
+ </v-alert>
28
+ </v-col>
3
29
  <v-col cols="12" lg="12">
4
30
  <TableMain
5
31
  title="Attendance Records"
@@ -71,6 +97,14 @@
71
97
  @close="dialogShowMoreActions = false"
72
98
  />
73
99
 
100
+ <AttendanceProxyDialog
101
+ v-if="siteDeviceMode"
102
+ v-model="dialogShowProxy"
103
+ :site="props.site"
104
+ :service-type="props.serviceType"
105
+ @saved="onProxySaved"
106
+ />
107
+
74
108
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
75
109
  </template>
76
110
 
@@ -107,7 +141,31 @@ const headers = [
107
141
  { title: "Total Hours", value: "totalHours" },
108
142
  ];
109
143
 
110
- const { getMyAttendances, checkIn, checkOut } = useAttendance();
144
+ const { getMyAttendances, checkIn, checkOut, getAttendanceSettings } = useAttendance();
145
+
146
+ // Site device mode: read once per site. A failed read means OFF (today's flow).
147
+ const siteDeviceMode = ref(false);
148
+ const dialogShowProxy = ref(false);
149
+ watch(
150
+ () => [props.site, props.serviceType],
151
+ async () => {
152
+ siteDeviceMode.value = false;
153
+ if (!props.site) return;
154
+ try {
155
+ const res: any = await getAttendanceSettings(props.site, props.serviceType);
156
+ siteDeviceMode.value = res?.siteDeviceMode === true;
157
+ } catch {
158
+ siteDeviceMode.value = false;
159
+ }
160
+ },
161
+ { immediate: true }
162
+ );
163
+
164
+ function onProxySaved(text: string) {
165
+ message.value = text;
166
+ messageColor.value = "success";
167
+ messageSnackbar.value = true;
168
+ }
111
169
  const { formatDate } = useUtils();
112
170
 
113
171
  const dialogShowMoreActions = ref(false);
@@ -170,7 +228,7 @@ watchEffect(() => {
170
228
  const today = new Date().toISOString().split("T")[0];
171
229
  todayAttendance.value =
172
230
  getMyAttendancesReq.value.items.find((item: any) => {
173
- if (!item.checkInTimestamp) return false;
231
+ if (!item.checkInTimestamp || item.isProxy) return false;
174
232
  const itemDate = new Date(item.checkInTimestamp)
175
233
  .toISOString()
176
234
  .split("T")[0];
@@ -58,6 +58,8 @@
58
58
  else. Two kinds share that list and they are governed differently,
59
59
  so the row has to say which it is.
60
60
  -->
61
+ <!-- "Admin · St. Regis Residences": names are unique per site (2026-10-01) -->
62
+ <template #item.name="{ item }">{{ roleLabel(item) }}</template>
61
63
  <template #item.scope="{ item }">
62
64
  <StatusChip
63
65
  :dot="false"
@@ -164,7 +166,7 @@
164
166
  <v-select
165
167
  v-model="memberReassignments[member._id]"
166
168
  :items="deletionPreview.replacementRoles"
167
- item-title="name"
169
+ :item-title="roleLabel"
168
170
  item-value="_id"
169
171
  label="Reassign role"
170
172
  density="comfortable"
@@ -204,7 +206,7 @@
204
206
 
205
207
  <script setup lang="ts">
206
208
  // Relative import: this layer's `utils/` is not auto-imported into consumers.
207
- import { safeReturnTo, showSiteField } from "../utils/role";
209
+ import { roleLabel, safeReturnTo, showSiteField } from "../utils/role";
208
210
  import { holdsPermission } from "../utils/permission-spellings";
209
211
  import {
210
212
  catalogueForOrg,
@@ -81,7 +81,60 @@ export default function useAttendance() {
81
81
  );
82
82
  }
83
83
 
84
+ // ---- Site device mode (check in/out for someone on a shared site phone) ----
85
+
86
+ function setSiteDeviceMode(site: string, enabled: boolean, serviceType: string = "") {
87
+ return useNuxtApp().$api<{ message: string; siteDeviceMode: boolean }>(
88
+ `/api/attendance-settings/site/${site}/device-mode`,
89
+ { method: "PUT", body: { serviceType, enabled } }
90
+ );
91
+ }
92
+
93
+ /** The person's photo, stored PRIVATELY by the server (never a public URL). */
94
+ function uploadAttendancePhoto(site: string, file: File, serviceType: string = "") {
95
+ const form = new FormData();
96
+ form.append("file", file);
97
+ return useNuxtApp().$api<{ id: string }>(`/api/attendances/site/${site}/photo`, {
98
+ method: "POST",
99
+ query: { serviceType },
100
+ body: form,
101
+ });
102
+ }
103
+
104
+ function proxyCheckOut(site: string, body: Record<string, any>, serviceType: string = "") {
105
+ return useNuxtApp().$api<{ message: string; id: string }>(
106
+ `/api/attendances/site/${site}/proxy-check-out`,
107
+ { method: "POST", body: { ...body, serviceType } }
108
+ );
109
+ }
110
+
111
+ function getOpenProxyCheckIns(site: string, serviceType: string = "") {
112
+ return useNuxtApp().$api<{ items: Array<Record<string, any>> }>(
113
+ `/api/attendances/site/${site}/proxy-open`,
114
+ { method: "GET", query: { serviceType } }
115
+ );
116
+ }
117
+
118
+ /** Path of the private photo; read through $api (session), never a public link. */
119
+ function attendancePhotoPath(id: string, which: "checkIn" | "checkOut") {
120
+ return `/api/attendances/id/${id}/photo/${which}`;
121
+ }
122
+
123
+ async function getAttendancePhotoUrl(id: string, which: "checkIn" | "checkOut") {
124
+ const blob = await useNuxtApp().$api<Blob>(attendancePhotoPath(id, which), {
125
+ method: "GET",
126
+ responseType: "blob",
127
+ });
128
+ return URL.createObjectURL(blob);
129
+ }
130
+
84
131
  return {
132
+ setSiteDeviceMode,
133
+ uploadAttendancePhoto,
134
+ proxyCheckOut,
135
+ getOpenProxyCheckIns,
136
+ attendancePhotoPath,
137
+ getAttendancePhotoUrl,
85
138
  getAttendances,
86
139
  getMyAttendances,
87
140
  getMyAttendanceById,
@@ -57,6 +57,30 @@ export function useAttendancePermission() {
57
57
  );
58
58
  });
59
59
 
60
+ // Site device mode switch. Widened, never swapped: the new key OR the existing
61
+ // settings key OR `*` - the same three the server accepts (core
62
+ // `SITE_DEVICE_MODE_PERMISSIONS`), so an admin who manages attendance
63
+ // settings today can use it without a role change.
64
+ const canManageSiteDeviceMode = computed(() => {
65
+ if (!userAppRole.value) return false;
66
+ if (moduleDenied("attendance-mgmt")) return false;
67
+ if (userAppRole.value.permissions.includes("*")) return true;
68
+ return (
69
+ hasPermission(
70
+ userAppRole.value,
71
+ permissions,
72
+ "attendance-mgmt",
73
+ "manage-site-device-mode"
74
+ ) ||
75
+ hasPermission(
76
+ userAppRole.value,
77
+ permissions,
78
+ "attendance-mgmt",
79
+ "manage-attendance-settings"
80
+ )
81
+ );
82
+ });
83
+
60
84
  const canViewOwnAttendance = computed(() => {
61
85
  if (!userAppRole.value) return false;
62
86
  if (moduleDenied("my-attendance")) return false;
@@ -85,6 +109,7 @@ export function useAttendancePermission() {
85
109
  canViewAllAttendance,
86
110
  canViewAttendanceDetails,
87
111
  canManageAttendanceSettings,
112
+ canManageSiteDeviceMode,
88
113
  canViewOwnAttendance,
89
114
  canCheckInOut,
90
115
  };
@@ -221,6 +221,11 @@ export default function useCleaningPermission() {
221
221
  description:
222
222
  "Allows the user to configure and manage attendance settings.",
223
223
  },
224
+ "manage-site-device-mode": {
225
+ check: true,
226
+ description:
227
+ "Allows the user to turn Site device mode on or off: one shared phone at the site checks staff in and out with a photo, their name and the last 4 characters of their NRIC.",
228
+ },
224
229
  },
225
230
  // my attendance permissions
226
231
  "my-attendance": {
@@ -198,6 +198,11 @@ export default function useLandscapingPermission() {
198
198
  check: true,
199
199
  description: "Allows the user to configure and manage attendance settings.",
200
200
  },
201
+ "manage-site-device-mode": {
202
+ check: true,
203
+ description:
204
+ "Allows the user to turn Site device mode on or off: one shared phone at the site checks staff in and out with a photo, their name and the last 4 characters of their NRIC.",
205
+ },
201
206
  },
202
207
  // my attendance permissions
203
208
  "my-attendance": {
@@ -213,6 +213,11 @@ export default function usePestControlPermission() {
213
213
  check: true,
214
214
  description: "Allows the user to configure and manage attendance settings.",
215
215
  },
216
+ "manage-site-device-mode": {
217
+ check: true,
218
+ description:
219
+ "Allows the user to turn Site device mode on or off: one shared phone at the site checks staff in and out with a photo, their name and the last 4 characters of their NRIC.",
220
+ },
216
221
  },
217
222
  // my attendance permissions
218
223
  "my-attendance": {
@@ -213,6 +213,11 @@ export default function usePoolMaintenancePermission() {
213
213
  check: true,
214
214
  description: "Allows the user to configure and manage attendance settings.",
215
215
  },
216
+ "manage-site-device-mode": {
217
+ check: true,
218
+ description:
219
+ "Allows the user to turn Site device mode on or off: one shared phone at the site checks staff in and out with a photo, their name and the last 4 characters of their NRIC.",
220
+ },
216
221
  },
217
222
  // my attendance permissions
218
223
  "my-attendance": {
@@ -491,6 +491,11 @@ export default function useSecurityPermission() {
491
491
  description:
492
492
  "Allows the user to configure and manage attendance settings.",
493
493
  },
494
+ "manage-site-device-mode": {
495
+ check: true,
496
+ description:
497
+ "Allows the user to turn Site device mode on or off: one shared phone at the site checks staff in and out with a photo, their name and the last 4 characters of their NRIC.",
498
+ },
494
499
  },
495
500
 
496
501
  "my-attendance": {
@@ -206,6 +206,7 @@ export const PERMISSIONS = {
206
206
  "see-all-attendance",
207
207
  "see-attendance-details",
208
208
  "manage-attendance-settings",
209
+ "manage-site-device-mode",
209
210
  ],
210
211
  "my-attendance": ["see-own-attendance", "check-in-out"],
211
212
  toilet_locations: ["create-toilet-location"],
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.83.0",
5
+ "version": "4.85.0",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
package/plugins/API.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { isPermissionRefusal } from "../utils/permission-refusal";
2
+ import { accessSuspendedMessage, SIGNIN_NOTICE_COOKIE } from "../utils/access-suspended";
2
3
 
3
4
  export default defineNuxtPlugin((nuxtApp) => {
4
5
  const { cookieConfig } = useRuntimeConfig().public;
6
+ let signingOut = false;
5
7
 
6
8
  const api = $fetch.create({
7
9
  baseURL: "/",
@@ -32,6 +34,20 @@ export default defineNuxtPlugin((nuxtApp) => {
32
34
  // 401 + "You do not have permission ..." (vehicle writes and every other
33
35
  // `requireSiteReach` gate). See utils/permission-refusal.ts.
34
36
  onResponseError({ response }) {
37
+ // Every membership suspended (2026-10-01): the server already ended the
38
+ // session. Sign out here and let the sign-in page say why, once.
39
+ const suspended = accessSuspendedMessage((response as any)?._data);
40
+ if (suspended && import.meta.client && !signingOut) {
41
+ signingOut = true;
42
+ void nuxtApp.runWithContext(() => {
43
+ useCookie(SIGNIN_NOTICE_COOKIE, { ...cookieConfig, maxAge: 300 }).value = suspended;
44
+ for (const name of ["sid", "user", "organization"]) useCookie(name, cookieConfig).value = null;
45
+ const { APP_MAIN, APP_NAME } = useRuntimeConfig().public as Record<string, string>;
46
+ if (String(APP_NAME ?? "").toLowerCase() === "main" || !APP_MAIN) return navigateTo("/sign-in");
47
+ window.location.href = `${APP_MAIN}/sign-in`;
48
+ });
49
+ return;
50
+ }
35
51
  if (!isPermissionRefusal(response?.status, (response as any)?._data)) return;
36
52
  if (!import.meta.client) return;
37
53
  const refresh = (nuxtApp as any).$refreshAppRole;
@@ -11,9 +11,11 @@ declare type TAttendance = {
11
11
  declare type TAttendanceCheckIn = {
12
12
  checkIn: {
13
13
  timestamp: string;
14
- location: TAttendanceLocation;
14
+ location?: TAttendanceLocation;
15
15
  img?: string;
16
+ privatePhoto?: string;
16
17
  };
18
+ proxy?: TAttendanceProxy;
17
19
  };
18
20
 
19
21
  declare type TAttendanceCheckOut = {
@@ -35,4 +37,14 @@ declare type TAttendanceSettings = {
35
37
  location?: TAttendanceLocation;
36
38
  isGeofencingEnabled?: boolean;
37
39
  mile?: number;
40
+ /** Site device mode: one shared phone may check staff in/out (default OFF). */
41
+ siteDeviceMode?: boolean;
42
+ };
43
+
44
+ /** Who a Site device mode check-in is for. Never the full NRIC. */
45
+ declare type TAttendanceProxy = {
46
+ name: string;
47
+ nricLast4: string;
48
+ nricPrefix?: string;
49
+ post?: string;
38
50
  };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Suspended = no access (owner 2026-10-01). When every membership of the
3
+ * signed-in person is suspended the API answers any request with 401 +
4
+ * `code: "ACCESS_SUSPENDED"` and the message "Your access has been suspended
5
+ * by <org>. Contact your administrator." (core `suspendedSessionGuard`). The
6
+ * session is already dead server-side; the app signs out and the sign-in page
7
+ * (web-app-main) shows the message once, from this cookie.
8
+ */
9
+ export const ACCESS_SUSPENDED_CODE = "ACCESS_SUSPENDED";
10
+ export const SIGNIN_NOTICE_COOKIE = "signin-notice";
11
+
12
+ export function accessSuspendedMessage(data: unknown): string | null {
13
+ const body = data as { code?: unknown; message?: unknown } | null | undefined;
14
+ if (body?.code !== ACCESS_SUSPENDED_CODE) return null;
15
+ return typeof body.message === "string" && body.message
16
+ ? body.message
17
+ : "Your access has been suspended. Contact your administrator.";
18
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Site device mode (owner request 2026-10-01): one shared phone at a site checks
3
+ * a person in or out with a photo, their name and their NRIC/FIN.
4
+ *
5
+ * Privacy (Singapore PDPA): the full NRIC is masked HERE, in the browser,
6
+ * before anything is sent. Only the last 4 characters leave the device, plus
7
+ * the prefix letter when the full number was typed, so the record can be shown
8
+ * as "G****932P" (owner's display rule); last 4 only is shown as "****932P".
9
+ */
10
+
11
+ export const FULL_NRIC_RE = /^[STFGM]\d{7}[A-Z]$/;
12
+ export const NRIC_LAST4_RE = /^\d{3}[A-Z]$/;
13
+ const FULL_NRIC_ANYWHERE_RE = /[STFGM]\d{7}[A-Z]/i;
14
+
15
+ export type TParsedNric =
16
+ | { ok: true; nricLast4: string; nricPrefix: string; masked: string; typedFull: boolean }
17
+ | { ok: false; error: string };
18
+
19
+ /** Full NRIC/FIN or just its last 4, any case, spaces ignored. */
20
+ export function parseNric(input: unknown): TParsedNric {
21
+ const value = String(input ?? "").replace(/\s+/g, "").toUpperCase();
22
+ if (!value) return { ok: false, error: "Type the NRIC/FIN, or just its last 4 characters (e.g. 932P)." };
23
+ if (FULL_NRIC_RE.test(value)) {
24
+ const nricLast4 = value.slice(-4);
25
+ const nricPrefix = value[0];
26
+ return { ok: true, nricLast4, nricPrefix, masked: `${nricPrefix}****${nricLast4}`, typedFull: true };
27
+ }
28
+ if (NRIC_LAST4_RE.test(value)) {
29
+ return { ok: true, nricLast4: value, nricPrefix: "", masked: `****${value}`, typedFull: false };
30
+ }
31
+ return {
32
+ ok: false,
33
+ error: "That doesn't look like an NRIC/FIN. Type the full number (e.g. S1234567D) or just the last 4 (e.g. 567D).",
34
+ };
35
+ }
36
+
37
+ /** Empty string when fine, otherwise the sentence to show. */
38
+ export function nameError(input: unknown): string {
39
+ const value = String(input ?? "").trim();
40
+ if (!value) return "Type the person's name.";
41
+ if (FULL_NRIC_ANYWHERE_RE.test(value)) return "Please type the NRIC in the NRIC box, not in the name.";
42
+ if (value.length < 2) return "Type the person's full name.";
43
+ if (value.length > 80) return "The name is too long (80 characters at most).";
44
+ return "";
45
+ }
46
+
47
+ export function postError(input: unknown): string {
48
+ const value = String(input ?? "").trim();
49
+ if (FULL_NRIC_ANYWHERE_RE.test(value)) return "Please don't type an NRIC in Shift / post.";
50
+ if (value.length > 80) return "Shift / post is too long (80 characters at most).";
51
+ return "";
52
+ }
53
+
54
+ /** What is sent to the server. Never contains the full NRIC. */
55
+ export function proxyPayload(input: { name: string; nric: string; post?: string }) {
56
+ const parsed = parseNric(input.nric);
57
+ if (!parsed.ok) throw new Error(parsed.error);
58
+ const name = String(input.name).trim().replace(/\s+/g, " ");
59
+ const post = String(input.post ?? "").trim();
60
+ return {
61
+ name,
62
+ nricLast4: parsed.nricLast4,
63
+ ...(parsed.nricPrefix ? { nricPrefix: parsed.nricPrefix } : {}),
64
+ ...(post ? { post } : {}),
65
+ };
66
+ }
67
+
68
+ /** CSV cell: quoted, and a leading = + - @ neutralised so a spreadsheet never runs it. */
69
+ function csvCell(value: unknown): string {
70
+ let text = value === null || value === undefined ? "" : String(value);
71
+ if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`;
72
+ return `"${text.replace(/"/g, '""')}"`;
73
+ }
74
+
75
+ export function toCsv(headers: string[], rows: unknown[][]): string {
76
+ return [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\r\n");
77
+ }
78
+
79
+ /** One export row for an attendance list item (web Attendance page). */
80
+ export function attendanceCsvRow(
81
+ item: Record<string, any>,
82
+ photoLink: (id: string, which: "checkIn" | "checkOut") => string,
83
+ formatDate: (d: unknown) => string,
84
+ ): unknown[] {
85
+ return [
86
+ item.isProxy ? item.proxyName : item.userName,
87
+ item.isProxy ? item.nricMasked : "",
88
+ item.isProxy ? item.post ?? "" : "",
89
+ item.isProxy ? `Site device (${item.userName ?? ""})` : "Own account",
90
+ item.checkInTimestamp ? formatDate(item.checkInTimestamp) : "",
91
+ item.checkOutTimestamp ? formatDate(item.checkOutTimestamp) : "",
92
+ item.totalHours ?? "",
93
+ item.hasCheckInPhoto ? photoLink(String(item._id), "checkIn") : "",
94
+ item.hasCheckOutPhoto ? photoLink(String(item._id), "checkOut") : "",
95
+ item.suspicious ? item.suspiciousReason : "",
96
+ ];
97
+ }
98
+
99
+ export const ATTENDANCE_CSV_HEADERS = [
100
+ "Person",
101
+ "NRIC (last 4)",
102
+ "Shift / post",
103
+ "Taken on",
104
+ "Check in",
105
+ "Check out",
106
+ "Total hours",
107
+ "Check-in photo",
108
+ "Check-out photo",
109
+ "Check this",
110
+ ];
package/utils/role.ts CHANGED
@@ -9,6 +9,19 @@
9
9
  export const showSiteField = (siteState: boolean, type: string): boolean =>
10
10
  siteState && type !== "admin";
11
11
 
12
+ /**
13
+ * A role's name with its site, "Admin · St. Regis Residences" (owner
14
+ * 2026-10-01: role names are unique per SITE, so two sites may both have an
15
+ * "Admin" - two independent roles - and every list that can show both must
16
+ * say which is which). Organisation-level roles (no site) show the name alone.
17
+ * `siteName` comes from the role list (`GET /api/roles`, core 2026-10-01).
18
+ */
19
+ export function roleLabel(role: { name?: unknown; siteName?: unknown; site?: unknown } | null | undefined): string {
20
+ const name = typeof role?.name === "string" ? role.name : "";
21
+ const site = typeof role?.siteName === "string" ? role.siteName.trim() : "";
22
+ return role?.site && site ? `${name} · ${site}` : name;
23
+ }
24
+
12
25
  /**
13
26
  * The Assign Role picker's list, with the member's CURRENT role always in it.
14
27
  *
@@ -50,13 +63,13 @@ export function memberRoleChip(
50
63
  const limited = limitedSites(item);
51
64
  if (orgLevel && limited.length) {
52
65
  return {
53
- text: limited.map((l) => `${l.roleName || "No role"} (${l.siteName || "a site"})`).join(", "),
66
+ text: limited.map((l) => `${l.roleName || "No role"} · ${l.siteName || "a site"}`).join(", "),
54
67
  tone: "info",
55
68
  };
56
69
  }
57
70
  if (orgLevel && item?.roleState === "other-site") {
58
71
  const site = typeof item?.roleSiteName === "string" && item.roleSiteName ? item.roleSiteName : "";
59
- return { text: site ? `${name || "Role"} (${site})` : name || "Role", tone: "info" };
72
+ return { text: site ? `${name || "Role"} · ${site}` : name || "Role", tone: "info" };
60
73
  }
61
74
  switch (item?.roleState) {
62
75
  case "missing":