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