@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.
@@ -1,5 +1,11 @@
1
1
  /**
2
- * EXTRA VISITS (SCHEDULE TASKS) - the rules and the wording, in one place.
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 visit to ${areas} (${units}) ${describeWhen(f, year)} — next: ${nextText}.`;
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 visit as a whole. "ended" reads "Finished": nothing is left to run. */
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 add a visit at its next date and time." },
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 visit." },
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" / "No visit yet". */
255
- export function lastRunLabel(row: { lastRun?: string | null; lastRunStatus?: string | null }): string {
256
- if (!row.lastRun) return "No visit yet";
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 visit says Finished, in one sentence. */
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
+ }