## 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>
467 lines
17 KiB
Vue
467 lines
17 KiB
Vue
<script setup>
|
|
import { computed, onMounted, provide, ref, watch } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import { useI18n } from "vue-i18n";
|
|
import {
|
|
PaginatedListKey,
|
|
usePaginatedList,
|
|
} from "@/components/pagination/paginatedList.vue";
|
|
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
|
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
|
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
|
import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
|
|
import PaginationDisplayTemplateDates
|
|
from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue";
|
|
import ShowErrorField from "@/components/global/ShowErrorField.vue";
|
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
|
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
|
|
import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js";
|
|
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
|
|
import { removeError } from "@/components/request/HandleGlobalError.vue";
|
|
|
|
const props = defineProps({
|
|
autoLoad: {
|
|
type: Boolean, default: true
|
|
}, setCustomerFilter: {
|
|
type: Number, default: 0
|
|
}, hideSearch: {
|
|
type: Boolean, default: false
|
|
}, title: {
|
|
type: String, default: ""
|
|
}, initialDateFrom: {
|
|
type: String, default: ""
|
|
}, initialDateTo: {
|
|
type: String, default: ""
|
|
}, inheritPeriodFilters: {
|
|
type: Boolean, default: false
|
|
}, loadAllAtOnce: {
|
|
type: Boolean, default: false
|
|
}, highlightUsageLogId: {
|
|
type: Number, default: 0
|
|
}, departmentId: {
|
|
type: Number, default: 0
|
|
}
|
|
});
|
|
|
|
const { t } = useI18n();
|
|
const router = useRouter();
|
|
const paginatedList = usePaginatedList();
|
|
provide(PaginatedListKey, paginatedList);
|
|
|
|
const {
|
|
isLoading,
|
|
list,
|
|
loadList,
|
|
metaCurrentPage,
|
|
metaItemsPerPage,
|
|
metaTotalItems,
|
|
setEndpoint,
|
|
setMetaItemsPerPage,
|
|
setPage,
|
|
search,
|
|
setFilter,
|
|
setOrder,
|
|
hideSearchField,
|
|
setHideSearchField,
|
|
lastError,
|
|
} = paginatedList;
|
|
|
|
const XLVASK_USAGE_ORDERS_ENDPOINT = "/modules/xlvask/services/usage/orders";
|
|
setEndpoint(XLVASK_USAGE_ORDERS_ENDPOINT, false);
|
|
if (props.loadAllAtOnce) {
|
|
setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false);
|
|
}
|
|
const parseInitialDate = (value) => {
|
|
if (!value) {
|
|
return new Date();
|
|
}
|
|
|
|
const parsed = parseLocalDateOnly(value);
|
|
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
|
};
|
|
|
|
const applyInitialPeriodFilters = () => {
|
|
if (props.initialDateFrom) {
|
|
setFilter("StartTime-date_from", props.initialDateFrom, false);
|
|
}
|
|
|
|
if (props.initialDateTo) {
|
|
setFilter("StartTime-date_to", props.initialDateTo, false);
|
|
}
|
|
};
|
|
|
|
const shouldShowLocalFilters = computed(() => !props.inheritPeriodFilters);
|
|
|
|
const titleText = computed(() => props.title || SessionUser.objects.orders.meta.title);
|
|
if (props.setCustomerFilter > 0) {
|
|
setFilter("customer_number", props.setCustomerFilter);
|
|
}
|
|
|
|
if (props.hideSearch) {
|
|
setHideSearchField(true);
|
|
} else {
|
|
setHideSearchField(false);
|
|
}
|
|
|
|
const routeDepartmentId = computed(() => Number.parseInt(
|
|
String(router.currentRoute.value.params.departmentId ?? ""),
|
|
10
|
|
));
|
|
const effectiveDepartmentId = computed(() =>
|
|
props.departmentId > 0
|
|
? props.departmentId
|
|
: Number.isInteger(routeDepartmentId.value) && routeDepartmentId.value > 0
|
|
? routeDepartmentId.value
|
|
: 0
|
|
);
|
|
|
|
if (effectiveDepartmentId.value > 0) {
|
|
setFilter("HallId", effectiveDepartmentId.value, false);
|
|
}
|
|
|
|
setOrder("StartTime", "desc");
|
|
|
|
const extractResponseData = (response) => response?.data?.data ?? response?.data ?? {};
|
|
|
|
const summary = ref({});
|
|
const summaryLoading = ref(false);
|
|
const summaryError = ref("");
|
|
let summaryRequestSequence = 0;
|
|
|
|
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 () => {
|
|
const sequence = ++summaryRequestSequence;
|
|
const params = buildUsagePaginationParams();
|
|
summaryLoading.value = true;
|
|
summaryError.value = "";
|
|
try {
|
|
const response = await SessionUser.request(
|
|
"/modules/xlvask/services/usage/orders/summary",
|
|
"GET",
|
|
params,
|
|
);
|
|
if (sequence !== summaryRequestSequence) return;
|
|
summary.value = extractResponseData(response).summary || {};
|
|
} catch (error) {
|
|
if (sequence !== summaryRequestSequence) return;
|
|
summary.value = {};
|
|
summaryError.value = t("invoicing_period.xlvask_review.summary_error");
|
|
console.error("Failed to load XL-Vask usage summary.", error);
|
|
} finally {
|
|
if (sequence === summaryRequestSequence) summaryLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const dateFrom = ref(parseInitialDate(props.initialDateFrom));
|
|
const dateTo = ref(parseInitialDate(props.initialDateTo));
|
|
|
|
const parsedDate = (date) => parseLocalDateOnly(date);
|
|
|
|
const reloadScheduled = ref(false);
|
|
const scheduleReload = () => {
|
|
if (!reloadScheduled.value) {
|
|
reloadScheduled.value = true;
|
|
setTimeout(() => {
|
|
reloadScheduled.value = false;
|
|
loadList();
|
|
}, 20);
|
|
}
|
|
};
|
|
|
|
const actions = {
|
|
date: {
|
|
from: {
|
|
select: (date) => {
|
|
dateFrom.value = parsedDate(date);
|
|
setFilter("StartTime-date_from", formatLocalDateOnly(date), false);
|
|
scheduleReload();
|
|
}
|
|
},
|
|
to: {
|
|
select: (date) => {
|
|
dateTo.value = parsedDate(date);
|
|
setFilter("StartTime-date_to", formatLocalDateOnly(date), false);
|
|
scheduleReload();
|
|
}
|
|
},
|
|
}
|
|
};
|
|
|
|
applyInitialPeriodFilters();
|
|
if (props.autoLoad) {
|
|
loadList();
|
|
loadSummary();
|
|
}
|
|
|
|
watch(
|
|
() => [props.initialDateFrom, props.initialDateTo],
|
|
([nextDateFrom, nextDateTo], [previousDateFrom, previousDateTo]) => {
|
|
if (nextDateFrom === previousDateFrom && nextDateTo === previousDateTo) {
|
|
return;
|
|
}
|
|
|
|
dateFrom.value = parseInitialDate(nextDateFrom);
|
|
dateTo.value = parseInitialDate(nextDateTo);
|
|
applyInitialPeriodFilters();
|
|
|
|
if (props.autoLoad) {
|
|
loadList();
|
|
loadSummary();
|
|
}
|
|
}
|
|
);
|
|
|
|
// 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();
|
|
});
|
|
});
|
|
|
|
const filterValues = ref({
|
|
import_state: "",
|
|
resolution_state: "",
|
|
certainty: "",
|
|
planned_action: "",
|
|
});
|
|
|
|
const applyReviewFilter = (key, value) => {
|
|
filterValues.value[key] = value;
|
|
setFilter(key, value, false);
|
|
setPage(1);
|
|
loadList();
|
|
};
|
|
|
|
const clearReviewFilters = () => {
|
|
Object.keys(filterValues.value).forEach((key) => {
|
|
filterValues.value[key] = "";
|
|
setFilter(key, "", false);
|
|
});
|
|
showOnlyUnattachedVehicle.value = false;
|
|
setPage(1);
|
|
loadList();
|
|
};
|
|
|
|
const summaryCards = computed(() => [
|
|
{ key: "new", count: summary.value.new ?? 0, tone: "is-info" },
|
|
{ key: "updated", count: summary.value.updated ?? 0, tone: "is-info" },
|
|
{ key: "unchanged", count: summary.value.unchanged ?? 0, tone: "is-light" },
|
|
{ key: "already_linked", count: summary.value.already_linked ?? 0, tone: "is-success" },
|
|
{ key: "auto_linked", count: summary.value.auto_linked ?? 0, tone: "is-success" },
|
|
{ key: "auto_created", count: summary.value.auto_created ?? 0, tone: "is-success" },
|
|
{ key: "certain", count: summary.value.certain ?? 0, tone: "is-success" },
|
|
{ key: "uncertain", count: summary.value.uncertain ?? 0, tone: "is-warning" },
|
|
{ key: "needs_review", count: summary.value.needs_review ?? 0, tone: "is-warning" },
|
|
{ key: "blocked", count: summary.value.blocked ?? 0, tone: "is-danger" },
|
|
{ key: "invalid", count: summary.value.invalid ?? 0, tone: "is-danger" },
|
|
{ key: "ignored", count: summary.value.ignored ?? 0, tone: "is-light" },
|
|
{ key: "failed", count: summary.value.failed ?? 0, tone: "is-danger" },
|
|
]);
|
|
|
|
const showOnlyUnattachedVehicle = ref(false);
|
|
const visibleObjectsCount = computed(() => {
|
|
const currentList = Array.isArray(list.value) ? list.value : [];
|
|
if (!showOnlyUnattachedVehicle.value) {
|
|
return currentList.length;
|
|
}
|
|
return currentList.filter((object) => !isUsageOrderAttachedToOrder(object)).length;
|
|
});
|
|
|
|
// 404 on the orders endpoint means the API surface has not been
|
|
// implemented yet. Show a friendly notice instead of the generic error
|
|
// popper the rest of the paginated surfaces surface.
|
|
const apiEndpointNotImplemented = computed(() => (
|
|
lastError.value?.status === 404
|
|
&& lastError.value?.endpoint === XLVASK_USAGE_ORDERS_ENDPOINT
|
|
));
|
|
|
|
watch(apiEndpointNotImplemented, (isNotImplemented) => {
|
|
if (isNotImplemented) {
|
|
removeError("paginatedGetRequest");
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="level mb-3">
|
|
<div class="level-left">
|
|
<div class="level-item">
|
|
<h1 class="title is-4">{{ titleText }} ({{SessionUser.objects.global.language.synchronized}})</h1>
|
|
</div>
|
|
<div class="level-item">
|
|
<h2 class="subtitle is-6">{{SessionUser.objects.global.language.showing}} {{ visibleObjectsCount }} {{SessionUser.objects.global.language.showing_of_separator}} {{ metaTotalItems }} {{SessionUser.objects.orders.meta.title.toLowerCase()}}</h2>
|
|
</div>
|
|
</div>
|
|
<div class="level-right">
|
|
<div class="level-item">
|
|
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{SessionUser.objects.global.language.reload}}</LoadButtonWhileAwait>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<section class="xlvask-review-summary mb-4" aria-live="polite" data-testid="xlvask-review-summary">
|
|
<div class="is-flex is-justify-content-space-between is-align-items-center mb-2">
|
|
<h2 class="title is-6 mb-0">{{ t('invoicing_period.xlvask_review.summary_title') }}</h2>
|
|
<span v-if="summaryLoading" class="icon has-text-grey"><i class="fas fa-spinner fa-spin"></i></span>
|
|
</div>
|
|
<p v-if="summaryError" class="help is-danger mb-2">{{ summaryError }}</p>
|
|
<div class="tags">
|
|
<span
|
|
v-for="card in summaryCards"
|
|
:key="card.key"
|
|
class="tag is-light xlvask-review-summary-chip"
|
|
:class="card.tone"
|
|
:data-testid="'xlvask-summary-' + card.key"
|
|
>
|
|
{{ t(`invoicing_period.xlvask_review.states.${card.key}`) }}: {{ card.count }}
|
|
</span>
|
|
</div>
|
|
</section>
|
|
<section class="box xlvask-review-filters mb-4" data-testid="xlvask-review-filters">
|
|
<div class="columns is-multiline is-variable is-2">
|
|
<div class="column is-6-tablet is-3-desktop">
|
|
<label class="label is-small" for="xlvask-import-state-filter">{{ t('invoicing_period.xlvask_review.filters.import_state') }}</label>
|
|
<div class="select is-small is-fullwidth">
|
|
<select id="xlvask-import-state-filter" :value="filterValues.import_state" @change="applyReviewFilter('import_state', $event.target.value)">
|
|
<option value="">{{ t('invoicing_period.xlvask_review.filters.all') }}</option>
|
|
<option v-for="state in ['new', 'updated', 'unchanged', 'invalid']" :key="state" :value="state">{{ t(`invoicing_period.xlvask_review.states.${state}`) }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="column is-6-tablet is-3-desktop">
|
|
<label class="label is-small" for="xlvask-resolution-state-filter">{{ t('invoicing_period.xlvask_review.filters.resolution_state') }}</label>
|
|
<div class="select is-small is-fullwidth">
|
|
<select id="xlvask-resolution-state-filter" :value="filterValues.resolution_state" @change="applyReviewFilter('resolution_state', $event.target.value)">
|
|
<option value="">{{ t('invoicing_period.xlvask_review.filters.all') }}</option>
|
|
<option v-for="state in ['already_linked', 'auto_linked', 'auto_created', 'needs_review', 'blocked', 'ignored', 'failed']" :key="state" :value="state">{{ t(`invoicing_period.xlvask_review.states.${state}`) }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="column is-6-tablet is-3-desktop">
|
|
<label class="label is-small" for="xlvask-certainty-filter">{{ t('invoicing_period.xlvask_review.filters.certainty') }}</label>
|
|
<div class="select is-small is-fullwidth">
|
|
<select id="xlvask-certainty-filter" :value="filterValues.certainty" @change="applyReviewFilter('certainty', $event.target.value)">
|
|
<option value="">{{ t('invoicing_period.xlvask_review.filters.all') }}</option>
|
|
<option v-for="state in ['certain', 'uncertain', 'none']" :key="state" :value="state">{{ t(`invoicing_period.xlvask_review.states.${state}`) }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="column is-6-tablet is-3-desktop">
|
|
<label class="label is-small" for="xlvask-action-filter">{{ t('invoicing_period.xlvask_review.filters.planned_action') }}</label>
|
|
<div class="select is-small is-fullwidth">
|
|
<select id="xlvask-action-filter" :value="filterValues.planned_action" @change="applyReviewFilter('planned_action', $event.target.value)">
|
|
<option value="">{{ t('invoicing_period.xlvask_review.filters.all') }}</option>
|
|
<option v-for="action in ['attach_order', 'create_order', 'resolve_mapping', 'recheck', 'ignore', 'none']" :key="action" :value="action">{{ t(`invoicing_period.xlvask_review.actions.${action}`) }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button class="button is-small is-light" type="button" @click="clearReviewFilters">
|
|
{{ t('invoicing_period.xlvask_review.filters.clear') }}
|
|
</button>
|
|
</section>
|
|
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_transactions')" v-if="!hideSearchField && shouldShowLocalFilters"/>
|
|
<PaginationDisplay v-if="shouldShowLocalFilters" :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
|
|
<template #paginationColumns>
|
|
<div class="column is-narrow my-3">
|
|
<label class="label">{{ t('pagination.order_direction') }}</label>
|
|
<div class="control">
|
|
<div class="select">
|
|
<select @change="setOrder('created_at', $event.target.value); loadList();">
|
|
<option value="asc">{{ t('pagination.ascending') }}</option>
|
|
<option value="desc" selected>{{ t('pagination.descending') }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<PaginationDisplayTemplateDates
|
|
v-bind:end-date="dateTo"
|
|
v-bind:start-date="dateFrom"
|
|
@update:startDate="actions.date.from.select"
|
|
@update:endDate="actions.date.to.select"
|
|
/>
|
|
<div class="column is-narrow my-3">
|
|
<label class="label">{{ t('invoicing_period.xlvask_review.filters.title') }}</label>
|
|
<div class="control">
|
|
<b-switch
|
|
size="is-small"
|
|
type="is-link"
|
|
v-model="showOnlyUnattachedVehicle"
|
|
>
|
|
{{ t('invoicing_period.xlvask_review.filters.unattached_only') }}
|
|
</b-switch>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</PaginationDisplay>
|
|
<div
|
|
v-else-if="apiEndpointNotImplemented"
|
|
class="notification is-info is-light mb-4"
|
|
data-testid="xlvask-api-not-implemented"
|
|
>
|
|
{{ t('invoicing_period.xlvask_review.errors.api_endpoint_not_implemented') }}
|
|
</div>
|
|
<ShowErrorField v-else error="paginatedGetRequest"/>
|
|
<XlvaskUsageOrdersTable
|
|
:objects="list"
|
|
:show-only-unattached-vehicle="showOnlyUnattachedVehicle"
|
|
:highlight-usage-log-id="props.highlightUsageLogId"
|
|
:allow-select-multiple="true"
|
|
:allow-review-actions="true"
|
|
:allow-adjudication-actions="false"
|
|
/>
|
|
<PaginationNavigation
|
|
v-if="!props.loadAllAtOnce"
|
|
:currentPage="metaCurrentPage"
|
|
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
|
|
:loadFunction="loadList"
|
|
:setPage="setPage"
|
|
:isLoading="isLoading"
|
|
/>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.xlvask-review-summary .tags {
|
|
gap: 0.35rem;
|
|
}
|
|
|
|
.xlvask-review-summary-chip {
|
|
height: auto;
|
|
min-height: 1.75rem;
|
|
white-space: normal;
|
|
}
|
|
|
|
.xlvask-review-filters {
|
|
padding: 0.9rem;
|
|
}
|
|
</style>
|