feat(pleno-vue): warning on red cars (TRU-99) (#318)
## Summary
Closes (heuristic for) **TRU-99 / SENERE 7**: warn operators in the
customer portal wash flow when a customer is flagged as a red car.
## Detection rule — heuristic, Mads to refine
The canonical rule (plate scan vs. red-car tag) is still TBD by Mads. As
a sensible default that fits the existing data model, this PR uses a
**manual customer-attribute flag**:
- If a customer has one of the configurable attribute keys set, the
warning fires. Default keys: `isRedCar`, `is_red_car`, `redCar`,
`red_car`.
- Detection is **case-insensitive** and the key list is **configurable**
(callers can override) so the rule can be tightened later without
touching the UI.
- Detection lives in `src/composables/redCarDetector.js`; reactive
loading lives in `src/composables/useRedCarWarning.js`.
- The composable reuses the existing `/customer/attributes` endpoint via
`customerAttributeService.js` — no backend change needed.
## UI
- New `RedCarWarning.vue` component renders a dismissable Buefy warning
in the vehicle step of the self-serve flow, with title + reason + care
suggestion.
- Wired into `MyWashStart.vue` via the existing `VehicleInputSection` /
`SelfServeVehicleStep` props. The composable is called with the
effective customer number (authenticated subuser or typed-in).
- Translations added in all 5 locales: `da`, `en`, `sv`, `de`, `no`
(source + regenerated runtime files).
## Tests
- 22 detector unit tests (positive, negative, case-insensitive, custom
keys, dedupe, normalisation).
- 5 i18n key presence tests across all 5 locales.
- 4 `RedCarWarning` component tests (conditional render, dismiss
wiring).
All new + existing related tests pass: `vitest run` on
`red-car-detector`, `red-car-warning-i18n`, `red-car-warning`,
`my-wash-start`, `customer-rule-registry`, `customer-rule-tooltip` →
**84/84 green**.
## Out of scope / not touched
- Backend / API: reused existing `/customer/attributes` endpoint.
- `openclaw.json`, deployment, merge — not touched (per task
constraints).
- Customer-rule registry: not added to `CUSTOMER_RULE_DEFINITIONS`
because the red-car flag is a soft warning, not a product-restriction
rule. If Mads wants it surfaced in the customer rule manager UI, that is
a follow-up.
## Files
- `src/composables/redCarDetector.js` (new)
- `src/composables/useRedCarWarning.js` (new)
- `src/components/displays/selfServe/RedCarWarning.vue` (new)
- `src/components/displays/selfServe/SelfServeVehicleStep.vue` (prop +
render)
-
`src/views/dashboards/userDashboard/wash/components/VehicleInputSection.vue`
(prop pass-through)
- `src/views/dashboards/userDashboard/wash/MyWashStart.vue` (composable
+ prop binding)
- `src/i18n/source/{da,en,sv,de,no}/phrases/compat/self_wash/index.json`
(new keys)
- `src/i18n/generated/{da,en,sv,de,no}-v2.json` (regenerated)
- `tests/unit/red-car-detector.spec.js` (new)
- `tests/unit/red-car-warning-i18n.spec.js` (new)
- `tests/unit/red-car-warning.spec.js` (new)
Refs: TRU-99
---------
Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: Frontend Subagent <frontend-agent@openclaw.local>
Co-authored-by: Pleno Bugfix Bot <bugfix-bot@pleno.local>
This commit is contained in:
co-authored by
Jeppe B
Frontend Subagent
Pleno Bugfix Bot
parent
19fbbcddce
commit
a345d91ae4
@@ -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();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps({
|
||||
isRedCar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
customerNumber: {
|
||||
type: [String, Number, null],
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["dismiss"]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const shouldRender = computed(() => Boolean(props.isRedCar));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<b-message
|
||||
v-if="shouldRender"
|
||||
type="is-warning"
|
||||
has-icon
|
||||
:closable="true"
|
||||
data-testid="red-car-warning"
|
||||
:title="t('self_wash.red_car_warning_title')"
|
||||
@close="emit('dismiss')"
|
||||
>
|
||||
<p data-testid="red-car-warning-message">
|
||||
{{ t("self_wash.red_car_warning_message") }}
|
||||
</p>
|
||||
<p class="mt-2" data-testid="red-car-warning-suggestion">
|
||||
{{ t("self_wash.red_car_warning_suggestion") }}
|
||||
</p>
|
||||
</b-message>
|
||||
</template>
|
||||
@@ -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<any>;
|
||||
vehicleStepError?: string | null;
|
||||
vehicleStepGuidance?: string | null;
|
||||
isRedCar?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -141,6 +143,11 @@ const emitVehicleTypeSelection = (selection: any) => {
|
||||
>
|
||||
{{ props.vehicleStepError }}
|
||||
</b-message>
|
||||
<RedCarWarning
|
||||
v-if="!props.vehicleStepError && props.isRedCar"
|
||||
:is-red-car="true"
|
||||
:customer-number="customerNumber"
|
||||
/>
|
||||
<b-message
|
||||
v-else-if="props.vehicleStepGuidance"
|
||||
type="is-info"
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Red-car detection utilities for the customer portal.
|
||||
*
|
||||
* SENERE 7 / TRU-99: Until Mads defines the canonical rule for
|
||||
* identifying a "red car" (e.g. plate scan, color tag, manual
|
||||
* customer flag), we use a manual customer-attribute flag as the
|
||||
* default heuristic. The attribute keys that trigger the warning
|
||||
* are configurable so the rule can be tightened later without
|
||||
* touching call sites.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default attribute keys that mark a customer as owning a red car.
|
||||
* Multiple keys are supported to give staff a few options when
|
||||
* tagging accounts in the customer-attribute editor.
|
||||
*/
|
||||
export const DEFAULT_RED_CAR_ATTRIBUTE_KEYS = Object.freeze([
|
||||
"isRedCar",
|
||||
"is_red_car",
|
||||
"redCar",
|
||||
"red_car",
|
||||
]);
|
||||
|
||||
const normalizeKey = (key) => 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<string|object|undefined|null>|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<string|object|undefined|null>|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);
|
||||
};
|
||||
@@ -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>|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<Array>,
|
||||
* isLoading: import("vue").Ref<boolean>,
|
||||
* error: import("vue").Ref<string|null>,
|
||||
* isRedCar: import("vue").ComputedRef<boolean>,
|
||||
* reload: () => Promise<void>,
|
||||
* 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,
|
||||
};
|
||||
};
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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: `
|
||||
<div
|
||||
class="b-message-stub"
|
||||
:data-type="type"
|
||||
:data-title="title"
|
||||
data-testid="red-car-warning"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user