@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
package/utils/schedule-task.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* EXTRA
|
|
2
|
+
* EXTRA CHECKS (stored as "schedule tasks") - the rules and the wording, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Named "Extra Checks" (owner 22 Oct: "Extra Visits (Scheduled)" was not
|
|
5
|
+
* understood). It is one more check of an area at a set time, added to staff's
|
|
6
|
+
* Daily Checklist - "Lunch toilet check", "Pump room check", "Pool water check".
|
|
7
|
+
* "Check" is the word every trade already uses and it matches "Daily Checklist".
|
|
8
|
+
* Saved data, API paths and permission keys keep their old names.
|
|
3
9
|
*
|
|
4
10
|
* Mirrors the server (module-hygiene `schedule-task-rules.util.ts`) so the form
|
|
5
11
|
* can say "this will never run" BEFORE the user saves, and the list and the
|
|
@@ -9,6 +15,18 @@
|
|
|
9
15
|
* Pure: no Vue, no imports, so it is unit-tested with `node --test`.
|
|
10
16
|
*/
|
|
11
17
|
|
|
18
|
+
export const FEATURE_NAME = "Extra Checks";
|
|
19
|
+
export const FEATURE_SUBTITLE =
|
|
20
|
+
"Add a check at a set time - once, or on a repeat. At that time it appears on staff's Daily Checklist on their phone.";
|
|
21
|
+
|
|
22
|
+
/** The chip on an extra round of the Daily Checklist: "Extra check: Lunch toilet check · 2:00 pm". */
|
|
23
|
+
export function extraCheckLabel(set: { scheduleTaskTitle?: string | null; scheduleTaskTime?: string | null } | null | undefined): string {
|
|
24
|
+
const title = (set?.scheduleTaskTitle || "").trim();
|
|
25
|
+
const time = fmtTime(set?.scheduleTaskTime);
|
|
26
|
+
if (!title) return "Extra check";
|
|
27
|
+
return `Extra check: ${title}${time ? ` · ${time}` : ""}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
12
30
|
export type TRepeat = "none" | "daily" | "weekdays" | "weekly" | "monthly";
|
|
13
31
|
|
|
14
32
|
export const REPEAT_OPTIONS: { title: string; value: TRepeat }[] = [
|
|
@@ -119,6 +137,29 @@ function occursOn(r: TRule, date: string): boolean {
|
|
|
119
137
|
}
|
|
120
138
|
}
|
|
121
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Does a STORED extra check fall on `date` (YYYY-MM-DD)? For calendars (the
|
|
142
|
+
* Property Management Task Calendar overlay). Paused ones do not; fields a stored
|
|
143
|
+
* row may lack are treated as empty.
|
|
144
|
+
*/
|
|
145
|
+
export function scheduleOccursOn(
|
|
146
|
+
row: { repeat?: string | null; dates?: string[] | null; daysOfWeek?: number[] | null; startDate?: string | null; endDate?: string | null; paused?: boolean | null },
|
|
147
|
+
date: string,
|
|
148
|
+
): boolean {
|
|
149
|
+
if (row.paused) return false;
|
|
150
|
+
return occursOn(
|
|
151
|
+
{
|
|
152
|
+
repeat: (row.repeat || "none") as TRepeat,
|
|
153
|
+
dates: row.dates ?? [],
|
|
154
|
+
daysOfWeek: row.daysOfWeek ?? [],
|
|
155
|
+
startDate: row.startDate ?? "",
|
|
156
|
+
endDate: row.endDate ?? "",
|
|
157
|
+
time: "",
|
|
158
|
+
},
|
|
159
|
+
date,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
122
163
|
/**
|
|
123
164
|
* The next day this would run if saved right now ("YYYY-MM-DD"), or null when it never would.
|
|
124
165
|
* A day whose time has already passed does not count: a task saved after its time waits for its next day.
|
|
@@ -231,36 +272,37 @@ export function buildSummary(
|
|
|
231
272
|
const units = `${unitCount} unit${unitCount === 1 ? "" : "s"}`;
|
|
232
273
|
const next = nextRunDate(f, now);
|
|
233
274
|
const nextText = next ? `${DAYS[weekdayOf(next)]} ${fmtDate(next, year)}` : "never";
|
|
234
|
-
return `Adds 1 extra
|
|
275
|
+
return `Adds 1 extra check to ${areas} (${units} to tick) ${describeWhen(f, year)} — next: ${nextText}.`;
|
|
235
276
|
}
|
|
236
277
|
|
|
237
|
-
/** The state of an extra
|
|
278
|
+
/** The state of an extra check as a whole. "ended" reads "Finished": nothing is left to run. */
|
|
238
279
|
export const STATUS_LABELS: Record<string, string> = { active: "Active", paused: "Paused", ended: "Finished" };
|
|
239
280
|
|
|
240
281
|
/** What each word means, shown on the page so nobody has to guess. Same words as the mobile apps. */
|
|
241
282
|
export const STATUS_HELP: { word: string; meaning: string }[] = [
|
|
242
|
-
{ word: "Active", meaning: "It will
|
|
283
|
+
{ word: "Active", meaning: "It will be added to staff's checklist at its next date and time." },
|
|
243
284
|
{ word: "Paused", meaning: "Stopped for now. Nothing is added until you press Resume." },
|
|
244
285
|
{ word: "Finished", meaning: "Its last date has passed, so nothing more is added. Press \"Extend or repeat\" to keep it going." },
|
|
245
286
|
];
|
|
246
287
|
export const RESULT_HELP: { word: string; meaning: string }[] = [
|
|
247
288
|
{ word: "Not started", meaning: "Nothing is ticked yet." },
|
|
248
289
|
{ word: "In progress", meaning: "Some units are ticked; the rest can still be done today." },
|
|
249
|
-
{ word: "Done", meaning: "Staff ticked every unit of the
|
|
290
|
+
{ word: "Done", meaning: "Staff ticked every unit of the check." },
|
|
250
291
|
{ word: "Needs redo", meaning: "A supervisor sent a unit back to be done again." },
|
|
251
292
|
{ word: "Missed", meaning: "The day ended with units not done." },
|
|
252
293
|
];
|
|
253
294
|
|
|
254
|
-
/** "Done 20 Oct" / "Missed 20 Oct" / "
|
|
255
|
-
export function lastRunLabel(row: { lastRun?: string | null; lastRunStatus?: string | null }): string {
|
|
256
|
-
|
|
295
|
+
/** "Done 20 Oct" / "Missed 20 Oct" / "Not done yet". */
|
|
296
|
+
export function lastRunLabel(row: { lastRun?: string | null; lastRunStatus?: string | null; scheduleStatus?: string | null }): string {
|
|
297
|
+
// Finished rows from before run records existed (20 Oct) may well have run: say we do not know.
|
|
298
|
+
if (!row.lastRun) return row.scheduleStatus === "ended" ? "No record" : "Not added yet";
|
|
257
299
|
const m = /^(\d{4}-\d{2}-\d{2})/.exec(row.lastRun);
|
|
258
300
|
const day = m ? fmtDate(m[1]) : "";
|
|
259
301
|
const word = { done: "Done", missed: "Missed", pending: "In progress" }[row.lastRunStatus || ""] || "Added";
|
|
260
302
|
return `${word} ${day}`.trim();
|
|
261
303
|
}
|
|
262
304
|
|
|
263
|
-
/** Why an extra
|
|
305
|
+
/** Why an extra check says Finished, in one sentence. */
|
|
264
306
|
export function finishedReason(row: Partial<TRule>, nowYear?: string): string {
|
|
265
307
|
if (row.repeat && row.repeat !== "none" && row.endDate) {
|
|
266
308
|
return `Its end date (${fmtDate(row.endDate, nowYear)}) has passed.`;
|
|
@@ -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
|
+
}
|