@7365admin1/layer-common 4.136.0 → 4.138.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +16 -4
  2. package/assets/css/primitives.css +54 -11
  3. package/assets/css/screens.css +3 -3
  4. package/assets/css/tokens.css +17 -12
  5. package/components/AnprCameraBadge.vue +1 -1
  6. package/components/AreaMain.vue +11 -5
  7. package/components/BillingMain.vue +12 -53
  8. package/components/BulletinBoardManagement.vue +9 -13
  9. package/components/CameraWall.vue +7 -7
  10. package/components/CameraWallTile.vue +8 -8
  11. package/components/Chat/ListCard.vue +1 -1
  12. package/components/CleaningScheduleMain.vue +56 -20
  13. package/components/ClientMain.vue +1 -1
  14. package/components/ClientSubscriptionForm.vue +1 -1
  15. package/components/DashboardMain.vue +13 -13
  16. package/components/HidAccessLogDashboard.vue +3 -3
  17. package/components/HidIntercomManagement.vue +14 -14
  18. package/components/HidQrCodeConfiguration.vue +3 -3
  19. package/components/HidReaderManagement.vue +1 -1
  20. package/components/InventoryPhotoThumbs.vue +1 -1
  21. package/components/InvitationMain.vue +8 -0
  22. package/components/Layout/Header.vue +2 -2
  23. package/components/Layout/NavigationDrawer.vue +1 -1
  24. package/components/MemberMain.vue +8 -0
  25. package/components/Nfc/NFCRouteScheduleTimes.vue +1 -1
  26. package/components/PlateNumberDisplay.vue +1 -1
  27. package/components/RolePermissionMain.vue +13 -0
  28. package/components/ScheduleAreaMain.vue +6 -9
  29. package/components/ScheduleGuide.vue +101 -0
  30. package/components/ScheduleTaskForm.vue +88 -7
  31. package/components/ScheduleTaskMain.vue +163 -56
  32. package/components/SiteSettings.vue +5 -5
  33. package/components/StatusChip.vue +1 -1
  34. package/components/VisitorsReportPreview.vue +2 -2
  35. package/composables/useKey.ts +20 -16
  36. package/package.json +1 -1
  37. package/pages/unauthorized.vue +4 -3
  38. package/utils/breadcrumb.ts +7 -0
  39. package/utils/nav-sections.ts +2 -0
  40. package/utils/schedule-task.ts +106 -23
