@7365admin1/layer-common 1.10.4 → 1.10.5

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.
@@ -0,0 +1,600 @@
1
+ <template>
2
+ <v-card width="100%" :loading="processing">
3
+ <v-toolbar>
4
+ <v-row no-gutters class="fill-height px-6 d-flex ga-2 justify-space-between" align="center">
5
+ <span class="font-weight-bold text-h5 text-capitalize">
6
+ {{ prop.mode }} Vehicle <span>({{ formatVehicleType(type) }})</span>
7
+ </span>
8
+ <span>
9
+ <ButtonClose @click="emit('close:all')" icon-only />
10
+ </span>
11
+ </v-row>
12
+ </v-toolbar>
13
+
14
+ <v-card-text style="max-height: 100vh; overflow-y: auto" class="pa-5 my-3">
15
+ <span class="text-subtitle-1 w-100 font-weight-bold mb-3">General Information</span>
16
+ <v-form ref="formRef" v-model="validForm" :disabled="processing" @click="errorMessage = ''">
17
+ <v-row no-gutters class="pt-4">
18
+ <v-col v-if="shouldShowField('seasonPassType')" cols="12">
19
+ <InputLabel class="text-capitalize" title="Season Pass Type" required />
20
+ <v-combobox v-model.trim="vehicle.seasonPassType" v-model:search="seasonPassTypeInput" :hide-no-data="false"
21
+ @update:modelValue="onSeasonPassTypeSelected" :items="seasonPassTypeArray" :rules="[requiredRule]"
22
+ item-title="title" item-value="value" variant="outlined" density="comfortable" persistent-hint
23
+ small-chips>
24
+ <template v-slot:no-data>
25
+ <v-list-item>
26
+ <v-list-item-title>
27
+ No results matching "<strong>{{ seasonPassTypeInput }}</strong>". This value will be added as new
28
+ option.
29
+ </v-list-item-title>
30
+ </v-list-item>
31
+ </template>
32
+ </v-combobox>
33
+ </v-col>
34
+
35
+ <v-col v-if="shouldShowField('name')" cols="12">
36
+ <v-row>
37
+ <v-col cols="12">
38
+ <InputLabel class="text-capitalize" title="Full Name" required />
39
+ <v-text-field v-model.trim="vehicle.name" density="comfortable" :rules="[requiredRule]" />
40
+ </v-col>
41
+ </v-row>
42
+ </v-col>
43
+
44
+ <v-col v-if="shouldShowField('nric')" cols="12">
45
+ <InputLabel class="text-capitalize" title="NRIC" required />
46
+ <InputNRICNumber v-model="vehicle.nric" density="comfortable" :rules="[requiredRule]" />
47
+ </v-col>
48
+
49
+ <v-col v-if="shouldShowField('phone')" cols="12">
50
+ <InputLabel class="text-capitalize" title="Phone Number" required />
51
+ <InputPhoneNumberV2 v-model="vehicle.phoneNumber" density="comfortable" :rules="[requiredRule]" />
52
+ </v-col>
53
+
54
+ <v-col v-if="shouldShowField('block')" cols="12">
55
+ <InputLabel class="text-capitalize" title="Block" required />
56
+ <v-select v-model="vehicle.block" :items="blocksArray" item-value="value" item-title="title"
57
+ @update:model-value="handleChangeBlock" density="comfortable" :rules="[requiredRule]" />
58
+ </v-col>
59
+
60
+ <v-col v-if="shouldShowField('level')" cols="12">
61
+ <InputLabel class="text-capitalize" title="Level" required />
62
+ <v-select v-model="vehicle.level" :items="levelsArray" density="comfortable" :disabled="!vehicle.block"
63
+ @update:model-value="handleChangeLevel" :rules="[requiredRule]" />
64
+ </v-col>
65
+
66
+ <v-col v-if="shouldShowField('unit')" cols="12">
67
+ <InputLabel class="text-capitalize" title="Unit" required />
68
+ <v-select v-model="vehicle.unit" :items="unitsArray" density="comfortable" :disabled="!vehicle.level"
69
+ :rules="[requiredRule]" />
70
+ </v-col>
71
+
72
+ <v-col v-if="shouldShowField('plateNumber')" cols="12">
73
+ <InputLabel class="text-capitalize" title="Vehicle Number" required />
74
+ <!-- <v-text-field v-model="vehicle.plateNumber" density="comfortable" :rules="[requiredRule]" /> -->
75
+ <InputVehicleNumber v-model="vehicle.plateNumber" density="comfortable" :rules="[requiredRule]" />
76
+ </v-col>
77
+
78
+ <v-col v-if="shouldShowField('remarks')" cols="12">
79
+ <InputLabel class="text-capitalize" title="Remarks" required />
80
+ <v-textarea v-model="vehicle.remarks" density="comfortable" :rows="3" no-resize :rules="[requiredRule]" />
81
+ </v-col>
82
+
83
+ <v-col v-if="shouldShowField('start')" cols="12">
84
+ <v-expand-transition v-if="showSubscriptionDateOptions">
85
+ <v-row no-gutters>
86
+ <v-row dense justify="space-between">
87
+ <template v-for="option in subscriptionOptions" :key="option.value">
88
+ <v-col cols="4">
89
+ <v-btn :text="option.label" class="text-capitalize" min-width="120" :ripple="false"
90
+ :class="[option.value === selectedSubscriptionDuration ? 'button-outline-class' : '']"
91
+ @click="selectedSubscriptionDuration = option.value" />
92
+ </v-col>
93
+
94
+ </template>
95
+ </v-row>
96
+
97
+ <v-row no-gutters class="mt-5">
98
+ <v-col cols="12">
99
+ <InputLabel class="text-capitalize" title="Start Date" required />
100
+ <InputDateTimePicker ref="startDateRef" v-model="vehicle.start" :rules="[validStartDateRule]" />
101
+ </v-col>
102
+
103
+ <v-col cols="12">
104
+ <InputLabel class="text-capitalize" title="Expiry Date" required />
105
+ <InputDateTimePicker ref="expiryDateRef" v-model="vehicle.end" :rules="[validExpiryDateRule]" />
106
+ </v-col>
107
+ </v-row>
108
+ </v-row>
109
+
110
+
111
+ </v-expand-transition>
112
+
113
+ <v-col cols="12" :class="[showSubscriptionDateOptions && 'mt-5']">
114
+ <v-row justify="center">
115
+ <v-col cols="6">
116
+ <v-btn block color="primary" variant="text" class="text-none font-weight-bold"
117
+ :text="showSubscriptionDateOptions ? 'Hide Subscription Options' : 'Show Subscription Options'"
118
+ @click="showSubscriptionDateOptions = !showSubscriptionDateOptions"></v-btn>
119
+ </v-col>
120
+ </v-row>
121
+ </v-col>
122
+ </v-col>
123
+
124
+ <v-col cols="12">
125
+ <v-row no-gutters>
126
+ <v-col cols="12" class="text-center">
127
+ <span class="text-none text-subtitle-2 font-weight-medium text-error">
128
+ {{ message }}
129
+ </span>
130
+ </v-col>
131
+ </v-row>
132
+ </v-col>
133
+ </v-row>
134
+ </v-form>
135
+ </v-card-text>
136
+ <v-row no-gutters class="w-100" v-if="errorMessage">
137
+ <p class="text-error w-100 text-center text-subtitle-2">{{ errorMessage }}</p>
138
+ </v-row>
139
+ <v-toolbar density="compact">
140
+ <v-row no-gutters>
141
+ <v-col cols="6">
142
+ <v-btn v-if="prop.mode === 'add'" tile block variant="text" class="text-none" size="48" @click="back"
143
+ text="Back to Selection" />
144
+ <v-btn v-else tile block variant="text" class="text-none" size="48" @click="close" text="Close" />
145
+ </v-col>
146
+ <v-col cols="6">
147
+ <v-btn tile block variant="flat" color="black" class="text-none" size="48"
148
+ :disabled="!validForm || processing" @click="submit" :text="prop.mode == 'add' ? 'Submit' : 'Update'" />
149
+ </v-col>
150
+ </v-row>
151
+ </v-toolbar>
152
+ </v-card>
153
+ </template>
154
+
155
+ <script setup lang="ts">
156
+
157
+
158
+
159
+ const prop = defineProps({
160
+ type: {
161
+ type: String as PropType<TVehicleType>,
162
+ required: true
163
+ },
164
+ org: {
165
+ type: String,
166
+ required: true
167
+ },
168
+ site: {
169
+ type: String,
170
+ required: true
171
+ },
172
+ mode: {
173
+ type: String as PropType<'add' | 'edit'>,
174
+ default: 'add'
175
+ },
176
+ vehicleData: {
177
+ type: Object as PropType<Partial<TVehicle> | null>,
178
+ default: null
179
+ }
180
+ });
181
+
182
+ const { requiredRule, formatDateISO8601, debounce } = useUtils();
183
+ const { addVehicle, getCustomSeasonPassTypes, updateVehicle, getVehicleByNRIC } = useVehicle();
184
+ const { getSiteById, getSiteLevels, getSiteUnits } = useSiteSettings();
185
+
186
+ const emit = defineEmits(['back', 'select', 'done', 'error', 'close', 'close:all']);
187
+
188
+
189
+ const vehicle = reactive<Partial<TVehicle>>({
190
+ plateNumber: '',
191
+ type: prop.type,
192
+ category: "resident",
193
+ name: '',
194
+ phoneNumber: '',
195
+ block: '',
196
+ level: '',
197
+ unit: '',
198
+ nric: '',
199
+ remarks: '',
200
+ seasonPassType: '',
201
+ start: '',
202
+ end: '',
203
+ site: prop.site,
204
+ org: prop.org,
205
+ _id: '',
206
+ });
207
+
208
+
209
+
210
+ const blocksArray = ref<TDefaultOptionObj[]>([]);
211
+ const levelsArray = ref<TDefaultOptionObj[]>([]);
212
+ const unitsArray = ref<TDefaultOptionObj[]>([]);
213
+ const seasonPassTypeArray = ref<{ title: string, value: string }[]>([]);
214
+
215
+ const matchingVehicles = ref<TVehicle[]>([]);
216
+ const showVehicleMatchDialog = ref(false);
217
+ const checkingNRIC = ref(false);
218
+
219
+ const defaultSeasonPassTypeArray = computed(() => {
220
+ return [
221
+ 'Resident Season Pass',
222
+ 'Tenant/Leaseholder Pass',
223
+ 'VIP/Preferred Parking Pass',
224
+ 'Visitor Season Pass',
225
+ 'Staff & Service Provider Pass',
226
+ 'Business Tenant Pass',
227
+ ].map((item) => ({
228
+ title: item,
229
+ value: item
230
+ }))
231
+ });
232
+
233
+
234
+ const typeFieldMap: Record<TVehicleType, string[]> = {
235
+ seasonpass: ['seasonPassType', 'name', 'phone', 'plateNumber', 'block', 'level', 'unit', 'start', 'end'],
236
+ blocklist: ['name', 'nric', 'phone', 'plateNumber', 'remarks'],
237
+ whitelist: ['name', 'nric', 'phone', 'plateNumber', 'block', 'level', 'unit']
238
+ };
239
+
240
+ const shouldShowField = (fieldKey: string): boolean => {
241
+ const visibleFields = typeFieldMap[prop.type as TVehicleType];
242
+ return visibleFields?.includes(fieldKey);
243
+ };
244
+
245
+
246
+ const validForm = ref(false);
247
+ const seasonPassTypeInput = ref('')
248
+ const formRef = ref<HTMLFormElement | null>(null);
249
+ const startDateRef = ref<HTMLInputElement | null>(null)
250
+ const expiryDateRef = ref<HTMLInputElement | null>(null)
251
+ const processing = ref(false);
252
+ const message = ref('');
253
+ const selectedSubscriptionDuration = ref<string | null>('custom')
254
+ const showSubscriptionDateOptions = ref(false)
255
+ const errorMessage = ref('');
256
+
257
+
258
+ // fetch existing vehicle data if in edit mode
259
+ if (prop.mode === 'edit') {
260
+ const existingVehicleData = JSON.parse(JSON.stringify(prop.vehicleData || {}));
261
+ Object.assign(vehicle, existingVehicleData);
262
+ }
263
+
264
+
265
+ const { data: siteData, refresh: refreshSiteData } = useLazyAsyncData(
266
+ `fetch-site-data-${prop.site}`,
267
+ async () => await getSiteById(prop.site));
268
+
269
+ const { data: levelsData, refresh: refreshLevelsData } = useLazyAsyncData(
270
+ `fetch-levels-data-${prop.site}-${vehicle.block}`,
271
+ async () => {
272
+ if (!vehicle.block) return Promise.resolve(null);;
273
+ return await getSiteLevels(prop.site, { block: Number(vehicle.block) })
274
+ });
275
+
276
+ const { data: unitsData, refresh: refreshUnitsData } = useLazyAsyncData(
277
+ `fetch-units-data-${prop.site}-${vehicle.level}`,
278
+ async () => {
279
+ if (!vehicle.level) return Promise.resolve(null);;
280
+ return await getSiteUnits(prop.site, Number(vehicle.block), vehicle.level)
281
+ });
282
+
283
+
284
+ const { data: seasonPassTypeData, refresh: refreshSeasonPassTypeData } = useLazyAsyncData(
285
+ `fetch-season-pass-type-data-${prop.site}`,
286
+ async () => await getCustomSeasonPassTypes(prop.site));
287
+
288
+
289
+ watch(
290
+ siteData,
291
+ (newVal) => {
292
+ const siteDataValue = newVal as any;
293
+ if (siteDataValue) {
294
+ const numberOfBlocks = siteDataValue.metadata?.block || 0;
295
+ for (let i = 1; i <= numberOfBlocks; i++) {
296
+ blocksArray.value.push({
297
+ title: `Block ${i}`,
298
+ value: i
299
+ });
300
+ }
301
+
302
+ } else {
303
+ blocksArray.value = [];
304
+ }
305
+ }, { immediate: true });
306
+
307
+
308
+ watch(
309
+ levelsData,
310
+ (newVal: any) => {
311
+ if (newVal) {
312
+ const arr = newVal.levels || [];
313
+ levelsArray.value = arr?.map((level: any) => ({
314
+ title: level,
315
+ value: level
316
+ }));
317
+ } else {
318
+ levelsArray.value = [];
319
+ }
320
+ }, { immediate: true });
321
+
322
+ watch(
323
+ unitsData,
324
+ (newVal: any) => {
325
+ if (newVal && Array.isArray(newVal)) {
326
+ const arr = newVal || [];
327
+ unitsArray.value = arr?.map((unit: any) => ({
328
+ title: unit?.name,
329
+ value: unit?._id
330
+ }));
331
+ } else {
332
+ unitsArray.value = [];
333
+ }
334
+ }, { immediate: true });
335
+
336
+
337
+ watch(
338
+ seasonPassTypeData,
339
+ (newVal) => {
340
+ if (Array.isArray(newVal)) {
341
+ const filteredArr = newVal.filter((item: any) => item.title && item.value);
342
+ seasonPassTypeArray.value = [...defaultSeasonPassTypeArray.value, ...filteredArr];
343
+ } else {
344
+ seasonPassTypeArray.value = defaultSeasonPassTypeArray.value;
345
+ }
346
+ }, { immediate: true });
347
+
348
+
349
+
350
+
351
+ const subscriptionOptions = computed(() => {
352
+ return [
353
+ { label: 'Custom', value: 'custom' },
354
+ { label: '1 Week', value: '1week' },
355
+ { label: '2 Weeks', value: '2weeks' },
356
+ { label: '3 Weeks', value: '3weeks' },
357
+ { label: '4 Weeks', value: '4weeks' },
358
+ { label: '5 Weeks', value: '5weeks' },
359
+ ];
360
+ })
361
+
362
+ function handleChangeBlock(value: any) {
363
+ vehicle.level = '';
364
+ vehicle.unit = '';
365
+ refreshLevelsData();
366
+ }
367
+
368
+ function handleChangeLevel(value: any) {
369
+ vehicle.unit = '';
370
+ refreshUnitsData();
371
+ }
372
+
373
+
374
+
375
+ function back() {
376
+ emit("back");
377
+ message.value = '';
378
+ showSubscriptionDateOptions.value = false;
379
+ selectedSubscriptionDuration.value = null;
380
+ }
381
+
382
+ function close() {
383
+ emit("close");
384
+ message.value = '';
385
+ showSubscriptionDateOptions.value = false;
386
+ selectedSubscriptionDuration.value = null;
387
+ }
388
+
389
+ function formatVehicleType(type: TVehicleType): string {
390
+ switch (type) {
391
+ case 'whitelist':
392
+ return 'Whitelist';
393
+ case 'blocklist':
394
+ return 'Blocklist';
395
+ case 'seasonpass':
396
+ return 'Season Pass';
397
+ default:
398
+ return '';
399
+ }
400
+ }
401
+
402
+ function onSeasonPassTypeSelected(val: string | { title: string, value: string }) {
403
+ vehicle.seasonPassType = typeof val === 'object' ? val?.value : val;
404
+ }
405
+
406
+
407
+ async function submit() {
408
+ errorMessage.value = '';
409
+ processing.value = true;
410
+ try {
411
+ const SPTVal = vehicle?.seasonPassType as string;
412
+ if (SPTVal) {
413
+ vehicle.seasonPassType = SPTVal?.charAt(0)?.toUpperCase() + SPTVal?.slice(1)?.toLowerCase();
414
+ }
415
+
416
+
417
+ const { plateNumber, type, category, name, phoneNumber, block, level, unit, nric, remarks, seasonPassType, start, end, site, org } = vehicle
418
+
419
+ let payload: Partial<TVehiclePayload> = {
420
+ plateNumber,
421
+ name,
422
+ phoneNumber,
423
+ };
424
+
425
+ if (prop.mode === 'add') {
426
+ payload = {
427
+ ...payload,
428
+ type,
429
+ category,
430
+ site,
431
+ org
432
+ }
433
+ }
434
+
435
+ if (vehicle.type === 'whitelist') {
436
+ payload = {
437
+ ...payload,
438
+ block,
439
+ level,
440
+ unit,
441
+ };
442
+ } else if (vehicle.type === 'seasonpass') {
443
+ payload = {
444
+ ...payload,
445
+ seasonPassType,
446
+ block,
447
+ level,
448
+ unit,
449
+ start,
450
+ end
451
+ };
452
+ } else if (vehicle.type === 'blocklist') {
453
+ payload = {
454
+ ...payload,
455
+ nric,
456
+ remarks,
457
+ };
458
+ }
459
+
460
+ if (prop.mode === 'add') {
461
+ await addVehicle(payload);
462
+ } else if (prop.mode === 'edit') {
463
+ const vehicleId = vehicle?._id as string;
464
+ await updateVehicle(vehicleId, payload);
465
+ }
466
+ emit("done");
467
+
468
+
469
+ } catch (error: any) {
470
+ const err = error?.data?.message
471
+ errorMessage.value = err || `Failed to ${prop.mode === 'add' ? 'add' : 'update'} vehicle. Please try again.`;
472
+ } finally {
473
+ processing.value = false;
474
+ }
475
+ }
476
+
477
+ // const showComponent = (value: TVehicleType[]) => {
478
+ // return value.includes(prop.type);
479
+
480
+ // }
481
+
482
+ watch(selectedSubscriptionDuration, (duration) => {
483
+
484
+
485
+ const dateNowISO = formatDateISO8601(new Date())
486
+ const weeksLaterISO = (week: number) => {
487
+ const now = new Date()
488
+ const dateWeekLater = new Date(now.getTime() + (week * 7) * 24 * 60 * 60 * 1000)
489
+ return formatDateISO8601(dateWeekLater)
490
+ }
491
+
492
+ if (duration === 'custom') {
493
+ vehicle.start = '';
494
+ vehicle.end = '';
495
+ } else if (duration === '1week') {
496
+ vehicle.start = dateNowISO
497
+ vehicle.end = weeksLaterISO(1)
498
+ } else if (duration === '2weeks') {
499
+ vehicle.start = dateNowISO
500
+ vehicle.end = weeksLaterISO(2)
501
+ } else if (duration === '3weeks') {
502
+ vehicle.start = dateNowISO
503
+ vehicle.end = weeksLaterISO(3)
504
+ } else if (duration === '4weeks') {
505
+ vehicle.start = dateNowISO
506
+ vehicle.end = weeksLaterISO(4)
507
+ } else if (duration === '5weeks') {
508
+ vehicle.start = dateNowISO
509
+ vehicle.end = weeksLaterISO(5)
510
+ }
511
+ })
512
+
513
+ function validStartDateRule(value: string) {
514
+ const expiryDateISO = vehicle.end;
515
+ if (!value && expiryDateISO) {
516
+ return 'Start Date is required';
517
+ }
518
+
519
+ return true;
520
+ }
521
+
522
+ function validExpiryDateRule(value: string) {
523
+ const startDateISO = vehicle.start;
524
+
525
+ if (!value && startDateISO) {
526
+ return 'Expiry Date is required';
527
+ }
528
+
529
+ if (value && startDateISO) {
530
+ const expiry = new Date(value);
531
+ const start = new Date(startDateISO as string);
532
+ return expiry > start || 'Expiry date must be later than start date';
533
+ }
534
+
535
+ return true;
536
+ }
537
+
538
+ watch([() => vehicle.end, () => vehicle.start], () => {
539
+ (expiryDateRef.value as any)?.validate();
540
+ (startDateRef.value as any)?.validate();
541
+ });
542
+
543
+
544
+ async function checkNRIC() {
545
+ if (!vehicle.nric || vehicle.nric.length < 5) return;
546
+
547
+ checkingNRIC.value = true;
548
+ try {
549
+ const res = await getVehicleByNRIC(vehicle.nric, prop.site);
550
+ if (res && res.vehicles && res.vehicles.length > 0) {
551
+ matchingVehicles.value = res.vehicles;
552
+ showVehicleMatchDialog.value = true;
553
+ } else {
554
+ showVehicleMatchDialog.value = false;
555
+ matchingVehicles.value = [];
556
+ }
557
+ } catch (error) {
558
+ console.error('Error checking NRIC:', error);
559
+ showVehicleMatchDialog.value = false;
560
+ matchingVehicles.value = [];
561
+ } finally {
562
+ checkingNRIC.value = false;
563
+ }
564
+ }
565
+
566
+ const debounceedCheckNRIC = debounce(checkNRIC, 500);
567
+
568
+ watch(
569
+ () => vehicle.nric,
570
+ async (newNRIC) => {
571
+ if (!newNRIC || newNRIC.length < 5) return;
572
+
573
+ debounceedCheckNRIC();
574
+ }
575
+ );
576
+
577
+ watch(
578
+ () => vehicle.plateNumber,
579
+ (newData) => {
580
+
581
+ const plateNumberExistsInNRICMatches = matchingVehicles.value.some(v => v.plateNumber === newData);
582
+ if (plateNumberExistsInNRICMatches) {
583
+ errorMessage.value = 'The vehicle number you entered is already associated with the NRIC provided. Please double check.';
584
+ showVehicleMatchDialog.value = true;
585
+ } else {
586
+ errorMessage.value = '';
587
+ showVehicleMatchDialog.value = false;
588
+ }
589
+ }
590
+ );
591
+
592
+
593
+
594
+
595
+ </script>
596
+ <style scoped>
597
+ .button-outline-class {
598
+ border: 1px solid rgba(var(--v-theme-primary));
599
+ }
600
+ </style>