@7365admin1/layer-common 4.147.1 → 4.148.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 +21 -4
- package/components/AreaChecklistHistoryLogs.vue +2 -2
- package/components/CleaningScheduleMain.vue +9 -4
- package/components/Input/NRICNumber.vue +15 -3
- package/components/ManageChecklistMain.vue +22 -8
- package/components/ScheduleAreaMain.vue +20 -6
- package/components/ScheduleGuide.vue +7 -1
- package/components/ScheduleTaskMain.vue +4 -2
- package/components/TableHygiene.vue +2 -2
- package/components/TaskCalendarMain.vue +200 -0
- package/components/TaskCalendarReadOnly.vue +22 -3
- package/components/VisitorManagement.vue +3 -2
- package/components/VisitorSocketPopUp.vue +2 -2
- package/composables/useCleaningSchedules.ts +5 -9
- package/package.json +1 -1
- package/utils/breadcrumb.ts +3 -2
- package/utils/schedule-task.ts +45 -0
- package/utils/task-calendar-tabs.ts +56 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.148.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4b2d44f: Daily Checklist: the help box points to the Task Calendar for extra checks; the day page names its day, says "Visits today", offers the Closed filter and stays view-only on a closed day even when opened from a link; the unit page explains Done / Needs redo; "Visit N" instead of "Set N"; the PDF file is named after its day; the setup banner uses the area list's unit counts instead of one request per area.
|
|
8
|
+
- 5276d06: Trade apps: Task Calendar becomes one page with two tabs, "From managing agent" (view only) and "My extra checks" (the Extra Checks list with add / edit / pause / delete), and one short "How the Task Calendar works" help box. New `TaskCalendarMain` component; `ScheduleTaskMain` and `TaskCalendarReadOnly` gain an `embedded` prop (default off, so existing pages are unchanged). Breadcrumb for the old Extra Checks address now reads "Task Calendar".
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- 2c5bc62: Task Calendar "From managing agent" tab: a plain sentence per tab when it is empty instead of "No data available".
|
|
13
|
+
|
|
14
|
+
## 4.147.2
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- 3a1e7ba: Stop the browser filling the saved sign-in password into the masked NRIC field on Add Visitor / Add Delivery. The field is now masked with CSS instead of being a password box.
|
|
19
|
+
|
|
3
20
|
## 4.147.1
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
|
@@ -3487,11 +3504,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
|
|
|
3487
3504
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
3488
3505
|
own rule from two endpoints the console already calls, unprojected:
|
|
3489
3506
|
|
|
3490
|
-
|
|
3491
|
-
|
|
3507
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
3508
|
+
GET /api/roles/id/:role that membership's role document
|
|
3492
3509
|
|
|
3493
|
-
|
|
3494
|
-
|
|
3510
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
3511
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
3495
3512
|
|
|
3496
3513
|
`role.default` is the marker because it is the only property of a platform
|
|
3497
3514
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -117,7 +117,7 @@
|
|
|
117
117
|
v-if="checklistSet.set !== undefined"
|
|
118
118
|
class="text-subtitle-2 font-weight-bold px-0"
|
|
119
119
|
>
|
|
120
|
-
|
|
120
|
+
{{ visitLabel(checklistSet.set) }}
|
|
121
121
|
<StatusChip
|
|
122
122
|
v-if="checklistSet.isScheduleTask"
|
|
123
123
|
tone="info"
|
|
@@ -262,7 +262,7 @@
|
|
|
262
262
|
</template>
|
|
263
263
|
|
|
264
264
|
<script setup lang="ts">
|
|
265
|
-
import { dailyStatusLabel, extraCheckLabel, historyUnitLabel } from "../utils/schedule-task";
|
|
265
|
+
import { dailyStatusLabel, extraCheckLabel, historyUnitLabel, visitLabel } 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: "" },
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
<ol class="sg-steps">
|
|
15
15
|
<li>
|
|
16
16
|
Every midnight a new row appears here for the day. It holds every
|
|
17
|
-
area, with as many visits as the area's "visits per day"
|
|
18
|
-
|
|
17
|
+
area, with as many visits as the area's "visits per day". An
|
|
18
|
+
<strong>extra check</strong> from the <strong>Task Calendar</strong>
|
|
19
|
+
("My extra checks") adds one more visit to its areas at its set
|
|
20
|
+
time that day.
|
|
19
21
|
</li>
|
|
20
22
|
<li>
|
|
21
23
|
Staff open the same list in their mobile app and tick each unit
|
|
@@ -403,7 +405,7 @@ async function downloadItem(item: any) {
|
|
|
403
405
|
|
|
404
406
|
try {
|
|
405
407
|
downloadingId.value = id;
|
|
406
|
-
await downloadChecklistPdf(id);
|
|
408
|
+
await downloadChecklistPdf(id, item?.createdAt);
|
|
407
409
|
} catch (err) {
|
|
408
410
|
showMessage(
|
|
409
411
|
(err as any)?.data?.message ||
|
|
@@ -420,6 +422,8 @@ const selectedScheduleStatus = useState<string>(
|
|
|
420
422
|
() => ""
|
|
421
423
|
);
|
|
422
424
|
|
|
425
|
+
const selectedScheduleDay = useState<string>("selectedScheduleDay", () => "");
|
|
426
|
+
|
|
423
427
|
const isItemClosed = (item: any): boolean =>
|
|
424
428
|
String(item?.status ?? "").toLowerCase() === "closed";
|
|
425
429
|
|
|
@@ -435,8 +439,9 @@ function onRowClick(data: any) {
|
|
|
435
439
|
return;
|
|
436
440
|
}
|
|
437
441
|
|
|
438
|
-
// Store status so nested pages know whether the
|
|
442
|
+
// Store status and day so nested pages know whether the list is closed and which day it is
|
|
439
443
|
selectedScheduleStatus.value = String(item?.status ?? "");
|
|
444
|
+
selectedScheduleDay.value = String(item?.createdAt ?? "");
|
|
440
445
|
|
|
441
446
|
if (props.type === "toilet") {
|
|
442
447
|
const path = `/${props.orgId}/${props.site}/toilet-checklist/${id}`;
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<v-text-field
|
|
3
3
|
v-model.trim="model"
|
|
4
|
-
class="filter-field"
|
|
4
|
+
:class="['filter-field', { 'nric-field--masked': masked && !shown }]"
|
|
5
5
|
:rules="rules"
|
|
6
6
|
:maxlength="maxlength"
|
|
7
7
|
:placeholder="placeholder"
|
|
8
8
|
:counter="maxlength"
|
|
9
9
|
@input="onInput"
|
|
10
10
|
:loading="loading"
|
|
11
|
-
|
|
11
|
+
type="text"
|
|
12
12
|
:append-inner-icon="masked ? (shown ? 'mdi-eye-off' : 'mdi-eye') : undefined"
|
|
13
13
|
autocomplete="off"
|
|
14
|
+
data-lpignore="true"
|
|
15
|
+
data-1p-ignore
|
|
14
16
|
outlined
|
|
15
17
|
clearable
|
|
16
18
|
@click:append-inner="shown = !shown"
|
|
@@ -63,4 +65,14 @@ function onInput(event) {
|
|
|
63
65
|
watch(model, (newVal) => {
|
|
64
66
|
emit('update:modelValue', newVal)
|
|
65
67
|
})
|
|
66
|
-
</script>
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<style scoped>
|
|
71
|
+
/* Masked by CSS, never type="password": a password box makes Chrome treat the
|
|
72
|
+
form as a login and fill the saved sign-in password into the NRIC field
|
|
73
|
+
(it ignores autocomplete="off" on password boxes). */
|
|
74
|
+
.nric-field--masked :deep(input) {
|
|
75
|
+
-webkit-text-security: disc;
|
|
76
|
+
text-security: disc;
|
|
77
|
+
}
|
|
78
|
+
</style>
|
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
</StatusChip>
|
|
17
17
|
</v-col>
|
|
18
18
|
</v-row>
|
|
19
|
+
<p class="checklist-intro" data-test="checklist-intro">
|
|
20
|
+
Each visit lists this area's units. Staff tick them on their phone; here
|
|
21
|
+
you can mark a unit <strong>Done</strong> (✓) or send it back with
|
|
22
|
+
<strong>Needs redo</strong> (✗, with a remark). The last unit of a
|
|
23
|
+
visit needs a photo. A visit marked "Extra check" was added from the Task
|
|
24
|
+
Calendar.
|
|
25
|
+
</p>
|
|
19
26
|
<TableHygiene
|
|
20
27
|
:title="scheduleTitle"
|
|
21
28
|
:headers="headers"
|
|
@@ -249,8 +256,8 @@
|
|
|
249
256
|
<v-card-title>
|
|
250
257
|
{{
|
|
251
258
|
modalData.currentSet !== undefined
|
|
252
|
-
?
|
|
253
|
-
: "All
|
|
259
|
+
? `${visitLabel(modalData.currentSet)} done`
|
|
260
|
+
: "All units ticked"
|
|
254
261
|
}}
|
|
255
262
|
</v-card-title>
|
|
256
263
|
|
|
@@ -285,7 +292,7 @@
|
|
|
285
292
|
v-if="!modalData.photoIds.length"
|
|
286
293
|
class="text-caption text-medium-emphasis mt-1"
|
|
287
294
|
>
|
|
288
|
-
At least one photo is
|
|
295
|
+
At least one photo is needed to finish this visit. You can take
|
|
289
296
|
or upload more than one.
|
|
290
297
|
</p>
|
|
291
298
|
</v-col>
|
|
@@ -382,7 +389,7 @@
|
|
|
382
389
|
<v-dialog v-model="showAttachmentDialog" max-width="700" scrollable>
|
|
383
390
|
<v-card class="screen-modal">
|
|
384
391
|
<v-card-title class="d-flex align-center">
|
|
385
|
-
<span>
|
|
392
|
+
<span>{{ visitLabel(attachmentDialogSetNum) }} — photos</span>
|
|
386
393
|
<v-spacer />
|
|
387
394
|
<v-btn
|
|
388
395
|
icon="mdi-close"
|
|
@@ -462,6 +469,7 @@
|
|
|
462
469
|
|
|
463
470
|
<script setup lang="ts">
|
|
464
471
|
import { readListPage } from "../utils/list-page";
|
|
472
|
+
import { isClosedDay, visitLabel } from "../utils/schedule-task";
|
|
465
473
|
import { isPhotoRefusal, isSetFinished } from "../utils/checklist-set";
|
|
466
474
|
import useCleaningSchedules from "../composables/useCleaningSchedules";
|
|
467
475
|
import useWorkerTerm from "../composables/useWorkerTerm";
|
|
@@ -504,8 +512,9 @@ const selectedScheduleStatus = useState<string>(
|
|
|
504
512
|
"selectedScheduleStatus",
|
|
505
513
|
() => ""
|
|
506
514
|
);
|
|
507
|
-
|
|
508
|
-
|
|
515
|
+
// Opened from the day list it knows; from a link or a refresh the units tell.
|
|
516
|
+
const isScheduleClosed = computed(() =>
|
|
517
|
+
isClosedDay(selectedScheduleStatus.value, items.value as any[]),
|
|
509
518
|
);
|
|
510
519
|
|
|
511
520
|
const page = ref<number>(1);
|
|
@@ -1075,13 +1084,13 @@ async function _updateUnitChecklist({
|
|
|
1075
1084
|
);
|
|
1076
1085
|
}
|
|
1077
1086
|
|
|
1078
|
-
showMessage(
|
|
1087
|
+
showMessage(`${visitLabel(setNumber)} done`, "success");
|
|
1079
1088
|
|
|
1080
1089
|
// Refresh data
|
|
1081
1090
|
await getUnitCleanerChecklistRefresh();
|
|
1082
1091
|
} catch (error: any) {
|
|
1083
1092
|
console.error("Error approving set:", error);
|
|
1084
|
-
showMessage(error?.message || "Could not finish this
|
|
1093
|
+
showMessage(error?.message || "Could not finish this visit", "error");
|
|
1085
1094
|
} finally {
|
|
1086
1095
|
submitting.value = false;
|
|
1087
1096
|
}
|
|
@@ -1089,6 +1098,11 @@ async function _updateUnitChecklist({
|
|
|
1089
1098
|
</script>
|
|
1090
1099
|
|
|
1091
1100
|
<style scoped>
|
|
1101
|
+
.checklist-intro {
|
|
1102
|
+
margin: 0 0 12px;
|
|
1103
|
+
font-size: var(--fs-cell);
|
|
1104
|
+
color: var(--text2);
|
|
1105
|
+
}
|
|
1092
1106
|
/* The idle checklist circle. `--muted` is the design's de-emphasis token and
|
|
1093
1107
|
clears the 3:1 bar a non-text indicator needs; Vuetify's `grey-lighten-2`
|
|
1094
1108
|
read 1.32:1. */
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
:site="site"
|
|
7
7
|
:service-type="serviceType"
|
|
8
8
|
:schedule-route="scheduleRoute"
|
|
9
|
-
text="These are the areas on this day's checklist. Click an area to see each unit, tick it as done, or send it back to be redone."
|
|
9
|
+
text="These are the areas on this day's checklist. Click an area to see each unit, tick it as done, or send it back to be redone. Visits today counts the area's normal visits plus any extra checks added today."
|
|
10
10
|
/>
|
|
11
11
|
<TableMain
|
|
12
|
-
:title="
|
|
12
|
+
:title="dayTitle"
|
|
13
13
|
:headers="headers"
|
|
14
14
|
:items="items"
|
|
15
15
|
:loading="loading"
|
|
@@ -143,7 +143,12 @@
|
|
|
143
143
|
|
|
144
144
|
<script setup lang="ts">
|
|
145
145
|
import useCleaningSchedules from "../composables/useCleaningSchedules";
|
|
146
|
-
import {
|
|
146
|
+
import {
|
|
147
|
+
dailyChecklistTitle,
|
|
148
|
+
dailyStatusLabel,
|
|
149
|
+
dayLabel,
|
|
150
|
+
isClosedDay,
|
|
151
|
+
} from "../utils/schedule-task";
|
|
147
152
|
import useUtils from "../composables/useUtils";
|
|
148
153
|
|
|
149
154
|
const props = defineProps({
|
|
@@ -177,7 +182,8 @@ const headers = [
|
|
|
177
182
|
{ title: "Area", value: "name" },
|
|
178
183
|
{ title: "Status", value: "status" },
|
|
179
184
|
...(props.showAreaType ? [{ title: "Type", value: "type" }] : []),
|
|
180
|
-
|
|
185
|
+
// The highest visit number on today's list: normal visits plus any extra checks added.
|
|
186
|
+
{ title: "Visits today", value: "set" },
|
|
181
187
|
];
|
|
182
188
|
|
|
183
189
|
const { formatDate, back } = useUtils();
|
|
@@ -190,9 +196,16 @@ const selectedScheduleStatus = useState<string>(
|
|
|
190
196
|
"selectedScheduleStatus",
|
|
191
197
|
() => ""
|
|
192
198
|
);
|
|
193
|
-
|
|
194
|
-
|
|
199
|
+
// Opened from the day list it knows; from a link or a refresh the rows tell.
|
|
200
|
+
const isScheduleClosed = computed(() =>
|
|
201
|
+
isClosedDay(selectedScheduleStatus.value, items.value),
|
|
195
202
|
);
|
|
203
|
+
const selectedScheduleDay = useState<string>("selectedScheduleDay", () => "");
|
|
204
|
+
// "Daily Checklist – Cleaning – Sat, 24 Oct 2026" (the day only when known).
|
|
205
|
+
const dayTitle = computed(() => {
|
|
206
|
+
const day = dayLabel(selectedScheduleDay.value);
|
|
207
|
+
return day ? `${pageTitle} – ${day}` : pageTitle;
|
|
208
|
+
});
|
|
196
209
|
|
|
197
210
|
const items = ref<Array<Record<string, any>>>([]);
|
|
198
211
|
|
|
@@ -202,6 +215,7 @@ const statusOptions = [
|
|
|
202
215
|
{ title: "Not started", value: "open" },
|
|
203
216
|
{ title: "In progress", value: "ongoing" },
|
|
204
217
|
{ title: "Done", value: "completed" },
|
|
218
|
+
{ title: "Closed", value: "closed" },
|
|
205
219
|
];
|
|
206
220
|
const areaTypeFilter = ref<TAreaType>("all");
|
|
207
221
|
const typeOptions = [
|
|
@@ -132,8 +132,14 @@ async function check() {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
const noUnits: string[] = [];
|
|
135
|
+
// A newer server lists each area's unit count: no lookups at all. Older
|
|
136
|
+
// replies (or cached ones) fall back to one capped lookup per area.
|
|
137
|
+
for (const a of areas) {
|
|
138
|
+
if (typeof a.unitCount === "number" && a.unitCount === 0) noUnits.push(a.name);
|
|
139
|
+
}
|
|
140
|
+
const unknown = areas.filter((a) => typeof a.unitCount !== "number");
|
|
135
141
|
await Promise.all(
|
|
136
|
-
|
|
142
|
+
unknown.slice(0, MAX_UNIT_CHECKS).map(async (a) => {
|
|
137
143
|
try {
|
|
138
144
|
const full = await getAreaById(a._id);
|
|
139
145
|
if (!(full?.units || []).length) noUnits.push(a.name);
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
:org-id="orgId"
|
|
6
6
|
:site="site"
|
|
7
7
|
:service-type="serviceType"
|
|
8
|
-
:text="FEATURE_SUBTITLE"
|
|
9
|
-
how-title="How extra checks work"
|
|
8
|
+
:text="embedded ? '' : FEATURE_SUBTITLE"
|
|
9
|
+
:how-title="embedded ? '' : 'How extra checks work'"
|
|
10
10
|
how-key="extra-visits"
|
|
11
11
|
>
|
|
12
12
|
<ol class="sg-steps">
|
|
@@ -509,6 +509,8 @@ const props = defineProps({
|
|
|
509
509
|
canUpdateScheduleTask: { type: Boolean, default: false },
|
|
510
510
|
canDeleteScheduleTask: { type: Boolean, default: false },
|
|
511
511
|
canViewScheduleTaskDetails: { type: Boolean, default: false },
|
|
512
|
+
/** Inside the Task Calendar page: its one help box replaces this page's own. */
|
|
513
|
+
embedded: { type: Boolean, default: false },
|
|
512
514
|
});
|
|
513
515
|
|
|
514
516
|
const { formatDate, debounce } = useUtils();
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
<span
|
|
84
84
|
class="text-caption font-weight-bold text-medium-emphasis text-uppercase"
|
|
85
85
|
>
|
|
86
|
-
|
|
86
|
+
{{ visitLabel(group.set) }}
|
|
87
87
|
</span>
|
|
88
88
|
<StatusChip
|
|
89
89
|
v-if="group.isScheduleTask"
|
|
@@ -206,7 +206,7 @@
|
|
|
206
206
|
</template>
|
|
207
207
|
|
|
208
208
|
<script lang="ts" setup>
|
|
209
|
-
import { extraCheckLabel } from "../utils/schedule-task";
|
|
209
|
+
import { extraCheckLabel, visitLabel } from "../utils/schedule-task";
|
|
210
210
|
defineOptions({ inheritAttrs: false });
|
|
211
211
|
|
|
212
212
|
const props = defineProps({
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
TASK CALENDAR for the five trade apps (owner 24 Oct 2026): one page for every
|
|
3
|
+
scheduled job at the site, in two tabs.
|
|
4
|
+
|
|
5
|
+
"From managing agent" - tasks the managing agent gave this company
|
|
6
|
+
(TaskCalendarReadOnly, view only).
|
|
7
|
+
"My extra checks" - the company's own extra checks
|
|
8
|
+
(ScheduleTaskMain, add / edit / pause / delete).
|
|
9
|
+
|
|
10
|
+
Each tab shows only when the viewer's role already allows it (the same keys
|
|
11
|
+
the two separate menu items used), so nobody gains or loses access. The tab
|
|
12
|
+
is kept in the address (?tab=agent | extra) so a link opens the right one.
|
|
13
|
+
-->
|
|
14
|
+
<template>
|
|
15
|
+
<v-row no-gutters>
|
|
16
|
+
<v-col cols="12">
|
|
17
|
+
<PageHeader title="Task Calendar" />
|
|
18
|
+
|
|
19
|
+
<v-expansion-panels
|
|
20
|
+
v-model="helpPanel"
|
|
21
|
+
class="tc-help mb-4"
|
|
22
|
+
variant="accordion"
|
|
23
|
+
flat
|
|
24
|
+
>
|
|
25
|
+
<v-expansion-panel value="how">
|
|
26
|
+
<v-expansion-panel-title class="tc-help__title">
|
|
27
|
+
<v-icon size="20" class="mr-2">mdi-help-circle-outline</v-icon>
|
|
28
|
+
How the Task Calendar works
|
|
29
|
+
</v-expansion-panel-title>
|
|
30
|
+
<v-expansion-panel-text class="tc-help__body">
|
|
31
|
+
<ul class="tc-help__list">
|
|
32
|
+
<li v-if="showAgent">
|
|
33
|
+
<strong>From managing agent</strong> - jobs the building's
|
|
34
|
+
managing agent gave your company, for example a monthly pump
|
|
35
|
+
service. You can only look at them here. The managing agent
|
|
36
|
+
marks them done.
|
|
37
|
+
</li>
|
|
38
|
+
<li v-if="showExtra">
|
|
39
|
+
<strong>My extra checks</strong> - checks your company adds on
|
|
40
|
+
top of the normal daily rounds, for example "Lunch toilet
|
|
41
|
+
check, Lobby Toilets, 2:00 pm every weekday". The managing
|
|
42
|
+
agent can see them too, but cannot change them.
|
|
43
|
+
</li>
|
|
44
|
+
<li v-if="showExtra">
|
|
45
|
+
At the set time, the extra check appears in staff's
|
|
46
|
+
<strong>Daily Checklist</strong> on their phone and they get a
|
|
47
|
+
notification. They tick each unit there.
|
|
48
|
+
</li>
|
|
49
|
+
</ul>
|
|
50
|
+
<div class="tc-help__h">What the words mean</div>
|
|
51
|
+
<dl class="tc-help__legend">
|
|
52
|
+
<template v-for="h in words" :key="h.word">
|
|
53
|
+
<dt><StatusChip :status="h.word" :label="h.word" :tone="h.tone" /></dt>
|
|
54
|
+
<dd>{{ h.meaning }}</dd>
|
|
55
|
+
</template>
|
|
56
|
+
</dl>
|
|
57
|
+
</v-expansion-panel-text>
|
|
58
|
+
</v-expansion-panel>
|
|
59
|
+
</v-expansion-panels>
|
|
60
|
+
|
|
61
|
+
<div v-if="tabs.length > 1" class="app-tabs mb-4" role="tablist">
|
|
62
|
+
<button
|
|
63
|
+
v-for="t in tabs"
|
|
64
|
+
:key="t.key"
|
|
65
|
+
type="button"
|
|
66
|
+
role="tab"
|
|
67
|
+
class="app-tab"
|
|
68
|
+
:class="{ 'app-tab--active': tab === t.key }"
|
|
69
|
+
:aria-selected="tab === t.key"
|
|
70
|
+
:data-test="`task-calendar-tab-${t.key}`"
|
|
71
|
+
@click="chooseTab(t.key)"
|
|
72
|
+
>
|
|
73
|
+
{{ t.name }}
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<TaskCalendarReadOnly
|
|
78
|
+
v-if="tab === 'agent'"
|
|
79
|
+
:site-id="siteId"
|
|
80
|
+
embedded
|
|
81
|
+
/>
|
|
82
|
+
<ScheduleTaskMain
|
|
83
|
+
v-else-if="tab === 'extra'"
|
|
84
|
+
:org-id="orgId"
|
|
85
|
+
:site="siteId"
|
|
86
|
+
:service-type="serviceType"
|
|
87
|
+
:can-view-schedule-tasks="canViewScheduleTasks"
|
|
88
|
+
:can-create-schedule-task="canCreateScheduleTask"
|
|
89
|
+
:can-update-schedule-task="canUpdateScheduleTask"
|
|
90
|
+
:can-delete-schedule-task="canDeleteScheduleTask"
|
|
91
|
+
:can-view-schedule-task-details="canViewScheduleTaskDetails"
|
|
92
|
+
embedded
|
|
93
|
+
/>
|
|
94
|
+
<v-alert v-else type="info" variant="tonal" class="ma-4">
|
|
95
|
+
Your role does not include the Task Calendar.
|
|
96
|
+
</v-alert>
|
|
97
|
+
</v-col>
|
|
98
|
+
</v-row>
|
|
99
|
+
</template>
|
|
100
|
+
|
|
101
|
+
<script setup lang="ts">
|
|
102
|
+
import {
|
|
103
|
+
pickTaskCalendarTab,
|
|
104
|
+
taskCalendarWords,
|
|
105
|
+
type TTaskCalendarTab,
|
|
106
|
+
} from "../utils/task-calendar-tabs";
|
|
107
|
+
|
|
108
|
+
const props = defineProps({
|
|
109
|
+
orgId: { type: String, required: true },
|
|
110
|
+
siteId: { type: String, required: true },
|
|
111
|
+
serviceType: { type: String, default: "" },
|
|
112
|
+
/** Opened from an old Extra Checks link: start on "My extra checks". */
|
|
113
|
+
initialTab: { type: String, default: "" },
|
|
114
|
+
canViewTaskCalendar: { type: Boolean, default: false },
|
|
115
|
+
canViewScheduleTasks: { type: Boolean, default: false },
|
|
116
|
+
canCreateScheduleTask: { type: Boolean, default: false },
|
|
117
|
+
canUpdateScheduleTask: { type: Boolean, default: false },
|
|
118
|
+
canDeleteScheduleTask: { type: Boolean, default: false },
|
|
119
|
+
canViewScheduleTaskDetails: { type: Boolean, default: false },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const route = useRoute();
|
|
123
|
+
const router = useRouter();
|
|
124
|
+
|
|
125
|
+
const showAgent = computed(() => props.canViewTaskCalendar);
|
|
126
|
+
const showExtra = computed(() => props.canViewScheduleTasks);
|
|
127
|
+
|
|
128
|
+
const words = computed(() =>
|
|
129
|
+
taskCalendarWords({ agent: showAgent.value, extra: showExtra.value }),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const tabs = computed(() => [
|
|
133
|
+
...(showAgent.value ? [{ key: "agent" as const, name: "From managing agent" }] : []),
|
|
134
|
+
...(showExtra.value ? [{ key: "extra" as const, name: "My extra checks" }] : []),
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
const tab = computed<TTaskCalendarTab | "">(() =>
|
|
138
|
+
pickTaskCalendarTab(route.query.tab, props.initialTab, {
|
|
139
|
+
agent: showAgent.value,
|
|
140
|
+
extra: showExtra.value,
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
function chooseTab(key: TTaskCalendarTab) {
|
|
145
|
+
if (key === tab.value) return;
|
|
146
|
+
router.replace({ query: { ...route.query, tab: key } });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Open until the viewer closes it; remembered on this browser only.
|
|
150
|
+
const HELP_STORE = "task-calendar-help";
|
|
151
|
+
const helpPanel = ref<string | undefined>("how");
|
|
152
|
+
onMounted(() => {
|
|
153
|
+
try {
|
|
154
|
+
if (localStorage.getItem(HELP_STORE) === "closed") helpPanel.value = undefined;
|
|
155
|
+
} catch {
|
|
156
|
+
// storage blocked: stay open
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
watch(helpPanel, (v) => {
|
|
160
|
+
try {
|
|
161
|
+
localStorage.setItem(HELP_STORE, v ? "open" : "closed");
|
|
162
|
+
} catch {
|
|
163
|
+
// storage blocked: nothing to remember
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
</script>
|
|
167
|
+
|
|
168
|
+
<style scoped>
|
|
169
|
+
.tc-help {
|
|
170
|
+
border: 1px solid var(--border);
|
|
171
|
+
border-radius: 8px;
|
|
172
|
+
}
|
|
173
|
+
.tc-help__title {
|
|
174
|
+
font-weight: 700;
|
|
175
|
+
}
|
|
176
|
+
.tc-help__body {
|
|
177
|
+
font-size: var(--fs-cell);
|
|
178
|
+
color: var(--text);
|
|
179
|
+
}
|
|
180
|
+
.tc-help__list {
|
|
181
|
+
margin: 0 0 12px 18px;
|
|
182
|
+
display: flex;
|
|
183
|
+
flex-direction: column;
|
|
184
|
+
gap: 6px;
|
|
185
|
+
}
|
|
186
|
+
.tc-help__h {
|
|
187
|
+
font-weight: 700;
|
|
188
|
+
margin-bottom: 6px;
|
|
189
|
+
}
|
|
190
|
+
.tc-help__legend {
|
|
191
|
+
display: grid;
|
|
192
|
+
grid-template-columns: max-content 1fr;
|
|
193
|
+
gap: 6px 12px;
|
|
194
|
+
align-items: center;
|
|
195
|
+
margin: 0;
|
|
196
|
+
}
|
|
197
|
+
.tc-help__legend dd {
|
|
198
|
+
margin: 0;
|
|
199
|
+
}
|
|
200
|
+
</style>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<v-row no-gutters>
|
|
3
|
-
<v-col cols="12">
|
|
3
|
+
<v-col v-if="!embedded" cols="12">
|
|
4
4
|
<PageHeader title="Task Calendar"
|
|
5
5
|
subtitle="Tasks the managing agent assigned to your company at this site. View only - the managing agent marks them done." />
|
|
6
6
|
</v-col>
|
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
</button>
|
|
17
17
|
</template>
|
|
18
18
|
|
|
19
|
-
<template
|
|
20
|
-
<DashboardEmptyState error />
|
|
19
|
+
<template #no-data>
|
|
20
|
+
<DashboardEmptyState v-if="error" error />
|
|
21
|
+
<div v-else class="tc-ro-empty" data-test="task-calendar-agent-empty">
|
|
22
|
+
{{ EMPTY_TEXT[status] || EMPTY_TEXT.planned }}
|
|
23
|
+
</div>
|
|
21
24
|
</template>
|
|
22
25
|
|
|
23
26
|
<template v-slot:item.dateTime="{ item }">{{ dueDate(item.dateTime) }}</template>
|
|
@@ -54,9 +57,17 @@ import { readListPage } from '../utils/list-page';
|
|
|
54
57
|
|
|
55
58
|
const props = defineProps({
|
|
56
59
|
siteId: { type: String, required: true },
|
|
60
|
+
/** Inside the Task Calendar page (TaskCalendarMain), which has the title and help. */
|
|
61
|
+
embedded: { type: Boolean, default: false },
|
|
57
62
|
});
|
|
58
63
|
|
|
59
64
|
const LIMIT = 20;
|
|
65
|
+
// Per tab, in plain words (owner 24 Oct): never a bare "No data available".
|
|
66
|
+
const EMPTY_TEXT: Record<string, string> = {
|
|
67
|
+
planned: "No jobs from the managing agent yet. When they give your company a job at this site, it shows here.",
|
|
68
|
+
overdue: "Nothing overdue. Jobs from the managing agent that pass their due date show here.",
|
|
69
|
+
completed: "No finished jobs yet. Jobs the managing agent marks done show here.",
|
|
70
|
+
};
|
|
60
71
|
const TABS = [
|
|
61
72
|
{ name: "Open", status: "planned" },
|
|
62
73
|
{ name: "Overdue", status: "overdue" },
|
|
@@ -113,3 +124,11 @@ function taskState(task: Record<string, any>) {
|
|
|
113
124
|
return { label: "Upcoming", color: "primary" };
|
|
114
125
|
}
|
|
115
126
|
</script>
|
|
127
|
+
|
|
128
|
+
<style scoped>
|
|
129
|
+
.tc-ro-empty {
|
|
130
|
+
padding: 24px 16px;
|
|
131
|
+
font-size: var(--fs-cell);
|
|
132
|
+
color: var(--text2);
|
|
133
|
+
}
|
|
134
|
+
</style>
|
|
@@ -1912,7 +1912,8 @@ ensureANPRSettings(true).catch(() => {
|
|
|
1912
1912
|
|
|
1913
1913
|
/*
|
|
1914
1914
|
* iSecure `useUnregisteredTab`: the Unregistered tab needs BOTH the site's
|
|
1915
|
-
* Unregistered switch AND an ANPR camera facing an entrance (entry / both
|
|
1915
|
+
* Unregistered switch AND an ANPR camera facing an entrance (entry / both /
|
|
1916
|
+
* visitors) -
|
|
1916
1917
|
* the same rule the plate notice (VisitorSocketPopUp) already applies.
|
|
1917
1918
|
*/
|
|
1918
1919
|
const { getAllSiteCameras } = useSiteSettings();
|
|
@@ -1928,7 +1929,7 @@ async function loadEntryFacingAnprCamera() {
|
|
|
1928
1929
|
? res.data.items
|
|
1929
1930
|
: [];
|
|
1930
1931
|
hasEntryFacingAnprCamera.value = cameras.some(
|
|
1931
|
-
(camera: any) =>
|
|
1932
|
+
(camera: any) => ["entry", "both", "visitors"].includes(camera?.direction)
|
|
1932
1933
|
);
|
|
1933
1934
|
} catch {
|
|
1934
1935
|
hasEntryFacingAnprCamera.value = false;
|
|
@@ -150,7 +150,7 @@ const { enableUnregistered, ensureANPRSettings } = useANPRSettings(
|
|
|
150
150
|
);
|
|
151
151
|
// The security mobile app's rule (isecure365 `useUnregisteredTab`): the
|
|
152
152
|
// site's Unregistered switch AND an ANPR camera facing an entrance (entry /
|
|
153
|
-
// both). With the switch alone the notice could appear for a site whose
|
|
153
|
+
// both / visitors). With the switch alone the notice could appear for a site whose
|
|
154
154
|
// cameras never register anyone arriving.
|
|
155
155
|
const { getAllSiteCameras } = useSiteSettings();
|
|
156
156
|
const hasEntryFacingAnprCamera = ref(false);
|
|
@@ -167,7 +167,7 @@ async function loadEntryFacingAnprCamera(siteId: string) {
|
|
|
167
167
|
? res.data.items
|
|
168
168
|
: [];
|
|
169
169
|
hasEntryFacingAnprCamera.value = items.some(
|
|
170
|
-
(camera: any) =>
|
|
170
|
+
(camera: any) => ["entry", "both", "visitors"].includes(camera?.direction)
|
|
171
171
|
);
|
|
172
172
|
} catch {
|
|
173
173
|
hasEntryFacingAnprCamera.value = false;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { checklistPdfName } from "../utils/schedule-task";
|
|
2
|
+
|
|
1
3
|
export default function useCleaningSchedules() {
|
|
2
4
|
const _cleanerChecklists = ref({
|
|
3
5
|
_id: "",
|
|
@@ -199,7 +201,8 @@ export default function useCleaningSchedules() {
|
|
|
199
201
|
window.URL.revokeObjectURL(downloadUrl);
|
|
200
202
|
}
|
|
201
203
|
|
|
202
|
-
|
|
204
|
+
/** `day`: the day the list is for (the row's createdAt), used to name the file. */
|
|
205
|
+
async function downloadChecklistPdf(scheduleId: string, day?: string | Date) {
|
|
203
206
|
const res = await useNuxtApp().$api<Blob | MediaSource>(
|
|
204
207
|
`/api/hygiene-area-checklist/schedule/${scheduleId}/pdf`,
|
|
205
208
|
{
|
|
@@ -210,17 +213,10 @@ export default function useCleaningSchedules() {
|
|
|
210
213
|
|
|
211
214
|
if (!res) throw new Error("Error downloading checklist PDF");
|
|
212
215
|
|
|
213
|
-
const date = new Date();
|
|
214
|
-
const formattedDate = `${String(date.getDate()).padStart(2, "0")}_${String(
|
|
215
|
-
date.getMonth() + 1,
|
|
216
|
-
).padStart(2, "0")}_${date.getFullYear()} ${String(
|
|
217
|
-
date.getHours(),
|
|
218
|
-
).padStart(2, "0")}_${String(date.getMinutes()).padStart(2, "0")}`;
|
|
219
|
-
|
|
220
216
|
const downloadUrl = window.URL.createObjectURL(res);
|
|
221
217
|
const a = document.createElement("a");
|
|
222
218
|
a.href = downloadUrl;
|
|
223
|
-
a.download =
|
|
219
|
+
a.download = checklistPdfName(day);
|
|
224
220
|
a.target = "_blank";
|
|
225
221
|
a.rel = "noopener noreferrer";
|
|
226
222
|
document.body.appendChild(a);
|
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.
|
|
5
|
+
"version": "4.148.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.",
|
package/utils/breadcrumb.ts
CHANGED
|
@@ -26,8 +26,9 @@ 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
|
-
|
|
30
|
-
["
|
|
29
|
+
// Extra Checks live inside the Task Calendar page since 24 Oct 2026.
|
|
30
|
+
["Schedule Task", "Task Calendar"],
|
|
31
|
+
["Extra Visits", "Task Calendar"],
|
|
31
32
|
...["Cleaning", "Technician", "Pest", "Pool", "Landscape"].flatMap((t): [string, string][] => [
|
|
32
33
|
[`${t} Schedule Area`, "Area"],
|
|
33
34
|
[`${t} Schedule Unit`, "Unit"],
|
package/utils/schedule-task.ts
CHANGED
|
@@ -405,3 +405,48 @@ export function historyUnitLabel(status: string | null | undefined): "Done" | "N
|
|
|
405
405
|
if (s === "rejected" || s === "needs redo") return "Needs redo";
|
|
406
406
|
return "Missed";
|
|
407
407
|
}
|
|
408
|
+
|
|
409
|
+
/** "Visit 2": one round of an area's units ("set" in the data). Same word as "Visits per day". */
|
|
410
|
+
export function visitLabel(set: number | string | null | undefined): string {
|
|
411
|
+
return set === null || set === undefined || set === "" ? "Visit" : `Visit ${set}`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Is this day's list closed (view only)? The day list says so when it is
|
|
416
|
+
* opened from there; opened from a link or after a refresh only the rows can
|
|
417
|
+
* tell, and at midnight the server closes every area and unit at once.
|
|
418
|
+
*/
|
|
419
|
+
export function isClosedDay(
|
|
420
|
+
dayStatus: string | null | undefined,
|
|
421
|
+
rows: { status?: string | null; units?: { status?: string | null }[] | null }[] | null | undefined = [],
|
|
422
|
+
): boolean {
|
|
423
|
+
const closed = (s: unknown) => String(s ?? "").trim().toLowerCase() === "closed";
|
|
424
|
+
if (closed(dayStatus)) return true;
|
|
425
|
+
return (rows ?? []).some((r) => closed(r?.status) || (r?.units ?? []).some((u) => closed(u?.status)));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** "Fri 24 Oct 2026" (Singapore), the day a list is for; "" when unknown. */
|
|
429
|
+
export function dayLabel(value: string | Date | null | undefined): string {
|
|
430
|
+
if (!value) return "";
|
|
431
|
+
const d = new Date(value);
|
|
432
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
433
|
+
return d.toLocaleDateString("en-SG", {
|
|
434
|
+
weekday: "short",
|
|
435
|
+
day: "numeric",
|
|
436
|
+
month: "short",
|
|
437
|
+
year: "numeric",
|
|
438
|
+
timeZone: "Asia/Singapore",
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The saved PDF's name: the DAY the list is for (Singapore), so three past
|
|
444
|
+
* days downloaded in a row do not all carry today's time.
|
|
445
|
+
* "Daily-Checklist-2026-10-24.pdf"; without a day, the old download-time name.
|
|
446
|
+
*/
|
|
447
|
+
export function checklistPdfName(day: string | Date | null | undefined, now: Date = new Date()): string {
|
|
448
|
+
const d = day ? new Date(day) : null;
|
|
449
|
+
if (d && !Number.isNaN(d.getTime())) return `Daily-Checklist-${sgtParts(d).date}.pdf`;
|
|
450
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
451
|
+
return `CHECKLIST_DOWNLOADED_${p(now.getDate())}_${p(now.getMonth() + 1)}_${now.getFullYear()} ${p(now.getHours())}_${p(now.getMinutes())}.pdf`;
|
|
452
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trade apps' Task Calendar (owner 24 Oct 2026): which tab to show, and the
|
|
3
|
+
* words of its one help box. Pure: no Vue, unit-tested with `node --test`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type TTaskCalendarTab = "agent" | "extra";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The tab to open: the address (?tab=) when the viewer may see it, else the
|
|
10
|
+
* page's own start tab (an old Extra Checks link starts on "extra"), else the
|
|
11
|
+
* first tab the viewer may see. "" = no tab at all (role has neither).
|
|
12
|
+
*/
|
|
13
|
+
export function pickTaskCalendarTab(
|
|
14
|
+
query: unknown,
|
|
15
|
+
initial: unknown,
|
|
16
|
+
allowed: { agent: boolean; extra: boolean },
|
|
17
|
+
): TTaskCalendarTab | "" {
|
|
18
|
+
const ok = (t: unknown): t is TTaskCalendarTab =>
|
|
19
|
+
(t === "agent" && allowed.agent) || (t === "extra" && allowed.extra);
|
|
20
|
+
const asked = Array.isArray(query) ? query[0] : query;
|
|
21
|
+
if (ok(asked)) return asked;
|
|
22
|
+
if (ok(initial)) return initial;
|
|
23
|
+
if (allowed.agent) return "agent";
|
|
24
|
+
if (allowed.extra) return "extra";
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One line per status word, only for the tabs the viewer sees. */
|
|
29
|
+
export function taskCalendarWords(show: { agent: boolean; extra: boolean }): { word: string; meaning: string; tone?: "info" | "err" }[] {
|
|
30
|
+
const words: { word: string; meaning: string; tone?: "info" | "err" }[] = [];
|
|
31
|
+
if (show.agent) {
|
|
32
|
+
words.push(
|
|
33
|
+
{ word: "Upcoming", meaning: "Managing agent job, not due yet.", tone: "info" },
|
|
34
|
+
{ word: "Overdue", meaning: "Managing agent job, due date passed and not marked done.", tone: "err" },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (show.extra) {
|
|
38
|
+
words.push(
|
|
39
|
+
{ word: "Active", meaning: "Extra check: it will be added at its next date and time." },
|
|
40
|
+
{ word: "Paused", meaning: "Extra check: stopped for now. Press Resume to start it again." },
|
|
41
|
+
{ word: "Finished", meaning: "Extra check: its last date has passed. Press \"Extend or repeat\" to keep it going." },
|
|
42
|
+
{ word: "In progress", meaning: "Last check: some units are ticked, the rest can still be done today." },
|
|
43
|
+
{ word: "Missed", meaning: "Last check: the day ended with units not done." },
|
|
44
|
+
{ word: "Needs redo", meaning: "Last check: a supervisor sent a unit back to be done again." },
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const done = [
|
|
48
|
+
show.agent ? "the managing agent marked the job done" : "",
|
|
49
|
+
show.extra ? "staff ticked every unit of the extra check" : "",
|
|
50
|
+
].filter(Boolean);
|
|
51
|
+
if (done.length) {
|
|
52
|
+
const text = done.join("; or ");
|
|
53
|
+
words.push({ word: "Done", meaning: `${text.charAt(0).toUpperCase()}${text.slice(1)}.` });
|
|
54
|
+
}
|
|
55
|
+
return words;
|
|
56
|
+
}
|