@@ -541,11 +541,6 @@ const saveANPRSettings = async () => {
541
541
  try {
542
542
  isSaving.value = true;
543
543
 
544
- // Replace this with your actual API endpoint / store action
545
- // await $fetch(`/api/sites/${siteId.value}/camera-settings`, {
546
- // method: 'POST',
547
- // body: cameraSettings.value
548
- // })
549
544
  const data = {
550
545
  site: props.siteId,
551
546
  ANPRSwitches: ANPRSwitches.value,
@@ -556,6 +551,11 @@ const saveANPRSettings = async () => {
556
551
  show("Saved successfully", "success");
557
552
  } catch (error) {
558
553
  console.error("Failed to save settings:", error);
554
+ show(
555
+ (error as any)?.data?.message ||
556
+ "Could not save the camera (ANPR) settings. Please try again.",
557
+ "error"
558
+ );
559
559
  } finally {
560
560
  isSaving.value = false;
561
561
  }
@@ -104,7 +104,7 @@ const toneClass = computed(() =>
104
104
  font-size: var(--fs-chip);
105
105
  }
106
106
 
107
- /* The role tag: `padding:3px 10px;border-radius:999px;font-size:11.5px`. */
107
+ /* The role tag: `padding:3px 10px;border-radius:999px;font-size:12.5px`. */
108
108
  .status-chip--pill {
109
109
  padding: 3px 10px;
110
110
  font-size: var(--fs-chip);
@@ -397,12 +397,12 @@ onMounted(() => {
397
397
 
398
398
  :deep(.v-data-table thead tr th) {
399
399
  padding: 6px !important;
400
- font-size: 11px !important;
400
+ font-size: 12px !important;
401
401
  }
402
402
 
403
403
  :deep(.v-data-table tbody tr td) {
404
404
  padding: 4px !important;
405
- font-size: 10px !important;
405
+ font-size: 12px !important;
406
406
  }
407
407
 
408
408
  :deep(.v-img) {
@@ -132,21 +132,25 @@ export default function useKey() {
132
132
  keyLog.value = data || new MKeyLog();
133
133
  }
134
134
 
135
- function addKeyLog(_keyLog: TKeyLog) {
135
+ // Await each request so a failed save reaches the caller's catch instead of showing "success".
136
+ async function addKeyLog(_keyLog: TKeyLog) {
136
137
  isAdding.value = true;
137
138
  delete _keyLog._id;
138
- const data = useNuxtApp().$api<{
139
- message: string | undefined;
140
- }>(keyLogApiUrl, {
141
- method: "POST",
142
- body: JSON.stringify(_keyLog),
143
- });
144
- isAdding.value = false;
145
- return data.message;
139
+ try {
140
+ const data = await useNuxtApp().$api<{
141
+ message: string | undefined;
142
+ }>(keyLogApiUrl, {
143
+ method: "POST",
144
+ body: JSON.stringify(_keyLog),
145
+ });
146
+ return data?.message;
147
+ } finally {
148
+ isAdding.value = false;
149
+ }
146
150
  }
147
151
 
148
- function getKeyById(id: string) {
149
- const data = useNuxtApp().$api<TKeyLog | undefined>(
152
+ async function getKeyById(id: string) {
153
+ const data = await useNuxtApp().$api<TKeyLog | undefined>(
150
154
  `${keyLogApiUrl}/${id}`,
151
155
  {
152
156
  method: "GET",
@@ -158,22 +162,22 @@ export default function useKey() {
158
162
  return data;
159
163
  }
160
164
 
161
- function updateKeyById(id: string, payload: TKeyLog) {
165
+ async function updateKeyById(id: string, payload: TKeyLog) {
162
166
  payload.codes =
163
167
  payload.codes instanceof Array && payload.codes.length > 0
164
168
  ? payload.codes
165
169
  : [];
166
- const data = useNuxtApp().$api<{
170
+ const data = await useNuxtApp().$api<{
167
171
  message: { acknowledged: boolean };
168
172
  }>(`${keyLogApiUrl}/${id}`, {
169
173
  method: "PUT",
170
174
  body: payload,
171
175
  });
172
- return data.message;
176
+ return data?.message;
173
177
  }
174
178
 
175
- function deleteKeyById(id: string) {
176
- const data = useNuxtApp().$api<{ message: TKeyLog }>(
179
+ async function deleteKeyById(id: string) {
180
+ const data = await useNuxtApp().$api<{ message: TKeyLog }>(
177
181
  `${keyLogApiUrl}/${id}`,
178
182
  {
179
183
  method: "DELETE",
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.136.0",
5
+ "version": "4.138.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.",
@@ -1,5 +1,6 @@
1
1
  <!--
2
- The "you are not a member of an organization" gate.
2
+ The "no permission for this page" gate (wording only; non-members use
3
+ require-organization-membership).
3
4
 
4
5
  Was Vuetify's chrome: `text-subtitle-1` for the sentence, `text-subtitle-2`
5
6
  on the buttons, and `variant="tonal" rounded="xl"` - a 24px-cornered pill in
@@ -12,8 +13,8 @@
12
13
  <template>
13
14
  <v-row no-gutters class="fill-height" align="center" justify="center">
14
15
  <v-col cols="12" lg="6" md="6" sm="6" class="text-center gate-page__message">
15
- You must be a member of an organization to view this page. Please join or
16
- contact your administrator for access.
16
+ You don't have permission to open this page. If you need it for your
17
+ work, ask your administrator to give your role access.
17
18
  <v-row no-gutters justify="center" class="mt-2">
18
19
  <v-btn
19
20
  class="screen-btn-primary"
@@ -25,6 +25,13 @@ const ACRONYMS: Record<string, string> = {
25
25
  const RENAMED: [string, string][] = [
26
26
  ["Building Mgmt", "Occupancy Mgmt"],
27
27
  ["Event Mgmt", "Task Calendar"],
28
+ // Schedule screens (owner 21 Oct): the same names the menu and the mobile apps use.
29
+ ["Schedule Task", "Extra Visits"],
30
+ ...["Cleaning", "Technician", "Pest", "Pool", "Landscape"].flatMap((t): [string, string][] => [
31
+ [`${t} Schedule Area`, "Area"],
32
+ [`${t} Schedule Unit`, "Unit"],
33
+ [`${t} Schedule`, "Daily Checklist"],
34
+ ]),
28
35
  ];
29
36
 
30
37
  export function moduleNameFromRoute(route: {
@@ -50,6 +50,8 @@ export const NAV_SECTIONS: Record<string, string> = {
50
50
  Area: "SERVICE DELIVERY",
51
51
  Unit: "SERVICE DELIVERY",
52
52
  "Schedule Task": "SERVICE DELIVERY",
53
+ "Daily Checklist": "SERVICE DELIVERY",
54
+ "Extra Visits": "SERVICE DELIVERY",
53
55
 
54
56
  // EQUIPMENT
55
57
  "Equipment Management": "EQUIPMENT",
@@ -176,12 +176,20 @@ export function validateSchedule(f: TScheduleForm, now: Date = new Date()): TSch
176
176
  return e;
177
177
  }
178
178
 
179
- /** "at 08:00 every weekday from 21 Oct until 31 Dec" - the When, as a sentence fragment. */
179
+ /** "08:00" -> "8:00 am", "14:30" -> "2:30 pm". Anything else is returned as given. */
180
+ export function fmtTime(time: string | null | undefined): string {
181
+ const m = TIME_RE.exec(time || "");
182
+ if (!m) return time || "";
183
+ const h = Number(m[1]);
184
+ return `${h % 12 || 12}:${m[2]} ${h < 12 ? "am" : "pm"}`;
185
+ }
186
+
187
+ /** "every weekday at 8:00 am from 21 Oct until 31 Dec" - the When, as a sentence fragment. */
180
188
  export function describeWhen(
181
189
  t: Partial<TRule> & { time?: string },
182
190
  nowYear?: string,
183
191
  ): string {
184
- const time = t.time ? `at ${t.time} ` : "";
192
+ const at = t.time ? ` at ${fmtTime(t.time)}` : "";
185
193
  const repeat = t.repeat || "none";
186
194
  const dates = t.dates || [];
187
195
  const range =
@@ -189,23 +197,23 @@ export function describeWhen(
189
197
  (t.endDate ? ` until ${fmtDate(t.endDate, nowYear)}` : "");
190
198
  switch (repeat) {
191
199
  case "daily":
192
- return `${time}every day${range}`;
200
+ return `every day${at}${range}`;
193
201
  case "weekdays":
194
- return `${time}every weekday${range}`;
202
+ return `every weekday${at}${range}`;
195
203
  case "weekly": {
196
204
  const names = WEEK_DAYS.filter((d) => (t.daysOfWeek || []).includes(d.value)).map((d) => d.title);
197
- return `${time}every ${names.join(", ") || "week"}${range}`;
205
+ return `every ${names.join(", ") || "week"}${at}${range}`;
198
206
  }
199
207
  case "monthly": {
200
208
  const day = t.startDate ? Number(t.startDate.slice(8, 10)) : null;
201
209
  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}`;
210
+ return `${on}${at}${range}`;
203
211
  }
204
212
  default: {
205
213
  const sorted = [...dates].sort();
206
- if (!sorted.length) return `${time}on no date`.trim();
214
+ if (!sorted.length) return `on no date${at}`;
207
215
  const shown = sorted.slice(0, 3).map((d) => fmtDate(d, nowYear)).join(", ");
208
- return `${time}on ${shown}${sorted.length > 3 ? ` +${sorted.length - 3} more` : ""}${
216
+ return `on ${shown}${sorted.length > 3 ? ` +${sorted.length - 3} more` : ""}${at}${
209
217
  t.endDate ? ` (until ${fmtDate(t.endDate, nowYear)})` : ""
210
218
  }`;
211
219
  }
@@ -226,33 +234,93 @@ export function buildSummary(
226
234
  return `Adds 1 extra visit to ${areas} (${units}) ${describeWhen(f, year)} — next: ${nextText}.`;
227
235
  }
228
236
 
229
- export const STATUS_LABELS: Record<string, string> = { active: "Active", paused: "Paused", ended: "Ended" };
237
+ /** The state of an extra visit as a whole. "ended" reads "Finished": nothing is left to run. */
238
+ export const STATUS_LABELS: Record<string, string> = { active: "Active", paused: "Paused", ended: "Finished" };
239
+
240
+ /** What each word means, shown on the page so nobody has to guess. Same words as the mobile apps. */
241
+ export const STATUS_HELP: { word: string; meaning: string }[] = [
242
+ { word: "Active", meaning: "It will add a visit at its next date and time." },
243
+ { word: "Paused", meaning: "Stopped for now. Nothing is added until you press Resume." },
244
+ { word: "Finished", meaning: "Its last date has passed, so nothing more is added. Press \"Extend or repeat\" to keep it going." },
245
+ ];
246
+ export const RESULT_HELP: { word: string; meaning: string }[] = [
247
+ { word: "Not started", meaning: "Nothing is ticked yet." },
248
+ { 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." },
250
+ { word: "Needs redo", meaning: "A supervisor sent a unit back to be done again." },
251
+ { word: "Missed", meaning: "The day ended with units not done." },
252
+ ];
230
253
 
231
- /** "Done 20 Oct" / "Missed 20 Oct" / "Not run yet". */
254
+ /** "Done 20 Oct" / "Missed 20 Oct" / "No visit yet". */
232
255
  export function lastRunLabel(row: { lastRun?: string | null; lastRunStatus?: string | null }): string {
233
- if (!row.lastRun) return "Not run yet";
256
+ if (!row.lastRun) return "No visit yet";
234
257
  const m = /^(\d{4}-\d{2}-\d{2})/.exec(row.lastRun);
235
258
  const day = m ? fmtDate(m[1]) : "";
236
- const word = { done: "Done", missed: "Missed", pending: "In progress" }[row.lastRunStatus || ""] || "Ran";
259
+ const word = { done: "Done", missed: "Missed", pending: "In progress" }[row.lastRunStatus || ""] || "Added";
237
260
  return `${word} ${day}`.trim();
238
261
  }
239
262
 
263
+ /** Why an extra visit says Finished, in one sentence. */
264
+ export function finishedReason(row: Partial<TRule>, nowYear?: string): string {
265
+ if (row.repeat && row.repeat !== "none" && row.endDate) {
266
+ return `Its end date (${fmtDate(row.endDate, nowYear)}) has passed.`;
267
+ }
268
+ return "All the dates it was set for have passed.";
269
+ }
270
+
240
271
  /**
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.
272
+ * One sentence per list row: "Every Mon, Wed at 2:00 pm in Lobby D — next on Wed 22 Oct".
273
+ * Paused and finished rows say so instead of a next date.
244
274
  */
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" },
275
+ export function describeRow(
276
+ row: Partial<TRule> & {
277
+ areas?: { name?: string }[];
278
+ nextRun?: string | null;
279
+ scheduleStatus?: string | null;
280
+ paused?: boolean;
281
+ },
282
+ nowYear?: string,
283
+ ): string {
284
+ const when = describeWhen(row, nowYear);
285
+ const names = (row.areas || []).map((a) => a?.name).filter(Boolean) as string[];
286
+ const where = names.length
287
+ ? ` in ${names.slice(0, 3).join(", ")}${names.length > 3 ? ` +${names.length - 3} more` : ""}`
288
+ : "";
289
+ const state = row.scheduleStatus || (row.paused ? "paused" : "active");
290
+ const m = /^(\d{4}-\d{2}-\d{2})/.exec(row.nextRun || "");
291
+ const tail =
292
+ state === "paused"
293
+ ? " — paused, nothing is added until you resume it"
294
+ : state === "ended"
295
+ ? " — finished"
296
+ : m
297
+ ? ` — next on ${DAYS[weekdayOf(m[1])]} ${fmtDate(m[1], nowYear)}`
298
+ : "";
299
+ return `${when.charAt(0).toUpperCase()}${when.slice(1)}${where}${tail}`;
300
+ }
301
+
302
+ /**
303
+ * One trade word and one page path per trade. Every trade's menu says "Daily
304
+ * Checklist" (owner 21 Oct; the mobile apps use the same name); the page title
305
+ * adds the trade ("Daily Checklist – Cleaning"). The path is the folder the
306
+ * trade app serves its daily checklist from - unchanged, so links and
307
+ * permissions keep working.
308
+ */
309
+ export const SCHEDULE_NAMES: Record<string, { title: string; trade: string; route: string }> = {
310
+ cleaning_services: { title: "Daily Checklist", trade: "Cleaning", route: "cleaning-schedule" },
311
+ landscaping_services: { title: "Daily Checklist", trade: "Landscape", route: "landscape-schedule" },
312
+ pest_control_services: { title: "Daily Checklist", trade: "Pest", route: "pest-schedule" },
313
+ pool_maintenance_services: { title: "Daily Checklist", trade: "Pool", route: "pool-schedule" },
314
+ mechanical_electrical_services: { title: "Daily Checklist", trade: "Technician", route: "technician-schedule" },
251
315
  };
252
316
 
253
317
  export const scheduleNameFor = (serviceType?: string) =>
254
318
  SCHEDULE_NAMES[serviceType || ""] || SCHEDULE_NAMES.cleaning_services;
255
319
 
320
+ /** "Daily Checklist – Cleaning" / "– Technician" / ... */
321
+ export const dailyChecklistTitle = (serviceType?: string) =>
322
+ `Daily Checklist – ${scheduleNameFor(serviceType).trade}`;
323
+
256
324
  /** Singapore "today" as YYYY-MM-DD, for "is today's list there yet". */
257
325
  export const sgtToday = (now: Date = new Date()) => sgtParts(now).date;
258
326
 
@@ -265,13 +333,28 @@ export function hasListForToday(rows: { createdAt?: string | Date }[], now: Date
265
333
  });
266
334
  }
267
335
 
268
- /** One spelling for the in-flight state ("Ongoing" / "On-going" -> "In progress"); other words pass through. */
336
+ /**
337
+ * The daily checklist's words, the same on web and mobile: Open -> Not started,
338
+ * Ongoing / On-going -> In progress, Completed -> Done. Closed stays Closed (the
339
+ * day ended; anything not ticked counts as Missed). Other words pass through.
340
+ */
269
341
  export function dailyStatusLabel(status: string | null | undefined): string {
270
342
  const s = String(status ?? "").trim();
271
343
  if (!s) return "No status";
272
- return /^on-?going$/i.test(s) ? "In progress" : s;
344
+ if (/^on-?going$/i.test(s)) return "In progress";
345
+ if (/^open$/i.test(s)) return "Not started";
346
+ if (/^completed?$/i.test(s)) return "Done";
347
+ return s;
273
348
  }
274
349
 
350
+ /** What the daily checklist's words mean, shown on the page. */
351
+ export const DAILY_STATUS_HELP: { word: string; meaning: string }[] = [
352
+ { word: "Not started", meaning: "The day's list is open and nothing is ticked yet." },
353
+ { word: "In progress", meaning: "Staff have ticked some units; the rest can still be done." },
354
+ { word: "Done", meaning: "Every unit on the list was ticked." },
355
+ { word: "Closed", meaning: "The day ended at midnight. The list is view-only, and anything not ticked counts as Missed." },
356
+ ];
357
+
275
358
  /** A unit's result in the history view: Done, Needs redo (rejected by a supervisor) or Missed (never done). */
276
359
  export function historyUnitLabel(status: string | null | undefined): "Done" | "Needs redo" | "Missed" {
277
360
  const s = String(status ?? "").trim().toLowerCase();