Add department self-serve management with API integration and tests:
- Introduced `DepartmentModulesDisplay` and `DepartmentModulesSetup` components for self-serve enablement management. - Implemented `getDepartmentSelfServeEnabled` and `setDepartmentSelfServeEnabled` helpers. - Added unit tests for self-serve status fetching, updating, and UI synchronization. - Enhanced connectivity and relay logic with in-progress wash session handling and detailed displays.
This commit is contained in:
@@ -5,12 +5,18 @@ import { authenticatedRequest } from "@/components/session/authenticatedRequest.
|
||||
import {BSwitch} from "buefy";
|
||||
import {useToast} from 'vue-toast-notification';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {
|
||||
getDepartmentSelfServeEnabled,
|
||||
setDepartmentSelfServeEnabled,
|
||||
} from "@/composables/departmentSelfServeEnabled.js";
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const selfServeEnabled = ref(false);
|
||||
const isLoadingSelfServeEnabled = ref(false);
|
||||
const isSavingSelfServeEnabled = ref(false);
|
||||
const bookingsCount = ref(0);
|
||||
|
||||
const modules = computed(() => [
|
||||
@@ -98,19 +104,24 @@ const modules = computed(() => [
|
||||
count: 0,
|
||||
hasSwitch: true,
|
||||
switchValue: selfServeEnabled,
|
||||
switchDisabled: isLoadingSelfServeEnabled.value || isSavingSelfServeEnabled.value,
|
||||
onToggle: async (newValue) => {
|
||||
const departmentId = getDepartmentId();
|
||||
if (!departmentId) return;
|
||||
// Get the current self-serve status, and set it to the opposite of the new value
|
||||
|
||||
const currentStatus = selfServeEnabled.value;
|
||||
selfServeEnabled.value = newValue;
|
||||
isSavingSelfServeEnabled.value = true;
|
||||
|
||||
try {
|
||||
await authenticatedRequest(`/departments/self-serve/enabled?id=${departmentId}&enabled=${newValue}`, 'PUT');
|
||||
selfServeEnabled.value = await setDepartmentSelfServeEnabled(departmentId, newValue);
|
||||
toast.success(newValue ? t('admin.department_modules.self_wash.enabled') : t('admin.department_modules.self_wash.disabled'));
|
||||
} catch (error) {
|
||||
console.error("Failed to update self-serve status", error);
|
||||
// Revert switch value on error
|
||||
selfServeEnabled.value = currentStatus;
|
||||
toast.error(t('admin.department_modules.self_wash.update_error'));
|
||||
} finally {
|
||||
isSavingSelfServeEnabled.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,11 +190,13 @@ const getSelfServeStatus = async () => {
|
||||
const departmentId = getDepartmentId();
|
||||
if (!departmentId) return;
|
||||
|
||||
isLoadingSelfServeEnabled.value = true;
|
||||
try {
|
||||
const response = await authenticatedRequest(`/departments/self-serve/enabled?id=${departmentId}`, 'GET');
|
||||
selfServeEnabled.value = response.data.data.enabled;
|
||||
selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch self-serve status", error);
|
||||
} finally {
|
||||
isLoadingSelfServeEnabled.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -217,7 +230,13 @@ setInterval(() => {
|
||||
<div class="is-pulled-right card-header-title pr-0">
|
||||
<span class="button is-small is-dark" @click="module.onClickCount" v-if="module.count > 0">{{ module.count }}</span>
|
||||
<span class="button is-small is-outlined" v-else-if="module?.hasSwitch" @click.stop>
|
||||
<b-switch size="is-small" type="is-link" :model-value="module.switchValue" @update:model-value="module.onToggle"/>
|
||||
<b-switch
|
||||
size="is-small"
|
||||
type="is-link"
|
||||
:model-value="Boolean(module?.switchValue?.value ?? module?.switchValue)"
|
||||
:disabled="module?.switchDisabled"
|
||||
@update:model-value="module.onToggle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -232,4 +251,4 @@ setInterval(() => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
|
||||
const normalizeDepartmentId = (departmentId) => {
|
||||
const parsedDepartmentId = Number.parseInt(String(departmentId), 10);
|
||||
if (!Number.isInteger(parsedDepartmentId) || parsedDepartmentId <= 0) {
|
||||
throw new Error(`Invalid department id: ${departmentId}`);
|
||||
}
|
||||
return parsedDepartmentId;
|
||||
};
|
||||
|
||||
const normalizeEnabledValue = (value) => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return value > 0;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const normalizedValue = value.trim().toLowerCase();
|
||||
if (["true", "1", "yes", "on", "enabled"].includes(normalizedValue)) {
|
||||
return true;
|
||||
}
|
||||
if (["false", "0", "no", "off", "disabled", ""].includes(normalizedValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return Boolean(value);
|
||||
};
|
||||
|
||||
const extractEnabledValue = (response) => {
|
||||
const payload = response?.data?.data ?? response?.data ?? response;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "enabled")) {
|
||||
return payload.enabled;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getDepartmentSelfServeEnabled = async (departmentId) => {
|
||||
const normalizedDepartmentId = normalizeDepartmentId(departmentId);
|
||||
const response = await authenticatedRequest(
|
||||
`/departments/self-serve/enabled?id=${normalizedDepartmentId}`,
|
||||
"GET"
|
||||
);
|
||||
|
||||
return normalizeEnabledValue(extractEnabledValue(response));
|
||||
};
|
||||
|
||||
export const setDepartmentSelfServeEnabled = async (departmentId, enabled) => {
|
||||
const normalizedDepartmentId = normalizeDepartmentId(departmentId);
|
||||
const normalizedEnabled = normalizeEnabledValue(enabled);
|
||||
|
||||
const response = await authenticatedRequest(
|
||||
`/departments/self-serve/enabled?id=${normalizedDepartmentId}&enabled=${normalizedEnabled ? "true" : "false"}`,
|
||||
"PUT"
|
||||
);
|
||||
|
||||
const responseEnabled = extractEnabledValue(response);
|
||||
return responseEnabled === undefined
|
||||
? normalizedEnabled
|
||||
: normalizeEnabledValue(responseEnabled);
|
||||
};
|
||||
|
||||
export default {
|
||||
getDepartmentSelfServeEnabled,
|
||||
setDepartmentSelfServeEnabled,
|
||||
};
|
||||
@@ -12,6 +12,10 @@ import SuperUserDashboardDepartmentModulesNavigation
|
||||
from "@/views/dashboards/superUserDashboard/department/modules/SuperUserDashboardDepartmentModulesNavigation.vue";
|
||||
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
|
||||
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
|
||||
import {
|
||||
getDepartmentSelfServeEnabled,
|
||||
setDepartmentSelfServeEnabled,
|
||||
} from "@/composables/departmentSelfServeEnabled.js";
|
||||
|
||||
// Get the department from the route
|
||||
const router = useRouter();
|
||||
@@ -21,6 +25,10 @@ const departmentVariables = ref([]);
|
||||
const workfeedDepartmentOptions = ref([{ value: "", label: "No Workfeed department" }]);
|
||||
const isLoadingWorkfeedDepartments = ref(false);
|
||||
const workfeedDepartmentOptionsError = ref("");
|
||||
const selfServeEnabled = ref(false);
|
||||
const isLoadingSelfServeEnabled = ref(false);
|
||||
const isSavingSelfServeEnabled = ref(false);
|
||||
const selfServeEnabledError = ref("");
|
||||
|
||||
const getDepartmentVariables = async () => {
|
||||
await SessionUser.request(
|
||||
@@ -129,8 +137,40 @@ const setWorkfeedDepartmentId = async (selectedDepartmentId) => {
|
||||
});
|
||||
};
|
||||
|
||||
const loadSelfServeEnabled = async () => {
|
||||
isLoadingSelfServeEnabled.value = true;
|
||||
selfServeEnabledError.value = "";
|
||||
try {
|
||||
selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId.value);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
selfServeEnabledError.value = "Unable to load self-serve status.";
|
||||
selfServeEnabled.value = false;
|
||||
} finally {
|
||||
isLoadingSelfServeEnabled.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateSelfServeEnabled = async (enabled) => {
|
||||
const previousValue = selfServeEnabled.value;
|
||||
selfServeEnabled.value = Boolean(enabled);
|
||||
isSavingSelfServeEnabled.value = true;
|
||||
selfServeEnabledError.value = "";
|
||||
|
||||
try {
|
||||
selfServeEnabled.value = await setDepartmentSelfServeEnabled(departmentId.value, enabled);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
selfServeEnabled.value = previousValue;
|
||||
selfServeEnabledError.value = "Unable to update self-serve status.";
|
||||
} finally {
|
||||
isSavingSelfServeEnabled.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
getDepartmentVariables();
|
||||
getWorkfeedDepartmentOptions();
|
||||
loadSelfServeEnabled();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -217,6 +257,25 @@ getWorkfeedDepartmentOptions();
|
||||
></ConfigurationSwitch>
|
||||
</template>
|
||||
</ConfigurationCategory>
|
||||
<ConfigurationCategory
|
||||
module="Department setup"
|
||||
title="Moduler"
|
||||
icon="fas fa-soap"
|
||||
subtitle="Selvvask"
|
||||
>
|
||||
<template #default>
|
||||
<ConfigurationSwitch
|
||||
title="Selvvask"
|
||||
description="Aktiver selvvask i denne afdeling."
|
||||
:value="selfServeEnabled"
|
||||
:disabled="isLoadingSelfServeEnabled || isSavingSelfServeEnabled"
|
||||
:on-switch="updateSelfServeEnabled"
|
||||
></ConfigurationSwitch>
|
||||
<p class="help is-danger" v-if="selfServeEnabledError">
|
||||
{{ selfServeEnabledError }}
|
||||
</p>
|
||||
</template>
|
||||
</ConfigurationCategory>
|
||||
<ConfigurationCategory
|
||||
module="Workfeed"
|
||||
title="Moduler"
|
||||
|
||||
+9
@@ -152,6 +152,15 @@ export const lane = {
|
||||
RESERVE: (laneId: number, payload: LaneCommandPayload = {}) => executeLaneCommand(laneId, "RESERVE", payload),
|
||||
RELEASE: (laneId: number, payload: LaneCommandPayload = {}) => executeLaneCommand(laneId, "RELEASE", payload),
|
||||
},
|
||||
wash: {
|
||||
inProgress: (laneId: number) => SessionUser.request(
|
||||
"/modules/self-serve/lane/wash/in-progress",
|
||||
"GET",
|
||||
{
|
||||
lane_id: normalizeLaneId(laneId),
|
||||
}
|
||||
),
|
||||
},
|
||||
gate: {
|
||||
open: (laneId: number, gate: LaneGate) => SessionUser.request(
|
||||
"/modules/self-serve/lane/gate/open",
|
||||
|
||||
+93
-4
@@ -29,6 +29,13 @@ type ForceEnableOptions = {
|
||||
duration?: number | null;
|
||||
licensePlate?: string | null;
|
||||
};
|
||||
type InProgressWashDetails = {
|
||||
lane_id: number;
|
||||
in_progress: boolean;
|
||||
session: Record<string, unknown> | null;
|
||||
customer: Record<string, unknown> | null;
|
||||
vehicle: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 7000;
|
||||
|
||||
@@ -37,6 +44,8 @@ const laneRelayLoading = reactive<Record<number, RelayLoadingMap>>({});
|
||||
const laneCommandLoading = reactive<Record<number, LaneCommandLoadingState>>({});
|
||||
const laneErrors = reactive<Record<number, string | null>>({});
|
||||
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 lanePollIntervals = new Map<number, ReturnType<typeof setInterval>>();
|
||||
|
||||
@@ -84,6 +93,12 @@ const ensureLaneState = (laneId: number): void => {
|
||||
if (!Object.prototype.hasOwnProperty.call(laneLastUpdatedAt, laneId)) {
|
||||
laneLastUpdatedAt[laneId] = null;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(laneInProgressDetails, laneId)) {
|
||||
laneInProgressDetails[laneId] = null;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(laneInProgressLoading, laneId)) {
|
||||
laneInProgressLoading[laneId] = false;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(lanePollSubscribers, laneId)) {
|
||||
lanePollSubscribers[laneId] = 0;
|
||||
}
|
||||
@@ -191,6 +206,40 @@ const updateLaneLastUpdatedAt = (laneId: number): void => {
|
||||
laneLastUpdatedAt[laneId] = Date.now();
|
||||
};
|
||||
|
||||
const normalizeInProgressDetails = (
|
||||
laneId: number,
|
||||
payload: unknown
|
||||
): InProgressWashDetails => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return {
|
||||
lane_id: laneId,
|
||||
in_progress: false,
|
||||
session: null,
|
||||
customer: null,
|
||||
vehicle: null,
|
||||
};
|
||||
}
|
||||
|
||||
const source = payload as Record<string, unknown>;
|
||||
const session = source.session && typeof source.session === "object"
|
||||
? source.session as Record<string, unknown>
|
||||
: null;
|
||||
const customer = source.customer && typeof source.customer === "object"
|
||||
? source.customer as Record<string, unknown>
|
||||
: null;
|
||||
const vehicle = source.vehicle && typeof source.vehicle === "object"
|
||||
? source.vehicle as Record<string, unknown>
|
||||
: null;
|
||||
|
||||
return {
|
||||
lane_id: Number.isFinite(Number(source.lane_id)) ? Number(source.lane_id) : laneId,
|
||||
in_progress: Boolean(source.in_progress),
|
||||
session,
|
||||
customer,
|
||||
vehicle,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseRelayStatus = (status: RelayStatus | null): RelayDisplayStatus => {
|
||||
if (!status) {
|
||||
return "MAINTENANCE";
|
||||
@@ -304,6 +353,32 @@ export const setRelayState = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchInProgressDetails = async (
|
||||
laneId: number
|
||||
): Promise<InProgressWashDetails> => {
|
||||
const normalizedLaneId = toLaneId(laneId);
|
||||
ensureLaneState(normalizedLaneId);
|
||||
|
||||
laneInProgressLoading[normalizedLaneId] = true;
|
||||
|
||||
try {
|
||||
const response = await lane.wash.inProgress(normalizedLaneId);
|
||||
const details = normalizeInProgressDetails(
|
||||
normalizedLaneId,
|
||||
extractPayload(response)
|
||||
);
|
||||
laneInProgressDetails[normalizedLaneId] = details;
|
||||
laneErrors[normalizedLaneId] = null;
|
||||
updateLaneLastUpdatedAt(normalizedLaneId);
|
||||
return details;
|
||||
} catch (error) {
|
||||
laneErrors[normalizedLaneId] = parseErrorMessage(error);
|
||||
throw error;
|
||||
} finally {
|
||||
laneInProgressLoading[normalizedLaneId] = false;
|
||||
}
|
||||
};
|
||||
|
||||
type CommandLoadingKey = keyof LaneCommandLoadingState;
|
||||
|
||||
const runLaneAction = async <T>(
|
||||
@@ -394,13 +469,17 @@ export const startPolling = async (
|
||||
}
|
||||
|
||||
const pollInterval = Math.max(1000, Math.round(intervalMs));
|
||||
const refreshLane = async () => {
|
||||
await Promise.allSettled([
|
||||
fetchAllRelayStatuses(normalizedLaneId),
|
||||
fetchInProgressDetails(normalizedLaneId),
|
||||
]);
|
||||
};
|
||||
|
||||
await fetchAllRelayStatuses(normalizedLaneId).catch(() => {
|
||||
// Polling should continue even if one fetch iteration fails.
|
||||
});
|
||||
await refreshLane();
|
||||
|
||||
const intervalRef = setInterval(() => {
|
||||
fetchAllRelayStatuses(normalizedLaneId).catch(() => {
|
||||
refreshLane().catch(() => {
|
||||
// Keep polling resilient to temporary backend/network failures.
|
||||
});
|
||||
}, pollInterval);
|
||||
@@ -439,6 +518,8 @@ export const useMachineConnectivity = (laneId: number) => {
|
||||
const commandLoading = computed(() => laneCommandLoading[normalizedLaneId]);
|
||||
const error = computed(() => laneErrors[normalizedLaneId]);
|
||||
const lastUpdatedAt = computed(() => laneLastUpdatedAt[normalizedLaneId]);
|
||||
const inProgressDetails = computed(() => laneInProgressDetails[normalizedLaneId]);
|
||||
const inProgressLoading = computed(() => laneInProgressLoading[normalizedLaneId]);
|
||||
const isPolling = computed(() => lanePollIntervals.has(normalizedLaneId));
|
||||
|
||||
return {
|
||||
@@ -449,9 +530,12 @@ export const useMachineConnectivity = (laneId: number) => {
|
||||
commandLoading,
|
||||
error,
|
||||
lastUpdatedAt,
|
||||
inProgressDetails,
|
||||
inProgressLoading,
|
||||
isPolling,
|
||||
fetchRelayStatus: (relay: RelayKind) => fetchRelayStatus(normalizedLaneId, relay),
|
||||
fetchAllRelayStatuses: () => fetchAllRelayStatuses(normalizedLaneId),
|
||||
fetchInProgressDetails: () => fetchInProgressDetails(normalizedLaneId),
|
||||
toggleRelay: (relay: RelayKind, on: boolean) => setRelayState(normalizedLaneId, relay, on),
|
||||
executeLaneCommand: (command: LaneCommand, payload: LaneCommandPayload = {}) => executeLaneCommand(normalizedLaneId, command, payload),
|
||||
stopWash: () => stopWash(normalizedLaneId),
|
||||
@@ -474,11 +558,14 @@ export const machineConnectivityStore = {
|
||||
laneCommandLoading,
|
||||
laneErrors,
|
||||
laneLastUpdatedAt,
|
||||
laneInProgressDetails,
|
||||
laneInProgressLoading,
|
||||
lanePollSubscribers,
|
||||
},
|
||||
functions: {
|
||||
fetchRelayStatus,
|
||||
fetchAllRelayStatuses,
|
||||
fetchInProgressDetails,
|
||||
setRelayState,
|
||||
executeLaneCommand,
|
||||
forceEnableMachine,
|
||||
@@ -508,6 +595,8 @@ export const __resetMachineConnectivityStoreForTests = (): void => {
|
||||
resetReactiveObject(laneCommandLoading as unknown as Record<string, unknown>);
|
||||
resetReactiveObject(laneErrors as unknown as Record<string, unknown>);
|
||||
resetReactiveObject(laneLastUpdatedAt as unknown as Record<string, unknown>);
|
||||
resetReactiveObject(laneInProgressDetails as unknown as Record<string, unknown>);
|
||||
resetReactiveObject(laneInProgressLoading as unknown as Record<string, unknown>);
|
||||
resetReactiveObject(lanePollSubscribers as unknown as Record<string, unknown>);
|
||||
};
|
||||
|
||||
|
||||
+60
-7
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineProps, onMounted, onUnmounted } from "vue";
|
||||
import { computed, onMounted, onUnmounted } from "vue";
|
||||
import SelfServeMachineStatus
|
||||
from "@/views/dashboards/superUserDashboard/selfserve/displays/machine/SelfServeMachineStatus.vue";
|
||||
import {
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type RelayKind,
|
||||
} from "@/views/dashboards/superUserDashboard/selfserve/components/SelfServeMachineConnectivity.vue";
|
||||
import type { Machine } from "@/views/dashboards/superUserDashboard/selfserve/types/MachineType.vue";
|
||||
import {BSwitch} from "buefy";
|
||||
|
||||
const props = defineProps<{ machine: Machine }>();
|
||||
|
||||
@@ -20,6 +19,8 @@ const statuses = computed(() => connectivity.statuses.value);
|
||||
const loading = computed(() => connectivity.loading.value);
|
||||
const commandLoading = computed(() => connectivity.commandLoading.value);
|
||||
const error = computed(() => connectivity.error.value);
|
||||
const inProgressDetails = computed(() => connectivity.inProgressDetails.value);
|
||||
const inProgressLoading = computed(() => connectivity.inProgressLoading.value);
|
||||
|
||||
const normalizeRelayId = (value: unknown): string | null => {
|
||||
if (typeof value !== "string") {
|
||||
@@ -39,9 +40,40 @@ const usesSharedGateRelay = computed(() => (
|
||||
const hasGateLoading = computed(() => (
|
||||
commandLoading.value.openEntranceGate || commandLoading.value.openExitGate
|
||||
));
|
||||
const combinedGateLoading = computed(() => (
|
||||
commandLoading.value.openEntranceGate || commandLoading.value.openExitGate
|
||||
));
|
||||
|
||||
const normalizeDisplayValue = (value: unknown): string | null => {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const customerDisplay = computed(() => {
|
||||
if (!inProgressDetails.value?.in_progress) {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
return normalizeDisplayValue(inProgressDetails.value.customer?.display_name)
|
||||
?? normalizeDisplayValue(inProgressDetails.value.customer?.customer_number)
|
||||
?? normalizeDisplayValue(inProgressDetails.value.session?.customer_number)
|
||||
?? "Unknown";
|
||||
});
|
||||
|
||||
const vehicleDisplay = computed(() => {
|
||||
if (!inProgressDetails.value?.in_progress) {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
return normalizeDisplayValue(inProgressDetails.value.vehicle?.reg)
|
||||
?? normalizeDisplayValue(inProgressDetails.value.session?.reg)
|
||||
?? "Unknown";
|
||||
});
|
||||
|
||||
const toggleRelay = async (relay: RelayKind, on: boolean) => {
|
||||
try {
|
||||
@@ -139,6 +171,27 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider">
|
||||
<p class="divider-text">Wash In Progress</p>
|
||||
</div>
|
||||
<div class="box is-shadowless p-3 mb-3">
|
||||
<template v-if="inProgressLoading">
|
||||
<p>Loading in-progress details...</p>
|
||||
</template>
|
||||
<template v-else-if="inProgressDetails?.in_progress">
|
||||
<p>
|
||||
<strong>Customer:</strong>
|
||||
{{ customerDisplay }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Vehicle:</strong>
|
||||
{{ vehicleDisplay }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>No wash in progress.</p>
|
||||
</template>
|
||||
</div>
|
||||
<div class="divider">
|
||||
<p class="divider-text">Gate Controls</p>
|
||||
</div>
|
||||
@@ -146,11 +199,11 @@ onUnmounted(() => {
|
||||
<div class="column is-full">
|
||||
<button
|
||||
class="button is-primary is-small is-fullwidth"
|
||||
:class="{ 'is-loading': combinedGateLoading }"
|
||||
:class="{ 'is-loading': hasGateLoading }"
|
||||
:disabled="hasGateLoading"
|
||||
@click="openSharedGate"
|
||||
>
|
||||
Open gate
|
||||
Open Gate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// @vitest-environment jsdom
|
||||
import { defineComponent, h } from "vue";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authenticatedRequest: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", async () => {
|
||||
const { ref } = await import("vue");
|
||||
const currentRoute = ref({
|
||||
params: {
|
||||
departmentId: "12",
|
||||
},
|
||||
});
|
||||
const push = vi.fn();
|
||||
|
||||
return {
|
||||
useRouter: () => ({
|
||||
currentRoute,
|
||||
push,
|
||||
}),
|
||||
__routeRef: currentRoute,
|
||||
__pushMock: push,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
|
||||
authenticatedRequest: mocks.authenticatedRequest,
|
||||
}));
|
||||
|
||||
vi.mock("vue-toast-notification", () => ({
|
||||
useToast: () => ({
|
||||
success: mocks.toastSuccess,
|
||||
error: mocks.toastError,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("buefy", () => ({
|
||||
BSwitch: defineComponent({
|
||||
name: "MockSwitch",
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:model-value"],
|
||||
setup(props, { emit }) {
|
||||
return () => h("button", {
|
||||
"data-testid": "self-serve-switch",
|
||||
"data-state": props.modelValue ? "on" : "off",
|
||||
"disabled": props.disabled,
|
||||
onClick: () => emit("update:model-value", !props.modelValue),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import DepartmentModulesDisplay from "@/components/displays/department/moduleNavigation/DepartmentModulesDisplay.vue";
|
||||
import { __routeRef } from "vue-router";
|
||||
|
||||
const flushMicrotasks = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
describe("DepartmentModulesDisplay self-serve management", () => {
|
||||
let setIntervalSpy;
|
||||
let consoleErrorSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.authenticatedRequest.mockReset();
|
||||
mocks.toastSuccess.mockReset();
|
||||
mocks.toastError.mockReset();
|
||||
__routeRef.value = { params: { departmentId: "12" } };
|
||||
|
||||
setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1);
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
mocks.authenticatedRequest.mockImplementation(async (url, method) => {
|
||||
if (String(url).startsWith("/admin/bookings/department/count?department_id=12")) {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
message: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (url === "/departments/self-serve/enabled?id=12" && method === "GET") {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (url === "/departments/self-serve/enabled?id=12&enabled=true" && method === "PUT") {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (url === "/departments/self-serve/enabled?id=12&enabled=false" && method === "PUT") {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected request: ${method} ${url}`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setIntervalSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("loads the current self-serve status on mount", async () => {
|
||||
const wrapper = mount(DepartmentModulesDisplay);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(mocks.authenticatedRequest).toHaveBeenCalledWith("/departments/self-serve/enabled?id=12", "GET");
|
||||
expect(wrapper.get("[data-testid='self-serve-switch']").attributes("data-state")).toBe("off");
|
||||
});
|
||||
|
||||
it("updates self-serve status and keeps UI in sync on success", async () => {
|
||||
const wrapper = mount(DepartmentModulesDisplay);
|
||||
await flushMicrotasks();
|
||||
|
||||
await wrapper.get("[data-testid='self-serve-switch']").trigger("click");
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(mocks.authenticatedRequest).toHaveBeenCalledWith(
|
||||
"/departments/self-serve/enabled?id=12&enabled=true",
|
||||
"PUT"
|
||||
);
|
||||
expect(wrapper.get("[data-testid='self-serve-switch']").attributes("data-state")).toBe("on");
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith("admin.department_modules.self_wash.enabled");
|
||||
});
|
||||
|
||||
it("reverts self-serve status and shows an error when update fails", async () => {
|
||||
mocks.authenticatedRequest.mockImplementation(async (url, method) => {
|
||||
if (String(url).startsWith("/admin/bookings/department/count?department_id=12")) {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
message: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (url === "/departments/self-serve/enabled?id=12" && method === "GET") {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (url === "/departments/self-serve/enabled?id=12&enabled=true" && method === "PUT") {
|
||||
throw new Error("save failed");
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected request: ${method} ${url}`);
|
||||
});
|
||||
|
||||
const wrapper = mount(DepartmentModulesDisplay);
|
||||
await flushMicrotasks();
|
||||
|
||||
await wrapper.get("[data-testid='self-serve-switch']").trigger("click");
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(wrapper.get("[data-testid='self-serve-switch']").attributes("data-state")).toBe("off");
|
||||
expect(mocks.toastError).toHaveBeenCalledWith("admin.department_modules.self_wash.update_error");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue"
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
describe("DepartmentModulesSetup self-serve management regression", () => {
|
||||
it("loads and updates department self-serve status through shared helpers", () => {
|
||||
expect(source).toContain("getDepartmentSelfServeEnabled");
|
||||
expect(source).toContain("setDepartmentSelfServeEnabled");
|
||||
expect(source).toContain("loadSelfServeEnabled()");
|
||||
expect(source).toContain("updateSelfServeEnabled");
|
||||
});
|
||||
|
||||
it("renders a dedicated self-serve module switch in setup UI", () => {
|
||||
expect(source).toContain("subtitle=\"Selvvask\"");
|
||||
expect(source).toContain("title=\"Selvvask\"");
|
||||
expect(source).toContain("Aktiver selvvask i denne afdeling.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authenticatedRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
|
||||
authenticatedRequest: mocks.authenticatedRequest,
|
||||
}));
|
||||
|
||||
import {
|
||||
getDepartmentSelfServeEnabled,
|
||||
setDepartmentSelfServeEnabled,
|
||||
} from "@/composables/departmentSelfServeEnabled.js";
|
||||
|
||||
describe("department self-serve enabled management", () => {
|
||||
beforeEach(() => {
|
||||
mocks.authenticatedRequest.mockReset();
|
||||
});
|
||||
|
||||
it("gets self-serve enabled status for a department", async () => {
|
||||
mocks.authenticatedRequest.mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
enabled: "true",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const enabled = await getDepartmentSelfServeEnabled("12");
|
||||
|
||||
expect(enabled).toBe(true);
|
||||
expect(mocks.authenticatedRequest).toHaveBeenCalledWith(
|
||||
"/departments/self-serve/enabled?id=12",
|
||||
"GET"
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes false-like enabled responses", async () => {
|
||||
mocks.authenticatedRequest.mockResolvedValue({
|
||||
data: {
|
||||
enabled: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const enabled = await getDepartmentSelfServeEnabled(12);
|
||||
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("updates self-serve enabled status for a department", async () => {
|
||||
mocks.authenticatedRequest.mockResolvedValue({
|
||||
data: {
|
||||
message: "updated",
|
||||
},
|
||||
});
|
||||
|
||||
const enabled = await setDepartmentSelfServeEnabled(22, true);
|
||||
|
||||
expect(enabled).toBe(true);
|
||||
expect(mocks.authenticatedRequest).toHaveBeenCalledWith(
|
||||
"/departments/self-serve/enabled?id=22&enabled=true",
|
||||
"PUT"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses response enabled state after update when backend returns it", async () => {
|
||||
mocks.authenticatedRequest.mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
enabled: "false",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const enabled = await setDepartmentSelfServeEnabled(22, true);
|
||||
|
||||
expect(enabled).toBe(false);
|
||||
expect(mocks.authenticatedRequest).toHaveBeenCalledWith(
|
||||
"/departments/self-serve/enabled?id=22&enabled=true",
|
||||
"PUT"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid department ids", async () => {
|
||||
await expect(getDepartmentSelfServeEnabled(0)).rejects.toThrow("Invalid department id");
|
||||
await expect(setDepartmentSelfServeEnabled("abc", true)).rejects.toThrow("Invalid department id");
|
||||
expect(mocks.authenticatedRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -218,6 +218,18 @@ describe("self-serve connectivity relay api wrappers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches in-progress wash details for a lane", async () => {
|
||||
await lane.wash.inProgress("13");
|
||||
|
||||
expect(mocks.request).toHaveBeenCalledWith(
|
||||
"/modules/self-serve/lane/wash/in-progress",
|
||||
"GET",
|
||||
{
|
||||
lane_id: 13,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid lane ids for relay and lane commands", async () => {
|
||||
await expect(relays.get.status.all(0)).rejects.toThrow("Invalid lane id");
|
||||
expect(() => lane.command.START(0)).toThrow("Invalid lane id");
|
||||
|
||||
@@ -33,6 +33,8 @@ const setEndpoints = {
|
||||
CLEANER: "/modules/self-serve/lane/relay/machine_cleaner/set",
|
||||
};
|
||||
|
||||
const inProgressEndpoint = "/modules/self-serve/lane/wash/in-progress";
|
||||
|
||||
const buildRelayStatus = (
|
||||
laneId,
|
||||
relay,
|
||||
@@ -49,6 +51,22 @@ const buildRelayStatus = (
|
||||
on,
|
||||
});
|
||||
|
||||
const buildInProgressDetails = (
|
||||
laneId,
|
||||
{
|
||||
inProgress = false,
|
||||
session = null,
|
||||
customer = null,
|
||||
vehicle = null,
|
||||
} = {}
|
||||
) => ({
|
||||
lane_id: laneId,
|
||||
in_progress: inProgress,
|
||||
session,
|
||||
customer,
|
||||
vehicle,
|
||||
});
|
||||
|
||||
const createDeferred = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
@@ -197,11 +215,61 @@ describe("self-serve machine connectivity store", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches in-progress wash details through shared connectivity helpers", async () => {
|
||||
const laneId = 19;
|
||||
|
||||
mocks.request.mockResolvedValue({
|
||||
data: {
|
||||
data: buildInProgressDetails(laneId, {
|
||||
inProgress: true,
|
||||
session: { id: 55, reg: "AB12345", customer_number: 9001 },
|
||||
customer: { id: 2, customer_number: 9001, display_name: "Jane Doe" },
|
||||
vehicle: { id: 3, reg: "AB12345" },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const connectivity = useMachineConnectivity(laneId);
|
||||
const details = await connectivity.fetchInProgressDetails();
|
||||
|
||||
expect(mocks.request).toHaveBeenCalledWith(
|
||||
inProgressEndpoint,
|
||||
"GET",
|
||||
{ lane_id: laneId }
|
||||
);
|
||||
expect(details).toMatchObject({
|
||||
lane_id: laneId,
|
||||
in_progress: true,
|
||||
customer: { display_name: "Jane Doe" },
|
||||
vehicle: { reg: "AB12345" },
|
||||
});
|
||||
expect(connectivity.inProgressDetails.value).toMatchObject({
|
||||
lane_id: laneId,
|
||||
in_progress: true,
|
||||
});
|
||||
expect(connectivity.inProgressLoading.value).toBe(false);
|
||||
});
|
||||
|
||||
it("sets lane error when in-progress details request fails", async () => {
|
||||
const laneId = 20;
|
||||
|
||||
mocks.request.mockRejectedValue(new Error("in-progress unavailable"));
|
||||
|
||||
const connectivity = useMachineConnectivity(laneId);
|
||||
await expect(connectivity.fetchInProgressDetails()).rejects.toThrow("in-progress unavailable");
|
||||
expect(connectivity.error.value).toContain("in-progress unavailable");
|
||||
expect(connectivity.inProgressLoading.value).toBe(false);
|
||||
});
|
||||
|
||||
it("starts and stops polling without leaking background status requests", async () => {
|
||||
vi.useFakeTimers();
|
||||
const laneId = 4;
|
||||
|
||||
mocks.request.mockImplementation(async (endpoint) => {
|
||||
if (endpoint === inProgressEndpoint) {
|
||||
return { data: { data: buildInProgressDetails(laneId) } };
|
||||
}
|
||||
|
||||
const relay = Object.entries(statusEndpoints).find(([, value]) => value === endpoint)?.[0];
|
||||
if (!relay) {
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
@@ -216,7 +284,7 @@ describe("self-serve machine connectivity store", () => {
|
||||
await vi.advanceTimersByTimeAsync(2200);
|
||||
|
||||
const statusCallCountBeforeStop = mocks.request.mock.calls.length;
|
||||
expect(statusCallCountBeforeStop).toBeGreaterThanOrEqual(9);
|
||||
expect(statusCallCountBeforeStop).toBeGreaterThanOrEqual(12);
|
||||
|
||||
connectivity.stopPolling();
|
||||
expect(connectivity.isPolling.value).toBe(false);
|
||||
@@ -283,6 +351,10 @@ describe("self-serve machine connectivity store", () => {
|
||||
const laneId = 31;
|
||||
|
||||
mocks.request.mockImplementation(async (endpoint) => {
|
||||
if (endpoint === inProgressEndpoint) {
|
||||
return { data: { data: buildInProgressDetails(laneId) } };
|
||||
}
|
||||
|
||||
const relay = Object.entries(statusEndpoints).find(([, value]) => value === endpoint)?.[0];
|
||||
if (!relay) {
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
|
||||
@@ -31,6 +31,8 @@ const buildConnectivity = () => ({
|
||||
openExitGate: false,
|
||||
}),
|
||||
error: ref(null),
|
||||
inProgressDetails: ref(null),
|
||||
inProgressLoading: ref(false),
|
||||
toggleRelay: mocks.toggleRelay,
|
||||
openGate: mocks.openGate,
|
||||
fetchAllRelayStatuses: mocks.fetchAllRelayStatuses,
|
||||
@@ -137,4 +139,65 @@ describe("SelfServeMachineRelayControls gate controls", () => {
|
||||
expect(mocks.openGate).toHaveBeenNthCalledWith(1, "ENTRANCE");
|
||||
expect(mocks.openGate).toHaveBeenNthCalledWith(2, "EXIT");
|
||||
});
|
||||
|
||||
it("shows no in-progress wash details when lane is idle", async () => {
|
||||
const wrapper = mount(SelfServeMachineRelayControls, {
|
||||
props: {
|
||||
machine: {
|
||||
id: 3,
|
||||
name: "Lane 3",
|
||||
status: "ONLINE",
|
||||
relay_in_id: "in",
|
||||
relay_out_id: "out",
|
||||
},
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
SelfServeMachineStatus: { template: "<div />" },
|
||||
BSwitch: { template: "<div />" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(wrapper.text()).toContain("No wash in progress.");
|
||||
});
|
||||
|
||||
it("renders customer and vehicle details when wash is in progress", async () => {
|
||||
mocks.useMachineConnectivity.mockImplementation(() => ({
|
||||
...buildConnectivity(),
|
||||
inProgressDetails: ref({
|
||||
lane_id: 4,
|
||||
in_progress: true,
|
||||
session: { reg: "AB12345", customer_number: 1001 },
|
||||
customer: { display_name: "John Doe", customer_number: 1001 },
|
||||
vehicle: { reg: "AB12345" },
|
||||
}),
|
||||
}));
|
||||
|
||||
const wrapper = mount(SelfServeMachineRelayControls, {
|
||||
props: {
|
||||
machine: {
|
||||
id: 4,
|
||||
name: "Lane 4",
|
||||
status: "ONLINE",
|
||||
relay_in_id: "in",
|
||||
relay_out_id: "out",
|
||||
},
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
SelfServeMachineStatus: { template: "<div />" },
|
||||
BSwitch: { template: "<div />" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(wrapper.text()).toContain("Customer:");
|
||||
expect(wrapper.text()).toContain("John Doe");
|
||||
expect(wrapper.text()).toContain("Vehicle:");
|
||||
expect(wrapper.text()).toContain("AB12345");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user