@7365admin1/layer-common 4.84.0 → 4.84.1-staging.482
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 +4 -4
- package/assets/css/primitives.css +107 -0
- package/assets/css/screens.css +8 -4
- package/components/AccessCardDetailsDialog.vue +57 -4
- package/components/AccessCardPreviewDialog.vue +291 -50
- package/components/AppSelect.vue +143 -4
- package/components/AttendanceDetailsDialog.vue +48 -1
- package/components/AttendanceMain.vue +104 -10
- package/components/AttendanceProxyDialog.vue +391 -0
- package/components/AttendanceSettingsDialog.vue +53 -1
- package/components/BuildingUnitFormEdit.vue +6 -0
- package/components/BuildingUnitManagement.vue +298 -0
- package/components/BulletinBoardView.vue +1 -1
- package/components/DashboardEmptyState.vue +8 -0
- package/components/DashboardMain.vue +14 -0
- package/components/DashboardPanel.vue +10 -0
- package/components/Dialog/UpdateMoreAction.vue +11 -1
- package/components/DocumentForm.vue +407 -112
- package/components/DocumentManagement.vue +213 -12
- package/components/Facility/BookingSetup.vue +63 -0
- package/components/HidAccessLogDashboard.vue +74 -17
- package/components/HidAccessPermissions.vue +424 -0
- package/components/HidIntercomManagement.vue +183 -14
- package/components/HidProfileQrCode.vue +332 -0
- package/components/HidQrCodeConfiguration.vue +17 -195
- package/components/HidReaderManagement.vue +398 -0
- package/components/HidReaderUserRoster.vue +44 -3
- package/components/HidUserEnrollment.vue +2239 -276
- package/components/InventoryMain.vue +11 -2
- package/components/InvitationClientForm.vue +19 -1
- package/components/MemberServiceProviders.vue +43 -45
- package/components/MyAttendanceMain.vue +60 -2
- package/components/NotificationBell.vue +14 -2
- package/components/TableMain.vue +22 -7
- package/components/VisitorForm.vue +12 -0
- package/components/VisitorManagement.vue +279 -7
- package/composables/useAttendance.ts +53 -0
- package/composables/useAttendancePermission.ts +25 -0
- package/composables/useCleaningPermission.ts +5 -0
- package/composables/useDocument.ts +7 -0
- package/composables/useFacility.ts +11 -0
- package/composables/useHidAmico.ts +165 -0
- package/composables/useHidNavigation.ts +25 -1
- package/composables/useHidReaderSelection.ts +50 -0
- package/composables/useLandscapingPermission.ts +5 -0
- package/composables/useMember.ts +12 -0
- package/composables/useNFCPatrolReportFilters.ts +21 -3
- package/composables/useNFCPatrolRoute.ts +8 -1
- package/composables/usePeople.ts +19 -0
- package/composables/usePestControlPermission.ts +5 -0
- package/composables/usePoolMaintenancePermission.ts +5 -0
- package/composables/useSecurityPermission.ts +5 -0
- package/composables/useServiceProvider.ts +45 -2
- package/composables/useSiteActions.ts +29 -0
- package/composables/useSiteCategory.ts +45 -0
- package/constants/permissions.ts +1 -0
- package/nuxt.config.ts +18 -0
- package/package.json +4 -2
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/plugins/vuetify.ts +6 -0
- package/server/plugins/no-powered-by.ts +7 -0
- package/types/attendance.d.ts +13 -1
- package/types/document.d.ts +14 -0
- package/types/facility.d.ts +4 -0
- package/types/member.d.ts +4 -0
- package/types/people.d.ts +28 -1
- package/types/service-provider.d.ts +5 -0
- package/types/site.d.ts +7 -1
- package/utils/attendance-proxy.ts +110 -0
- package/utils/hid-enrolment-subject.ts +344 -0
- package/utils/hid-permission-assignments.ts +198 -0
- package/utils/hid-reader-selection.ts +52 -0
- package/utils/notification-target.ts +43 -4
- package/utils/occupancy-role.ts +109 -0
- package/utils/sanitize-html.ts +16 -0
- package/utils/security-headers.ts +52 -0
- package/utils/service-type.ts +30 -0
- package/utils/site-actions.ts +18 -0
- package/utils/site-provider-members.ts +37 -0
package/constants/permissions.ts
CHANGED
|
@@ -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/nuxt.config.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
|
2
2
|
import vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
|
|
3
|
+
import { SECURITY_HEADERS } from "./utils/security-headers";
|
|
3
4
|
export default defineNuxtConfig({
|
|
4
5
|
ssr: false,
|
|
5
6
|
devtools: { enabled: false },
|
|
7
|
+
|
|
8
|
+
// Security headers for every app that extends this layer: the HTML shell gets
|
|
9
|
+
// them from Nitro, the static files from the `_headers` file the Cloudflare
|
|
10
|
+
// preset writes from these same rules. See utils/security-headers.ts.
|
|
11
|
+
routeRules: {
|
|
12
|
+
"/**": { headers: SECURITY_HEADERS },
|
|
13
|
+
// Stored files read with the signed-in session (API-core
|
|
14
|
+
// GET /api/files/object/<key>) instead of the public CDN. Unused until an
|
|
15
|
+
// app's API_DO_STORAGE_ENDPOINT points at /api/storage/<folder>; that switch
|
|
16
|
+
// goes with SPACES_PRIVATE_PREFIXES - see the storage runbook.
|
|
17
|
+
...(process.env.API_CORE
|
|
18
|
+
? { "/api/storage/**": { proxy: `${process.env.API_CORE}/api/files/object/**` } }
|
|
19
|
+
: {}),
|
|
20
|
+
},
|
|
6
21
|
build: {
|
|
7
22
|
transpile: ["vuetify"],
|
|
8
23
|
},
|
|
@@ -48,6 +63,9 @@ export default defineNuxtConfig({
|
|
|
48
63
|
cookieConfig: {
|
|
49
64
|
domain: (process.env.DOMAIN as string) ?? "localhost",
|
|
50
65
|
secure: true,
|
|
66
|
+
// Explicit, not the browser default: the API also reads `sid` from the
|
|
67
|
+
// cookie, so a cross-site form post must never carry it (CSRF).
|
|
68
|
+
sameSite: "lax",
|
|
51
69
|
maxAge: 30 * 24 * 60 * 60,
|
|
52
70
|
},
|
|
53
71
|
APP: (process.env.APP as string) ?? "App",
|
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.84.
|
|
5
|
+
"version": "4.84.1-staging.482",
|
|
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.",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"pages",
|
|
23
23
|
"plugins",
|
|
24
24
|
"public",
|
|
25
|
+
"server",
|
|
25
26
|
"services",
|
|
26
27
|
"types",
|
|
27
28
|
"utils",
|
|
@@ -57,8 +58,9 @@
|
|
|
57
58
|
"@mdi/font": "^7.4.47",
|
|
58
59
|
"@types/qrcode": "^1.5.6",
|
|
59
60
|
"ckeditor5": "47.4.0",
|
|
61
|
+
"dompurify": "^3.3.1",
|
|
60
62
|
"hls.js": "^1.6.13",
|
|
61
|
-
"html2pdf.js": "^0.
|
|
63
|
+
"html2pdf.js": "^0.14.0",
|
|
62
64
|
"html5-qrcode": "^2.3.8",
|
|
63
65
|
"moment-timezone": "^0.6.0",
|
|
64
66
|
"nuxt-signature-pad": "^1.8.0",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
:org="orgId"
|
|
6
6
|
message="Enable HID as a service for this site before managing physical HID cards."
|
|
7
7
|
>
|
|
8
|
-
<HidUserEnrollment :site="siteId" card-management />
|
|
8
|
+
<HidUserEnrollment :site="siteId" :org="orgId" card-management />
|
|
9
9
|
</HidEnabledGate>
|
|
10
10
|
</v-container>
|
|
11
11
|
</template>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-container fluid>
|
|
3
|
+
<HidEnabledGate
|
|
4
|
+
:site="siteId"
|
|
5
|
+
:org="orgId"
|
|
6
|
+
message="Enable HID as a service for this site before generating a QR code."
|
|
7
|
+
>
|
|
8
|
+
<HidProfileQrCode :site="siteId" />
|
|
9
|
+
</HidEnabledGate>
|
|
10
|
+
</v-container>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script setup lang="ts">
|
|
14
|
+
definePageMeta({
|
|
15
|
+
layout: "default",
|
|
16
|
+
middleware: ["01-auth", "02-org"],
|
|
17
|
+
memberOnly: true,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const route = useRoute();
|
|
21
|
+
const siteId = computed(() => String(route.params.site ?? ""));
|
|
22
|
+
const orgId = computed(() => String(route.params.org ?? ""));
|
|
23
|
+
</script>
|
package/plugins/vuetify.ts
CHANGED
|
@@ -114,6 +114,12 @@ export default defineNuxtPlugin((app) => {
|
|
|
114
114
|
|
|
115
115
|
const vuetify = createVuetify({
|
|
116
116
|
defaults: {
|
|
117
|
+
// Phones (owner 2026-10-02, "most pages are not mobile responsive"): below
|
|
118
|
+
// 600px every data table shows each row as a stacked card (label: value)
|
|
119
|
+
// instead of a wide grid scrolled sideways. Tablets and desktop unchanged.
|
|
120
|
+
VDataTable: { mobileBreakpoint: "sm" },
|
|
121
|
+
VDataTableServer: { mobileBreakpoint: "sm" },
|
|
122
|
+
VDataTableVirtual: { mobileBreakpoint: "sm" },
|
|
117
123
|
VTextField: {
|
|
118
124
|
variant: "outlined",
|
|
119
125
|
density: "comfortable",
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Nuxt's renderer stamps `x-powered-by: Nuxt` on the HTML shell. It tells a
|
|
2
|
+
// scanner the framework for free and helps nobody, so drop it.
|
|
3
|
+
export default defineNitroPlugin((nitroApp) => {
|
|
4
|
+
nitroApp.hooks.hook("render:response", (response) => {
|
|
5
|
+
if (response?.headers) delete response.headers["x-powered-by"];
|
|
6
|
+
});
|
|
7
|
+
});
|
package/types/attendance.d.ts
CHANGED
|
@@ -11,9 +11,11 @@ declare type TAttendance = {
|
|
|
11
11
|
declare type TAttendanceCheckIn = {
|
|
12
12
|
checkIn: {
|
|
13
13
|
timestamp: string;
|
|
14
|
-
location
|
|
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
|
};
|
package/types/document.d.ts
CHANGED
|
@@ -3,4 +3,18 @@ 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;
|
|
6
20
|
};
|
package/types/facility.d.ts
CHANGED
|
@@ -73,6 +73,10 @@ 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;
|
|
76
80
|
bookingAmount: number | null;
|
|
77
81
|
peakBookingFeeAmount?: number | null;
|
|
78
82
|
bookingFeeGuestAmount?: number | null;
|
package/types/member.d.ts
CHANGED
|
@@ -10,6 +10,10 @@ 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;
|
|
13
17
|
createdAt?: string;
|
|
14
18
|
updatedAt?: string;
|
|
15
19
|
deletedAt?: string;
|
package/types/people.d.ts
CHANGED
|
@@ -22,11 +22,38 @@ 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;
|
|
25
49
|
};
|
|
26
50
|
|
|
27
51
|
declare type TPlateNumber = { plateNumber: string, recNo?: string, _id?: string, status: TVehicleStatus, type: TVehicleType, anprCameras?: TVehicleAnprCamera[] };
|
|
28
52
|
|
|
29
53
|
|
|
30
|
-
|
|
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 | "" }
|
|
31
58
|
|
|
32
59
|
declare type TPeopleType = "guest" | "resident" | "tenant"
|
|
@@ -7,6 +7,11 @@ 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;
|
|
10
15
|
};
|
|
11
16
|
|
|
12
17
|
declare type TServiceProviderName = {
|
package/types/site.d.ts
CHANGED
|
@@ -5,7 +5,10 @@ declare type THidQrCodeFormat = "0" | "1" | "2";
|
|
|
5
5
|
declare type THidPermissionCategory =
|
|
6
6
|
| "resident"
|
|
7
7
|
| "property_management"
|
|
8
|
-
|
|
8
|
+
/** A provider COMPANY - granting one admits every active member of that org. */
|
|
9
|
+
| "service_provider"
|
|
10
|
+
/** ONE PERSON from a provider company, by their membership at this site. */
|
|
11
|
+
| "service_provider_member";
|
|
9
12
|
|
|
10
13
|
declare type THidPermissionAssignment = {
|
|
11
14
|
subjectId: string;
|
|
@@ -27,7 +30,10 @@ declare type THidSitePermissions = {
|
|
|
27
30
|
counts: {
|
|
28
31
|
resident: number;
|
|
29
32
|
propertyManagement: number;
|
|
33
|
+
/** Provider COMPANIES granted. */
|
|
30
34
|
serviceProvider: number;
|
|
35
|
+
/** Named provider people granted. Counted apart from the companies. */
|
|
36
|
+
serviceProviderMember: number;
|
|
31
37
|
intercom: number;
|
|
32
38
|
};
|
|
33
39
|
};
|
|
@@ -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
|
+
];
|