@7365admin1/layer-common 4.82.1-staging.476 → 4.84.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +16 -4
  2. package/assets/css/primitives.css +0 -57
  3. package/assets/css/screens.css +4 -8
  4. package/components/AccessCardDetailsDialog.vue +4 -57
  5. package/components/AccessCardPreviewDialog.vue +50 -291
  6. package/components/AppSelect.vue +4 -143
  7. package/components/AttendanceDetailsDialog.vue +1 -48
  8. package/components/AttendanceMain.vue +10 -104
  9. package/components/AttendanceSettingsDialog.vue +1 -53
  10. package/components/BuildingUnitFormEdit.vue +0 -6
  11. package/components/BulletinBoardView.vue +1 -1
  12. package/components/Dialog/UpdateMoreAction.vue +1 -11
  13. package/components/DocumentForm.vue +112 -407
  14. package/components/DocumentManagement.vue +12 -213
  15. package/components/Facility/BookingSetup.vue +0 -63
  16. package/components/HidAccessLogDashboard.vue +17 -74
  17. package/components/HidIntercomManagement.vue +14 -183
  18. package/components/HidQrCodeConfiguration.vue +195 -17
  19. package/components/HidReaderManagement.vue +0 -398
  20. package/components/HidReaderUserRoster.vue +3 -44
  21. package/components/HidUserEnrollment.vue +250 -1573
  22. package/components/InventoryMain.vue +2 -11
  23. package/components/InvitationClientForm.vue +1 -19
  24. package/components/MyAttendanceMain.vue +2 -60
  25. package/components/NotificationBell.vue +2 -14
  26. package/components/TableMain.vue +7 -22
  27. package/components/VisitorForm.vue +0 -12
  28. package/components/VisitorManagement.vue +5 -267
  29. package/composables/useAttendance.ts +0 -53
  30. package/composables/useAttendancePermission.ts +0 -25
  31. package/composables/useCleaningPermission.ts +0 -5
  32. package/composables/useDocument.ts +0 -7
  33. package/composables/useFacility.ts +0 -11
  34. package/composables/useHidAmico.ts +0 -165
  35. package/composables/useHidNavigation.ts +1 -25
  36. package/composables/useLandscapingPermission.ts +0 -5
  37. package/composables/useMember.ts +0 -12
  38. package/composables/usePeople.ts +0 -19
  39. package/composables/usePestControlPermission.ts +0 -5
  40. package/composables/usePoolMaintenancePermission.ts +0 -5
  41. package/composables/useSecurityPermission.ts +0 -5
  42. package/composables/useServiceProvider.ts +2 -40
  43. package/constants/permissions.ts +0 -1
  44. package/nuxt.config.ts +0 -18
  45. package/package.json +2 -4
  46. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  47. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  48. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  49. package/types/attendance.d.ts +1 -13
  50. package/types/document.d.ts +0 -14
  51. package/types/facility.d.ts +0 -4
  52. package/types/member.d.ts +0 -4
  53. package/types/people.d.ts +1 -28
  54. package/types/service-provider.d.ts +0 -5
  55. package/utils/notification-target.ts +4 -43
  56. package/components/AttendanceProxyDialog.vue +0 -391
  57. package/components/BuildingUnitManagement.vue +0 -298
  58. package/components/HidAccessPermissions.vue +0 -424
  59. package/components/HidProfileQrCode.vue +0 -332
  60. package/composables/useHidReaderSelection.ts +0 -50
  61. package/composables/useSiteActions.ts +0 -29
  62. package/composables/useSiteCategory.ts +0 -45
  63. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
  64. package/server/plugins/no-powered-by.ts +0 -7
  65. package/utils/attendance-proxy.ts +0 -110
  66. package/utils/hid-enrolment-subject.ts +0 -144
  67. package/utils/hid-permission-assignments.ts +0 -190
  68. package/utils/hid-reader-selection.ts +0 -52
  69. package/utils/occupancy-role.ts +0 -109
  70. package/utils/sanitize-html.ts +0 -16
  71. package/utils/security-headers.ts +0 -52
  72. package/utils/service-type.ts +0 -30
  73. package/utils/site-actions.ts +0 -18
