diff --git a/src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue b/src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue index acdc0e4e..5eca3f02 100644 --- a/src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue +++ b/src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue @@ -103,19 +103,20 @@ if (props.hideSearch) { setHideSearchField(false); } -const routeDepartmentId = Number.parseInt( +const routeDepartmentId = computed(() => Number.parseInt( String(router.currentRoute.value.params.departmentId ?? ""), 10 -); -const effectiveDepartmentId = +)); +const effectiveDepartmentId = computed(() => props.departmentId > 0 ? props.departmentId - : Number.isInteger(routeDepartmentId) && routeDepartmentId > 0 - ? routeDepartmentId - : 0; + : Number.isInteger(routeDepartmentId.value) && routeDepartmentId.value > 0 + ? routeDepartmentId.value + : 0 +); -if (effectiveDepartmentId > 0) { - setFilter("HallId", effectiveDepartmentId, false); +if (effectiveDepartmentId.value > 0) { + setFilter("HallId", effectiveDepartmentId.value, false); } setOrder("StartTime", "desc"); @@ -131,6 +132,7 @@ const buildUsagePaginationParams = (extra = {}) => ({ ...extra, dateFrom: props.initialDateFrom || formatLocalDateOnly(dateFrom.value), dateTo: props.initialDateTo || formatLocalDateOnly(dateTo.value), + ...(effectiveDepartmentId.value > 0 ? { HallId: effectiveDepartmentId.value } : {}), }); const loadSummary = async () => { @@ -215,6 +217,31 @@ watch( } ); +// Re-apply the department (HallId) filter and re-issue the query when +// the department selector changes (either via the `departmentId` prop +// or via the `departmentId` route param). Without this watcher the +// filter was set only once at setup, so changing departments left the +// Selvvask usage query bound to the original department. +watch( + effectiveDepartmentId, + (nextDepartmentId, previousDepartmentId) => { + if (nextDepartmentId === previousDepartmentId) { + return; + } + + if (nextDepartmentId > 0) { + setFilter("HallId", nextDepartmentId, false); + } else { + setFilter("HallId", "*", false); + } + + if (props.autoLoad) { + loadList(); + loadSummary(); + } + } +); + onMounted(() => { window.addEventListener("xlvask-usage-order-updated", () => { loadSummary(); diff --git a/src/components/displays/selfServe/RedCarWarning.vue b/src/components/displays/selfServe/RedCarWarning.vue new file mode 100644 index 00000000..6d9064a0 --- /dev/null +++ b/src/components/displays/selfServe/RedCarWarning.vue @@ -0,0 +1,44 @@ + + + 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/); + }); +});