@7365admin1/layer-common 4.133.0 → 4.135.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.
@@ -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="680" persistent>
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-form ref="formRef" v-model="valid">
23
- <v-row dense>
24
- <v-col cols="12">
25
- <InputLabel for="title" title="Title" required />
26
- <v-text-field
27
- id="title"
28
- v-model="subject"
29
- variant="outlined"
30
- density="comfortable"
31
- class="filter-field mb-4"
32
- placeholder="Enter task title"
33
- hide-details="auto"
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
- </v-col>
36
-
37
- <v-col cols="12">
38
- <InputLabel for="date-range" title="Select Dates" required />
39
- <div class="task-form__panel mb-4">
40
- <v-date-picker
41
- v-model="pickerDates"
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
- :show-current="false"
44
- color="primary"
45
- elevation="0"
46
- width="100%"
47
- class="ma-0"
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="selectedDates.length > 0"
55
- class="d-flex flex-wrap ga-2"
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-chip
58
- v-for="date in selectedDates"
59
- :key="date"
60
- size="small"
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
- {{ date }}
65
- </v-chip>
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
- <div v-else class="task-form__hint">
68
- No dates selected. Click on the calendar to select dates.
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-col>
72
-
73
- <v-col cols="12">
74
- <InputLabel for="schedule-time" title="Time" required />
75
- <v-text-field
76
- id="schedule-time"
77
- v-model="scheduleTime"
78
- type="time"
79
- variant="outlined"
80
- density="comfortable"
81
- class="filter-field mb-4"
82
- hide-details="auto"
83
- prepend-inner-icon="mdi-clock-outline"
84
- />
85
- </v-col>
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
- <v-list-item
136
- rounded="0"
137
- class="px-3"
138
- @click="toggleArea(area.value || area._id)"
139
- >
140
- <template #prepend>
141
- <v-icon size="18" color="medium-emphasis">
142
- {{
143
- openAreas.includes(area.value || area._id)
144
- ? "mdi-chevron-down"
145
- : "mdi-chevron-right"
146
- }}
147
- </v-icon>
148
- </template>
149
-
150
- <v-list-item-title class="text-body-2 font-weight-medium">
151
- {{ area.name }}
152
- </v-list-item-title>
153
-
154
- <template #append>
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-row no-gutters>
246
- <v-col cols="6" class="pr-2">
247
- <v-btn
248
- block
249
- variant="outlined"
250
- class="text-none screen-btn-ghost"
251
- @click="close"
252
- :disabled="submitting"
253
- >
254
- Cancel
255
- </v-btn>
256
- </v-col>
257
- <v-col cols="6" class="pl-2">
258
- <v-btn
259
- block
260
- variant="flat"
261
- class="text-none screen-btn-primary"
262
- @click="submitTask"
263
- :loading="submitting"
264
- >
265
- {{ props.mode === "edit" ? "Save Changes" : "Add Schedule Task" }}
266
- </v-btn>
267
- </v-col>
268
- </v-row>
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
- const formRef = ref<HTMLFormElement | null>(null);
307
- const valid = ref(false);
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 selectedDates = computed(() =>
316
- pickerDates.value.map((d) => toLocalISO(d)).sort()
317
- );
421
+ const daysOfWeek = ref<number[]>([]);
422
+ const startDate = ref("");
423
+ const endDate = ref("");
318
424
 
319
425
  const selectedAreas = ref<any[]>([]);
320
- const loadingUnits = ref(false);
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
- const removeDate = (dateISO: string) => {
381
- pickerDates.value = pickerDates.value.filter(
382
- (d) => toLocalISO(d) !== dateISO
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
- const toggleArea = (id: string) => {
393
- const idx = openAreas.value.indexOf(id);
394
- if (idx === -1) {
395
- openAreas.value.push(id);
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
- const toISODate = (d: any) => {
419
- if (!d) return null;
420
- try {
421
- const dt = new Date(d);
422
- if (isNaN(dt.getTime())) return null;
423
- return dt.toISOString().slice(0, 10);
424
- } catch (e) {
425
- return null;
426
- }
427
- };
428
-
429
- // Handle dates array from task
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 || "Failed to load schedule task",
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
- `Schedule task ${
530
- props.mode === "edit" ? "updated" : "created"
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
- showMessage(
540
- error?.data?.message || "Failed to submit schedule task",
541
- "error"
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, taskData]) => {
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
- /* Was `<v-sheet border rounded color="grey-lighten-5">` - a fixed light grey
568
- that stayed light on the dark theme. The design has no calendar panel, so
569
- this is the estate's own card surface and border, nothing invented. */
570
- .task-form__panel {
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
- /* Replaces `text-caption text-disabled` / `text-medium-emphasis` / `text-grey`,
578
- three different Vuetify greys doing the same job.
579
-
580
- `--text2`, not `--muted`: the design is silent on this hint line, and `--muted`
581
- at 12px measured 3.24:1 on the light card. That pairing would have been OURS,
582
- not the design's, so it is fixed here with an existing token rather than
583
- recorded. `--text2` measures 8.2:1. */
584
- .task-form__hint {
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: 600;
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>