@7365admin1/layer-common 4.2.2-staging.263 → 4.2.2-staging.265

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.
@@ -46,7 +46,7 @@
46
46
  label="Email"
47
47
  density="comfortable"
48
48
  variant="outlined"
49
- hide-details
49
+ :rules="[emailRule]"
50
50
  />
51
51
  </v-col>
52
52
 
@@ -146,6 +146,15 @@
146
146
  hide-details
147
147
  class="mb-2"
148
148
  />
149
+ <v-text-field
150
+ v-model="member.email"
151
+ type="email"
152
+ label="Email"
153
+ density="comfortable"
154
+ variant="outlined"
155
+ :rules="[requiredRule, emailRule]"
156
+ class="mb-2"
157
+ />
149
158
  <InputPhoneNumberV2 v-model="member.contact" density="comfortable" hide-details class="mb-2" />
150
159
  <v-text-field
151
160
  v-model="member.plateNumber"
@@ -156,6 +165,10 @@
156
165
  />
157
166
  </div>
158
167
 
168
+ <v-alert v-if="membersError" type="error" variant="tonal" density="compact" class="mb-3">
169
+ {{ membersError }}
170
+ </v-alert>
171
+
159
172
  <v-btn variant="outlined" block class="mb-3" prepend-icon="mdi-plus" @click="addDraftMember">
160
173
  Add
161
174
  </v-btn>
@@ -166,6 +179,8 @@
166
179
  </template>
167
180
 
168
181
  <script lang="ts" setup>
182
+ import { isLikelyEmail } from "../../utils/self-service-links";
183
+
169
184
  const prop = defineProps({
170
185
  site: { type: String, required: true },
171
186
  });
@@ -176,6 +191,10 @@ const { createVisitor } = usePublicVisitorOnboarding();
176
191
  const { isSupported: isContactPickerSupported, pickContact } = useContactPicker();
177
192
 
178
193
  const requiredRule = (v: any) => (v !== null && v !== undefined && v !== "") || "This field is required";
194
+ // Optional, but if typed it has to be sendable - the API validates it with Joi
195
+ // and would otherwise reject the whole submission with a message about a field
196
+ // the visitor cannot see.
197
+ const emailRule = (v: any) => !v || isLikelyEmail(v) || "Enter a valid email address";
179
198
 
