@7365admin1/layer-common 4.2.2-staging.262 → 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.
@@ -6,8 +6,15 @@
6
6
  <!-- `text-error` is Vuetify's own class over `--v-theme-error`,
7
7
  which is the design's `--err` in both themes - already a token,
8
8
  so it stays. -->
9
+ <!-- `message` is a FAILURE the caller wants read before the
10
+ operator tries again. It used to append "Do you want to delete
11
+ anyway?", which promises a force this dialog cannot perform -
12
+ pressing Delete simply repeats the same request. No caller had
13
+ ever passed `message`, so the sentence had never rendered.
14
+ `VehicleManagement` is the first, and it passes the server's
15
+ refusal when an ANPR revoke could not be confirmed. -->
9
16
  <div v-if="message" class="text-error mt-2">
10
- {{ message }} Do you want to delete anyway?
17
+ {{ message }}
11
18
  </div>
12
19
  </v-card-text>
13
20
 
@@ -327,7 +327,15 @@
327
327
  Back to Selection
328
328
  </AppButton>
329
329
  <AppButton v-else variant="ghost" @click="close">Close</AppButton>
330
- <AppButton :disabled="!validForm || processing" @click="submit">
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
- const err = error?.data?.message;
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
- err ||
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 || "";
@@ -36,6 +36,45 @@
36
36
  <template #item.plates="{ value, item }">
37
37
  <PlateNumberDisplay :plate-numbers="value" :default-value="item.plateNumber" />
38
38
  </template>
39
+
40
+ <!--
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.
57
+ -->
58
+ <template #item.action="{ item }">
59
+ <v-menu v-if="rowHasVehicleActions(item, vehiclePermissions)" location="bottom end">
60
+ <template #activator="{ props: menuProps }">
61
+ <AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" aria-label="Vehicle actions"
62
+ @click.stop />
63
+ </template>
64
+ <v-list density="compact">
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>
75
+ </v-list>
76
+ </v-menu>
77
+ </template>
39
78
  </TableMain>
40
79
 
41
80
  <!-- <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" /> -->
@@ -91,33 +130,17 @@
91
130
  {{ formatVehicleStatus(value).label }}
92
131
  </v-chip>
93
132
  </template>
94
- <template #item.action="{ item: plateNumberItem, value }">
95
- <v-menu v-if="hasVehicleActions(plateNumberItem as TPlateNumber)">
133
+ <template #item.action="{ item: plateNumberItem }">
134
+ <v-menu v-if="vehiclePlateActions(plateNumberItem, vehiclePermissions).length">
96
135
  <template #activator="{ props: menuProps }">
97
136
  <v-btn icon="mdi-dots-vertical" v-bind="menuProps" flat size="x-small" />
98
137
  </template>
99
138
  <v-list density="compact">
100
- <v-list-item v-if="(plateNumberItem as TPlateNumber)?.status == 'active'"
101
- title="Edit Vehicle" prepend-icon="mdi-pencil"
102
- @click="handleEditVehicleAction(plateNumberItem as TPlateNumber)" />
103
- <v-list-item
104
- v-if="canApproveVehicle && (plateNumberItem as TPlateNumber)?.status == 'pending'"
105
- title="Approve" prepend-icon="mdi-check-circle" base-color="success"
106
- @click="handleApproveVehicle(plateNumberItem as TPlateNumber)" />
107
- <v-list-item v-if="(plateNumberItem as TPlateNumber)?.status == 'deleted'" title="Restore"
108
- prepend-icon="mdi-restore" base-color="warning"
109
- @click="handleRestoreVehicle(plateNumberItem as TPlateNumber)" />
110
- <v-list-item
111
- v-if="((plateNumberItem as TPlateNumber)?.type === 'whitelist' || (plateNumberItem as TPlateNumber)?.type === 'blocklist') && plateNumberItem?.status == 'active'"
112
- :title="(plateNumberItem as TPlateNumber)?.type === 'blocklist' ? 'Unblock' : 'Block'"
113
- prepend-icon="mdi-swap-horizontal"
114
- :base-color="(plateNumberItem as TPlateNumber)?.type === 'blocklist' ? 'primary' : 'error'"
115
- @click="handleUpdateType(plateNumberItem as TPlateNumber)" />
116
-
117
139
  <v-list-item
