Files
pleno-vue/src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue
T
a345d91ae4 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>
2026-08-16 16:22:05 +02:00

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>