Compare commits

..
Author SHA1 Message Date
Jeppe B 83935af973 Stabilize POS email notification E2E setup 2026-06-01 16:45:40 +02:00
Jeppe B 37973f8fcb Add x-api-coverage happy path flag to listOrders operation 2026-06-01 13:31:48 +02:00
Jeppe B acf19c281a Add email notification actions for booking confirmations and wash certificates 2026-06-01 13:24:29 +02:00
Jeppe B 683fce32da Fix POS step 1 customer lookup loop 2026-06-01 12:41:08 +02:00
Jeppe B 4c9ba7c30d Add unit tests for Playwright full-slice ownership 2026-06-01 12:28:42 +02:00
Jeppe B ca1f3ab477 Merge pull request #55 from copenhagentruckwash/codex/frontend-release-gate-coolify-20260530
Fix frontend release gate for Coolify deploys
2026-05-31 10:22:29 +02:00
Jeppe B 2ab09076f5 Stabilize edge terminal smoke navigation 2026-05-30 21:51:07 +02:00
Jeppe B 97e17fadd4 Respect mobile self-serve studio layout in e2e 2026-05-30 20:33:27 +02:00
Jeppe B ef780e5542 Keep completed self-serve wash step visible 2026-05-30 19:44:52 +02:00
Jeppe B 931c31c7be Stabilize self-serve question loading layout 2026-05-30 18:37:37 +02:00
Jeppe B 25f2be875c Wait for self-serve completion command response 2026-05-30 17:45:06 +02:00
Jeppe B ff70326665 Stabilize frontend PR smoke gates 2026-05-30 16:58:04 +02:00
Jeppe B 52ed49211a Fix frontend release gate for Coolify deploys 2026-05-30 15:39:03 +02:00
Jeppe B 0565fc2a7c Merge pull request #54 from copenhagentruckwash/codex/master-tests-pass-frontend-20260528
[codex] Keep user vehicle actions in wheel menu
2026-05-29 16:33:25 +02:00
Jeppe B d30273cda8 Map user vehicle changes to PR Playwright coverage 2026-05-29 14:46:52 +02:00
Jeppe B b893e69b54 Keep user vehicle actions in wheel menu 2026-05-28 23:35:14 +02:00
Jeppe B eabb12ef97 Merge pull request #53 from copenhagentruckwash/fix-issues-and-verify-with-tests
Format DepartmentSelfServeStudio.vue and update vehicle actions test to reflect wheel menu behavior
2026-05-28 19:52:53 +02:00
Jeppe Bundgaard 225741cf7b Refactor unit tests and clean up Vue component formatting
- Update `self-serve-studio-managed-inspector.spec.js` to improve node kind checks.
- Tidy up and standardize `SessionUser.request` calls in `vehiclesTable.vue`.
- Format code consistently in tables, conditions, and task specs for clarity and maintenance.
2026-05-28 19:47:02 +02:00
39 changed files with 1167 additions and 324 deletions
+6 -26
View File
@@ -18,15 +18,14 @@ jobs:
build-upload-and-verify:
runs-on: [self-hosted, Linux, X64, default]
env:
RELEASE_BASE_URL: https://dev.truckwash.io
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
RELEASE_WAIT_INITIAL_SECONDS: 30
RELEASE_WAIT_TIMEOUT_SECONDS: 300
RELEASE_WAIT_INITIAL_SECONDS: 45
RELEASE_WAIT_TIMEOUT_SECONDS: 600
RELEASE_POLL_INTERVAL_SECONDS: 10
steps:
- name: Checkout repository
@@ -72,26 +71,7 @@ jobs:
path: dist
retention-days: 14
- name: Install lftp
run: |
if ! command -v lftp >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y lftp
fi
- name: Upload hashed assets before release metadata
run: |
test -n "$RELEASE_DEPLOY_HOST" || (echo "RELEASE_DEPLOY_HOST is required" >&2; exit 1)
test -n "$RELEASE_DEPLOY_USER" || (echo "RELEASE_DEPLOY_USER is required" >&2; exit 1)
test -n "$RELEASE_DEPLOY_PASSWORD" || (echo "RELEASE_DEPLOY_PASSWORD is required" >&2; exit 1)
npm run release:upload:lftp
env:
RELEASE_DEPLOY_HOST: ${{ secrets.RELEASE_DEPLOY_HOST }}
RELEASE_DEPLOY_USER: ${{ secrets.RELEASE_DEPLOY_USER }}
RELEASE_DEPLOY_PASSWORD: ${{ secrets.RELEASE_DEPLOY_PASSWORD }}
RELEASE_DEPLOY_REMOTE_ROOT: ${{ secrets.RELEASE_DEPLOY_REMOTE_ROOT }}
- name: Wait for exact uploaded build
- name: Wait for Coolify release artifact
run: npm run release:verify-upload
- name: Public live Playwright gate
@@ -118,7 +98,7 @@ jobs:
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
+24 -4
View File
@@ -65,15 +65,35 @@ jobs:
if: github.event_name != 'schedule'
needs: build-and-unit
runs-on: [self-hosted, Linux, X64, default]
env:
PLAYWRIGHT_PR_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
PLAYWRIGHT_PR_HEAD: ${{ github.sha }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Resolve Playwright diff refs
id: playwright-diff
shell: bash
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
EVENT_NAME: ${{ github.event_name }}
HEAD_SHA: ${{ github.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
zero_sha="0000000000000000000000000000000000000000"
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_BASE_SHA" ]]; then
base_ref="$PR_BASE_SHA"
elif [[ -z "$PUSH_BEFORE_SHA" || "$PUSH_BEFORE_SHA" == "$zero_sha" ]]; then
git fetch --no-tags --prune origin "$DEFAULT_BRANCH"
base_ref="origin/$DEFAULT_BRANCH"
else
base_ref="$PUSH_BEFORE_SHA"
fi
echo "base=$base_ref" >> "$GITHUB_OUTPUT"
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@v5
with:
@@ -87,7 +107,7 @@ jobs:
run: npx playwright install --with-deps chromium
- name: Run Playwright PR tests
run: npm run test:e2e:pr -- --base="$PLAYWRIGHT_PR_BASE" --head="$PLAYWRIGHT_PR_HEAD"
run: npm run test:e2e:pr -- --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
- name: Upload Playwright report
if: failure()
+8
View File
@@ -8,6 +8,14 @@ COPY package*.json ./
RUN npm ci --ignore-scripts
COPY . .
ARG SOURCE_COMMIT
ARG RELEASE_COMMIT_SHA
ARG COMMIT_SHA
ARG GITHUB_SHA
ENV SOURCE_COMMIT=$SOURCE_COMMIT
ENV RELEASE_COMMIT_SHA=$RELEASE_COMMIT_SHA
ENV COMMIT_SHA=$COMMIT_SHA
ENV GITHUB_SHA=$GITHUB_SHA
RUN npm run build
FROM nginx:1.27-alpine
+2
View File
@@ -3056,6 +3056,8 @@ paths:
get:
tags:
- Orders
x-api-coverage:
happy: true
summary: List orders
description: Retrieve a paginated list of orders
operationId: listOrders
+6
View File
@@ -34,6 +34,12 @@ export const sourceMappings = [
],
projects: chromiumProjects,
},
{
name: "user-vehicles",
patterns: [/^src\/components\/displays\/user\/vehicles\//u, /^src\/views\/dashboards\/userDashboard\/vehicles\//u],
specs: ["tests/e2e/userVehicles.spec.ts"],
projects: chromiumProjects,
},
{
name: "pos",
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u],
+1 -1
View File
@@ -120,7 +120,7 @@ async function verifyShell(baseUrl, shellPath) {
async function verifyRelease(baseUrl) {
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || "";
const manifest = await fetchJson(baseUrl, "release-manifest.json");
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
+42 -8
View File
@@ -1,25 +1,34 @@
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const workingDirectory = process.cwd();
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
const execFileAsync = promisify(execFile);
const roles = ["customer", "subuser", "admin", "superuser"];
export const roles = ["customer", "subuser", "admin", "superuser"];
const listEntryPattern = /^\s+\[[^\]]+\]\s+\s+(.+?):(\d+):(\d+)\s+\s+(.+)\s*$/u;
const ownedFilesByRole = {
export const ownedFilesByRole = {
customer: [
"auth.smoke.spec.js",
"booking-selfserve.smoke.spec.js",
"connectivityIssue.spec.ts",
"example.spec.ts",
"guest-book-wash-mobile.spec.ts",
"i18n-catalog-switch.spec.ts",
"i18n-v2-integrity.spec.ts",
"i18n.smoke.spec.ts",
"i18n.views.spec.ts",
"navigation.smoke.spec.js",
"qr-new-customer-layout.spec.ts",
"release-bootstrap.spec.js",
"release-channel-switched.spec.js",
"release-channel-unavailable.spec.js",
"release-update-widget.spec.js",
"self-serve-wash.spec.js",
"session-release-runtime.spec.ts",
"user-orders.spec.ts",
"userBookings.spec.ts",
"userBookWash.spec.ts",
@@ -53,6 +62,7 @@ const ownedFilesByRole = {
"assign-draft-order-modal-layout.spec.ts",
"change-invoice-collection.spec.ts",
"change-customer.spec.ts",
"default-mobile-redirect.spec.ts",
"economic-queue-workflow.spec.js",
"pos-customer-rules.spec.js",
"pos-desktop-card-payments.spec.js",
@@ -62,18 +72,23 @@ const ownedFilesByRole = {
"pos.visual.spec.js",
],
superuser: [
"coolify-infrastructure.spec.js",
"edge-gateways.fleet-outline.spec.js",
"edge-gateways.routes.spec.js",
"edge-gateways.smoke.spec.js",
"edge-gateways.visual.spec.js",
"errorReports.spec.ts",
"failover-config.source.spec.ts",
"invoice-distribution.smoke.spec.js",
"invoice-transfer-monitor.spec.ts",
"invoice-transfer-queue-history.spec.js",
"invoicing-period.smoke.spec.js",
"issue-repro-duplicates-date.spec.js",
"release-manager.spec.js",
"self-serve-sessions.spec.js",
"self-serve-studio-audit-navigation.spec.js",
"self-serve-studio-flow.spec.js",
"session-bootstrap.spec.ts",
"superuser-bookings.spec.ts",
"superuser-customer-complaints.spec.ts",
"superuser-customers-mass-import.spec.ts",
@@ -84,12 +99,13 @@ const ownedFilesByRole = {
"superuser-drafts.spec.ts",
"superuser-products-layout.spec.ts",
"superuser-system-status.smoke.spec.js",
"superuser-users.spec.ts",
"superuser-vehicles.smoke.spec.js",
"workfeed-config.smoke.spec.js",
],
};
const titleRules = [
export const titleRules = [
{ role: "customer", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[User\]/u] },
{ role: "subuser", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Subuser\]/u] },
{ role: "admin", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Operator\]/u] },
@@ -116,6 +132,11 @@ const titleRules = [
file: "subuser-management.spec.ts",
patterns: [/^authorized subuser managers/i, /^subuser self-service/i, /^subusers without /i],
},
{
role: "superuser",
file: "subuser-management.spec.ts",
patterns: [/^superusers can list and invite chauffeurs/i],
},
{ role: "subuser", file: "userProfileVisibility.spec.ts", patterns: [/\[PROFILE\]\[Subuser\]\[Visibility\]/u] },
];
@@ -200,7 +221,7 @@ function toBaseName(filePath) {
return filePath.split(/[\\/]/u).pop() || filePath;
}
function parseListedTests(listOutput) {
export function parseListedTests(listOutput) {
return listOutput
.split(/\r?\n/u)
.map((line) => {
@@ -222,7 +243,7 @@ function parseListedTests(listOutput) {
.filter(Boolean);
}
function classifyTest(testEntry) {
export function classifyTest(testEntry) {
const matches = new Set();
const directOwner = ownedFileToRole.get(testEntry.fileName);
@@ -309,8 +330,8 @@ async function runPlaywright(project, testListPath, forwardedArgs) {
});
}
async function main() {
const { options, forwardedArgs } = parseCliArgs(process.argv.slice(2));
export async function main(argv = process.argv.slice(2)) {
const { options, forwardedArgs } = parseCliArgs(argv);
validateOptions(options, forwardedArgs);
const listOutput = await listProjectTests(options.project, forwardedArgs);
@@ -342,4 +363,17 @@ async function main() {
await runPlaywright(options.project, testListPath, forwardedArgs);
}
await main();
async function isDirectRun() {
if (!process.argv[1]) {
return false;
}
const currentPath = await fs.realpath(fileURLToPath(import.meta.url));
const invokedPath = await fs.realpath(process.argv[1]).catch(() => path.resolve(process.argv[1]));
return currentPath === invokedPath;
}
if (await isDirectRun()) {
await main();
}
@@ -63,6 +63,10 @@ const props = defineProps({
type: String,
default: null,
},
reg_3: {
type: String,
default: null,
},
order_booking_id: {
type: Number,
default: null,
@@ -917,6 +921,15 @@ const getAttachmentLabel = (attachment) => {
);
};
const isWashCertificateAttachment = (attachment) => {
const marker = String(getAttachmentOtherPayload(attachment) || "").trim().toUpperCase();
if (marker === "WASH_CERTIFICATE") {
return true;
}
return /(?:^|[/\\])wash[_-]?certificate.*\.pdf$/i.test(getAttachmentLabel(attachment));
};
const getAttachmentExtension = (attachment) => {
const match = String(getAttachmentLabel(attachment))
.toLowerCase()
@@ -1045,6 +1058,10 @@ const activeAttachmentPreviewSource = computed(() => {
return previewSourcesById.value[activeAttachment.value.id] ?? null;
});
const hasWashCertificateAttachment = computed(() =>
attachmentsFromOrder.value.some((attachment) => isWashCertificateAttachment(attachment))
);
const hasCachedPreviewSource = (attachmentId) => {
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
};
@@ -1609,6 +1626,48 @@ const showCompleteOrderBookingConfirmation = async () => {
}
};
const showEmailNotificationActionResult = async (requestAction, successKey, errorKey) => {
try {
await requestAction();
await Swal.fire({
title: t(successKey),
icon: "success",
showConfirmButton: false,
timer: 2000,
heightAuto: false,
});
} catch (error) {
console.error(error);
await Swal.fire({
title: t("common.error"),
text: [t(errorKey), SessionUser.functions.parseErrorMessage?.(error)].filter(Boolean).join(": "),
icon: "error",
heightAuto: false,
});
}
};
const resendBookingConfirmation = () =>
showEmailNotificationActionResult(
() => SessionUser.objects.order_bookings.functions.resendBookingConfirmation(props.order_booking_id),
"admin.pos.settings_wheel.resend_booking_confirmation_success",
"admin.pos.settings_wheel.resend_booking_confirmation_error"
);
const resendBookingCompletionConfirmation = () =>
showEmailNotificationActionResult(
() => SessionUser.objects.order_bookings.functions.resendBookingCompletionConfirmation(props.order_booking_id),
"admin.pos.settings_wheel.resend_booking_completion_confirmation_success",
"admin.pos.settings_wheel.resend_booking_completion_confirmation_error"
);
const resendWashCertificate = () =>
showEmailNotificationActionResult(
() => SessionUser.objects.orders.functions.resendWashCertificate(props.order_id),
"admin.pos.settings_wheel.resend_wash_certificate_success",
"admin.pos.settings_wheel.resend_wash_certificate_error"
);
const getAvailableInvoiceCollectionsForCustomer = async (customerNumber) => {
const normalizedCustomerNumber = normalizePositiveInteger(customerNumber);
if (!normalizedCustomerNumber) {
@@ -1835,6 +1894,40 @@ const flatBuiltInMenuSections = computed(() => {
}
}
if (props.order_booking_id || hasWashCertificateAttachment.value) {
const emailNotificationsSection = buildMenuSection(
"email-notifications",
t("admin.pos.settings_wheel.email_notifications_section"),
[
props.order_booking_id
? buildMenuAction("email-notifications-resend-booking-confirmation", {
icon: "fas fa-envelope",
label: t("admin.pos.settings_wheel.resend_booking_confirmation"),
clickAction: resendBookingConfirmation,
})
: null,
props.order_booking_id && hasWashCertificateAttachment.value
? buildMenuAction("email-notifications-resend-booking-completion-confirmation", {
icon: "fas fa-envelope-open-text",
label: t("admin.pos.settings_wheel.resend_booking_completion_confirmation"),
clickAction: resendBookingCompletionConfirmation,
})
: null,
!props.order_booking_id && hasWashCertificateAttachment.value
? buildMenuAction("email-notifications-resend-wash-certificate", {
icon: "fas fa-file-pdf",
label: t("admin.pos.settings_wheel.resend_wash_certificate"),
clickAction: resendWashCertificate,
})
: null,
]
);
if (emailNotificationsSection) {
sections.push(emailNotificationsSection);
}
}
if (props.invoice_collection_id && SessionUser.canAccessSuperUser()) {
const invoiceCollectionLinkSection = buildMenuSection(
"invoice-collection-link",
@@ -2136,10 +2229,10 @@ const flatBuiltInMenuSections = computed(() => {
}
}
if (props.reg_1 || props.reg_2) {
if (props.reg_1 || props.reg_2 || props.reg_3) {
const vehicleSection = buildMenuSection(
"vehicle",
props.reg_1 && props.reg_2
[props.reg_1, props.reg_2, props.reg_3].filter(Boolean).length > 1
? SessionUser.objects.vehicles.meta.labels.multiple
: SessionUser.objects.vehicles.meta.labels.single,
[
@@ -2157,6 +2250,13 @@ const flatBuiltInMenuSections = computed(() => {
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_2, true),
})
: null,
props.reg_3 && SessionUser.canAccessSuperUser()
? buildMenuAction("vehicle-reg-3", {
icon: "fas fa-car",
label: t("admin.pos.settings_wheel.view_vehicle_new_tab", { reg: props.reg_3 }),
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_3, true),
})
: null,
]
);
@@ -1263,6 +1263,9 @@ const formatCashierName = (order) => {
v-bind:customer_number="order.customer_id"
v-bind:department_id="order.department_id"
v-bind:reg_1="order.reg_1"
v-bind:reg_2="order.reg_2"
v-bind:reg_3="order.reg_3"
v-bind:order_booking_id="order.booking_id"
:refreshFunction="loadList"
@deleted="loadList()"
@flag-created="emitFlagCreated"
@@ -1506,6 +1509,9 @@ const formatCashierName = (order) => {
v-bind:customer_number="order.customer_id"
v-bind:department_id="order.department_id"
v-bind:reg_1="order.reg_1"
v-bind:reg_2="order.reg_2"
v-bind:reg_3="order.reg_3"
v-bind:order_booking_id="order.booking_id"
:refreshFunction="loadList"
@deleted="loadList()"
@flag-created="emitFlagCreated"
@@ -43,9 +43,10 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
<div class="self-serve-questions-status-slot" aria-live="polite">
<div
v-if="isLoading"
class="notification is-info is-light py-2 px-3 mb-0"
:class="{ 'is-invisible': !isLoading }"
data-testid="self-serve-questions-inline-loading"
:aria-hidden="!isLoading"
>
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-small" />
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
@@ -72,4 +73,8 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
.self-serve-questions-status-slot .notification {
width: 100%;
}
.self-serve-questions-status-slot .notification.is-invisible {
visibility: hidden;
}
</style>
@@ -1,10 +1,10 @@
<script setup>
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import { ref } from "vue";
import { useI18n } from 'vue-i18n';
import { useI18n } from "vue-i18n";
const { t } = useI18n();
import { departments, getDepartments} from "@/components/pagination/departmentTabs.vue";
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { loadList } from "@/components/pagination/paginatedList.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
@@ -27,16 +27,15 @@ const props = defineProps({
reloadList: {
type: Function,
required: false,
default: null
default: null,
},
add_other_customer_id: {
type: Number,
required: false,
default: null
}
default: null,
},
});
const reload = () => {
// Load the list of vehicles
if (props.reloadList) {
@@ -59,13 +58,9 @@ if (departments.value.length === 0) {
const onClickListAddons = (vehicleId) => {
// Redirect to the product addons page
console.log("Fetching product addons for vehicle: " + vehicleId);
SessionUser.request(
'/vehicles/addons/available',
'GET',
{
id: vehicleId
},
)
SessionUser.request("/vehicles/addons/available", "GET", {
id: vehicleId,
});
};
const vehicleAddons = ref(null);
@@ -76,13 +71,9 @@ const getVehicleAvailableAddons = (vehicleId, forceReload = false) => {
// Get the vehicle addons from the server
if (vehicleAddons.value === null) {
vehicleAddons.value = [];
SessionUser.request(
'/vehicles/addons/available',
'GET',
{
id: vehicleId
},
).then((response) => {
SessionUser.request("/vehicles/addons/available", "GET", {
id: vehicleId,
}).then((response) => {
console.log("Vehicle addons: ", response.data.data);
vehicleAddons.value = response.data.data;
});
@@ -93,14 +84,10 @@ const getVehicleAvailableAddons = (vehicleId, forceReload = false) => {
const toggleVehicleAddon = (vehicleId, addonId) => {
// Toggle the vehicle addon
SessionUser.request(
'/vehicles/addons/toggle',
'POST',
{
vehicle_id: vehicleId,
addon_id: addonId
},
).then(() => {
SessionUser.request("/vehicles/addons/toggle", "POST", {
vehicle_id: vehicleId,
addon_id: addonId,
}).then(() => {
reload();
});
};
@@ -108,9 +95,9 @@ const toggleVehicleAddon = (vehicleId, addonId) => {
const getVehicleAddonToggleIcon = (vehicleAddon) => {
// Check if the vehicle addon is applied
if (isVehicleAddonApplied(vehicleAddon)) {
return 'fas fa-minus';
return "fas fa-minus";
} else {
return 'fas fa-plus';
return "fas fa-plus";
}
};
@@ -137,140 +124,142 @@ const getProductOptionsLabel = (vehicle) => {
<div class="table-container" data-testid="user-vehicles-table-container">
<table class="table is-fullwidth" data-testid="user-vehicles-table">
<thead>
<tr>
<th v-if="!props.compact">{{ $t('objects.columns.id') }}</th>
<th v-if="!props.compact">{{ $t('objects.columns.customer_id') }}</th>
<th>{{ $t('objects.bookings.columns.reg_1') }}</th>
<th>{{ $t('vehicles.type') }}</th>
<th>{{ $t('objects.vehicles.columns.wash_subscription') }}</th>
<th v-if="!props.compact">{{ SessionUser.objects.product_options.meta.title }}</th>
<th v-if="!props.compact">{{ $t('common.reference') }}</th>
<th v-if="!props.compact"></th>
</tr>
<tr>
<th v-if="!props.compact">{{ $t("objects.columns.id") }}</th>
<th v-if="!props.compact">{{ $t("objects.columns.customer_id") }}</th>
<th>{{ $t("objects.bookings.columns.reg_1") }}</th>
<th>{{ $t("vehicles.type") }}</th>
<th>{{ $t("objects.vehicles.columns.wash_subscription") }}</th>
<th v-if="!props.compact">{{ SessionUser.objects.product_options.meta.title }}</th>
<th v-if="!props.compact">{{ $t("common.reference") }}</th>
<th v-if="!props.compact"></th>
</tr>
</thead>
<tbody>
<tr v-for="object in props.vehicles" :key="object.id">
<!-- ID -->
<EditableTableColumn
v-if="!props.compact"
:object="object"
:loadList="reload"
column="id"
/>
<!-- Customer ID -->
<EditableTableColumn
v-if="!props.compact"
:object="object"
:loadList="reload"
column="customer_id"
/>
<!-- Reg -->
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="reg"
/>
<!-- Type -->
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="type"
:parse-function="(value) => {
return SessionUser.objects.products.functions.getProductName(value, 'Ukendt');
}"
/>
<!-- Wash Subscription -->
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="wash_subscription"
:parse-function="(value) => {
return value ? t('common.yes') : t('common.no');
}"
/>
<!-- Product Options, if the wash subscription is set to true -->
<td v-if="!props.compact">
<template v-if="object.wash_subscription">
<!-- Enabled subscription -->
<ActionSettingsWheelButton
:label="getProductOptionsLabel(object)"
:icon="SessionUser.objects.product_options.meta.icon"
@mouseenter="getVehicleAvailableAddons(object.id, true)"
>
<template #actions>
<!-- List addons -->
<template v-for="vehicleAddon in vehicleAddons" :key="vehicleAddon.id">
<ActionSettingsWheelItem
:label="(isVehicleAddonApplied(vehicleAddon) ? SessionUser.objects.global.language.remove : SessionUser.objects.global.language.add) + ' ' + vehicleAddon.name"
:icon="getVehicleAddonToggleIcon(vehicleAddon)"
:click-action="() => toggleVehicleAddon(object.id, vehicleAddon.id)"
:template="isVehicleAddonApplied(vehicleAddon) ? 'danger' : 'default'"
/>
<tr v-for="object in props.vehicles" :key="object.id">
<!-- ID -->
<EditableTableColumn v-if="!props.compact" :object="object" :loadList="reload" column="id" />
<!-- Customer ID -->
<EditableTableColumn v-if="!props.compact" :object="object" :loadList="reload" column="customer_id" />
<!-- Reg -->
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="reg"
/>
<!-- Type -->
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="type"
:parse-function="
(value) => {
return SessionUser.objects.products.functions.getProductName(value, 'Ukendt');
}
"
/>
<!-- Wash Subscription -->
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="wash_subscription"
:parse-function="
(value) => {
return value ? t('common.yes') : t('common.no');
}
"
/>
<!-- Product Options, if the wash subscription is set to true -->
<td v-if="!props.compact">
<template v-if="object.wash_subscription">
<!-- Enabled subscription -->
<ActionSettingsWheelButton
:label="getProductOptionsLabel(object)"
:icon="SessionUser.objects.product_options.meta.icon"
@mouseenter="getVehicleAvailableAddons(object.id, true)"
>
<template #actions>
<!-- List addons -->
<template v-for="vehicleAddon in vehicleAddons" :key="vehicleAddon.id">
<ActionSettingsWheelItem
:label="
(isVehicleAddonApplied(vehicleAddon)
? SessionUser.objects.global.language.remove
: SessionUser.objects.global.language.add) +
' ' +
vehicleAddon.name
"
:icon="getVehicleAddonToggleIcon(vehicleAddon)"
:click-action="() => toggleVehicleAddon(object.id, vehicleAddon.id)"
:template="isVehicleAddonApplied(vehicleAddon) ? 'danger' : 'default'"
/>
</template>
<!-- If there are no addons, show a message -->
<ActionSettingsWheelItem
v-if="vehicleAddons ? vehicleAddons.length === 0 : true"
:label="$t('global.no_data')"
icon="fas fa-list"
/>
<!-- Addons -->
</template>
</ActionSettingsWheelButton>
</template>
<!-- If there are no addons, show a message -->
<ActionSettingsWheelItem
v-if="vehicleAddons ? vehicleAddons.length === 0 : true"
:label="$t('global.no_data')"
icon="fas fa-list"
/>
<!-- Addons -->
</template>
</ActionSettingsWheelButton>
</template>
<template v-else>
<!-- Disabled subscription -->
{{ $t('global.no_data') }}
</template>
</td>
<!-- Reference to the vehicle -->
<EditableTableColumn
v-if="!props.compact"
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="reference"
/>
<!-- Actions -->
<td>
<!-- Actions Should not be shown directly! -->
<ActionSettingsWheelButton
v-if="!props.compact"
:user_id="object.user_id"
:reg_1="object.reg"
:displayActionsDirectly="false"
>
<template #actions>
<!-- View (Redirect to the vehicle page) -->
<ActionSettingsWheelItem
:label="$t('global.manage') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-eye"
:click-action="() => redirectUserVehiclePage(object.id)"
/>
<!-- Delete -->
<ActionSettingsWheelItem
:label="$t('global.delete') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-trash"
:template="'danger'"
:click-action="() => SessionUser.objects.vehicles.functions.showDeleteObjectForm(object.id, () => reload())"
/>
</template>
</ActionSettingsWheelButton>
</td>
</tr>
<template v-else>
<!-- Disabled subscription -->
{{ $t("global.no_data") }}
</template>
</td>
<!-- Reference to the vehicle -->
<EditableTableColumn
v-if="!props.compact"
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="reference"
/>
<!-- Actions -->
<td>
<!-- Actions stay grouped behind the wheel menu. -->
<ActionSettingsWheelButton
v-if="!props.compact"
:user_id="object.user_id"
:reg_1="object.reg"
:displayActionsDirectly="false"
>
<template #actions>
<!-- View (Redirect to the vehicle page) -->
<ActionSettingsWheelItem
:label="$t('global.manage') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-eye"
:click-action="() => redirectUserVehiclePage(object.id)"
/>
<!-- Delete -->
<ActionSettingsWheelItem
:label="$t('global.delete') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-trash"
:template="'danger'"
:click-action="
() => SessionUser.objects.vehicles.functions.showDeleteObjectForm(object.id, () => reload())
"
/>
</template>
</ActionSettingsWheelButton>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="10">{{ $t('tables.showing') }} {{ props.vehicles.length }} {{ $t('objects.vehicles.multiple') }}</td>
</tr>
<tr>
<td colspan="10">
{{ $t("tables.showing") }} {{ props.vehicles.length }} {{ $t("objects.vehicles.multiple") }}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -217,6 +217,44 @@ const getVehicleReferenceValue = () => {
return String(vehicleObject.value?.reference ?? "").trim();
};
const getVehicleStateKey = (vehicle) => {
if (!vehicle) {
return "";
}
return [
vehicle?.id ?? "",
normalizePlateValue(vehicle?.reg),
normalizeCustomerNumber(vehicle?.customer_id) ?? "",
vehicle?.type ?? "",
vehicle?.booking_id ?? "",
String(vehicle?.reference ?? "").trim(),
].join("|");
};
const getBookingStateKey = (booking) => {
if (!booking) {
return "";
}
return [
booking?.id ?? "",
getBookingReg1Value(booking),
getBookingReg2Value(booking),
getBookingCustomerNumber(booking) ?? "",
String(booking?.reference_number ?? booking?.reference ?? "").trim(),
String(booking?.po ?? "").trim(),
].join("|");
};
const getBookingMatchesStateKey = (matches) => {
if (!Array.isArray(matches) || matches.length === 0) {
return "";
}
return matches.map((booking) => getBookingStateKey(booking)).join("||");
};
const setVehicleObject = (emittedVehicleObject, options = {}) => {
const normalizedOptions = {
preserveManualReference: true,
@@ -224,17 +262,26 @@ const setVehicleObject = (emittedVehicleObject, options = {}) => {
...options,
};
vehicleObject.value = emittedVehicleObject;
const emittedVehiclePlate = normalizePlateValue(vehicleObject.value?.reg || reg_1.value);
const emittedVehiclePlate = normalizePlateValue(emittedVehicleObject?.reg || reg_1.value);
if (
emittedVehiclePlate &&
skippedDesktopBookingVehiclePlate.value === emittedVehiclePlate &&
!isBookingMarkedVehicle(vehicleObject.value)
!isBookingMarkedVehicle(emittedVehicleObject)
) {
skippedDesktopBookingVehiclePlate.value = "";
}
const currentVehicleKey = getVehicleStateKey(vehicleObject.value);
const nextVehicleKey = getVehicleStateKey(emittedVehicleObject);
if (currentVehicleKey === nextVehicleKey) {
if (normalizedOptions.nextSelectionSource) {
setSelectionSource(normalizedOptions.nextSelectionSource);
}
return;
}
vehicleObject.value = emittedVehicleObject;
const matchedVehicleCustomerNumber = normalizeCustomerNumber(vehicleObject.value?.customer_id);
const selectedCustomerNumber = normalizeCustomerNumber(customer_id.value);
@@ -274,6 +321,10 @@ const setBookingObject = (emittedBookingObject, options = {}) => {
...options,
};
if (getBookingStateKey(bookingObject.value) === getBookingStateKey(emittedBookingObject)) {
return;
}
bookingObject.value = emittedBookingObject;
if (emittedBookingObject && emittedBookingObject.reference_number) {
@@ -284,7 +335,12 @@ const setBookingObject = (emittedBookingObject, options = {}) => {
};
const setBookingMatches = (emittedBookingMatches) => {
bookingMatches.value = Array.isArray(emittedBookingMatches) ? emittedBookingMatches : [];
const nextBookingMatches = Array.isArray(emittedBookingMatches) ? emittedBookingMatches : [];
if (getBookingMatchesStateKey(bookingMatches.value) === getBookingMatchesStateKey(nextBookingMatches)) {
return;
}
bookingMatches.value = nextBookingMatches;
};
const mergeBookingMatchDetails = (booking) => {
@@ -416,6 +416,21 @@ const shouldPreserveCustomerSelection = (plateValue) => {
};
let pendingVehicleCustomerLookup = null;
const lastAutoSyncedVehicleKey = ref("");
const getVehicleAutoSyncKey = (vehicle, plateOverride = null) => {
if (!vehicle) {
return "";
}
return [
resolveBookingPlate(vehicle, plateOverride),
vehicle?.id ?? "",
normalizeCustomerNumber(vehicle?.customer_id) ?? "",
normalizeCustomerNumber(customer_id.value) ?? "",
].join("|");
};
const syncMatchedVehicleSelection = (vehicle, options = {}) => {
const normalizedOptions = {
plateOverride: null,
@@ -590,6 +605,7 @@ watch(reg_1, (newValue) => {
// Define the search ID for this input change
const search_id = register_new_search();
console.log("reg_1 changed:", newValue);
lastAutoSyncedVehicleKey.value = "";
const shouldKeepCustomerSelection = shouldPreserveCustomerSelection(newValue);
selectedDropdownItem.value = -1;
// Check if the new value is empty, if so, clear the vehicles_matching array
@@ -847,11 +863,17 @@ watch(vehicles_matching, (newValue) => {
const currentValue = reg_1.value;
const vehicle = newValue.find((vehicle) => vehicle.reg === currentValue);
if (vehicle) {
syncMatchedVehicleSelection(vehicle);
const nextAutoSyncKey = getVehicleAutoSyncKey(vehicle, currentValue);
if (lastAutoSyncedVehicleKey.value !== nextAutoSyncKey) {
lastAutoSyncedVehicleKey.value = nextAutoSyncKey;
syncMatchedVehicleSelection(vehicle);
}
} else if (getBookingMatchesForSelection(null, currentValue).length > 0) {
lastAutoSyncedVehicleKey.value = "";
clearCustomerConflict();
emitVehicleObject(null, currentValue);
} else {
lastAutoSyncedVehicleKey.value = "";
clearCustomerConflict();
}
// Check if the selectedDropdownItem index is valid
@@ -356,6 +356,20 @@ export const OrderBookings = {
{ safety_seal: normalizeCompletionSafetySeal(safetySeal) },
onAfterComplete
);
},
resendBookingConfirmation: async (id) => {
return SessionUser.request(
OrderBookings.meta.endpoint + "/booking-confirmation/resend",
"POST",
{ id: parseInt(id, 10) }
).then((response) => response.data.data);
},
resendBookingCompletionConfirmation: async (id) => {
return SessionUser.request(
OrderBookings.meta.endpoint + "/completion-confirmation/resend",
"POST",
{ id: parseInt(id, 10) }
).then((response) => response.data.data);
}
},
/**
@@ -924,6 +924,13 @@ const assignDraftOrderCustomer = async ({
console.error(error);
});
},
resendWashCertificate: async (id) => {
return SessionUser.request(
Orders.meta.endpoint + "/wash-certificate/resend",
"POST",
{ id: parseInt(id, 10) }
).then((response) => response.data.data);
},
showAttachWashCertificateForm(order_id, onAfterSubmit = null) {
const normalizedOrderId = Number.parseInt(order_id, 10);
if (!Number.isInteger(normalizedOrderId) || normalizedOrderId <= 0) {
+43 -3
View File
@@ -1188,6 +1188,7 @@ export const selectScan = (scan) => {
};
const cached_customer_names = [];
const activeCustomerSelectionRequests = new Map();
/** Get the customer's name */
export const getCustomerName = async (customerNumber) => {
@@ -1216,23 +1217,62 @@ export const getCustomerName = async (customerNumber) => {
/** Search, then select customer by customer number */
export const searchAndSelectCustomer = async (customerNumber, options = {}) => {
const normalizedOptions = {
forceRefresh: false,
...options,
};
const normalizedCustomerNumber = resolveCustomerNumber(customerNumber);
if (!normalizedCustomerNumber) {
return null;
}
const selectedCustomerNumber = getSelectedCustomerNumber();
const hasSelectedCustomerData =
customer_data.value &&
typeof customer_data.value === "object" &&
!Array.isArray(customer_data.value) &&
Object.keys(customer_data.value).length > 0;
if (
!normalizedOptions.forceRefresh &&
selectedCustomerNumber === normalizedCustomerNumber &&
hasSelectedCustomerData
) {
return customer_data.value;
}
if (!normalizedOptions.forceRefresh && activeCustomerSelectionRequests.has(normalizedCustomerNumber)) {
return await activeCustomerSelectionRequests.get(normalizedCustomerNumber);
}
// Get the customer data
return await authenticatedRequest(`/users/customer?customer_number=${customerNumber}`, "GET")
const request = authenticatedRequest(`/users/customer?customer_number=${normalizedCustomerNumber}`, "GET")
.then((response) => {
console.log(response);
const responseData = response?.data?.data ?? {};
const rawEconomicCustomer = responseData.economic_customer ?? null;
const normalizedCustomer = normalizeCustomerRecord(rawEconomicCustomer, {
...responseData,
customerNumber: resolveCustomerNumber(rawEconomicCustomer) ?? resolveCustomerNumber(customerNumber),
customerNumber: resolveCustomerNumber(rawEconomicCustomer) ?? normalizedCustomerNumber,
});
selectCustomer(normalizedCustomer, options);
selectCustomer(normalizedCustomer, normalizedOptions);
return normalizedCustomer;
})
.catch((error) => {
console.error(error);
return null;
})
.finally(() => {
if (activeCustomerSelectionRequests.get(normalizedCustomerNumber) === request) {
activeCustomerSelectionRequests.delete(normalizedCustomerNumber);
}
});
if (!normalizedOptions.forceRefresh) {
activeCustomerSelectionRequests.set(normalizedCustomerNumber, request);
}
return await request;
};
/** Get order items */
+10
View File
@@ -600,10 +600,20 @@
"delete_user": "Slet bruger",
"download_invoice": "Download faktura",
"edit_permissions": "Rediger tilladelser",
"email_notifications_section": "E-mail notifikationer",
"enter_password": "Indtast adgangskode",
"error": "Fejl",
"error_changing_password": "Fejl ved ændring af adgangskode",
"error_downloading_attachment": "Fejl ved download af vedhæftet fil",
"resend_booking_confirmation": "(Gen)send bookingbekræftelse",
"resend_booking_confirmation_error": "Bookingbekræftelsen kunne ikke sendes",
"resend_booking_confirmation_success": "Bookingbekræftelsen er sendt",
"resend_booking_completion_confirmation": "(Gen)send bekræftelse for færdig booking",
"resend_booking_completion_confirmation_error": "Bekræftelsen for færdig booking kunne ikke sendes",
"resend_booking_completion_confirmation_success": "Bekræftelsen for færdig booking er sendt",
"resend_wash_certificate": "(Gen)send vaskecertifikat",
"resend_wash_certificate_error": "Vaskecertifikatet kunne ikke sendes",
"resend_wash_certificate_success": "Vaskecertifikatet er sendt",
"link_copied": "Link kopieret",
"login_as_customer": "Log ind som kunde",
"login_as_user": "Log ind som bruger",
+10
View File
@@ -600,10 +600,20 @@
"delete_user": "Benutzer l?schen",
"download_invoice": "Rechnung herunterladen",
"edit_permissions": "Berechtigungen bearbeiten",
"email_notifications_section": "E-Mail-Benachrichtigungen",
"enter_password": "Passwort eingeben",
"error": "Fehler",
"error_changing_password": "Beim ?ndern des Passworts ist ein Fehler aufgetreten.",
"error_downloading_attachment": "Beim Herunterladen der angeh?ngten Datei ist ein Fehler aufgetreten.",
"resend_booking_confirmation": "(Re)send booking confirmation",
"resend_booking_confirmation_error": "The booking confirmation could not be sent",
"resend_booking_confirmation_success": "The booking confirmation has been sent",
"resend_booking_completion_confirmation": "(Re)send booking completion confirmation",
"resend_booking_completion_confirmation_error": "The booking completion confirmation could not be sent",
"resend_booking_completion_confirmation_success": "The booking completion confirmation has been sent",
"resend_wash_certificate": "(Re)send wash certificate",
"resend_wash_certificate_error": "The wash certificate could not be sent",
"resend_wash_certificate_success": "The wash certificate has been sent",
"link_copied": "Link in die Zwischenablage kopiert",
"login_as_customer": "Als Kunde anmelden",
"login_as_user": "Als Benutzer anmelden",
+10
View File
@@ -600,10 +600,20 @@
"delete_user": "Delete user",
"download_invoice": "Download invoice",
"edit_permissions": "Edit permissions",
"email_notifications_section": "Email notifications",
"enter_password": "Enter password",
"error": "Error",
"error_changing_password": "An error occurred while changing the password.",
"error_downloading_attachment": "An error occurred while downloading the attached file.",
"resend_booking_confirmation": "(Re)send booking confirmation",
"resend_booking_confirmation_error": "The booking confirmation could not be sent",
"resend_booking_confirmation_success": "The booking confirmation has been sent",
"resend_booking_completion_confirmation": "(Re)send booking completion confirmation",
"resend_booking_completion_confirmation_error": "The booking completion confirmation could not be sent",
"resend_booking_completion_confirmation_success": "The booking completion confirmation has been sent",
"resend_wash_certificate": "(Re)send wash certificate",
"resend_wash_certificate_error": "The wash certificate could not be sent",
"resend_wash_certificate_success": "The wash certificate has been sent",
"link_copied": "Link copied to clipboard",
"login_as_customer": "Log in as customer",
"login_as_user": "Log in as user",
+10
View File
@@ -600,10 +600,20 @@
"delete_user": "Delete user",
"download_invoice": "Last ned faktura",
"edit_permissions": "Rediger tillatelser",
"email_notifications_section": "E-postvarsler",
"enter_password": "Skriv inn passord",
"error": "Feil",
"error_changing_password": "Det oppstod en feil under endring av passord.",
"error_downloading_attachment": "Det oppsto en feil under nedlasting av den vedlagte filen.",
"resend_booking_confirmation": "(Re)send booking confirmation",
"resend_booking_confirmation_error": "The booking confirmation could not be sent",
"resend_booking_confirmation_success": "The booking confirmation has been sent",
"resend_booking_completion_confirmation": "(Re)send booking completion confirmation",
"resend_booking_completion_confirmation_error": "The booking completion confirmation could not be sent",
"resend_booking_completion_confirmation_success": "The booking completion confirmation has been sent",
"resend_wash_certificate": "(Re)send wash certificate",
"resend_wash_certificate_error": "The wash certificate could not be sent",
"resend_wash_certificate_success": "The wash certificate has been sent",
"link_copied": "Linken er kopiert til utklippstavlen",
"login_as_customer": "Logg inn som kunde",
"login_as_user": "Logg inn som bruker",
+10
View File
@@ -600,10 +600,20 @@
"delete_user": "Delete user",
"download_invoice": "Ladda ner faktura",
"edit_permissions": "Edit permissions",
"email_notifications_section": "E-postaviseringar",
"enter_password": "Enter password",
"error": "Fel",
"error_changing_password": "Ett fel uppstod när lösenordet ändrades.",
"error_downloading_attachment": "Ett fel uppstod när den bifogade filen laddades ner.",
"resend_booking_confirmation": "(Re)send booking confirmation",
"resend_booking_confirmation_error": "The booking confirmation could not be sent",
"resend_booking_confirmation_success": "The booking confirmation has been sent",
"resend_booking_completion_confirmation": "(Re)send booking completion confirmation",
"resend_booking_completion_confirmation_error": "The booking completion confirmation could not be sent",
"resend_booking_completion_confirmation_success": "The booking completion confirmation has been sent",
"resend_wash_certificate": "(Re)send wash certificate",
"resend_wash_certificate_error": "The wash certificate could not be sent",
"resend_wash_certificate_success": "The wash certificate has been sent",
"link_copied": "Link copied to clipboard",
"login_as_customer": "Log in as customer",
"login_as_user": "Log in as user",
@@ -519,9 +519,11 @@ const hasConfirmedCurrentQuestionReview = computed(
const shouldReturnToQuestionsForReview = computed(
() =>
!isCompletingWash.value &&
!washInProgress.value &&
visibleQuestions.value.length > 0 &&
currentStep.value > steps.QUESTIONS &&
currentStep.value < steps.COMPLETED &&
!hasConfirmedCurrentQuestionReview.value
);
+68 -1
View File
@@ -51,8 +51,9 @@ async function navigateToPosBootPage(page: Page, { waitForSession = false }: { w
try {
await page.goto(POS_BOOT_URL, { waitUntil: "domcontentloaded", timeout: 30_000 });
await sessionRequest;
await Promise.race([sessionRequest, getPosBootStep(page).waitFor({ state: "visible", timeout: 10_000 })]);
await expect(getPosBootStep(page)).toBeVisible({ timeout: 10_000 });
await sessionRequest;
return;
} catch (error) {
lastError = error;
@@ -2747,6 +2748,72 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
await expect(attachmentsSection).toBeVisible();
});
test("shows email notification actions and resends booking completion confirmation", async ({ page }) => {
const baseFixture = createPosFixture();
await mockApi(page, {
authenticated: true,
permissions: SUPERUSER_POS_PERMISSIONS,
edgeGateways: false,
pos: createPosFixture({
ordersById: {
54518: {
...baseFixture.ordersById[54518],
user_id: 77,
booking_id: 9101,
customer_name: "(TEST) Pleno Vognmandsforretning",
invoice_collection_id: 101,
},
},
attachmentsByOrderId: {
54518: [
{
id: 7401,
object_type: "orders",
object_id: 54518,
content: {
image: null,
document: "wash_certificate_54518.pdf",
relation: null,
other: "WASH_CERTIFICATE",
src: null,
},
created_at: "2026-04-21 08:44:07",
updated_at: "2026-04-21 08:44:07",
deleted_at: null,
},
],
},
}),
});
await primeOperatorSession(page, "pos-orders-email-notifications-token", SUPERUSER_POS_PERMISSIONS);
await page.setViewportSize({ width: 1900, height: 900 });
await page.goto("/admin/12/modules/pos/orders");
await expect(page.locator("table")).toBeVisible({ timeout: 30_000 });
const dropdownRoot = await openLowestVisibleOrderActionDropdown(page);
const dropdownContent = dropdownRoot.locator(".dropdown-content").first();
const emailSection = dropdownContent.getByTestId("action-settings-wheel-section-email-notifications");
await expect(emailSection).toBeVisible();
await emailSection.hover();
const emailSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-email-notifications");
await expect(emailSubmenu).toBeVisible();
await expect(emailSubmenu).toContainText("(Gen)send bookingbekræftelse");
await expect(emailSubmenu).toContainText("(Gen)send bekræftelse for færdig booking");
await expect(emailSubmenu).not.toContainText("(Gen)send vaskecertifikat");
const resendRequestPromise = page.waitForRequest((request) => {
return request.method() === "POST" && request.url().includes("/order-bookings/completion-confirmation/resend");
});
await emailSubmenu.getByRole("button", { name: /\(Gen\)send bekræftelse for færdig booking/i }).click();
const resendRequest = await resendRequestPromise;
expect(resendRequest.postDataJSON()).toEqual({ id: 9101 });
});
test("keeps the first action reachable while async menu sections load", async ({ page }) => {
const posFixture = createAsyncActionMenuPosFixture();
+5 -77
View File
@@ -761,86 +761,14 @@ async function installCoolifyMocks(page, state) {
}
test.describe("Coolify infrastructure management", () => {
test("@smoke configures and tests a Coolify connection with mocked API data", async ({ page }) => {
const state = await boot(page);
test("@smoke opens the current Coolify integration surface from the legacy route", async ({ page }) => {
await boot(page);
await page.goto("/superuser/configuration/coolify");
await expect(page.getByRole("heading", { name: "Coolify configuration" })).toBeVisible({ timeout: 30_000 });
await page.locator(".card-header").filter({ hasText: "API connection" }).click();
await page.locator(".card-header").filter({ hasText: "Gateway load balancer" }).click();
await page.locator(".card-header").filter({ hasText: "Instances" }).click();
await page.locator(".card-header").filter({ hasText: "Managed targets" }).click();
await expect(page.getByTestId("coolify-load-balancer-card")).toContainText("api-v2.truckwash.io");
await expect(page.getByTestId("coolify-gateways-table")).toContainText("65.21.214.30");
await expect(page.getByTestId("coolify-load-balancer-drift")).toContainText("Add target 65.21.214.30");
await page.getByRole("button", { name: "Dry-run reconcile" }).click();
expect(state.lbReconciles.at(-1)).toEqual({ dry_run: true });
await page.getByRole("button", { name: "Enforce reconcile" }).click();
expect(state.lbReconciles.at(-1)).toEqual({ dry_run: false });
await expect(page.getByTestId("coolify-load-balancer-card")).toContainText("ok");
await expect(page.getByTestId("coolify-load-balancer-drift")).toContainText("No planned changes.");
await page.getByRole("button", { name: "Dry-run route deploy" }).click();
expect(state.routeDeploys.at(-1)).toEqual({ dry_run: true });
expect(state.routePlans.at(-1)).toEqual(
expect.arrayContaining([
expect.objectContaining({
public_url: "https://api-v2.truckwash.io/internal/api",
frontend_public_url: "https://api-v2.truckwash.io/internal/frontend",
}),
])
);
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Route plan");
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Warnings 1");
await page.getByRole("button", { name: "Deploy api-v2 route" }).click();
expect(state.routeDeploys.at(-1)).toEqual({ dry_run: false });
expect(state.routePlans.at(-1)).toEqual(
expect.arrayContaining([
expect.objectContaining({
public_url: "https://api-v2.truckwash.io/internal/api",
frontend_public_url: "https://api-v2.truckwash.io/internal/frontend",
}),
])
);
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Route deploy");
await expect(page.getByTestId("coolify-gateway-route-result")).toContainText("Applied 1");
await page.getByRole("button", { name: "Deploy latest API code" }).click();
expect(state.apiCodeDeploys.at(-1)).toEqual({ dry_run: false, deploy_routes: true });
expect(state.apiCodePlans.at(-1)).toEqual(
expect.arrayContaining([
expect.objectContaining({
public_url: "https://api-v2.truckwash.io/internal/api",
}),
])
);
await expect(page.getByTestId("coolify-gateway-code-deploy-result")).toContainText("API code deploy");
await expect(page.getByTestId("coolify-gateway-code-deploy-result")).toContainText("Applied 1");
await expect(page.getByTestId("coolify-gateway-code-deploy-result")).toContainText("Route applied 1");
await expect(page.getByTestId("coolify-instances-table")).toContainText("Production Coolify");
await expect(page.getByTestId("coolify-targets-table")).toContainText("failover_ready");
const form = page.getByTestId("coolify-instance-form");
await form.locator('input[type="text"]').nth(0).fill("Staging Coolify");
await form.locator('input[type="url"]').fill("https://staging-coolify.example.test");
await form.locator('input[type="password"]').fill("token-secret");
await expect(form.getByText("Project UUID")).toHaveCount(0);
await expect(form.getByText("Server UUID")).toHaveCount(0);
await form.getByRole("button", { name: "Save" }).click();
await expect(page.getByTestId("coolify-instances-table")).toContainText("Staging Coolify");
await page.getByTestId("coolify-instances-table").getByRole("button", { name: "Test" }).first().click();
await expect(page.getByTestId("coolify-instances-table")).toContainText("ok");
await page.getByTestId("coolify-targets-table").getByRole("button", { name: "Reconcile" }).first().click();
await expect.poll(() => state.reconciledTargets).toContain(7);
await expect(page.getByTestId("coolify-targets-table")).toContainText("failover_ready");
page.once("dialog", async (dialog) => {
expect(dialog.message()).toContain("delete-coolify-target-7");
await dialog.accept("delete-coolify-target-7");
});
await page.getByTestId("coolify-targets-table").getByRole("button", { name: "Delete" }).first().click();
await expect(page.getByTestId("coolify-targets-table")).toContainText("No Coolify targets configured.");
await expect(page).toHaveURL(/\/superuser\/configuration\/releases\/integrations\?panel=coolify/);
await expect(page.getByRole("heading", { name: "Release Manager" })).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("release-coolify-panel")).toContainText("Coolify");
});
test("@smoke creates Coolify-backed database, Redis, and MinIO replicas from replication management", async ({
+58 -10
View File
@@ -5,6 +5,57 @@ async function primeSuperuserSession(page) {
await primeMockSession(page, { token: "superuser-edge-gateway-token" });
}
const transientDynamicImportPattern = /(?:Failed to fetch dynamically imported module|ERR_NETWORK_CHANGED)/iu;
async function gotoEdgeAgentTerminal(page, gatewayId = 701) {
const terminalPath = `/superuser/selfserve/edge-agents/${encodeURIComponent(String(gatewayId))}/terminal`;
const loadErrors = [];
const recordLoadError = (error) => {
const message = typeof error === "string" ? error : error?.message || String(error || "");
if (transientDynamicImportPattern.test(message)) {
loadErrors.push(message);
}
};
const onConsole = (message) => {
if (message.type() === "error") {
recordLoadError(message.text());
}
};
const onPageError = (error) => recordLoadError(error);
page.on("console", onConsole);
page.on("pageerror", onPageError);
try {
for (let attempt = 0; attempt < 3; attempt += 1) {
loadErrors.length = 0;
let navigationError = null;
try {
await page.goto(terminalPath, { waitUntil: "domcontentloaded" });
} catch (error) {
navigationError = error;
recordLoadError(error);
}
if (navigationError && loadErrors.length === 0) {
throw navigationError;
}
const terminalVisible = await page
.getByTestId("gateway-terminal-page")
.isVisible({ timeout: attempt === 0 ? 5_000 : 10_000 })
.catch(() => false);
if (terminalVisible || loadErrors.length === 0) {
return;
}
}
} finally {
page.off("console", onConsole);
page.off("pageerror", onPageError);
}
}
async function selectLastGatewayBindingRelay(page, relayId) {
const relaySelect = page.locator('[data-testid^="gateway-binding-relay-"]').last();
await expect
@@ -919,14 +970,11 @@ test.describe("Edge gateway management smoke", () => {
};
};
await page.route(
/https?:\/\/(?:api\.truckwash\.io(?::\d+)?)\/modules\/edge-gateways\/workspace\/departments\/1(?:\?.*)?$/i,
async (route) => {
await route.fulfill(json({ data: buildGateWorkspace() }));
}
);
await page.route(/\/modules\/edge-gateways\/workspace\/departments\/1(?:\?.*)?$/i, async (route) => {
await route.fulfill(json({ data: buildGateWorkspace() }));
});
await page.route(/https?:\/\/(?:api\.truckwash\.io(?::\d+)?)\/department\/gates(?:\?.*)?$/i, async (route) => {
await page.route(/\/department\/gates(?:\?.*)?$/i, async (route) => {
const request = route.request();
const method = request.method();
@@ -1104,7 +1152,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/selfserve/edge-agents/701/terminal", { waitUntil: "domcontentloaded" });
await gotoEdgeAgentTerminal(page);
await expect(page.getByTestId("gateway-terminal-page")).toBeVisible();
await expect(page.getByTestId("gateway-terminal-status")).toContainText(/not connected to the broker/i);
@@ -1133,7 +1181,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/selfserve/edge-agents/701/terminal", { waitUntil: "domcontentloaded" });
await gotoEdgeAgentTerminal(page);
await expect(page.getByTestId("gateway-terminal-page")).toBeVisible();
await expect(page.getByTestId("gateway-terminal-status")).toContainText(/Broker did not complete/i, {
@@ -1154,7 +1202,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/selfserve/edge-agents/701/terminal", { waitUntil: "domcontentloaded" });
await gotoEdgeAgentTerminal(page);
await expect(page.getByTestId("gateway-terminal-page")).toBeVisible();
await expect(page.getByTestId("gateway-terminal-status")).toContainText(/connecting|open/i);
+16 -6
View File
@@ -2304,7 +2304,8 @@ test.describe("All-in-one self-serve studio", () => {
};
});
expect(laneScopeOptionMetrics.bottomGap).toBeLessThanOrEqual(2);
expect(laneScopeOptionMetrics.optionHeight).toBeGreaterThan(80);
const minimumScopeOptionHeight = (page.viewportSize()?.width ?? 1024) < 640 ? 44 : 80;
expect(laneScopeOptionMetrics.optionHeight).toBeGreaterThanOrEqual(minimumScopeOptionHeight);
await page.getByTestId("studio-scope-option-lane-7").click();
await page.getByTestId("studio-scope-option-vehicle-8").click();
await page.getByTestId("studio-scope-apply").click();
@@ -2847,20 +2848,29 @@ test.describe("All-in-one self-serve studio", () => {
const sidebar = document.querySelector(".studio-sidebar")?.getBoundingClientRect();
const workspace = document.querySelector('[data-testid="studio-simulator-workspace"]')?.getBoundingClientRect();
return {
shellLeft: shell?.left || 0,
shellRight: shell?.right || 0,
shellWidth: shell?.width || 0,
sidebarBottom: sidebar?.bottom || 0,
sidebarRight: sidebar?.right || 0,
sidebarWidth: sidebar?.width || 0,
workspaceLeft: workspace?.left || 0,
workspaceRight: workspace?.right || 0,
workspaceTop: workspace?.top || 0,
workspaceWidth: workspace?.width || 0,
};
});
expect(simulatorLayoutMetrics.workspaceLeft).toBeGreaterThanOrEqual(simulatorLayoutMetrics.sidebarRight - 4);
expect(simulatorLayoutMetrics.workspaceRight).toBeGreaterThanOrEqual(simulatorLayoutMetrics.shellRight - 8);
expect(simulatorLayoutMetrics.workspaceWidth).toBeGreaterThan(
simulatorLayoutMetrics.shellWidth - simulatorLayoutMetrics.sidebarWidth - 40
);
if ((page.viewportSize()?.width ?? 1024) < 820) {
expect(simulatorLayoutMetrics.workspaceTop).toBeGreaterThanOrEqual(simulatorLayoutMetrics.sidebarBottom - 4);
expect(simulatorLayoutMetrics.workspaceLeft).toBeGreaterThanOrEqual(simulatorLayoutMetrics.shellLeft - 8);
expect(simulatorLayoutMetrics.workspaceWidth).toBeGreaterThan(simulatorLayoutMetrics.shellWidth - 40);
} else {
expect(simulatorLayoutMetrics.workspaceLeft).toBeGreaterThanOrEqual(simulatorLayoutMetrics.sidebarRight - 4);
expect(simulatorLayoutMetrics.workspaceRight).toBeGreaterThanOrEqual(simulatorLayoutMetrics.shellRight - 8);
expect(simulatorLayoutMetrics.workspaceWidth).toBeGreaterThan(
simulatorLayoutMetrics.shellWidth - simulatorLayoutMetrics.sidebarWidth - 40
);
}
await expect(page.getByTestId("studio-hardware-mode")).toContainText("Studio");
await expect(page.getByTestId("studio-simulator-scope")).toContainText("Lane 7");
await expect(page.getByTestId("studio-simulator-scope")).toContainText("Bus");
+34 -9
View File
@@ -50,18 +50,20 @@ async function advanceGuidedWashToLastStep(page) {
}
}
function waitForLaneCommandRequest(page, command) {
return page.waitForRequest((request) => {
if (!request.url().includes("/modules/self-serve/lane/command")) {
async function waitForLaneCommandRequest(page, command) {
const request = await page.waitForRequest((candidateRequest) => {
if (!candidateRequest.url().includes("/modules/self-serve/lane/command")) {
return false;
}
if (request.method() !== "POST") {
if (candidateRequest.method() !== "POST") {
return false;
}
const body = request.postDataJSON?.() || {};
const body = candidateRequest.postDataJSON?.() || {};
return body.command === command;
});
await request.response();
return request;
}
function buildSavedProgress(overrides = {}) {
@@ -234,7 +236,7 @@ test.describe("Self-serve wash", () => {
lane_id: 7,
command: "OPEN_PROPERTY_EXIT_GATE",
});
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible();
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("self-serve-nav-close").click();
await expect(page.getByTestId("self-serve-completed-step")).toBeHidden();
await expect(page.getByTestId("self-serve-vehicle-step")).toBeVisible();
@@ -857,7 +859,7 @@ test.describe("Self-serve wash", () => {
command: "OPEN_PROPERTY_EXIT_GATE",
});
await expect(page.getByTestId("self-serve-action-error")).toBeHidden();
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible();
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden();
});
@@ -981,6 +983,7 @@ test.describe("Self-serve wash", () => {
permissions: ["user"],
selfServe: true,
});
api.selfServe.answerResponseDelayMs = 1000;
const preview = api.selfServe.previewByKey["7:AB12345"];
const primaryQuestion = preview.questions[0];
const followUpQuestion = {
@@ -1014,7 +1017,29 @@ test.describe("Self-serve wash", () => {
.poll(async () => questionCards.evaluateAll((cards) => cards.map((card) => card.getAttribute("data-testid"))))
.toEqual(["self-serve-question-11", "self-serve-question-21"]);
const firstQuestionBefore = await page.getByTestId("self-serve-question-11").boundingBox();
const firstQuestion = page.getByTestId("self-serve-question-11");
let stableQuestionBoxSamples = 0;
let previousQuestionBoxKey = "";
await expect
.poll(
async () => {
const [box, viewport] = await Promise.all([firstQuestion.boundingBox(), page.viewportSize()]);
if (!box || !viewport || box.x < 0 || box.x >= viewport.width / 2) {
stableQuestionBoxSamples = 0;
previousQuestionBoxKey = "";
return false;
}
const boxKey = [box.x, box.y, box.width, box.height].map((value) => Math.round(value)).join(":");
stableQuestionBoxSamples = boxKey === previousQuestionBoxKey ? stableQuestionBoxSamples + 1 : 0;
previousQuestionBoxKey = boxKey;
return stableQuestionBoxSamples >= 2;
},
{ timeout: 10_000 }
)
.toBe(true);
const firstQuestionBefore = await firstQuestion.boundingBox();
expect(firstQuestionBefore).not.toBeNull();
const reorderedAnsweredQuestions = [{ ...followUpQuestion }, { ...primaryQuestion, answer: false }];
@@ -1034,7 +1059,7 @@ test.describe("Self-serve wash", () => {
await expect(noButton).toHaveClass(/is-danger/);
await expect(noButton).not.toHaveClass(/is-light/);
await expect(page.getByTestId("self-serve-questions-inline-loading")).toBeVisible();
const firstQuestionWhileLoading = await page.getByTestId("self-serve-question-11").boundingBox();
const firstQuestionWhileLoading = await firstQuestion.boundingBox();
expect(firstQuestionWhileLoading).not.toBeNull();
expect(Math.abs((firstQuestionWhileLoading?.y || 0) - (firstQuestionBefore?.y || 0))).toBeLessThanOrEqual(4);
@@ -851,13 +851,15 @@ test.describe("Superuser system status smoke", () => {
await expect(page.getByTestId("replication-host-database-2").locator(".buttons.are-small")).toHaveCount(0);
await expect(primaryActions.locator(".action-settings-wheel-trigger")).toBeVisible();
await primaryActions.locator(".action-settings-wheel-trigger").click();
await expect(primaryActions.locator(".dropdown-item-action:visible")).toHaveCount(1);
await expect(primaryActions.locator(".dropdown-item-action:visible")).toHaveCount(2);
await expect(primaryActions.getByRole("button", { name: "Test" })).toBeVisible();
await expect(primaryActions.getByRole("button", { name: "Omdøb" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(replicaActions.locator(".action-settings-wheel-trigger")).toBeVisible();
const replicaActionTrigger = replicaActions.locator(".action-settings-wheel-trigger");
await replicaActionTrigger.click();
await expect(replicaActions.locator(".dropdown-item-action:visible")).toHaveCount(3);
await expect(replicaActions.locator(".dropdown-item-action:visible")).toHaveCount(4);
await expect(replicaActions.getByRole("button", { name: "Omdøb" })).toBeVisible();
const [triggerBox, menuBox] = await Promise.all([
replicaActionTrigger.boundingBox(),
replicaActions.locator(".dropdown-content").boundingBox(),
+16
View File
@@ -3203,6 +3203,16 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
return true;
}
if (
(pathname.endsWith("/order-bookings/booking-confirmation/resend") ||
pathname.endsWith("/order-bookings/completion-confirmation/resend")) &&
method === "POST"
) {
const body = request.postDataJSON?.() || {};
await route.fulfill(json({ success: true, data: { id: Number(body.id || 0), sent: true } }));
return true;
}
if (pathname.endsWith("/department/numberplatescanners") && method === "GET") {
await route.fulfill(json({ success: true, data: posFixture.numberPlateScanners || [] }));
return true;
@@ -3802,6 +3812,12 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
return true;
}
if (pathname.endsWith("/orders/wash-certificate/resend") && method === "POST") {
const body = request.postDataJSON?.() || {};
await route.fulfill(json({ success: true, data: { id: Number(body.id || 0), sent: true } }));
return true;
}
if (pathname.endsWith("/departments/order/recommended") && method === "GET") {
await route.fulfill(
json({
@@ -89,6 +89,8 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
},
showEditObjectFieldForm: vi.fn(() => Promise.resolve()),
functions: {
resendBookingCompletionConfirmation: vi.fn(() => Promise.resolve()),
resendBookingConfirmation: vi.fn(() => Promise.resolve()),
showDeleteConfirmationModal: vi.fn(() => Promise.resolve()),
},
},
@@ -120,6 +122,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
assignDraftCustomer: vi.fn(() => Promise.resolve()),
get_department_id: vi.fn(() => Promise.resolve(1)),
removeAttachment: vi.fn(() => Promise.resolve({ success: true })),
resendWashCertificate: vi.fn(() => Promise.resolve()),
showAttachWashCertificateForm: vi.fn(() => Promise.resolve()),
showChangeCustomerForm: vi.fn(() => Promise.resolve()),
showChangeInvoiceCollectionForm: vi.fn(() => Promise.resolve()),
@@ -184,6 +187,7 @@ const SETTINGS_WHEEL_TRANSLATION_KEYS = [
"admin.pos.settings_wheel.delete_user",
"admin.pos.settings_wheel.download_invoice",
"admin.pos.settings_wheel.edit_permissions",
"admin.pos.settings_wheel.email_notifications_section",
"admin.pos.settings_wheel.enter_password",
"admin.pos.settings_wheel.error",
"admin.pos.settings_wheel.error_changing_password",
@@ -212,6 +216,15 @@ const SETTINGS_WHEEL_TRANSLATION_KEYS = [
"admin.pos.settings_wheel.password_changed_text",
"admin.pos.settings_wheel.please_enter_password",
"admin.pos.settings_wheel.relays_section",
"admin.pos.settings_wheel.resend_booking_completion_confirmation",
"admin.pos.settings_wheel.resend_booking_completion_confirmation_error",
"admin.pos.settings_wheel.resend_booking_completion_confirmation_success",
"admin.pos.settings_wheel.resend_booking_confirmation",
"admin.pos.settings_wheel.resend_booking_confirmation_error",
"admin.pos.settings_wheel.resend_booking_confirmation_success",
"admin.pos.settings_wheel.resend_wash_certificate",
"admin.pos.settings_wheel.resend_wash_certificate_error",
"admin.pos.settings_wheel.resend_wash_certificate_success",
"admin.pos.settings_wheel.scan_qr_to_login",
"admin.pos.settings_wheel.self_serve_customer",
"admin.pos.settings_wheel.self_serve_driver",
@@ -461,6 +474,12 @@ describe("ActionSettingsWheelButton", () => {
SessionUser.objects.orders.functions.downloadAttachment.mockResolvedValue("https://cdn.example.test/attachment");
SessionUser.objects.orders.functions.removeAttachment.mockReset();
SessionUser.objects.orders.functions.removeAttachment.mockResolvedValue({ success: true });
SessionUser.objects.orders.functions.resendWashCertificate.mockReset();
SessionUser.objects.orders.functions.resendWashCertificate.mockResolvedValue({});
SessionUser.objects.order_bookings.functions.resendBookingConfirmation.mockReset();
SessionUser.objects.order_bookings.functions.resendBookingConfirmation.mockResolvedValue({});
SessionUser.objects.order_bookings.functions.resendBookingCompletionConfirmation.mockReset();
SessionUser.objects.order_bookings.functions.resendBookingCompletionConfirmation.mockResolvedValue({});
SessionUser.objects.department_gates.showCreateObjectForm.mockClear();
SessionUser.objects.department_relays.showCreateObjectForm.mockClear();
SessionUser.objects.collectedOrderInvoices.add.mockReset();
@@ -749,6 +768,141 @@ describe("ActionSettingsWheelButton", () => {
expect(wrapper.text()).not.toContain("admin.pos.settings_wheel.mark_as_completed");
});
it("hides email notifications when the order has no booking or wash certificate", async () => {
const wrapper = mountFlatDropdownButton({
order_id: 42,
order_booking_id: null,
});
await flushMicrotasks();
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
expect(wrapper.text()).not.toContain("admin.pos.settings_wheel.email_notifications_section");
expect(wrapper.text()).not.toContain("admin.pos.settings_wheel.resend_booking_confirmation");
expect(wrapper.text()).not.toContain("admin.pos.settings_wheel.resend_wash_certificate");
});
it("shows booking email notification actions for booking-only orders", async () => {
const wrapper = mountFlatDropdownButton({
order_id: 42,
order_booking_id: 123,
department_id: 1,
});
await flushMicrotasks();
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
expect(wrapper.text()).toContain("admin.pos.settings_wheel.email_notifications_section");
expect(wrapper.text()).toContain("admin.pos.settings_wheel.resend_booking_confirmation");
expect(wrapper.text()).not.toContain("admin.pos.settings_wheel.resend_booking_completion_confirmation");
expect(wrapper.text()).not.toContain("admin.pos.settings_wheel.resend_wash_certificate");
const button = wrapper
.findAll("button.dropdown-item-action")
.find((buttonWrapper) => buttonWrapper.text().includes("admin.pos.settings_wheel.resend_booking_confirmation"));
expect(button).toBeTruthy();
await button.trigger("click");
await flushMicrotasks();
expect(SessionUser.objects.order_bookings.functions.resendBookingConfirmation).toHaveBeenCalledWith(123);
expect(swalFireMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "admin.pos.settings_wheel.resend_booking_confirmation_success",
icon: "success",
})
);
});
it("shows booking completion email action when a booking has a wash certificate", async () => {
SessionUser.objects.orders.functions.fetchAttachments.mockResolvedValue([
{
id: 301,
content: {
document: "wash_certificate_42.pdf",
other: "WASH_CERTIFICATE",
},
},
]);
const wrapper = mountFlatDropdownButton({
order_id: 42,
order_booking_id: 123,
department_id: 1,
});
await flushMicrotasks();
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
expect(wrapper.text()).toContain("admin.pos.settings_wheel.email_notifications_section");
expect(wrapper.text()).toContain("admin.pos.settings_wheel.resend_booking_confirmation");
expect(wrapper.text()).toContain("admin.pos.settings_wheel.resend_booking_completion_confirmation");
expect(wrapper.text()).not.toContain("admin.pos.settings_wheel.resend_wash_certificate");
const button = wrapper
.findAll("button.dropdown-item-action")
.find((buttonWrapper) =>
buttonWrapper.text().includes("admin.pos.settings_wheel.resend_booking_completion_confirmation")
);
expect(button).toBeTruthy();
await button.trigger("click");
await flushMicrotasks();
expect(SessionUser.objects.order_bookings.functions.resendBookingCompletionConfirmation).toHaveBeenCalledWith(123);
expect(swalFireMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "admin.pos.settings_wheel.resend_booking_completion_confirmation_success",
icon: "success",
})
);
});
it("shows wash certificate email action when an order has a certificate without booking", async () => {
SessionUser.objects.orders.functions.fetchAttachments.mockResolvedValue([
{
id: 302,
content: {
document: "wash_certificate_42_302.pdf",
other: "wash_certificate_42_302.pdf",
},
},
]);
const wrapper = mountFlatDropdownButton({
order_id: 42,
order_booking_id: null,
});
await flushMicrotasks();
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
expect(wrapper.text()).toContain("admin.pos.settings_wheel.email_notifications_section");
expect(wrapper.text()).not.toContain("admin.pos.settings_wheel.resend_booking_confirmation");
expect(wrapper.text()).not.toContain("admin.pos.settings_wheel.resend_booking_completion_confirmation");
expect(wrapper.text()).toContain("admin.pos.settings_wheel.resend_wash_certificate");
const button = wrapper
.findAll("button.dropdown-item-action")
.find((buttonWrapper) => buttonWrapper.text().includes("admin.pos.settings_wheel.resend_wash_certificate"));
expect(button).toBeTruthy();
await button.trigger("click");
await flushMicrotasks();
expect(SessionUser.objects.orders.functions.resendWashCertificate).toHaveBeenCalledWith(42);
expect(swalFireMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "admin.pos.settings_wheel.resend_wash_certificate_success",
icon: "success",
})
);
});
it("routes 'change invoice collection' action without opening a new tab", async () => {
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
@@ -0,0 +1,87 @@
import { execFileSync } from "node:child_process";
import { readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
classifyTest,
ownedFilesByRole,
parseListedTests,
titleRules,
} from "../../scripts/run-playwright-full-slice.mjs";
const root = process.cwd();
const playwrightCliPath = join(root, "node_modules", "@playwright", "test", "cli.js");
const titleRuleFiles = new Set(titleRules.map((rule) => rule.file));
const e2eSpecFiles = readdirSync(join(root, "tests/e2e"))
.filter((file) => /\.spec\.(?:js|ts)$/u.test(file))
.sort();
describe("Playwright full-slice ownership", () => {
it("assigns every top-level e2e spec to one role or a title rule", () => {
const directOwners = new Map();
for (const [role, files] of Object.entries(ownedFilesByRole)) {
for (const file of files) {
if (directOwners.has(file)) {
throw new Error(`${file} is owned by both ${directOwners.get(file)} and ${role}.`);
}
directOwners.set(file, role);
}
}
const coveredFiles = new Set([...directOwners.keys(), ...titleRuleFiles]);
const uncoveredFiles = e2eSpecFiles.filter((file) => !coveredFiles.has(file));
expect(uncoveredFiles).toEqual([]);
});
it("keeps shared and infrastructure specs in their intended shards", () => {
expect(ownedFilesByRole.admin).toEqual(expect.arrayContaining(["default-mobile-redirect.spec.ts"]));
expect(ownedFilesByRole.superuser).toEqual(
expect.arrayContaining([
"coolify-infrastructure.spec.js",
"errorReports.spec.ts",
"failover-config.source.spec.ts",
"release-manager.spec.js",
"session-bootstrap.spec.ts",
"superuser-users.spec.ts",
])
);
expect(ownedFilesByRole.customer).toEqual(
expect.arrayContaining([
"guest-book-wash-mobile.spec.ts",
"i18n-catalog-switch.spec.ts",
"i18n-v2-integrity.spec.ts",
"release-bootstrap.spec.js",
"release-channel-switched.spec.js",
"release-channel-unavailable.spec.js",
"release-update-widget.spec.js",
"session-release-runtime.spec.ts",
])
);
});
it("classifies every listed test before role filtering", () => {
const listOutput = execFileSync(
process.execPath,
[playwrightCliPath, "test", "--list", "--project=chromium-desktop"],
{
cwd: root,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
}
);
const classificationErrors = [];
for (const testEntry of parseListedTests(listOutput)) {
try {
classifyTest(testEntry);
} catch (error) {
classificationErrors.push(error.message);
}
}
expect(classificationErrors).toEqual([]);
});
});
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { sourceMappings } from "../../scripts/playwright-pr-mapping.mjs";
const specsFor = (file) =>
sourceMappings
.filter((mapping) => mapping.patterns.some((pattern) => pattern.test(file)))
.flatMap((mapping) => mapping.specs);
describe("Playwright PR mapping", () => {
it("maps user vehicle table changes to the user vehicles E2E coverage", () => {
expect(specsFor("src/components/displays/user/vehicles/vehiclesTable.vue")).toContain(
"tests/e2e/userVehicles.spec.ts"
);
});
});
+77
View File
@@ -116,12 +116,17 @@ vi.mock("sweetalert2", () => ({
}));
import axios from "axios";
import { getAttributes } from "@/components/shop/CustomerAttributes.vue";
import { getNotes } from "@/components/shop/CustomerNotes.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {
clearActivePosOrderContext,
clearCustomerSelection,
clearStoredPosOrderId,
createOrder,
completed_at,
customer_data,
customer_id,
customer_name,
department_id,
@@ -137,6 +142,7 @@ import {
reg_2,
reg_3,
restoreStoredPosOrderId,
searchAndSelectCustomer,
scan_data,
scans,
step,
@@ -192,6 +198,77 @@ describe("POSDepartmentProcess.loadOrderItems", () => {
});
});
describe("POSDepartmentProcess.searchAndSelectCustomer", () => {
beforeEach(() => {
clearCustomerSelection();
authenticatedRequest.mockReset();
getNotes.mockReset();
getNotes.mockResolvedValue({ data: { data: [] } });
getAttributes.mockReset();
getAttributes.mockResolvedValue({ data: { success: true, data: [] } });
});
const mockCustomerResponse = (customerNumber = 12345679, name = "Pleno Logistics") => ({
data: {
data: {
customer_number: customerNumber,
customer_name: name,
economic_customer: {
customerNumber,
name,
barred: false,
},
},
},
});
it("returns the already selected customer without a duplicate lookup", async () => {
authenticatedRequest.mockResolvedValueOnce(mockCustomerResponse());
const selectedCustomer = await searchAndSelectCustomer(12345679);
const cachedCustomer = await searchAndSelectCustomer(12345679);
expect(authenticatedRequest).toHaveBeenCalledTimes(1);
expect(authenticatedRequest).toHaveBeenCalledWith("/users/customer?customer_number=12345679", "GET");
expect(cachedCustomer).toBe(customer_data.value);
expect(cachedCustomer).toEqual(selectedCustomer);
expect(customer_id.value).toBe(12345679);
expect(customer_name.value).toBe("Pleno Logistics");
});
it("shares concurrent lookups for the same customer number", async () => {
let resolveRequest;
authenticatedRequest.mockReturnValueOnce(
new Promise((resolve) => {
resolveRequest = resolve;
})
);
const firstLookup = searchAndSelectCustomer(12345679);
const secondLookup = searchAndSelectCustomer(12345679);
resolveRequest(mockCustomerResponse());
const [firstCustomer, secondCustomer] = await Promise.all([firstLookup, secondLookup]);
expect(authenticatedRequest).toHaveBeenCalledTimes(1);
expect(firstCustomer).toEqual(secondCustomer);
expect(customer_id.value).toBe(12345679);
});
it("performs a new lookup when forceRefresh is requested", async () => {
authenticatedRequest
.mockResolvedValueOnce(mockCustomerResponse(12345679, "Pleno Logistics"))
.mockResolvedValueOnce(mockCustomerResponse(12345679, "Pleno Logistics Updated"));
await searchAndSelectCustomer(12345679);
const refreshedCustomer = await searchAndSelectCustomer(12345679, { forceRefresh: true });
expect(authenticatedRequest).toHaveBeenCalledTimes(2);
expect(refreshedCustomer.name).toBe("Pleno Logistics Updated");
expect(customer_name.value).toBe("Pleno Logistics Updated");
});
});
describe("POSDepartmentProcess.clearActivePosOrderContext", () => {
it("clears active order, customer, metadata and stored mobile order id", () => {
localStorage.setItem("pos_order_id", "51211");
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { nextTick, ref } from "vue";
import { flushPromises } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mountWithApp } from "./helpers/mountWithApp.js";
@@ -223,9 +224,17 @@ const LicensePlateReg1InputStub = {
emitLinkedVehicle() {
this.$emit("update:vehicleObject", { ...linkedVehicle });
},
emitLinkedVehicleTwice() {
this.$emit("update:vehicleObject", { ...linkedVehicle });
this.$emit("update:vehicleObject", { ...linkedVehicle });
},
emitBookedVehicle() {
this.$emit("update:vehicleObject", { ...bookedVehicle });
},
emitLinkedBookingMatchesTwice() {
this.$emit("update:bookingMatches", [{ ...bookingWithItems }]);
this.$emit("update:bookingMatches", [{ ...bookingWithItems }]);
},
},
template: `
<div>
@@ -243,6 +252,13 @@ const LicensePlateReg1InputStub = {
>
Emit linked vehicle
</button>
<button
type="button"
data-testid="emit-linked-vehicle-twice"
@click="emitLinkedVehicleTwice"
>
Emit linked vehicle twice
</button>
<button
type="button"
data-testid="emit-booked-vehicle"
@@ -250,6 +266,13 @@ const LicensePlateReg1InputStub = {
>
Emit booked vehicle
</button>
<button
type="button"
data-testid="emit-linked-booking-matches-twice"
@click="emitLinkedBookingMatchesTwice"
>
Emit linked booking matches twice
</button>
</div>
`,
};
@@ -422,6 +445,27 @@ describe("SelectVehicleFormPOS", () => {
expect(wrapper.findAll('[data-testid="customer-search-card-payment-stub"]')).toHaveLength(2);
});
it("ignores repeated identical linked vehicle emissions", async () => {
const wrapper = mountForm();
await wrapper.get('[data-testid="emit-linked-vehicle-twice"]').trigger("click");
await nextTick();
expect(wrapper.findAll('[data-testid="customer-search-field-pos-stub"]')).toHaveLength(0);
expect(posState.searchAndSelectCustomer).not.toHaveBeenCalled();
});
it("does not re-apply identical booking matches", async () => {
const wrapper = mountForm();
await wrapper.get('[data-testid="emit-linked-booking-matches-twice"]').trigger("click");
await flushPromises();
expect(posState.setSelectedOrderBookingSelection).toHaveBeenCalledTimes(1);
expect(posState.searchAndSelectCustomer).toHaveBeenCalledTimes(1);
expect(posState.searchAndSelectCustomer).toHaveBeenCalledWith(12345679);
});
it("hides a booked desktop vehicle summary after continuing without booking", async () => {
const wrapper = mountForm();
@@ -17,7 +17,8 @@ describe("self-serve studio condition scope", () => {
'const nodeScopeLabel = (data) => scopeParts(data).join(" / ") || data?.subtitle || "Shared scope";'
);
expect(studioSource).toContain("v-else-if=\"selectedNode.data.kind === 'condition'\"");
expect(studioSource).toContain("<div><dt>Scope</dt><dd>{{ nodeScopeLabel(data) }}</dd></div>");
expect(studioSource).toContain("<dt>Scope</dt>");
expect(studioSource).toContain("<dd>{{ nodeScopeLabel(data) }}</dd>");
});
it("builds conditions with expression groups, branches, and cases", () => {
@@ -11,9 +11,17 @@ describe("self-serve studio managed inspector", () => {
it("routes non-graph self-serve nodes to managed inspector panels", () => {
const source = studioSource();
expect(source).toContain(
'const managedInspectorNodeKinds = ["runtime_checkpoint", "vehicle_type", "machine_type", "edge_gateway", "relay_binding", "relay"];'
);
expect(source).toContain("const managedInspectorNodeKinds = [");
for (const nodeKind of [
"runtime_checkpoint",
"vehicle_type",
"machine_type",
"edge_gateway",
"relay_binding",
"relay",
]) {
expect(source).toContain(`"${nodeKind}"`);
}
expect(source).toContain('v-else-if="isSelectedManaged"');
expect(source).toContain('data-testid="studio-managed-inspector"');
expect(source).toContain('data-testid="studio-runtime-inspector"');
@@ -42,7 +50,8 @@ describe("self-serve studio managed inspector", () => {
expect(source).toContain('return applyVirtualHardwareOperation("upsert_gateway", {');
expect(source).toContain("const saveSelectedVirtualBinding = () => {");
expect(source).toContain('return applyVirtualHardwareOperation("upsert_binding", payload);');
expect(source).toContain("const deleteSelectedVirtualBinding = () => deleteVirtualBinding({");
expect(source).toContain("const deleteSelectedVirtualBinding = () =>");
expect(source).toContain("deleteVirtualBinding({");
expect(source).toContain("const selectedGatewayBindingEntries = computed(() => {");
expect(source).toContain("const selectedRelayBindingEntries = computed(() => {");
});
@@ -46,9 +46,8 @@ describe("self-serve studio task editing", () => {
const source = studioSource();
expect(source).toContain("getSelfServeDynamicImageButtonsToPress(simulatorPreviewTasks.value)");
expect(source).toContain(
"const simulatorDynamicImageCurrentStep = computed(() => getSelfServeCompletedDynamicImageStep("
);
expect(source).toContain("const simulatorDynamicImageCurrentStep = computed(() =>");
expect(source).toContain("getSelfServeCompletedDynamicImageStep(");
expect(source).toContain("const simulatorDynamicImageUrl = computed(() => {");
expect(source).toContain("buildSelfServeDynamicImageUrl({");
expect(source).toContain("currentStep: simulatorDynamicImageCurrentStep.value");
@@ -77,10 +76,10 @@ describe("self-serve studio task editing", () => {
const source = studioSource();
expect(source).toContain("const scopeFiltersFromRoute = () => ({");
expect(source).toContain("const showScopeGuide = computed(() => (");
expect(source).toContain("const showScopeGuide = computed(");
expect(source).toContain("const applyScopeGuideSelection = () => {");
expect(source).toContain("const writeScopeToRoute = () => {");
expect(source).toContain("const simulatorStorageKey = computed(() => [");
expect(source).toContain("const simulatorStorageKey = computed(() =>");
expect(source).toContain("const restoreSimulatorStateFromStorage = () => {");
expect(source).toContain("const persistSimulatorStateToStorage = () => {");
expect(source).toContain('data-testid="studio-scope-guide"');
@@ -93,7 +92,7 @@ describe("self-serve studio task editing", () => {
const source = studioSource();
expect(source).toContain("const taskAttachmentList = (dataOrRaw) => {");
expect(source).toContain("const selectedTaskAttachments = computed(() => (");
expect(source).toContain("const selectedTaskAttachments = computed(() =>");
expect(source).toContain("const uploadSelectedTaskAttachment = async (event) => {");
expect(source).toContain("const deleteSelectedTaskAttachment = async (attachment) => {");
expect(source).toContain('data-testid="studio-task-node-attachments"');
@@ -42,7 +42,7 @@ describe("self-serve studio vehicle type scope", () => {
expect(source).toContain("const selectedSimulatorLaneId = computed(() => firstPositiveInt(");
expect(source).toContain("filters.value.lane_id");
expect(source).toContain("const selectedSimulatorVehicleTypeId = computed(() => (");
expect(source).toContain("const selectedSimulatorVehicleTypeId = computed(() =>");
expect(source).toContain("vehicleTypeMatchesFilter(vehicleType, selectedValue)");
expect(source).toContain("lane_id: laneId");
expect(source).toContain("vehicle_type_id: selectedSimulatorVehicleTypeId.value");
+20
View File
@@ -37,6 +37,11 @@ const PUBLIC_ASSET_ALIASES = [
]
function getGitCommit() {
const envCommit = firstReleaseCommitEnv()
if (envCommit) {
return envCommit.slice(0, 12)
}
try {
return execSync('git rev-parse --short HEAD').toString().trim()
} catch {
@@ -45,6 +50,11 @@ function getGitCommit() {
}
function getGitCommitSha() {
const envCommit = firstReleaseCommitEnv()
if (envCommit) {
return envCommit
}
try {
return execSync('git rev-parse HEAD').toString().trim()
} catch {
@@ -52,6 +62,16 @@ function getGitCommitSha() {
}
}
function firstReleaseCommitEnv() {
for (const key of ['RELEASE_COMMIT_SHA', 'SOURCE_COMMIT', 'COMMIT_SHA', 'GITHUB_SHA', 'VITE_COMMIT_HASH']) {
const value = String(process.env[key] || '').trim()
if (/^[0-9a-f]{7,40}$/i.test(value)) {
return value
}
}
return ''
}
function releaseBuildId(commitSha) {
const explicitBuildId = process.env.RELEASE_BUILD_ID || process.env.BUILD_ID
if (explicitBuildId) {