@7365admin1/layer-common 4.97.1-staging.586 → 4.97.1-staging.588
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/components/AddPassKeyToVisitor.vue +332 -54
- package/components/FileInput.vue +4 -0
- package/components/MemberInformation.vue +580 -281
- package/components/Nfc/NFCPatrolRouteForm.vue +81 -13
- package/components/Nfc/NFCPatrolRouteMain.vue +17 -5
- package/components/Nfc/PatrolReport/SummaryReportTable.vue +1 -0
- package/components/NricScanDialog.vue +512 -0
- package/components/PassInformation.vue +11 -2
- package/components/VisitorForm.vue +1793 -642
- package/components/VisitorFormSelection.vue +51 -18
- package/components/VisitorManagement.vue +4507 -3518
- package/components/VisitorPassKeyQRScanner.vue +1 -1
- package/components/VisitorSocketPopUp.vue +72 -23
- package/composables/useNFCPatrolReportFilters.ts +55 -15
- package/composables/useNFCPatrolRoute.ts +4 -2
- package/composables/usePeople.ts +54 -0
- package/composables/useVisitor.ts +4 -2
- package/composables/useVisitorSocket.ts +19 -0
- package/package.json +2 -1
- package/types/nfc-patrol-report.ts +4 -2
- package/utils/nfc-patrol-report.ts +47 -10
- package/utils/nfc-patrol-schedule.ts +20 -0
- package/utils/nric-card.ts +278 -0
- package/utils/visitor-people.ts +262 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* People lookup helpers for the visitor form's autofill, ported as is from the
|
|
3
|
+
* security mobile app (isecure365 `src/features/people/people.service.ts` and
|
|
4
|
+
* `src/features/visitors/delivery-company.ts`), so the web and the app fill a
|
|
5
|
+
* visitor in the same way.
|
|
6
|
+
*
|
|
7
|
+
* People records are kept per visitor type (guest, contractor, delivery, with
|
|
8
|
+
* walk-in kept as guest). The form looks a person up across EVERY visitor type
|
|
9
|
+
* - a past contractor is offered when adding a Guest, and so on - with the type
|
|
10
|
+
* being registered listed first, and the same person on file under several
|
|
11
|
+
* types shown once.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The person types a visitor registration keeps a People record for. */
|
|
15
|
+
export const VISITOR_PERSON_TYPES = ["guest", "contractor", "delivery"] as const;
|
|
16
|
+
|
|
17
|
+
export type VisitorPerson = {
|
|
18
|
+
_id?: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
nric?: string;
|
|
21
|
+
contact?: string | string[];
|
|
22
|
+
plateNumber?: string | string[];
|
|
23
|
+
company?: string;
|
|
24
|
+
companyName?: string | string[];
|
|
25
|
+
type?: string;
|
|
26
|
+
/** Every record behind one result (a merged result is several). */
|
|
27
|
+
ids?: string[];
|
|
28
|
+
/** The companies of each record by its person type. */
|
|
29
|
+
companiesByType?: Record<string, string[]>;
|
|
30
|
+
contacts?: string[];
|
|
31
|
+
plateNumbers?: string[];
|
|
32
|
+
plates?: { plateNumber?: string }[];
|
|
33
|
+
firstName?: string;
|
|
34
|
+
lastName?: string;
|
|
35
|
+
phoneNumber?: string;
|
|
36
|
+
contactNumbers?: unknown[];
|
|
37
|
+
phoneNumbers?: unknown[];
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const listOf = (value: unknown): unknown[] =>
|
|
42
|
+
Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
|
|
43
|
+
|
|
44
|
+
function uniqueOf(
|
|
45
|
+
values: unknown[],
|
|
46
|
+
pick: (value: Record<string, unknown>) => unknown
|
|
47
|
+
): string[] {
|
|
48
|
+
return Array.from(
|
|
49
|
+
new Set(
|
|
50
|
+
values
|
|
51
|
+
.map((value) =>
|
|
52
|
+
typeof value === "string"
|
|
53
|
+
? value
|
|
54
|
+
: value && typeof value === "object"
|
|
55
|
+
? pick(value as Record<string, unknown>)
|
|
56
|
+
: undefined
|
|
57
|
+
)
|
|
58
|
+
.filter(
|
|
59
|
+
(value): value is string => typeof value === "string" && Boolean(value.trim())
|
|
60
|
+
)
|
|
61
|
+
.map((value) => value.trim())
|
|
62
|
+
)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function summaryName(person: VisitorPerson): string {
|
|
67
|
+
return (
|
|
68
|
+
person.name ||
|
|
69
|
+
[person.firstName, person.lastName].filter(Boolean).join(" ") ||
|
|
70
|
+
"Unknown"
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function summaryContacts(person: VisitorPerson): string[] {
|
|
75
|
+
return uniqueOf(
|
|
76
|
+
[
|
|
77
|
+
...listOf(person.contact),
|
|
78
|
+
person.phoneNumber,
|
|
79
|
+
...listOf(person.contacts),
|
|
80
|
+
...listOf(person.contactNumbers),
|
|
81
|
+
...listOf(person.phoneNumbers),
|
|
82
|
+
],
|
|
83
|
+
(value) => value.contact || value.phoneNumber || value.number
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function summaryPlates(person: VisitorPerson): string[] {
|
|
88
|
+
return uniqueOf(
|
|
89
|
+
[...listOf(person.plateNumber), ...listOf(person.plates), ...listOf(person.plateNumbers)],
|
|
90
|
+
(value) => value.plateNumber || value.number || value.value
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The companies on one record (`companyName`, or a single `company`). */
|
|
95
|
+
function companiesOf(person: VisitorPerson): string[] {
|
|
96
|
+
return [
|
|
97
|
+
...new Set(
|
|
98
|
+
[...listOf(person.companyName), person.company]
|
|
99
|
+
.filter((name): name is string => typeof name === "string" && Boolean(name.trim()))
|
|
100
|
+
.map((name) => name.trim())
|
|
101
|
+
),
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The companies saved for the person from registrations of `type` only:
|
|
107
|
+
* contractor companies on the Contractor form, delivery companies on the
|
|
108
|
+
* Delivery form. Walk-in is kept as guest.
|
|
109
|
+
*/
|
|
110
|
+
export function companiesForType(person: VisitorPerson, type?: string): string[] {
|
|
111
|
+
const personType = type === "walk-in" ? "guest" : type;
|
|
112
|
+
if (!personType) return [];
|
|
113
|
+
if (person.companiesByType) return person.companiesByType[personType] ?? [];
|
|
114
|
+
return person.type === personType ? companiesOf(person) : [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const sameText = (a?: string, b?: string) =>
|
|
118
|
+
Boolean(a?.trim()) && a!.trim().toLowerCase() === b?.trim().toLowerCase();
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Records with the same name and the same NRIC (or, with no NRIC on either,
|
|
122
|
+
* the same name and a shared phone number) are one person: shown once, with
|
|
123
|
+
* every number, plate and company of both.
|
|
124
|
+
*/
|
|
125
|
+
export function mergePeople(people: VisitorPerson[]): VisitorPerson[] {
|
|
126
|
+
const merged: VisitorPerson[] = [];
|
|
127
|
+
const seenIds = new Set<string>();
|
|
128
|
+
for (const person of people) {
|
|
129
|
+
if (person._id) {
|
|
130
|
+
if (seenIds.has(person._id)) continue;
|
|
131
|
+
seenIds.add(person._id);
|
|
132
|
+
}
|
|
133
|
+
const contacts = summaryContacts(person);
|
|
134
|
+
const same = merged.find((other) => {
|
|
135
|
+
if (!sameText(summaryName(other), summaryName(person))) return false;
|
|
136
|
+
if (other.nric || person.nric) return sameText(other.nric, person.nric);
|
|
137
|
+
const otherContacts = other.contacts ?? [];
|
|
138
|
+
return contacts.some((contact) => otherContacts.includes(contact));
|
|
139
|
+
});
|
|
140
|
+
if (!same) {
|
|
141
|
+
merged.push({
|
|
142
|
+
...person,
|
|
143
|
+
ids: person._id ? [person._id] : [],
|
|
144
|
+
companiesByType: person.type ? { [person.type]: companiesOf(person) } : {},
|
|
145
|
+
contacts,
|
|
146
|
+
plateNumbers: summaryPlates(person),
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (person.type) {
|
|
151
|
+
const byType = { ...(same.companiesByType ?? {}) };
|
|
152
|
+
byType[person.type] = [
|
|
153
|
+
...new Set([...(byType[person.type] ?? []), ...companiesOf(person)]),
|
|
154
|
+
];
|
|
155
|
+
same.companiesByType = byType;
|
|
156
|
+
}
|
|
157
|
+
if (person._id) same.ids = [...(same.ids ?? []), person._id];
|
|
158
|
+
same.contacts = [...new Set([...(same.contacts ?? []), ...contacts])];
|
|
159
|
+
same.plateNumbers = [
|
|
160
|
+
...new Set([...(same.plateNumbers ?? []), ...summaryPlates(person)]),
|
|
161
|
+
];
|
|
162
|
+
same.companyName = [
|
|
163
|
+
...new Set(
|
|
164
|
+
[...listOf(same.companyName), ...listOf(person.companyName)].filter(
|
|
165
|
+
(name): name is string => typeof name === "string" && Boolean(name.trim())
|
|
166
|
+
)
|
|
167
|
+
),
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
return merged;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** People of the visitor types only, the type being registered first, merged. */
|
|
174
|
+
export function visitorPeopleResults(
|
|
175
|
+
found: VisitorPerson[],
|
|
176
|
+
preferType?: string
|
|
177
|
+
): VisitorPerson[] {
|
|
178
|
+
const visitorTypes: readonly string[] = VISITOR_PERSON_TYPES;
|
|
179
|
+
return mergePeople(
|
|
180
|
+
found
|
|
181
|
+
// A server without the several-types filter returns every type.
|
|
182
|
+
.filter((person) => !person.type || visitorTypes.includes(person.type))
|
|
183
|
+
.sort((a, b) => Number(b.type === preferType) - Number(a.type === preferType))
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Every record behind a result (a merged one is several). */
|
|
188
|
+
export function recordIdsOf(person: VisitorPerson): string[] {
|
|
189
|
+
return [...new Set([...(person.ids ?? []), ...(person._id ? [person._id] : [])])];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A stable key for "this person was already filled in from this field". */
|
|
193
|
+
export function personKey(person: VisitorPerson): string {
|
|
194
|
+
const ids = recordIdsOf(person);
|
|
195
|
+
if (ids.length) return ids.join(",");
|
|
196
|
+
return `${summaryName(person)}|${person.nric ?? ""}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const DELETE_PERSON_TITLE = "Delete saved details?";
|
|
200
|
+
export const deletePersonMessage = (name: string) =>
|
|
201
|
+
`${name}'s saved details will no longer be offered when filling in the form. Use this when the NRIC saved for them is wrong. Their past visits are not changed, and their details are saved again on their next visit.`;
|
|
202
|
+
export const PERSON_DELETED_MESSAGE = "Saved details deleted.";
|
|
203
|
+
|
|
204
|
+
/*
|
|
205
|
+
* The delivery company list (isecure365 `delivery-company.ts`): names are
|
|
206
|
+
* compared ignoring case and surrounding spaces, so "grabfood" is the listed
|
|
207
|
+
* "GrabFood".
|
|
208
|
+
*/
|
|
209
|
+
export const normalizeCompany = (value: unknown): string =>
|
|
210
|
+
typeof value === "string" ? value.trim() : "";
|
|
211
|
+
|
|
212
|
+
export const sameCompany = (a: string, b: string): boolean =>
|
|
213
|
+
a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
214
|
+
|
|
215
|
+
export const isListedCompany = (list: readonly string[], name: string): boolean =>
|
|
216
|
+
list.some((company) => sameCompany(company, name));
|
|
217
|
+
|
|
218
|
+
/** A typed name that matches a listed company apart from case / spaces becomes the listed one. */
|
|
219
|
+
export function listedName(list: readonly string[], typed: unknown): string {
|
|
220
|
+
const name = normalizeCompany(typed);
|
|
221
|
+
if (!name) return "";
|
|
222
|
+
return list.find((company) => sameCompany(company, name)) ?? name;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export const withCompany = (list: readonly string[], name: string): string[] =>
|
|
226
|
+
isListedCompany(list, name) ? [...list] : [...list, name];
|
|
227
|
+
|
|
228
|
+
export function renamedCompany(
|
|
229
|
+
list: readonly string[],
|
|
230
|
+
original: string,
|
|
231
|
+
name: string
|
|
232
|
+
): string[] {
|
|
233
|
+
const index = list.findIndex((company) => sameCompany(company, original));
|
|
234
|
+
if (index === -1) return [...list, name];
|
|
235
|
+
const next = [...list];
|
|
236
|
+
next[index] = name;
|
|
237
|
+
return next;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export const withoutCompany = (list: readonly string[], name: string): string[] =>
|
|
241
|
+
list.filter((company) => !sameCompany(company, name));
|
|
242
|
+
|
|
243
|
+
export const COMPANY_NAME_REQUIRED = "Company name is required.";
|
|
244
|
+
export const COMPANY_ALREADY_LISTED = "This company is already in the list.";
|
|
245
|
+
|
|
246
|
+
export function renameProblem(
|
|
247
|
+
list: readonly string[],
|
|
248
|
+
original: string,
|
|
249
|
+
value: unknown
|
|
250
|
+
): string | null {
|
|
251
|
+
const name = normalizeCompany(value);
|
|
252
|
+
if (!name) return COMPANY_NAME_REQUIRED;
|
|
253
|
+
if (!sameCompany(name, original) && isListedCompany(list, name))
|
|
254
|
+
return COMPANY_ALREADY_LISTED;
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** "Pass VP01 is In Use" -> "VP01" (the pass / key the server refused). */
|
|
259
|
+
export function refusedPassKeyName(message: string | undefined | null): string {
|
|
260
|
+
const match = /\b(?:Pass|Key)\s+(\S+)\s+is\s+/i.exec(String(message ?? ""));
|
|
261
|
+
return match?.[1]?.replace(/[.,;:]+$/, "") ?? "";
|
|
262
|
+
}
|