@7365admin1/layer-common 4.97.1-staging.577 → 4.97.1-staging.579

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.
@@ -0,0 +1,71 @@
1
+ // Browser logs for the Seven365 Logger. Entries are batched and sent to API-core
2
+ // (POST /api/client-logs), which files them under this app's own folder and adds
3
+ // the signed-in user from the session. Nothing is sent in development.
4
+ type Level = "info" | "warn" | "error";
5
+
6
+ type Entry = { level: Level; message: string; context: Record<string, unknown>; timestamp: string };
7
+
8
+ const queue: Entry[] = [];
9
+ const recent = new Map<string, number>();
10
+ const sessionId = Math.random().toString(36).slice(2, 12);
11
+ let app = "";
12
+ let timer: ReturnType<typeof setTimeout> | null = null;
13
+
14
+ const BATCH = 20;
15
+ const FLUSH_MS = 5000;
16
+ const REPEAT_WINDOW_MS = 10000;
17
+
18
+ export function configureClientLog(appName: string) {
19
+ app = appName;
20
+ }
21
+
22
+ export function apiPath(url: string) {
23
+ return String(url)
24
+ .split("?")[0]
25
+ .replace(/\b[0-9a-f]{24}\b/gi, ":id")
26
+ .replace(/\/\d+(?=\/|$)/g, "/:n");
27
+ }
28
+
29
+ function sid() {
30
+ const match = document.cookie.match(/(?:^|;\s*)sid=([^;]+)/);
31
+ return match ? decodeURIComponent(match[1]) : "";
32
+ }
33
+
34
+ export function clientLog(level: Level, message: string, context: Record<string, unknown> = {}) {
35
+ if (!import.meta.client || import.meta.dev || !app || !message) return;
36
+
37
+ // The same line from the same tab within 10s is one event (a re-rendering
38
+ // component can throw the same error dozens of times a second).
39
+ const key = `${level}|${message}`;
40
+ const now = Date.now();
41
+ if ((recent.get(key) ?? 0) > now - REPEAT_WINDOW_MS) return;
42
+ if (recent.size > 200) recent.clear();
43
+ recent.set(key, now);
44
+
45
+ queue.push({
46
+ level,
47
+ message: String(message).slice(0, 2000),
48
+ context: { ...context, page: location.pathname, sessionId },
49
+ timestamp: new Date().toISOString(),
50
+ });
51
+
52
+ if (queue.length >= BATCH) void flushClientLogs();
53
+ else if (!timer) timer = setTimeout(() => void flushClientLogs(), FLUSH_MS);
54
+ }
55
+
56
+ export async function flushClientLogs() {
57
+ if (timer) clearTimeout(timer);
58
+ timer = null;
59
+ const logs = queue.splice(0, 50);
60
+ if (!logs.length) return;
61
+ try {
62
+ await fetch("/api/client-logs", {
63
+ method: "POST",
64
+ keepalive: true,
65
+ headers: { "Content-Type": "application/json", Authorization: sid() },
66
+ body: JSON.stringify({ app, logs }),
67
+ });
68
+ } catch {
69
+ // Logging must never break the page; a lost batch is acceptable.
70
+ }
71
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * EXTRA VISITS (SCHEDULE TASKS) - the rules and the wording, in one place.
3
+ *
4
+ * Mirrors the server (module-hygiene `schedule-task-rules.util.ts`) so the form
5
+ * can say "this will never run" BEFORE the user saves, and the list and the
6
+ * summary can speak plain English. The server stays the authority; every
7
+ * message it sends is still shown. All times are Singapore time (UTC+8).
8
+ *
9
+ * Pure: no Vue, no imports, so it is unit-tested with `node --test`.
10
+ */
11
+
12
+ export type TRepeat = "none" | "daily" | "weekdays" | "weekly" | "monthly";
13
+
14
+ export const REPEAT_OPTIONS: { title: string; value: TRepeat }[] = [
15
+ { title: "Once", value: "none" },
16
+ { title: "Every day", value: "daily" },
17
+ { title: "Weekdays (Mon to Fri)", value: "weekdays" },
18
+ { title: "Weekly", value: "weekly" },
19
+ { title: "Monthly", value: "monthly" },
20
+ ];
21
+
22
+ /** Sunday = 0, as the server stores it. Shown Monday first. */
23
+ export const WEEK_DAYS: { title: string; value: number }[] = [
24
+ { title: "Mon", value: 1 },
25
+ { title: "Tue", value: 2 },
26
+ { title: "Wed", value: 3 },
27
+ { title: "Thu", value: 4 },
28
+ { title: "Fri", value: 5 },
29
+ { title: "Sat", value: 6 },
30
+ { title: "Sun", value: 0 },
31
+ ];
32
+
33
+ export type TScheduleForm = {
34
+ title: string;
35
+ time: string; // HH:mm
36
+ repeat: TRepeat;
37
+ dates: string[]; // YYYY-MM-DD, used when repeat is "none"
38
+ daysOfWeek: number[];
39
+ startDate: string; // YYYY-MM-DD, when repeating
40
+ endDate: string; // YYYY-MM-DD or ""
41
+ areaCount: number;
42
+ };
43
+
44
+ export type TScheduleErrors = Partial<
45
+ Record<"title" | "areas" | "time" | "dates" | "daysOfWeek" | "startDate" | "endDate" | "never", string>
46
+ >;
47
+
48
+ const SGT_MS = 8 * 60 * 60 * 1000;
49
+ const TIME_RE = /^([0-1]\d|2[0-3]):([0-5]\d)$/;
50
+ const pad = (n: number) => String(n).padStart(2, "0");
51
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
52
+ const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
53
+
54
+ /** Singapore calendar date, minutes since midnight, weekday (0 = Sunday). */
55
+ export function sgtParts(now: Date = new Date()) {
56
+ const s = new Date(now.getTime() + SGT_MS);
57
+ return {
58
+ date: `${s.getUTCFullYear()}-${pad(s.getUTCMonth() + 1)}-${pad(s.getUTCDate())}`,
59
+ minutes: s.getUTCHours() * 60 + s.getUTCMinutes(),
60
+ weekday: s.getUTCDay(),
61
+ };
62
+ }
63
+
64
+ const toMinutes = (time: string) => {
65
+ const [h, m] = time.split(":").map(Number);
66
+ return h * 60 + m;
67
+ };
68
+ const utc = (date: string) => {
69
+ const [y, m, d] = date.split("-").map(Number);
70
+ return new Date(Date.UTC(y, m - 1, d));
71
+ };
72
+ const weekdayOf = (date: string) => utc(date).getUTCDay();
73
+ const daysInMonth = (date: string) => {
74
+ const [y, m] = date.split("-").map(Number);
75
+ return new Date(Date.UTC(y, m, 0)).getUTCDate();
76
+ };
77
+ const addDays = (date: string, n: number) => {
78
+ const t = utc(date);
79
+ t.setUTCDate(t.getUTCDate() + n);
80
+ return `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
81
+ };
82
+
83
+ /** "21 Oct" (the year is added only when it is not the current one). */
84
+ export function fmtDate(date: string, nowYear = sgtParts().date.slice(0, 4)): string {
85
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date || "")) return date || "";
86
+ const [y, m, d] = date.split("-");
87
+ return `${Number(d)} ${MONTHS[Number(m) - 1]}${y === nowYear ? "" : ` ${y}`}`;
88
+ }
89
+
90
+ /** "Tue 21 Oct, 08:00" from a server ISO string ("2026-10-21T08:00:00+08:00") - read as written, no time zone maths. */
91
+ export function fmtMoment(iso: string | null | undefined, nowYear?: string): string {
92
+ const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(iso || "");
93
+ if (!m) return "";
94
+ return `${DAYS[weekdayOf(m[1])]} ${fmtDate(m[1], nowYear)}, ${m[2]}`;
95
+ }
96
+
97
+ type TRule = Pick<TScheduleForm, "repeat" | "dates" | "daysOfWeek" | "startDate" | "endDate" | "time">;
98
+
99
+ const isRepeating = (r: Pick<TRule, "repeat">) => !!r.repeat && r.repeat !== "none";
100
+
101
+ function occursOn(r: TRule, date: string): boolean {
102
+ if (r.endDate && date > r.endDate) return false;
103
+ if (!isRepeating(r)) return r.dates.includes(date);
104
+ if (r.startDate && date < r.startDate) return false;
105
+ const wd = weekdayOf(date);
106
+ switch (r.repeat) {
107
+ case "daily":
108
+ return true;
109
+ case "weekdays":
110
+ return wd >= 1 && wd <= 5;
111
+ case "weekly":
112
+ return r.daysOfWeek.includes(wd);
113
+ case "monthly": {
114
+ const wanted = r.startDate ? Number(r.startDate.slice(8, 10)) : 1;
115
+ return Number(date.slice(8, 10)) === Math.min(wanted, daysInMonth(date));
116
+ }
117
+ default:
118
+ return false;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The next day this would run if saved right now ("YYYY-MM-DD"), or null when it never would.
124
+ * A day whose time has already passed does not count: a task saved after its time waits for its next day.
125
+ */
126
+ export function nextRunDate(r: TRule, now: Date = new Date()): string | null {
127
+ if (r.time && !TIME_RE.test(r.time)) return null;
128
+ const { date: today, minutes } = sgtParts(now);
129
+ const due = r.time ? toMinutes(r.time) : 0;
130
+ if (occursOn(r, today) && minutes < due) return today;
131
+ if (!isRepeating(r)) {
132
+ const later = r.dates.filter((d) => d > today && (!r.endDate || d <= r.endDate)).sort();
133
+ return later[0] ?? null;
134
+ }
135
+ const from = r.startDate && r.startDate > today ? r.startDate : today;
136
+ // 400 days covers the longest gap (a monthly rule on the 31st).
137
+ for (let i = from === today ? 1 : 0; i <= 400; i++) {
138
+ const d = addDays(from, i);
139
+ if (r.endDate && d > r.endDate) return null;
140
+ if (occursOn(r, d)) return d;
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /** Plain-English problems with the form, mirroring the server's checks. Empty object = fine. */
146
+ export function validateSchedule(f: TScheduleForm, now: Date = new Date()): TScheduleErrors {
147
+ const e: TScheduleErrors = {};
148
+ const { date: today, minutes } = sgtParts(now);
149
+ const repeating = isRepeating(f);
150
+
151
+ if (!f.title.trim()) e.title = "Please enter a title.";
152
+ if (f.areaCount < 1) e.areas = "Pick at least one area.";
153
+ if (!TIME_RE.test(f.time || "")) e.time = "Please choose a time (24-hour, like 08:30).";
154
+
155
+ if (repeating) {
156
+ if (!f.startDate) e.startDate = "Please choose a start date.";
157
+ if (f.repeat === "weekly" && !f.daysOfWeek.length) e.daysOfWeek = "Pick at least one weekday.";
158
+ } else if (!f.dates.length) {
159
+ e.dates = "Pick at least one date.";
160
+ }
161
+
162
+ const start = repeating ? f.startDate : [...f.dates].sort()[0];
163
+ if (f.endDate && start && start > f.endDate) e.endDate = "The end date must be on or after the start date.";
164
+ else if (f.endDate && f.endDate < today) e.endDate = "The end date has already passed.";
165
+
166
+ if (!repeating && f.dates.length && !e.time) {
167
+ if (f.dates.every((d) => d < today)) e.dates = "All the dates you picked have already passed. Pick today or a later date.";
168
+ else if (f.dates.every((d) => d <= today) && f.dates.includes(today) && toMinutes(f.time) <= minutes) {
169
+ e.dates = "That time has already passed today. Pick a later time or another date.";
170
+ }
171
+ }
172
+
173
+ if (!Object.keys(e).length && !nextRunDate(f, now)) {
174
+ e.never = "This schedule would never run. Check the dates, end date and time.";
175
+ }
176
+ return e;
177
+ }
178
+
179
+ /** "at 08:00 every weekday from 21 Oct until 31 Dec" - the When, as a sentence fragment. */
180
+ export function describeWhen(
181
+ t: Partial<TRule> & { time?: string },
182
+ nowYear?: string,
183
+ ): string {
184
+ const time = t.time ? `at ${t.time} ` : "";
185
+ const repeat = t.repeat || "none";
186
+ const dates = t.dates || [];
187
+ const range =
188
+ (t.startDate ? ` from ${fmtDate(t.startDate, nowYear)}` : "") +
189
+ (t.endDate ? ` until ${fmtDate(t.endDate, nowYear)}` : "");
190
+ switch (repeat) {
191
+ case "daily":
192
+ return `${time}every day${range}`;
193
+ case "weekdays":
194
+ return `${time}every weekday${range}`;
195
+ case "weekly": {
196
+ const names = WEEK_DAYS.filter((d) => (t.daysOfWeek || []).includes(d.value)).map((d) => d.title);
197
+ return `${time}every ${names.join(", ") || "week"}${range}`;
198
+ }
199
+ case "monthly": {
200
+ const day = t.startDate ? Number(t.startDate.slice(8, 10)) : null;
201
+ const on = day === null ? "every month" : day === 31 ? "on the last day of every month" : `on day ${day} of every month`;
202
+ return `${time}${on}${range}`;
203
+ }
204
+ default: {
205
+ const sorted = [...dates].sort();
206
+ if (!sorted.length) return `${time}on no date`.trim();
207
+ const shown = sorted.slice(0, 3).map((d) => fmtDate(d, nowYear)).join(", ");
208
+ return `${time}on ${shown}${sorted.length > 3 ? ` +${sorted.length - 3} more` : ""}${
209
+ t.endDate ? ` (until ${fmtDate(t.endDate, nowYear)})` : ""
210
+ }`;
211
+ }
212
+ }
213
+ }
214
+
215
+ /** The sentence shown before saving. */
216
+ export function buildSummary(
217
+ f: TScheduleForm,
218
+ unitCount: number,
219
+ now: Date = new Date(),
220
+ ): string {
221
+ const year = sgtParts(now).date.slice(0, 4);
222
+ const areas = `${f.areaCount} area${f.areaCount === 1 ? "" : "s"}`;
223
+ const units = `${unitCount} unit${unitCount === 1 ? "" : "s"}`;
224
+ const next = nextRunDate(f, now);
225
+ const nextText = next ? `${DAYS[weekdayOf(next)]} ${fmtDate(next, year)}` : "never";
226
+ return `Adds 1 extra visit to ${areas} (${units}) ${describeWhen(f, year)} — next: ${nextText}.`;
227
+ }
228
+
229
+ export const STATUS_LABELS: Record<string, string> = { active: "Active", paused: "Paused", ended: "Ended" };
230
+
231
+ /** "Done 20 Oct" / "Missed 20 Oct" / "Not run yet". */
232
+ export function lastRunLabel(row: { lastRun?: string | null; lastRunStatus?: string | null }): string {
233
+ if (!row.lastRun) return "Not run yet";
234
+ const m = /^(\d{4}-\d{2}-\d{2})/.exec(row.lastRun);
235
+ const day = m ? fmtDate(m[1]) : "";
236
+ const word = { done: "Done", missed: "Missed", pending: "In progress" }[row.lastRunStatus || ""] || "Ran";
237
+ return `${word} ${day}`.trim();
238
+ }
239
+
240
+ /**
241
+ * One name and one page path per trade. The names are the web menu names (M&E
242
+ * is "Technician Schedule" - the mobile app mirrors it); the path is the folder
243
+ * the trade app serves its daily schedule from.
244
+ */
245
+ export const SCHEDULE_NAMES: Record<string, { title: string; route: string }> = {
246
+ cleaning_services: { title: "Cleaning Schedule", route: "cleaning-schedule" },
247
+ landscaping_services: { title: "Landscape Schedule", route: "landscape-schedule" },
248
+ pest_control_services: { title: "Pest Schedule", route: "pest-schedule" },
249
+ pool_maintenance_services: { title: "Pool Schedule", route: "pool-schedule" },
250
+ mechanical_electrical_services: { title: "Technician Schedule", route: "technician-schedule" },
251
+ };
252
+
253
+ export const scheduleNameFor = (serviceType?: string) =>
254
+ SCHEDULE_NAMES[serviceType || ""] || SCHEDULE_NAMES.cleaning_services;
255
+
256
+ /** Singapore "today" as YYYY-MM-DD, for "is today's list there yet". */
257
+ export const sgtToday = (now: Date = new Date()) => sgtParts(now).date;
258
+
259
+ /** True when any row in the list was created today (Singapore time). */
260
+ export function hasListForToday(rows: { createdAt?: string | Date }[], now: Date = new Date()): boolean {
261
+ const today = sgtToday(now);
262
+ return rows.some((r) => {
263
+ const t = r.createdAt ? new Date(r.createdAt) : null;
264
+ return !!t && !Number.isNaN(t.getTime()) && sgtParts(t).date === today;
265
+ });
266
+ }
267
+
268
+ /** One spelling for the in-flight state ("Ongoing" / "On-going" -> "In progress"); other words pass through. */
269
+ export function dailyStatusLabel(status: string | null | undefined): string {
270
+ const s = String(status ?? "").trim();
271
+ if (!s) return "No status";
272
+ return /^on-?going$/i.test(s) ? "In progress" : s;
273
+ }
274
+
275
+ /** A unit's result in the history view: Done, Needs redo (rejected by a supervisor) or Missed (never done). */
276
+ export function historyUnitLabel(status: string | null | undefined): "Done" | "Needs redo" | "Missed" {
277
+ const s = String(status ?? "").trim().toLowerCase();
278
+ if (s === "completed" || s === "done" || s === "approved") return "Done";
279
+ if (s === "rejected" || s === "needs redo") return "Needs redo";
280
+ return "Missed";
281
+ }
package/utils/status.ts CHANGED
@@ -73,6 +73,12 @@ const TONES: Record<string, TStatusTone> = {
73
73
 
74
74
  complete: "ok",
75
75
 
76
+ // Extra visits (schedule tasks): the last run's result and the task's state.
77
+ done: "ok",
78
+ missed: "err",
79
+ paused: "warn",
80
+ "needs redo": "warn",
81
+
76
82
  inactive: "neutral", // was `grey`
77
83
  "in active": "neutral", // the same word, printed "In-Active"
78
84
  declined: "neutral", // was Vuetify's `default`
@@ -1,85 +0,0 @@
1
- export default function useScheduleTaskArea() {
2
- async function getAreas({
3
- site = "",
4
- search = "",
5
- page = 1,
6
- limit = 10,
7
- } = {}) {
8
- return useNuxtApp().$api<Record<string, any>>(
9
- "/api/hygiene-schedule-task-area",
10
- {
11
- method: "GET",
12
- query: {
13
- site,
14
- search,
15
- page,
16
- limit,
17
- },
18
- }
19
- );
20
- }
21
-
22
- function createArea(name: string, site: string) {
23
- return useNuxtApp().$api<Record<string, any>>(
24
- "/api/hygiene-schedule-task-area",
25
- {
26
- method: "POST",
27
- body: {
28
- name,
29
- site,
30
- },
31
- }
32
- );
33
- }
34
-
35
- function updateArea(id: string, name: string) {
36
- return useNuxtApp().$api<Record<string, any>>(
37
- `/api/hygiene-schedule-task-area/id/${id}`,
38
- {
39
- method: "PATCH",
40
- body: { name },
41
- }
42
- );
43
- }
44
-
45
- function deleteArea(id: string) {
46
- return useNuxtApp().$api<Record<string, any>>(
47
- `/api/hygiene-schedule-task-area/id/${id}`,
48
- {
49
- method: "DELETE",
50
- }
51
- );
52
- }
53
-
54
- function getAreaById(id: string) {
55
- return useNuxtApp().$api<Record<string, any>>(
56
- `/api/hygiene-schedule-task-area/id/${id}`,
57
- {
58
- method: "GET",
59
- }
60
- );
61
- }
62
-
63
- function uploadAreas(file: File, site: string) {
64
- const formData = new FormData();
65
- formData.append("file", file);
66
- formData.append("site", site);
67
-
68
- return useNuxtApp().$api<Record<string, any>>(
69
- "/api/hygiene-schedule-task-area/upload",
70
- {
71
- method: "POST",
72
- body: formData,
73
- }
74
- );
75
- }
76
-
77
- return {
78
- getAreas,
79
- createArea,
80
- updateArea,
81
- deleteArea,
82
- getAreaById,
83
- uploadAreas,
84
- };
85
- }