118
- v-if="canDeleteVehicle && (plateNumberItem as TPlateNumber)?.status == 'active'"
119
- title="Delete" prepend-icon="mdi-delete" base-color="error"
120
- @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)" />
121
144
  </v-list>
122
145
  </v-menu>
123
146
  </template>
@@ -140,24 +163,39 @@
140
163
  </DialogUpdateMoreAction>
141
164
  </v-dialog>
142
165
  <v-dialog v-model="dialog.deleteVehicle" persistent width="540">
143
- <DialogDeleteConfirmation :loading="deletingVehicle"
144
- :prompt-title="`Are you sure want to delete this vehicle - ${selectedPlateNumberObject?.plateNumber}?`"
166
+ <!--
167
+ A row is ONE plate document, not a person: the list groups sibling
168
+ plates by NRIC and keeps the first document's `_id` as the row's `_id`.
169
+ So the copy names the plate it is about to remove, says what removing
170
+ it actually does (revokes ANPR access), and says plainly that nothing
171
+ else on the record is touched. `message` carries the server's refusal
172
+ when the ANPR revoke could not be confirmed - the dialog stays open on
173
+ that path so the operator can read it and retry.
174
+ -->
175
+ <DialogDeleteConfirmation :loading="deletingVehicle" :message="deleteError"
176
+ :prompt-title="`Remove vehicle number ${selectedPlateNumberObject?.plateNumber} from this record? This revokes its ANPR access - it will no longer open the barrier. The resident and any other vehicle numbers on this record are not affected.`"
145
177
  @delete="submitDelete" @close="closeDeleteDialog" />
146
178
  </v-dialog>
147
179
  <v-dialog v-model="dialog.approveVehicle" persistent width="540">
148
- <DialogReusablePrompt :loading="approvingVehicle"
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"
149
187
  :prompt-title="`Are you sure want to approve this vehicle - ${selectedPlateNumberObject?.plateNumber}?`"
150
- @proceed="submitApprove" @close="dialog.approveVehicle = false" />
188
+ @proceed="submitApprove" @close="closeActionDialog('approveVehicle')" />
151
189
  </v-dialog>
152
190
  <v-dialog v-model="dialog.restoreVehicle" persistent width="540">
153
- <DialogReusablePrompt :loading="restoringVehicle"
191
+ <DialogReusablePrompt :loading="restoringVehicle" :message="actionError"
154
192
  :prompt-title="`Are you sure want to restore this vehicle - ${selectedPlateNumberObject?.plateNumber}?`"
155
- @proceed="submitRestore" @close="dialog.restoreVehicle = false" />
193
+ @proceed="submitRestore" @close="closeActionDialog('restoreVehicle')" />
156
194
  </v-dialog>
157
195
  <v-dialog v-model="dialog.updateVehicleType" persistent width="540">
158
- <DialogReusablePrompt :loading="updatingType"
196
+ <DialogReusablePrompt :loading="updatingType" :message="actionError"
159
197
  :prompt-title="`Are you sure want to update the type of this vehicle - ${selectedPlateNumberObject?.plateNumber}?`"
160
- @proceed="submitUpdateType" @close="dialog.updateVehicleType = false" />
198
+ @proceed="submitUpdateType" @close="closeActionDialog('updateVehicleType')" />
161
199
  </v-dialog>
162
200
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
163
201
  </v-row>
@@ -166,6 +204,12 @@
166
204
  <script lang="ts" setup>
167
205
  import useUtils from '../composables/useUtils';
168
206
  import useVehicle from '../composables/useVehicle';
