@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.
@@ -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 || "";
@@ -38,30 +38,40 @@
38
38
  </template>
39
39
 
40
40
  <!--
41
- Edit and Delete were already built and already shipping, but only two
42
- clicks deep: click the row -> the read-only "Preview" dialog -> the
43
- kebab on the NESTED plate table. Nothing on the row itself said the
44
- actions existed. This is the same kebab every other table in this
45
- package draws (`AppButton variant="row"` as a `v-menu` activator, see
46
- `MemberMain.vue`), and it calls the SAME handlers the nested menu calls,
47
- so there is exactly one code path per operation.
48
-
49
- `@click.stop` because the row carries `@row-click`; without it the
50
- kebab would open the Preview dialog underneath the menu.
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="rowPlate(item) && hasVehicleActions(rowPlate(item) as TPlateNumber)"
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
- <v-list-item v-if="canUpdateVehicle && rowPlate(item)?.status === 'active'" title="Edit Vehicle"
61
- prepend-icon="mdi-pencil" @click="handleEditVehicleAction(rowPlate(item) as TPlateNumber)" />
62
- <v-list-item v-if="canDeleteVehicle && rowPlate(item)?.status === 'active'" title="Delete"
63
- prepend-icon="mdi-delete" base-color="error"
64
- @click="handleDeleteVehicleAction(rowPlate(item) as TPlateNumber)" />
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, value }">
124
- <v-menu v-if="hasVehicleActions(plateNumberItem as TPlateNumber)">
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-if="canUpdateVehicle && (plateNumberItem as TPlateNumber)?.status == 'active'"
131
- title="Edit Vehicle" prepend-icon="mdi-pencil"
132
- @click="handleEditVehicleAction(plateNumberItem as TPlateNumber)" />
133
- <v-list-item
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
- <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"
188
187
  :prompt-title="`Are you sure want to approve this vehicle - ${selectedPlateNumberObject?.plateNumber}?`"
189
- @proceed="submitApprove" @close="dialog.approveVehicle = false" />
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="dialog.restoreVehicle = false" />
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="dialog.updateVehicleType = false" />
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 plate document a MAIN-table row actually is.
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
- function rowPlate(item: any): TPlateNumber | null {
451
- const plates = (item?.plates ?? []) as Array<TPlateNumber>;
452
- return (
453
- plates.find((plate) => String(plate?._id) === String(item?._id)) ??
454
- plates[0] ??
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
- * True when at least one menu item would render. It has to be the exact union
461
- * of the five `v-if`s below it, or the kebab appears over an empty menu.
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 hasVehicleActions(item: TPlateNumber) {
469
- const isActive = item?.status === "active";
470
- const isPending = item?.status === "pending";
471
- const isDeleted = item?.status === "deleted";
472
- const canUpdateType = item?.type === "whitelist" || item?.type === "blocklist";
473
-
474
- return (
475
- (props.canUpdateVehicle && isActive) ||
476
- (props.canApproveVehicle && isPending) ||
477
- isDeleted ||
478
- (props.canUpdateVehicle && canUpdateType && isActive) ||
479
- (props.canDeleteVehicle && isActive)
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
- console.error("Error deleting vehicle:", error);
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
- dialog.restoreVehicle = false;
594
+ closeActionDialog("restoreVehicle");
582
595
  showMessage(res.message, "success");
583
596
  await getVehiclesRefresh();
584
597
  } catch (error: any) {
585
- console.error("Error restoring vehicle:", error);
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
- dialog.updateVehicleType = false;
641
+ closeActionDialog("updateVehicleType");
631
642
  showMessage(res.message, "success");
632
643
  await getVehiclesRefresh();
633
644
  } catch (error: any) {
634
- console.error("Error updating vehicle type:", error);
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
- dialog.approveVehicle = false;
666
+ closeActionDialog("approveVehicle");
658
667
  showMessage(res.message, "success");
659
668
  await getVehiclesRefresh();
660
669
  } catch (error: any) {
661
- console.error("Error approving vehicle:", error);
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.263",
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
+ }