Add DepartmentSelfServeStudio.vue for advanced self-serve configuration management:

- Introduced a new Vue component supporting versioned configuration authoring for questions, conditions, tasks, and rules.
- Implemented scoped editing with lane and machine type-specific configurations.
- Added version tracking features, including validation, publication, and rollback.
- Enhanced UI with tabbed navigation, modals for adding/editing records, and notifications for actions.
This commit is contained in:
Jeppe Bundgaard
2026-03-25 17:55:17 +01:00
parent 2c22a548e1
commit 30d23aeaf8
15 changed files with 2359 additions and 122 deletions
+2 -1
View File
@@ -24,7 +24,8 @@ const routeTitleI18nKeys = {
"adminlogin": ["header.operator_login"], "adminlogin": ["header.operator_login"],
"guestHome": ["guest.home.title"], "guestHome": ["guest.home.title"],
"PwaDownload": ["guest.pwa.title"], "PwaDownload": ["guest.pwa.title"],
"connectivity-issue": ["connectivity.title"] "connectivity-issue": ["connectivity.title"],
"selfservestudio": ["admin.self_serve.title"]
}; };
const toReadableTitle = (value) => { const toReadableTitle = (value) => {
@@ -124,6 +124,20 @@ const modules = computed(() => [
isSavingSelfServeEnabled.value = false; isSavingSelfServeEnabled.value = false;
} }
} }
},
{
id: 6,
name: 'Self-Serve Studio',
description: 'Configure questions, conditions, rules and tasks',
icon: "fas fa-project-diagram",
onClick: () => {
const departmentId = router.currentRoute.value.params.departmentId;
router.push(`/admin/${departmentId}/modules/self-serve/studio`);
},
onClickCount: () => {
this.onClick();
},
count: 0
} }
/** /**
* { * {
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import Swal from "sweetalert2";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useSelfServeLogic } from "@/composables/useSelfServeLogic"; import { useSelfServeLogic } from "@/composables/useSelfServeLogic";
import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue"; import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue";
@@ -28,7 +29,16 @@ const props = defineProps({
const emit = defineEmits(["closeModal"]); const emit = defineEmits(["closeModal"]);
const availableLanes = ref([]); const availableLanes = ref([]);
const availableVehicleTypes = ref([]);
const selectedLaneId = ref(props.laneId ? parseInt(props.laneId) : null); const selectedLaneId = ref(props.laneId ? parseInt(props.laneId) : null);
const normalizeVehicleTypeId = (value) => {
const parsed = parseInt(value);
if (Number.isNaN(parsed) || parsed <= 0) {
return null;
}
return parsed;
};
const selectedVehicleTypeId = ref(normalizeVehicleTypeId(props.vehicleTypeId));
const reg = ref("AB12345"); const reg = ref("AB12345");
const { const {
@@ -44,17 +54,84 @@ const {
evaluationTrace, evaluationTrace,
visibleQuestions, visibleQuestions,
activeTasks, activeTasks,
currentQuestion,
allVisibleQuestionsAnswered,
machineAvailable, machineAvailable,
allowed, allowed,
fetchSelfServeData, fetchSelfServeData,
syncVehicleAnswer, syncVehicleAnswer,
clearVehicleAnswers,
evaluateCondition,
} = useSelfServeLogic(); } = useSelfServeLogic();
const emptyCompletedTasks = computed(() => ({})); const emptyCompletedTasks = computed(() => ({}));
const isAnswered = (questionId) => {
const answer = answers.value[questionId];
return answer === true || answer === false;
};
const displayQuestions = computed(() => {
const localVisibleQuestions = [...questions.value]
.filter((question) => {
const conditionId = parseInt(question?.condition_id ?? 0);
if (!conditionId) {
return true;
}
return evaluateCondition(conditionId);
})
.sort((a, b) => {
const priorityA = parseInt(a?.order_priority ?? 0);
const priorityB = parseInt(b?.order_priority ?? 0);
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
return parseInt(a?.id ?? 0) - parseInt(b?.id ?? 0);
});
if (visibleQuestions.value.length > 0) {
const mergedById = new Map();
visibleQuestions.value.forEach((question) => {
mergedById.set(parseInt(question.id), question);
});
// Keep unanswered follow-up questions visible even if backend summary is briefly stale.
localVisibleQuestions.forEach((question) => {
const questionId = parseInt(question.id);
if (!mergedById.has(questionId) && !isAnswered(questionId)) {
mergedById.set(questionId, question);
}
});
return [...mergedById.values()].sort((a, b) => {
const priorityA = parseInt(a?.order_priority ?? 0);
const priorityB = parseInt(b?.order_priority ?? 0);
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
return parseInt(a?.id ?? 0) - parseInt(b?.id ?? 0);
});
}
const hasUnansweredQuestions = localVisibleQuestions.some((question) => !isAnswered(question.id));
if (!hasUnansweredQuestions) {
return visibleQuestions.value;
}
return localVisibleQuestions;
});
const allDisplayQuestionsAnswered = computed(() => (
displayQuestions.value.length > 0
&& displayQuestions.value.every((question) => isAnswered(question.id))
));
const answeredQuestions = computed(() => ( const answeredQuestions = computed(() => (
visibleQuestions.value.filter((question) => answers.value[question.id] === true || answers.value[question.id] === false) displayQuestions.value.filter((question) => isAnswered(question.id))
));
const normalizedReg = computed(() => reg.value.trim().toUpperCase());
const canClearAnswers = computed(() => (
!!selectedLaneId.value
&& normalizedReg.value.length >= 2
&& !loading.value
)); ));
const loadLanes = async () => { const loadLanes = async () => {
@@ -66,14 +143,31 @@ const loadLanes = async () => {
} }
}; };
const loadVehicleTypes = async () => {
const options = await SessionUser.objects.vehicles.columns.type.options(true, {
isWash: true,
addDefaultOption: true,
restrictToCategory4: null,
includeProductRaw: true,
});
availableVehicleTypes.value = (options || [])
.map((entry) => ({
id: parseInt(entry.id),
name: entry.name,
}))
.filter((entry) => !Number.isNaN(entry.id) && entry.id > 0);
};
const refresh = async () => { const refresh = async () => {
if (!selectedLaneId.value || !reg.value.trim()) { if (!selectedLaneId.value || !reg.value.trim()) {
return; return;
} }
const vehicleTypeId = normalizeVehicleTypeId(selectedVehicleTypeId.value);
await fetchSelfServeData( await fetchSelfServeData(
props.departmentId, props.departmentId,
props.vehicleTypeId, vehicleTypeId,
selectedLaneId.value, selectedLaneId.value,
reg.value.trim().toUpperCase() reg.value.trim().toUpperCase()
); );
@@ -87,6 +181,33 @@ const submitAnswer = async (questionId, value) => {
reg: reg.value, reg: reg.value,
questionId, questionId,
value, value,
vehicleTypeId: normalizeVehicleTypeId(selectedVehicleTypeId.value),
});
};
const clearAnswers = async () => {
if (!canClearAnswers.value) {
return;
}
const confirmation = await Swal.fire({
title: "Ryd besvarelser?",
text: `Registreringsnummer ${normalizedReg.value} pa bane ${selectedLaneId.value} bliver ryddet.`,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Ja, ryd besvarelser",
cancelButtonText: "Annuller",
});
if (!confirmation.isConfirmed) {
return;
}
await clearVehicleAnswers({
departmentId: props.departmentId,
laneId: selectedLaneId.value,
vehicleTypeId: normalizeVehicleTypeId(selectedVehicleTypeId.value),
reg: normalizedReg.value,
}); });
}; };
@@ -96,11 +217,12 @@ const closeModal = () => {
onMounted(async () => { onMounted(async () => {
await loadLanes(); await loadLanes();
await loadVehicleTypes();
await refresh(); await refresh();
}); });
watch(() => [selectedLaneId.value, reg.value], async ([laneId, registration], [oldLaneId, oldRegistration] = []) => { watch(() => [selectedLaneId.value, reg.value, normalizeVehicleTypeId(selectedVehicleTypeId.value)], async ([laneId, registration, vehicleTypeId], [oldLaneId, oldRegistration, oldVehicleTypeId] = []) => {
if (laneId === oldLaneId && registration === oldRegistration) { if (laneId === oldLaneId && registration === oldRegistration && vehicleTypeId === oldVehicleTypeId) {
return; return;
} }
@@ -126,11 +248,11 @@ watch(() => [selectedLaneId.value, reg.value], async ([laneId, registration], [o
<div class="column is-12"> <div class="column is-12">
<div class="box"> <div class="box">
<div class="columns is-multiline"> <div class="columns is-multiline">
<div class="column is-4"> <div class="column is-3">
<label class="label">Registreringsnummer</label> <label class="label">Registreringsnummer</label>
<input v-model="reg" class="input" type="text" placeholder="AB12345" data-testid="self-serve-try-reg" /> <input v-model="reg" class="input" type="text" placeholder="AB12345" data-testid="self-serve-try-reg" />
</div> </div>
<div v-if="!props.laneId" class="column is-4"> <div v-if="!props.laneId" class="column is-3">
<label class="label">Bane</label> <label class="label">Bane</label>
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select v-model="selectedLaneId" data-testid="self-serve-try-lane"> <select v-model="selectedLaneId" data-testid="self-serve-try-lane">
@@ -141,11 +263,22 @@ watch(() => [selectedLaneId.value, reg.value], async ([laneId, registration], [o
</select> </select>
</div> </div>
</div> </div>
<div v-else class="column is-4"> <div v-else class="column is-3">
<label class="label">Bane</label> <label class="label">Bane</label>
<input class="input" :value="selectedLaneId" type="text" disabled> <input class="input" :value="selectedLaneId" type="text" disabled>
</div> </div>
<div class="column is-4 is-flex is-align-items-flex-end"> <div class="column is-3">
<label class="label">Koretojstype</label>
<div class="select is-fullwidth">
<select v-model="selectedVehicleTypeId" data-testid="self-serve-try-vehicle-type">
<option :value="null">Auto (fra registreringsnummer)</option>
<option v-for="entry in availableVehicleTypes" :key="entry.id" :value="entry.id">
{{ entry.name }}
</option>
</select>
</div>
</div>
<div class="column is-3 is-flex is-align-items-flex-end">
<button class="button is-link is-fullwidth" data-testid="self-serve-try-refresh" :class="{ 'is-loading': loading }" @click="refresh"> <button class="button is-link is-fullwidth" data-testid="self-serve-try-refresh" :class="{ 'is-loading': loading }" @click="refresh">
Hent preview Hent preview
</button> </button>
@@ -159,8 +292,8 @@ watch(() => [selectedLaneId.value, reg.value], async ([laneId, registration], [o
<span class="tag" :class="machineAvailable ? 'is-success' : 'is-light'"> <span class="tag" :class="machineAvailable ? 'is-success' : 'is-light'">
Maskine tilgaengelig: {{ machineAvailable ? "Ja" : "Nej" }} Maskine tilgaengelig: {{ machineAvailable ? "Ja" : "Nej" }}
</span> </span>
<span class="tag" :class="allVisibleQuestionsAnswered ? 'is-success' : 'is-warning'"> <span class="tag" :class="allDisplayQuestionsAnswered ? 'is-success' : 'is-warning'">
Alle synlige sporgsmal besvaret: {{ allVisibleQuestionsAnswered ? "Ja" : "Nej" }} Alle synlige sporgsmal besvaret: {{ allDisplayQuestionsAnswered ? "Ja" : "Nej" }}
</span> </span>
<span v-if="session" class="tag is-info"> <span v-if="session" class="tag is-info">
Session: {{ session.status }} (#{{ session.id }}) Session: {{ session.status }} (#{{ session.id }})
@@ -188,25 +321,12 @@ watch(() => [selectedLaneId.value, reg.value], async ([laneId, registration], [o
<div class="box" style="height: 100%"> <div class="box" style="height: 100%">
<h4 class="title is-5">Sporgsmal</h4> <h4 class="title is-5">Sporgsmal</h4>
<div v-if="currentQuestion" class="notification is-info is-light"> <div v-if="displayQuestions.length === 0" class="notification is-success is-light">
<p class="title is-6">{{ currentQuestion.question }}</p> <p>Ingen synlige sporgsmal for denne preview.</p>
<p v-if="currentQuestion.description" class="is-size-7 mb-3">{{ currentQuestion.description }}</p>
<div class="buttons">
<button class="button is-success" :class="{ 'is-loading': loading }" @click="submitAnswer(currentQuestion.id, true)">
{{ $t("common.yes") }}
</button>
<button class="button is-danger" :class="{ 'is-loading': loading }" @click="submitAnswer(currentQuestion.id, false)">
{{ $t("common.no") }}
</button>
</div>
</div>
<div v-else class="notification is-success is-light">
<p>{{ visibleQuestions.length === 0 ? "Ingen synlige sporgsmal for denne preview." : "Alle synlige sporgsmal er besvaret." }}</p>
</div> </div>
<SelfServeQuestionCards <SelfServeQuestionCards
:visibleQuestions="visibleQuestions" :visibleQuestions="displayQuestions"
:answers="answers" :answers="answers"
@answer-question="submitAnswer" @answer-question="submitAnswer"
/> />
@@ -249,7 +369,18 @@ watch(() => [selectedLaneId.value, reg.value], async ([laneId, registration], [o
<hr /> <hr />
<h5 class="subtitle is-6">Besvarede sporgsmal</h5> <div class="is-flex is-align-items-center is-justify-content-space-between mb-2">
<h5 class="subtitle is-6 mb-0">Besvarede sporgsmal</h5>
<button
class="button is-small is-light"
data-testid="self-serve-try-clear-answers"
:class="{ 'is-loading': loading }"
:disabled="!canClearAnswers"
@click="clearAnswers"
>
Ryd besvarelser
</button>
</div>
<ul> <ul>
<li v-for="question in answeredQuestions" :key="question.id" class="mb-1"> <li v-for="question in answeredQuestions" :key="question.id" class="mb-1">
{{ question.question }}: {{ question.question }}:
@@ -163,11 +163,19 @@ export const SelfServeVehicleConditions = {
single: async (id) => { single: async (id) => {
return ObjectsGlobal.get.object(SelfServeVehicleConditions.meta.endpoint, id); return ObjectsGlobal.get.object(SelfServeVehicleConditions.meta.endpoint, id);
}, },
previewAllowed: async (laneId, reg) => { previewAllowed: async (laneId, reg, vehicleTypeId = null) => {
return authenticatedRequest("/department/selfserve/vehicle/allowed", "GET", { const params = {
lane_id: parseInt(laneId), lane_id: parseInt(laneId),
reg: reg, reg: reg,
}).then((response) => response.data.data || response.data); };
const normalizedVehicleTypeId = parseInt(vehicleTypeId);
if (!Number.isNaN(normalizedVehicleTypeId) && normalizedVehicleTypeId > 0) {
params.vehicle_type = normalizedVehicleTypeId;
}
return authenticatedRequest("/department/selfserve/vehicle/allowed", "GET", params)
.then((response) => response.data.data || response.data);
}, },
washSummary: async (params = {}) => { washSummary: async (params = {}) => {
return authenticatedRequest("/department/selfserve/washes/summary", "GET", params) return authenticatedRequest("/department/selfserve/washes/summary", "GET", params)
+246 -19
View File
@@ -52,6 +52,9 @@ const normalizeTask = (task) => ({
}); });
const extractTaskId = (task) => parseInt(task?.task_id ?? task?.id ?? 0) || null; const extractTaskId = (task) => parseInt(task?.task_id ?? task?.id ?? 0) || null;
const extractVehicleConditionQuestionId = (condition) => (
parseInt(condition?.question?.id ?? condition?.question_id ?? condition?.question ?? 0) || null
);
const buildAnswersMap = (questions) => questions.reduce((accumulator, question) => { const buildAnswersMap = (questions) => questions.reduce((accumulator, question) => {
accumulator[question.id] = question.answer; accumulator[question.id] = question.answer;
@@ -70,6 +73,35 @@ const sortByOrderPriority = (list) => [...list].sort((a, b) => {
return parseInt(a?.id ?? 0) - parseInt(b?.id ?? 0); return parseInt(a?.id ?? 0) - parseInt(b?.id ?? 0);
}); });
const normalizePositiveInt = (value) => {
const parsed = parseInt(value);
return !Number.isNaN(parsed) && parsed > 0 ? parsed : null;
};
const extractResolvedVehicleTypeId = (payload) => {
if (!payload || typeof payload !== "object") {
return null;
}
const candidates = [
payload?.vehicle_type_id,
payload?.session?.vehicle_type_id,
payload?.vehicle?.vehicle_type_id,
payload?.vehicle?.type_id,
payload?.vehicle?.type?.id,
payload?.vehicle?.type,
];
for (const candidate of candidates) {
const normalizedCandidate = normalizePositiveInt(candidate);
if (normalizedCandidate !== null) {
return normalizedCandidate;
}
}
return null;
};
const mergeQuestion = (existingQuestion, incomingQuestion, incomingRaw = {}) => { const mergeQuestion = (existingQuestion, incomingQuestion, incomingRaw = {}) => {
if (!existingQuestion) { if (!existingQuestion) {
return incomingQuestion; return incomingQuestion;
@@ -177,8 +209,22 @@ export function useSelfServeLogic() {
const configVersionId = ref(null); const configVersionId = ref(null);
const evaluationTrace = ref(null); const evaluationTrace = ref(null);
const lastPreviewContextKey = ref(null); const lastPreviewContextKey = ref(null);
const lastVehicleTypeOverride = ref(null);
const lastResolvedVehicleTypeId = ref(null);
const summaryVisibleQuestionIds = ref([]); const summaryVisibleQuestionIds = ref([]);
const summaryQuestionOrder = ref({}); const summaryQuestionOrder = ref({});
const latestFetchRequestId = ref(0);
const beginFetchRequest = () => {
latestFetchRequestId.value += 1;
return latestFetchRequestId.value;
};
const isFetchRequestActive = (requestId) => (
requestId === null
|| requestId === undefined
|| requestId === latestFetchRequestId.value
);
const setSummaryVisibleQuestions = (questionList = []) => { const setSummaryVisibleQuestions = (questionList = []) => {
const normalizedIds = (Array.isArray(questionList) ? questionList : []) const normalizedIds = (Array.isArray(questionList) ? questionList : [])
@@ -192,6 +238,18 @@ export function useSelfServeLogic() {
}, {}); }, {});
}; };
const updateResolvedVehicleTypeId = (...sources) => {
for (const source of sources) {
const resolvedVehicleTypeId = extractResolvedVehicleTypeId(source);
if (resolvedVehicleTypeId !== null) {
lastResolvedVehicleTypeId.value = resolvedVehicleTypeId;
return resolvedVehicleTypeId;
}
}
return lastResolvedVehicleTypeId.value;
};
const isImage = (attachment) => { const isImage = (attachment) => {
const filename = attachment?.content?.other || ""; const filename = attachment?.content?.other || "";
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"]; const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
@@ -246,7 +304,11 @@ export function useSelfServeLogic() {
}; };
const applyPreviewData = async (previewData, options = {}) => { const applyPreviewData = async (previewData, options = {}) => {
const { mergeQuestions = false } = options; const { mergeQuestions = false, requestId = null } = options;
if (!isFetchRequestActive(requestId)) {
return false;
}
preview.value = previewData || null; preview.value = previewData || null;
lane.value = previewData?.lane || lane.value; lane.value = previewData?.lane || lane.value;
machineType.value = previewData?.machine_type || machineType.value; machineType.value = previewData?.machine_type || machineType.value;
@@ -289,13 +351,24 @@ export function useSelfServeLogic() {
? previewData.tasks.map(normalizeTask).sort((a, b) => a.order_priority - b.order_priority) ? previewData.tasks.map(normalizeTask).sort((a, b) => a.order_priority - b.order_priority)
: []; : [];
tasks.value = await hydrateTaskAttachments(normalizedTasks); const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
if (!isFetchRequestActive(requestId)) {
return false;
}
tasks.value = hydratedTasks;
allowedServices.value = Array.isArray(previewData?.allowed_services) allowedServices.value = Array.isArray(previewData?.allowed_services)
? previewData.allowed_services ? previewData.allowed_services
: []; : [];
return true;
}; };
const applySummaryData = async (summaryData) => { const applySummaryData = async (summaryData, options = {}) => {
const { requestId = null } = options;
if (!isFetchRequestActive(requestId)) {
return false;
}
summary.value = summaryData || null; summary.value = summaryData || null;
session.value = summaryData?.session || session.value; session.value = summaryData?.session || session.value;
lane.value = summaryData?.lane || lane.value; lane.value = summaryData?.lane || lane.value;
@@ -332,9 +405,16 @@ export function useSelfServeLogic() {
.map(normalizeTask) .map(normalizeTask)
.sort((a, b) => a.order_priority - b.order_priority); .sort((a, b) => a.order_priority - b.order_priority);
tasks.value = await hydrateTaskAttachments(normalizedTasks); const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
if (!isFetchRequestActive(requestId)) {
return false;
}
tasks.value = hydratedTasks;
allowedServices.value = Array.from(new Set(normalizedTasks.flatMap((task) => task.services || []))); allowedServices.value = Array.from(new Set(normalizedTasks.flatMap((task) => task.services || [])));
} }
return true;
}; };
const downloadAttachment = async (taskId, attachmentId) => { const downloadAttachment = async (taskId, attachmentId) => {
@@ -350,7 +430,8 @@ export function useSelfServeLogic() {
} }
}; };
const fetchWashSummary = async (params = {}, manageLoading = true) => { const fetchWashSummary = async (params = {}, manageLoading = true, options = {}) => {
const { requestId = null } = options;
if (!params.session_id && !(params.lane_id && params.reg)) { if (!params.session_id && !(params.lane_id && params.reg)) {
return null; return null;
} }
@@ -361,7 +442,10 @@ export function useSelfServeLogic() {
try { try {
const summaryData = await SessionUser.objects.self_serve_vehicle_conditions.get.washSummary(params); const summaryData = await SessionUser.objects.self_serve_vehicle_conditions.get.washSummary(params);
await applySummaryData(summaryData); const didApply = await applySummaryData(summaryData, { requestId });
if (!didApply) {
return null;
}
return summaryData; return summaryData;
} catch (error) { } catch (error) {
console.error("Error fetching self-serve summary:", error); console.error("Error fetching self-serve summary:", error);
@@ -374,6 +458,8 @@ export function useSelfServeLogic() {
}; };
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null) => { const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null) => {
const requestId = beginFetchRequest();
if (!laneId || !reg || reg.trim().length < 2) { if (!laneId || !reg || reg.trim().length < 2) {
preview.value = null; preview.value = null;
summary.value = null; summary.value = null;
@@ -391,33 +477,93 @@ export function useSelfServeLogic() {
configVersionId.value = null; configVersionId.value = null;
evaluationTrace.value = null; evaluationTrace.value = null;
lastPreviewContextKey.value = null; lastPreviewContextKey.value = null;
lastVehicleTypeOverride.value = null;
lastResolvedVehicleTypeId.value = null;
summaryVisibleQuestionIds.value = []; summaryVisibleQuestionIds.value = [];
summaryQuestionOrder.value = {}; summaryQuestionOrder.value = {};
loading.value = false;
return null; return null;
} }
loading.value = true; loading.value = true;
try { try {
const normalizedReg = reg.trim().toUpperCase(); const normalizedReg = reg.trim().toUpperCase();
const contextKey = `${parseInt(laneId)}:${normalizedReg}`; const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
const hasVehicleTypeOverride = !Number.isNaN(normalizedVehicleTypeId) && normalizedVehicleTypeId > 0;
lastVehicleTypeOverride.value = hasVehicleTypeOverride ? normalizedVehicleTypeId : null;
if (hasVehicleTypeOverride) {
lastResolvedVehicleTypeId.value = normalizedVehicleTypeId;
}
const contextVehicleTypeKey = hasVehicleTypeOverride
? normalizedVehicleTypeId
: (lastResolvedVehicleTypeId.value ?? "auto");
const contextKey = `${parseInt(laneId)}:${normalizedReg}:${contextVehicleTypeKey}`;
const shouldMergeQuestions = lastPreviewContextKey.value === contextKey; const shouldMergeQuestions = lastPreviewContextKey.value === contextKey;
const previewData = await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg); const previewData = hasVehicleTypeOverride
await applyPreviewData(previewData, { mergeQuestions: shouldMergeQuestions }); ? await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg, normalizedVehicleTypeId)
lastPreviewContextKey.value = contextKey; : await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg);
if (!isFetchRequestActive(requestId)) {
return null;
}
const didApplyPreview = await applyPreviewData(previewData, {
mergeQuestions: shouldMergeQuestions,
requestId,
});
if (!didApplyPreview || !isFetchRequestActive(requestId)) {
return null;
}
updateResolvedVehicleTypeId(previewData, previewData?.session, previewData?.vehicle);
const resolvedContextVehicleTypeKey = hasVehicleTypeOverride
? normalizedVehicleTypeId
: (lastResolvedVehicleTypeId.value ?? "auto");
lastPreviewContextKey.value = `${parseInt(laneId)}:${normalizedReg}:${resolvedContextVehicleTypeKey}`;
let summaryData = null; let summaryData = null;
if (previewData?.session?.id) { if (previewData?.session?.id) {
summaryData = await fetchWashSummary({ session_id: previewData.session.id }, false); summaryData = await fetchWashSummary(
{ session_id: previewData.session.id },
false,
{ requestId }
);
} }
let hasSummaryQuestions = Array.isArray(summaryData?.questions); if (!isFetchRequestActive(requestId)) {
if (!hasSummaryQuestions && previewData?.session?.id) { return null;
const fallbackSummary = await fetchWashSummary({ lane_id: parseInt(laneId), reg: normalizedReg }, false); }
hasSummaryQuestions = Array.isArray(fallbackSummary?.questions);
updateResolvedVehicleTypeId(summaryData, summaryData?.session);
let hasSummaryQuestions = Array.isArray(summaryData?.questions) && summaryData.questions.length > 0;
const shouldRunFallbackSummary = !!previewData?.session?.id
&& (!hasSummaryQuestions || !hasVehicleTypeOverride);
if (shouldRunFallbackSummary) {
const fallbackSummaryParams = {
lane_id: parseInt(laneId),
reg: normalizedReg,
};
const fallbackVehicleTypeId = hasVehicleTypeOverride
? normalizedVehicleTypeId
: updateResolvedVehicleTypeId(previewData, summaryData, session.value, vehicle.value);
if (fallbackVehicleTypeId !== null) {
fallbackSummaryParams.vehicle_type = fallbackVehicleTypeId;
}
const fallbackSummary = await fetchWashSummary(
fallbackSummaryParams,
false,
{ requestId }
);
if (!isFetchRequestActive(requestId)) {
return null;
}
updateResolvedVehicleTypeId(fallbackSummary, fallbackSummary?.session);
hasSummaryQuestions = Array.isArray(fallbackSummary?.questions) && fallbackSummary.questions.length > 0;
} }
// If summary is not available yet, keep the preview list visible so the Questions step is not empty. // If summary is not available yet, keep the preview list visible so the Questions step is not empty.
if (!hasSummaryQuestions) { if (!hasSummaryQuestions && isFetchRequestActive(requestId)) {
setSummaryVisibleQuestions(questions.value); setSummaryVisibleQuestions(questions.value);
} }
@@ -426,11 +572,13 @@ export function useSelfServeLogic() {
console.error("Error fetching self-serve preview:", error); console.error("Error fetching self-serve preview:", error);
return null; return null;
} finally { } finally {
loading.value = false; if (isFetchRequestActive(requestId)) {
loading.value = false;
}
} }
}; };
const syncVehicleAnswer = async ({ departmentId, laneId, customerNumber = null, reg, questionId, value }) => { const syncVehicleAnswer = async ({ departmentId, laneId, customerNumber = null, reg, questionId, value, vehicleTypeId = undefined }) => {
if (!departmentId || !laneId || !reg || !questionId) { if (!departmentId || !laneId || !reg || !questionId) {
return null; return null;
} }
@@ -455,11 +603,20 @@ export function useSelfServeLogic() {
[parseInt(questionId)]: value, [parseInt(questionId)]: value,
}; };
const refreshVehicleTypeCandidate = vehicleTypeId === undefined || vehicleTypeId === null
? (lastVehicleTypeOverride.value ?? lastResolvedVehicleTypeId.value)
: vehicleTypeId;
const normalizedRefreshVehicleTypeId = parseInt(refreshVehicleTypeCandidate);
const refreshVehicleTypeId = !Number.isNaN(normalizedRefreshVehicleTypeId) && normalizedRefreshVehicleTypeId > 0
? normalizedRefreshVehicleTypeId
: null;
if (responseSummary) { if (responseSummary) {
await applySummaryData(responseSummary); await applySummaryData(responseSummary);
updateResolvedVehicleTypeId(responseSummary, responseSummary?.session);
} }
await fetchSelfServeData(departmentId, null, laneId, normalizedReg); await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg);
answers.value = { answers.value = {
...answers.value, ...answers.value,
[parseInt(questionId)]: value, [parseInt(questionId)]: value,
@@ -473,6 +630,67 @@ export function useSelfServeLogic() {
} }
}; };
const clearVehicleAnswers = async ({ departmentId = null, laneId, reg, questionIds = [], vehicleTypeId = null }) => {
if (!laneId || !reg || !reg.trim()) {
return { deletedCount: 0 };
}
loading.value = true;
try {
const normalizedReg = reg.trim().toUpperCase();
const filters = {
lane: parseInt(laneId),
reg: normalizedReg,
};
if (departmentId !== null && departmentId !== undefined && !Number.isNaN(parseInt(departmentId))) {
filters.department = parseInt(departmentId);
}
const conditions = await SessionUser.objects.self_serve_vehicle_conditions.get.all(filters);
const normalizedQuestionIds = new Set(
(Array.isArray(questionIds) ? questionIds : [])
.map((id) => parseInt(id))
.filter((id) => !Number.isNaN(id) && id > 0)
);
const conditionIdsToDelete = (Array.isArray(conditions) ? conditions : [])
.filter((condition) => {
if (normalizedQuestionIds.size === 0) {
return true;
}
const conditionQuestionId = extractVehicleConditionQuestionId(condition);
return conditionQuestionId !== null && normalizedQuestionIds.has(conditionQuestionId);
})
.map((condition) => parseInt(condition?.id ?? 0))
.filter((id) => !Number.isNaN(id) && id > 0);
await Promise.all(conditionIdsToDelete.map((conditionId) => (
SessionUser.objects.self_serve_vehicle_conditions.delete(conditionId)
)));
// Force a non-merge refresh so cleared answers are not kept from local state.
lastPreviewContextKey.value = null;
const refreshVehicleTypeCandidate = vehicleTypeId !== null && vehicleTypeId !== undefined
? vehicleTypeId
: (lastVehicleTypeOverride.value ?? lastResolvedVehicleTypeId.value);
const normalizedRefreshVehicleTypeId = parseInt(refreshVehicleTypeCandidate);
const refreshVehicleTypeId = !Number.isNaN(normalizedRefreshVehicleTypeId) && normalizedRefreshVehicleTypeId > 0
? normalizedRefreshVehicleTypeId
: null;
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg);
return { deletedCount: conditionIdsToDelete.length };
} catch (error) {
console.error("Error clearing self-serve answers:", error);
throw error;
} finally {
loading.value = false;
}
};
const reset = () => { const reset = () => {
completedTasks.value = {}; completedTasks.value = {};
}; };
@@ -492,6 +710,14 @@ export function useSelfServeLogic() {
const visibleQuestions = computed(() => { const visibleQuestions = computed(() => {
const allowedQuestionIds = new Set(Array.isArray(summaryVisibleQuestionIds.value) ? summaryVisibleQuestionIds.value : []); const allowedQuestionIds = new Set(Array.isArray(summaryVisibleQuestionIds.value) ? summaryVisibleQuestionIds.value : []);
const hasSummarySnapshot = summary.value !== null;
if (hasSummarySnapshot && allowedQuestionIds.size === 0) {
return [...questions.value]
.filter((question) => answers.value[question.id] === true || answers.value[question.id] === false)
.sort((a, b) => parseInt(a?.order_priority ?? 0) - parseInt(b?.order_priority ?? 0));
}
return [...questions.value] return [...questions.value]
.filter((question) => allowedQuestionIds.has(question.id)) .filter((question) => allowedQuestionIds.has(question.id))
.sort((a, b) => { .sort((a, b) => {
@@ -664,6 +890,7 @@ export function useSelfServeLogic() {
fetchSelfServeData, fetchSelfServeData,
fetchWashSummary, fetchWashSummary,
syncVehicleAnswer, syncVehicleAnswer,
clearVehicleAnswers,
evaluateRule, evaluateRule,
evaluateCondition, evaluateCondition,
isQuestionVisible, isQuestionVisible,
+44 -24
View File
@@ -121,18 +121,8 @@ import DepartmentTimeBookingsNew
from "@/views/dashboards/departmentDashboard/modules/time-bookings/book/DepartmentTimeBookingsNew.vue"; from "@/views/dashboards/departmentDashboard/modules/time-bookings/book/DepartmentTimeBookingsNew.vue";
import DepartmentWashLanes from "@/views/dashboards/departmentDashboard/modules/wash-lanes/DepartmentWashLanes.vue"; import DepartmentWashLanes from "@/views/dashboards/departmentDashboard/modules/wash-lanes/DepartmentWashLanes.vue";
import DepartmentWashLane from "@/views/dashboards/departmentDashboard/modules/wash-lanes/DepartmentWashLane.vue"; import DepartmentWashLane from "@/views/dashboards/departmentDashboard/modules/wash-lanes/DepartmentWashLane.vue";
import DepartmentSelfServeQuestions import DepartmentSelfServeStudio
from "@/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeQuestions.vue"; from "@/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue";
import DepartmentSelfServeTasks
from "@/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeTasks.vue";
import DepartmentSelfServeConditions
from "@/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeConditions.vue";
import DepartmentSelfServeConditionRules
from "@/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeConditionRules.vue";
import DepartmentSelfServeVehicleConditions
from "@/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeVehicleConditions.vue";
import DepartmentSelfServeMachineTypes
from "@/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeMachineTypes.vue";
import DepartmentGoals import DepartmentGoals
from "@/views/dashboards/departmentDashboard/modules/goals/DepartmentGoals.vue"; from "@/views/dashboards/departmentDashboard/modules/goals/DepartmentGoals.vue";
import ConfigurationLimble from "@/views/dashboards/superUserDashboard/configuration/ConfigurationLimble.vue"; import ConfigurationLimble from "@/views/dashboards/superUserDashboard/configuration/ConfigurationLimble.vue";
@@ -524,41 +514,71 @@ export const router = createRouter({
component: DepartmentWashLane, component: DepartmentWashLane,
meta: { middleware: adminMiddleware, departmentSelection: true } meta: { middleware: adminMiddleware, departmentSelection: true }
}, },
{
name: 'selfservestudio',
path: '/admin/:departmentId/modules/self-serve/studio',
component: DepartmentSelfServeStudio,
meta: { middleware: adminMiddleware, departmentSelection: true, title: 'Self-Serve Studio' }
},
{ {
name: 'selfservequestions', name: 'selfservequestions',
path: '/admin/:departmentId/modules/self-serve/questions', path: '/admin/:departmentId/modules/self-serve/questions',
component: DepartmentSelfServeQuestions, redirect: (to) => ({
meta: { middleware: adminMiddleware, departmentSelection: true } name: 'selfservestudio',
params: to.params,
query: { ...to.query, tab: 'questions' }
}),
meta: { middleware: adminMiddleware, departmentSelection: true, title: 'Self-Serve Studio' }
}, },
{ {
name: 'selfservetasks', name: 'selfservetasks',
path: '/admin/:departmentId/modules/self-serve/tasks', path: '/admin/:departmentId/modules/self-serve/tasks',
component: DepartmentSelfServeTasks, redirect: (to) => ({
meta: { middleware: adminMiddleware, departmentSelection: true } name: 'selfservestudio',
params: to.params,
query: { ...to.query, tab: 'tasks' }
}),
meta: { middleware: adminMiddleware, departmentSelection: true, title: 'Self-Serve Studio' }
}, },
{ {
name: 'selfserveconditions', name: 'selfserveconditions',
path: '/admin/:departmentId/modules/self-serve/conditions', path: '/admin/:departmentId/modules/self-serve/conditions',
component: DepartmentSelfServeConditions, redirect: (to) => ({
meta: { middleware: adminMiddleware, departmentSelection: true } name: 'selfservestudio',
params: to.params,
query: { ...to.query, tab: 'conditions' }
}),
meta: { middleware: adminMiddleware, departmentSelection: true, title: 'Self-Serve Studio' }
}, },
{ {
name: 'selfserveconditionrules', name: 'selfserveconditionrules',
path: '/admin/:departmentId/modules/self-serve/condition-rules', path: '/admin/:departmentId/modules/self-serve/condition-rules',
component: DepartmentSelfServeConditionRules, redirect: (to) => ({
meta: { middleware: adminMiddleware, departmentSelection: true } name: 'selfservestudio',
params: to.params,
query: { ...to.query, tab: 'conditions' }
}),
meta: { middleware: adminMiddleware, departmentSelection: true, title: 'Self-Serve Studio' }
}, },
{ {
name: 'selfservevehicleconditions', name: 'selfservevehicleconditions',
path: '/admin/:departmentId/modules/self-serve/vehicle-conditions', path: '/admin/:departmentId/modules/self-serve/vehicle-conditions',
component: DepartmentSelfServeVehicleConditions, redirect: (to) => ({
meta: { middleware: adminMiddleware, departmentSelection: true } name: 'selfservestudio',
params: to.params,
query: { ...to.query, tab: 'conditions', compat_view: 'vehicle-conditions' }
}),
meta: { middleware: adminMiddleware, departmentSelection: true, title: 'Self-Serve Studio' }
}, },
{ {
name: 'selfservemachinetypes', name: 'selfservemachinetypes',
path: '/admin/:departmentId/modules/self-serve/machine-types', path: '/admin/:departmentId/modules/self-serve/machine-types',
component: DepartmentSelfServeMachineTypes, redirect: (to) => ({
meta: { middleware: adminMiddleware, departmentSelection: true } name: 'selfservestudio',
params: to.params,
query: { ...to.query, tab: 'conditions', compat_view: 'machine-types' }
}),
meta: { middleware: adminMiddleware, departmentSelection: true, title: 'Self-Serve Studio' }
}, },
{ {
name: 'superuser', name: 'superuser',
@@ -29,6 +29,22 @@ const selectVehicleType = (vehicleType) => {
selectedVehicleType.value = vehicleType; selectedVehicleType.value = vehicleType;
}; };
const openStudio = () => {
const query = {
tab: "conditions",
lane_id: laneId,
};
if (lane.value?.machine_type_id) {
query.machine_type_id = lane.value.machine_type_id;
}
router.push({
name: "selfservestudio",
params: { departmentId },
query,
});
};
const loadLane = async () => { const loadLane = async () => {
isLoading.value = true; isLoading.value = true;
try { try {
@@ -55,6 +71,12 @@ onMounted(() => {
<div v-if="lane" data-testid="department-wash-lane-page"> <div v-if="lane" data-testid="department-wash-lane-page">
<PageTitle :title="t('admin.wash_lanes.single.title') + ': ' + lane.name" :subtitle="'ID: ' + lane.id"> <PageTitle :title="t('admin.wash_lanes.single.title') + ': ' + lane.name" :subtitle="'ID: ' + lane.id">
<template #buttons> <template #buttons>
<button class="button is-link is-light" @click="openStudio">
<span class="icon">
<i class="fas fa-project-diagram"></i>
</span>
<span>Open Self-Serve Studio</span>
</button>
<button class="button is-dark" @click="router.push(`/admin/${departmentId}/modules/wash-lanes`)"> <button class="button is-dark" @click="router.push(`/admin/${departmentId}/modules/wash-lanes`)">
<span class="icon"> <span class="icon">
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
@@ -286,12 +308,12 @@ onMounted(() => {
</span> </span>
<span>{{ t('admin.self_serve.tasks.add') }}</span> <span>{{ t('admin.self_serve.tasks.add') }}</span>
</button> </button>
<router-link :to="{ name: 'selfservevehicleconditions', params: { departmentId: departmentId } }" class="button is-link is-light"> <button class="button is-link is-light" @click="openStudio">
<span class="icon"> <span class="icon">
<i class="fas fa-car-side"></i> <i class="fas fa-project-diagram"></i>
</span> </span>
<span>{{ t('admin.self_serve.vehicle_conditions.title') }}</span> <span>Open Studio</span>
</router-link> </button>
<button <button
class="button is-warning is-light" class="button is-warning is-light"
data-testid="self-serve-admin-open-try-modal" data-testid="self-serve-admin-open-try-modal"
@@ -47,6 +47,7 @@ const laneLastUpdatedAt = reactive<Record<number, number | null>>({});
const laneInProgressDetails = reactive<Record<number, InProgressWashDetails | null>>({}); const laneInProgressDetails = reactive<Record<number, InProgressWashDetails | null>>({});
const laneInProgressLoading = reactive<Record<number, boolean>>({}); const laneInProgressLoading = reactive<Record<number, boolean>>({});
const lanePollSubscribers = reactive<Record<number, number>>({}); const lanePollSubscribers = reactive<Record<number, number>>({});
const lanePollingActive = reactive<Record<number, boolean>>({});
const lanePollIntervals = new Map<number, ReturnType<typeof setInterval>>(); const lanePollIntervals = new Map<number, ReturnType<typeof setInterval>>();
const createInitialRelayStatusMap = (): RelayStatusMap => ({ const createInitialRelayStatusMap = (): RelayStatusMap => ({
@@ -102,6 +103,9 @@ const ensureLaneState = (laneId: number): void => {
if (!Object.prototype.hasOwnProperty.call(lanePollSubscribers, laneId)) { if (!Object.prototype.hasOwnProperty.call(lanePollSubscribers, laneId)) {
lanePollSubscribers[laneId] = 0; lanePollSubscribers[laneId] = 0;
} }
if (!Object.prototype.hasOwnProperty.call(lanePollingActive, laneId)) {
lanePollingActive[laneId] = false;
}
}; };
const parseErrorMessage = (error: unknown): string => { const parseErrorMessage = (error: unknown): string => {
@@ -465,9 +469,12 @@ export const startPolling = async (
lanePollSubscribers[normalizedLaneId] += 1; lanePollSubscribers[normalizedLaneId] += 1;
if (lanePollIntervals.has(normalizedLaneId)) { if (lanePollIntervals.has(normalizedLaneId)) {
lanePollingActive[normalizedLaneId] = true;
return; return;
} }
lanePollingActive[normalizedLaneId] = true;
const pollInterval = Math.max(1000, Math.round(intervalMs)); const pollInterval = Math.max(1000, Math.round(intervalMs));
const refreshLane = async () => { const refreshLane = async () => {
await Promise.allSettled([ await Promise.allSettled([
@@ -501,6 +508,7 @@ export const stopPolling = (laneId: number): void => {
clearInterval(intervalRef); clearInterval(intervalRef);
} }
lanePollIntervals.delete(normalizedLaneId); lanePollIntervals.delete(normalizedLaneId);
lanePollingActive[normalizedLaneId] = false;
}; };
export const clearLaneError = (laneId: number): void => { export const clearLaneError = (laneId: number): void => {
@@ -520,7 +528,7 @@ export const useMachineConnectivity = (laneId: number) => {
const lastUpdatedAt = computed(() => laneLastUpdatedAt[normalizedLaneId]); const lastUpdatedAt = computed(() => laneLastUpdatedAt[normalizedLaneId]);
const inProgressDetails = computed(() => laneInProgressDetails[normalizedLaneId]); const inProgressDetails = computed(() => laneInProgressDetails[normalizedLaneId]);
const inProgressLoading = computed(() => laneInProgressLoading[normalizedLaneId]); const inProgressLoading = computed(() => laneInProgressLoading[normalizedLaneId]);
const isPolling = computed(() => lanePollIntervals.has(normalizedLaneId)); const isPolling = computed(() => Boolean(lanePollingActive[normalizedLaneId]));
return { return {
laneId: normalizedLaneId, laneId: normalizedLaneId,
@@ -561,6 +569,7 @@ export const machineConnectivityStore = {
laneInProgressDetails, laneInProgressDetails,
laneInProgressLoading, laneInProgressLoading,
lanePollSubscribers, lanePollSubscribers,
lanePollingActive,
}, },
functions: { functions: {
fetchRelayStatus, fetchRelayStatus,
@@ -598,6 +607,7 @@ export const __resetMachineConnectivityStoreForTests = (): void => {
resetReactiveObject(laneInProgressDetails as unknown as Record<string, unknown>); resetReactiveObject(laneInProgressDetails as unknown as Record<string, unknown>);
resetReactiveObject(laneInProgressLoading as unknown as Record<string, unknown>); resetReactiveObject(laneInProgressLoading as unknown as Record<string, unknown>);
resetReactiveObject(lanePollSubscribers as unknown as Record<string, unknown>); resetReactiveObject(lanePollSubscribers as unknown as Record<string, unknown>);
resetReactiveObject(lanePollingActive as unknown as Record<string, unknown>);
}; };
export default { export default {
@@ -0,0 +1,29 @@
<script setup lang="ts">
import {defineProps} from "vue";
import {BIcon} from "buefy";
defineProps<{
icon: string;
type: string;
label: string;
}>()
</script>
<template>
<div class="flex items-center gap-2">
<span class="is-small label">
<span class="mr-2">
<b-icon
:icon="icon"
:type="type"
pack="fas"
size="is-small"
/>
</span>
{{ label }}
</span>
</div>
</template>
<style scoped>
</style>
@@ -102,6 +102,7 @@ watch(
<div class="divider"> <div class="divider">
<p class="divider-text">Access Controls</p> <p class="divider-text">Access Controls</p>
</div> </div>
<p class="title is-6 mb-2">Department Self-Serve</p>
<div class="columns is-mobile"> <div class="columns is-mobile">
<div class="column is-half"> <div class="column is-half">
<b-tooltip multilined dashed> <b-tooltip multilined dashed>
@@ -119,6 +120,7 @@ watch(
<div class="columns is-mobile"> <div class="columns is-mobile">
<div class="column is-half"> <div class="column is-half">
<SelfServeMachineStatus :machineStatus="departmentSelfServeEnabled ? 'ON' : 'OFF'" /> <SelfServeMachineStatus :machineStatus="departmentSelfServeEnabled ? 'ON' : 'OFF'" />
<p class="label is-small mt-2">{{ departmentSelfServeStatusLabel }}</p>
</div> </div>
<div class="column is-half"> <div class="column is-half">
<b-switch <b-switch
@@ -132,6 +134,7 @@ watch(
</div> </div>
</div> </div>
</div> </div>
<p v-if="departmentSelfServeError" class="help is-danger mt-2">{{ departmentSelfServeError }}</p>
<!-- Relay controls --> <!-- Relay controls -->
<SelfServeMachineRelayControls :machine="machine" /> <SelfServeMachineRelayControls :machine="machine" />
<!-- Current wash cycle --> <!-- Current wash cycle -->
@@ -89,8 +89,12 @@ const parseBillableStatus = (inProgressDetails: typeof connectivity.inProgressDe
if (!inProgressDetails?.in_progress) { if (!inProgressDetails?.in_progress) {
return MachineStatusType.OFF; return MachineStatusType.OFF;
} }
const includedTimeMs = (inProgressDetails.session.included_minutes ?? 0) * 60 * 1000; const includedTimeMs = Number(inProgressDetails.session?.included_minutes ?? 0) * 60 * 1000;
const washStartedAt = new Date(inProgressDetails.session.wash_started_at).getTime(); const washStartedAtValue = inProgressDetails.session?.wash_started_at;
const washStartedAt = washStartedAtValue ? new Date(String(washStartedAtValue)).getTime() : Number.NaN;
if (!Number.isFinite(washStartedAt)) {
return MachineStatusType.ON;
}
if (Date.now() - washStartedAt >= includedTimeMs) { if (Date.now() - washStartedAt >= includedTimeMs) {
return MachineStatusType.BILLABLE; return MachineStatusType.BILLABLE;
} }
@@ -103,9 +107,13 @@ const parseBillableMinuteAmount = (inProgressDetails: typeof connectivity.inProg
} }
// When there's included minutes, it should count down. // When there's included minutes, it should count down.
// When there's no included minutes, or they've all been used, it should count up from 0. // When there's no included minutes, or they've all been used, it should count up from 0.
const includedMinutes = inProgressDetails.session.included_minutes ?? 0; const includedMinutes = Number(inProgressDetails.session?.included_minutes ?? 0);
const washStartedAt = new Date(inProgressDetails.session.wash_started_at).getTime(); const washStartedAtValue = inProgressDetails.session?.wash_started_at;
const elapsedMinutes = Math.floor((Date.now() - washStartedAt) / (60 * 1000)); const washStartedAt = washStartedAtValue ? new Date(String(washStartedAtValue)).getTime() : Number.NaN;
if (!Number.isFinite(washStartedAt)) {
return "N/A";
}
const elapsedMinutes = Math.max(0, Math.floor((Date.now() - washStartedAt) / (60 * 1000)));
if (includedMinutes > 0 && elapsedMinutes >= includedMinutes) { if (includedMinutes > 0 && elapsedMinutes >= includedMinutes) {
// If there are included minutes, we show the remaining minutes. // If there are included minutes, we show the remaining minutes.
return elapsedMinutes - includedMinutes; return elapsedMinutes - includedMinutes;
@@ -113,6 +121,8 @@ const parseBillableMinuteAmount = (inProgressDetails: typeof connectivity.inProg
// If there are no included minutes, we show the elapsed minutes. // If there are no included minutes, we show the elapsed minutes.
return includedMinutes - elapsedMinutes; return includedMinutes - elapsedMinutes;
} }
return elapsedMinutes;
} }
const openGate = async (gate: LaneGate) => { const openGate = async (gate: LaneGate) => {
@@ -241,7 +251,7 @@ onUnmounted(() => {
<p>Loading in-progress details...</p> <p>Loading in-progress details...</p>
</template> </template>
<template v-else-if="inProgressDetails?.in_progress"> <template v-else-if="inProgressDetails?.in_progress">
<div class="columns is-mobile"> <div class="columns is-mobile is-multiline">
<div class="column is-half"> <div class="column is-half">
<p> <p>
<b-tooltip multilined dashed> <b-tooltip multilined dashed>
@@ -266,6 +276,52 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</div> </div>
<!-- Customer -->
<div class="column is-half">
<p>
<b-tooltip multilined dashed>
<template v-slot:default>
<p>Customer</p>
</template>
<template v-slot:content>
The customer associated with the current wash session, if available. This is based on the most recent session information and may not always be accurate.
</template>
</b-tooltip>
</p>
</div>
<div class="column is-half">
<div class="columns is-mobile">
<div class="column is-half">
<SelfServeMachineStatus :machine-status="inProgressDetails.customer ? MachineStatusType.ON : MachineStatusType.OFF" />
</div>
<div class="column is-half">
<p class="label is-small">Customer: {{ customerDisplay }}</p>
</div>
</div>
</div>
<!-- Vehicle -->
<div class="column is-half">
<p>
<b-tooltip multilined dashed>
<template v-slot:default>
<p>Vehicle</p>
</template>
<template v-slot:content>
The vehicle associated with the current wash, if available. This is based on the most recent session information and may not always be accurate.
</template>
</b-tooltip>
</p>
</div>
<div class="column is-half">
<div class="columns is-mobile">
<div class="column is-half">
<SelfServeMachineStatus :machine-status="inProgressDetails.vehicle ? MachineStatusType.ON : MachineStatusType.OFF" />
</div>
<div class="column is-half">
<p class="label is-small">Vehicle: {{ vehicleDisplay }}</p>
</div>
</div>
</div>
</div> </div>
</template> </template>
<template v-else> <template v-else>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import {defineProps} from "vue"; import {defineProps} from "vue";
import type {MachineStatus} from "@/views/dashboards/superUserDashboard/selfserve/types/MachineStatusType.vue"; import type {MachineStatus} from "@/views/dashboards/superUserDashboard/selfserve/types/MachineStatusType.vue";
import {BIcon} from "buefy"; import SelfServeGenericStatus from "@/views/dashboards/superUserDashboard/selfserve/displays/machine/SelfServeGenericStatus.vue";
defineProps<{machineStatus: typeof MachineStatus[keyof typeof MachineStatus]}>() defineProps<{machineStatus: typeof MachineStatus[keyof typeof MachineStatus]}>()
const icon = { const icon = {
@@ -11,7 +11,7 @@ const icon = {
ON: "check", ON: "check",
OFF: "close", OFF: "close",
INCLUDED: "clock", INCLUDED: "clock",
BILLABLE: "clock", BILLABLE: "dollar-sign",
} }
const type = { const type = {
ONLINE: "is-success", ONLINE: "is-success",
@@ -19,31 +19,18 @@ const type = {
MAINTENANCE: "is-warning", MAINTENANCE: "is-warning",
ON: "is-success", ON: "is-success",
OFF: "is-danger", OFF: "is-danger",
INCLUDED: "is-info", INCLUDED: "is-grey",
BILLABLE: "is-info", BILLABLE: "is-success",
} }
</script> </script>
<template> <template>
<div class="flex items-center gap-2"> <SelfServeGenericStatus
<span class="is-small label"> :icon="icon[machineStatus]"
<span class="mr-2"> :type="type[machineStatus]"
<b-icon :label="machineStatus === 'INCLUDED' ? 'Incl.' : machineStatus === 'BILLABLE' ? 'ADD' : machineStatus === 'MAINTENANCE' ? 'N/A' : machineStatus"
:icon="icon[machineStatus]" />
:type="type[machineStatus]"
pack="fas"
size="is-small"
/>
</span>
<!-- If INCLUDED -->
<span v-if="machineStatus === 'INCLUDED'">PREPAID</span>
<span v-else-if="machineStatus === 'BILLABLE'">ADD</span>
<!-- If maintenance, show maintenance -->
<span v-else-if="machineStatus === 'MAINTENANCE'">N/A</span>
<span v-else>{{ machineStatus }}</span>
</span>
</div>
</template> </template>
<style scoped> <style scoped>
+382
View File
@@ -0,0 +1,382 @@
// @vitest-environment jsdom
import { flushPromises } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
fetchSelfServeData: vi.fn(),
syncVehicleAnswer: vi.fn(),
clearVehicleAnswers: vi.fn(),
swalFire: vi.fn(),
getLanes: vi.fn(),
getVehicleTypeOptions: vi.fn(),
loading: { value: false, __v_isRef: true },
lane: { value: { id: 3, name: "Lane 3" }, __v_isRef: true },
machineType: { value: null, __v_isRef: true },
session: { value: null, __v_isRef: true },
events: { value: [], __v_isRef: true },
questions: { value: [], __v_isRef: true },
answers: { value: {}, __v_isRef: true },
allowedServices: { value: [], __v_isRef: true },
configVersionId: { value: null, __v_isRef: true },
evaluationTrace: { value: null, __v_isRef: true },
visibleQuestions: { value: [], __v_isRef: true },
activeTasks: { value: [], __v_isRef: true },
currentQuestion: { value: null, __v_isRef: true },
allVisibleQuestionsAnswered: { value: false, __v_isRef: true },
machineAvailable: { value: true, __v_isRef: true },
allowed: { value: true, __v_isRef: true },
evaluateCondition: vi.fn(() => true),
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
user: {
customer_number: { value: 12345 },
},
objects: {
department_lanes: {
get: {
all: mocks.getLanes,
},
},
vehicles: {
columns: {
type: {
options: mocks.getVehicleTypeOptions,
},
},
},
},
},
}));
vi.mock("sweetalert2", () => ({
default: {
fire: mocks.swalFire,
},
}));
vi.mock("@/composables/useSelfServeLogic", () => ({
useSelfServeLogic: () => ({
loading: mocks.loading,
lane: mocks.lane,
machineType: mocks.machineType,
session: mocks.session,
events: mocks.events,
questions: mocks.questions,
answers: mocks.answers,
allowedServices: mocks.allowedServices,
configVersionId: mocks.configVersionId,
evaluationTrace: mocks.evaluationTrace,
visibleQuestions: mocks.visibleQuestions,
activeTasks: mocks.activeTasks,
currentQuestion: mocks.currentQuestion,
allVisibleQuestionsAnswered: mocks.allVisibleQuestionsAnswered,
machineAvailable: mocks.machineAvailable,
allowed: mocks.allowed,
fetchSelfServeData: mocks.fetchSelfServeData,
syncVehicleAnswer: mocks.syncVehicleAnswer,
clearVehicleAnswers: mocks.clearVehicleAnswers,
evaluateCondition: mocks.evaluateCondition,
}),
}));
import { mountWithApp } from "./helpers/mountWithApp.js";
import SelfServeTryModal from "@/components/displays/department/tables/SelfServeTryModal.vue";
describe("SelfServeTryModal", () => {
beforeEach(() => {
mocks.fetchSelfServeData.mockReset();
mocks.syncVehicleAnswer.mockReset();
mocks.clearVehicleAnswers.mockReset();
mocks.swalFire.mockReset();
mocks.getLanes.mockReset();
mocks.getVehicleTypeOptions.mockReset();
mocks.evaluateCondition.mockReset();
mocks.getLanes.mockResolvedValue([
{ id: 3, department: 9, name: "Lane 3" },
]);
mocks.getVehicleTypeOptions.mockResolvedValue([
{ id: 0, name: "Ukendt" },
{ id: 2, name: "Truck" },
{ id: 3, name: "Van" },
]);
mocks.clearVehicleAnswers.mockResolvedValue({ deletedCount: 1 });
mocks.swalFire.mockResolvedValue({ isConfirmed: true });
mocks.loading.value = false;
mocks.questions.value = [];
mocks.visibleQuestions.value = [];
mocks.answers.value = {};
mocks.currentQuestion.value = null;
mocks.evaluateCondition.mockReturnValue(true);
});
const mountModal = () => mountWithApp(SelfServeTryModal, {
props: {
departmentId: 9,
laneId: 3,
},
global: {
stubs: {
SelfServeQuestionCards: true,
SelfServeTaskList: true,
},
},
});
it("keeps clear answers enabled for valid lane/reg even when no visible answers exist", async () => {
const wrapper = mountModal();
await flushPromises();
const clearButton = wrapper.get('[data-testid="self-serve-try-clear-answers"]');
expect(clearButton.attributes("disabled")).toBeUndefined();
});
it("allows selecting vehicle type and refreshes preview with the selected type", async () => {
const wrapper = mountModal();
await flushPromises();
mocks.fetchSelfServeData.mockClear();
await wrapper.get('[data-testid="self-serve-try-vehicle-type"]').setValue("3");
await flushPromises();
expect(mocks.fetchSelfServeData).toHaveBeenCalledWith(9, 3, 3, "AB12345");
});
it("syncs answers with the selected vehicle type context", async () => {
mocks.visibleQuestions.value = [{ id: 77, question: "Question 77" }];
mocks.currentQuestion.value = null;
mocks.syncVehicleAnswer.mockResolvedValue({});
const wrapper = mountWithApp(SelfServeTryModal, {
props: {
departmentId: 9,
laneId: 3,
},
global: {
stubs: {
SelfServeQuestionCards: {
props: ["visibleQuestions"],
emits: ["answer-question"],
template: "<button data-testid='emit-card-answer' @click=\"$emit('answer-question', visibleQuestions[0]?.id, true)\">answer</button>",
},
SelfServeTaskList: true,
},
},
});
await flushPromises();
await wrapper.get('[data-testid="self-serve-try-vehicle-type"]').setValue("3");
await flushPromises();
await wrapper.get("[data-testid='emit-card-answer']").trigger("click");
await flushPromises();
expect(mocks.syncVehicleAnswer).toHaveBeenCalledWith({
departmentId: 9,
laneId: 3,
customerNumber: 12345,
reg: "AB12345",
questionId: 77,
value: true,
vehicleTypeId: 3,
});
});
it("does not duplicate the current question in the question cards list", async () => {
mocks.currentQuestion.value = { id: 11, question: "Question 11" };
mocks.visibleQuestions.value = [
{ id: 11, question: "Question 11" },
{ id: 22, question: "Question 22" },
];
const wrapper = mountWithApp(SelfServeTryModal, {
props: {
departmentId: 9,
laneId: 3,
},
global: {
stubs: {
SelfServeQuestionCards: {
props: ["visibleQuestions"],
template: "<div data-testid='question-cards-probe'>{{ visibleQuestions.map(q => q.question).join('|') }}</div>",
},
SelfServeTaskList: true,
},
},
});
await flushPromises();
expect(wrapper.find(".notification.is-info").exists()).toBe(false);
expect(wrapper.get("[data-testid='question-cards-probe']").text()).toBe("Question 11|Question 22");
});
it("keeps rendering known questions when visible list is temporarily empty but unanswered questions still exist", async () => {
mocks.visibleQuestions.value = [];
mocks.questions.value = [
{ id: 11, question: "Question 11", order_priority: 1 },
{ id: 22, question: "Question 22", order_priority: 2 },
];
mocks.answers.value = { 11: true, 22: null };
const wrapper = mountWithApp(SelfServeTryModal, {
props: {
departmentId: 9,
laneId: 3,
},
global: {
stubs: {
SelfServeQuestionCards: {
props: ["visibleQuestions"],
template: "<div data-testid='question-cards-probe'>{{ visibleQuestions.map(q => q.question).join('|') }}</div>",
},
SelfServeTaskList: true,
},
},
});
await flushPromises();
expect(wrapper.get("[data-testid='question-cards-probe']").text()).toBe("Question 11|Question 22");
expect(wrapper.text()).toContain("Alle synlige sporgsmal besvaret: Nej");
});
it("keeps condition-true unanswered follow-up visible when server visible list is stale", async () => {
mocks.visibleQuestions.value = [
{ id: 11, question: "Question 11", order_priority: 1, condition_id: null },
];
mocks.questions.value = [
{ id: 11, question: "Question 11", order_priority: 1, condition_id: null },
{ id: 12, question: "Follow-up question", order_priority: 2, condition_id: 100 },
];
mocks.answers.value = { 11: true, 12: null };
mocks.evaluateCondition.mockImplementation((conditionId) => parseInt(conditionId) === 100);
const wrapper = mountWithApp(SelfServeTryModal, {
props: {
departmentId: 9,
laneId: 3,
},
global: {
stubs: {
SelfServeQuestionCards: {
props: ["visibleQuestions"],
template: "<div data-testid='question-cards-probe'>{{ visibleQuestions.map(q => q.question).join('|') }}</div>",
},
SelfServeTaskList: true,
},
},
});
await flushPromises();
expect(wrapper.get("[data-testid='question-cards-probe']").text()).toBe("Question 11|Follow-up question");
});
it("does not add follow-up questions when their condition is false", async () => {
mocks.visibleQuestions.value = [
{ id: 11, question: "Question 11", order_priority: 1, condition_id: null },
];
mocks.questions.value = [
{ id: 11, question: "Question 11", order_priority: 1, condition_id: null },
{ id: 12, question: "Follow-up question", order_priority: 2, condition_id: 100 },
];
mocks.answers.value = { 11: false, 12: null };
mocks.evaluateCondition.mockReturnValue(false);
const wrapper = mountWithApp(SelfServeTryModal, {
props: {
departmentId: 9,
laneId: 3,
},
global: {
stubs: {
SelfServeQuestionCards: {
props: ["visibleQuestions"],
template: "<div data-testid='question-cards-probe'>{{ visibleQuestions.map(q => q.question).join('|') }}</div>",
},
SelfServeTaskList: true,
},
},
});
await flushPromises();
expect(wrapper.get("[data-testid='question-cards-probe']").text()).toBe("Question 11");
});
it("disables clear answers when registration is invalid", async () => {
const wrapper = mountModal();
await flushPromises();
await wrapper.get('[data-testid="self-serve-try-reg"]').setValue("A");
await flushPromises();
const clearButton = wrapper.get('[data-testid="self-serve-try-clear-answers"]');
expect(clearButton.attributes("disabled")).toBeDefined();
});
it("disables clear answers while loading", async () => {
mocks.loading.value = true;
const wrapper = mountModal();
await flushPromises();
const clearButton = wrapper.get('[data-testid="self-serve-try-clear-answers"]');
expect(clearButton.attributes("disabled")).toBeDefined();
});
it("does not clear answers when confirmation is cancelled", async () => {
mocks.swalFire.mockResolvedValue({ isConfirmed: false });
const wrapper = mountModal();
await flushPromises();
await wrapper.get('[data-testid="self-serve-try-clear-answers"]').trigger("click");
expect(mocks.swalFire).toHaveBeenCalledTimes(1);
expect(mocks.clearVehicleAnswers).not.toHaveBeenCalled();
});
it("clears answers for the selected lane and registration when confirmed", async () => {
const wrapper = mountWithApp(SelfServeTryModal, {
props: {
departmentId: 9,
laneId: 3,
},
global: {
stubs: {
SelfServeQuestionCards: true,
SelfServeTaskList: true,
},
},
});
await flushPromises();
const clearButton = wrapper.get('[data-testid="self-serve-try-clear-answers"]');
expect(clearButton.attributes("disabled")).toBeUndefined();
await clearButton.trigger("click");
expect(mocks.swalFire).toHaveBeenCalledTimes(1);
expect(mocks.swalFire.mock.calls[0][0]).toMatchObject({
title: "Ryd besvarelser?",
icon: "warning",
});
expect(mocks.swalFire.mock.calls[0][0].text).toContain("AB12345");
expect(mocks.swalFire.mock.calls[0][0].text).toContain("3");
expect(mocks.clearVehicleAnswers).toHaveBeenCalledWith({
departmentId: 9,
laneId: 3,
vehicleTypeId: null,
reg: "AB12345",
});
});
});
+315 -13
View File
@@ -4,6 +4,8 @@ const mocks = vi.hoisted(() => ({
previewAllowed: vi.fn(), previewAllowed: vi.fn(),
washSummary: vi.fn(), washSummary: vi.fn(),
add: vi.fn(), add: vi.fn(),
getAll: vi.fn(),
deleteCondition: vi.fn(),
attachmentsList: vi.fn(), attachmentsList: vi.fn(),
attachmentsDownload: vi.fn(), attachmentsDownload: vi.fn(),
request: vi.fn(), request: vi.fn(),
@@ -17,8 +19,10 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
get: { get: {
previewAllowed: mocks.previewAllowed, previewAllowed: mocks.previewAllowed,
washSummary: mocks.washSummary, washSummary: mocks.washSummary,
all: mocks.getAll,
}, },
add: mocks.add, add: mocks.add,
delete: mocks.deleteCondition,
}, },
self_serve_tasks: { self_serve_tasks: {
attachments: { attachments: {
@@ -37,6 +41,8 @@ describe("useSelfServeLogic", () => {
mocks.previewAllowed.mockReset(); mocks.previewAllowed.mockReset();
mocks.washSummary.mockReset(); mocks.washSummary.mockReset();
mocks.add.mockReset(); mocks.add.mockReset();
mocks.getAll.mockReset();
mocks.deleteCondition.mockReset();
mocks.attachmentsList.mockReset(); mocks.attachmentsList.mockReset();
mocks.attachmentsDownload.mockReset(); mocks.attachmentsDownload.mockReset();
mocks.request.mockReset(); mocks.request.mockReset();
@@ -87,7 +93,7 @@ describe("useSelfServeLogic", () => {
await logic.fetchSelfServeData(1, 9, 7, "ab12345"); await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(mocks.previewAllowed).toHaveBeenCalledWith(7, "AB12345"); expect(mocks.previewAllowed).toHaveBeenCalledWith(7, "AB12345", 9);
expect(mocks.washSummary).toHaveBeenCalledWith({ session_id: 91 }); expect(mocks.washSummary).toHaveBeenCalledWith({ session_id: 91 });
expect(logic.allowed.value).toBe(true); expect(logic.allowed.value).toBe(true);
expect(logic.machineAvailable.value).toBe(true); expect(logic.machineAvailable.value).toBe(true);
@@ -128,7 +134,7 @@ describe("useSelfServeLogic", () => {
await logic.fetchSelfServeData(1, 9, 7, "ab12345"); await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(mocks.washSummary).toHaveBeenNthCalledWith(1, { session_id: 91 }); expect(mocks.washSummary).toHaveBeenNthCalledWith(1, { session_id: 91 });
expect(mocks.washSummary).toHaveBeenNthCalledWith(2, { lane_id: 7, reg: "AB12345" }); expect(mocks.washSummary).toHaveBeenNthCalledWith(2, { lane_id: 7, reg: "AB12345", vehicle_type: 9 });
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1]); expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1]);
}); });
@@ -152,6 +158,61 @@ describe("useSelfServeLogic", () => {
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1, 2]); expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1, 2]);
}); });
it("keeps the newest task list when overlapping preview requests resolve out of order", async () => {
const createDeferred = () => {
let resolve;
const promise = new Promise((res) => {
resolve = res;
});
return { promise, resolve };
};
const firstPreview = createDeferred();
const secondPreview = createDeferred();
mocks.previewAllowed
.mockImplementationOnce(() => firstPreview.promise)
.mockImplementationOnce(() => secondPreview.promise);
mocks.attachmentsList.mockResolvedValue({ data: [] });
const logic = useSelfServeLogic();
const firstFetch = logic.fetchSelfServeData(1, null, 7, "ab12345");
const secondFetch = logic.fetchSelfServeData(1, 9, 7, "ab12345");
secondPreview.resolve({
allowed: true,
session: null,
questions: [],
tasks: [
{ id: 1001, task: "Task A", order_priority: 1 },
{ id: 1002, task: "Task B", order_priority: 2 },
],
conditions: [],
rules: [],
allowed_services: [],
});
await secondFetch;
expect(logic.tasks.value.map((task) => task.id)).toEqual([1001, 1002]);
firstPreview.resolve({
allowed: true,
session: null,
questions: [],
tasks: [
{ id: 1001, task: "Task A", order_priority: 1 },
],
conditions: [],
rules: [],
allowed_services: [],
});
await firstFetch;
expect(logic.tasks.value.map((task) => task.id)).toEqual([1001, 1002]);
});
it("evaluates conditional rules for task activation", () => { it("evaluates conditional rules for task activation", () => {
const logic = useSelfServeLogic(); const logic = useSelfServeLogic();
@@ -270,6 +331,160 @@ describe("useSelfServeLogic", () => {
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([2]); expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([2]);
}); });
it("preserves the selected vehicle-type override when refreshing after answer sync", async () => {
mocks.previewAllowed.mockResolvedValue({
allowed: true,
session: { id: 66, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Primary question", answer: null, order_priority: 1 },
],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary.mockResolvedValue({
session: { id: 66, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Primary question", answer: null, order_priority: 1 },
],
tasks: [],
events: [],
});
mocks.add.mockResolvedValue({
data: {
data: {
selfserve: {
session: { id: 66, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Primary question", answer: true, order_priority: 1 },
],
tasks: [],
conditions: [],
rules: [],
},
},
},
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
await logic.syncVehicleAnswer({
departmentId: 1,
laneId: 7,
customerNumber: 12345,
reg: "ab12345",
questionId: 1,
value: true,
});
expect(mocks.previewAllowed).toHaveBeenNthCalledWith(1, 7, "AB12345", 9);
expect(mocks.previewAllowed).toHaveBeenNthCalledWith(2, 7, "AB12345", 9);
});
it("uses the resolved auto vehicle type for lane/reg summary fallback", async () => {
mocks.previewAllowed.mockResolvedValue({
allowed: true,
session: { id: 91, status: "IN_PROGRESS", vehicle_type_id: 5 },
questions: [
{ id: 1, question: "Primary question", answer: null, order_priority: 1 },
],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary.mockResolvedValue({
session: { id: 91, status: "IN_PROGRESS", vehicle_type_id: 5 },
questions: [
{ id: 1, question: "Primary question", answer: null, order_priority: 1 },
],
tasks: [],
events: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, null, 7, "ab12345");
expect(mocks.washSummary).toHaveBeenNthCalledWith(1, { session_id: 91 });
expect(mocks.washSummary).toHaveBeenNthCalledWith(2, { lane_id: 7, reg: "AB12345", vehicle_type: 5 });
});
it("reuses the resolved auto vehicle type when refreshing after answer sync", async () => {
mocks.previewAllowed.mockResolvedValue({
allowed: true,
session: { id: 66, status: "IN_PROGRESS", vehicle_type_id: 5 },
questions: [
{ id: 1, question: "Primary question", answer: null, order_priority: 1 },
],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary.mockResolvedValue({
session: { id: 66, status: "IN_PROGRESS", vehicle_type_id: 5 },
questions: [
{ id: 1, question: "Primary question", answer: null, order_priority: 1 },
],
tasks: [],
events: [],
});
mocks.add.mockResolvedValue({
data: {
data: {
selfserve: {
session: { id: 66, status: "IN_PROGRESS", vehicle_type_id: 5 },
questions: [
{ id: 1, question: "Primary question", answer: true, order_priority: 1 },
],
tasks: [],
conditions: [],
rules: [],
},
},
},
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, null, 7, "ab12345");
await logic.syncVehicleAnswer({
departmentId: 1,
laneId: 7,
customerNumber: 12345,
reg: "ab12345",
questionId: 1,
value: true,
});
expect(mocks.previewAllowed).toHaveBeenNthCalledWith(1, 7, "AB12345");
expect(mocks.previewAllowed).toHaveBeenNthCalledWith(2, 7, "AB12345", 5);
});
it("keeps answered questions visible when summary returns no currently-visible question list", async () => {
mocks.previewAllowed.mockResolvedValue({
allowed: false,
session: { id: 99, status: "MACHINE_NOT_ALLOWED" },
questions: [
{ id: 1, question: "Primary question", answer: true, order_priority: 1 },
],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary.mockResolvedValue({
session: { id: 99, status: "MACHINE_NOT_ALLOWED" },
questions: [],
tasks: [],
events: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(logic.answers.value[1]).toBe(true);
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1]);
});
it("hides condition-dependent questions again when the parent answer no longer satisfies the condition", async () => { it("hides condition-dependent questions again when the parent answer no longer satisfies the condition", async () => {
const baseConditions = [ const baseConditions = [
{ id: 100, name: "Primary answer is yes" }, { id: 100, name: "Primary answer is yes" },
@@ -289,17 +504,29 @@ describe("useSelfServeLogic", () => {
conditions: baseConditions, conditions: baseConditions,
rules: baseRules, rules: baseRules,
}); });
mocks.washSummary.mockResolvedValueOnce({ mocks.washSummary
session: { id: 55, status: "IN_PROGRESS" }, .mockResolvedValueOnce({
questions: [ session: { id: 55, status: "IN_PROGRESS" },
{ id: 2, question: "Primary question", answer: true, order_priority: 1, condition_id: null }, questions: [
{ id: 3, question: "Conditional follow-up", answer: null, order_priority: 2, condition_id: 100 }, { id: 2, question: "Primary question", answer: true, order_priority: 1, condition_id: null },
], { id: 3, question: "Conditional follow-up", answer: null, order_priority: 2, condition_id: 100 },
tasks: [], ],
conditions: baseConditions, tasks: [],
rules: baseRules, conditions: baseConditions,
events: [], rules: baseRules,
}); events: [],
})
.mockResolvedValueOnce({
session: { id: 55, status: "IN_PROGRESS" },
questions: [
{ id: 2, question: "Primary question", answer: true, order_priority: 1, condition_id: null },
{ id: 3, question: "Conditional follow-up", answer: null, order_priority: 2, condition_id: 100 },
],
tasks: [],
conditions: baseConditions,
rules: baseRules,
events: [],
});
mocks.add.mockResolvedValue({ mocks.add.mockResolvedValue({
data: { data: {
@@ -439,4 +666,79 @@ describe("useSelfServeLogic", () => {
expect(logic.answers.value[1]).toBe(true); expect(logic.answers.value[1]).toBe(true);
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1, 2]); expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1, 2]);
}); });
it("clears persisted answers for a lane/reg and refreshes preview state", async () => {
mocks.previewAllowed
.mockResolvedValueOnce({
allowed: true,
session: { id: 88, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Doors closed?", answer: true, order_priority: 1 },
{ id: 2, question: "Windows closed?", answer: false, order_priority: 2 },
],
tasks: [],
conditions: [],
rules: [],
})
.mockResolvedValueOnce({
allowed: true,
session: { id: 88, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Doors closed?", answer: null, order_priority: 1 },
{ id: 2, question: "Windows closed?", answer: null, order_priority: 2 },
],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary
.mockResolvedValueOnce({
session: { id: 88, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Doors closed?", answer: true, order_priority: 1 },
{ id: 2, question: "Windows closed?", answer: false, order_priority: 2 },
],
tasks: [],
events: [],
})
.mockResolvedValueOnce({
session: { id: 88, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Doors closed?", answer: null, order_priority: 1 },
{ id: 2, question: "Windows closed?", answer: null, order_priority: 2 },
],
tasks: [],
events: [],
});
mocks.getAll.mockResolvedValue([
{ id: 701, question: 1 },
{ id: 702, question: 2 },
]);
mocks.deleteCondition.mockResolvedValue({ data: { message: "Condition deleted" } });
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(3, null, 7, "ab12345");
expect(logic.answers.value[1]).toBe(true);
expect(logic.answers.value[2]).toBe(false);
const result = await logic.clearVehicleAnswers({
departmentId: 3,
laneId: 7,
reg: "ab12345",
});
expect(result).toEqual({ deletedCount: 2 });
expect(mocks.getAll).toHaveBeenCalledWith({
lane: 7,
reg: "AB12345",
department: 3,
});
expect(mocks.deleteCondition).toHaveBeenCalledTimes(2);
expect(mocks.deleteCondition).toHaveBeenNthCalledWith(1, 701);
expect(mocks.deleteCondition).toHaveBeenNthCalledWith(2, 702);
expect(logic.answers.value[1]).toBe(null);
expect(logic.answers.value[2]).toBe(null);
expect(logic.allVisibleQuestionsAnswered.value).toBe(false);
});
}); });