@@ -3,18 +3,4 @@ declare type TDocument = {
3
3
  name: string;
4
4
  attachment: string;
5
5
  type: string;
6
- /**
7
- * Set at upload or renewal, absent when the document never expires.
8
- *
9
- * An ISO instant on the way out of the API and a `YYYY-MM-DD` string on the
10
- * way back in, which is what `InputDatePicker` binds to - hence the loose
11
- * type rather than `string`.
12
- */
13
- expiryDate?: string | Date | null;
14
- remarks?: string;
15
- size?: number;
16
- parentId?: string | null;
17
- createdAt?: string | Date;
18
- updatedAt?: string | Date;
19
- status?: string;
20
6
  };
@@ -73,10 +73,6 @@ declare type TFacility = {
73
73
  hourInterval?: number;
74
74
  isBookingFeeEnabled: boolean;
75
75
  isGuestEnabled: boolean;
76
- /** Residents may complete this facility's checklist themselves. */
77
- isResidentChecklistEnabled?: boolean;
78
- /** Security may complete it. Mutually exclusive with the above. */
79
- isSecurityChecklistEnabled?: boolean;
80
76
  bookingAmount: number | null;
81
77
  peakBookingFeeAmount?: number | null;
82
78
  bookingFeeGuestAmount?: number | null;
package/types/member.d.ts CHANGED
@@ -10,10 +10,6 @@ declare type TMember = {
10
10
  customerOrgId?: string;
11
11
  customerSiteId?: string;
12
12
  status?: string;
13
- email?: string;
14
- /** The building unit this member manages (a unit's Management tab). */
15
- unit?: string | null;
16
- unitName?: string;
17
13
  createdAt?: string;
18
14
  updatedAt?: string;
19
15
  deletedAt?: string;
package/types/people.d.ts CHANGED
@@ -22,38 +22,11 @@ declare type TPeople = {
22
22
  email?: string;
23
23
  files?: { name: string, id: string }[];
24
24
  isOwner?: boolean;
25
- /**
26
- * Owner / occupant / person-in-charge, as the BUSINESS people form asks it.
27
- * Absent on everyone else - `isOwner` is still the flag that decides unit
28
- * ownership, and the server derives it from this when it is sent. See
29
- * `OccupancyRoles` in core's `person.model.ts`.
30
- */
31
- occupancyRole?: TOccupancyRole;
32
- // Link to this person's `users` row. Empty for the person types that carry
33
- // no login, and empty for a resident whose account was never written.
34
- user?: string;
35
- };
36
-
37
- declare type TOccupancyRole = "owner" | "occupant" | "person-in-charge";
38
-
39
- /** One standing and everything the product says about it. See `utils/occupancy-role.ts`. */
40
- declare type TOccupancyRoleOption = {
41
- value: TOccupancyRole;
42
- /** The label a stored role is printed with. */
43
- title: string;
44
- /** The row menu's wording. */
45
- action: string;
46
- /** The confirmation's heading and its sentence. */
47
- prompt: string;
48
- description: string;
49
25
  };
50
26
 
51
27
  declare type TPlateNumber = { plateNumber: string, recNo?: string, _id?: string, status: TVehicleStatus, type: TVehicleType, anprCameras?: TVehicleAnprCamera[] };
52
28
 
53
29
 
54
- // `""` is the FORM's "nothing picked yet" - it is stripped from the payload
55
- // before it is sent, because absence is what the server reads as "never
56
- // asked". See `PeopleFormMgmt.vue`.
57
- declare type TPeoplePayload = Pick<TGuest, "name" | "block" | "level" | "unit" | "unitName" | "contact" | "plateNumber" | "nric" | "contact" | "remarks" | "org" | "site" | "start" | "end" | "type" | "isOwner"> & { status?: string; user?: string; occupancyRole?: TOccupancyRole | "" }
30
+ declare type TPeoplePayload = Pick<TGuest, "name" | "block" | "level" | "unit" | "unitName" | "contact" | "plateNumber" | "nric" | "contact" | "remarks" | "org" | "site" | "start" | "end" | "type" | "isOwner"> & { status?: string }
58
31
 
59
32
  declare type TPeopleType = "guest" | "resident" | "tenant"
@@ -7,11 +7,6 @@ declare type TServiceProvider = {
7
7
  serviceProviderOrgId: string;
8
8
  type: string;
9
9
  nature: string;
10
- status?: string;
11
- email?: string;
12
- /** The building unit this provider serves (a unit's Management tab). */
13
- unit?: string | null;
14
- unitName?: string;
15
10
  };
16
11
 
17
12
  declare type TServiceProviderName = {
@@ -45,7 +45,7 @@ export type TTargetRow = {
45
45
  * (core `withNotificationKind`); `site` stands in for `siteId` on rows kept
46
46
  * unscoped on purpose. Older rows have neither and are read from `module`.
47
47
  */
48
- params?: { id?: string; org?: string; kind?: string; site?: string; storeId?: string } | null;
48
+ params?: { id?: string; org?: string; kind?: string; site?: string } | null;
49
49
  };
50
50
 
51
51
  /** What the surrounding app knows that the stored row does not. */
@@ -127,26 +127,9 @@ export function notificationKind(item: TTargetRow | null | undefined): string {
127
127
  return "";
128
128
  }
129
129
  if (module === "other") {
130
- if (/(accepted|declined) your invitation$/i.test(title)) return "serviceProviderInviteAnswer";
131
- // Inventory alerts sent before core had an inventory module were stored as "other".
132
- return /request waiting|request (was )?(approved|rejected)|low (on )?stock|stock handed to you|hand-over (confirmed|declined)/i.test(title)
133
- ? "inventory"
134
- : "";
130
+ return /(accepted|declined) your invitation$/i.test(title) ? "serviceProviderInviteAnswer" : "";
135
131
  }
136
- // Older rows spelled the work order module with a hyphen.
137
- return module === "work-order" ? "workOrder" : module;
138
- }
139
-
140
- /**
141
- * Which inventory tab an alert is about. Alerts carry the store id but no tab,
142
- * so the tab follows the alert's fixed title (API-core inventory-notify.service)
143
- * - the same rule the mobile apps use. Values are the web tabs' own names.
144
- */
145
- export function inventoryTabForAlert(title: string): "requests" | "stock" | "mine" | "" {
146
- if (/stock handed to you|hand-over (confirmed|declined)/i.test(title)) return "mine";
147
- if (/request waiting|request (was )?(approved|rejected)/i.test(title)) return "requests";
148
- if (/low (on )?stock/i.test(title)) return "stock";
149
- return "";
132
+ return module;
150
133
  }
151
134
 
152
135
  /**
@@ -199,8 +182,6 @@ const SITE_PAGES: Record<string, TSitePage[]> = {
199
182
  { apps: ["security"], page: () => "virtual-patrol/cctv" },
200
183
  { apps: ["property-management"], page: () => "cctv" },
201
184
  ],
202
- // A robot at the site raised an alarm (core safety alert, category robotAlert).
203
- robotAlert: [{ apps: ["security", "property-management", "hygiene"], page: () => "robot-management/alerts" }],
204
185
  // hygiene has its Service Providers page too since 2026-09-28
205
186
  serviceProviderInviteAnswer: [{ apps: SERVICE_APPS, page: () => "service-providers" }],
206
187
  };
@@ -233,12 +214,7 @@ function sitePageTarget(
233
214
  const list = apps === "any" ? [home, ...SERVICE_APPS.filter((a) => a !== home)] : apps;
234
215
  for (const app of list) candidates.push({ app, path: base + page(id) });
235
216
  }
236
- if (kind !== "inventory") return { candidates };
237
- // Open the alert's own store on the tab it is about (InventoryMain reads ?storeId=&tab=).
238
- const storeId = String(item.params?.storeId ?? item.params?.id ?? "").trim();
239
- const tab = inventoryTabForAlert(String(item.title ?? ""));
240
- const query: Record<string, string> = { ...(storeId ? { storeId } : {}), ...(tab ? { tab } : {}) };
241
- return Object.keys(query).length ? { candidates, query } : { candidates };
217
+ return { candidates };
242
218
  }
243
219
 
244
220
  /** Seven365 staff pages, in the organisation app. */
@@ -318,21 +294,6 @@ export function notificationTarget(
318
294
  };
319
295
  }
320
296
 
321
- /** Kinds that only the iService365 phone apps can open (residents' own screens). */
322
- const PHONE_ONLY_KINDS = new Set(["prelovedMarketplace", "marketplace-order"]);
323
-
324
- /**
325
- * What the bell says when a click has nowhere to go, so a click is never a
326
- * silent nothing. A link grants nothing either way: the page opened still
327
- * checks the person may see it.
328
- */
329
- export function noTargetMessage(item: TTargetRow | null | undefined): string {
330
- if (PHONE_ONLY_KINDS.has(notificationKind(item))) {
331
- return "This notification opens in the iService365 app on your phone.";
332
- }
333
- return "There is no page to open for this notification here. It has been marked as read.";
334
- }
335
-
336
297
  /**
337
298
  * The full URL for a target whose page lives in another web app, or "" when
338
299
  * there is none to build (no path, or that app's URL is not configured).
@@ -1,391 +0,0 @@
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>