Files
pleno-vue/src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue
T
6b44835347 Surface XL vask accept/compare/link/deny actions and i18n labels (#263)
## Why

In the superuser fakturaer-periode selvvask view, XL vask rows were
missing usable controls. Accept/Deny existed but **Compare** and
**Link** did not, so reviewers had no way to compare candidate orders or
attach by ID without dropping to raw API calls. Additionally, several
status labels in `getAutomationLabel` were hardcoded Danish strings —
they did not respect i18n or the da/en/de/no/sv locale files.

A legacy stub in `XLVaskUsageLog.vue` (`<template v-if="usage.WashItems
&& 1 === 2">`) permanently disabled the per-row wash items display.

## What changed


`src/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue`:
- New **Compare** button — `<b-modal>` side-by-side price view using
existing `duplicates` + `doesObjectHaveExactMatch`. Disabled when no
duplicates. Test IDs `xlvask-compare-{id}` and `xlvask-compare-modal`.
- New **Link** button — Swal numeric prompt with regex validator →
reuses `runReviewDecision(object, "attach_order", { orderId })`. Test ID
`xlvask-automation-link-{id}`.
- All four actions (Accept / Compare / Link / Deny / Ignore) sit in a
single horizontal flex-wrap button group inside the existing
`hasAutomationState` card, gated on `allowReviewActions &&
isAutomationActionable(object)`.
- Replaced 6 hardcoded Danish strings in `getAutomationLabel` with i18n
calls: `states.suggested_*`, `states.auto_accepted_*`,
`states.accepted_*`.

`src/i18n/source/global/shared/invoicing_period/xlvask_autopilot.json`
(and the 5 locale overrides) — added:
- `actions.compare`, `actions.link`
- `actions.compare_modal_title`, `actions.compare_modal_close`
- `actions.link_prompt_title`, `actions.link_prompt_label`,
`actions.link_prompt_invalid`
- `states.suggested_create_order`, `states.suggested_attach_order`,
`states.auto_accepted_create`, `states.auto_accepted_attach`,
`states.accepted_create`, `states.accepted_attach`

Regenerated the i18n bundle (`src/i18n/generated/*-v2.json`).


`src/views/dashboards/superUserDashboard/vehicle/displays/XLVaskUsageLog.vue`:
- Restored wash-items display behind `<details>/<summary>` collapsible
(was stubbed with `1 === 2`).

## Verification

- `npx eslint` — clean.
- `npm run i18n:v2:check` — all 4 sub-checks green.

Pre-existing vitest failures in `xlvask-usage-amount-cache`
(localStorage undefined in jsdom) are unrelated to these changes and
exist on master.

## Risk

- Surface-only changes inside existing automation card; no new
endpoints, no new permissions, no data shape changes. Backwards
compatible.

🤖 Generated with [OpenClaw](https://openclaw.ai)

---------

Co-authored-by: XL Vask Subagent <agent@truckwash.dk>
Co-authored-by: Truck Wash Agent <agent@copenhagentruckwash.local>
2026-08-09 01:14:26 +02:00

1055 lines
42 KiB
Vue

<script setup>
import { SessionUser } from "@/components/session/token/SessionUser.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
}, automationWorkspace: {
type: Boolean, default: false
}
})
import {computed, onMounted, onUnmounted, provide, ref, watch } from "vue";
import { useRouter } from "vue-router";
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 { useI18n } from 'vue-i18n'
import { BSwitch } from "buefy";
import Swal from "sweetalert2";
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 {
emptyXlvaskAutopilotSummary,
emptyXlvaskAutomationCapabilities,
emptyXlvaskAutomationReadiness,
isXlvaskAutopilotRunActive,
normalizeXlvaskAutomationCapabilities,
normalizeXlvaskAutomationReadiness,
normalizeXlvaskAutopilotRun,
normalizeXlvaskAutopilotSummary,
xlvaskAutopilotRunProgress,
} from "@/components/displays/department/pos/sync/xlvaskAutopilotUi.js";
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,
} = paginatedList;
setEndpoint("/modules/xlvask/services/usage/orders", 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 the customer filter is set, filter the orders by the customer number
if (props.setCustomerFilter > 0) {
setFilter("customer_number", props.setCustomerFilter);
}
// Hide the search field
if (props.hideSearch) {
setHideSearchField(true);
} else {
setHideSearchField(false);
}
// If the departmentId is set, in the route, filter the orders by the departmentId
if (router.currentRoute.value.params.departmentId) {
setOrder("StartTime", "desc");
//setFilter("department", router.currentRoute.value.params.departmentId, false);
} else {
setOrder("StartTime", "desc");
}
const isImportLoading = ref(false);
const summary = ref(emptyXlvaskAutopilotSummary());
const summaryLoading = ref(false);
const summaryError = ref("");
let summaryRequestSequence = 0;
const autopilotRun = ref(null);
const runError = ref("");
const capabilities = ref(emptyXlvaskAutomationCapabilities());
const readiness = ref(emptyXlvaskAutomationReadiness());
const controlStateLoading = ref(false);
const controlStateError = ref("");
const adminReadinessError = ref("");
const policyActionLoading = ref(false);
const activeRunCheckComplete = ref(false);
let controlStateRequestSequence = 0;
let activeRunRecoverySequence = 0;
let periodScopeSequence = 0;
let currentRunIdempotencyKey = "";
let currentRunFingerprint = "";
let runPollTimer = null;
let runPollSequence = 0;
let runPollingStartedAt = 0;
const runPollingPaused = ref(false);
const RUN_POLL_INTERVAL_MS = 2_000;
const RUN_POLL_MAX_DURATION_MS = 5 * 60 * 1_000;
const extractResponseData = (response) => response?.data?.data ?? response?.data ?? {};
const loadSummary = async () => {
const sequence = ++summaryRequestSequence;
const requestedFrom = props.initialDateFrom || formatLocalDateOnly(dateFrom.value);
const requestedTo = props.initialDateTo || formatLocalDateOnly(dateTo.value);
summaryLoading.value = true;
summaryError.value = "";
try {
const response = await SessionUser.request(
"/modules/xlvask/services/usage/orders/summary",
"GET",
{ dateFrom: requestedFrom, dateTo: requestedTo },
);
if (sequence !== summaryRequestSequence) return;
const data = extractResponseData(response);
summary.value = normalizeXlvaskAutopilotSummary(data.summary);
} catch (error) {
if (sequence !== summaryRequestSequence) return;
summary.value = emptyXlvaskAutopilotSummary();
summaryError.value = t("invoicing_period.xlvask_autopilot.summary_error");
console.error("Failed to load XL-Vask autopilot summary.", error);
} finally {
if (sequence === summaryRequestSequence) summaryLoading.value = false;
}
};
const loadAutomationControlState = async () => {
if (!props.automationWorkspace) return;
const sequence = ++controlStateRequestSequence;
controlStateLoading.value = true;
controlStateError.value = "";
adminReadinessError.value = "";
try {
const capabilityResponse = await SessionUser.request(
"/modules/xlvask/services/usage/automation/capabilities",
"GET",
buildImportUsageParams(),
);
if (sequence !== controlStateRequestSequence) return;
const capabilityData = extractResponseData(capabilityResponse);
capabilities.value = normalizeXlvaskAutomationCapabilities(capabilityData);
readiness.value = normalizeXlvaskAutomationReadiness(capabilityData.readiness || capabilityData);
if (!capabilities.value.can_view) return;
if (capabilities.value.can_manage_policy) {
try {
const readinessResponse = await SessionUser.request(
"/modules/xlvask/services/usage/automation/admin/readiness",
"GET",
buildImportUsageParams(),
);
if (sequence !== controlStateRequestSequence) return;
readiness.value = normalizeXlvaskAutomationReadiness(extractResponseData(readinessResponse));
} catch (error) {
if (sequence !== controlStateRequestSequence) return;
adminReadinessError.value = t("invoicing_period.xlvask_autopilot.controls.readiness_error");
console.error("Failed to load XL-Vask administrator readiness.", error);
}
}
} catch (error) {
if (sequence !== controlStateRequestSequence) return;
capabilities.value = emptyXlvaskAutomationCapabilities();
readiness.value = emptyXlvaskAutomationReadiness();
controlStateError.value = t("invoicing_period.xlvask_autopilot.controls.load_error");
console.error("Failed to load XL-Vask automation control state.", error);
} finally {
if (sequence === controlStateRequestSequence) controlStateLoading.value = false;
}
};
const buildImportUsageParams = () => {
if (!props.inheritPeriodFilters) {
return {
dateFrom: formatLocalDateOnly(dateFrom.value),
dateTo: formatLocalDateOnly(dateTo.value),
};
}
return {
...(props.initialDateFrom ? { dateFrom: props.initialDateFrom } : {}),
...(props.initialDateTo ? { dateTo: props.initialDateTo } : {}),
};
};
const refreshAutopilotSurface = async () => {
await Promise.all([loadList(), loadSummary()]);
window.dispatchEvent(new CustomEvent("xlvask-usage-order-updated", {
detail: { runId: autopilotRun.value?.id ?? null, source: "autopilot" },
}));
};
const clearRunPollTimer = () => {
if (runPollTimer !== null) {
clearTimeout(runPollTimer);
runPollTimer = null;
}
};
const invalidateAutopilotScope = () => {
periodScopeSequence += 1;
runPollSequence += 1;
activeRunRecoverySequence += 1;
controlStateRequestSequence += 1;
activeRunCheckComplete.value = false;
clearRunPollTimer();
autopilotRun.value = null;
isImportLoading.value = false;
runPollingPaused.value = false;
runPollingStartedAt = 0;
runError.value = "";
currentRunIdempotencyKey = "";
currentRunFingerprint = "";
};
const pollAutopilotRun = async (runId, sequence) => {
if (!runId || sequence !== runPollSequence) return;
if (document.hidden) {
runPollingPaused.value = true;
return;
}
if (runPollingStartedAt > 0 && Date.now() - runPollingStartedAt >= RUN_POLL_MAX_DURATION_MS) {
runPollingPaused.value = true;
return;
}
try {
const response = await SessionUser.request(
`/modules/xlvask/services/usage/autopilot-runs/${runId}`,
"GET",
);
if (sequence !== runPollSequence) return;
const data = extractResponseData(response);
autopilotRun.value = normalizeXlvaskAutopilotRun(data.run);
if (isXlvaskAutopilotRunActive(autopilotRun.value)) {
runPollTimer = setTimeout(() => pollAutopilotRun(runId, sequence), RUN_POLL_INTERVAL_MS);
return;
}
isImportLoading.value = false;
runPollingPaused.value = false;
runPollingStartedAt = 0;
currentRunIdempotencyKey = "";
currentRunFingerprint = "";
await refreshAutopilotSurface();
await loadAutomationControlState();
} catch (error) {
if (sequence !== runPollSequence) return;
runPollingPaused.value = true;
runError.value = t("invoicing_period.xlvask_autopilot.run_status_error");
console.error("Failed to load XL-Vask autopilot run.", error);
}
};
const resumeAutopilotPolling = ({ resetWindow = true } = {}) => {
if (!isXlvaskAutopilotRunActive(autopilotRun.value) || document.hidden) return;
clearRunPollTimer();
if (resetWindow) runPollingStartedAt = Date.now();
runPollingPaused.value = false;
void pollAutopilotRun(autopilotRun.value.id, runPollSequence);
};
const handleVisibilityChange = () => {
if (!document.hidden && runPollingPaused.value && runPollingStartedAt > 0
&& Date.now() - runPollingStartedAt < RUN_POLL_MAX_DURATION_MS) {
resumeAutopilotPolling({ resetWindow: false });
}
};
const handleUsageOrderUpdated = (event) => {
if (event?.detail?.source === "adjudication") {
void Promise.all([loadSummary(), loadAutomationControlState()]);
return;
}
if (event?.detail?.source !== "autopilot") {
void loadSummary();
}
};
const createIdempotencyKey = () => {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
return `xlvask-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const getRunIdempotencyKey = (mode, params) => {
const fingerprint = JSON.stringify({ mode, ...params });
if (fingerprint !== currentRunFingerprint || !currentRunIdempotencyKey) {
currentRunFingerprint = fingerprint;
currentRunIdempotencyKey = createIdempotencyKey();
}
return currentRunIdempotencyKey;
};
const buildExecutionReadinessSnapshot = () => ({
ready: readiness.value.ready,
worker_healthy: readiness.value.worker_healthy,
blocked_reasons: [...readiness.value.blocked_reasons],
can_execute: capabilities.value.can_execute,
allowed_modes: [...capabilities.value.allowed_modes],
policy_version: readiness.value.policy_version,
effective_stage: readiness.value.effective_stage,
eligible: readiness.value.eligible_counts.total,
attach_global: readiness.value.budgets.attach_order.remaining_global,
attach_hall_minimum: readiness.value.budgets.attach_order.remaining_hall,
create_global: readiness.value.budgets.create_order.remaining_global,
create_hall_minimum: readiness.value.budgets.create_order.remaining_hall,
sources: [...capabilities.value.effective_action_sources],
});
const canStartDryRun = computed(() => props.automationWorkspace
&& capabilities.value.can_dry_run
&& capabilities.value.allowed_modes.includes("dry_run")
&& activeRunCheckComplete.value
&& !isXlvaskAutopilotRunActive(autopilotRun.value));
const canStartExecute = computed(() => props.automationWorkspace
&& capabilities.value.can_execute
&& readiness.value.ready
&& capabilities.value.allowed_modes.includes("execute")
&& activeRunCheckComplete.value
&& !isXlvaskAutopilotRunActive(autopilotRun.value));
const startAutopilotRun = async (mode = "dry_run") => {
if (mode === "dry_run" ? !canStartDryRun.value : !canStartExecute.value) return;
const requestScopeSequence = periodScopeSequence;
const runParams = buildImportUsageParams();
const readinessSnapshot = buildExecutionReadinessSnapshot();
const readinessFingerprint = JSON.stringify(readinessSnapshot);
if (mode === "execute") {
const confirmationPhrase = t("invoicing_period.xlvask_autopilot.controls.execute_phrase");
const confirmation = await Swal.fire({
icon: "warning",
title: t("invoicing_period.xlvask_autopilot.controls.execute_title"),
text: t("invoicing_period.xlvask_autopilot.controls.execute_description", {
from: runParams.dateFrom || "—",
to: runParams.dateTo || "—",
eligible: readinessSnapshot.eligible,
attachGlobal: readinessSnapshot.attach_global,
attachHall: readinessSnapshot.attach_hall_minimum,
createGlobal: readinessSnapshot.create_global,
createHall: readinessSnapshot.create_hall_minimum,
sources: readinessSnapshot.sources.join(", ") || "—",
}),
input: "text",
inputLabel: t("invoicing_period.xlvask_autopilot.preview.confirmation_label", { phrase: confirmationPhrase }),
inputPlaceholder: confirmationPhrase,
showCancelButton: true,
confirmButtonText: t("invoicing_period.xlvask_autopilot.controls.execute"),
cancelButtonText: t("common.cancel"),
inputValidator: (value) => String(value ?? "").trim() === confirmationPhrase
? undefined
: t("invoicing_period.xlvask_autopilot.preview.confirmation_mismatch", { phrase: confirmationPhrase }),
});
if (!confirmation.isConfirmed) return;
if (requestScopeSequence !== periodScopeSequence) return;
if (readinessFingerprint !== JSON.stringify(buildExecutionReadinessSnapshot())) return;
if (!canStartExecute.value) return;
}
isImportLoading.value = true;
runError.value = "";
clearRunPollTimer();
const sequence = ++runPollSequence;
runPollingStartedAt = Date.now();
runPollingPaused.value = false;
try {
const idempotencyKey = getRunIdempotencyKey(mode, runParams);
const response = await SessionUser.request(
"/modules/xlvask/services/usage/autopilot-runs",
"POST",
{
...runParams,
mode,
forceRefetch: true,
idempotency_key: idempotencyKey,
},
);
if (requestScopeSequence !== periodScopeSequence) return;
const data = extractResponseData(response);
autopilotRun.value = normalizeXlvaskAutopilotRun(data.run);
if (!autopilotRun.value?.id) {
throw new Error("Autopilot run response did not contain a run id.");
}
if (isXlvaskAutopilotRunActive(autopilotRun.value)) {
await pollAutopilotRun(autopilotRun.value.id, sequence);
} else {
await refreshAutopilotSurface();
isImportLoading.value = false;
currentRunIdempotencyKey = "";
currentRunFingerprint = "";
}
} catch (error) {
if (requestScopeSequence !== periodScopeSequence) return;
runError.value = t("invoicing_period.xlvask_autopilot.run_start_error");
console.error("Failed to start XL-Vask autopilot.", error);
isImportLoading.value = false;
}
};
const recoverActiveAutopilotRun = async () => {
if (!props.automationWorkspace || !capabilities.value.can_view) return;
if (!capabilities.value.can_dry_run && !capabilities.value.can_execute && !capabilities.value.can_review) {
activeRunCheckComplete.value = true;
return;
}
const recoverySequence = ++activeRunRecoverySequence;
activeRunCheckComplete.value = false;
try {
const response = await SessionUser.request(
"/modules/xlvask/services/usage/autopilot-runs/active",
"GET",
buildImportUsageParams(),
);
if (recoverySequence !== activeRunRecoverySequence) return;
const data = extractResponseData(response);
const run = normalizeXlvaskAutopilotRun(data.run);
activeRunCheckComplete.value = true;
if (!isXlvaskAutopilotRunActive(run)) return;
autopilotRun.value = run;
isImportLoading.value = true;
runPollSequence += 1;
runPollingStartedAt = Date.now();
resumeAutopilotPolling();
} catch (error) {
if (recoverySequence !== activeRunRecoverySequence) return;
activeRunCheckComplete.value = false;
controlStateError.value = t("invoicing_period.xlvask_autopilot.controls.active_run_error");
console.error("Failed to recover the active XL-Vask automation run.", error);
}
};
const NEXT_POLICY_STAGE = {
off: "advisory",
advisory: "ai_attach_canary",
ai_attach_canary: "ai_attach_verified",
ai_attach_verified: "ai_create_canary",
ai_create_canary: "verified_capped",
halted: "advisory",
};
const nextPolicyStage = computed(() => NEXT_POLICY_STAGE[readiness.value.effective_stage] || "");
const canAdvancePolicy = computed(() => capabilities.value.can_manage_policy
&& !adminReadinessError.value
&& Boolean(nextPolicyStage.value)
&& (readiness.value.ready || readiness.value.effective_stage === "halted"));
const applyPolicyPreview = async () => {
if (!canAdvancePolicy.value || policyActionLoading.value) return;
policyActionLoading.value = true;
try {
const reasonResult = await Swal.fire({
title: t("invoicing_period.xlvask_autopilot.controls.policy_reason_title"),
input: "textarea",
inputLabel: t("invoicing_period.xlvask_autopilot.preview.reason_label"),
showCancelButton: true,
confirmButtonText: t("common.continue"),
cancelButtonText: t("common.cancel"),
inputValidator: (value) => String(value ?? "").trim()
? undefined
: t("invoicing_period.xlvask_autopilot.preview.reason_required"),
});
if (!reasonResult.isConfirmed) return;
const previewResponse = await SessionUser.request(
"/modules/xlvask/services/usage/automation/admin/policy/previews",
"POST",
{
target_stage: nextPolicyStage.value,
expected_policy_version: readiness.value.policy_version,
reason: String(reasonResult.value).trim(),
},
);
const preview = extractResponseData(previewResponse).preview;
if (!preview?.id || !preview?.selection_hash || preview.expected_policy_version === undefined) {
throw new Error("Policy preview is incomplete.");
}
const phrase = String(preview.confirmation_phrase || t("invoicing_period.xlvask_autopilot.controls.policy_phrase")).trim();
const confirmation = await Swal.fire({
icon: "warning",
title: t("invoicing_period.xlvask_autopilot.controls.policy_title"),
text: t("invoicing_period.xlvask_autopilot.controls.policy_description", { stage: nextPolicyStage.value }),
input: preview.requires_confirmation === false ? undefined : "text",
inputLabel: preview.requires_confirmation === false
? undefined
: t("invoicing_period.xlvask_autopilot.preview.confirmation_label", { phrase }),
inputPlaceholder: phrase,
showCancelButton: true,
confirmButtonText: t("invoicing_period.xlvask_autopilot.controls.policy_apply"),
cancelButtonText: t("common.cancel"),
inputValidator: preview.requires_confirmation === false
? undefined
: (value) => String(value ?? "").trim() === phrase
? undefined
: t("invoicing_period.xlvask_autopilot.preview.confirmation_mismatch", { phrase }),
});
if (!confirmation.isConfirmed) return;
const response = await SessionUser.request(
"/modules/xlvask/services/usage/automation/admin/policy/apply",
"POST",
{
preview_id: preview.id,
selection_hash: preview.selection_hash,
...(preview.requires_confirmation === false ? {} : { confirmation_text: confirmation.value }),
expected_policy_version: preview.expected_policy_version,
},
);
const data = extractResponseData(response);
readiness.value = normalizeXlvaskAutomationReadiness(data.readiness || data);
await loadAutomationControlState();
} catch (error) {
await Swal.fire({
icon: "error",
title: t("invoicing_period.xlvask_autopilot.preview.error_title"),
text: SessionUser.functions.parseErrorMessage?.(error) || String(error),
});
} finally {
policyActionLoading.value = false;
}
};
const haltAutomation = async () => {
if (!capabilities.value.can_halt || policyActionLoading.value) return;
const result = await Swal.fire({
icon: "warning",
title: t("invoicing_period.xlvask_autopilot.controls.halt_title"),
input: "textarea",
inputLabel: t("invoicing_period.xlvask_autopilot.preview.reason_label"),
showCancelButton: true,
confirmButtonText: t("invoicing_period.xlvask_autopilot.controls.halt"),
cancelButtonText: t("common.cancel"),
inputValidator: (value) => String(value ?? "").trim()
? undefined
: t("invoicing_period.xlvask_autopilot.preview.reason_required"),
});
if (!result.isConfirmed) return;
policyActionLoading.value = true;
try {
const response = await SessionUser.request(
"/modules/xlvask/services/usage/automation/admin/halt",
"POST",
{ reason: String(result.value).trim() },
);
const data = extractResponseData(response);
readiness.value = normalizeXlvaskAutomationReadiness(data.readiness || data);
await loadAutomationControlState();
} catch (error) {
await Swal.fire({
icon: "error",
title: t("invoicing_period.xlvask_autopilot.preview.error_title"),
text: SessionUser.functions.parseErrorMessage?.(error) || String(error),
});
} finally {
policyActionLoading.value = false;
}
};
const dateFrom = ref(parseInitialDate(props.initialDateFrom));
const dateTo = ref(parseInitialDate(props.initialDateTo));
const parsedDate = (date) => {
return 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();
// Load the list automatically if the autoLoad prop is set
if (props.autoLoad) {
loadList();
loadSummary();
}
watch(
() => [props.initialDateFrom, props.initialDateTo],
async ([nextDateFrom, nextDateTo], [previousDateFrom, previousDateTo]) => {
if (nextDateFrom === previousDateFrom && nextDateTo === previousDateTo) {
return;
}
invalidateAutopilotScope();
const scopeSequence = periodScopeSequence;
dateFrom.value = parseInitialDate(nextDateFrom);
dateTo.value = parseInitialDate(nextDateTo);
currentRunIdempotencyKey = "";
currentRunFingerprint = "";
applyInitialPeriodFilters();
if (props.autoLoad) {
loadList();
loadSummary();
}
if (props.automationWorkspace) {
await loadAutomationControlState();
if (scopeSequence !== periodScopeSequence) return;
await recoverActiveAutopilotRun();
}
}
);
onUnmounted(() => {
runPollSequence += 1;
clearRunPollTimer();
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("xlvask-usage-order-updated", handleUsageOrderUpdated);
});
onMounted(async () => {
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("xlvask-usage-order-updated", handleUsageOrderUpdated);
if (props.automationWorkspace) {
const scopeSequence = periodScopeSequence;
await loadAutomationControlState();
if (scopeSequence !== periodScopeSequence) return;
await recoverActiveAutopilotRun();
}
});
const filterValues = ref({
import_state: "",
resolution_state: "",
certainty: "",
planned_action: "",
});
const applyAutopilotFilter = (key, value) => {
filterValues.value[key] = value;
setFilter(key, value, false);
setPage(1);
loadList();
};
const clearAutopilotFilters = () => {
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, tone: "is-info" },
{ key: "updated", count: summary.value.updated, tone: "is-info" },
{ key: "unchanged", count: summary.value.unchanged, tone: "is-light" },
{ key: "already_linked", count: summary.value.already_linked, tone: "is-success" },
{ key: "auto_linked", count: summary.value.auto_linked, tone: "is-success" },
{ key: "auto_created", count: summary.value.auto_created, tone: "is-success" },
{ key: "certain", count: summary.value.certain, tone: "is-success" },
{ key: "uncertain", count: summary.value.uncertain, tone: "is-warning" },
{ key: "needs_review", count: summary.value.needs_review, tone: "is-warning" },
{ key: "blocked", count: summary.value.blocked, tone: "is-danger" },
{ key: "invalid", count: summary.value.invalid, tone: "is-danger" },
{ key: "ignored", count: summary.value.ignored, tone: "is-light" },
{ key: "failed", count: summary.value.failed, tone: "is-danger" },
]);
const runProgress = computed(() => xlvaskAutopilotRunProgress(autopilotRun.value));
const runStatusLabel = computed(() => {
if (!autopilotRun.value) return "";
const phase = autopilotRun.value.phase || autopilotRun.value.status;
return t(`invoicing_period.xlvask_autopilot.run_phases.${phase}`, phase);
});
const reviewProgressPercent = (action) => {
const progress = readiness.value.review_progress[action];
if (!progress?.target) return 0;
return Math.min(100, Math.round((progress.reviewed / progress.target) * 100));
};
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;
});
</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 v-if="props.automationWorkspace && capabilities.can_dry_run" class="level-item">
<LoadButtonWhileAwait
class="is-dark"
:isLoading="isImportLoading"
:disabled="!canStartDryRun"
:loadFunction="() => startAutopilotRun('dry_run')"
icon="fas fa-search"
data-testid="xlvask-autopilot-dry-run"
>{{ t('invoicing_period.xlvask_autopilot.controls.analyze') }}</LoadButtonWhileAwait>
</div>
<div v-if="props.automationWorkspace && capabilities.can_execute" class="level-item">
<LoadButtonWhileAwait
class="is-danger"
:isLoading="isImportLoading"
:disabled="!canStartExecute"
:loadFunction="() => startAutopilotRun('execute')"
icon="fas fa-robot"
data-testid="xlvask-autopilot-execute"
>{{ t('invoicing_period.xlvask_autopilot.controls.execute') }}</LoadButtonWhileAwait>
</div>
<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
v-if="props.automationWorkspace"
class="box xlvask-automation-controls mb-4"
data-testid="xlvask-automation-controls"
aria-labelledby="xlvask-automation-controls-title"
>
<div class="is-flex is-justify-content-space-between is-align-items-flex-start is-flex-wrap-wrap mb-3">
<div>
<h2 id="xlvask-automation-controls-title" class="title is-6 mb-1">
{{ t('invoicing_period.xlvask_autopilot.controls.title') }}
</h2>
<p class="is-size-7 has-text-grey mb-0">
{{ t('invoicing_period.xlvask_autopilot.controls.stage', { stage: readiness.effective_stage }) }}
</p>
</div>
<span v-if="controlStateLoading" class="icon has-text-grey" role="status" :aria-label="t('common.loading')">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
</span>
<div v-else class="buttons mb-0">
<button
v-if="capabilities.can_manage_policy && nextPolicyStage"
type="button"
class="button is-small is-link is-light"
:class="{ 'is-loading': policyActionLoading }"
:disabled="policyActionLoading || !canAdvancePolicy"
data-testid="xlvask-policy-advance"
@click="applyPolicyPreview"
>{{ t('invoicing_period.xlvask_autopilot.controls.policy_advance', { stage: nextPolicyStage }) }}</button>
<button
v-if="capabilities.can_halt"
type="button"
class="button is-small is-danger"
:class="{ 'is-loading': policyActionLoading }"
:disabled="policyActionLoading"
data-testid="xlvask-policy-halt"
@click="haltAutomation"
>{{ t('invoicing_period.xlvask_autopilot.controls.halt') }}</button>
</div>
</div>
<p v-if="controlStateError" class="notification is-danger is-light py-2 px-3" role="alert">{{ controlStateError }}</p>
<template v-else-if="capabilities.can_view">
<p v-if="adminReadinessError" class="notification is-warning is-light py-2 px-3" role="alert">
{{ adminReadinessError }}
</p>
<div class="tags mb-3" data-testid="xlvask-automation-readiness">
<span class="tag" :class="readiness.ready ? 'is-success' : 'is-danger'">
{{ readiness.ready ? t('invoicing_period.xlvask_autopilot.controls.ready') : t('invoicing_period.xlvask_autopilot.controls.not_ready') }}
</span>
<span class="tag" :class="readiness.worker_healthy ? 'is-success is-light' : 'is-warning is-light'">
{{ readiness.worker_healthy ? t('invoicing_period.xlvask_autopilot.controls.worker_healthy') : t('invoicing_period.xlvask_autopilot.controls.worker_unhealthy') }}
</span>
<span v-if="readiness.model" class="tag is-light">{{ t('invoicing_period.xlvask_autopilot.model') }}: {{ readiness.model }}</span>
<span v-if="readiness.policy_version" class="tag is-light">{{ t('invoicing_period.xlvask_autopilot.policy_version') }}: {{ readiness.policy_version }}</span>
</div>
<ul v-if="readiness.blocked_reasons.length || capabilities.blocked_reasons.length" class="notification is-warning is-light py-2 px-4" role="status">
<li v-for="reason in [...readiness.blocked_reasons, ...capabilities.blocked_reasons]" :key="reason">{{ reason }}</li>
</ul>
<div class="columns is-multiline is-variable is-2">
<div v-for="action in ['attach_order', 'create_order']" :key="action" class="column is-12-tablet is-6-desktop">
<div class="xlvask-review-progress">
<div class="is-flex is-justify-content-space-between is-size-7 mb-1">
<strong>{{ t(`invoicing_period.xlvask_autopilot.actions.${action}`) }}</strong>
<span>{{ readiness.review_progress[action].reviewed }}/{{ readiness.review_progress[action].target }}</span>
</div>
<progress class="progress is-small is-info mb-1" :value="reviewProgressPercent(action)" max="100">
{{ reviewProgressPercent(action) }}%
</progress>
<p class="is-size-7 has-text-grey mb-0">
{{ t('invoicing_period.xlvask_autopilot.controls.budget', {
global: readiness.budgets[action].remaining_global,
hall: readiness.budgets[action].remaining_hall,
}) }}
</p>
</div>
</div>
</div>
</template>
<p v-else-if="!controlStateLoading" class="notification is-warning is-light py-2 px-3" role="status">
{{ t('invoicing_period.xlvask_autopilot.controls.no_access') }}
</p>
</section>
<article
v-if="props.automationWorkspace && (autopilotRun || runError)"
class="message xlvask-autopilot-run-status"
:class="{ 'is-info': isXlvaskAutopilotRunActive(autopilotRun), 'is-success': autopilotRun?.status === 'completed', 'is-warning': autopilotRun?.status === 'completed_with_warnings', 'is-danger': runError || autopilotRun?.status === 'failed' }"
data-testid="xlvask-autopilot-run-status"
>
<div class="message-body">
<div class="is-flex is-justify-content-space-between is-flex-wrap-wrap mb-2">
<strong>{{ runError || runStatusLabel }}</strong>
<span v-if="autopilotRun?.id" class="is-size-7 has-text-grey">#{{ autopilotRun.id }}</span>
</div>
<progress
v-if="isXlvaskAutopilotRunActive(autopilotRun)"
class="progress is-info is-small mb-2"
:value="runProgress"
max="100"
>{{ runProgress }}%</progress>
<p v-if="autopilotRun?.total" class="is-size-7 mb-0">
{{ t('invoicing_period.xlvask_autopilot.run_progress', { processed: autopilotRun.processed, total: autopilotRun.total }) }}
</p>
<button
v-if="runPollingPaused && isXlvaskAutopilotRunActive(autopilotRun)"
type="button"
class="button is-small is-info is-light mt-2"
data-testid="xlvask-autopilot-resume"
@click="resumeAutopilotPolling()"
>{{ t('invoicing_period.xlvask_autopilot.resume_status') }}</button>
<p v-if="autopilotRun?.warning" class="has-text-warning-dark mt-2 mb-0">{{ autopilotRun.warning }}</p>
<p v-if="autopilotRun?.error" class="has-text-danger mt-2 mb-0">{{ autopilotRun.error }}</p>
</div>
</article>
<section class="xlvask-autopilot-summary mb-4" aria-live="polite" data-testid="xlvask-autopilot-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_autopilot.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-autopilot-summary-chip"
:class="card.tone"
:data-testid="'xlvask-summary-' + card.key"
>
{{ t(`invoicing_period.xlvask_autopilot.states.${card.key}`) }}: {{ card.count }}
</span>
</div>
</section>
<section class="box xlvask-autopilot-filters mb-4" data-testid="xlvask-autopilot-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_autopilot.filters.import_state') }}</label>
<div class="select is-small is-fullwidth">
<select id="xlvask-import-state-filter" :value="filterValues.import_state" @change="applyAutopilotFilter('import_state', $event.target.value)">
<option value="">{{ t('invoicing_period.xlvask_autopilot.filters.all') }}</option>
<option v-for="state in ['new', 'updated', 'unchanged', 'invalid']" :key="state" :value="state">{{ t(`invoicing_period.xlvask_autopilot.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_autopilot.filters.resolution_state') }}</label>
<div class="select is-small is-fullwidth">
<select id="xlvask-resolution-state-filter" :value="filterValues.resolution_state" @change="applyAutopilotFilter('resolution_state', $event.target.value)">
<option value="">{{ t('invoicing_period.xlvask_autopilot.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_autopilot.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_autopilot.filters.certainty') }}</label>
<div class="select is-small is-fullwidth">
<select id="xlvask-certainty-filter" :value="filterValues.certainty" @change="applyAutopilotFilter('certainty', $event.target.value)">
<option value="">{{ t('invoicing_period.xlvask_autopilot.filters.all') }}</option>
<option v-for="state in ['certain', 'uncertain', 'none']" :key="state" :value="state">{{ t(`invoicing_period.xlvask_autopilot.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_autopilot.filters.planned_action') }}</label>
<div class="select is-small is-fullwidth">
<select id="xlvask-action-filter" :value="filterValues.planned_action" @change="applyAutopilotFilter('planned_action', $event.target.value)">
<option value="">{{ t('invoicing_period.xlvask_autopilot.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_autopilot.actions.${action}`) }}</option>
</select>
</div>
</div>
</div>
<button class="button is-small is-light" type="button" @click="clearAutopilotFilters">
{{ t('invoicing_period.xlvask_autopilot.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>
<!-- Sort by created_at -->
<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_autopilot.filters.title') }}</label>
<div class="control">
<b-switch
size="is-small"
type="is-link"
v-model="showOnlyUnattachedVehicle"
>
{{ t('invoicing_period.xlvask_autopilot.filters.unattached_only') }}
</b-switch>
</div>
</div>
<!--
<PaginationDisplayTemplateDate
:label="SessionUser.objects.global.language.date_from"
@update:date="actions.date.from.select"
v-bind:model-value="dateFrom"
/>
<PaginationDisplayTemplateDate
:label="SessionUser.objects.global.language.date_to"
@update:date="actions.date.to.select"
v-bind:model-value="dateTo"
/>
-->
</template>
</PaginationDisplay>
<ShowErrorField v-else error="paginatedGetRequest"/>
<XlvaskUsageOrdersTable
:objects="list"
:show-only-unattached-vehicle="showOnlyUnattachedVehicle"
:highlight-usage-log-id="props.highlightUsageLogId"
:allow-select-multiple="props.automationWorkspace && capabilities.can_review"
:allow-review-actions="props.automationWorkspace && capabilities.can_review"
:allow-adjudication-actions="props.automationWorkspace && capabilities.can_manage_policy"
/>
<PaginationNavigation
v-if="!props.loadAllAtOnce"
:currentPage="metaCurrentPage"
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
:loadFunction="loadList"
:setPage="setPage"
:isLoading="isLoading"
/>
</template>
<style scoped>
.xlvask-autopilot-summary .tags {
gap: 0.35rem;
}
.xlvask-autopilot-summary-chip {
height: auto;
min-height: 1.75rem;
white-space: normal;
}
.xlvask-autopilot-filters {
padding: 0.9rem;
}
.xlvask-autopilot-run-status .message-body {
padding: 0.85rem 1rem;
}
.xlvask-review-progress {
border: 1px solid #e7e7e7;
border-radius: 6px;
padding: 0.75rem;
}
</style>