@7365admin1/layer-common 4.143.0 → 4.146.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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.146.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cc99934: Extra Visits renamed Extra Checks across the screen, form, breadcrumb, role editor and Daily Checklist chips (chip now shows the check's name and time). Last-check wording for old rows; search resets to page 1.
8
+
9
+ ## 4.145.0
10
+
11
+ ### Minor Changes
12
+
13
+ - e7358d9: Roles table "App" column shows plain app names (`roleAppLabel`) instead of stored codes like security_agency; Subscription plans: Deactivate asks for confirmation.
14
+
15
+ ## 4.144.0
16
+
17
+ ### Minor Changes
18
+
19
+ - b64093c: NFC patrol reports: the Monthly Report has a Year picker (this year and the five before it), so past years can be reported; each month's row still opens that month's patrols, and the export heading and file name use the picked year. Route form: the Route timing Off hint now says the route still runs only on its selected Days (use Select all for every day).
20
+ - a5acba6: Visitor list shows NFC card numbers (not database ids) for a visitor's extra cards; AreaMain / ScheduleAreaMain take `showAreaType` (default true) to hide the cleaning-only Common/Toilet type; AreaFormDialog `hideTypeField`; M&E unit import example; Blocks screen action labels say Block.
21
+
3
22
  ## 4.143.0
4
23
 
5
24
  ### Minor Changes
@@ -3413,11 +3432,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3413
3432
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3414
3433
  own rule from two endpoints the console already calls, unprojected:
3415
3434
 
3416
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3417
- GET /api/roles/id/:role that membership's role document
3435
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3436
+ GET /api/roles/id/:role that membership's role document
3418
3437
 
3419
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3420
- staff = member.type === "admin" && role.type === "admin"
3438
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3439
+ staff = member.type === "admin" && role.type === "admin"
3421
3440
 
3422
3441
  `role.default` is the marker because it is the only property of a platform
3423
3442
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -125,7 +125,7 @@
125
125
  pill
126
126
  class="ml-2"
127
127
  >
128
- Extra visit
128
+ {{ extraCheckLabel(checklistSet) }}
129
129
  </StatusChip>
130
130
  </v-list-subheader>
131
131
 
@@ -262,7 +262,7 @@
262
262
  </template>
263
263
 
264
264
  <script setup lang="ts">
265
- import { dailyStatusLabel, historyUnitLabel } from "../utils/schedule-task";
265
+ import { dailyStatusLabel, extraCheckLabel, historyUnitLabel } from "../utils/schedule-task";
266
266
  const props = defineProps({
267
267
  orgId: { type: String, required: true, default: "" },
268
268
  site: { type: String, required: true, default: "" },
@@ -37,7 +37,7 @@
37
37
  <v-card-text class="screen-modal__body">
38
38
  <v-form ref="formRef" v-model="valid">
39
39
  <v-row no-gutters class="pa-0">
40
- <v-col v-if="prop.showType" cols="12" class="pa-0 mb-2">
40
+ <v-col v-if="prop.showType && !prop.hideTypeField" cols="12" class="pa-0 mb-2">
41
41
  <InputLabel for="type" title="Type" required class="mb-1" />
42
42
  <v-select
43
43
  v-model="typeModel"
@@ -169,6 +169,9 @@ const prop = defineProps({
169
169
  label: { type: String, default: "Name" },
170
170
  entityType: { type: String, default: "Area" }, // values: "Area", "Unit"
171
171
  showType: { type: Boolean, default: true },
172
+ // Keeps the type in the saved payload (as loaded, or "Common") but does not
173
+ // offer the Common / Toilet choice - for apps where it means nothing.
174
+ hideTypeField: { type: Boolean, default: false },
172
175
  showOptional: { type: Boolean, default: true },
173
176
  showUnits: { type: Boolean, default: false },
174
177
  site: { type: String, default: "" },
@@ -17,7 +17,7 @@
17
17
  @create="onCreateItem"
18
18
  @row-click="handleRowClick"
19
19
  >
20
- <template #extension>
20
+ <template v-if="props.showAreaType" #extension>
21
21
  <v-row no-gutters class="w-100 d-flex flex-column">
22
22
  <!--
23
23
  A bare `v-card` here paints Vuetify's own `surface`, a slab a
@@ -138,6 +138,7 @@
138
138
  :label="formLabel"
139
139
  :entityType="entityTypeLabel"
140
140
  :showUnits="true"
141
+ :hideTypeField="!props.showAreaType"
141
142
  :site="props.site"
142
143
  :serviceType="props.serviceType"
143
144
  @saved="_createArea"
@@ -314,7 +315,9 @@
314
315
  title="Import Areas from Excel"
315
316
  noun="areas"
316
317
  name-label="Area"
317
- fill-hint="One area per row: its name, Type (Common or Toilet, from the dropdown), Set and the units to check, separated by commas."
318
+ :fill-hint="props.showAreaType
319
+ ? 'One area per row: its name, Type (Common or Toilet, from the dropdown), Set and the units to check, separated by commas.'
320
+ : 'One area per row: its name, Set and the units to check, separated by commas. Leave Type as Common.'"
318
321
  duplicate-note="Areas already on the list (same name and type) are skipped and left unchanged."
319
322
  template-file-name="areas-import-template.xlsx"
320
323
  :download-template="() => downloadTemplate('areas', importQuery)"
@@ -345,6 +348,10 @@ const props = defineProps({
345
348
  canUpdateArea: { type: Boolean, default: false },
346
349
  canDeleteArea: { type: Boolean, default: false },
347
350
  canImportArea: { type: Boolean, default: false },
351
+ // Common / Toilet is a cleaning distinction (Toilet areas get the toilet
352
+ // checklist QR). Apps without it hide the Type filter, column and field;
353
+ // the saved `type` value is untouched (new areas stay "common").
354
+ showAreaType: { type: Boolean, default: true },
348
355
  });
349
356
 
350
357
  const isCleanerArea = computed(() => {
@@ -403,7 +410,7 @@ const isDownloading = ref(false);
403
410
 
404
411
  const headers = [
405
412
  { title: "AREA", value: "name" },
406
- { title: "TYPE", value: "type" },
413
+ ...(props.showAreaType ? [{ title: "TYPE", value: "type" }] : []),
407
414
  { title: "SETS", value: "set" },
408
415
  ];
409
416
 
@@ -120,7 +120,7 @@
120
120
 
121
121
 
122
122
  <v-col cols="12" class="px-6">
123
- <InputLabel class="text-capitalize font-weight-bold" title="Building Floor plan" />
123
+ <InputLabel class="text-capitalize font-weight-bold" title="Block floor plan" />
124
124
  <InputFileV2 v-model="building.buildingFiles" :multiple="true" accept="*" :max-length="10"
125
125
  title="Upload Files" />
126
126
  </v-col>
@@ -97,13 +97,13 @@
97
97
  <v-list class="pa-0">
98
98
  <v-list-item v-if="canUpdateBuilding" @click="openEditDialog()">
99
99
  <v-list-item-title class="text-subtitle-2">
100
- Edit Building
100
+ Edit Block
101
101
  </v-list-item-title>
102
102
  </v-list-item>
103
103
 
104
104
  <v-list-item v-if="canDeleteBuilding" @click="openDeleteDialog()" class="text-red">
105
105
  <v-list-item-title class="text-subtitle-2">
106
- Delete Building
106
+ Delete Block
107
107
  </v-list-item-title>
108
108
  </v-list-item>
109
109
  </v-list>
@@ -144,7 +144,7 @@
144
144
  <v-col cols="6">
145
145
  <v-btn tile block size="48" color="black" variant="flat" class="text-none" @click="handleDeleteBuilding"
146
146
  :loading="deleteLoading">
147
- Delete Building
147
+ Delete Block
148
148
  </v-btn>
149
149
  </v-col>
150
150
  </v-row>
@@ -15,7 +15,7 @@
15
15
  <li>
16
16
  Every midnight a new row appears here for the day. It holds every
17
17
  area, with as many visits as the area's "visits per day", plus
18
- any <strong>Extra Visits</strong> set for that day.
18
+ any <strong>Extra Checks</strong> set for that day.
19
19
  </li>
20
20
  <li>
21
21
  Staff open the same list in their mobile app and tick each unit
@@ -208,7 +208,7 @@ const props = defineProps({
208
208
  // "Daily Checklist – Cleaning" / "– Landscape" / "– Pest" / "– Pool" / "– Technician"
209
209
  const pageTitle = dailyChecklistTitle(props.serviceType);
210
210
 
211
- // `?date=YYYY-MM-DD` (from an Extra Visit's "See that day's checklist") opens on that one day.
211
+ // `?date=YYYY-MM-DD` (from an Extra Check's "See that day's checklist") opens on that one day.
212
212
  const linkedDay = String(useRoute().query.date ?? "");
213
213
  const startDate = ref(/^\d{4}-\d{2}-\d{2}$/.test(linkedDay) ? linkedDay : "");
214
214
  const endDate = ref(startDate.value);
@@ -100,6 +100,7 @@ import {
100
100
  reportFileName,
101
101
  type CsvSection,
102
102
  } from "../../utils/report-export";
103
+ import { reportYear } from "../../utils/nfc-patrol-report";
103
104
 
104
105
  import PatrolReportTab from "./PatrolReport/PatrolReportTab.vue";
105
106
  import DailyReportTab from "./PatrolReport/DailyReportTab.vue";
@@ -137,6 +138,7 @@ function updateFilters(value: NFCPatrolReportFilters) {
137
138
  filters.route = value.route;
138
139
  filters.timeRange = value.timeRange;
139
140
  filters.date = value.date;
141
+ filters.year = value.year;
140
142
  }
141
143
 
142
144
  const { exportReportPdf } = useReportPdf();
@@ -147,10 +149,10 @@ function reportMeta(report: HTMLElement, extension: "pdf" | "csv"): ReportPdfMet
147
149
  const route = options.value.routes.find(({ value }) => value === filters.route)?.title;
148
150
  const text = (selector: string) => report.querySelector(selector)?.textContent?.trim() || undefined;
149
151
 
150
- // The yearly table always covers the current year (useNFCPatrolMonthlyReport).
152
+ // The yearly table covers the year picked on the Monthly Report.
151
153
  const period =
152
154
  activeTab.value === "monthly"
153
- ? { label: "Year", value: String(new Date().getFullYear()) }
155
+ ? { label: "Year", value: reportYear(filters.year) }
154
156
  : { label: "Date", value: formatReportDate(filters.date) };
155
157
  const details = [{ label: "Route", value: route ?? "-" }, period];
156
158
  if (activeTab.value === "patrol" && filters.timeRange) {
@@ -111,7 +111,7 @@
111
111
  {{
112
112
  form.timed
113
113
  ? "Patrols start at the route's start times, within the tolerance."
114
- : "No time window: this route can be patrolled at any time."
114
+ : "No time window: patrol at any time of day, on the selected Days only. Use Select all for every day."
115
115
  }}
116
116
  </div>
117
117
  </v-col>
@@ -5,6 +5,7 @@
5
5
  :options="options"
6
6
  :show-time-filter="false"
7
7
  :show-date-filter="false"
8
+ show-year-filter
8
9
  @update:model-value="$emit('update:filters', $event)"
9
10
  @export="(type) => $emit('export', type, csvSections())"
10
11
  />
@@ -53,7 +54,7 @@ import type {
53
54
  } from "../../../types/nfc-patrol-report";
54
55
 
55
56
  import { useNFCPatrolMonthlyReport } from "../../../composables/useNFCPatrolMonthlyReport";
56
- import { monthlyReportCsv } from "../../../utils/nfc-patrol-report";
57
+ import { monthlyReportCsv, reportYear } from "../../../utils/nfc-patrol-report";
57
58
  import PatrolMonthDialog from "./PatrolMonthDialog.vue";
58
59
  import type { CsvSection } from "../../../utils/report-export";
59
60
 
@@ -84,8 +85,7 @@ const monthTitle = ref("");
84
85
  const monthRows = shallowRef<NFCPatrolReportSummaryRow[]>([]);
85
86
 
86
87
  function openMonth(monthIndex: number) {
87
- // The yearly report always covers the current year (useNFCPatrolMonthlyReport).
88
- monthTitle.value = `${report.value?.rows[monthIndex]?.month ?? ""} ${new Date().getFullYear()}`;
88
+ monthTitle.value = `${report.value?.rows[monthIndex]?.month ?? ""} ${reportYear(props.filters.year)}`;
89
89
  monthRows.value = monthRuns(monthIndex);
90
90
  monthOpen.value = true;
91
91
  }
@@ -95,7 +95,7 @@ function csvSections() {
95
95
  }
96
96
 
97
97
  watch(
98
- () => [props.filters.route, props.active],
98
+ () => [props.filters.route, props.filters.year, props.active],
99
99
  () => {
100
100
  if (props.active) {
101
101
  void fetchReport();
@@ -63,6 +63,25 @@
63
63
  />
64
64
  </v-col>
65
65
 
66
+ <!-- Monthly Report: which year. Each month's row then opens that month. -->
67
+ <v-col
68
+ v-if="showYearFilter"
69
+ cols="12"
70
+ md="2"
71
+ class="px-md-2 mb-2 mb-md-0"
72
+ >
73
+ <v-select
74
+ :model-value="reportYear(modelValue.year)"
75
+ :items="reportYears()"
76
+ label="Year"
77
+ class="filter-field"
78
+ variant="outlined"
79
+ density="comfortable"
80
+ hide-details
81
+ @update:model-value="updateFilter('year', $event)"
82
+ />
83
+ </v-col>
84
+
66
85
  <v-spacer />
67
86
 
68
87
  <v-col cols="12" md="2" class="pl-md-2">
@@ -99,6 +118,7 @@ import type {
99
118
  NFCPatrolReportFilters,
100
119
  } from "../../../types/nfc-patrol-report";
101
120
  import ReportDatePicker from "./ReportDatePicker.vue";
121
+ import { reportYear, reportYears } from "../../../utils/nfc-patrol-report";
102
122
 
103
123
  const props = withDefaults(
104
124
  defineProps<{
@@ -106,12 +126,14 @@ const props = withDefaults(
106
126
  options: NFCPatrolReportFilterOptions;
107
127
  showTimeFilter?: boolean;
108
128
  showDateFilter?: boolean;
129
+ showYearFilter?: boolean;
109
130
  }>(),
110
131
  {
111
132
  showTimeFilter: true,
112
133
  showDateFilter: true,
134
+ showYearFilter: false,
113
135
  },
114
- );;
136
+ );
115
137
 
116
138
  const emit = defineEmits<{
117
139
  "update:modelValue": [value: NFCPatrolReportFilters];
@@ -68,6 +68,8 @@
68
68
  -->
69
69
  <!-- "Admin · St. Regis Residences": names are unique per site (2026-10-01) -->
70
70
  <template #item.name="{ item }">{{ roleLabel(item) }}</template>
71
+ <!-- "App" column: plain app name, not the stored code (security_agency) -->
72
+ <template #item.type="{ item }">{{ roleAppLabel(item.type) }}</template>
71
73
  <template #item.scope="{ item }">
72
74
  <StatusChip
73
75
  :dot="false"
@@ -214,7 +216,7 @@
214
216
 
215
217
  <script setup lang="ts">
216
218
  // Relative import: this layer's `utils/` is not auto-imported into consumers.
217
- import { roleLabel, safeReturnTo, showSiteField, sitePickerRoleLabel } from "../utils/role";
219
+ import { roleAppLabel, roleLabel, safeReturnTo, showSiteField, sitePickerRoleLabel } from "../utils/role";
218
220
  import { holdsPermission } from "../utils/permission-spellings";
219
221
  import { countRealPermissions } from "../utils/security-catalogue";
220
222
  import {
@@ -56,6 +56,7 @@
56
56
  />
57
57
 
58
58
  <v-select
59
+ v-if="props.showAreaType"
59
60
  v-model="areaTypeFilter"
60
61
  :items="typeOptions"
61
62
  density="compact"
@@ -155,6 +156,9 @@ const props = defineProps({
155
156
  canGenerateChecklist: { type: Boolean, default: false },
156
157
  canViewHistory: { type: Boolean, default: false },
157
158
  canManageScheduleTasks: { type: Boolean, default: false },
159
+ // Common / Toilet is a cleaning distinction; apps without it hide the Type
160
+ // filter and column (saved `type` values are untouched).
161
+ showAreaType: { type: Boolean, default: true },
158
162
  });
159
163
 
160
164
  // "Daily Checklist – Cleaning" / "– Landscape" / ... (same title as the day list)
@@ -172,7 +176,7 @@ const pageRange = ref("-- - -- of --");
172
176
  const headers = [
173
177
  { title: "Area", value: "name" },
174
178
  { title: "Status", value: "status" },
175
- { title: "Type", value: "type" },
179
+ ...(props.showAreaType ? [{ title: "Type", value: "type" }] : []),
176
180
  { title: "Visits per day", value: "set" },
177
181
  ];
178
182
 
@@ -1,8 +1,8 @@
1
1
  <!--
2
- ADD / EDIT AN EXTRA VISIT - four short steps: What, Where, When, Who is told.
2
+ ADD / EDIT AN EXTRA CHECK - four short steps: What, Where, When, Who is told.
3
3
 
4
4
  The last step shows one sentence saying exactly what will be created
5
- ("Adds 1 extra visit to 3 areas (24 units) at 08:00 every weekday ... next:
5
+ ("Adds 1 extra check to 3 areas (24 units) at 08:00 every weekday ... next:
6
6
  Wed 21 Oct"). The form checks what the server checks (utils/schedule-task.ts)
7
7
  so a schedule that would never run is caught before saving; whatever the
8
8
  server still refuses is shown in its own words.
@@ -11,7 +11,7 @@
11
11
  <v-dialog v-model="show" max-width="760" persistent scrollable>
12
12
  <v-card class="screen-modal">
13
13
  <v-card-title class="d-flex align-center">
14
- {{ props.mode === "edit" ? "Edit Extra Visit" : "Add Extra Visit" }}
14
+ {{ props.mode === "edit" ? "Edit Extra Check" : "Add Extra Check" }}
15
15
  <v-spacer />
16
16
  <v-btn
17
17
  icon="mdi-close"
@@ -49,7 +49,7 @@
49
49
  <v-stepper-window-item :value="1">
50
50
  <div class="st-step">
51
51
  <p class="st-step__help">
52
- Give the extra visit a name staff will recognise, and say
52
+ Give the extra check a name staff will recognise, and say
53
53
  what they should do.
54
54
  </p>
55
55
  <InputLabel for="title" title="Title" required />
@@ -61,7 +61,7 @@
61
61
  class="filter-field mb-4"
62
62
  placeholder="For example: Lunch toilet check"
63
63
  :error-messages="fieldError('title')"
64
- hint="Staff see this name on their phone, on the extra round."
64
+ hint="Staff see this name on their phone and on the Daily Checklist."
65
65
  persistent-hint
66
66
  hide-details="auto"
67
67
  />
@@ -73,7 +73,7 @@
73
73
  density="comfortable"
74
74
  class="filter-field"
75
75
  rows="3"
76
- placeholder="Optional. Anything staff should know for this visit."
76
+ placeholder="Optional. Anything staff should know for this check."
77
77
  hint="Optional. For example: refill soap and tissue as well."
78
78
  persistent-hint
79
79
  hide-details="auto"
@@ -85,9 +85,8 @@
85
85
  <v-stepper-window-item :value="2">
86
86
  <div class="st-step">
87
87
  <p class="st-step__help">
88
- Pick the areas that get the extra visit. Every unit in a
89
- picked area gets one more tick box on the Daily Checklist
90
- for this visit.
88
+ Pick the areas to check. Every unit in a picked area gets
89
+ one more tick box on the Daily Checklist for this check.
91
90
  </p>
92
91
  <InputLabel for="selected-areas" title="Areas" required />
93
92
  <v-select
@@ -170,7 +169,7 @@
170
169
  <div class="st-step">
171
170
  <p class="st-step__help">
172
171
  Choose the time, and whether it happens once or repeats.
173
- Times are Singapore time. The extra round appears on staff's
172
+ Times are Singapore time. The check appears on staff's
174
173
  checklist at this time.
175
174
  </p>
176
175
  <v-row dense>
@@ -305,8 +304,8 @@
305
304
  class="mb-4"
306
305
  title="Who is told"
307
306
  >
308
- Staff on this site get a push notification when the extra
309
- visit is added to their checklist.
307
+ Staff of this site who use the mobile app get a phone
308
+ notification when the check is added to their checklist.
310
309
  </v-alert>
311
310
 
312
311
  <div class="st-summary">
@@ -321,7 +320,7 @@
321
320
  <div class="st-preview">
322
321
  <div class="st-preview__head">
323
322
  <v-icon size="18" class="mr-1">mdi-clock-outline</v-icon>
324
- {{ previewTime }} · Extra visit
323
+ {{ previewTime }} · Extra check
325
324
  </div>
326
325
  <div class="st-preview__title">
327
326
  {{ subject.trim() || "(your title)" }}
@@ -391,7 +390,7 @@
391
390
  :loading="submitting"
392
391
  @click="submitTask"
393
392
  >
394
- {{ props.mode === "edit" ? "Save Changes" : "Add Extra Visit" }}
393
+ {{ props.mode === "edit" ? "Save Changes" : "Add Extra Check" }}
395
394
  </v-btn>
396
395
  </v-card-actions>
397
396
  </v-card>
@@ -667,7 +666,7 @@ const loadTaskData = async () => {
667
666
  } catch (error: any) {
668
667
  if ((props.taskData as any)?.title) setFormFromTask(props.taskData);
669
668
  showMessage(
670
- error?.data?.message || "Could not load this extra visit.",
669
+ error?.data?.message || "Could not load this extra check.",
671
670
  "error",
672
671
  );
673
672
  } finally {
@@ -719,7 +718,7 @@ const submitTask = async () => {
719
718
 
720
719
  showMessage(
721
720
  response?.message ||
722
- `Extra visit ${props.mode === "edit" ? "updated" : "added"}.`,
721
+ `Extra check ${props.mode === "edit" ? "saved" : "added"}.`,
723
722
  "success",
724
723
  );
725
724
 
@@ -729,7 +728,7 @@ const submitTask = async () => {
729
728
  } catch (error: any) {
730
729
  // The server's own plain-English message (permission, validation, ...).
731
730
  serverError.value =
732
- error?.data?.message || "Could not save this extra visit. Please try again.";
731
+ error?.data?.message || "Could not save this extra check. Please try again.";
733
732
  showMessage(serverError.value, "error");
734
733
  } finally {
735
734
  submitting.value = false;
@@ -5,8 +5,8 @@
5
5
  :org-id="orgId"
6
6
  :site="site"
7
7
  :service-type="serviceType"
8
- text="An extra visit adds one more round to the Daily Checklist at a time you choose - once, or on a repeat."
9
- how-title="How extra visits work"
8
+ :text="FEATURE_SUBTITLE"
9
+ how-title="How extra checks work"
10
10
  how-key="extra-visits"
11
11
  >
12
12
  <ol class="sg-steps">
@@ -16,14 +16,14 @@
16
16
  that.
17
17
  </li>
18
18
  <li>
19
- An <strong>extra visit</strong> adds one more round to that
20
- checklist, in the areas you pick, at the time you set - once, or
21
- repeating (every day, weekdays, chosen days, or monthly).
19
+ An <strong>extra check</strong> adds one more round of ticks to
20
+ that checklist, in the areas you pick, at the time you set - once,
21
+ or repeating (every day, weekdays, chosen days, or monthly).
22
22
  </li>
23
23
  <li>
24
- At that time, staff see the extra round in their mobile app's
25
- daily checklist (with a push notification) and tick each unit as
26
- they finish it.
24
+ At that time, staff get a phone notification and see the extra
25
+ check in their app's Daily Checklist, marked "Extra check" with its
26
+ name. They tick each unit as they finish it.
27
27
  </li>
28
28
  <li>
29
29
  Here and in the Daily Checklist you see how it went:
@@ -33,9 +33,9 @@
33
33
  </ol>
34
34
  <div class="sg-example">
35
35
  <strong>Example:</strong> the lobby toilets get busy at lunch. Add
36
- an extra visit called "Lunch toilet check", pick the area "Lobby
37
- Toilets", set 2:00 pm, every weekday. From the next weekday, staff
38
- get one more toilet round at 2:00 pm, on top of their normal ones.
36
+ an extra check called "Lunch toilet check", pick the area "Lobby
37
+ Toilets", set 2:00 pm, every weekday. Every weekday at 2:00 pm staff
38
+ get one more toilet check, on top of their normal rounds.
39
39
  </div>
40
40
  <div class="sg-h">What the words mean</div>
41
41
  <dl class="sg-legend">
@@ -45,12 +45,12 @@
45
45
  </template>
46
46
  <template v-for="h in RESULT_HELP" :key="'r' + h.word">
47
47
  <dt><StatusChip :status="h.word" :label="h.word" /></dt>
48
- <dd>Last visit: {{ h.meaning }}</dd>
48
+ <dd>Last check: {{ h.meaning }}</dd>
49
49
  </template>
50
50
  </dl>
51
51
  </ScheduleGuide>
52
52
  <TableMain
53
- :title="'Extra Visits'"
53
+ :title="FEATURE_NAME"
54
54
  :items="items"
55
55
  :headers="headers"
56
56
  :loading="loading"
@@ -60,21 +60,21 @@
60
60
  :pageRange="pageRange"
61
61
  @refresh="getTaskRefresh"
62
62
  :canCreate="canCreateScheduleTask"
63
- createLabel="Add Extra Visit"
63
+ createLabel="Add Extra Check"
64
64
  @create="openFormDialog"
65
65
  @row-click="onRowClick"
66
66
  >
67
67
  <template #no-data>
68
68
  <div class="st-empty">
69
- <strong>No extra visits yet.</strong>
69
+ <strong>No extra checks yet.</strong>
70
70
  <span>
71
71
  Your Daily Checklist already covers the normal rounds. Add an
72
- extra visit when an area needs one more round at a set time -
72
+ extra check when an area needs one more check at a set time -
73
73
  for example "Lunch toilet check, Lobby Toilets, 2:00 pm every
74
74
  weekday".
75
75
  </span>
76
76
  <span v-if="canCreateScheduleTask">
77
- Press <strong>Add Extra Visit</strong> above to start. Your
77
+ Press <strong>Add Extra Check</strong> above to start. Your
78
78
  Areas and Units must be set up first.
79
79
  </span>
80
80
  </div>
@@ -88,7 +88,7 @@
88
88
  variant="flat"
89
89
  @click="openFormDialog"
90
90
  >
91
- Add Extra Visit
91
+ Add Extra Check
92
92
  </v-btn>
93
93
  </v-col>
94
94
 
@@ -210,14 +210,14 @@
210
210
 
211
211
  <v-dialog v-model="dialogShowMoreActions" width="400" persistent>
212
212
  <HygieneUpdateMoreAction
213
- :title="selectedTask?.title || 'Extra Visit'"
213
+ :title="selectedTask?.title || 'Extra Check'"
214
214
  :canUpdate="canUpdateScheduleTask"
215
215
  @close="dialogShowMoreActions = false"
216
216
  :canDelete="canDeleteScheduleTask"
217
- :deleteButtonLabel="'Delete Extra Visit'"
217
+ :deleteButtonLabel="'Delete Extra Check'"
218
218
  @edit="onEditFromMoreAction"
219
219
  @delete="onDeleteFromMoreAction"
220
- :editButtonLabel="'Edit Extra Visit'"
220
+ :editButtonLabel="'Edit Extra Check'"
221
221
  >
222
222
  <template #content>
223
223
  <v-row no-gutters>
@@ -242,7 +242,7 @@
242
242
 
243
243
  <v-row no-gutters class="mb-2">
244
244
  <v-col cols="5" class="text-subtitle-2 font-weight-bold">
245
- Next visit:
245
+ Next check:
246
246
  </v-col>
247
247
  <v-col cols="7" class="text-subtitle-2">
248
248
  {{ selectedNext || "-" }}
@@ -251,7 +251,7 @@
251
251
 
252
252
  <v-row no-gutters class="mb-2">
253
253
  <v-col cols="5" class="text-subtitle-2 font-weight-bold">
254
- Last visit:
254
+ Last check:
255
255
  </v-col>
256
256
  <v-col cols="7" class="text-subtitle-2">
257
257
  {{ lastRunLabel(selectedTask) }}
@@ -375,7 +375,7 @@
375
375
  </div>
376
376
  <span v-else class="text-caption text-disabled"
377
377
  >No units in this area, so it is skipped when
378
- this visit is added.</span
378
+ this check is added.</span
379
379
  >
380
380
  </v-card-text>
381
381
  </div>
@@ -388,7 +388,7 @@
388
388
 
389
389
  <v-row v-if="selectedTask.description" no-gutters class="mb-2">
390
390
  <v-col cols="5" class="text-subtitle-2 font-weight-bold">
391
- Description:
391
+ Instructions:
392
392
  </v-col>
393
393
  <v-col cols="7" class="text-subtitle-2">
394
394
  {{ selectedTask.description || "N/A" }}
@@ -441,11 +441,11 @@
441
441
  <v-dialog v-model="dialogDeleteTask" max-width="520">
442
442
  <v-card class="screen-modal">
443
443
  <v-card-title class="text-capitalize">
444
- Delete {{ selectedTask?.title || "Extra Visit" }}
444
+ Delete {{ selectedTask?.title || "Extra Check" }}
445
445
  </v-card-title>
446
446
 
447
447
  <v-card-text class="screen-modal__body">
448
- This stops all future extra visits from it. Visits that already
448
+ This stops all future extra checks from it. Checks that already
449
449
  happened stay on their Daily Checklists. Delete it?
450
450
  </v-card-text>
451
451
 
@@ -487,6 +487,8 @@ import {
487
487
  RESULT_HELP,
488
488
  STATUS_HELP,
489
489
  STATUS_LABELS,
490
+ FEATURE_NAME,
491
+ FEATURE_SUBTITLE,
490
492
  describeRow,
491
493
  describeWhen,
492
494
  finishedReason,
@@ -526,7 +528,7 @@ const canViewScheduleTaskDetails = computed(
526
528
 
527
529
  const checklistRoute = scheduleNameFor(props.serviceType).route;
528
530
 
529
- /** Opens the Daily Checklist filtered to the day this extra visit was last added. */
531
+ /** Opens the Daily Checklist filtered to the day this extra check was last added. */
530
532
  function openChecklist(row: Record<string, any>) {
531
533
  const day = String(row?.lastRun || "").slice(0, 10);
532
534
  navigateTo({
@@ -593,10 +595,10 @@ watch(dialogShowMoreActions, async (isOpen) => {
593
595
  const items = ref<any[]>([]);
594
596
 
595
597
  const headers = [
596
- { title: "Extra visit", value: "title" },
597
- { title: "What will happen", value: "when", sortable: false },
598
- { title: "Next visit", value: "nextRun", sortable: false },
599
- { title: "Last visit", value: "lastRun", sortable: false },
598
+ { title: "Extra check", value: "title" },
599
+ { title: "When and where", value: "when", sortable: false },
600
+ { title: "Next check", value: "nextRun", sortable: false },
601
+ { title: "Last check", value: "lastRun", sortable: false },
600
602
  { title: "Status", value: "status", sortable: false },
601
603
  { title: "", value: "actions", sortable: false, align: "end" as const },
602
604
  ];
@@ -660,7 +662,9 @@ const debounceSearch = debounce(getTaskRefresh, 500);
660
662
  watch(
661
663
  [searchInput],
662
664
  () => {
663
- debounceSearch();
665
+ // A search on page 2 must not land on an empty page: start from page 1.
666
+ if (page.value !== 1) page.value = 1;
667
+ else debounceSearch();
664
668
  },
665
669
  { immediate: false, deep: true }
666
670
  );
@@ -679,7 +683,7 @@ function onEditRow(item: Record<string, any>) {
679
683
  showForm.value = true;
680
684
  }
681
685
 
682
- // A finished extra visit: open its "When" step so a new end date or repeat is one change away.
686
+ // A finished extra check: open its "When" step so a new end date or repeat is one change away.
683
687
  function onExtendRow(item: Record<string, any>) {
684
688
  selectedTask.value = item;
685
689
  formMode.value = "edit";
@@ -699,14 +703,14 @@ async function togglePause(item: Record<string, any>) {
699
703
  await setScheduleTaskPaused(item._id, !resuming);
700
704
  showMessage(
701
705
  resuming
702
- ? "Resumed. Extra visits are added again from its next date."
703
- : "Paused. No extra visits are added until you resume it.",
706
+ ? "Resumed. It is added again from its next date."
707
+ : "Paused. Nothing is added to staff's checklist until you resume it.",
704
708
  "success",
705
709
  );
706
710
  await getTaskRefresh();
707
711
  } catch (error: any) {
708
712
  showMessage(
709
- error?.data?.message || "Could not change this extra visit.",
713
+ error?.data?.message || "Could not change this extra check.",
710
714
  "error",
711
715
  );
712
716
  } finally {
@@ -756,12 +760,12 @@ async function _deleteScheduleTask() {
756
760
 
757
761
  const response = await deleteScheduleTask(id);
758
762
  dialogDeleteTask.value = false;
759
- showMessage("Extra visit deleted. Past visits are kept.", "success");
763
+ showMessage("Extra check deleted. Past checks are kept.", "success");
760
764
 
761
765
  await getTaskRefresh();
762
766
  } catch (error: any) {
763
767
  showMessage(
764
- error?.data?.message || "Could not delete this extra visit.",
768
+ error?.data?.message || "Could not delete this extra check.",
765
769
  "error",
766
770
  );
767
771
  } finally {
@@ -163,7 +163,11 @@
163
163
  "
164
164
  value="toggle-status"
165
165
  class="text-body-2"
166
- @click="togglePlanStatus(item)"
166
+ @click="
167
+ item.status === 'active'
168
+ ? (planToDeactivate = item)
169
+ : togglePlanStatus(item)
170
+ "
167
171
  />
168
172
  </v-list>
169
173
  </v-card>
@@ -197,6 +201,32 @@
197
201
  />
198
202
  </v-dialog>
199
203
 
204
+ <ConfirmDialog
205
+ :model-value="!!planToDeactivate"
206
+ @update:model-value="(v: boolean) => { if (!v) planToDeactivate = null; }"
207
+ >
208
+ <template #title>Deactivate plan</template>
209
+ <template #description>
210
+ Deactivate {{ planToDeactivate?.name || "this plan" }}? You can activate
211
+ it again from the Deactivated tab.
212
+ </template>
213
+ <template #footer>
214
+ <v-spacer />
215
+ <v-btn
216
+ variant="outlined"
217
+ :disabled="isDeactivating"
218
+ @click="planToDeactivate = null"
219
+ >Cancel</v-btn
220
+ >
221
+ <v-btn
222
+ color="error"
223
+ variant="flat"
224
+ :loading="isDeactivating"
225
+ @click="confirmDeactivate"
226
+ >Deactivate</v-btn
227
+ >
228
+ </template>
229
+ </ConfirmDialog>
200
230
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
201
231
  </v-container>
202
232
  </template>
@@ -417,6 +447,20 @@ async function handleSubmit(payload: PlanFormState) {
417
447
  }
418
448
  }
419
449
 
450
+ // Deactivate asks first; Activate stays one tap.
451
+ const planToDeactivate = ref<SubscriptionPlan | null>(null);
452
+ const isDeactivating = ref(false);
453
+ async function confirmDeactivate() {
454
+ if (!planToDeactivate.value) return;
455
+ isDeactivating.value = true;
456
+ try {
457
+ await togglePlanStatus(planToDeactivate.value);
458
+ } finally {
459
+ isDeactivating.value = false;
460
+ planToDeactivate.value = null;
461
+ }
462
+ }
463
+
420
464
  async function togglePlanStatus(plan: SubscriptionPlan) {
421
465
  const nextStatus = plan.status === "active" ? "deactive" : "active";
422
466
 
@@ -92,7 +92,7 @@
92
92
  class="text-none"
93
93
  >
94
94
  <v-icon size="12">mdi-calendar-clock</v-icon>
95
- Extra visit
95
+ {{ extraCheckLabel(group) }}
96
96
  </StatusChip>
97
97
  <slot name="group-header-chips" :group="group">
98
98
  <StatusChip
@@ -206,6 +206,7 @@
206
206
  </template>
207
207
 
208
208
  <script lang="ts" setup>
209
+ import { extraCheckLabel } from "../utils/schedule-task";
209
210
  defineOptions({ inheritAttrs: false });
210
211
 
211
212
  const props = defineProps({
@@ -296,6 +297,8 @@ const groupedItems = computed(() => {
296
297
  return {
297
298
  set: item.set,
298
299
  isScheduleTask: item.isScheduleTask ?? false,
300
+ scheduleTaskTitle: item.scheduleTaskTitle ?? null,
301
+ scheduleTaskTime: item.scheduleTaskTime ?? null,
299
302
  completedByName: item.completedByName ?? null,
300
303
  attachments:
301
304
  (item.attachment as string[] | undefined) ??
@@ -187,7 +187,9 @@
187
187
  title="Import Units from Excel"
188
188
  noun="units"
189
189
  name-label="Unit"
190
- fill-hint="One unit per row, e.g. Toilet Bowl, Sofa."
190
+ :fill-hint="/mechanical/i.test(props.serviceType)
191
+ ? 'One unit per row, e.g. Air-con Unit, Water Pump.'
192
+ : 'One unit per row, e.g. Toilet Bowl, Sofa.'"
191
193
  duplicate-note="Units already on the list are skipped and left unchanged."
192
194
  template-file-name="units-import-template.xlsx"
193
195
  :download-template="() => downloadTemplate('units', importQuery)"
@@ -368,6 +368,7 @@
368
368
  <v-menu
369
369
  v-if="item.accessCards?.type === 'NFC' && item.accessCards?.cardNo"
370
370
  :close-on-content-click="false"
371
+ @update:model-value="(open: boolean) => open && loadNfcCardNos(item)"
371
372
  >
372
373
  <template #activator="{ props: nfcMenuProps }">
373
374
  <div
@@ -400,7 +401,7 @@
400
401
  v-for="card in cardGroup.filter((c: any) => c._id !== item.accessCards._id)"
401
402
  :key="card._id"
402
403
  prepend-icon="mdi-credit-card-outline"
403
- :title="card._id"
404
+ :title="nfcCardLabel(card, item)"
404
405
  />
405
406
  </template>
406
407
  </v-list>
@@ -1272,7 +1273,7 @@
1272
1273
  <div class="d-flex align-center ga-2 mb-2">
1273
1274
  <v-icon size="18" icon="mdi-credit-card-outline" color="purple" />
1274
1275
  <v-chip size="small" variant="tonal" color="purple">{{
1275
- card.cardNo
1276
+ nfcCardNos[card.cardId] || card.cardNo
1276
1277
  }}</v-chip>
1277
1278
  </div>
1278
1279
  <v-select
@@ -1687,7 +1688,35 @@ const {
1687
1688
  visitorDataFromScannedQRCodeCheckIn,
1688
1689
  sendPassSms,
1689
1690
  } = useVisitor();
1690
- const { visitorCheckout } = useAccessManagement();
1691
+ const { visitorCheckout, getCardDetails } = useAccessManagement();
1692
+
1693
+ // A visitor's extra NFC cards are stored as ids only, so their card numbers
1694
+ // are read when the card list (or the return dialog) opens. Until then, or if
1695
+ // the card can't be read, a plain placeholder shows - never the database id.
1696
+ const nfcCardNos = reactive<Record<string, string>>({});
1697
+ function nfcCardLabel(card: any, visitor: any): string {
1698
+ const id = String(card?._id ?? "");
1699
+ if (card?.cardNo) return card.cardNo;
1700
+ if (id && id === String(visitor?.accessCards?._id ?? ""))
1701
+ return visitor.accessCards.cardNo;
1702
+ return nfcCardNos[id] || "Card number not available";
1703
+ }
1704
+ async function loadNfcCardNos(visitor: any) {
1705
+ const ids = ((visitor?.cards?.flat?.() ?? []) as any[])
1706
+ .filter((c) => c?._id && !c.cardNo && !nfcCardNos[String(c._id)])
1707
+ .map((c) => String(c._id))
1708
+ .filter((id) => id !== String(visitor?.accessCards?._id ?? ""));
1709
+ await Promise.all(
1710
+ ids.map(async (cardId) => {
1711
+ try {
1712
+ const res = await getCardDetails({ siteId, cardId });
1713
+ if (res?.data?.cardNo) nfcCardNos[cardId] = String(res.data.cardNo);
1714
+ } catch {
1715
+ // left as the placeholder
1716
+ }
1717
+ })
1718
+ );
1719
+ }
1691
1720
  const {
1692
1721
  debounce,
1693
1722
  formatCamelCaseToWords,
@@ -2903,10 +2932,7 @@ function handleCheckout(userId: string) {
2903
2932
  const allCards: any[] = visitor.cards?.flat() ?? [];
2904
2933
  nfcCardReturnStatuses.value = allCards.map((card: any) => ({
2905
2934
  cardId: card._id,
2906
- cardNo:
2907
- card._id === visitor.accessCards._id
2908
- ? visitor.accessCards.cardNo
2909
- : card._id,
2935
+ cardNo: nfcCardLabel(card, visitor),
2910
2936
  status: "In Use",
2911
2937
  remarks: "",
2912
2938
  }));
@@ -2920,6 +2946,7 @@ function handleCheckout(userId: string) {
2920
2946
  },
2921
2947
  ];
2922
2948
  }
2949
+ loadNfcCardNos(visitor);
2923
2950
  dialog.returnNfcCard = true;
2924
2951
  return;
2925
2952
  }
@@ -11,6 +11,7 @@ import {
11
11
  mapLogsToMonthlyRows,
12
12
  mapLogsToMonthRuns,
13
13
  monthIndexOf,
14
+ reportYear,
14
15
  } from "../utils/nfc-patrol-report";
15
16
 
16
17
  interface NFCPatrolLogListResponse {
@@ -53,7 +54,7 @@ export function useNFCPatrolMonthlyReport(
53
54
  */
54
55
  const failed = ref(false);
55
56
 
56
- // The year's patrol logs, kept so a month's row can open its list without
57
+ // The picked year's patrol logs, kept so a month's row can open its list without
57
58
  // asking the server again. Not deep-reactive: nothing edits them.
58
59
  const logs = shallowRef<Record<string, any>[]>([]);
59
60
  let siteTimezone: string | undefined;
@@ -70,7 +71,7 @@ export function useNFCPatrolMonthlyReport(
70
71
  loading.value = true;
71
72
  notFound.value = false;
72
73
  failed.value = false;
73
- const currentYear = new Date().getFullYear().toString();
74
+ const year = reportYear(filters.year);
74
75
  try {
75
76
  // The whole year, not page one of it. `collectAllPages` reads the page
76
77
  // count out of the first reply and walks the rest.
@@ -81,7 +82,8 @@ export function useNFCPatrolMonthlyReport(
81
82
  limit: 100,
82
83
  site,
83
84
  routeId: filters.route,
84
- date: currentYear,
85
+ // "YYYY" + type "month": the server returns that whole year.
86
+ date: year,
85
87
  type: "month",
86
88
  }),
87
89
  ),
@@ -21,6 +21,7 @@ export function useNFCPatrolReportFilters(site: string) {
21
21
  route: "",
22
22
  timeRange: "",
23
23
  date: getTodayDate(),
24
+ year: String(new Date().getFullYear()),
24
25
  });
25
26
 
26
27
  const options = ref<NFCPatrolReportFilterOptions>({
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.143.0",
5
+ "version": "4.146.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.",
@@ -6,6 +6,8 @@ export interface NFCPatrolReportFilters {
6
6
  route: string;
7
7
  timeRange: string;
8
8
  date: string;
9
+ /** The Monthly Report's year, "YYYY". Absent = the current year. */
10
+ year?: string;
9
11
  }
10
12
 
11
13
  export interface NFCPatrolReportExportRequest {
@@ -26,7 +26,8 @@ const RENAMED: [string, string][] = [
26
26
  ["Building Mgmt", "Occupancy Mgmt"],
27
27
  ["Event Mgmt", "Task Calendar"],
28
28
  // Schedule screens (owner 21 Oct): the same names the menu and the mobile apps use.
29
- ["Schedule Task", "Extra Visits"],
29
+ ["Schedule Task", "Extra Checks"],
30
+ ["Extra Visits", "Extra Checks"],
30
31
  ...["Cleaning", "Technician", "Pest", "Pool", "Landscape"].flatMap((t): [string, string][] => [
31
32
  [`${t} Schedule Area`, "Area"],
32
33
  [`${t} Schedule Unit`, "Unit"],
@@ -52,6 +52,7 @@ export const NAV_SECTIONS: Record<string, string> = {
52
52
  "Schedule Task": "SERVICE DELIVERY",
53
53
  "Daily Checklist": "SERVICE DELIVERY",
54
54
  "Extra Visits": "SERVICE DELIVERY",
55
+ "Extra Checks": "SERVICE DELIVERY",
55
56
 
56
57
  // EQUIPMENT
57
58
  "Equipment Management": "EQUIPMENT",
@@ -102,6 +102,21 @@ export function isMissedCheckpoint(
102
102
  return Number.isFinite(end) && now.getTime() > end;
103
103
  }
104
104
 
105
+ /** The Monthly Report's year: the one picked, or the current year. */
106
+ export function reportYear(year?: string, now = new Date()) {
107
+ return /^\d{4}$/.test(year ?? "") ? (year as string) : String(now.getFullYear());
108
+ }
109
+
110
+ /**
111
+ * The years the Monthly Report offers, newest first: this year and the five
112
+ * before it.
113
+ */
114
+ // ponytail: fixed 6-year window; derive from the oldest log if sites need more.
115
+ export function reportYears(now = new Date(), count = 6) {
116
+ const current = now.getFullYear();
117
+ return Array.from({ length: count }, (_, i) => String(current - i));
118
+ }
119
+
105
120
  /** The month (0-11) a patrol log counts under; NaN for a date that is not one. */
106
121
  export function monthIndexOf(log: Record<string, any>) {
107
122
  return new Date(log?.date).getMonth();
@@ -68,12 +68,13 @@ export const RESOURCE_LABELS: Record<string, string> = {
68
68
  export const RESOURCE_LABELS_BY_TYPE: Record<string, Record<string, string>> = {
69
69
  property_management_agency: { "virtual-patrol": "CCTV" },
70
70
  real_estate_developer: { "virtual-patrol": "CCTV" },
71
- // Trade catalogue v2: the trade apps call `event-mgmt` the Task Calendar.
72
- cleaning_services: { "event-mgmt": "Task Calendar" },
73
- mechanical_electrical_services: { "event-mgmt": "Task Calendar" },
74
- pest_control_services: { "event-mgmt": "Task Calendar" },
75
- pool_maintenance_services: { "event-mgmt": "Task Calendar" },
76
- landscaping_services: { "event-mgmt": "Task Calendar" },
71
+ // Trade catalogue v2: the trade apps call `event-mgmt` the Task Calendar and
72
+ // `schedule-task-mgmt` Extra Checks (owner 22 Oct; the key is unchanged).
73
+ cleaning_services: { "event-mgmt": "Task Calendar", "schedule-task-mgmt": "Extra checks" },
74
+ mechanical_electrical_services: { "event-mgmt": "Task Calendar", "schedule-task-mgmt": "Extra checks" },
75
+ pest_control_services: { "event-mgmt": "Task Calendar", "schedule-task-mgmt": "Extra checks" },
76
+ pool_maintenance_services: { "event-mgmt": "Task Calendar", "schedule-task-mgmt": "Extra checks" },
77
+ landscaping_services: { "event-mgmt": "Task Calendar", "schedule-task-mgmt": "Extra checks" },
77
78
  };
78
79
 
79
80
  /**
@@ -113,8 +114,18 @@ export function formatResourceLabel(key: string, type?: string): string {
113
114
  * `.replace(/-/g, " ")` inline; this keeps them from drifting apart, and keeps
114
115
  * an acronym from being lower-cased into nonsense.
115
116
  */
117
+ /** Action keys whose words name a screen by its old name. Display only. */
118
+ const ACTION_LABELS: Record<string, string> = {
119
+ "see-all-schedule-tasks": "see all extra checks",
120
+ "add-schedule-task": "add extra check",
121
+ "update-schedule-task": "edit, pause or resume extra check",
122
+ "delete-schedule-task": "delete extra check",
123
+ "see-schedule-task-details": "see extra check details",
124
+ };
125
+
116
126
  export function formatActionLabel(action: string): string {
117
127
  if (!action) return "";
128
+ if (ACTION_LABELS[action]) return ACTION_LABELS[action];
118
129
  return action
119
130
  .split(/[-_\s]+/)
120
131
  .filter(Boolean)
package/utils/role.ts CHANGED
@@ -158,3 +158,27 @@ export function siteAccessLabel(item: { limitedTo?: unknown } | null | undefined
158
158
  const names = limitedSites(item).map((l) => l.siteName || "a site");
159
159
  return names.length ? `Limited to: ${names.join(", ")}` : "All sites";
160
160
  }
161
+
162
+ /**
163
+ * A role's `type` is the app it belongs to, stored as a code
164
+ * (`security_agency`). The roles table shows the app's plain name instead.
165
+ * An unknown code is shown with its underscores as spaces, never dropped.
166
+ */
167
+ const ROLE_APP_LABELS: Readonly<Record<string, string>> = Object.freeze({
168
+ admin: "Super Admin",
169
+ organization: "Organization",
170
+ property_management_agency: "Property Management",
171
+ security_agency: "Security",
172
+ cleaning_services: "Cleaning",
173
+ mechanical_electrical_services: "Mechanical & Electrical",
174
+ pest_control_services: "Pest Control",
175
+ pool_maintenance_services: "Pool Maintenance",
176
+ landscaping_services: "Landscape",
177
+ resident: "Resident",
178
+ });
179
+
180
+ export function roleAppLabel(type: unknown): string {
181
+ const code = String(type ?? "").trim();
182
+ if (!code) return "";
183
+ return ROLE_APP_LABELS[code] ?? code.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
184
+ }
@@ -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.`;