207
+ import {
208
+ rowHasVehicleActions,
209
+ rowPlates,
210
+ vehiclePlateActions,
211
+ type VehicleActionKey,
212
+ } from '../utils/vehicle-actions';
169
213
 
170
214
 
171
215
 
@@ -191,6 +235,7 @@ const headers = [
191
235
  { title: "Category", value: "category" },
192
236
  // { title: "Type", value: "type" },
193
237
  // { title: "Status", value: "status" },
238
+ { title: "", value: "action" },
194
239
  ]
195
240
 
196
241
  const plateHeaders = [
@@ -205,10 +250,6 @@ const plateHeaders = [
205
250
  const { formatCamelCaseToWords, formatDate, debounce, maskNRIC } = useUtils();
206
251
  const { getVehicles, deleteVehicle, formatVehicleStatus, approveVehicle, updateVehicle, getSpecificVehicleById } = useVehicle();
207
252
 
208
- const canApproveVehicle = computed(() => {
209
- return true;
210
- })
211
-
212
253
  const items = ref<Array<Record<string, any>>>([]);
213
254
  const page = ref(1);
214
255
  const pages = ref(0);
@@ -235,7 +276,33 @@ const selectedPlateNumberId = ref<string | null>(null); // individual plate numb
235
276
  const message = ref("");
236
277
  const messageColor = ref("");
237
278
  const messageSnackbar = ref(false);
238
- const bypass = ref(false);
279
+ // The server's refusal when an ANPR revoke could not be confirmed. Rendered
280
+ // inside the delete dialog rather than a 5-second toast: it names the camera
281
+ // that failed and the operator needs it in front of them to retry.
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
+ }
239
306
 
240
307
  function showMessage(msg: string, color: string) {
241
308
  message.value = msg;
@@ -298,6 +365,7 @@ function formatValues(key: string, value: any) {
298
365
 
299
366
  const closeDeleteDialog = () => {
300
367
  dialog.deleteVehicle = false;
368
+ deleteError.value = "";
301
369
  };
302
370
 
303
371
  const handleCloseAll = () => {
@@ -385,31 +453,48 @@ function handleDeleteVehicleAction(item: TPlateNumber) {
385
453
 
386
454
  function handleApproveVehicle(item: TPlateNumber) {
387
455
  selectedPlateNumberObject.value = item || null;
456
+ actionError.value = "";
388
457
  dialog.approveVehicle = true;
389
458
  }
390
459
  function handleRestoreVehicle(item: TPlateNumber) {
391
460
  selectedPlateNumberObject.value = item || null;
461
+ actionError.value = "";
392
462
  dialog.restoreVehicle = true;
393
463
  }
394
464
 
395
465
  function handleUpdateType(item: TPlateNumber) {
396
466
  selectedPlateNumberObject.value = item || null;
467
+ actionError.value = "";
397
468
  dialog.updateVehicleType = true;
398
469
  }
399
470
 
400
- function hasVehicleActions(item: TPlateNumber) {
401
- const isActive = item?.status === "active";
402
- const isPending = item?.status === "pending";
403
- const isDeleted = item?.status === "deleted";
404
- const canUpdateType = item?.type === "whitelist" || item?.type === "blocklist";
405
-
406
- return (
407
- isActive ||
408
- (props.canApproveVehicle && isPending) ||
409
- isDeleted ||
410
- (canUpdateType && isActive) ||
411
- (props.canDeleteVehicle && isActive)
412
- );
471
+ /**
472
+ * The three gates the action list is decided by, passed straight through from
473
+ * the host app's `useLocalPermission()`.
474
+ */
475
+ const vehiclePermissions = computed(() => ({
476
+ canUpdateVehicle: props.canUpdateVehicle,
477
+ canDeleteVehicle: props.canDeleteVehicle,
478
+ canApproveVehicle: props.canApproveVehicle,
479
+ }));
480
+
481
+ /**
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.
484
+ */
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
+ }
413
498
  }
414
499
 
415
500
  function handleSelectVehicleStatus(value: TVehicleType) {
@@ -469,14 +554,21 @@ async function submitDelete() {
469
554
 
470
555
  try {
471
556
  deletingVehicle.value = true;
557
+ deleteError.value = "";
472
558
  const res = await deleteVehicle({ site: props.site, recno: recNo, id: plateNumberId as string, type }
473
559
  );
474
560
  dialog.deleteVehicle = false;
475
561
  showMessage(res.message, "success");
476
562
  await getVehiclesRefresh();
477
563
  } catch (error: any) {
478
- console.error("Error deleting vehicle:", error);
479
- message.value = error.response._data.message;
564
+ // The delete is fail-closed on the server: if the plate cannot be confirmed
565
+ // gone from every ANPR camera holding it, the record is left intact and the
566
+ // request is refused. Say so. This used to assign to `message` WITHOUT
567
+ // raising the snackbar - and `error.response._data` throws on a network
568
+ // error - so a refused delete showed the operator nothing at all.
569
+ deleteError.value = readServerMessage(error, "Failed to delete vehicle.");
570
+ // The dialog stays open and the row stays in the table - nothing is removed
571
+ // optimistically, so what is on screen still matches the database.
480
572
  } finally {
481
573
  deletingVehicle.value = false;
482
574
  }
@@ -495,17 +587,15 @@ async function submitRestore() {
495
587
 
496
588
  try {
497
589
  restoringVehicle.value = true;
590
+ actionError.value = "";
498
591
  // reactivate and restore will use the same endpoint, just with different payload
499
592
  const res = await approveVehicle({ site: props.site, org: props.org, id: vehicleId as string }
500
593
  );
501
- dialog.restoreVehicle = false;
594
+ closeActionDialog("restoreVehicle");
502
595
  showMessage(res.message, "success");
503
596
  await getVehiclesRefresh();
504
597
  } catch (error: any) {
505
- console.error("Error restoring vehicle:", error);
506
- const errMessage = error?.response?._data?.message || "Failed to restore vehicle";
507
- showMessage(errMessage, "error");
508
- // message.value = error.response._data.message;
598
+ actionError.value = readServerMessage(error, "Failed to restore vehicle.");
509
599
  } finally {
510
600
  restoringVehicle.value = false;
511
601
  }
@@ -546,15 +636,13 @@ async function submitUpdateType() {
546
636
 
547
637
  try {
548
638
  updatingType.value = true;
639
+ actionError.value = "";
549
640
  const res = await updateVehicle(vehicleId as string, payload);
550
- dialog.updateVehicleType = false;
641
+ closeActionDialog("updateVehicleType");
551
642
  showMessage(res.message, "success");
552
643
  await getVehiclesRefresh();
553
644
  } catch (error: any) {
554
- console.error("Error updating vehicle type:", error);
555
- const errMessage = error?.response?._data?.message || "Failed to update vehicle type";
556
- showMessage(errMessage, "error");
557
- // message.value = error.response._data.message;
645
+ actionError.value = readServerMessage(error, "Failed to update vehicle type.");
558
646
  } finally {
559
647
  updatingType.value = false;
560
648
  }
@@ -571,16 +659,15 @@ async function submitApprove() {
571
659
 
572
660
  try {
573
661
  approvingVehicle.value = true;
662
+ actionError.value = "";
574
663
  const res = await approveVehicle({ site: props.site, org: props.org, id: plateNumberId as string }
575
664
 
576
665
  );
577
- dialog.approveVehicle = false;
666
+ closeActionDialog("approveVehicle");
578
667
  showMessage(res.message, "success");
579
668
  await getVehiclesRefresh();
580
669
  } catch (error: any) {
581
- console.error("Error approving vehicle:", error);
582
- const errMessage = error?.response?._data?.message || "Failed to approve vehicle";
583
- showMessage(errMessage, "error");
670
+ actionError.value = readServerMessage(error, "Failed to approve vehicle.");
584
671
  } finally {
585
672
  approvingVehicle.value = false;
586
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.262",
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
+ }