@7365admin1/layer-common 4.97.1-staging.577 → 4.97.1-staging.579
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/AreaChecklistHistoryLogs.vue +5 -4
- package/components/CleaningScheduleMain.vue +109 -14
- package/components/ManageChecklistMain.vue +16 -12
- package/components/ScheduleAreaMain.vue +56 -71
- package/components/ScheduleGuide.vue +132 -0
- package/components/ScheduleTaskForm.vue +520 -353
- package/components/ScheduleTaskMain.vue +211 -70
- package/components/TableHygiene.vue +2 -2
- package/composables/useCleaningSchedules.ts +9 -0
- package/composables/useScheduleTask.ts +32 -2
- package/nuxt.config.ts +4 -0
- package/package.json +1 -1
- package/plugins/API.ts +31 -1
- package/plugins/client-logs.client.ts +47 -0
- package/types/cleaner-schedule.d.ts +1 -1
- package/utils/client-log.ts +71 -0
- package/utils/schedule-task.ts +281 -0
- package/utils/status.ts +6 -0
- package/composables/useScheduleTaskArea.ts +0 -85
|
@@ -1,12 +1,17 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ADD / EDIT AN EXTRA VISIT - four short steps: What, Where, When, Who is told.
|
|
3
|
+
|
|
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:
|
|
6
|
+
Wed 21 Oct"). The form checks what the server checks (utils/schedule-task.ts)
|
|
7
|
+
so a schedule that would never run is caught before saving; whatever the
|
|
8
|
+
server still refuses is shown in its own words.
|
|
9
|
+
-->
|
|
1
10
|
<template>
|
|
2
|
-
<v-dialog v-model="show" max-width="
|
|
11
|
+
<v-dialog v-model="show" max-width="760" persistent scrollable>
|
|
3
12
|
<v-card class="screen-modal">
|
|
4
13
|
<v-card-title class="d-flex align-center">
|
|
5
|
-
{{
|
|
6
|
-
props.mode === "edit"
|
|
7
|
-
? "Edit Schedule Task"
|
|
8
|
-
: "Create Schedule Task"
|
|
9
|
-
}}
|
|
14
|
+
{{ props.mode === "edit" ? "Edit Extra Visit" : "Add Extra Visit" }}
|
|
10
15
|
<v-spacer />
|
|
11
16
|
<v-btn
|
|
12
17
|
icon="mdi-close"
|
|
@@ -19,253 +24,340 @@
|
|
|
19
24
|
</v-card-title>
|
|
20
25
|
|
|
21
26
|
<v-card-text class="screen-modal__body">
|
|
22
|
-
<v-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
<v-stepper
|
|
28
|
+
v-model="step"
|
|
29
|
+
flat
|
|
30
|
+
hide-actions
|
|
31
|
+
elevation="0"
|
|
32
|
+
class="st-stepper"
|
|
33
|
+
>
|
|
34
|
+
<v-stepper-header class="st-stepper__header">
|
|
35
|
+
<template v-for="(label, i) in STEP_LABELS" :key="label">
|
|
36
|
+
<v-stepper-item
|
|
37
|
+
:title="label"
|
|
38
|
+
:value="i + 1"
|
|
39
|
+
:complete="step > i + 1"
|
|
40
|
+
:error="showErrors && stepHasError(i + 1)"
|
|
41
|
+
editable
|
|
34
42
|
/>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
<v-divider v-if="i < STEP_LABELS.length - 1" />
|
|
44
|
+
</template>
|
|
45
|
+
</v-stepper-header>
|
|
46
|
+
|
|
47
|
+
<v-stepper-window>
|
|
48
|
+
<!-- 1. WHAT -->
|
|
49
|
+
<v-stepper-window-item :value="1">
|
|
50
|
+
<div class="st-step">
|
|
51
|
+
<p class="st-step__help">
|
|
52
|
+
Give the extra visit a name staff will recognise, and say
|
|
53
|
+
what they should do.
|
|
54
|
+
</p>
|
|
55
|
+
<InputLabel for="title" title="Title" required />
|
|
56
|
+
<v-text-field
|
|
57
|
+
id="title"
|
|
58
|
+
v-model="subject"
|
|
59
|
+
variant="outlined"
|
|
60
|
+
density="comfortable"
|
|
61
|
+
class="filter-field mb-4"
|
|
62
|
+
placeholder="For example: Midday toilet round"
|
|
63
|
+
:error-messages="fieldError('title')"
|
|
64
|
+
hide-details="auto"
|
|
65
|
+
/>
|
|
66
|
+
<InputLabel for="description" title="Instructions" />
|
|
67
|
+
<v-textarea
|
|
68
|
+
id="description"
|
|
69
|
+
v-model="description"
|
|
70
|
+
variant="outlined"
|
|
71
|
+
density="comfortable"
|
|
72
|
+
class="filter-field"
|
|
73
|
+
rows="3"
|
|
74
|
+
placeholder="Optional. Anything staff should know for this visit."
|
|
75
|
+
hide-details="auto"
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
</v-stepper-window-item>
|
|
79
|
+
|
|
80
|
+
<!-- 2. WHERE -->
|
|
81
|
+
<v-stepper-window-item :value="2">
|
|
82
|
+
<div class="st-step">
|
|
83
|
+
<p class="st-step__help">
|
|
84
|
+
Pick the areas that get the extra visit. Every unit in an
|
|
85
|
+
area is added to the checklist.
|
|
86
|
+
</p>
|
|
87
|
+
<InputLabel for="selected-areas" title="Areas" required />
|
|
88
|
+
<v-select
|
|
89
|
+
id="selected-areas"
|
|
90
|
+
v-model="selectedAreas"
|
|
91
|
+
:items="areasList"
|
|
92
|
+
item-title="name"
|
|
93
|
+
item-value="value"
|
|
94
|
+
return-object
|
|
42
95
|
multiple
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
96
|
+
variant="outlined"
|
|
97
|
+
density="comfortable"
|
|
98
|
+
class="filter-field mb-2"
|
|
99
|
+
:loading="loadingAreas"
|
|
100
|
+
placeholder="Choose one or more areas"
|
|
101
|
+
:error-messages="fieldError('areas')"
|
|
102
|
+
hide-details="auto"
|
|
103
|
+
chips
|
|
104
|
+
closable-chips
|
|
48
105
|
/>
|
|
49
|
-
<v-divider class="my-3" />
|
|
50
|
-
<div class="task-form__hint mb-2">
|
|
51
|
-
{{ selectedDates.length }} date(s) selected
|
|
52
|
-
</div>
|
|
53
106
|
<div
|
|
54
|
-
v-if="
|
|
55
|
-
class="
|
|
107
|
+
v-if="!loadingAreas && areasList.length === 0"
|
|
108
|
+
class="st-step__help"
|
|
109
|
+
>
|
|
110
|
+
There are no Areas on this site yet. Add Areas and Units
|
|
111
|
+
first, then come back.
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<v-list
|
|
115
|
+
v-if="selectedAreas.length"
|
|
116
|
+
density="compact"
|
|
117
|
+
class="st-areas"
|
|
56
118
|
>
|
|
57
|
-
<v-
|
|
58
|
-
v-for="
|
|
59
|
-
:key="
|
|
60
|
-
|
|
61
|
-
closable
|
|
62
|
-
@click:close="removeDate(date)"
|
|
119
|
+
<v-list-item
|
|
120
|
+
v-for="area in selectedAreas"
|
|
121
|
+
:key="areaKey(area)"
|
|
122
|
+
:title="area.name"
|
|
63
123
|
>
|
|
64
|
-
|
|
65
|
-
|
|
124
|
+
<template #append>
|
|
125
|
+
<v-progress-circular
|
|
126
|
+
v-if="!(areaKey(area) in fetchedAreaUnits)"
|
|
127
|
+
size="14"
|
|
128
|
+
width="2"
|
|
129
|
+
indeterminate
|
|
130
|
+
color="primary"
|
|
131
|
+
/>
|
|
132
|
+
<StatusChip
|
|
133
|
+
v-else-if="unitCountOf(area) === 0"
|
|
134
|
+
status="Paused"
|
|
135
|
+
:dot="false"
|
|
136
|
+
label="No units - skipped"
|
|
137
|
+
/>
|
|
138
|
+
<StatusChip
|
|
139
|
+
v-else
|
|
140
|
+
tone="neutral"
|
|
141
|
+
:dot="false"
|
|
142
|
+
:label="`${unitCountOf(area)} unit${
|
|
143
|
+
unitCountOf(area) === 1 ? '' : 's'
|
|
144
|
+
}`"
|
|
145
|
+
/>
|
|
146
|
+
</template>
|
|
147
|
+
</v-list-item>
|
|
148
|
+
</v-list>
|
|
149
|
+
<v-alert
|
|
150
|
+
v-if="emptyAreaNames.length"
|
|
151
|
+
type="warning"
|
|
152
|
+
variant="tonal"
|
|
153
|
+
density="compact"
|
|
154
|
+
class="mt-3"
|
|
155
|
+
>
|
|
156
|
+
{{ emptyAreaNames.join(", ") }}
|
|
157
|
+
{{ emptyAreaNames.length === 1 ? "has" : "have" }} no Units,
|
|
158
|
+
so nothing is added there. Add Units to the area first.
|
|
159
|
+
</v-alert>
|
|
160
|
+
</div>
|
|
161
|
+
</v-stepper-window-item>
|
|
162
|
+
|
|
163
|
+
<!-- 3. WHEN -->
|
|
164
|
+
<v-stepper-window-item :value="3">
|
|
165
|
+
<div class="st-step">
|
|
166
|
+
<p class="st-step__help">
|
|
167
|
+
Choose the time, and whether it happens once or repeats.
|
|
168
|
+
Times are Singapore time.
|
|
169
|
+
</p>
|
|
170
|
+
<v-row dense>
|
|
171
|
+
<v-col cols="12" sm="6">
|
|
172
|
+
<InputLabel for="schedule-time" title="Time" required />
|
|
173
|
+
<v-text-field
|
|
174
|
+
id="schedule-time"
|
|
175
|
+
v-model="scheduleTime"
|
|
176
|
+
type="time"
|
|
177
|
+
variant="outlined"
|
|
178
|
+
density="comfortable"
|
|
179
|
+
class="filter-field"
|
|
180
|
+
prepend-inner-icon="mdi-clock-outline"
|
|
181
|
+
:error-messages="fieldError('time')"
|
|
182
|
+
hide-details="auto"
|
|
183
|
+
/>
|
|
184
|
+
</v-col>
|
|
185
|
+
<v-col cols="12" sm="6">
|
|
186
|
+
<InputLabel for="repeat" title="Repeat" required />
|
|
187
|
+
<v-select
|
|
188
|
+
id="repeat"
|
|
189
|
+
v-model="repeat"
|
|
190
|
+
:items="REPEAT_OPTIONS"
|
|
191
|
+
item-title="title"
|
|
192
|
+
item-value="value"
|
|
193
|
+
variant="outlined"
|
|
194
|
+
density="comfortable"
|
|
195
|
+
class="filter-field"
|
|
196
|
+
hide-details="auto"
|
|
197
|
+
/>
|
|
198
|
+
</v-col>
|
|
199
|
+
</v-row>
|
|
200
|
+
|
|
201
|
+
<!-- Once: pick the day(s) -->
|
|
202
|
+
<div v-if="repeat === 'none'" class="mt-4">
|
|
203
|
+
<InputLabel title="Day" required />
|
|
204
|
+
<div class="st-panel">
|
|
205
|
+
<v-date-picker
|
|
206
|
+
v-model="pickerDates"
|
|
207
|
+
multiple
|
|
208
|
+
:min="today"
|
|
209
|
+
:show-current="false"
|
|
210
|
+
color="primary"
|
|
211
|
+
elevation="0"
|
|
212
|
+
width="100%"
|
|
213
|
+
class="ma-0"
|
|
214
|
+
/>
|
|
215
|
+
<div class="st-step__help mt-2">
|
|
216
|
+
{{ selectedDates.length }} day(s) selected.
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
<div v-if="fieldError('dates').length" class="st-error">
|
|
220
|
+
{{ fieldError("dates")[0] }}
|
|
221
|
+
</div>
|
|
66
222
|
</div>
|
|
67
|
-
|
|
68
|
-
|
|
223
|
+
|
|
224
|
+
<!-- Repeating -->
|
|
225
|
+
<div v-else class="mt-4">
|
|
226
|
+
<div v-if="repeat === 'weekly'" class="mb-4">
|
|
227
|
+
<InputLabel title="On these days" required />
|
|
228
|
+
<v-btn-toggle
|
|
229
|
+
v-model="daysOfWeek"
|
|
230
|
+
multiple
|
|
231
|
+
density="comfortable"
|
|
232
|
+
color="primary"
|
|
233
|
+
variant="outlined"
|
|
234
|
+
divided
|
|
235
|
+
>
|
|
236
|
+
<v-btn
|
|
237
|
+
v-for="d in WEEK_DAYS"
|
|
238
|
+
:key="d.value"
|
|
239
|
+
:value="d.value"
|
|
240
|
+
class="text-none"
|
|
241
|
+
>
|
|
242
|
+
{{ d.title }}
|
|
243
|
+
</v-btn>
|
|
244
|
+
</v-btn-toggle>
|
|
245
|
+
<div v-if="fieldError('daysOfWeek').length" class="st-error">
|
|
246
|
+
{{ fieldError("daysOfWeek")[0] }}
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
<v-row dense>
|
|
250
|
+
<v-col cols="12" sm="6">
|
|
251
|
+
<InputLabel for="start-date" title="Starts on" required />
|
|
252
|
+
<v-text-field
|
|
253
|
+
id="start-date"
|
|
254
|
+
v-model="startDate"
|
|
255
|
+
type="date"
|
|
256
|
+
:min="today"
|
|
257
|
+
variant="outlined"
|
|
258
|
+
density="comfortable"
|
|
259
|
+
class="filter-field"
|
|
260
|
+
:error-messages="fieldError('startDate')"
|
|
261
|
+
hide-details="auto"
|
|
262
|
+
/>
|
|
263
|
+
</v-col>
|
|
264
|
+
<v-col cols="12" sm="6">
|
|
265
|
+
<InputLabel for="end-date" title="Ends on (optional)" />
|
|
266
|
+
<v-text-field
|
|
267
|
+
id="end-date"
|
|
268
|
+
v-model="endDate"
|
|
269
|
+
type="date"
|
|
270
|
+
:min="startDate || today"
|
|
271
|
+
variant="outlined"
|
|
272
|
+
density="comfortable"
|
|
273
|
+
class="filter-field"
|
|
274
|
+
clearable
|
|
275
|
+
hint="Leave empty to keep repeating"
|
|
276
|
+
persistent-hint
|
|
277
|
+
:error-messages="fieldError('endDate')"
|
|
278
|
+
/>
|
|
279
|
+
</v-col>
|
|
280
|
+
</v-row>
|
|
281
|
+
<div v-if="repeat === 'monthly'" class="st-step__help mt-2">
|
|
282
|
+
Runs on the same day of the month as the start date. On
|
|
283
|
+
the 31st it runs on the last day of shorter months.
|
|
284
|
+
</div>
|
|
69
285
|
</div>
|
|
70
286
|
</div>
|
|
71
|
-
</v-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<v-col cols="12">
|
|
88
|
-
<InputLabel for="selected-areas" title="Select Areas" required />
|
|
89
|
-
<v-select
|
|
90
|
-
id="selected-areas"
|
|
91
|
-
v-model="selectedAreas"
|
|
92
|
-
:items="areasList"
|
|
93
|
-
item-title="name"
|
|
94
|
-
item-value="value"
|
|
95
|
-
return-object
|
|
96
|
-
multiple
|
|
97
|
-
variant="outlined"
|
|
98
|
-
density="comfortable"
|
|
99
|
-
class="filter-field mb-4"
|
|
100
|
-
:loading="loadingAreas"
|
|
101
|
-
placeholder="Choose one or more areas"
|
|
102
|
-
hide-details="auto"
|
|
103
|
-
chips
|
|
104
|
-
closable-chips
|
|
105
|
-
>
|
|
106
|
-
<template v-slot:selection="{ item, index }">
|
|
107
|
-
<v-chip
|
|
108
|
-
v-if="index < 3"
|
|
109
|
-
size="small"
|
|
110
|
-
closable
|
|
111
|
-
@click:close="selectedAreas.splice(index, 1)"
|
|
112
|
-
>
|
|
113
|
-
{{ item.title }}
|
|
114
|
-
</v-chip>
|
|
115
|
-
<span
|
|
116
|
-
v-if="index === 3"
|
|
117
|
-
class="task-form__hint align-self-center"
|
|
118
|
-
>
|
|
119
|
-
(+{{ selectedAreas.length - 3 }} others)
|
|
120
|
-
</span>
|
|
121
|
-
</template>
|
|
122
|
-
</v-select>
|
|
123
|
-
</v-col>
|
|
124
|
-
|
|
125
|
-
<v-col v-if="selectedAreas.length > 0" cols="12">
|
|
126
|
-
<InputLabel title="Units Under Selected Areas" />
|
|
127
|
-
<v-card variant="outlined" rounded class="mb-4">
|
|
128
|
-
<v-list density="compact" nav>
|
|
129
|
-
<template
|
|
130
|
-
v-for="(area, idx) in selectedAreas"
|
|
131
|
-
:key="area.value || area._id"
|
|
132
|
-
>
|
|
133
|
-
<v-divider v-if="idx > 0" />
|
|
287
|
+
</v-stepper-window-item>
|
|
288
|
+
|
|
289
|
+
<!-- 4. WHO IS TOLD + SUMMARY -->
|
|
290
|
+
<v-stepper-window-item :value="4">
|
|
291
|
+
<div class="st-step">
|
|
292
|
+
<v-alert
|
|
293
|
+
type="info"
|
|
294
|
+
variant="tonal"
|
|
295
|
+
density="comfortable"
|
|
296
|
+
class="mb-4"
|
|
297
|
+
title="Who is told"
|
|
298
|
+
>
|
|
299
|
+
Staff on this site get a push notification when the extra
|
|
300
|
+
visit is added to their checklist.
|
|
301
|
+
</v-alert>
|
|
134
302
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
<v-progress-circular
|
|
156
|
-
v-if="
|
|
157
|
-
loadingUnits &&
|
|
158
|
-
!(area.value || area._id in fetchedAreaUnits)
|
|
159
|
-
"
|
|
160
|
-
size="14"
|
|
161
|
-
width="2"
|
|
162
|
-
indeterminate
|
|
163
|
-
color="primary"
|
|
164
|
-
/>
|
|
165
|
-
<v-chip
|
|
166
|
-
v-else
|
|
167
|
-
size="x-small"
|
|
168
|
-
variant="tonal"
|
|
169
|
-
color="primary"
|
|
170
|
-
>
|
|
171
|
-
{{
|
|
172
|
-
(fetchedAreaUnits[area.value || area._id] || [])
|
|
173
|
-
.length
|
|
174
|
-
}}
|
|
175
|
-
</v-chip>
|
|
176
|
-
</template>
|
|
177
|
-
</v-list-item>
|
|
178
|
-
|
|
179
|
-
<v-expand-transition>
|
|
180
|
-
<div v-show="openAreas.includes(area.value || area._id)">
|
|
181
|
-
<v-card-text class="pt-1 pb-3 px-3">
|
|
182
|
-
<div
|
|
183
|
-
v-if="
|
|
184
|
-
loadingUnits &&
|
|
185
|
-
!(area.value || area._id in fetchedAreaUnits)
|
|
186
|
-
"
|
|
187
|
-
class="d-flex flex-wrap ga-2"
|
|
188
|
-
>
|
|
189
|
-
<v-skeleton-loader
|
|
190
|
-
v-for="n in 4"
|
|
191
|
-
:key="n"
|
|
192
|
-
type="chip"
|
|
193
|
-
width="80"
|
|
194
|
-
/>
|
|
195
|
-
</div>
|
|
196
|
-
|
|
197
|
-
<div
|
|
198
|
-
v-else-if="
|
|
199
|
-
(fetchedAreaUnits[area.value || area._id] || [])
|
|
200
|
-
.length > 0
|
|
201
|
-
"
|
|
202
|
-
class="d-flex flex-wrap ga-1"
|
|
203
|
-
>
|
|
204
|
-
<v-chip
|
|
205
|
-
v-for="unit in fetchedAreaUnits[
|
|
206
|
-
area.value || area._id
|
|
207
|
-
]"
|
|
208
|
-
:key="unit.unit"
|
|
209
|
-
size="small"
|
|
210
|
-
variant="tonal"
|
|
211
|
-
>
|
|
212
|
-
{{ unit.name }}
|
|
213
|
-
</v-chip>
|
|
214
|
-
</div>
|
|
215
|
-
|
|
216
|
-
<span v-else class="task-form__hint">
|
|
217
|
-
No units configured
|
|
218
|
-
</span>
|
|
219
|
-
</v-card-text>
|
|
220
|
-
</div>
|
|
221
|
-
</v-expand-transition>
|
|
222
|
-
</template>
|
|
223
|
-
</v-list>
|
|
224
|
-
</v-card>
|
|
225
|
-
</v-col>
|
|
226
|
-
|
|
227
|
-
<v-col cols="12">
|
|
228
|
-
<InputLabel for="description" title="Task Description" />
|
|
229
|
-
<v-textarea
|
|
230
|
-
id="description"
|
|
231
|
-
v-model="description"
|
|
232
|
-
variant="outlined"
|
|
233
|
-
density="comfortable"
|
|
234
|
-
class="filter-field mb-2"
|
|
235
|
-
rows="4"
|
|
236
|
-
placeholder="Add additional details about this task..."
|
|
237
|
-
hide-details="auto"
|
|
238
|
-
/>
|
|
239
|
-
</v-col>
|
|
240
|
-
</v-row>
|
|
241
|
-
</v-form>
|
|
303
|
+
<div class="st-summary">
|
|
304
|
+
<div class="st-summary__label">This will:</div>
|
|
305
|
+
<div class="st-summary__text">{{ summary }}</div>
|
|
306
|
+
</div>
|
|
307
|
+
<div v-if="fieldError('never').length" class="st-error mt-2">
|
|
308
|
+
{{ fieldError("never")[0] }}
|
|
309
|
+
</div>
|
|
310
|
+
<v-alert
|
|
311
|
+
v-if="serverError"
|
|
312
|
+
type="error"
|
|
313
|
+
variant="tonal"
|
|
314
|
+
density="comfortable"
|
|
315
|
+
class="mt-4"
|
|
316
|
+
>
|
|
317
|
+
{{ serverError }}
|
|
318
|
+
</v-alert>
|
|
319
|
+
</div>
|
|
320
|
+
</v-stepper-window-item>
|
|
321
|
+
</v-stepper-window>
|
|
322
|
+
</v-stepper>
|
|
242
323
|
</v-card-text>
|
|
243
324
|
|
|
244
325
|
<v-card-actions class="screen-modal__footer">
|
|
245
|
-
<v-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
326
|
+
<v-btn
|
|
327
|
+
variant="outlined"
|
|
328
|
+
class="text-none screen-btn-ghost"
|
|
329
|
+
:disabled="submitting"
|
|
330
|
+
@click="close"
|
|
331
|
+
>
|
|
332
|
+
Cancel
|
|
333
|
+
</v-btn>
|
|
334
|
+
<v-spacer />
|
|
335
|
+
<v-btn
|
|
336
|
+
v-if="step > 1"
|
|
337
|
+
variant="outlined"
|
|
338
|
+
class="text-none screen-btn-ghost"
|
|
339
|
+
:disabled="submitting"
|
|
340
|
+
@click="step -= 1"
|
|
341
|
+
>
|
|
342
|
+
Back
|
|
343
|
+
</v-btn>
|
|
344
|
+
<v-btn
|
|
345
|
+
v-if="step < STEP_LABELS.length"
|
|
346
|
+
variant="flat"
|
|
347
|
+
class="text-none screen-btn-primary"
|
|
348
|
+
@click="goNext"
|
|
349
|
+
>
|
|
350
|
+
Next
|
|
351
|
+
</v-btn>
|
|
352
|
+
<v-btn
|
|
353
|
+
v-else
|
|
354
|
+
variant="flat"
|
|
355
|
+
class="text-none screen-btn-primary"
|
|
356
|
+
:loading="submitting"
|
|
357
|
+
@click="submitTask"
|
|
358
|
+
>
|
|
359
|
+
{{ props.mode === "edit" ? "Save Changes" : "Add Extra Visit" }}
|
|
360
|
+
</v-btn>
|
|
269
361
|
</v-card-actions>
|
|
270
362
|
</v-card>
|
|
271
363
|
</v-dialog>
|
|
@@ -276,6 +368,15 @@
|
|
|
276
368
|
<script setup lang="ts">
|
|
277
369
|
import useAreas from "../composables/useAreas";
|
|
278
370
|
import useScheduleTask from "../composables/useScheduleTask";
|
|
371
|
+
import {
|
|
372
|
+
REPEAT_OPTIONS,
|
|
373
|
+
WEEK_DAYS,
|
|
374
|
+
buildSummary,
|
|
375
|
+
sgtToday,
|
|
376
|
+
validateSchedule,
|
|
377
|
+
type TRepeat,
|
|
378
|
+
type TScheduleErrors,
|
|
379
|
+
} from "../utils/schedule-task";
|
|
279
380
|
|
|
280
381
|
const props = defineProps({
|
|
281
382
|
mode: {
|
|
@@ -303,72 +404,33 @@ const { getAreas, getAreaById } = useAreas();
|
|
|
303
404
|
const { createScheduleTask, updateScheduleTask, getScheduleTaskById } =
|
|
304
405
|
useScheduleTask();
|
|
305
406
|
|
|
306
|
-
|
|
307
|
-
const
|
|
407
|
+
// --- state: everything is declared before any watcher or computed reads it ---
|
|
408
|
+
const STEP_LABELS = ["What", "Where", "When", "Who is told"];
|
|
409
|
+
const step = ref(1);
|
|
410
|
+
const showErrors = ref(false);
|
|
308
411
|
const submitting = ref(false);
|
|
309
412
|
const loadingTask = ref(false);
|
|
413
|
+
const serverError = ref("");
|
|
310
414
|
|
|
415
|
+
const today = sgtToday();
|
|
311
416
|
const subject = ref("");
|
|
312
417
|
const description = ref("");
|
|
313
418
|
const scheduleTime = ref("08:00");
|
|
419
|
+
const repeat = ref<TRepeat>("none");
|
|
314
420
|
const pickerDates = ref<Date[]>([]);
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
);
|
|
421
|
+
const daysOfWeek = ref<number[]>([]);
|
|
422
|
+
const startDate = ref("");
|
|
423
|
+
const endDate = ref("");
|
|
318
424
|
|
|
319
425
|
const selectedAreas = ref<any[]>([]);
|
|
320
|
-
const
|
|
426
|
+
const areasList = ref<any[]>([]);
|
|
321
427
|
const fetchedAreaUnits = ref<Record<string, { name: string; unit: string }[]>>(
|
|
322
|
-
{}
|
|
323
|
-
);
|
|
324
|
-
const openAreas = ref<string[]>([]);
|
|
325
|
-
|
|
326
|
-
const selectedAreaIds = computed(() =>
|
|
327
|
-
selectedAreas.value.map((a: any) => a.value || a._id)
|
|
428
|
+
{},
|
|
328
429
|
);
|
|
329
430
|
|
|
330
|
-
watch(selectedAreaIds, async (ids, prevIds) => {
|
|
331
|
-
openAreas.value = ids.slice();
|
|
332
|
-
|
|
333
|
-
const toFetch = ids.filter(
|
|
334
|
-
(id) => !prevIds?.includes(id) && !(id in fetchedAreaUnits.value)
|
|
335
|
-
);
|
|
336
|
-
|
|
337
|
-
if (!toFetch.length) return;
|
|
338
|
-
|
|
339
|
-
loadingUnits.value = true;
|
|
340
|
-
try {
|
|
341
|
-
await Promise.all(
|
|
342
|
-
toFetch.map(async (id) => {
|
|
343
|
-
try {
|
|
344
|
-
const result = await getAreaById(id);
|
|
345
|
-
fetchedAreaUnits.value[id] = result?.units || [];
|
|
346
|
-
} catch {
|
|
347
|
-
fetchedAreaUnits.value[id] = [];
|
|
348
|
-
}
|
|
349
|
-
})
|
|
350
|
-
);
|
|
351
|
-
} finally {
|
|
352
|
-
loadingUnits.value = false;
|
|
353
|
-
}
|
|
354
|
-
});
|
|
355
|
-
|
|
356
431
|
const message = ref("");
|
|
357
432
|
const messageSnackbar = ref(false);
|
|
358
433
|
const messageColor = ref("success");
|
|
359
|
-
const areasList = ref<any[]>([]);
|
|
360
|
-
|
|
361
|
-
const generateDateRange = (start: Date, end: Date): Date[] => {
|
|
362
|
-
const dates: Date[] = [];
|
|
363
|
-
const current = new Date(start);
|
|
364
|
-
|
|
365
|
-
while (current <= end) {
|
|
366
|
-
dates.push(new Date(current));
|
|
367
|
-
current.setDate(current.getDate() + 1);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
return dates;
|
|
371
|
-
};
|
|
372
434
|
|
|
373
435
|
const toLocalISO = (d: Date): string => {
|
|
374
436
|
const y = d.getFullYear();
|
|
@@ -377,11 +439,53 @@ const toLocalISO = (d: Date): string => {
|
|
|
377
439
|
return `${y}-${m}-${day}`;
|
|
378
440
|
};
|
|
379
441
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
);
|
|
442
|
+
// A date the server sent ("2026-10-21") as a local calendar day, with no time zone shift.
|
|
443
|
+
const fromISODate = (s: string) => {
|
|
444
|
+
const [y, m, d] = s.split("-").map(Number);
|
|
445
|
+
return new Date(y, m - 1, d);
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const selectedDates = computed(() =>
|
|
449
|
+
pickerDates.value.map((d) => toLocalISO(d)).sort(),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const areaKey = (a: any): string => a.value || a._id;
|
|
453
|
+
const unitCountOf = (a: any) => (fetchedAreaUnits.value[areaKey(a)] || []).length;
|
|
454
|
+
const emptyAreaNames = computed(() =>
|
|
455
|
+
selectedAreas.value
|
|
456
|
+
.filter(
|
|
457
|
+
(a) => areaKey(a) in fetchedAreaUnits.value && unitCountOf(a) === 0,
|
|
458
|
+
)
|
|
459
|
+
.map((a) => a.name),
|
|
460
|
+
);
|
|
461
|
+
const totalUnits = computed(() =>
|
|
462
|
+
selectedAreas.value.reduce((n, a) => n + unitCountOf(a), 0),
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const formState = computed(() => ({
|
|
466
|
+
title: subject.value,
|
|
467
|
+
time: scheduleTime.value,
|
|
468
|
+
repeat: repeat.value,
|
|
469
|
+
dates: selectedDates.value,
|
|
470
|
+
daysOfWeek: daysOfWeek.value,
|
|
471
|
+
startDate: startDate.value,
|
|
472
|
+
endDate: repeat.value === "none" ? "" : endDate.value || "",
|
|
473
|
+
areaCount: selectedAreas.value.length,
|
|
474
|
+
}));
|
|
475
|
+
|
|
476
|
+
const errors = computed<TScheduleErrors>(() => validateSchedule(formState.value));
|
|
477
|
+
const summary = computed(() => buildSummary(formState.value, totalUnits.value));
|
|
478
|
+
|
|
479
|
+
const STEP_FIELDS: Record<number, (keyof TScheduleErrors)[]> = {
|
|
480
|
+
1: ["title"],
|
|
481
|
+
2: ["areas"],
|
|
482
|
+
3: ["time", "dates", "daysOfWeek", "startDate", "endDate"],
|
|
483
|
+
4: ["never"],
|
|
384
484
|
};
|
|
485
|
+
const stepHasError = (n: number) =>
|
|
486
|
+
STEP_FIELDS[n].some((f) => !!errors.value[f]);
|
|
487
|
+
const fieldError = (f: keyof TScheduleErrors): string[] =>
|
|
488
|
+
showErrors.value && errors.value[f] ? [errors.value[f] as string] : [];
|
|
385
489
|
|
|
386
490
|
const showMessage = (text: string, color: string = "success") => {
|
|
387
491
|
message.value = text;
|
|
@@ -389,23 +493,53 @@ const showMessage = (text: string, color: string = "success") => {
|
|
|
389
493
|
messageSnackbar.value = true;
|
|
390
494
|
};
|
|
391
495
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
} else {
|
|
397
|
-
openAreas.value.splice(idx, 1);
|
|
496
|
+
function goNext() {
|
|
497
|
+
if (stepHasError(step.value)) {
|
|
498
|
+
showErrors.value = true;
|
|
499
|
+
return;
|
|
398
500
|
}
|
|
399
|
-
|
|
501
|
+
step.value += 1;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// --- watchers (after the state above) ---
|
|
505
|
+
watch(repeat, (r) => {
|
|
506
|
+
// A repeat needs a start day; default it to today so the form is valid straight away.
|
|
507
|
+
if (r !== "none" && !startDate.value) startDate.value = today;
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const selectedAreaIds = computed(() => selectedAreas.value.map(areaKey));
|
|
511
|
+
|
|
512
|
+
watch(selectedAreaIds, async (ids, prevIds) => {
|
|
513
|
+
const toFetch = ids.filter(
|
|
514
|
+
(id) => !prevIds?.includes(id) && !(id in fetchedAreaUnits.value),
|
|
515
|
+
);
|
|
516
|
+
if (!toFetch.length) return;
|
|
517
|
+
await Promise.all(
|
|
518
|
+
toFetch.map(async (id) => {
|
|
519
|
+
try {
|
|
520
|
+
const result = await getAreaById(id);
|
|
521
|
+
fetchedAreaUnits.value[id] = result?.units || [];
|
|
522
|
+
} catch {
|
|
523
|
+
fetchedAreaUnits.value[id] = [];
|
|
524
|
+
}
|
|
525
|
+
}),
|
|
526
|
+
);
|
|
527
|
+
});
|
|
400
528
|
|
|
401
529
|
const resetForm = () => {
|
|
530
|
+
step.value = 1;
|
|
531
|
+
showErrors.value = false;
|
|
532
|
+
serverError.value = "";
|
|
402
533
|
subject.value = "";
|
|
403
534
|
description.value = "";
|
|
404
535
|
scheduleTime.value = "08:00";
|
|
536
|
+
repeat.value = "none";
|
|
405
537
|
pickerDates.value = [];
|
|
538
|
+
daysOfWeek.value = [];
|
|
539
|
+
startDate.value = "";
|
|
540
|
+
endDate.value = "";
|
|
406
541
|
selectedAreas.value = [];
|
|
407
542
|
fetchedAreaUnits.value = {};
|
|
408
|
-
openAreas.value = [];
|
|
409
543
|
};
|
|
410
544
|
|
|
411
545
|
const setFormFromTask = (task: any) => {
|
|
@@ -414,28 +548,23 @@ const setFormFromTask = (task: any) => {
|
|
|
414
548
|
subject.value = task.title || "";
|
|
415
549
|
description.value = task.description || "";
|
|
416
550
|
scheduleTime.value = task.time || "08:00";
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (task.dates && Array.isArray(task.dates) && task.dates.length > 0) {
|
|
431
|
-
pickerDates.value = task.dates.map((d: string) => new Date(d));
|
|
432
|
-
} else if (task.startDate || task.endDate) {
|
|
433
|
-
// Fallback for old format (startDate/endDate)
|
|
434
|
-
const start = toISODate(task.startDate || task.start);
|
|
435
|
-
const end = toISODate(task.endDate || task.end);
|
|
436
|
-
if (start && end) {
|
|
437
|
-
pickerDates.value = generateDateRange(new Date(start), new Date(end));
|
|
551
|
+
repeat.value = (task.repeat || "none") as TRepeat;
|
|
552
|
+
daysOfWeek.value = Array.isArray(task.daysOfWeek) ? [...task.daysOfWeek] : [];
|
|
553
|
+
startDate.value = task.startDate || "";
|
|
554
|
+
endDate.value = task.endDate || "";
|
|
555
|
+
|
|
556
|
+
if (Array.isArray(task.dates) && task.dates.length > 0) {
|
|
557
|
+
pickerDates.value = task.dates.map(fromISODate);
|
|
558
|
+
} else if (repeat.value === "none" && task.startDate && task.endDate) {
|
|
559
|
+
// Oldest format: a start..end range. Expand it to the days it meant.
|
|
560
|
+
const days: Date[] = [];
|
|
561
|
+
const last = fromISODate(task.endDate);
|
|
562
|
+
for (const d = fromISODate(task.startDate); d <= last; d.setDate(d.getDate() + 1)) {
|
|
563
|
+
days.push(new Date(d));
|
|
438
564
|
}
|
|
565
|
+
pickerDates.value = days;
|
|
566
|
+
startDate.value = "";
|
|
567
|
+
endDate.value = "";
|
|
439
568
|
}
|
|
440
569
|
|
|
441
570
|
selectedAreas.value = Array.isArray(task.areas)
|
|
@@ -454,7 +583,7 @@ const { data: getAreasReq, pending: loadingAreas } = await useLazyAsyncData(
|
|
|
454
583
|
{
|
|
455
584
|
watch: [show, () => props.site],
|
|
456
585
|
immediate: false,
|
|
457
|
-
}
|
|
586
|
+
},
|
|
458
587
|
);
|
|
459
588
|
|
|
460
589
|
watchEffect(() => {
|
|
@@ -488,8 +617,8 @@ const loadTaskData = async () => {
|
|
|
488
617
|
} catch (error: any) {
|
|
489
618
|
if ((props.taskData as any)?.title) setFormFromTask(props.taskData);
|
|
490
619
|
showMessage(
|
|
491
|
-
error?.data?.message || "
|
|
492
|
-
"error"
|
|
620
|
+
error?.data?.message || "Could not load this extra visit.",
|
|
621
|
+
"error",
|
|
493
622
|
);
|
|
494
623
|
} finally {
|
|
495
624
|
loadingTask.value = false;
|
|
@@ -497,17 +626,31 @@ const loadTaskData = async () => {
|
|
|
497
626
|
};
|
|
498
627
|
|
|
499
628
|
const submitTask = async () => {
|
|
629
|
+
serverError.value = "";
|
|
630
|
+
if (Object.keys(errors.value).length) {
|
|
631
|
+
// Jump to the first step with a problem and show it.
|
|
632
|
+
showErrors.value = true;
|
|
633
|
+
step.value = [1, 2, 3, 4].find(stepHasError) || step.value;
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
500
637
|
submitting.value = true;
|
|
501
638
|
try {
|
|
639
|
+
const repeating = repeat.value !== "none";
|
|
502
640
|
const payload = {
|
|
503
|
-
title: subject.value,
|
|
641
|
+
title: subject.value.trim(),
|
|
504
642
|
time: scheduleTime.value,
|
|
505
|
-
dates: selectedDates.value, // Array of YYYY-MM-DD strings
|
|
506
643
|
description: description.value?.trim() || "",
|
|
507
644
|
areas: selectedAreas.value.map((area: any) => ({
|
|
508
645
|
name: area.name || area.title || "",
|
|
509
646
|
value: area._id || area.value || area,
|
|
510
647
|
})),
|
|
648
|
+
repeat: repeat.value,
|
|
649
|
+
// null clears a field the server may already hold (edit); on create it is ignored.
|
|
650
|
+
dates: repeating ? [] : selectedDates.value,
|
|
651
|
+
daysOfWeek: repeat.value === "weekly" ? daysOfWeek.value : null,
|
|
652
|
+
startDate: repeating ? startDate.value : null,
|
|
653
|
+
endDate: repeating && endDate.value ? endDate.value : null,
|
|
511
654
|
};
|
|
512
655
|
|
|
513
656
|
let response;
|
|
@@ -526,20 +669,18 @@ const submitTask = async () => {
|
|
|
526
669
|
|
|
527
670
|
showMessage(
|
|
528
671
|
response?.message ||
|
|
529
|
-
`
|
|
530
|
-
|
|
531
|
-
} successfully`,
|
|
532
|
-
"success"
|
|
672
|
+
`Extra visit ${props.mode === "edit" ? "updated" : "added"}.`,
|
|
673
|
+
"success",
|
|
533
674
|
);
|
|
534
675
|
|
|
535
676
|
emit("saved");
|
|
536
677
|
emit("refresh");
|
|
537
678
|
close();
|
|
538
679
|
} catch (error: any) {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
);
|
|
680
|
+
// The server's own plain-English message (permission, validation, ...).
|
|
681
|
+
serverError.value =
|
|
682
|
+
error?.data?.message || "Could not save this extra visit. Please try again.";
|
|
683
|
+
showMessage(serverError.value, "error");
|
|
543
684
|
} finally {
|
|
544
685
|
submitting.value = false;
|
|
545
686
|
}
|
|
@@ -552,38 +693,64 @@ const close = () => {
|
|
|
552
693
|
|
|
553
694
|
watch(
|
|
554
695
|
[show, () => props.mode, () => props.taskData],
|
|
555
|
-
async ([isOpen, mode
|
|
696
|
+
async ([isOpen, mode]) => {
|
|
556
697
|
if (isOpen && mode === "edit") {
|
|
557
698
|
await loadTaskData();
|
|
558
699
|
} else if (!isOpen) {
|
|
559
700
|
resetForm();
|
|
560
701
|
}
|
|
561
702
|
},
|
|
562
|
-
{ immediate: false }
|
|
703
|
+
{ immediate: false },
|
|
563
704
|
);
|
|
564
705
|
</script>
|
|
565
706
|
|
|
566
707
|
<style scoped>
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
.
|
|
708
|
+
.st-step {
|
|
709
|
+
padding: 16px 4px 4px;
|
|
710
|
+
}
|
|
711
|
+
.st-step__help {
|
|
712
|
+
margin: 0 0 12px;
|
|
713
|
+
font-size: var(--fs-chip-lg);
|
|
714
|
+
font-weight: 600;
|
|
715
|
+
color: var(--text2);
|
|
716
|
+
}
|
|
717
|
+
.st-error {
|
|
718
|
+
margin-top: 6px;
|
|
719
|
+
font-size: var(--fs-chip-lg);
|
|
720
|
+
font-weight: 600;
|
|
721
|
+
color: rgb(var(--v-theme-error));
|
|
722
|
+
}
|
|
723
|
+
.st-panel {
|
|
571
724
|
padding: 16px;
|
|
572
725
|
border: 1px solid var(--border);
|
|
573
726
|
border-radius: var(--r-inner);
|
|
574
727
|
background: var(--card);
|
|
575
728
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
729
|
+
.st-areas {
|
|
730
|
+
margin-top: 8px;
|
|
731
|
+
border: 1px solid var(--border);
|
|
732
|
+
border-radius: var(--r-inner);
|
|
733
|
+
background: transparent;
|
|
734
|
+
}
|
|
735
|
+
.st-summary {
|
|
736
|
+
padding: 16px;
|
|
737
|
+
border: 1px solid var(--border);
|
|
738
|
+
border-radius: var(--r-inner);
|
|
739
|
+
background: var(--card);
|
|
740
|
+
}
|
|
741
|
+
.st-summary__label {
|
|
742
|
+
margin-bottom: 4px;
|
|
585
743
|
font-size: var(--fs-chip-lg);
|
|
586
|
-
font-weight:
|
|
744
|
+
font-weight: 700;
|
|
587
745
|
color: var(--text2);
|
|
588
746
|
}
|
|
747
|
+
.st-summary__text {
|
|
748
|
+
font-size: 15px;
|
|
749
|
+
font-weight: 600;
|
|
750
|
+
}
|
|
751
|
+
.st-stepper,
|
|
752
|
+
.st-stepper__header {
|
|
753
|
+
background: transparent;
|
|
754
|
+
box-shadow: none;
|
|
755
|
+
}
|
|
589
756
|
</style>
|