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"],
"guestHome": ["guest.home.title"],
"PwaDownload": ["guest.pwa.title"],
"connectivity-issue": ["connectivity.title"]
"connectivity-issue": ["connectivity.title"],
"selfservestudio": ["admin.self_serve.title"]
};
const toReadableTitle = (value) => {
@@ -124,6 +124,20 @@ const modules = computed(() => [
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>
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import Swal from "sweetalert2";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useSelfServeLogic } from "@/composables/useSelfServeLogic";
import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue";
@@ -28,7 +29,16 @@ const props = defineProps({
const emit = defineEmits(["closeModal"]);
const availableLanes = ref([]);
const availableVehicleTypes = ref([]);
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 {
@@ -44,17 +54,84 @@ const {
evaluationTrace,
visibleQuestions,
activeTasks,
currentQuestion,
allVisibleQuestionsAnswered,
machineAvailable,
allowed,
fetchSelfServeData,
syncVehicleAnswer,
clearVehicleAnswers,
evaluateCondition,
} = useSelfServeLogic();
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(() => (
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 () => {
@@ -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 () => {
if (!selectedLaneId.value || !reg.value.trim()) {
return;
}
const vehicleTypeId = normalizeVehicleTypeId(selectedVehicleTypeId.value);
await fetchSelfServeData(
props.departmentId,
props.vehicleTypeId,
vehicleTypeId,
selectedLaneId.value,
reg.value.trim().toUpperCase()
);
@@ -87,6 +181,33 @@ const submitAnswer = async (questionId, value) => {
reg: reg.value,
questionId,
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 () => {
await loadLanes();
await loadVehicleTypes();
await refresh();
});
watch(() => [selectedLaneId.value, reg.value], async ([laneId, registration], [oldLaneId, oldRegistration] = []) => {
if (laneId === oldLaneId && registration === oldRegistration) {
watch(() => [selectedLaneId.value, reg.value, normalizeVehicleTypeId(selectedVehicleTypeId.value)], async ([laneId, registration, vehicleTypeId], [oldLaneId, oldRegistration, oldVehicleTypeId] = []) => {
if (laneId === oldLaneId && registration === oldRegistration && vehicleTypeId === oldVehicleTypeId) {
return;
}
@@ -126,11 +248,11 @@ watch(() => [selectedLaneId.value, reg.value], async ([laneId, registration], [o
<div class="column is-12">
<div class="box">
<div class="columns is-multiline">
<div class="column is-4">
<div class="column is-3">
<label class="label">Registreringsnummer</label>
<input v-model="reg" class="input" type="text" placeholder="AB12345" data-testid="self-serve-try-reg" />
</div>
<div v-if="!props.laneId" class="column is-4">
<div v-if="!props.laneId" class="column is-3">
<label class="label">Bane</label>
<div class="select is-fullwidth">
<select v-model="selectedLaneId" data-testid="self-serve-try-lane">
@@ -141,11 +263,22 @@ watch(() => [selectedLaneId.value, reg.value], async ([laneId, registration], [o
</select>
</div>
</div>
<div v-else class="column is-4">
<div v-else class="column is-3">
<label class="label">Bane</label>
<input class="input" :value="selectedLaneId" type="text" disabled>
</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">
Hent preview
</button>
@@ -159,8 +292,8 @@ watch(() => [selectedLaneId.value, reg.value], async ([laneId, registration], [o
<span class="tag" :class="machineAvailable ? 'is-success' : 'is-light'">
Maskine tilgaengelig: {{ machineAvailable ? "Ja" : "Nej" }}
</span>
<span class="tag" :class="allVisibleQuestionsAnswered ? 'is-success' : 'is-warning'">
Alle synlige sporgsmal besvaret: {{ allVisibleQuestionsAnswered ? "Ja" : "Nej" }}
<span class="tag" :class="allDisplayQuestionsAnswered ? 'is-success' : 'is-warning'">
Alle synlige sporgsmal besvaret: {{ allDisplayQuestionsAnswered ? "Ja" : "Nej" }}
</span>
<span v-if="session" class="tag is-info">
Session: {{ session.status }} (#{{ session.id }})
@@ -188,25 +321,12 @@ watch(() => [selectedLaneId.value, reg.value], async ([laneId, registration], [o
<div class="box" style="height: 100%">
<h4 class="title is-5">Sporgsmal</h4>
<div v-if="currentQuestion" class="notification is-info is-light">
<p class="title is-6">{{ currentQuestion.question }}</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 v-if="displayQuestions.length === 0" class="notification is-success is-light">
<p>Ingen synlige sporgsmal for denne preview.</p>
</div>
<SelfServeQuestionCards
:visibleQuestions="visibleQuestions"
:visibleQuestions="displayQuestions"
:answers="answers"
@answer-question="submitAnswer"
/>
@@ -249,7 +369,18 @@ watch(() => [selectedLaneId.value, reg.value], async ([laneId, registration], [o
<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>
<li v-for="question in answeredQuestions" :key="question.id" class="mb-1">
{{ question.question }}:
@@ -163,11 +163,19 @@ export const SelfServeVehicleConditions = {
single: async (id) => {
return ObjectsGlobal.get.object(SelfServeVehicleConditions.meta.endpoint, id);
},
previewAllowed: async (laneId, reg) => {
return authenticatedRequest("/department/selfserve/vehicle/allowed", "GET", {
previewAllowed: async (laneId, reg, vehicleTypeId = null) => {
const params = {
lane_id: parseInt(laneId),
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 = {}) => {
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 extractVehicleConditionQuestionId = (condition) => (
parseInt(condition?.question?.id ?? condition?.question_id ?? condition?.question ?? 0) || null
);
const buildAnswersMap = (questions) => questions.reduce((accumulator, question) => {
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);
});
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 = {}) => {
if (!existingQuestion) {
return incomingQuestion;
@@ -177,8 +209,22 @@ export function useSelfServeLogic() {
const configVersionId = ref(null);
const evaluationTrace = ref(null);
const lastPreviewContextKey = ref(null);
const lastVehicleTypeOverride = ref(null);
const lastResolvedVehicleTypeId = ref(null);
const summaryVisibleQuestionIds = 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 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 filename = attachment?.content?.other || "";
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
@@ -246,7 +304,11 @@ export function useSelfServeLogic() {
};
const applyPreviewData = async (previewData, options = {}) => {
const { mergeQuestions = false } = options;
const { mergeQuestions = false, requestId = null } = options;
if (!isFetchRequestActive(requestId)) {
return false;
}
preview.value = previewData || null;
lane.value = previewData?.lane || lane.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)
: [];
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)
? 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;
session.value = summaryData?.session || session.value;
lane.value = summaryData?.lane || lane.value;
@@ -332,9 +405,16 @@ export function useSelfServeLogic() {
.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.from(new Set(normalizedTasks.flatMap((task) => task.services || [])));
}
return true;
};
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)) {
return null;
}
@@ -361,7 +442,10 @@ export function useSelfServeLogic() {
try {
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;
} catch (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 requestId = beginFetchRequest();
if (!laneId || !reg || reg.trim().length < 2) {
preview.value = null;
summary.value = null;
@@ -391,33 +477,93 @@ export function useSelfServeLogic() {
configVersionId.value = null;
evaluationTrace.value = null;
lastPreviewContextKey.value = null;
lastVehicleTypeOverride.value = null;
lastResolvedVehicleTypeId.value = null;
summaryVisibleQuestionIds.value = [];
summaryQuestionOrder.value = {};
loading.value = false;
return null;
}
loading.value = true;
try {
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 previewData = await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg);
await applyPreviewData(previewData, { mergeQuestions: shouldMergeQuestions });
lastPreviewContextKey.value = contextKey;
const previewData = hasVehicleTypeOverride
? await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg, normalizedVehicleTypeId)
: 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;
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 (!hasSummaryQuestions && previewData?.session?.id) {
const fallbackSummary = await fetchWashSummary({ lane_id: parseInt(laneId), reg: normalizedReg }, false);
hasSummaryQuestions = Array.isArray(fallbackSummary?.questions);
if (!isFetchRequestActive(requestId)) {
return null;
}
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 (!hasSummaryQuestions) {
if (!hasSummaryQuestions && isFetchRequestActive(requestId)) {
setSummaryVisibleQuestions(questions.value);
}
@@ -426,11 +572,13 @@ export function useSelfServeLogic() {
console.error("Error fetching self-serve preview:", error);
return null;
} 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) {
return null;
}
@@ -455,11 +603,20 @@ export function useSelfServeLogic() {
[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) {
await applySummaryData(responseSummary);
updateResolvedVehicleTypeId(responseSummary, responseSummary?.session);
}
await fetchSelfServeData(departmentId, null, laneId, normalizedReg);
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg);
answers.value = {
...answers.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 = () => {
completedTasks.value = {};
};
@@ -492,6 +710,14 @@ export function useSelfServeLogic() {
const visibleQuestions = computed(() => {
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]
.filter((question) => allowedQuestionIds.has(question.id))
.sort((a, b) => {
@@ -664,6 +890,7 @@ export function useSelfServeLogic() {
fetchSelfServeData,
fetchWashSummary,
syncVehicleAnswer,
clearVehicleAnswers,
evaluateRule,
evaluateCondition,
isQuestionVisible,
+44 -24
View File
@@ -121,18 +121,8 @@ import DepartmentTimeBookingsNew
from "@/views/dashboards/departmentDashboard/modules/time-bookings/book/DepartmentTimeBookingsNew.vue";
import DepartmentWashLanes from "@/views/dashboards/departmentDashboard/modules/wash-lanes/DepartmentWashLanes.vue";
import DepartmentWashLane from "@/views/dashboards/departmentDashboard/modules/wash-lanes/DepartmentWashLane.vue";
import DepartmentSelfServeQuestions
from "@/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeQuestions.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 DepartmentSelfServeStudio
from "@/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue";
import DepartmentGoals
from "@/views/dashboards/departmentDashboard/modules/goals/DepartmentGoals.vue";
import ConfigurationLimble from "@/views/dashboards/superUserDashboard/configuration/ConfigurationLimble.vue";
@@ -524,41 +514,71 @@ export const router = createRouter({
component: DepartmentWashLane,
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',
path: '/admin/:departmentId/modules/self-serve/questions',
component: DepartmentSelfServeQuestions,
meta: { middleware: adminMiddleware, departmentSelection: true }
redirect: (to) => ({
name: 'selfservestudio',
params: to.params,
query: { ...to.query, tab: 'questions' }
}),
meta: { middleware: adminMiddleware, departmentSelection: true, title: 'Self-Serve Studio' }
},
{
name: 'selfservetasks',
path: '/admin/:departmentId/modules/self-serve/tasks',
component: DepartmentSelfServeTasks,
meta: { middleware: adminMiddleware, departmentSelection: true }
redirect: (to) => ({
name: 'selfservestudio',
params: to.params,
query: { ...to.query, tab: 'tasks' }
}),
meta: { middleware: adminMiddleware, departmentSelection: true, title: 'Self-Serve Studio' }
},
{
name: 'selfserveconditions',
path: '/admin/:departmentId/modules/self-serve/conditions',
component: DepartmentSelfServeConditions,
meta: { middleware: adminMiddleware, departmentSelection: true }
redirect: (to) => ({
name: 'selfservestudio',
params: to.params,
query: { ...to.query, tab: 'conditions' }
}),
meta: { middleware: adminMiddleware, departmentSelection: true, title: 'Self-Serve Studio' }
},
{
name: 'selfserveconditionrules',
path: '/admin/:departmentId/modules/self-serve/condition-rules',
component: DepartmentSelfServeConditionRules,
meta: { middleware: adminMiddleware, departmentSelection: true }
redirect: (to) => ({
name: 'selfservestudio',
params: to.params,
query: { ...to.query, tab: 'conditions' }
}),
meta: { middleware: adminMiddleware, departmentSelection: true, title: 'Self-Serve Studio' }
},
{
name: 'selfservevehicleconditions',
path: '/admin/:departmentId/modules/self-serve/vehicle-conditions',
component: DepartmentSelfServeVehicleConditions,
meta: { middleware: adminMiddleware, departmentSelection: true }
redirect: (to) => ({
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',
path: '/admin/:departmentId/modules/self-serve/machine-types',
component: DepartmentSelfServeMachineTypes,
meta: { middleware: adminMiddleware, departmentSelection: true }
redirect: (to) => ({
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',
@@ -29,6 +29,22 @@ const selectVehicleType = (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 () => {
isLoading.value = true;
try {
@@ -55,6 +71,12 @@ onMounted(() => {
<div v-if="lane" data-testid="department-wash-lane-page">
<PageTitle :title="t('admin.wash_lanes.single.title') + ': ' + lane.name" :subtitle="'ID: ' + lane.id">
<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`)">
<span class="icon">
<i class="fas fa-arrow-left"></i>
@@ -286,12 +308,12 @@ onMounted(() => {
</span>
<span>{{ t('admin.self_serve.tasks.add') }}</span>
</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">
<i class="fas fa-car-side"></i>
<i class="fas fa-project-diagram"></i>
</span>
<span>{{ t('admin.self_serve.vehicle_conditions.title') }}</span>
</router-link>
<span>Open Studio</span>
</button>
<button
class="button is-warning is-light"
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 laneInProgressLoading = reactive<Record<number, boolean>>({});
const lanePollSubscribers = reactive<Record<number, number>>({});
const lanePollingActive = reactive<Record<number, boolean>>({});
const lanePollIntervals = new Map<number, ReturnType<typeof setInterval>>();
const createInitialRelayStatusMap = (): RelayStatusMap => ({
@@ -102,6 +103,9 @@ const ensureLaneState = (laneId: number): void => {
if (!Object.prototype.hasOwnProperty.call(lanePollSubscribers, laneId)) {
lanePollSubscribers[laneId] = 0;
}
if (!Object.prototype.hasOwnProperty.call(lanePollingActive, laneId)) {
lanePollingActive[laneId] = false;
}
};
const parseErrorMessage = (error: unknown): string => {
@@ -465,9 +469,12 @@ export const startPolling = async (
lanePollSubscribers[normalizedLaneId] += 1;
if (lanePollIntervals.has(normalizedLaneId)) {
lanePollingActive[normalizedLaneId] = true;
return;
}
lanePollingActive[normalizedLaneId] = true;
const pollInterval = Math.max(1000, Math.round(intervalMs));
const refreshLane = async () => {
await Promise.allSettled([
@@ -501,6 +508,7 @@ export const stopPolling = (laneId: number): void => {
clearInterval(intervalRef);
}
lanePollIntervals.delete(normalizedLaneId);
lanePollingActive[normalizedLaneId] = false;
};
export const clearLaneError = (laneId: number): void => {
@@ -520,7 +528,7 @@ export const useMachineConnectivity = (laneId: number) => {
const lastUpdatedAt = computed(() => laneLastUpdatedAt[normalizedLaneId]);
const inProgressDetails = computed(() => laneInProgressDetails[normalizedLaneId]);
const inProgressLoading = computed(() => laneInProgressLoading[normalizedLaneId]);
const isPolling = computed(() => lanePollIntervals.has(normalizedLaneId));
const isPolling = computed(() => Boolean(lanePollingActive[normalizedLaneId]));
return {
laneId: normalizedLaneId,
@@ -561,6 +569,7 @@ export const machineConnectivityStore = {
laneInProgressDetails,
laneInProgressLoading,
lanePollSubscribers,
lanePollingActive,
},
functions: {
fetchRelayStatus,
@@ -598,6 +607,7 @@ export const __resetMachineConnectivityStoreForTests = (): void => {
resetReactiveObject(laneInProgressDetails as unknown as Record<string, unknown>);
resetReactiveObject(laneInProgressLoading as unknown as Record<string, unknown>);
resetReactiveObject(lanePollSubscribers as unknown as Record<string, unknown>);
resetReactiveObject(lanePollingActive as unknown as Record<string, unknown>);
};
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">
<p class="divider-text">Access Controls</p>
</div>
<p class="title is-6 mb-2">Department Self-Serve</p>
<div class="columns is-mobile">
<div class="column is-half">
<b-tooltip multilined dashed>
@@ -119,6 +120,7 @@ watch(
<div class="columns is-mobile">
<div class="column is-half">
<SelfServeMachineStatus :machineStatus="departmentSelfServeEnabled ? 'ON' : 'OFF'" />
<p class="label is-small mt-2">{{ departmentSelfServeStatusLabel }}</p>
</div>
<div class="column is-half">
<b-switch
@@ -132,6 +134,7 @@ watch(
</div>
</div>
</div>
<p v-if="departmentSelfServeError" class="help is-danger mt-2">{{ departmentSelfServeError }}</p>
<!-- Relay controls -->
<SelfServeMachineRelayControls :machine="machine" />
<!-- Current wash cycle -->
@@ -89,8 +89,12 @@ const parseBillableStatus = (inProgressDetails: typeof connectivity.inProgressDe
if (!inProgressDetails?.in_progress) {
return MachineStatusType.OFF;
}
const includedTimeMs = (inProgressDetails.session.included_minutes ?? 0) * 60 * 1000;
const washStartedAt = new Date(inProgressDetails.session.wash_started_at).getTime();
const includedTimeMs = Number(inProgressDetails.session?.included_minutes ?? 0) * 60 * 1000;
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) {
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 no included minutes, or they've all been used, it should count up from 0.
const includedMinutes = inProgressDetails.session.included_minutes ?? 0;
const washStartedAt = new Date(inProgressDetails.session.wash_started_at).getTime();
const elapsedMinutes = Math.floor((Date.now() - washStartedAt) / (60 * 1000));
const includedMinutes = Number(inProgressDetails.session?.included_minutes ?? 0);
const washStartedAtValue = inProgressDetails.session?.wash_started_at;
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 there are included minutes, we show the remaining minutes.
return elapsedMinutes - includedMinutes;
@@ -113,6 +121,8 @@ const parseBillableMinuteAmount = (inProgressDetails: typeof connectivity.inProg
// If there are no included minutes, we show the elapsed minutes.
return includedMinutes - elapsedMinutes;
}
return elapsedMinutes;
}
const openGate = async (gate: LaneGate) => {
@@ -241,7 +251,7 @@ onUnmounted(() => {
<p>Loading in-progress details...</p>
</template>
<template v-else-if="inProgressDetails?.in_progress">
<div class="columns is-mobile">
<div class="columns is-mobile is-multiline">
<div class="column is-half">
<p>
<b-tooltip multilined dashed>
@@ -266,6 +276,52 @@ onUnmounted(() => {
</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>
</template>
<template v-else>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import {defineProps} from "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]}>()
const icon = {
@@ -11,7 +11,7 @@ const icon = {
ON: "check",
OFF: "close",
INCLUDED: "clock",
BILLABLE: "clock",
BILLABLE: "dollar-sign",
}
const type = {
ONLINE: "is-success",
@@ -19,31 +19,18 @@ const type = {
MAINTENANCE: "is-warning",
ON: "is-success",
OFF: "is-danger",
INCLUDED: "is-info",
BILLABLE: "is-info",
INCLUDED: "is-grey",
BILLABLE: "is-success",
}
</script>
<template>
<div class="flex items-center gap-2">
<span class="is-small label">
<span class="mr-2">
<b-icon
: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>
<SelfServeGenericStatus
:icon="icon[machineStatus]"
:type="type[machineStatus]"
:label="machineStatus === 'INCLUDED' ? 'Incl.' : machineStatus === 'BILLABLE' ? 'ADD' : machineStatus === 'MAINTENANCE' ? 'N/A' : machineStatus"
/>
</template>
<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(),
washSummary: vi.fn(),
add: vi.fn(),
getAll: vi.fn(),
deleteCondition: vi.fn(),
attachmentsList: vi.fn(),
attachmentsDownload: vi.fn(),
request: vi.fn(),
@@ -17,8 +19,10 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
get: {
previewAllowed: mocks.previewAllowed,
washSummary: mocks.washSummary,
all: mocks.getAll,
},
add: mocks.add,
delete: mocks.deleteCondition,
},
self_serve_tasks: {
attachments: {
@@ -37,6 +41,8 @@ describe("useSelfServeLogic", () => {
mocks.previewAllowed.mockReset();
mocks.washSummary.mockReset();
mocks.add.mockReset();
mocks.getAll.mockReset();
mocks.deleteCondition.mockReset();
mocks.attachmentsList.mockReset();
mocks.attachmentsDownload.mockReset();
mocks.request.mockReset();
@@ -87,7 +93,7 @@ describe("useSelfServeLogic", () => {
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(logic.allowed.value).toBe(true);
expect(logic.machineAvailable.value).toBe(true);
@@ -128,7 +134,7 @@ describe("useSelfServeLogic", () => {
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
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]);
});
@@ -152,6 +158,61 @@ describe("useSelfServeLogic", () => {
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", () => {
const logic = useSelfServeLogic();
@@ -270,6 +331,160 @@ describe("useSelfServeLogic", () => {
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 () => {
const baseConditions = [
{ id: 100, name: "Primary answer is yes" },
@@ -289,17 +504,29 @@ describe("useSelfServeLogic", () => {
conditions: baseConditions,
rules: baseRules,
});
mocks.washSummary.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.washSummary
.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: [],
})
.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({
data: {
@@ -439,4 +666,79 @@ describe("useSelfServeLogic", () => {
expect(logic.answers.value[1]).toBe(true);
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);
});
});