@7365admin1/layer-common 4.2.2-staging.263 → 4.2.2-staging.264
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/VehicleForm.vue +34 -8
- package/components/VehicleManagement.vue +109 -102
- package/package.json +1 -1
- package/utils/vehicle-actions.ts +141 -0
|
@@ -327,7 +327,15 @@
|
|
|
327
327
|
Back to Selection
|
|
328
328
|
</AppButton>
|
|
329
329
|
<AppButton v-else variant="ghost" @click="close">Close</AppButton>
|
|
330
|
-
|
|
330
|
+
<!--
|
|
331
|
+
NOT disabled on `validForm`. It used to be, and on a record with no
|
|
332
|
+
Block/Level/Unit - which exist, e.g. BEN21 on staging - the Update
|
|
333
|
+
button was simply dead: Vuetify does not mark a field invalid until it
|
|
334
|
+
has been validated, so there was no red field, no message, and no way
|
|
335
|
+
to find out why. The click now runs the validation, which paints every
|
|
336
|
+
offending field and writes the reason above.
|
|
337
|
+
-->
|
|
338
|
+
<AppButton :disabled="processing" @click="submit">
|
|
331
339
|
{{ prop.mode == "add" ? "Submit" : "Update" }}
|
|
332
340
|
</AppButton>
|
|
333
341
|
</div>
|
|
@@ -751,6 +759,18 @@ function showMessage(msg: string, color: string) {
|
|
|
751
759
|
|
|
752
760
|
async function submit() {
|
|
753
761
|
errorMessage.value = "";
|
|
762
|
+
|
|
763
|
+
// Ask the form first. `validate()` marks each failing field, so the person
|
|
764
|
+
// filling it in can see WHICH details are missing rather than meeting a
|
|
765
|
+
// button that does nothing.
|
|
766
|
+
const result = await (formRef.value as any)?.validate?.();
|
|
767
|
+
if (result && result.valid === false) {
|
|
768
|
+
errorMessage.value =
|
|
769
|
+
"Some required details are missing or not valid. The fields marked in " +
|
|
770
|
+
"red above need to be filled in before this can be saved.";
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
|
|
754
774
|
processing.value = true;
|
|
755
775
|
try {
|
|
756
776
|
const SPTVal = vehicle?.seasonPassType as string;
|
|
@@ -828,19 +848,26 @@ async function submit() {
|
|
|
828
848
|
};
|
|
829
849
|
}
|
|
830
850
|
|
|
851
|
+
// Say what the SERVER says happened, not a hardcoded sentence. The server
|
|
852
|
+
// reports whether the plate actually reached an ANPR camera - a site whose
|
|
853
|
+
// camera is switched off saves the vehicle but registers nothing, and this
|
|
854
|
+
// used to announce "Vehicle added successfully." over the top of that.
|
|
831
855
|
if (prop.mode === "add") {
|
|
832
|
-
await addVehicle(payload);
|
|
833
|
-
showMessage("Vehicle added successfully.", "success");
|
|
856
|
+
const res: any = await addVehicle(payload);
|
|
857
|
+
showMessage(res?.data || res?.message || "Vehicle added successfully.", "success");
|
|
834
858
|
} else if (prop.mode === "edit") {
|
|
835
859
|
const plateNumberId = prop.plateNumberId as string;
|
|
836
|
-
await updateVehicle(plateNumberId, payload);
|
|
837
|
-
showMessage("Vehicle updated successfully.", "success");
|
|
860
|
+
const res: any = await updateVehicle(plateNumberId, payload);
|
|
861
|
+
showMessage(res?.data || res?.message || "Vehicle updated successfully.", "success");
|
|
838
862
|
}
|
|
839
863
|
emit("done");
|
|
840
864
|
} catch (error: any) {
|
|
841
|
-
|
|
865
|
+
// A refused create is fail-closed on the server and its message names the
|
|
866
|
+
// camera that would not take the plate. `$fetch` puts it on `data`, `ofetch`
|
|
867
|
+
// on `response._data`; reading only one of them dropped the reason.
|
|
842
868
|
errorMessage.value =
|
|
843
|
-
|
|
869
|
+
error?.data?.message ||
|
|
870
|
+
error?.response?._data?.message ||
|
|
844
871
|
`Failed to ${
|
|
845
872
|
prop.mode === "add" ? "add" : "update"
|
|
846
873
|
} vehicle. Please try again.`;
|
|
@@ -1023,7 +1050,6 @@ function handleCloseMatchDialog() {
|
|
|
1023
1050
|
|
|
1024
1051
|
onMounted(() => {
|
|
1025
1052
|
setTimeout(() => {
|
|
1026
|
-
console.log("VehicleForm mounted with props:", prop);
|
|
1027
1053
|
if (prop.mode === "edit" && prop.vehicleData) {
|
|
1028
1054
|
// In edit mode, we only want to check for matching people records if there is a pre-filled unit value. If there is no unit value, we can assume that this vehicle record is not linked to any existing people record and skip the check.
|
|
1029
1055
|
vehicle.seasonPassType = prop.vehicleData.seasonPassType || "";
|
|
@@ -38,30 +38,40 @@
|
|
|
38
38
|
</template>
|
|
39
39
|
|
|
40
40
|
<!--
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
41
|
+
The row kebab. Same activator every other table in this package draws
|
|
42
|
+
(`AppButton variant="row"` as a `v-menu` activator, see
|
|
43
|
+
`MemberMain.vue`). `@click.stop` because the row also carries
|
|
44
|
+
`@row-click`; without it the kebab would open the Preview dialog
|
|
45
|
+
underneath the menu.
|
|
46
|
+
|
|
47
|
+
A row can stand for MORE THAN ONE vehicle. The list groups sibling
|
|
48
|
+
plates by NRIC, so Test Res 3 on staging is one row holding TEST123 and
|
|
49
|
+
TEST124 - two separate documents. The menu used to resolve a single
|
|
50
|
+
plate and act on it silently; now every plate on the row is listed
|
|
51
|
+
under its own number and the operator picks the one they mean.
|
|
52
|
+
|
|
53
|
+
The items themselves come from `vehiclePlateActions`, the same function
|
|
54
|
+
the Preview dialog's menu uses, so the two can no longer disagree - and
|
|
55
|
+
the kebab's own `v-if` asks that function too, so an empty menu cannot
|
|
56
|
+
be drawn.
|
|
51
57
|
-->
|
|
52
58
|
<template #item.action="{ item }">
|
|
53
|
-
<v-menu v-if="
|
|
54
|
-
location="bottom end">
|
|
59
|
+
<v-menu v-if="rowHasVehicleActions(item, vehiclePermissions)" location="bottom end">
|
|
55
60
|
<template #activator="{ props: menuProps }">
|
|
56
61
|
<AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" aria-label="Vehicle actions"
|
|
57
62
|
@click.stop />
|
|
58
63
|
</template>
|
|
59
64
|
<v-list density="compact">
|
|
60
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
<template v-for="plate in rowPlates(item)" :key="plate._id ?? plate.plateNumber">
|
|
66
|
+
<template v-if="vehiclePlateActions(plate, vehiclePermissions).length">
|
|
67
|
+
<v-list-subheader v-if="rowPlates(item).length > 1">
|
|
68
|
+
{{ plate.plateNumber }}
|
|
69
|
+
</v-list-subheader>
|
|
70
|
+
<v-list-item v-for="action in vehiclePlateActions(plate, vehiclePermissions)" :key="action.key"
|
|
71
|
+
:title="action.title" :prepend-icon="action.icon" :base-color="action.color"
|
|
72
|
+
@click="runVehicleAction(action.key, plate as TPlateNumber)" />
|
|
73
|
+
</template>
|
|
74
|
+
</template>
|
|
65
75
|
</v-list>
|
|
66
76
|
</v-menu>
|
|
67
77
|
</template>
|
|
@@ -120,34 +130,17 @@
|
|
|
120
130
|
{{ formatVehicleStatus(value).label }}
|
|
121
131
|
</v-chip>
|
|
122
132
|
</template>
|
|
123
|
-
<template #item.action="{ item: plateNumberItem
|
|
124
|
-
<v-menu v-if="
|
|
133
|
+
<template #item.action="{ item: plateNumberItem }">
|
|
134
|
+
<v-menu v-if="vehiclePlateActions(plateNumberItem, vehiclePermissions).length">
|
|
125
135
|
<template #activator="{ props: menuProps }">
|
|
126
136
|
<v-btn icon="mdi-dots-vertical" v-bind="menuProps" flat size="x-small" />
|
|
127
137
|
</template>
|
|
128
138
|
<v-list density="compact">
|
|
129
139
|
<v-list-item
|
|
130
|
-
v-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
v-if="canApproveVehicle && (plateNumberItem as TPlateNumber)?.status == 'pending'"
|
|
135
|
-
title="Approve" prepend-icon="mdi-check-circle" base-color="success"
|
|
136
|
-
@click="handleApproveVehicle(plateNumberItem as TPlateNumber)" />
|
|
137
|
-
<v-list-item v-if="(plateNumberItem as TPlateNumber)?.status == 'deleted'" title="Restore"
|
|
138
|
-
prepend-icon="mdi-restore" base-color="warning"
|
|
139
|
-
@click="handleRestoreVehicle(plateNumberItem as TPlateNumber)" />
|
|
140
|
-
<v-list-item
|
|
141
|
-
v-if="canUpdateVehicle && ((plateNumberItem as TPlateNumber)?.type === 'whitelist' || (plateNumberItem as TPlateNumber)?.type === 'blocklist') && plateNumberItem?.status == 'active'"
|
|
142
|
-
:title="(plateNumberItem as TPlateNumber)?.type === 'blocklist' ? 'Unblock' : 'Block'"
|
|
143
|
-
prepend-icon="mdi-swap-horizontal"
|
|
144
|
-
:base-color="(plateNumberItem as TPlateNumber)?.type === 'blocklist' ? 'primary' : 'error'"
|
|
145
|
-
@click="handleUpdateType(plateNumberItem as TPlateNumber)" />
|
|
146
|
-
|
|
147
|
-
<v-list-item
|
|
148
|
-
v-if="canDeleteVehicle && (plateNumberItem as TPlateNumber)?.status == 'active'"
|
|
149
|
-
title="Delete" prepend-icon="mdi-delete" base-color="error"
|
|
150
|
-
@click="handleDeleteVehicleAction(plateNumberItem as TPlateNumber)" />
|
|
140
|
+
v-for="action in vehiclePlateActions(plateNumberItem, vehiclePermissions)"
|
|
141
|
+
:key="action.key" :title="action.title" :prepend-icon="action.icon"
|
|
142
|
+
:base-color="action.color"
|
|
143
|
+
@click="runVehicleAction(action.key, plateNumberItem as TPlateNumber)" />
|
|
151
144
|
</v-list>
|
|
152
145
|
</v-menu>
|
|
153
146
|
</template>
|
|
@@ -184,19 +177,25 @@
|
|
|
184
177
|
@delete="submitDelete" @close="closeDeleteDialog" />
|
|
185
178
|
</v-dialog>
|
|
186
179
|
<v-dialog v-model="dialog.approveVehicle" persistent width="540">
|
|
187
|
-
|
|
180
|
+
<!--
|
|
181
|
+
`message` carries the server's refusal. Approve talks to the ANPR
|
|
182
|
+
cameras, so a failure here is a sentence about a camera that the
|
|
183
|
+
operator has to read and act on - it was going to a five-second
|
|
184
|
+
snackbar, along with the device's raw `{"ErrorCode":...}` reply.
|
|
185
|
+
-->
|
|
186
|
+
<DialogReusablePrompt :loading="approvingVehicle" :message="actionError"
|
|
188
187
|
:prompt-title="`Are you sure want to approve this vehicle - ${selectedPlateNumberObject?.plateNumber}?`"
|
|
189
|
-
@proceed="submitApprove" @close="
|
|
188
|
+
@proceed="submitApprove" @close="closeActionDialog('approveVehicle')" />
|
|
190
189
|
</v-dialog>
|
|
191
190
|
<v-dialog v-model="dialog.restoreVehicle" persistent width="540">
|
|
192
|
-
<DialogReusablePrompt :loading="restoringVehicle"
|
|
191
|
+
<DialogReusablePrompt :loading="restoringVehicle" :message="actionError"
|
|
193
192
|
:prompt-title="`Are you sure want to restore this vehicle - ${selectedPlateNumberObject?.plateNumber}?`"
|
|
194
|
-
@proceed="submitRestore" @close="
|
|
193
|
+
@proceed="submitRestore" @close="closeActionDialog('restoreVehicle')" />
|
|
195
194
|
</v-dialog>
|
|
196
195
|
<v-dialog v-model="dialog.updateVehicleType" persistent width="540">
|
|
197
|
-
<DialogReusablePrompt :loading="updatingType"
|
|
196
|
+
<DialogReusablePrompt :loading="updatingType" :message="actionError"
|
|
198
197
|
:prompt-title="`Are you sure want to update the type of this vehicle - ${selectedPlateNumberObject?.plateNumber}?`"
|
|
199
|
-
@proceed="submitUpdateType" @close="
|
|
198
|
+
@proceed="submitUpdateType" @close="closeActionDialog('updateVehicleType')" />
|
|
200
199
|
</v-dialog>
|
|
201
200
|
<Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
|
|
202
201
|
</v-row>
|
|
@@ -205,6 +204,12 @@
|
|
|
205
204
|
<script lang="ts" setup>
|
|
206
205
|
import useUtils from '../composables/useUtils';
|
|
207
206
|
import useVehicle from '../composables/useVehicle';
|
|
207
|
+
import {
|
|
208
|
+
rowHasVehicleActions,
|
|
209
|
+
rowPlates,
|
|
210
|
+
vehiclePlateActions,
|
|
211
|
+
type VehicleActionKey,
|
|
212
|
+
} from '../utils/vehicle-actions';
|
|
208
213
|
|
|
209
214
|
|
|
210
215
|
|
|
@@ -275,6 +280,29 @@ const messageSnackbar = ref(false);
|
|
|
275
280
|
// inside the delete dialog rather than a 5-second toast: it names the camera
|
|
276
281
|
// that failed and the operator needs it in front of them to retry.
|
|
277
282
|
const deleteError = ref("");
|
|
283
|
+
// The same thing for Approve / Restore / Block, which also talk to the cameras
|
|
284
|
+
// and whose refusals were being thrown at a snackbar that times out.
|
|
285
|
+
const actionError = ref("");
|
|
286
|
+
|
|
287
|
+
function closeActionDialog(key: "approveVehicle" | "restoreVehicle" | "updateVehicleType") {
|
|
288
|
+
dialog[key] = false;
|
|
289
|
+
actionError.value = "";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The server's own sentence, or a plain fallback.
|
|
294
|
+
*
|
|
295
|
+
* `error.response._data` is undefined on a network failure, so reading it
|
|
296
|
+
* unguarded threw out of the catch and the operator saw nothing at all.
|
|
297
|
+
*/
|
|
298
|
+
function readServerMessage(error: any, fallback: string) {
|
|
299
|
+
return (
|
|
300
|
+
error?.response?._data?.message ||
|
|
301
|
+
error?.data?.message ||
|
|
302
|
+
error?.message ||
|
|
303
|
+
fallback
|
|
304
|
+
);
|
|
305
|
+
}
|
|
278
306
|
|
|
279
307
|
function showMessage(msg: string, color: string) {
|
|
280
308
|
message.value = msg;
|
|
@@ -425,59 +453,48 @@ function handleDeleteVehicleAction(item: TPlateNumber) {
|
|
|
425
453
|
|
|
426
454
|
function handleApproveVehicle(item: TPlateNumber) {
|
|
427
455
|
selectedPlateNumberObject.value = item || null;
|
|
456
|
+
actionError.value = "";
|
|
428
457
|
dialog.approveVehicle = true;
|
|
429
458
|
}
|
|
430
459
|
function handleRestoreVehicle(item: TPlateNumber) {
|
|
431
460
|
selectedPlateNumberObject.value = item || null;
|
|
461
|
+
actionError.value = "";
|
|
432
462
|
dialog.restoreVehicle = true;
|
|
433
463
|
}
|
|
434
464
|
|
|
435
465
|
function handleUpdateType(item: TPlateNumber) {
|
|
436
466
|
selectedPlateNumberObject.value = item || null;
|
|
467
|
+
actionError.value = "";
|
|
437
468
|
dialog.updateVehicleType = true;
|
|
438
469
|
}
|
|
439
470
|
|
|
440
471
|
/**
|
|
441
|
-
* The
|
|
442
|
-
*
|
|
443
|
-
* `vehicle.repo.ts` groups sibling plates by non-empty NRIC and keeps the FIRST
|
|
444
|
-
* document's `_id` as the row's `_id` (`vehicleId: { $first: "$_id" }`), then
|
|
445
|
-
* `$addToSet`s the group into `plates`. The row itself carries no
|
|
446
|
-
* `plateNumber`, `type`, `recNo` or `status` - only `plates[]` does - so a row
|
|
447
|
-
* action has to resolve its own plate out of that array before it can name it
|
|
448
|
-
* or delete it.
|
|
472
|
+
* The three gates the action list is decided by, passed straight through from
|
|
473
|
+
* the host app's `useLocalPermission()`.
|
|
449
474
|
*/
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
null
|
|
456
|
-
);
|
|
457
|
-
}
|
|
475
|
+
const vehiclePermissions = computed(() => ({
|
|
476
|
+
canUpdateVehicle: props.canUpdateVehicle,
|
|
477
|
+
canDeleteVehicle: props.canDeleteVehicle,
|
|
478
|
+
canApproveVehicle: props.canApproveVehicle,
|
|
479
|
+
}));
|
|
458
480
|
|
|
459
481
|
/**
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
* `isActive` used to stand alone here, which was the Edit item's test - but
|
|
464
|
-
* Edit is now gated on `canUpdateVehicle`, and Block/Unblock only offers itself
|
|
465
|
-
* for a whitelist/blocklist plate, so a read-only role or a plate of another
|
|
466
|
-
* type would have opened nothing.
|
|
482
|
+
* One entry point for every menu item, in both menus, so a new action can only
|
|
483
|
+
* be added in one place and cannot be forgotten in the other.
|
|
467
484
|
*/
|
|
468
|
-
function
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
485
|
+
function runVehicleAction(key: VehicleActionKey, plate: TPlateNumber) {
|
|
486
|
+
switch (key) {
|
|
487
|
+
case "edit":
|
|
488
|
+
return handleEditVehicleAction(plate);
|
|
489
|
+
case "approve":
|
|
490
|
+
return handleApproveVehicle(plate);
|
|
491
|
+
case "restore":
|
|
492
|
+
return handleRestoreVehicle(plate);
|
|
493
|
+
case "toggle-type":
|
|
494
|
+
return handleUpdateType(plate);
|
|
495
|
+
case "delete":
|
|
496
|
+
return handleDeleteVehicleAction(plate);
|
|
497
|
+
}
|
|
481
498
|
}
|
|
482
499
|
|
|
483
500
|
function handleSelectVehicleStatus(value: TVehicleType) {
|
|
@@ -549,12 +566,7 @@ async function submitDelete() {
|
|
|
549
566
|
// request is refused. Say so. This used to assign to `message` WITHOUT
|
|
550
567
|
// raising the snackbar - and `error.response._data` throws on a network
|
|
551
568
|
// error - so a refused delete showed the operator nothing at all.
|
|
552
|
-
|
|
553
|
-
deleteError.value =
|
|
554
|
-
error?.response?._data?.message ||
|
|
555
|
-
error?.data?.message ||
|
|
556
|
-
error?.message ||
|
|
557
|
-
"Failed to delete vehicle.";
|
|
569
|
+
deleteError.value = readServerMessage(error, "Failed to delete vehicle.");
|
|
558
570
|
// The dialog stays open and the row stays in the table - nothing is removed
|
|
559
571
|
// optimistically, so what is on screen still matches the database.
|
|
560
572
|
} finally {
|
|
@@ -575,17 +587,15 @@ async function submitRestore() {
|
|
|
575
587
|
|
|
576
588
|
try {
|
|
577
589
|
restoringVehicle.value = true;
|
|
590
|
+
actionError.value = "";
|
|
578
591
|
// reactivate and restore will use the same endpoint, just with different payload
|
|
579
592
|
const res = await approveVehicle({ site: props.site, org: props.org, id: vehicleId as string }
|
|
580
593
|
);
|
|
581
|
-
|
|
594
|
+
closeActionDialog("restoreVehicle");
|
|
582
595
|
showMessage(res.message, "success");
|
|
583
596
|
await getVehiclesRefresh();
|
|
584
597
|
} catch (error: any) {
|
|
585
|
-
|
|
586
|
-
const errMessage = error?.response?._data?.message || "Failed to restore vehicle";
|
|
587
|
-
showMessage(errMessage, "error");
|
|
588
|
-
// message.value = error.response._data.message;
|
|
598
|
+
actionError.value = readServerMessage(error, "Failed to restore vehicle.");
|
|
589
599
|
} finally {
|
|
590
600
|
restoringVehicle.value = false;
|
|
591
601
|
}
|
|
@@ -626,15 +636,13 @@ async function submitUpdateType() {
|
|
|
626
636
|
|
|
627
637
|
try {
|
|
628
638
|
updatingType.value = true;
|
|
639
|
+
actionError.value = "";
|
|
629
640
|
const res = await updateVehicle(vehicleId as string, payload);
|
|
630
|
-
|
|
641
|
+
closeActionDialog("updateVehicleType");
|
|
631
642
|
showMessage(res.message, "success");
|
|
632
643
|
await getVehiclesRefresh();
|
|
633
644
|
} catch (error: any) {
|
|
634
|
-
|
|
635
|
-
const errMessage = error?.response?._data?.message || "Failed to update vehicle type";
|
|
636
|
-
showMessage(errMessage, "error");
|
|
637
|
-
// message.value = error.response._data.message;
|
|
645
|
+
actionError.value = readServerMessage(error, "Failed to update vehicle type.");
|
|
638
646
|
} finally {
|
|
639
647
|
updatingType.value = false;
|
|
640
648
|
}
|
|
@@ -651,16 +659,15 @@ async function submitApprove() {
|
|
|
651
659
|
|
|
652
660
|
try {
|
|
653
661
|
approvingVehicle.value = true;
|
|
662
|
+
actionError.value = "";
|
|
654
663
|
const res = await approveVehicle({ site: props.site, org: props.org, id: plateNumberId as string }
|
|
655
664
|
|
|
656
665
|
);
|
|
657
|
-
|
|
666
|
+
closeActionDialog("approveVehicle");
|
|
658
667
|
showMessage(res.message, "success");
|
|
659
668
|
await getVehiclesRefresh();
|
|
660
669
|
} catch (error: any) {
|
|
661
|
-
|
|
662
|
-
const errMessage = error?.response?._data?.message || "Failed to approve vehicle";
|
|
663
|
-
showMessage(errMessage, "error");
|
|
670
|
+
actionError.value = readServerMessage(error, "Failed to approve vehicle.");
|
|
664
671
|
} finally {
|
|
665
672
|
approvingVehicle.value = false;
|
|
666
673
|
}
|
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.2.2-staging.
|
|
5
|
+
"version": "4.2.2-staging.264",
|
|
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.",
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which actions a vehicle plate offers, in one place.
|
|
3
|
+
*
|
|
4
|
+
* The reason this exists rather than a list of `v-if`s: the row menu and the
|
|
5
|
+
* Preview dialog's menu each had their OWN copy of the conditions, and they
|
|
6
|
+
* drifted. On staging, 1 Sep 2026:
|
|
7
|
+
*
|
|
8
|
+
* - a PENDING plate's row kebab opened an EMPTY menu - Approve was only ever
|
|
9
|
+
* written into the Preview copy;
|
|
10
|
+
* - an INACTIVE (blocked) plate had no kebab at all and no action in Preview
|
|
11
|
+
* either, because every condition in both copies tested for
|
|
12
|
+
* active / pending / deleted, so there was no way to restore it.
|
|
13
|
+
*
|
|
14
|
+
* With the decision in one function, "the kebab shows" and "the menu has
|
|
15
|
+
* something in it" are the same question, and an empty menu cannot be drawn.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type VehicleActionKey =
|
|
19
|
+
| "edit"
|
|
20
|
+
| "approve"
|
|
21
|
+
| "restore"
|
|
22
|
+
| "toggle-type"
|
|
23
|
+
| "delete";
|
|
24
|
+
|
|
25
|
+
export type VehicleActionPermissions = {
|
|
26
|
+
canUpdateVehicle?: boolean;
|
|
27
|
+
canDeleteVehicle?: boolean;
|
|
28
|
+
canApproveVehicle?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type VehicleAction = {
|
|
32
|
+
key: VehicleActionKey;
|
|
33
|
+
title: string;
|
|
34
|
+
icon: string;
|
|
35
|
+
/** Vuetify `base-color`; undefined means the list's default. */
|
|
36
|
+
color?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The shape a plate is read as. Only the three fields the decision uses. */
|
|
40
|
+
export type VehicleActionPlate = {
|
|
41
|
+
status?: string | null;
|
|
42
|
+
type?: string | null;
|
|
43
|
+
plateNumber?: string | null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `inactive` is the status a blocked vehicle carries, and `deleted` is a
|
|
48
|
+
* soft-deleted one. Both are "not currently working, and someone should be
|
|
49
|
+
* able to put it back".
|
|
50
|
+
*/
|
|
51
|
+
const RESTORABLE = new Set(["inactive", "deleted", "rejected"]);
|
|
52
|
+
|
|
53
|
+
export function vehiclePlateActions(
|
|
54
|
+
plate: VehicleActionPlate | null | undefined,
|
|
55
|
+
permissions: VehicleActionPermissions = {},
|
|
56
|
+
): VehicleAction[] {
|
|
57
|
+
if (!plate) return [];
|
|
58
|
+
|
|
59
|
+
const status = String(plate.status ?? "").toLowerCase();
|
|
60
|
+
const type = String(plate.type ?? "").toLowerCase();
|
|
61
|
+
|
|
62
|
+
const isActive = status === "active";
|
|
63
|
+
const isPending = status === "pending";
|
|
64
|
+
const isRestorable = RESTORABLE.has(status);
|
|
65
|
+
const isTogglableType = type === "whitelist" || type === "blocklist";
|
|
66
|
+
|
|
67
|
+
const actions: VehicleAction[] = [];
|
|
68
|
+
|
|
69
|
+
if (permissions.canUpdateVehicle && isActive) {
|
|
70
|
+
actions.push({ key: "edit", title: "Edit Vehicle", icon: "mdi-pencil" });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (permissions.canApproveVehicle && isPending) {
|
|
74
|
+
actions.push({
|
|
75
|
+
key: "approve",
|
|
76
|
+
title: "Approve",
|
|
77
|
+
icon: "mdi-check-circle",
|
|
78
|
+
color: "success",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Restore is deliberately NOT gated on `canUpdateVehicle`: it was ungated
|
|
83
|
+
// before this change and narrowing a gate is a separate, role-data decision.
|
|
84
|
+
// What changes is that a blocked vehicle now offers it at all.
|
|
85
|
+
if (isRestorable) {
|
|
86
|
+
actions.push({
|
|
87
|
+
key: "restore",
|
|
88
|
+
title: status === "inactive" ? "Unblock" : "Restore",
|
|
89
|
+
icon: "mdi-restore",
|
|
90
|
+
color: "warning",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (permissions.canUpdateVehicle && isTogglableType && isActive) {
|
|
95
|
+
actions.push({
|
|
96
|
+
key: "toggle-type",
|
|
97
|
+
title: type === "blocklist" ? "Unblock" : "Block",
|
|
98
|
+
icon: "mdi-swap-horizontal",
|
|
99
|
+
color: type === "blocklist" ? "primary" : "error",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (permissions.canDeleteVehicle && isActive) {
|
|
104
|
+
actions.push({
|
|
105
|
+
key: "delete",
|
|
106
|
+
title: "Delete",
|
|
107
|
+
icon: "mdi-delete",
|
|
108
|
+
color: "error",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return actions;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Every plate a MAIN-table row stands for.
|
|
117
|
+
*
|
|
118
|
+
* `vehicle.repo.ts` groups sibling plates by non-empty NRIC and keeps the FIRST
|
|
119
|
+
* document's `_id` as the row's `_id`, then collects the group into `plates`.
|
|
120
|
+
* Test Res 3 on staging is one row holding TEST123 (whitelist) and TEST124
|
|
121
|
+
* (blocklist) - two separate vehicle documents. The row menu used to resolve a
|
|
122
|
+
* single plate and silently act on TEST123, with no way to reach TEST124.
|
|
123
|
+
*/
|
|
124
|
+
export function rowPlates(row: any): any[] {
|
|
125
|
+
const plates = Array.isArray(row?.plates) ? row.plates : [];
|
|
126
|
+
if (plates.length) return plates;
|
|
127
|
+
|
|
128
|
+
// A row that carries its own plate fields and no group (the shape the older
|
|
129
|
+
// list endpoints return) is a single plate.
|
|
130
|
+
return row?.plateNumber ? [row] : [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** True when at least one plate on the row offers at least one action. */
|
|
134
|
+
export function rowHasVehicleActions(
|
|
135
|
+
row: any,
|
|
136
|
+
permissions: VehicleActionPermissions = {},
|
|
137
|
+
): boolean {
|
|
138
|
+
return rowPlates(row).some(
|
|
139
|
+
(plate) => vehiclePlateActions(plate, permissions).length > 0,
|
|
140
|
+
);
|
|
141
|
+
}
|