+
+
diff --git a/src/components/displays/selfServe/SelfServeVehicleStep.vue b/src/components/displays/selfServe/SelfServeVehicleStep.vue
index a80f58a4..8a3ec12e 100644
--- a/src/components/displays/selfServe/SelfServeVehicleStep.vue
+++ b/src/components/displays/selfServe/SelfServeVehicleStep.vue
@@ -2,6 +2,7 @@
import { computed, ref, watch } from "vue";
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
+import RedCarWarning from "@/components/displays/selfServe/RedCarWarning.vue";
const props = defineProps<{
customerNumber: string | number | null;
@@ -17,6 +18,7 @@ const props = defineProps<{
vehicleTypes: Array;
vehicleStepError?: string | null;
vehicleStepGuidance?: string | null;
+ isRedCar?: boolean;
}>();
const emit = defineEmits<{
@@ -141,6 +143,11 @@ const emitVehicleTypeSelection = (selection: any) => {
>
{{ props.vehicleStepError }}
+ String(key ?? "").trim();
+
+const toNormalizedKeySet = (keys) => {
+ const set = new Set();
+ if (!Array.isArray(keys)) {
+ return set;
+ }
+ for (const key of keys) {
+ const normalized = normalizeKey(key).toLowerCase();
+ if (normalized) {
+ set.add(normalized);
+ }
+ }
+ return set;
+};
+
+/**
+ * Extract the attribute key string from a single customer-attribute
+ * row. Accepts both the API shape (object with `attribute`) and the
+ * plain-string shorthand used in some call sites.
+ *
+ * @param {string|object|undefined|null} entry
+ * @returns {string}
+ */
+export const extractAttributeKey = (entry) => {
+ if (typeof entry === "string") {
+ return normalizeKey(entry);
+ }
+ if (entry && typeof entry === "object") {
+ const candidate = entry.attribute ?? entry.key ?? entry.name;
+ return normalizeKey(candidate);
+ }
+ return "";
+};
+
+/**
+ * Extract the attribute key strings from a list of customer
+ * attributes. Returns an empty array for invalid input.
+ *
+ * @param {Array|null|undefined} attributes
+ * @returns {string[]}
+ */
+export const extractAttributeKeys = (attributes) => {
+ if (!Array.isArray(attributes)) {
+ return [];
+ }
+ const keys = [];
+ for (const entry of attributes) {
+ const key = extractAttributeKey(entry);
+ if (key) {
+ keys.push(key);
+ }
+ }
+ return keys;
+};
+
+/**
+ * Determine whether a customer should be flagged as a "red car"
+ * based on their attribute list. The check is case-insensitive.
+ *
+ * @param {Array|null|undefined} attributes
+ * @param {object} [options]
+ * @param {string[]} [options.attributeKeys] - attribute keys that
+ * mark a red car. Defaults to DEFAULT_RED_CAR_ATTRIBUTE_KEYS.
+ * @returns {boolean}
+ */
+export const isRedCarCustomer = (attributes, options = {}) => {
+ const keys = toNormalizedKeySet(
+ options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS
+ );
+ if (keys.size === 0) {
+ return false;
+ }
+ const attributeKeys = extractAttributeKeys(attributes);
+ return attributeKeys.some((key) => keys.has(key.toLowerCase()));
+};
+
+/**
+ * Return the configured red-car attribute keys (normalized,
+ * deduplicated, lowercased). Useful for displaying the active
+ * heuristic to operators.
+ *
+ * @param {object} [options]
+ * @param {string[]} [options.attributeKeys]
+ * @returns {string[]}
+ */
+export const getRedCarAttributeKeys = (options = {}) => {
+ const keys = toNormalizedKeySet(
+ options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS
+ );
+ return Array.from(keys);
+};
diff --git a/src/composables/useRedCarWarning.js b/src/composables/useRedCarWarning.js
new file mode 100644
index 00000000..365ace80
--- /dev/null
+++ b/src/composables/useRedCarWarning.js
@@ -0,0 +1,104 @@
+import { computed, ref, unref, watch } from "vue";
+import {
+ extractCustomerAttributesData,
+ listCustomerAttributes,
+} from "@/features/customer/customerAttributeService.js";
+import {
+ DEFAULT_RED_CAR_ATTRIBUTE_KEYS,
+ isRedCarCustomer,
+} from "./redCarDetector.js";
+
+const isPositiveInteger = (value) => {
+ const parsed = Number.parseInt(String(value ?? ""), 10);
+ return Number.isInteger(parsed) && parsed > 0;
+};
+
+/**
+ * Composable that watches a customer number and exposes a reactive
+ * "is this customer flagged as a red car" state for the customer
+ * portal wash flow.
+ *
+ * SENERE 7 / TRU-99 — the canonical rule is still TBD by Mads; this
+ * defaults to a manual customer-attribute flag and lets callers
+ * override the attribute keys so the rule can be tightened later.
+ *
+ * @param {import("vue").Ref|number|string|null|undefined} customerNumberSource
+ * @param {object} [options]
+ * @param {string[]} [options.attributeKeys] - attribute keys that
+ * mark a red car. Defaults to DEFAULT_RED_CAR_ATTRIBUTE_KEYS.
+ * @returns {{
+ * attributes: import("vue").Ref,
+ * isLoading: import("vue").Ref,
+ * error: import("vue").Ref,
+ * isRedCar: import("vue").ComputedRef,
+ * reload: () => Promise,
+ * reset: () => void,
+ * }}
+ */
+export const useRedCarWarning = (customerNumberSource, options = {}) => {
+ const attributes = ref([]);
+ const isLoading = ref(false);
+ const error = ref(null);
+ const loadedCustomerNumber = ref(null);
+
+ const attributeKeys = options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS;
+
+ const isRedCar = computed(() => isRedCarCustomer(attributes.value, { attributeKeys }));
+
+ const reset = () => {
+ attributes.value = [];
+ isLoading.value = false;
+ error.value = null;
+ loadedCustomerNumber.value = null;
+ };
+
+ const reload = async () => {
+ const customerNumber = unref(customerNumberSource);
+ if (!isPositiveInteger(customerNumber)) {
+ reset();
+ return;
+ }
+ if (loadedCustomerNumber.value === customerNumber && attributes.value.length > 0) {
+ return;
+ }
+
+ isLoading.value = true;
+ error.value = null;
+ try {
+ const response = await listCustomerAttributes({ customerNumber });
+ const nextAttributes = extractCustomerAttributesData(response);
+ attributes.value = nextAttributes;
+ loadedCustomerNumber.value = customerNumber;
+ } catch (err) {
+ console.warn("Unable to load red-car customer attributes", err);
+ attributes.value = [];
+ loadedCustomerNumber.value = null;
+ error.value = err?.message || "load_failed";
+ } finally {
+ isLoading.value = false;
+ }
+ };
+
+ if (typeof customerNumberSource === "object" && customerNumberSource !== null && "value" in customerNumberSource) {
+ watch(
+ () => unref(customerNumberSource),
+ (next) => {
+ if (isPositiveInteger(next)) {
+ reload();
+ } else {
+ reset();
+ }
+ },
+ { immediate: true }
+ );
+ }
+
+ return {
+ attributes,
+ isLoading,
+ error,
+ isRedCar,
+ reload,
+ reset,
+ };
+};
diff --git a/src/i18n/generated/da-v2.json b/src/i18n/generated/da-v2.json
index a6a973ce..a0977c39 100644
--- a/src/i18n/generated/da-v2.json
+++ b/src/i18n/generated/da-v2.json
@@ -5915,6 +5915,9 @@
"open_property_exit_gate": "@:{'words.generated.abn'} udgangsport",
"perform_wash": "@:{'words.generated.udfør'} @:{'words.generated.vask'}",
"questions_answered": "@.capitalize:{'words.generated.spørgsmal'} @:{'words.generated.besvaret'}",
+ "red_car_warning_title": "Rød bil på pladsen",
+ "red_car_warning_message": "Denne kunde er markeret som rød bil. Rød lak er mere følsom over for pletter og hvirvler — behandl med ekstra forsigtighed.",
+ "red_car_warning_suggestion": "Anbefal en skånsom vask: undgå højtryk og hårde børster, og tør med en blød mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.vælg'} {plate} @:{'words.generated.fra'} @:{'words.generated.dine'} @:{'words.generated.køretøjer'}",
"select_vehicle_type": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.type'} @:{'words.generated.af'} @:{'words.generated.køretøj'}",
"select_vehicle": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.køretøj'}",
diff --git a/src/i18n/generated/de-v2.json b/src/i18n/generated/de-v2.json
index e706a84a..38b3b872 100644
--- a/src/i18n/generated/de-v2.json
+++ b/src/i18n/generated/de-v2.json
@@ -6025,6 +6025,9 @@
"open_property_exit_gate": "Grundstuecksausgangstor @:{'words.generated.oeffnen'}",
"perform_wash": "@.upper:{'words.generated.w'}?@:{'words.generated.sche'} durchf?@:{'words.generated.hren'}",
"questions_answered": "@:{'words.generated.fragen'} @:{'words.generated.beantwortet'}",
+ "red_car_warning_title": "Rotes Auto vor Ort",
+ "red_car_warning_message": "Dieser Kunde ist als rotes Auto markiert. Roter Lack ist anfälliger für Flecken und Swirls — bitte mit besonderer Vorsicht behandeln.",
+ "red_car_warning_suggestion": "Empfehlen Sie eine sanfte Wäsche: keinen Hochdruck, keine harten Bürsten, mit weicher Mikrofaser trocknen.",
"select_from_vehicles": "@.upper:{'words.generated.w'}?@:{'words.generated.hlen'} @.capitalize:{'words.generated.sie'} {plate} @:{'words.generated.aus'} @:{'words.generated.ihren'} Fahrzeugen",
"select_vehicle_type": "@:{'words.generated.fahrzeugtyp'} @:{'words.generated.ausw'}?@:{'words.generated.hlen'}",
"select_vehicle": "@:{'words.generated.fahrzeug'} @:{'words.generated.ausw'}?@:{'words.generated.hlen'}",
diff --git a/src/i18n/generated/en-v2.json b/src/i18n/generated/en-v2.json
index 7dc0951a..857c1dce 100644
--- a/src/i18n/generated/en-v2.json
+++ b/src/i18n/generated/en-v2.json
@@ -5746,6 +5746,9 @@
"open_property_exit_gate": "@.capitalize:{'words.generated.open'} @:{'words.generated.property'} @:{'words.generated.exit'} @:{'words.generated.gate'}",
"perform_wash": "@.capitalize:{'words.generated.perform'} @:{'words.generated.wash'}",
"questions_answered": "@.capitalize:{'words.generated.questions'} @:{'words.generated.answered'}",
+ "red_car_warning_title": "Red car on site",
+ "red_car_warning_message": "This customer is flagged as a red car. Red paint is more prone to staining and swirls — handle with extra care.",
+ "red_car_warning_suggestion": "Suggest a gentler wash: skip high-pressure pre-wash, avoid harsh brushes, and dry with a soft microfiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.select'} {plate} @:{'words.generated.from'} @:{'words.generated.your'} @:{'words.generated.vehicles'}",
"select_vehicle_type": "@.capitalize:{'words.generated.select'} @:{'words.generated.vehicle'} @:{'words.generated.type'}",
"select_vehicle": "@.capitalize:{'words.generated.select'} @:{'words.generated.vehicle'}",
diff --git a/src/i18n/generated/no-v2.json b/src/i18n/generated/no-v2.json
index 3ac9cf67..d35245ff 100644
--- a/src/i18n/generated/no-v2.json
+++ b/src/i18n/generated/no-v2.json
@@ -6028,6 +6028,9 @@
"open_property_exit_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} utgangsport",
"perform_wash": "Utfør @:{'words.generated.vask'}",
"questions_answered": "@.capitalize:{'words.generated.spørsmal'} @:{'words.generated.besvart'}",
+ "red_car_warning_title": "Rød bil på plassen",
+ "red_car_warning_message": "Denne kunden er markert som rød bil. Rød lakk er mer utsatt for flekker og virvler — håndter med ekstra forsiktighet.",
+ "red_car_warning_suggestion": "Anbefal en skånsom vask: unngå høytrykk og harde børster, og tørk med en myk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.velg'} {plate} @:{'words.generated.fra'} @:{'words.generated.kjøretøyene'} @:{'words.generated.dine'}",
"select_vehicle_type": "@.capitalize:{'words.generated.velg'} @:{'words.generated.kjøretøytype'}",
"select_vehicle": "@.capitalize:{'words.generated.velg'} @:{'words.generated.kjøretøy'}",
diff --git a/src/i18n/generated/sv-v2.json b/src/i18n/generated/sv-v2.json
index c78c20e0..8fd8ae72 100644
--- a/src/i18n/generated/sv-v2.json
+++ b/src/i18n/generated/sv-v2.json
@@ -6078,6 +6078,9 @@
"open_property_exit_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} utgangsgrind",
"perform_wash": "Perform @:{'words.generated.wash'}",
"questions_answered": "@.capitalize:{'words.generated.fragor'} @:{'words.generated.besvarade'}",
+ "red_car_warning_title": "Röd bil på plats",
+ "red_car_warning_message": "Denna kund är flaggad som röd bil. Röd lack är känsligare för fläckar och virvlar — hantera med extra försiktighet.",
+ "red_car_warning_suggestion": "Rekommendera en skonsam tvätt: undvik högtryck och hårda borstar, och torka med en mjuk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.valj'} {plate} @:{'words.generated.fran'} @:{'words.generated.dina'} @:{'words.generated.fordon'}",
"select_vehicle_type": "@.capitalize:{'words.generated.valj'} @:{'words.generated.fordonstyp'}",
"select_vehicle": "@.capitalize:{'words.generated.valj'} @:{'words.generated.fordon'}",
diff --git a/src/i18n/source/da/phrases/compat/self_wash/index.json b/src/i18n/source/da/phrases/compat/self_wash/index.json
index 0f354c91..5db19fd2 100644
--- a/src/i18n/source/da/phrases/compat/self_wash/index.json
+++ b/src/i18n/source/da/phrases/compat/self_wash/index.json
@@ -24,6 +24,9 @@
"open_property_exit_gate": "@:{'terms.glossary.abn'} udgangsport",
"perform_wash": "@:{'terms.glossary.udfør'} @:{'terms.glossary.vask'}",
"questions_answered": "@.capitalize:{'terms.glossary.spørgsmal'} @:{'terms.glossary.besvaret'}",
+ "red_car_warning_title": "Rød bil på pladsen",
+ "red_car_warning_message": "Denne kunde er markeret som rød bil. Rød lak er mere følsom over for pletter og hvirvler — behandl med ekstra forsigtighed.",
+ "red_car_warning_suggestion": "Anbefal en skånsom vask: undgå højtryk og hårde børster, og tør med en blød mikrofiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.vælg'} {plate} @:{'terms.glossary.fra'} @:{'terms.glossary.dine'} @:{'terms.glossary.køretøjer'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.type'} @:{'terms.glossary.af'} @:{'terms.glossary.køretøj'}",
"select_vehicle": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.køretøj'}",
diff --git a/src/i18n/source/de/phrases/compat/self_wash/index.json b/src/i18n/source/de/phrases/compat/self_wash/index.json
index 3d7e6a7d..55cedec2 100644
--- a/src/i18n/source/de/phrases/compat/self_wash/index.json
+++ b/src/i18n/source/de/phrases/compat/self_wash/index.json
@@ -24,6 +24,9 @@
"open_property_exit_gate": "Grundstuecksausgangstor @:{'terms.glossary.oeffnen'}",
"perform_wash": "@.upper:{'terms.glossary.w'}?@:{'terms.glossary.sche'} durchf?@:{'terms.glossary.hren'}",
"questions_answered": "@:{'terms.glossary.fragen'} @:{'terms.glossary.beantwortet'}",
+ "red_car_warning_title": "Rotes Auto vor Ort",
+ "red_car_warning_message": "Dieser Kunde ist als rotes Auto markiert. Roter Lack ist anfälliger für Flecken und Swirls — bitte mit besonderer Vorsicht behandeln.",
+ "red_car_warning_suggestion": "Empfehlen Sie eine sanfte Wäsche: keinen Hochdruck, keine harten Bürsten, mit weicher Mikrofaser trocknen.",
"select_from_vehicles": "@.upper:{'terms.glossary.w'}?@:{'terms.glossary.hlen'} @.capitalize:{'terms.glossary.sie'} {plate} @:{'terms.glossary.aus'} @:{'terms.glossary.ihren'} Fahrzeugen",
"select_vehicle_type": "@:{'terms.glossary.fahrzeugtyp'} @:{'terms.glossary.ausw'}?@:{'terms.glossary.hlen'}",
"select_vehicle": "@:{'terms.glossary.fahrzeug'} @:{'terms.glossary.ausw'}?@:{'terms.glossary.hlen'}",
diff --git a/src/i18n/source/en/phrases/compat/self_wash/index.json b/src/i18n/source/en/phrases/compat/self_wash/index.json
index 0cff80e7..5c92b43a 100644
--- a/src/i18n/source/en/phrases/compat/self_wash/index.json
+++ b/src/i18n/source/en/phrases/compat/self_wash/index.json
@@ -24,6 +24,9 @@
"open_property_exit_gate": "@.capitalize:{'terms.glossary.open'} @:{'terms.glossary.property'} @:{'terms.glossary.exit'} @:{'terms.glossary.gate'}",
"perform_wash": "@.capitalize:{'terms.glossary.perform'} @:{'terms.glossary.wash'}",
"questions_answered": "@.capitalize:{'terms.glossary.questions'} @:{'terms.glossary.answered'}",
+ "red_car_warning_title": "Red car on site",
+ "red_car_warning_message": "This customer is flagged as a red car. Red paint is more prone to staining and swirls — handle with extra care.",
+ "red_car_warning_suggestion": "Suggest a gentler wash: skip high-pressure pre-wash, avoid harsh brushes, and dry with a soft microfiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.select'} {plate} @:{'terms.glossary.from'} @:{'terms.glossary.your'} @:{'terms.glossary.vehicles'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.type'}",
"select_vehicle": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.vehicle'}",
diff --git a/src/i18n/source/no/phrases/compat/self_wash/index.json b/src/i18n/source/no/phrases/compat/self_wash/index.json
index 60f4ac3b..f8ee0263 100644
--- a/src/i18n/source/no/phrases/compat/self_wash/index.json
+++ b/src/i18n/source/no/phrases/compat/self_wash/index.json
@@ -24,6 +24,9 @@
"open_property_exit_gate": "@:{'terms.glossary.aapne'} @:{'terms.glossary.eiendommens'} utgangsport",
"perform_wash": "Utfør @:{'terms.glossary.vask'}",
"questions_answered": "@.capitalize:{'terms.glossary.spørsmal'} @:{'terms.glossary.besvart'}",
+ "red_car_warning_title": "Rød bil på plassen",
+ "red_car_warning_message": "Denne kunden er markert som rød bil. Rød lakk er mer utsatt for flekker og virvler — håndter med ekstra forsiktighet.",
+ "red_car_warning_suggestion": "Anbefal en skånsom vask: unngå høytrykk og harde børster, og tørk med en myk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.velg'} {plate} @:{'terms.glossary.fra'} @:{'terms.glossary.kjøretøyene'} @:{'terms.glossary.dine'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.kjøretøytype'}",
"select_vehicle": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.kjøretøy'}",
diff --git a/src/i18n/source/sv/phrases/compat/self_wash/index.json b/src/i18n/source/sv/phrases/compat/self_wash/index.json
index d9e42012..939135ca 100644
--- a/src/i18n/source/sv/phrases/compat/self_wash/index.json
+++ b/src/i18n/source/sv/phrases/compat/self_wash/index.json
@@ -24,6 +24,9 @@
"open_property_exit_gate": "@:{'terms.glossary.oeppna'} @:{'terms.glossary.fastighetens'} utgangsgrind",
"perform_wash": "Perform @:{'terms.glossary.wash'}",
"questions_answered": "@.capitalize:{'terms.glossary.fragor'} @:{'terms.glossary.besvarade'}",
+ "red_car_warning_title": "Röd bil på plats",
+ "red_car_warning_message": "Denna kund är flaggad som röd bil. Röd lack är känsligare för fläckar och virvlar — hantera med extra försiktighet.",
+ "red_car_warning_suggestion": "Rekommendera en skonsam tvätt: undvik högtryck och hårda borstar, och torka med en mjuk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.valj'} {plate} @:{'terms.glossary.fran'} @:{'terms.glossary.dina'} @:{'terms.glossary.fordon'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.fordonstyp'}",
"select_vehicle": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.fordon'}",
diff --git a/src/views/dashboards/userDashboard/wash/MyWashStart.vue b/src/views/dashboards/userDashboard/wash/MyWashStart.vue
index cb6cc1b3..1271615a 100644
--- a/src/views/dashboards/userDashboard/wash/MyWashStart.vue
+++ b/src/views/dashboards/userDashboard/wash/MyWashStart.vue
@@ -22,6 +22,7 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
import { useWashProgress } from "@/composables/useWashProgress";
import { useWashFlowState } from "@/composables/useWashFlowState";
import { useWashSessionActions } from "@/composables/useWashSessionActions";
+import { useRedCarWarning } from "@/composables/useRedCarWarning.js";
import { getSelfServeTaskDynamicImageButtons } from "@/services/selfServeDynamicImage.js";
import type { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
@@ -860,6 +861,17 @@ const applyResolvedVehicleTypeSelection = () => {
const showCustomerNumberInput = computed(() => !getAuthenticatedCustomerNumber() && !customerNumberInput.value);
+const effectiveCustomerNumberForRedCar = computed(() => {
+ const authenticated = getAuthenticatedCustomerNumber();
+ if (authenticated) {
+ return authenticated;
+ }
+ const typed = resolveEffectiveCustomerNumber(customerNumberInput.value);
+ return typed ?? null;
+});
+
+const { isRedCar: isRedCarCustomerFlag } = useRedCarWarning(effectiveCustomerNumberForRedCar);
+
const hasLocationCoordinates = computed(() => locations.hasValidCoordinatePair(locations.location.value?.coords));
const canUseDepartmentHeaderSelection = computed(
@@ -2383,6 +2395,7 @@ watch(
:vehicle-types="vehicleTypes"
:vehicle-step-error="vehicleStepError"
:vehicle-step-guidance="vehicleStepGuidanceKey ? $t(vehicleStepGuidanceKey) : null"
+ :is-red-car="isRedCarCustomerFlag"
@update:customer-number="onUpdateCustomerNumber"
@update:registration-number="onUpdateRegistrationNumber"
@select-vehicle-type="onSelectVehicleType"
diff --git a/src/views/dashboards/userDashboard/wash/components/VehicleInputSection.vue b/src/views/dashboards/userDashboard/wash/components/VehicleInputSection.vue
index 8b2f1eaf..b1637417 100644
--- a/src/views/dashboards/userDashboard/wash/components/VehicleInputSection.vue
+++ b/src/views/dashboards/userDashboard/wash/components/VehicleInputSection.vue
@@ -20,6 +20,7 @@ defineProps<{
vehicleTypes: any[];
vehicleStepError?: string | null;
vehicleStepGuidance?: string | null;
+ isRedCar?: boolean;
}>();
const emit = defineEmits<{
@@ -56,6 +57,7 @@ const emitVehicleTypeSelection = (selection: VehicleTypeSelection) => {
:vehicle-types="vehicleTypes"
:vehicle-step-error="vehicleStepError"
:vehicle-step-guidance="vehicleStepGuidance"
+ :is-red-car="isRedCar"
@update:customer-number="emitCustomerNumber"
@update:customerNumber="emitCustomerNumber"
@update:registration-number="emitRegistrationNumber"
diff --git a/tests/unit/red-car-detector.spec.js b/tests/unit/red-car-detector.spec.js
new file mode 100644
index 00000000..06e435e5
--- /dev/null
+++ b/tests/unit/red-car-detector.spec.js
@@ -0,0 +1,150 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ DEFAULT_RED_CAR_ATTRIBUTE_KEYS,
+ extractAttributeKey,
+ extractAttributeKeys,
+ getRedCarAttributeKeys,
+ isRedCarCustomer,
+} from "@/composables/redCarDetector.js";
+
+describe("redCarDetector.extractAttributeKey", () => {
+ it("returns trimmed string for plain string entries", () => {
+ expect(extractAttributeKey("isRedCar")).toBe("isRedCar");
+ expect(extractAttributeKey(" invoiceAllOrdersIndividually ")).toBe("invoiceAllOrdersIndividually");
+ });
+
+ it("extracts the attribute field from object rows", () => {
+ expect(extractAttributeKey({ attribute: "isRedCar" })).toBe("isRedCar");
+ expect(extractAttributeKey({ attribute: "isRedCar", customer_number: 42 })).toBe("isRedCar");
+ });
+
+ it("falls back to key/name aliases when attribute is missing", () => {
+ expect(extractAttributeKey({ key: "redCar" })).toBe("redCar");
+ expect(extractAttributeKey({ name: "red_car" })).toBe("red_car");
+ });
+
+ it("returns empty string for invalid input", () => {
+ expect(extractAttributeKey(null)).toBe("");
+ expect(extractAttributeKey(undefined)).toBe("");
+ expect(extractAttributeKey(123)).toBe("");
+ expect(extractAttributeKey({})).toBe("");
+ expect(extractAttributeKey({ attribute: "" })).toBe("");
+ });
+});
+
+describe("redCarDetector.extractAttributeKeys", () => {
+ it("returns the attribute key for each row", () => {
+ expect(extractAttributeKeys(["isRedCar", { attribute: "redCar" }, { key: "is_red_car" }])).toEqual([
+ "isRedCar",
+ "redCar",
+ "is_red_car",
+ ]);
+ });
+
+ it("filters out invalid rows and empty keys", () => {
+ expect(
+ extractAttributeKeys([null, undefined, "isRedCar", "", { attribute: "" }, { attribute: " redCar " }])
+ ).toEqual(["isRedCar", "redCar"]);
+ });
+
+ it("returns an empty array for non-array input", () => {
+ expect(extractAttributeKeys(null)).toEqual([]);
+ expect(extractAttributeKeys(undefined)).toEqual([]);
+ expect(extractAttributeKeys("isRedCar")).toEqual([]);
+ });
+});
+
+describe("redCarDetector.isRedCarCustomer (positive cases)", () => {
+ it("flags a customer with the default isRedCar attribute", () => {
+ expect(isRedCarCustomer([{ attribute: "isRedCar" }])).toBe(true);
+ });
+
+ it("flags any of the default attribute keys", () => {
+ for (const key of DEFAULT_RED_CAR_ATTRIBUTE_KEYS) {
+ expect(isRedCarCustomer([{ attribute: key }])).toBe(true);
+ }
+ });
+
+ it("flags when the attribute is a plain string", () => {
+ expect(isRedCarCustomer(["isRedCar"])).toBe(true);
+ expect(isRedCarCustomer(["red_car"])).toBe(true);
+ });
+
+ it("matches case-insensitively", () => {
+ expect(isRedCarCustomer([{ attribute: "ISREDCAR" }])).toBe(true);
+ expect(isRedCarCustomer([{ attribute: "isredcar" }])).toBe(true);
+ });
+
+ it("flags a customer with a custom configured key", () => {
+ expect(
+ isRedCarCustomer([{ attribute: "needsGentleWash" }], {
+ attributeKeys: ["needsGentleWash"],
+ })
+ ).toBe(true);
+ });
+
+ it("flags when at least one matching attribute is present among others", () => {
+ expect(
+ isRedCarCustomer([
+ { attribute: "invoiceAllOrdersIndividually" },
+ { attribute: "isRedCar" },
+ { attribute: "onlyTankCleaning" },
+ ])
+ ).toBe(true);
+ });
+});
+
+describe("redCarDetector.isRedCarCustomer (negative cases)", () => {
+ it("returns false for an empty attribute list", () => {
+ expect(isRedCarCustomer([])).toBe(false);
+ });
+
+ it("returns false when no attribute matches the default keys", () => {
+ expect(isRedCarCustomer([{ attribute: "onlyTankCleaning" }, { attribute: "invoiceAllOrdersIndividually" }])).toBe(
+ false
+ );
+ });
+
+ it("returns false for null/undefined input", () => {
+ expect(isRedCarCustomer(null)).toBe(false);
+ expect(isRedCarCustomer(undefined)).toBe(false);
+ });
+
+ it("returns false when configured keys are all empty strings", () => {
+ expect(isRedCarCustomer([{ attribute: "isRedCar" }], { attributeKeys: ["", " "] })).toBe(false);
+ });
+
+ it("returns false for an unrelated custom key when defaults are used", () => {
+ expect(isRedCarCustomer([{ attribute: "needsGentleWash" }])).toBe(false);
+ });
+
+ it("returns false for a custom key when a different custom key is configured", () => {
+ expect(
+ isRedCarCustomer([{ attribute: "needsGentleWash" }], {
+ attributeKeys: ["isRedCar"],
+ })
+ ).toBe(false);
+ });
+});
+
+describe("redCarDetector.getRedCarAttributeKeys", () => {
+ it("returns the default keys when no override is given", () => {
+ const keys = getRedCarAttributeKeys();
+ expect(keys).toContain("isredcar");
+ expect(keys).toContain("is_red_car");
+ expect(keys).toContain("redcar");
+ expect(keys).toContain("red_car");
+ });
+
+ it("normalizes and deduplicates custom keys", () => {
+ const keys = getRedCarAttributeKeys({
+ attributeKeys: [" isRedCar ", "ISREDCAR", "", "redCar"],
+ });
+ expect(keys).toEqual(["isredcar", "redcar"]);
+ });
+
+ it("returns an empty array when no valid keys are configured", () => {
+ expect(getRedCarAttributeKeys({ attributeKeys: ["", " "] })).toEqual([]);
+ });
+});
diff --git a/tests/unit/red-car-warning-i18n.spec.js b/tests/unit/red-car-warning-i18n.spec.js
new file mode 100644
index 00000000..5b11f6ed
--- /dev/null
+++ b/tests/unit/red-car-warning-i18n.spec.js
@@ -0,0 +1,42 @@
+import { readFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+const SOURCE_ROOT = resolve(new URL("../..", import.meta.url).pathname, "src/i18n/source");
+
+const LOCALES = ["da", "en", "sv", "de", "no"];
+const REQUIRED_KEYS = ["red_car_warning_title", "red_car_warning_message", "red_car_warning_suggestion"];
+
+const readSelfWashSource = (locale) => {
+ const filePath = join(SOURCE_ROOT, locale, "phrases/compat/self_wash/index.json");
+ return JSON.parse(readFileSync(filePath, "utf8"));
+};
+
+const getNestedValue = (root, path) => {
+ let current = root;
+ for (const segment of path) {
+ if (current == null || typeof current !== "object") {
+ return undefined;
+ }
+ current = current[segment];
+ }
+ return current;
+};
+
+describe("red-car warning i18n keys", () => {
+ for (const locale of LOCALES) {
+ it(`exposes the required keys in locale '${locale}'`, () => {
+ const messages = readSelfWashSource(locale);
+ const selfWash = messages?.compat?.self_wash ?? {};
+
+ for (const key of REQUIRED_KEYS) {
+ const value = selfWash[key];
+ expect(value, `missing key self_wash.${key} in ${locale}`).toBeDefined();
+ expect(typeof value === "string" && value.trim() !== "", `empty value for self_wash.${key} in ${locale}`).toBe(
+ true
+ );
+ }
+ });
+ }
+});
diff --git a/tests/unit/red-car-warning.spec.js b/tests/unit/red-car-warning.spec.js
new file mode 100644
index 00000000..30c0a1a4
--- /dev/null
+++ b/tests/unit/red-car-warning.spec.js
@@ -0,0 +1,74 @@
+// @vitest-environment jsdom
+import { mount } from "@vue/test-utils";
+import { describe, expect, it } from "vitest";
+
+import RedCarWarning from "@/components/displays/selfServe/RedCarWarning.vue";
+import { createTestI18n } from "./helpers/mountWithApp.js";
+
+const BMessageStub = {
+ name: "BMessage",
+ props: {
+ type: { type: String, default: "" },
+ title: { type: String, default: "" },
+ },
+ emits: ["close"],
+ template: `
+
+
+
+ `,
+};
+
+const factory = (props = {}, messages = {}) => {
+ const i18n = createTestI18n({
+ en: {
+ self_wash: {
+ red_car_warning_title: "Red car on site",
+ red_car_warning_message: "Handle with extra care.",
+ red_car_warning_suggestion: "Use a gentler wash.",
+ ...messages.en?.self_wash,
+ },
+ },
+ });
+
+ return mount(RedCarWarning, {
+ props,
+ global: {
+ plugins: [i18n],
+ stubs: {
+ BMessage: BMessageStub,
+ },
+ },
+ });
+};
+
+describe("RedCarWarning", () => {
+ it("renders the warning when isRedCar is true", () => {
+ const wrapper = factory({ isRedCar: true });
+ expect(wrapper.find('[data-testid="red-car-warning"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="red-car-warning-message"]').text()).toBe("Handle with extra care.");
+ expect(wrapper.find('[data-testid="red-car-warning-suggestion"]').text()).toBe("Use a gentler wash.");
+ });
+
+ it("does not render the warning when isRedCar is false", () => {
+ const wrapper = factory({ isRedCar: false });
+ expect(wrapper.find('[data-testid="red-car-warning"]').exists()).toBe(false);
+ });
+
+ it("does not render by default (no prop passed)", () => {
+ const wrapper = factory();
+ expect(wrapper.find('[data-testid="red-car-warning"]').exists()).toBe(false);
+ });
+
+ it("forwards the close event from the b-message as a dismiss emit", () => {
+ const wrapper = factory({ isRedCar: true });
+ const bMessage = wrapper.findComponent(BMessageStub);
+ bMessage.vm.$emit("close");
+ expect(wrapper.emitted()).toHaveProperty("dismiss");
+ });
+});
diff --git a/tests/unit/xlvask-usage-department-filter.spec.js b/tests/unit/xlvask-usage-department-filter.spec.js
index cbfd0ca5..23825850 100644
--- a/tests/unit/xlvask-usage-department-filter.spec.js
+++ b/tests/unit/xlvask-usage-department-filter.spec.js
@@ -15,8 +15,8 @@ describe("xlvask usage pagination department selector propagation", () => {
it("applies the HallId filter when the departmentId prop is provided", () => {
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
- expect(source).toMatch(/effectiveDepartmentId\s*>\s*0/);
- expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*effectiveDepartmentId\s*,\s*false\s*\)/);
+ expect(source).toMatch(/effectiveDepartmentId\.value\s*>\s*0/);
+ expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*effectiveDepartmentId\.value\s*,\s*false\s*\)/);
});
it("falls back to the departmentId route param when the prop is not provided", () => {
@@ -30,9 +30,9 @@ describe("xlvask usage pagination department selector propagation", () => {
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
expect(source).toContain(
- "const effectiveDepartmentId =\n props.departmentId > 0\n ? props.departmentId\n : Number.isInteger(routeDepartmentId) && routeDepartmentId > 0\n ? routeDepartmentId\n : 0;"
+ "const effectiveDepartmentId = computed(() =>\n props.departmentId > 0\n ? props.departmentId\n : Number.isInteger(routeDepartmentId.value) && routeDepartmentId.value > 0\n ? routeDepartmentId.value\n : 0\n);"
);
- expect(source).toMatch(/if\s*\(effectiveDepartmentId\s*>\s*0\)\s*\{\s*setFilter\(\s*["']HallId["']/);
+ expect(source).toMatch(/if\s*\(effectiveDepartmentId\.value\s*>\s*0\)\s*\{\s*setFilter\(\s*["']HallId["']/);
});
it("DepartmentPosSync forwards the URL departmentId to XLVaskUsagePagination", () => {
diff --git a/tests/unit/xlvask-usage-pagination-department-propagation.spec.js b/tests/unit/xlvask-usage-pagination-department-propagation.spec.js
new file mode 100644
index 00000000..20458e1f
--- /dev/null
+++ b/tests/unit/xlvask-usage-pagination-department-propagation.spec.js
@@ -0,0 +1,51 @@
+// @vitest-environment node
+
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+
+const readSource = (relativePath) => readFileSync(join(process.cwd(), relativePath), "utf8");
+
+describe("XLVaskUsagePagination department (HallId) propagation", () => {
+ const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
+
+ it("reacts to department changes via a computed effectiveDepartmentId", () => {
+ // The department must be derived reactively from the prop and the
+ // route param, not captured once at setup time.
+ expect(source).toMatch(
+ /const\s+effectiveDepartmentId\s*=\s*computed\(\s*\(\)\s*=>\s*[\s\S]*?props\.departmentId[\s\S]*?routeDepartmentId\.value[\s\S]*?\)\s*\)/
+ );
+ });
+
+ it("watches the effective department and re-applies the HallId filter", () => {
+ // The component must watch the computed effective department so
+ // changing the department selector re-issues the query with the
+ // new department in the filter.
+ expect(source).toMatch(/watch\(\s*effectiveDepartmentId\s*,/);
+ expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*nextDepartmentId\s*,\s*false\s*\)/);
+ });
+
+ it("re-issues the usage query when the department changes", () => {
+ // The watch handler must trigger both loadList and loadSummary so
+ // the visible orders and the summary cards both reflect the new
+ // department.
+ const watchBlockMatch = source.match(/watch\(\s*effectiveDepartmentId\s*,[\s\S]*?\n\)\s*;/);
+ expect(watchBlockMatch).not.toBeNull();
+ const watchBlock = watchBlockMatch?.[0] ?? "";
+ expect(watchBlock).toMatch(/loadList\s*\(\s*\)/);
+ expect(watchBlock).toMatch(/loadSummary\s*\(\s*\)/);
+ });
+
+ it("clears the HallId filter when the department is unset", () => {
+ // When the new department is 0 / unset, the filter must be cleared
+ // (setFilter with '*' removes the key) so the query is not bound
+ // to a stale department.
+ expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*["']\*["']\s*,\s*false\s*\)/);
+ });
+
+ it("includes the active department in the summary query params", () => {
+ // The summary endpoint must also be re-issued with the new
+ // department; otherwise the summary cards show stale counts.
+ expect(source).toMatch(/HallId:\s*effectiveDepartmentId\.value/);
+ });
+});