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:
+2
-1
@@ -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)
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
+1045
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
+11
-1
@@ -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 {
|
||||
|
||||
+29
@@ -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>
|
||||
+3
@@ -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 -->
|
||||
|
||||
+62
-6
@@ -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>
|
||||
|
||||
+9
-22
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user