180
199
  const contractorTypes = [
181
200
  { title: "Home Contractor", value: "home-contractor" },
@@ -199,25 +218,40 @@ const visitor = reactive({
199
218
  remarks: "",
200
219
  });
201
220
 
202
- type TDraftMember = { name: string; nric: string; contact: string; plateNumber: string };
221
+ type TDraftMember = { name: string; nric: string; email: string; contact: string; plateNumber: string };
203
222
  const members = ref<TDraftMember[]>([]);
204
223
  const draftMembers = ref<TDraftMember[]>([]);
205
224
  const showMembersDialog = ref(false);
225
+ const membersError = ref("");
206
226
 
207
227
  watch(showMembersDialog, (open) => {
208
228
  if (open) {
209
229
  draftMembers.value = members.value.length
210
230
  ? members.value.map((m) => ({ ...m }))
211
- : [{ name: "", nric: "", contact: "", plateNumber: "" }];
231
+ : [{ name: "", nric: "", email: "", contact: "", plateNumber: "" }];
232
+ membersError.value = "";
212
233
  }
213
234
  });
214
235
 
215
236
  function addDraftMember() {
216
- draftMembers.value.push({ name: "", nric: "", contact: "", plateNumber: "" });
237
+ draftMembers.value.push({ name: "", nric: "", email: "", contact: "", plateNumber: "" });
217
238
  }
218
239
 
219
240
  function saveMembers() {
220
- members.value = draftMembers.value.filter((m) => m.name && m.contact);
241
+ // The members dialog is lazy (no `eager`), so its fields unmount when it
242
+ // closes and stop counting towards the form's own `formValid` - which is what
243
+ // gates Submit. Without this guard a malformed member address would sail past
244
+ // both, and the API would reject the entire submission with a Joi message
245
+ // naming a field the contractor can no longer see. Blank stays allowed: an
246
+ // email is optional per member, it just means no mail of their own.
247
+ const invalid = draftMembers.value.find((m) => m.email && !isLikelyEmail(m.email));
248
+ if (invalid) {
249
+ membersError.value = `Enter a valid email address for ${invalid.name || "this member"}, or leave it blank.`;
250
+ return;
251
+ }
252
+
253
+ membersError.value = "";
254
+ members.value = draftMembers.value.filter((m) => m.name && m.contact && m.email);
221
255
  showMembersDialog.value = false;
222
256
  }
223
257
 
@@ -256,6 +290,7 @@ async function submit() {
256
290
  const res: any = await createVisitor(prop.site, payload);
257
291
  emit("done", res?.status || "pending", res?._id, {
258
292
  name: visitor.name,
293
+ email: visitor.email,
259
294
  expectedCheckIn: visitor.expectedCheckIn,
260
295
  members: res?.members || [],
261
296
  });
@@ -14,30 +14,59 @@
14
14
  Hi {{ leadName }}, here {{ members.length ? "are the check-in links for you and your team" : "is your check-in link" }}.
15
15
  </p>
16
16
 
17
- <div class="link-row pa-3 mb-3">
18
- <p class="text-body-2 font-weight-medium mb-2">{{ leadName }}</p>
19
- <div class="d-flex ga-2">
20
- <v-btn variant="outlined" color="error" size="small" class="flex-grow-1" @click="copyLink(leadLink)">
21
- Copy Link
22
- </v-btn>
23
- <v-btn variant="outlined" color="error" size="small" class="flex-grow-1" :href="leadLink" target="_blank">
24
- View Link
25
- </v-btn>
26
- </div>
27
- </div>
17
+ <template v-for="(row, index) in rows" :key="row._id">
18
+ <p v-if="index === 1" class="text-caption text-medium-emphasis mb-2">Members</p>
19
+
20
+ <div class="link-row pa-3 mb-3">
21
+ <p class="text-body-2 font-weight-medium mb-2">{{ row.name }}</p>
28
22
 
29
- <template v-if="members.length">
30
- <p class="text-caption text-medium-emphasis mb-2">Members</p>
31
- <div v-for="m in members" :key="m._id" class="link-row pa-3 mb-3">
32
- <p class="text-body-2 font-weight-medium mb-2">{{ m.name }}</p>
33
23
  <div class="d-flex ga-2">
34
- <v-btn variant="outlined" color="error" size="small" class="flex-grow-1" @click="copyLink(linkFor(m._id))">
24
+ <v-btn variant="outlined" color="error" size="small" class="flex-grow-1" @click="copyLink(linkFor(row._id))">
35
25
  Copy Link
36
26
  </v-btn>
37
- <v-btn variant="outlined" color="error" size="small" class="flex-grow-1" :href="linkFor(m._id)" target="_blank">
27
+ <v-btn variant="outlined" color="error" size="small" class="flex-grow-1" :href="linkFor(row._id)" target="_blank">
38
28
  View Link
39
29
  </v-btn>
30
+ <v-btn
31
+ variant="outlined"
32
+ color="error"
33
+ size="small"
34
+ class="flex-grow-1"
35
+ :loading="resendState[row._id] === 'sending'"
36
+ :disabled="resendState[row._id] === 'sending'"
37
+ @click="onResend(row)"
38
+ >
39
+ {{ resendState[row._id] === "sent" ? "Sent" : "Resend" }}
40
+ </v-btn>
41
+ </div>
42
+
43
+ <div v-if="showEmailInput[row._id]" class="d-flex ga-2 mt-3 align-start">
44
+ <v-text-field
45
+ v-model="emailDrafts[row._id]"
46
+ type="email"
47
+ label="Email"
48
+ density="compact"
49
+ variant="outlined"
50
+ hide-details
51
+ :disabled="resendState[row._id] === 'sending'"
52
+ @keyup.enter="onResend(row)"
53
+ />
54
+ <v-btn
55
+ color="error"
56
+ variant="flat"
57
+ size="small"
58
+ class="mt-1"
59
+ :loading="resendState[row._id] === 'sending'"
60
+ :disabled="resendState[row._id] === 'sending' || !isLikelyEmail(emailDrafts[row._id])"
61
+ @click="onResend(row)"
62
+ >
63
+ Send
64
+ </v-btn>
40
65
  </div>
66
+
67
+ <p v-if="resendError[row._id]" class="text-caption text-error mt-2 mb-0">
68
+ {{ resendError[row._id] }}
69
+ </p>
41
70
  </div>
42
71
  </template>
43
72
 
@@ -46,27 +75,116 @@
46
75
  </v-btn>
47
76
 
48
77
  <v-snackbar v-model="copied" timeout="1800" location="bottom">Link copied</v-snackbar>
78
+ <v-snackbar v-model="resent" timeout="2400" location="bottom">Email sent</v-snackbar>
49
79
 
50
80
  <PublicOnboardingShareDialog v-model="showShare" :message="shareMessage" :link="leadLink" />
51
81
  </v-card>
52
82
  </template>
53
83
 
54
84
  <script lang="ts" setup>
85
+ import { isLikelyEmail, needsEmailInput, passLink, type TPassRow } from "../../utils/self-service-links";
86
+
55
87
  const props = defineProps<{
56
88
  leadId: string;
57
89
  leadName: string;
90
+ leadEmail?: string | null;
58
91
  status: string;
59
92
  expectedCheckIn?: string | null;
60
- members: { _id: string; name: string }[];
93
+ members: { _id: string; name: string; email?: string | null }[];
61
94
  }>();
62
95
 
96
+ const { resendSelfServicePass } = usePublicVisitorOnboarding();
97
+
98
+ /**
99
+ * Addresses typed into a row's inline field and accepted by the server. The
100
+ * props are a snapshot of the moment the group was created, so without this a
101
+ * row would keep asking for an address it has already been given.
102
+ */
103
+ const sentEmails = ref<Record<string, string>>({});
104
+
105
+ /**
106
+ * The lead and every member as one list - they get the identical row, and the
107
+ * lead is simply the first of them. Index 1 is where the "Members" caption
108
+ * goes.
109
+ */
110
+ const rows = computed<TPassRow[]>(() =>
111
+ [
112
+ { _id: props.leadId, name: props.leadName, email: props.leadEmail ?? null },
113
+ ...props.members,
114
+ ].map((row) => ({ ...row, email: sentEmails.value[row._id] || row.email })),
115
+ );
116
+
63
117
  function linkFor(id: string) {
64
118
  const origin = import.meta.client ? window.location.origin : "";
65
- return `${origin}/public-view/visitor-onboarding/pass/${id}`;
119
+ return passLink(origin, id);
66
120
  }
67
121
 
68
122
  const leadLink = computed(() => linkFor(props.leadId));
69
123
 
124
+ const resendState = reactive<Record<string, "idle" | "sending" | "sent">>({});
125
+ const resendError = reactive<Record<string, string>>({});
126
+ const emailDrafts = reactive<Record<string, string>>({});
127
+ const showEmailInput = reactive<Record<string, boolean>>({});
128
+ const resent = ref(false);
129
+
130
+ /**
131
+ * One button, two jobs. A row that already has an address just sends. A row
132
+ * that has none opens its inline field first, and the second press - or Enter
133
+ * in the field - is the one that sends what was typed.
134
+ */
135
+ async function onResend(row: TPassRow) {
136
+ // The inline Send button and the Enter key both land here, and neither is
137
+ // gone from the DOM while a send is in flight. Without this, a double press
138
+ // fires two requests for one row: the second reliably trips the server's own
139
+ // cooldown (it is stamped before the send), and if it settles last it
140
+ // overwrites a successful send with a cooldown error the visitor cannot act on.
141
+ if (resendState[row._id] === "sending") return;
142
+
143
+ const draft = String(emailDrafts[row._id] ?? "").trim();
144
+
145
+ if (needsEmailInput(row) && !isLikelyEmail(draft)) {
146
+ showEmailInput[row._id] = true;
147
+ resendError[row._id] = "";
148
+ return;
149
+ }
150
+
151
+ resendError[row._id] = "";
152
+ resendState[row._id] = "sending";
153
+
154
+ try {
155
+ // Only sent for a row with no address of its own - the server refuses to
156
+ // redirect one that has, so there is nothing to gain by sending it anyway.
157
+ await resendSelfServicePass(row._id, needsEmailInput(row) ? draft : undefined);
158
+ resendState[row._id] = "sent";
159
+ // "Sent" is a moment-in-time confirmation, not a permanent state - the
160
+ // button still works on a later press, so it must not be stuck reading
161
+ // "Sent" for the rest of the page's life. Guarded: a later press may have
162
+ // already moved the row on to "sending" by the time this fires.
163
+ setTimeout(() => {
164
+ if (resendState[row._id] === "sent") resendState[row._id] = "idle";
165
+ }, 3000);
166
+ if (needsEmailInput(row)) {
167
+ sentEmails.value = { ...sentEmails.value, [row._id]: draft };
168
+ showEmailInput[row._id] = false;
169
+ }
170
+ resent.value = true;
171
+ } catch (error: any) {
172
+ resendState[row._id] = "idle";
173
+ const statusCode = error?.statusCode ?? error?.response?.status;
174
+ // The server's own words: the cooldown says how many seconds are left, and
175
+ // a rejected address says so. Both are written to be read by a visitor.
176
+ // The per-IP limiter's 429 is the one response here with no JSON body -
177
+ // it answers plain text, so `error.message` would otherwise put the raw
178
+ // "[POST] ... 429 Too Many Requests" fetch error on the row.
179
+ resendError[row._id] =
180
+ error?.data?.message ||
181
+ (statusCode === 429
182
+ ? "Too many attempts. Please wait a moment and try again."
183
+ : error?.message) ||
184
+ "Unable to send that email. Please try again.";
185
+ }
186
+ }
187
+
70
188
  const arrivalDateDisplay = computed(() => {
71
189
  if (!props.expectedCheckIn) return "";
72
190
  const d = new Date(props.expectedCheckIn);
@@ -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
  }
@@ -74,6 +74,19 @@ export default function usePublicVisitorOnboarding() {
74
74
  );
75
75
  }
76
76
 
77
+ // The Resend button on the links card. `email` is only meaningful for a
78
+ // registration that carries no address of its own - the server refuses to
79
+ // redirect one that does, so sending it for an addressed row is harmless but
80
+ // pointless. A 429 (per-record cooldown, or the per-IP limiter) and a 400
81
+ // both arrive as a thrown error carrying the server's own message, which the
82
+ // card shows on the row.
83
+ function resendSelfServicePass(id: string, email?: string) {
84
+ return useNuxtApp().$api<{ message: string }>(
85
+ `/api/visitor-transactions/self-service/resend/${id}`,
86
+ { method: "POST", body: email ? { email } : {} },
87
+ );
88
+ }
89
+
77
90
  return {
78
91
  getOnboardingSettings,
79
92
  getBlocks,
@@ -83,5 +96,6 @@ export default function usePublicVisitorOnboarding() {
83
96
  getOvernightParkingHours,
84
97
  getSelfServicePreview,
85
98
  getSelfServiceQrCode,
99
+ resendSelfServicePass,
86
100
  };
87
101
  }
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.265",
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,45 @@
1
+ /**
2
+ * The pure part of the self-service links card.
3
+ *
4
+ * The card hands a lead contractor and each of their members a Copy Link /
5
+ * View Link / Resend row. Which of those rows still has to ask for an email
6
+ * address before Resend can do anything is the whole of the interaction, so it
7
+ * is decided here rather than inline in the template.
8
+ */
9
+
10
+ export type TPassRow = {
11
+ _id: string;
12
+ name: string;
13
+ email?: string | null;
14
+ };
15
+
16
+ /**
17
+ * The public preview/QR page for one registration. `origin` is empty during
18
+ * the server render (there is no `window`), which correctly yields a relative
19
+ * link - the card only ever renders it into an href or the clipboard, both of
20
+ * which resolve it against the page the visitor is already on.
21
+ */
22
+ export function passLink(origin: string, id: string): string {
23
+ return `${origin}/public-view/visitor-onboarding/pass/${id}`;
24
+ }
25
+
26
+ /**
27
+ * True when this row has nobody to send to yet, which is the card's cue to
28
+ * open its inline email field instead of sending. A whitespace-only value is
29
+ * no address - the API would refuse it.
30
+ */
31
+ export function needsEmailInput(row: TPassRow): boolean {
32
+ return !String(row.email ?? "").trim();
33
+ }
34
+
35
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
36
+
37
+ /**
38
+ * Deliberately the same loose check the API applies
39
+ * (`isLikelySelfServiceEmail` in @7365admin1/core), so the Send button is not
40
+ * offered for something the server will only refuse. The server stays the
41
+ * authority; this just avoids a pointless round trip.
42
+ */
43
+ export function isLikelyEmail(value?: string | null): boolean {
44
+ return EMAIL_PATTERN.test(String(value ?? "").trim());
45
+ }
@@ -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
+ }