@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.
- package/CHANGELOG.md +16 -4
- package/assets/css/primitives.css +0 -57
- package/assets/css/screens.css +4 -8
- package/components/AccessCardDetailsDialog.vue +4 -57
- package/components/AccessCardPreviewDialog.vue +50 -291
- package/components/AppSelect.vue +4 -143
- package/components/AttendanceDetailsDialog.vue +1 -48
- package/components/AttendanceMain.vue +10 -104
- package/components/AttendanceSettingsDialog.vue +1 -53
- package/components/BuildingUnitFormEdit.vue +0 -6
- package/components/BulletinBoardView.vue +1 -1
- package/components/Dialog/UpdateMoreAction.vue +1 -11
- package/components/DocumentForm.vue +112 -407
- package/components/DocumentManagement.vue +12 -213
- package/components/Facility/BookingSetup.vue +0 -63
- package/components/HidAccessLogDashboard.vue +17 -74
- package/components/HidIntercomManagement.vue +14 -183
- package/components/HidQrCodeConfiguration.vue +195 -17
- package/components/HidReaderManagement.vue +0 -398
- package/components/HidReaderUserRoster.vue +3 -44
- package/components/HidUserEnrollment.vue +250 -1573
- package/components/InventoryMain.vue +2 -11
- package/components/InvitationClientForm.vue +1 -19
- package/components/MyAttendanceMain.vue +2 -60
- package/components/NotificationBell.vue +2 -14
- package/components/TableMain.vue +7 -22
- package/components/VisitorForm.vue +0 -12
- package/components/VisitorManagement.vue +5 -267
- package/composables/useAttendance.ts +0 -53
- package/composables/useAttendancePermission.ts +0 -25
- package/composables/useCleaningPermission.ts +0 -5
- package/composables/useDocument.ts +0 -7
- package/composables/useFacility.ts +0 -11
- package/composables/useHidAmico.ts +0 -165
- package/composables/useHidNavigation.ts +1 -25
- package/composables/useLandscapingPermission.ts +0 -5
- package/composables/useMember.ts +0 -12
- package/composables/usePeople.ts +0 -19
- package/composables/usePestControlPermission.ts +0 -5
- package/composables/usePoolMaintenancePermission.ts +0 -5
- package/composables/useSecurityPermission.ts +0 -5
- package/composables/useServiceProvider.ts +2 -40
- package/constants/permissions.ts +0 -1
- package/nuxt.config.ts +0 -18
- package/package.json +2 -4
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/types/attendance.d.ts +1 -13
- package/types/document.d.ts +0 -14
- package/types/facility.d.ts +0 -4
- package/types/member.d.ts +0 -4
- package/types/people.d.ts +1 -28
- package/types/service-provider.d.ts +0 -5
- package/utils/notification-target.ts +4 -43
- package/components/AttendanceProxyDialog.vue +0 -391
- package/components/BuildingUnitManagement.vue +0 -298
- package/components/HidAccessPermissions.vue +0 -424
- package/components/HidProfileQrCode.vue +0 -332
- package/composables/useHidReaderSelection.ts +0 -50
- package/composables/useSiteActions.ts +0 -29
- package/composables/useSiteCategory.ts +0 -45
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
- package/server/plugins/no-powered-by.ts +0 -7
- package/utils/attendance-proxy.ts +0 -110
- package/utils/hid-enrolment-subject.ts +0 -144
- package/utils/hid-permission-assignments.ts +0 -190
- package/utils/hid-reader-selection.ts +0 -52
- package/utils/occupancy-role.ts +0 -109
- package/utils/sanitize-html.ts +0 -16
- package/utils/security-headers.ts +0 -52
- package/utils/service-type.ts +0 -30
- package/utils/site-actions.ts +0 -18
package/types/document.d.ts
CHANGED
|
@@ -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
|
};
|
package/types/facility.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|