@7365admin1/layer-common 4.144.0 → 4.147.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 +65 -4
- package/components/AddPassKeyToVisitor.vue +332 -54
- package/components/AreaChecklistHistoryLogs.vue +2 -2
- package/components/CleaningScheduleMain.vue +2 -2
- package/components/FileInput.vue +4 -0
- package/components/MemberInformation.vue +580 -281
- package/components/NricScanDialog.vue +512 -0
- package/components/PassInformation.vue +11 -2
- package/components/RolePermissionMain.vue +3 -1
- package/components/ScheduleTaskForm.vue +16 -17
- package/components/ScheduleTaskMain.vue +43 -39
- package/components/SubscriptionPlanTable.vue +45 -1
- package/components/TableHygiene.vue +4 -1
- package/components/VisitorForm.vue +1796 -642
- package/components/VisitorFormSelection.vue +51 -18
- package/components/VisitorManagement.vue +1641 -640
- package/components/VisitorPassKeyQRScanner.vue +1 -1
- package/components/VisitorSocketPopUp.vue +72 -23
- package/composables/usePeople.ts +54 -0
- package/composables/useVisitor.ts +4 -2
- package/composables/useVisitorSocket.ts +19 -0
- package/package.json +2 -1
- package/utils/breadcrumb.ts +2 -1
- package/utils/nav-sections.ts +1 -0
- package/utils/nric-card.ts +278 -0
- package/utils/permission-labels.ts +17 -6
- package/utils/role.ts +24 -0
- package/utils/schedule-task.ts +51 -9
- package/utils/visitor-people.ts +262 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<v-card class="screen-modal qr-scanner">
|
|
7
7
|
<div class="qr-scanner__head">
|
|
8
8
|
<v-card-title>Scan QR Code</v-card-title>
|
|
9
|
-
<
|
|
9
|
+
<v-btn icon="mdi-close" variant="text" aria-label="Close" @click="closeDialog()" />
|
|
10
10
|
</div>
|
|
11
11
|
|
|
12
12
|
<div class="screen-modal__body qr-scanner__body">
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
fires, what it links to, or how long it stays; only how it is drawn.
|
|
42
42
|
-->
|
|
43
43
|
<v-snackbar
|
|
44
|
-
v-if="
|
|
44
|
+
v-if="enableUnregisteredSnackbar && canViewVisitor"
|
|
45
45
|
v-model="snackBarMessage.modal"
|
|
46
46
|
:timeout="-1"
|
|
47
47
|
location="bottom center"
|
|
@@ -75,8 +75,10 @@
|
|
|
75
75
|
</div>
|
|
76
76
|
|
|
77
77
|
<div class="anpr-toast__actions">
|
|
78
|
+
<!-- "Register" for a user who can register (Update right), as the
|
|
79
|
+
security mobile app; otherwise only through to the list. -->
|
|
78
80
|
<AppButton variant="ghost" @click="goToUnregistered">
|
|
79
|
-
Go to Unregistered
|
|
81
|
+
{{ canUpdateVisitor ? "Register" : "Go to Unregistered" }}
|
|
80
82
|
</AppButton>
|
|
81
83
|
</div>
|
|
82
84
|
</v-snackbar>
|
|
@@ -131,6 +133,11 @@ const props = defineProps({
|
|
|
131
133
|
type: Boolean,
|
|
132
134
|
default: false,
|
|
133
135
|
},
|
|
136
|
+
/** The visitor Update right: the notice's button reads "Register". */
|
|
137
|
+
canUpdateVisitor: {
|
|
138
|
+
type: Boolean,
|
|
139
|
+
default: false,
|
|
140
|
+
},
|
|
134
141
|
});
|
|
135
142
|
|
|
136
143
|
const config = useRuntimeConfig();
|
|
@@ -141,8 +148,36 @@ const { tab } = useVisitor();
|
|
|
141
148
|
const { enableUnregistered, ensureANPRSettings } = useANPRSettings(
|
|
142
149
|
() => props.siteId
|
|
143
150
|
);
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
// The security mobile app's rule (isecure365 `useUnregisteredTab`): the
|
|
152
|
+
// site's Unregistered switch AND an ANPR camera facing an entrance (entry /
|
|
153
|
+
// both). With the switch alone the notice could appear for a site whose
|
|
154
|
+
// cameras never register anyone arriving.
|
|
155
|
+
const { getAllSiteCameras } = useSiteSettings();
|
|
156
|
+
const hasEntryFacingAnprCamera = ref(false);
|
|
157
|
+
|
|
158
|
+
async function loadEntryFacingAnprCamera(siteId: string) {
|
|
159
|
+
hasEntryFacingAnprCamera.value = false;
|
|
160
|
+
if (!siteId) return;
|
|
161
|
+
try {
|
|
162
|
+
const res = await getAllSiteCameras({ site: siteId, type: "anpr", page: 1, limit: 100 });
|
|
163
|
+
if (siteId !== props.siteId) return;
|
|
164
|
+
const items = Array.isArray(res?.items)
|
|
165
|
+
? res.items
|
|
166
|
+
: Array.isArray(res?.data?.items)
|
|
167
|
+
? res.data.items
|
|
168
|
+
: [];
|
|
169
|
+
hasEntryFacingAnprCamera.value = items.some(
|
|
170
|
+
(camera: any) => camera?.direction === "entry" || camera?.direction === "both"
|
|
171
|
+
);
|
|
172
|
+
} catch {
|
|
173
|
+
hasEntryFacingAnprCamera.value = false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const enableUnregisteredSnackbar = computed(
|
|
178
|
+
() => enableUnregistered.value && hasEntryFacingAnprCamera.value
|
|
179
|
+
);
|
|
180
|
+
const { socketUnregisteredVisitorTrigger, visitorSocketData, registerVisitorFormData } =
|
|
146
181
|
useVisitorSocket();
|
|
147
182
|
|
|
148
183
|
const APP_NAME = publicConfig.APP || "";
|
|
@@ -260,10 +295,10 @@ const showPlate = (plateNumber: string) => {
|
|
|
260
295
|
}
|
|
261
296
|
|
|
262
297
|
snackBarMessage.plateNumber = plateNumber;
|
|
298
|
+
// No timeout (security mobile app, 2026-10): it stays on every screen until
|
|
299
|
+
// it is closed or its button is used. A newer plate replaces it. (The
|
|
300
|
+
// window below is no longer started; its cancels are harmless.)
|
|
263
301
|
snackBarMessage.modal = true;
|
|
264
|
-
// Restart the 20s window from zero. A replacement plate gets a full window
|
|
265
|
-
// of its own; without this it inherited whatever was left of the one before.
|
|
266
|
-
plateToastWindow.restart();
|
|
267
302
|
};
|
|
268
303
|
|
|
269
304
|
const connectSocket = () => {
|
|
@@ -284,17 +319,27 @@ const connectSocket = () => {
|
|
|
284
319
|
|
|
285
320
|
socket.value.on("anpr-messages", (data: TVisitorSocketData) => {
|
|
286
321
|
const plateNumber = data?.plateNumber?.trim() || "";
|
|
287
|
-
const
|
|
288
|
-
const isPlateNumberChanging =
|
|
289
|
-
Boolean(lastPlateNumber.value) && lastPlateNumber.value !== plateNumber;
|
|
322
|
+
const recordId = String(data?._id ?? "").trim();
|
|
290
323
|
|
|
291
|
-
|
|
324
|
+
// Every message still reaches the visitor list (its reload).
|
|
292
325
|
visitorSocketData.value = null;
|
|
293
326
|
visitorSocketData.value = data;
|
|
294
327
|
newVisitor.value = data;
|
|
295
328
|
|
|
296
|
-
if (
|
|
297
|
-
|
|
329
|
+
if (plateNumber) {
|
|
330
|
+
// An arrival. Only one with both the record id and the plate is kept for
|
|
331
|
+
// Register, and it drops any outstanding trigger; a read with no record
|
|
332
|
+
// has nothing to register and raises no notice (security mobile app).
|
|
333
|
+
// Nothing else on the feed touches the stored arrival.
|
|
334
|
+
if (recordId) {
|
|
335
|
+
registerVisitorFormData.value = {
|
|
336
|
+
_id: recordId,
|
|
337
|
+
plateNumber,
|
|
338
|
+
site: data?.site,
|
|
339
|
+
};
|
|
340
|
+
socketUnregisteredVisitorTrigger.value = false;
|
|
341
|
+
showPlate(plateNumber);
|
|
342
|
+
}
|
|
298
343
|
lastPlateNumber.value = plateNumber;
|
|
299
344
|
return;
|
|
300
345
|
}
|
|
@@ -354,24 +399,27 @@ const goToUnregistered = async () => {
|
|
|
354
399
|
});
|
|
355
400
|
};
|
|
356
401
|
|
|
402
|
+
// A site switch, or leaving the signed-in layout (logout): the open notice and
|
|
403
|
+
// the stored arrival belong to the old site, so neither survives it.
|
|
404
|
+
const clearPlateNotice = () => {
|
|
405
|
+
snackBarMessage.modal = false;
|
|
406
|
+
registerVisitorFormData.value = null;
|
|
407
|
+
socketUnregisteredVisitorTrigger.value = false;
|
|
408
|
+
};
|
|
409
|
+
|
|
357
410
|
watch(
|
|
358
411
|
() => props.siteId,
|
|
359
|
-
() => {
|
|
412
|
+
(siteId, previous) => {
|
|
413
|
+
if (previous !== undefined && siteId !== previous) clearPlateNotice();
|
|
360
414
|
disconnectSocket();
|
|
361
415
|
connectSocket();
|
|
416
|
+
void loadEntryFacingAnprCamera(siteId);
|
|
362
417
|
},
|
|
363
418
|
{ immediate: true, deep: true }
|
|
364
419
|
);
|
|
365
420
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
() => {
|
|
369
|
-
if (isUnregisteredVisitorRoute.value) {
|
|
370
|
-
snackBarMessage.modal = false;
|
|
371
|
-
}
|
|
372
|
-
},
|
|
373
|
-
{ immediate: true }
|
|
374
|
-
);
|
|
421
|
+
// The notice now also shows on the Unregistered tab (security mobile app):
|
|
422
|
+
// its button opens Register for the record there.
|
|
375
423
|
|
|
376
424
|
watch(enableUnregisteredSnackbar, (enabled) => {
|
|
377
425
|
if (!enabled) {
|
|
@@ -402,6 +450,7 @@ watch(
|
|
|
402
450
|
);
|
|
403
451
|
|
|
404
452
|
onUnmounted(() => {
|
|
453
|
+
clearPlateNotice();
|
|
405
454
|
plateToastWindow.cancel();
|
|
406
455
|
if (typeof document !== "undefined") {
|
|
407
456
|
document.removeEventListener("keydown", onEscape);
|
package/composables/usePeople.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
VISITOR_PERSON_TYPES,
|
|
3
|
+
recordIdsOf,
|
|
4
|
+
visitorPeopleResults,
|
|
5
|
+
type VisitorPerson,
|
|
6
|
+
} from "../utils/visitor-people";
|
|
7
|
+
|
|
1
8
|
export default function () {
|
|
2
9
|
async function getAll({
|
|
3
10
|
page = 1,
|
|
@@ -66,6 +73,51 @@ export default function () {
|
|
|
66
73
|
});
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The visitor form's autofill lookup across EVERY visitor type, as the
|
|
78
|
+
* security mobile app (isecure365 `findAnyVisitorType`): one request with
|
|
79
|
+
* `type[]=guest&type[]=contractor&type[]=delivery`, the type being registered
|
|
80
|
+
* listed first, and one person on file under several types merged into one
|
|
81
|
+
* result. `walk-in` is looked up as `guest`.
|
|
82
|
+
*/
|
|
83
|
+
async function findVisitorPeopleAnyType(
|
|
84
|
+
by: "nric" | "contact" | "plateNumber",
|
|
85
|
+
term: string,
|
|
86
|
+
site?: string,
|
|
87
|
+
preferType?: string
|
|
88
|
+
): Promise<VisitorPerson[]> {
|
|
89
|
+
const res = await $fetch<any>(`/api/people`, {
|
|
90
|
+
method: "GET",
|
|
91
|
+
query: {
|
|
92
|
+
[by]: term,
|
|
93
|
+
...(site ? { site } : {}),
|
|
94
|
+
"type[]": [...VISITOR_PERSON_TYPES],
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
const items: VisitorPerson[] = Array.isArray(res)
|
|
98
|
+
? res
|
|
99
|
+
: Array.isArray(res?.items)
|
|
100
|
+
? res.items
|
|
101
|
+
: Array.isArray(res?.data?.items)
|
|
102
|
+
? res.data.items
|
|
103
|
+
: Array.isArray(res?.data)
|
|
104
|
+
? res.data
|
|
105
|
+
: [];
|
|
106
|
+
const type = preferType === "walk-in" ? "guest" : preferType;
|
|
107
|
+
return visitorPeopleResults(items, type);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Deletes the People records behind one autofill result (a merged result is
|
|
112
|
+
* several): `PUT /api/people/:id`, a soft delete. For a person saved with a
|
|
113
|
+
* wrong NRIC; past visits are not touched.
|
|
114
|
+
*/
|
|
115
|
+
async function deletePersonRecords(person: VisitorPerson) {
|
|
116
|
+
for (const id of recordIdsOf(person)) {
|
|
117
|
+
await deleteById(id);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
69
121
|
async function findPersonByContact(
|
|
70
122
|
contact: string
|
|
71
123
|
): Promise<null | Partial<TPeople>> {
|
|
@@ -164,6 +216,8 @@ export default function () {
|
|
|
164
216
|
findPersonByNRICMultipleResult,
|
|
165
217
|
findPersonByPhoneNumberMultipleResult,
|
|
166
218
|
findPersonByPlateNumberMultipleResult,
|
|
219
|
+
findVisitorPeopleAnyType,
|
|
220
|
+
deletePersonRecords,
|
|
167
221
|
findPersonByContact,
|
|
168
222
|
getPeopleByUnit,
|
|
169
223
|
getOccupiedStructure,
|
|
@@ -4,11 +4,13 @@ export default function () {
|
|
|
4
4
|
value: TVisitorType;
|
|
5
5
|
};
|
|
6
6
|
|
|
7
|
+
// The Add type picker, in iSecure's order and words (`VISITOR_FORM_TYPES` /
|
|
8
|
+
// `VISITOR_FORM_TITLES`). The values are the stored types - never rename.
|
|
7
9
|
const visitorSelection: TVisitorSelection[] = [
|
|
8
|
-
{ label: "
|
|
10
|
+
{ label: "Guest", value: "guest" },
|
|
9
11
|
{ label: "Contractor", value: "contractor" },
|
|
10
|
-
{ label: "Walk-In", value: "walk-in" },
|
|
11
12
|
{ label: "Delivery", value: "delivery" },
|
|
13
|
+
{ label: "Walk-In", value: "walk-in" },
|
|
12
14
|
{ label: "Pick-Up", value: "pick-up" },
|
|
13
15
|
{ label: "Drop-Off", value: "drop-off" },
|
|
14
16
|
];
|
|
@@ -64,7 +64,25 @@ export const plateToast = (
|
|
|
64
64
|
};
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The last vehicle ARRIVAL the ANPR camera read, kept so the plate notice's
|
|
69
|
+
* Register / "Go to Unregistered" opens the Register flow for THAT record.
|
|
70
|
+
* As the security mobile app (isecure365 `anpr-unregistered.ts`): only a read
|
|
71
|
+
* with both the record id and the plate is kept, and nothing else on the feed
|
|
72
|
+
* (a camera alert, a reload) replaces it.
|
|
73
|
+
*/
|
|
74
|
+
export type TRegisterVisitorFormData = {
|
|
75
|
+
/** The unregistered record the camera created - the one the form updates. */
|
|
76
|
+
_id: string;
|
|
77
|
+
plateNumber: string;
|
|
78
|
+
site?: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
67
81
|
export const useVisitorSocket = () => {
|
|
82
|
+
const registerVisitorFormData = useState(
|
|
83
|
+
"registerVisitorFormData",
|
|
84
|
+
(): TRegisterVisitorFormData | null => null,
|
|
85
|
+
);
|
|
68
86
|
const socketUnregisteredVisitorTrigger = useState(
|
|
69
87
|
"socketUnregisteredVisitorTrigger",
|
|
70
88
|
() => false,
|
|
@@ -76,6 +94,7 @@ export const useVisitorSocket = () => {
|
|
|
76
94
|
|
|
77
95
|
|
|
78
96
|
return {
|
|
97
|
+
registerVisitorFormData,
|
|
79
98
|
socketUnregisteredVisitorTrigger,
|
|
80
99
|
visitorSocketData,
|
|
81
100
|
};
|
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.
|
|
5
|
+
"version": "4.147.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.",
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"sass": "^1.80.6",
|
|
70
70
|
"sip.js": "0.21.2",
|
|
71
71
|
"socket.io-client": "^4.8.3",
|
|
72
|
+
"tesseract.js": "^5.1.1",
|
|
72
73
|
"vue-draggable-next": "^2.3.0",
|
|
73
74
|
"vue3-signature": "^0.2.4",
|
|
74
75
|
"vuetify-pro-tiptap": "^2.8.2",
|
package/utils/breadcrumb.ts
CHANGED
|
@@ -26,7 +26,8 @@ const RENAMED: [string, string][] = [
|
|
|
26
26
|
["Building Mgmt", "Occupancy Mgmt"],
|
|
27
27
|
["Event Mgmt", "Task Calendar"],
|
|
28
28
|
// Schedule screens (owner 21 Oct): the same names the menu and the mobile apps use.
|
|
29
|
-
["Schedule Task", "Extra
|
|
29
|
+
["Schedule Task", "Extra Checks"],
|
|
30
|
+
["Extra Visits", "Extra Checks"],
|
|
30
31
|
...["Cleaning", "Technician", "Pest", "Pool", "Landscape"].flatMap((t): [string, string][] => [
|
|
31
32
|
[`${t} Schedule Area`, "Area"],
|
|
32
33
|
[`${t} Schedule Unit`, "Unit"],
|
package/utils/nav-sections.ts
CHANGED
|
@@ -52,6 +52,7 @@ export const NAV_SECTIONS: Record<string, string> = {
|
|
|
52
52
|
"Schedule Task": "SERVICE DELIVERY",
|
|
53
53
|
"Daily Checklist": "SERVICE DELIVERY",
|
|
54
54
|
"Extra Visits": "SERVICE DELIVERY",
|
|
55
|
+
"Extra Checks": "SERVICE DELIVERY",
|
|
55
56
|
|
|
56
57
|
// EQUIPMENT
|
|
57
58
|
"Equipment Management": "EQUIPMENT",
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ported verbatim from the security mobile app (isecure365
|
|
3
|
+
* `src/features/visitors/nric-card.ts`) so the web NRIC scan reads a card
|
|
4
|
+
* exactly as the app does. Keep the two in step.
|
|
5
|
+
*
|
|
6
|
+
* Reading a Singapore NRIC (or FIN card) from the text the camera saw.
|
|
7
|
+
*
|
|
8
|
+
* The visitor form's "Scan" beside the NRIC field photographs the card, runs
|
|
9
|
+
* on-device text recognition (`modules/text-recognizer`) and hands the lines
|
|
10
|
+
* here. Nothing leaves the device: the photo is deleted straight after it is
|
|
11
|
+
* read, and only the two values below reach the form.
|
|
12
|
+
*
|
|
13
|
+
* The front of the card reads, top to bottom:
|
|
14
|
+
*
|
|
15
|
+
* REPUBLIC OF SINGAPORE
|
|
16
|
+
* IDENTITY CARD NO. S1234567D
|
|
17
|
+
* Name
|
|
18
|
+
* TAN AH KOW <- may wrap onto a second line
|
|
19
|
+
* (name in Chinese / Tamil / Jawi, when there is one)
|
|
20
|
+
* Race ... Date of birth ... Sex ... Country/Place of birth ...
|
|
21
|
+
*
|
|
22
|
+
* The back carries the number again (with its barcode), so the number alone
|
|
23
|
+
* can also be read from the back.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** One line of recognised text and where it sits in the photo. */
|
|
27
|
+
export type CardLine = {
|
|
28
|
+
text: string;
|
|
29
|
+
x: number;
|
|
30
|
+
y: number;
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type NricCardRead = {
|
|
36
|
+
nric: string | null;
|
|
37
|
+
name: string | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const WEIGHTS = [2, 7, 6, 5, 4, 3, 2];
|
|
41
|
+
|
|
42
|
+
/** Check letter by remainder, per prefix family. `T` / `G` add 4 first. */
|
|
43
|
+
const CHECK_LETTERS: Record<string, { letters: string; offset: number }> = {
|
|
44
|
+
S: { letters: "JZIHGFEDCBA", offset: 0 },
|
|
45
|
+
T: { letters: "JZIHGFEDCBA", offset: 4 },
|
|
46
|
+
F: { letters: "XWUTRQPNMLK", offset: 0 },
|
|
47
|
+
G: { letters: "XWUTRQPNMLK", offset: 4 },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the last letter matches the number. `null` for an M-series FIN,
|
|
52
|
+
* whose check letter is not checked here: the scanner instead waits for the
|
|
53
|
+
* same M number to be read twice in a row.
|
|
54
|
+
*/
|
|
55
|
+
export function isNricChecksumValid(nric: string): boolean | null {
|
|
56
|
+
const value = nric.toUpperCase();
|
|
57
|
+
if (!/^[STFGM]\d{7}[A-Z]$/.test(value)) return false;
|
|
58
|
+
const rule = CHECK_LETTERS[value[0]];
|
|
59
|
+
if (!rule) return null;
|
|
60
|
+
let sum = rule.offset;
|
|
61
|
+
for (let i = 0; i < 7; i += 1) sum += Number(value[i + 1]) * WEIGHTS[i];
|
|
62
|
+
return rule.letters[sum % 11] === value[8];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Characters text recognition commonly puts where a digit is printed. */
|
|
66
|
+
const AS_DIGIT: Record<string, string> = {
|
|
67
|
+
O: "0",
|
|
68
|
+
D: "0",
|
|
69
|
+
Q: "0",
|
|
70
|
+
I: "1",
|
|
71
|
+
L: "1",
|
|
72
|
+
Z: "2",
|
|
73
|
+
S: "5",
|
|
74
|
+
G: "6",
|
|
75
|
+
B: "8",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** A letter, seven digit-like characters, a letter — before any fixing. */
|
|
79
|
+
const NRIC_LIKE = /^[STFGM5][0-9ODQILZSGB]{7}[A-Z]$/;
|
|
80
|
+
|
|
81
|
+
function fixCandidate(raw: string): string {
|
|
82
|
+
const prefix = raw[0] === "5" ? "S" : raw[0];
|
|
83
|
+
const digits = raw
|
|
84
|
+
.slice(1, 8)
|
|
85
|
+
.split("")
|
|
86
|
+
.map((ch) => AS_DIGIT[ch] ?? ch)
|
|
87
|
+
.join("");
|
|
88
|
+
return `${prefix}${digits}${raw[8]}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The NRIC/FIN in the text, or null. A number whose check letter matches wins
|
|
93
|
+
* over one that only has the right shape (an M-series FIN).
|
|
94
|
+
*/
|
|
95
|
+
export function findNric(lines: CardLine[]): string | null {
|
|
96
|
+
const texts = lines.map((line) => line.text);
|
|
97
|
+
// Each line on its own, then everything joined in case the number was split.
|
|
98
|
+
texts.push(lines.map((line) => line.text).join(""));
|
|
99
|
+
|
|
100
|
+
let shapeOnly: string | null = null;
|
|
101
|
+
for (const text of texts) {
|
|
102
|
+
const compact = text.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
103
|
+
for (let start = 0; start + 9 <= compact.length; start += 1) {
|
|
104
|
+
const window = compact.slice(start, start + 9);
|
|
105
|
+
if (!NRIC_LIKE.test(window)) continue;
|
|
106
|
+
const candidate = fixCandidate(window);
|
|
107
|
+
const valid = isNricChecksumValid(candidate);
|
|
108
|
+
if (valid === true) return candidate;
|
|
109
|
+
if (valid === null && !shapeOnly) shapeOnly = candidate;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return shapeOnly;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A number with the NRIC's shape whose check letter does NOT match, or null.
|
|
117
|
+
*
|
|
118
|
+
* Never filled in on its own: the scanner only offers it ("Use anyway") once
|
|
119
|
+
* the same number has been read several times in a row, so a one-off misread
|
|
120
|
+
* never reaches the form but a worn card, or a specimen with a dummy number,
|
|
121
|
+
* does not leave the guard stuck.
|
|
122
|
+
*/
|
|
123
|
+
export function findUncheckedNric(lines: CardLine[]): string | null {
|
|
124
|
+
const texts = lines.map((line) => line.text);
|
|
125
|
+
texts.push(lines.map((line) => line.text).join(""));
|
|
126
|
+
for (const text of texts) {
|
|
127
|
+
const compact = text.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
128
|
+
for (let start = 0; start + 9 <= compact.length; start += 1) {
|
|
129
|
+
const window = compact.slice(start, start + 9);
|
|
130
|
+
if (!NRIC_LIKE.test(window)) continue;
|
|
131
|
+
const candidate = fixCandidate(window);
|
|
132
|
+
if (isNricChecksumValid(candidate) === false) return candidate;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const NAME_LABEL = /^\s*name\b[\s:.]*/i;
|
|
139
|
+
/** Labels that come after the name on the card: reading stops at them. */
|
|
140
|
+
const AFTER_NAME = /\b(race|date|birth|sex|country|identity|republic)\b/i;
|
|
141
|
+
|
|
142
|
+
/** Printed names are capitals: letters, spaces and a little punctuation. */
|
|
143
|
+
function asName(text: string): string | null {
|
|
144
|
+
const value = text.replace(/\s+/g, " ").trim();
|
|
145
|
+
if (!/^[A-Za-z][A-Za-z .,'@/()-]*$/.test(value)) return null;
|
|
146
|
+
const letters = value.replace(/[^A-Za-z]/g, "");
|
|
147
|
+
if (letters.length < 2) return null;
|
|
148
|
+
const capitals = letters.replace(/[^A-Z]/g, "").length;
|
|
149
|
+
// A few lower-case misreads are fine; mostly lower case is a label, not a name.
|
|
150
|
+
if (capitals / letters.length < 0.8) return null;
|
|
151
|
+
return value.toUpperCase();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The labels printed right under the name ("Race", "Date of birth", "Sex",
|
|
156
|
+
* "Country/Place of birth"), left-aligned with it. Not "Date of issue", which
|
|
157
|
+
* is on the back.
|
|
158
|
+
*/
|
|
159
|
+
const UNDER_NAME = /\b(race|sex|country|date of b\w*|birth)\b/i;
|
|
160
|
+
/** Capitals on the card that are not the holder's name. */
|
|
161
|
+
const NOT_A_NAME = /\b(republic|singapore|identity|card|specimen)\b/i;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Race values printed on the card, under "Race". When the name line itself is
|
|
165
|
+
* not read, the Race value is the next line in capitals and was being taken
|
|
166
|
+
* for the name.
|
|
167
|
+
*/
|
|
168
|
+
const RACE_VALUES = new Set([
|
|
169
|
+
"CHINESE",
|
|
170
|
+
"MALAY",
|
|
171
|
+
"INDIAN",
|
|
172
|
+
"EURASIAN",
|
|
173
|
+
"CAUCASIAN",
|
|
174
|
+
"FILIPINO",
|
|
175
|
+
"JAVANESE",
|
|
176
|
+
"BOYANESE",
|
|
177
|
+
"BUGIS",
|
|
178
|
+
"SIKH",
|
|
179
|
+
"ARAB",
|
|
180
|
+
"JAPANESE",
|
|
181
|
+
"KOREAN",
|
|
182
|
+
"THAI",
|
|
183
|
+
"VIETNAMESE",
|
|
184
|
+
"BURMESE",
|
|
185
|
+
"SINHALESE",
|
|
186
|
+
"PAKISTANI",
|
|
187
|
+
"BANGLADESHI",
|
|
188
|
+
"NEPALESE",
|
|
189
|
+
"INDONESIAN",
|
|
190
|
+
"OTHERS",
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
function nameOf(line: CardLine): string | null {
|
|
194
|
+
const name = asName(line.text);
|
|
195
|
+
if (!name || NOT_A_NAME.test(name) || RACE_VALUES.has(name)) return null;
|
|
196
|
+
return name;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The name from its first line, plus the lines it wraps onto: a long name
|
|
201
|
+
* wraps onto a second line set tight under the first; the name in another
|
|
202
|
+
* script below it has a wider gap and is not Latin.
|
|
203
|
+
*/
|
|
204
|
+
function joinName(first: CardLine, following: CardLine[]): string {
|
|
205
|
+
const parts = [first];
|
|
206
|
+
for (const line of following) {
|
|
207
|
+
if (AFTER_NAME.test(line.text)) break;
|
|
208
|
+
const previous = parts[parts.length - 1];
|
|
209
|
+
const gap = line.y - (previous.y + previous.height);
|
|
210
|
+
if (nameOf(line) && gap < previous.height * 0.6 && parts.length < 3) {
|
|
211
|
+
parts.push(line);
|
|
212
|
+
} else break;
|
|
213
|
+
}
|
|
214
|
+
return parts
|
|
215
|
+
.map((line) => nameOf(line))
|
|
216
|
+
.join(" ")
|
|
217
|
+
.replace(/\s+/g, " ")
|
|
218
|
+
.trim();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The name under the "Name" label, starting at the label's left edge. */
|
|
222
|
+
function nameUnderLabel(sorted: CardLine[]): string | null {
|
|
223
|
+
const labelIndex = sorted.findIndex((line) => NAME_LABEL.test(line.text));
|
|
224
|
+
if (labelIndex < 0) return null;
|
|
225
|
+
const label = sorted[labelIndex];
|
|
226
|
+
|
|
227
|
+
// "Name TAN AH KOW" read as one line.
|
|
228
|
+
const sameLine = asName(label.text.replace(NAME_LABEL, ""));
|
|
229
|
+
if (sameLine && !AFTER_NAME.test(sameLine) && !NOT_A_NAME.test(sameLine)) {
|
|
230
|
+
return sameLine;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const tolerance = Math.max(label.height * 1.5, label.width * 0.5);
|
|
234
|
+
const below = sorted
|
|
235
|
+
.slice(labelIndex + 1)
|
|
236
|
+
.filter(
|
|
237
|
+
(line) => line.y > label.y && Math.abs(line.x - label.x) <= tolerance,
|
|
238
|
+
);
|
|
239
|
+
// The first line under the label must be the name; anything else ends it.
|
|
240
|
+
if (!below.length || AFTER_NAME.test(below[0].text) || !nameOf(below[0])) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
return joinName(below[0], below.slice(1));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Without the "Name" label (it is printed small and is often not read): the
|
|
248
|
+
* name is the first line in capitals in the column above "Race" / "Date of
|
|
249
|
+
* birth", which are left-aligned with it.
|
|
250
|
+
*/
|
|
251
|
+
function nameAboveDetails(sorted: CardLine[]): string | null {
|
|
252
|
+
const details = sorted.filter((line) => UNDER_NAME.test(line.text));
|
|
253
|
+
if (!details.length) return null;
|
|
254
|
+
// The top row of those labels, its left-most: "Race" or "Date of birth",
|
|
255
|
+
// never "Sex" beside it.
|
|
256
|
+
const top = details[0];
|
|
257
|
+
const anchor = details
|
|
258
|
+
.filter((line) => Math.abs(line.y - top.y) <= top.height)
|
|
259
|
+
.reduce((left, line) => (line.x < left.x ? line : left));
|
|
260
|
+
|
|
261
|
+
const tolerance = anchor.height * 2;
|
|
262
|
+
const column = sorted.filter(
|
|
263
|
+
(line) => line.y < anchor.y && Math.abs(line.x - anchor.x) <= tolerance,
|
|
264
|
+
);
|
|
265
|
+
const firstIndex = column.findIndex((line) => nameOf(line) !== null);
|
|
266
|
+
if (firstIndex < 0) return null;
|
|
267
|
+
return joinName(column[firstIndex], column.slice(firstIndex + 1));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The holder's name off the front of the card, or null. */
|
|
271
|
+
export function findName(lines: CardLine[]): string | null {
|
|
272
|
+
const sorted = [...lines].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
273
|
+
return nameUnderLabel(sorted) ?? nameAboveDetails(sorted);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function readNricCard(lines: CardLine[]): NricCardRead {
|
|
277
|
+
return { nric: findNric(lines), name: findName(lines) };
|
|
278
|
+
}
|
|
@@ -68,12 +68,13 @@ export const RESOURCE_LABELS: Record<string, string> = {
|
|
|
68
68
|
export const RESOURCE_LABELS_BY_TYPE: Record<string, Record<string, string>> = {
|
|
69
69
|
property_management_agency: { "virtual-patrol": "CCTV" },
|
|
70
70
|
real_estate_developer: { "virtual-patrol": "CCTV" },
|
|
71
|
-
// Trade catalogue v2: the trade apps call `event-mgmt` the Task Calendar
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
// Trade catalogue v2: the trade apps call `event-mgmt` the Task Calendar and
|
|
72
|
+
// `schedule-task-mgmt` Extra Checks (owner 22 Oct; the key is unchanged).
|
|
73
|
+
cleaning_services: { "event-mgmt": "Task Calendar", "schedule-task-mgmt": "Extra checks" },
|
|
74
|
+
mechanical_electrical_services: { "event-mgmt": "Task Calendar", "schedule-task-mgmt": "Extra checks" },
|
|
75
|
+
pest_control_services: { "event-mgmt": "Task Calendar", "schedule-task-mgmt": "Extra checks" },
|
|
76
|
+
pool_maintenance_services: { "event-mgmt": "Task Calendar", "schedule-task-mgmt": "Extra checks" },
|
|
77
|
+
landscaping_services: { "event-mgmt": "Task Calendar", "schedule-task-mgmt": "Extra checks" },
|
|
77
78
|
};
|
|
78
79
|
|
|
79
80
|
/**
|
|
@@ -113,8 +114,18 @@ export function formatResourceLabel(key: string, type?: string): string {
|
|
|
113
114
|
* `.replace(/-/g, " ")` inline; this keeps them from drifting apart, and keeps
|
|
114
115
|
* an acronym from being lower-cased into nonsense.
|
|
115
116
|
*/
|
|
117
|
+
/** Action keys whose words name a screen by its old name. Display only. */
|
|
118
|
+
const ACTION_LABELS: Record<string, string> = {
|
|
119
|
+
"see-all-schedule-tasks": "see all extra checks",
|
|
120
|
+
"add-schedule-task": "add extra check",
|
|
121
|
+
"update-schedule-task": "edit, pause or resume extra check",
|
|
122
|
+
"delete-schedule-task": "delete extra check",
|
|
123
|
+
"see-schedule-task-details": "see extra check details",
|
|
124
|
+
};
|
|
125
|
+
|
|
116
126
|
export function formatActionLabel(action: string): string {
|
|
117
127
|
if (!action) return "";
|
|
128
|
+
if (ACTION_LABELS[action]) return ACTION_LABELS[action];
|
|
118
129
|
return action
|
|
119
130
|
.split(/[-_\s]+/)
|
|
120
131
|
.filter(Boolean)
|
package/utils/role.ts
CHANGED
|
@@ -158,3 +158,27 @@ export function siteAccessLabel(item: { limitedTo?: unknown } | null | undefined
|
|
|
158
158
|
const names = limitedSites(item).map((l) => l.siteName || "a site");
|
|
159
159
|
return names.length ? `Limited to: ${names.join(", ")}` : "All sites";
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A role's `type` is the app it belongs to, stored as a code
|
|
164
|
+
* (`security_agency`). The roles table shows the app's plain name instead.
|
|
165
|
+
* An unknown code is shown with its underscores as spaces, never dropped.
|
|
166
|
+
*/
|
|
167
|
+
const ROLE_APP_LABELS: Readonly<Record<string, string>> = Object.freeze({
|
|
168
|
+
admin: "Super Admin",
|
|
169
|
+
organization: "Organization",
|
|
170
|
+
property_management_agency: "Property Management",
|
|
171
|
+
security_agency: "Security",
|
|
172
|
+
cleaning_services: "Cleaning",
|
|
173
|
+
mechanical_electrical_services: "Mechanical & Electrical",
|
|
174
|
+
pest_control_services: "Pest Control",
|
|
175
|
+
pool_maintenance_services: "Pool Maintenance",
|
|
176
|
+
landscaping_services: "Landscape",
|
|
177
|
+
resident: "Resident",
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
export function roleAppLabel(type: unknown): string {
|
|
181
|
+
const code = String(type ?? "").trim();
|
|
182
|
+
if (!code) return "";
|
|
183
|
+
return ROLE_APP_LABELS[code] ?? code.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
|
|
184
|
+
}
|