Files
pleno-vue/tests/e2e/invoicing-period.smoke.spec.js
Jeppe Bandopenhands c207fea61e feat(period): render customer indicator chips on every subpage including Alle (#299)
## Summary

Pairs with
[copenhagentruckwash/api#371](https://github.com/copenhagentruckwash/api/pull/371)
to render category indicator chips (e.g. *Faktura pr. ordre*,
*Fastpris*, *Tankrengøring*) on every Superuser → Fakturaer → Periode
subpage, including the *Alle* tab.

## What changed

* `InvoicingBillingPeriodCustomerAttributes.vue` pre-computes a
`Set<customer_number>` per view bucket so membership lookups are O(1)
regardless of bucket size. The component already iterated
`sharedVariables.types`; this PR just hoists the membership check out of
the per-chip `Array.some()` into a precomputed Set index.
* Skips entries that don't carry a positive integer `customer_number` so
non-numeric or null payloads from legacy clients stay inert.
* Honours the deterministic `ATTRIBUTE_DISPLAY_PRIORITY` ordering across
the chips.

## Tests

### Unit (vitest, jsdom)


`tests/unit/invoicing-billing-period-customer-attributes-membership.spec.js`
adds five focused tests covering:

* active-bucket full-card path,
* lightweight-membership rendering on the *Alle* tab,
* explicit `all` exclusion from chip membership,
* defensive numeric guard for malformed entries,
* deterministic display order across buckets.

### e2e (Playwright)

* New `@smoke` spec "period customer attribute chips render on every
subpage including Alle" validates that `invoice_per_order`,
`fixed_pricing`, and `tank_cleaning` chips all render on the *Alle* tab
and that single-category customers render exactly one chip.
* Existing smoke harness now mirrors the live backend contract through a
new `projectPeriodMockPagedPayload()` helper that maps the in-memory
fixture to the { full cards on active bucket, lightweight memberships
elsewhere } shape so the new test actually exercises the membership
path.

## Plan

`docs/invoicing-period-tag-membership-plan.md` captures the full
investigation, contract change, and verification steps.

🤖 Generated by [OpenHands](https://docs.openhands.dev/) on behalf of
copenhagentruckwash.

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 14:36:23 +02:00

3641 lines
137 KiB
JavaScript

import { expect, test } from "@playwright/test";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
const periodRouteReadyTimeout = process.env.CI ? 30_000 : 15_000;
function json(body, status = 200) {
return {
status,
contentType: "application/json",
body: JSON.stringify(body),
};
}
function matchesApiPath(urlString, expectedPath) {
const url = new URL(urlString);
return url.pathname === expectedPath || url.pathname === `/api${expectedPath}`;
}
async function captureXlvaskVisualEvidence(page, testInfo) {
const phase = String(process.env.XLVASK_VISUAL_EVIDENCE_PHASE || "").trim();
const evidenceRoot = String(process.env.XLVASK_VISUAL_EVIDENCE_ROOT || "").trim();
if (!evidenceRoot || !["before", "after"].includes(phase)) {
return false;
}
const projectName = String(testInfo.project.name || "");
const device = projectName.includes("mobile") ? "mobile" : projectName.includes("tablet") ? "tablet" : "desktop";
const outputDirectory = path.join(evidenceRoot, "invoice-period-self-wash");
await mkdir(outputDirectory, { recursive: true });
await page.getByTestId("invoicing-period-self-wash-view").scrollIntoViewIfNeeded();
await page.screenshot({
path: path.join(outputDirectory, `${phase}-${device}.png`),
fullPage: false,
});
return phase === "before";
}
function periodFixtureDate(dateFrom, fallback = "2026-07-14") {
const value = String(dateFrom || fallback).slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : fallback;
}
async function suppressVueDevtoolsOverlay(page) {
await page.addInitScript(() => {
const STYLE_ID = "__e2e-hide-vue-devtools";
localStorage.setItem("lastVersionCheck", String(Date.now()));
const apply = () => {
const target = document.head || document.documentElement;
if (!target) {
return;
}
if (!document.getElementById(STYLE_ID)) {
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent =
"#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
target.appendChild(style);
}
const container = document.getElementById("__vue-devtools-container__");
if (container) {
container.style.display = "none";
container.style.pointerEvents = "none";
}
};
apply();
const startObserving = () => {
if (!document.documentElement) {
requestAnimationFrame(startObserving);
return;
}
const observer = new MutationObserver(apply);
observer.observe(document.documentElement, { childList: true, subtree: true });
};
startObserving();
});
}
function createPeriodPayload() {
return {
types: {
all: [
{
id: 11,
customer_number: 4001,
customer_name: "Acme Fleet",
requires_action: true,
transactions: [{ id: 9001, date: "2026-03-10T10:00:00.000Z", amount: 120, booked: false, excluded: false }],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {
fixed_pricing: {
price: 500,
},
},
},
{
id: 12,
customer_number: 4002,
customer_name: "Nordic Transport",
requires_action: false,
transactions: [{ id: 9002, date: "2026-03-11T10:00:00.000Z", amount: 80, booked: true, excluded: false }],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
],
invoice_per_order: [
{
id: 13,
customer_number: 7001,
customer_name: "Invoice Per Order Co",
requires_action: true,
transactions: [{ id: 9003, date: "2026-03-12T10:00:00.000Z", amount: 75, booked: false, excluded: false }],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
],
fixed_pricing: [
{
id: 11,
customer_number: 4001,
customer_name: "Acme Fleet",
requires_action: true,
transactions: [{ id: 9005, date: "2026-03-31T10:00:00.000Z", amount: 1000, booked: true, excluded: false }],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {
fixed_pricing: {
price: 1000,
},
},
},
],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [
{
id: 14,
customer_number: 8001,
customer_name: "Subscription Movers",
requires_action: true,
transactions: [{ id: 9004, date: "2026-03-13T10:00:00.000Z", amount: 60, booked: false, excluded: false }],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
],
possible_duplicates: [],
},
};
}
// Mirrors the real backend's applyPeriodPagination: only the requested
// `periodView` bucket keeps full customer cards; every other bucket is
// replaced with lightweight `{customer_number, membership_only}`
// memberships deduplicated by customer_number. This keeps the e2e mock
// in lock-step with the live API contract so the chip-stacking
// scenarios actually exercise the membership path.
function projectPeriodMockPagedPayload(payload, periodView) {
if (!payload || !payload.types || payload.__rawResponse) {
return payload;
}
const activeView = periodView && payload.types[periodView] !== undefined ? periodView : "all";
const types = payload.types;
const projected = {};
for (const [typeName, customers] of Object.entries(types)) {
if (typeName === activeView) {
projected[typeName] = Array.isArray(customers) ? customers.slice() : [];
continue;
}
const seen = new Set();
const memberships = [];
if (Array.isArray(customers)) {
for (const customer of customers) {
if (!customer || typeof customer !== "object") continue;
const customerNumber = Number(customer.customer_number);
if (!Number.isInteger(customerNumber) || customerNumber < 1) continue;
if (seen.has(customerNumber)) continue;
seen.add(customerNumber);
memberships.push({
customer_number: customerNumber,
membership_only: true,
});
}
}
projected[typeName] = memberships;
}
return { ...payload, types: projected };
}
function createObjectTreePeriodPayload({ dateFrom = "2026-07-14" } = {}) {
const payload = createPeriodPayload();
const fixtureDate = periodFixtureDate(dateFrom);
const order = {
id: 9001,
customer_id: 4101,
customer_number: 4101,
customer_name: "Object Tree Logistics",
cashier_id: 7,
cashier_name: "Jeppe",
department_id: 1,
department_name: "Copenhagen",
invoice_collection_id: 3001,
invoice_collection: {
id: 3001,
name: "",
notes: "",
po_number: "",
external_id: "",
closed_at: "",
},
date: `${fixtureDate}T10:00:00.000Z`,
created_at: `${fixtureDate} 10:00:00`,
amount: 360,
total_net_amount: 360,
reference: "FORVOGN-REF-9001",
notes: "",
po: "",
reg_1: "OT4101",
reg_2: "",
reg_3: "",
include_in_invoice: null,
attachments: [],
booked: false,
excluded: false,
};
payload.types.all = [
{
id: 91,
customer_number: 4101,
customer_name: "Object Tree Logistics",
requires_action: true,
transactions: [order],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
];
return payload;
}
function createObjectTreeV2PeriodPayload(options = {}) {
const payload = createObjectTreePeriodPayload(options);
payload.capabilities = { object_tree_v2: true };
return payload;
}
function createObjectTreeV2Snapshot(revision = "snapshot-rev-1") {
const periodOrder = createObjectTreePeriodPayload({ dateFrom: "2026-07-01" }).types.all[0].transactions[0];
return {
complete: true,
snapshot_revision: revision,
customer_number: 4101,
date_from: "2026-07-01",
date_to: "2026-07-31",
capabilities: {
object_tree_v2: true,
actions: {
queue_economic: true,
remove_customer_rule_violations: false,
merge_collections: false,
split_by_month: false,
reset_hidden_item_prices: false,
},
},
customer: { customer_number: 4101, customer_name: "Object Tree Logistics" },
collections: [
{
id: 3001,
in_selected_period: true,
complete_order_count: 2,
complete_total_net_amount: 460,
period_order_count: 1,
period_total_net_amount: 360,
orders: [
{
...periodOrder,
in_selected_period: true,
items: [{ id: 7701, order_id: 9001, product_name: "Period wash", price: 360, quantity: 1 }],
attachments: [],
bookings: [],
xlvask: [],
},
{
...periodOrder,
id: 9002,
date: "2026-06-29T10:00:00.000Z",
created_at: "2026-06-29 10:00:00",
amount: 100,
total_net_amount: 100,
in_selected_period: false,
items: [{ id: 7702, order_id: 9002, product_name: "Off-period wash", price: 100, quantity: 1 }],
attachments: [],
bookings: [],
xlvask: [],
},
],
},
],
uncollected_orders: [],
agreements: [],
payments: [],
economic_invoices: [],
};
}
function createFlaggedPeriodPayload({
resolvedManualFlagIds = [],
resolvedAutomaticFingerprints = [],
flaggedTransactionBooked = false,
flaggedCustomerRequiresAction = true,
includeHiddenFilteredFlag = false,
invoiceCollectionId = null,
dateFrom = "2026-07-14",
} = {}) {
const payload = createPeriodPayload();
const fixtureDate = periodFixtureDate(dateFrom);
payload.types.all[0].requires_action = flaggedCustomerRequiresAction;
payload.types.all[0].transactions = payload.types.all[0].transactions.map((transaction) => ({
...transaction,
customer_id: 4001,
customer_name: "Acme Fleet",
cashier_id: 7,
cashier_name: "Copenhagen",
department_id: 1,
reference: "",
notes: "",
po: "",
reg_1: "AC4001",
reg_2: "",
reg_3: "",
amount: 120,
total_net_amount: 120,
invoice_collection_id: invoiceCollectionId,
invoice_collection: invoiceCollectionId
? {
id: invoiceCollectionId,
closed_at: `${fixtureDate} 10:30:00`,
booked_invoice_id: 28368,
}
: null,
economic_invoice_module: null,
stripe_invoice_module: null,
error_message: null,
completed_at: null,
pending_handheld: false,
user_id: 11,
booked: flaggedTransactionBooked,
date: `${fixtureDate}T10:00:00.000Z`,
created_at: `${fixtureDate} 10:00:00`,
}));
const flags = [
{
id: 501,
source: "manual",
severity: "red",
status: "active",
target_type: "customer",
target_id: 4001,
customer_number: 4001,
reason: "Manual red flag for customer review.",
message: "Manual red flag for customer review.",
created_at: "2026-05-11 10:00:00",
created_by: 7,
created_by_name: "Jeppe",
},
{
id: "auto-order-reference-1",
source: "automatic",
severity: "yellow",
status: "active",
target_type: "order_field",
target_id: 9001,
field: "reference",
customer_number: 4001,
order_id: 9001,
order_item_id: null,
definition_key: "customer_rule_requires_reference",
fingerprint: "order-reference-fingerprint-1",
message_key: "invoice_period.flags.automatic.customer_rule_requires_reference",
message_params: {},
message: "Order is missing a required reference.",
context: {
department_id: 1,
order_id: 9001,
order_items: [
{ id: 7701, product_name: "Spot Free", quantity: 1, price: 99 },
{ id: 7702, product_name: "Wash", quantity: 1, price: 199 },
],
},
},
{
id: "auto-price-1",
source: "automatic",
severity: "yellow",
status: "active",
target_type: "order_item_field",
target_id: 7701,
field: "price",
customer_number: 4001,
order_id: 9001,
order_item_id: 7701,
definition_key: "price_mismatch",
fingerprint: "price-fingerprint-1",
message_key: "invoice_period.flags.automatic.price_mismatch",
message_params: {
product: "Spot Free",
},
message: "Spot Free product price differs from expected.",
message_parts: [
{ type: "order_item", text: "Spot Free" },
{ type: "text", text: " product price differs from " },
{ type: "expected_price", text: "expected" },
{ type: "text", text: "." },
],
context: {
department_id: 1,
order_id: 9001,
order_item_id: 7701,
order_items: [
{ id: 7701, product_name: "Spot Free", quantity: 1, price: 99 },
{ id: 7702, product_name: "Wash", quantity: 1, price: 199 },
],
expected_price_breakdown: {
product_price: 100,
department_price: 90,
product_discount_percentage: 10,
category_discount_percentage: 0,
applied_discount_percentage: 10,
expected_price: 81,
},
},
},
];
if (includeHiddenFilteredFlag) {
flags.push({
id: "auto-hidden-order-reference-1",
source: "automatic",
severity: "yellow",
status: "active",
target_type: "order_field",
target_id: 9006,
field: "reference",
customer_number: 4001,
order_id: 9006,
order_item_id: null,
invoice_collection_id: 16891,
definition_key: "customer_rule_requires_reference",
fingerprint: "hidden-order-reference-fingerprint-1",
message_key: "invoice_period.flags.automatic.customer_rule_requires_reference",
message_params: {},
message: "Hidden booked order is missing a required reference.",
context: {
department_id: 1,
order_id: 9006,
invoice_collection_id: 16891,
order_items: [{ id: 7716, product_name: "Hidden Wash", quantity: 1, price: 75 }],
},
});
}
const resolvedManualFlagIdSet = new Set(resolvedManualFlagIds.map((id) => String(id)));
const resolvedAutomaticFingerprintSet = new Set(
resolvedAutomaticFingerprints.map((fingerprint) => String(fingerprint))
);
const activeFlags = flags.filter((flag) => {
if (flag.source === "manual") {
return !resolvedManualFlagIdSet.has(String(flag.id));
}
return !resolvedAutomaticFingerprintSet.has(String(flag.fingerprint || flag.id));
});
const manualFlagCount = activeFlags.filter((flag) => flag.source === "manual").length;
const automaticFlagCount = activeFlags.filter((flag) => flag.source === "automatic").length;
const flaggedMetadata = {
status_indicator: manualFlagCount > 0 ? "flag_red" : automaticFlagCount > 0 ? "flag_yellow" : "circle_red",
flag_counts: {
manual: manualFlagCount,
automatic: automaticFlagCount,
total: manualFlagCount + automaticFlagCount,
},
flags: activeFlags,
};
payload.types.all[0] = {
...payload.types.all[0],
...flaggedMetadata,
};
payload.types.fixed_pricing[0] = {
...payload.types.fixed_pricing[0],
...flaggedMetadata,
};
return payload;
}
function createPossibleDuplicatesPeriodPayload({ dateFrom = "2026-05-11" } = {}) {
const duplicateDate = dateFrom || "2026-05-11";
const duplicateTransaction = (id, customerNumber, customerName, amount, hour) => ({
id,
amount,
booked: false,
excluded: false,
reg_1: customerNumber === 7201 ? "EC 21233" : "EC21233",
date: `${duplicateDate}T${hour}:00:00.000Z`,
created_at: `${duplicateDate} ${hour}:00:00`,
invoice_collection_id: id + 1000,
queue_status: null,
queue_job_id: null,
customer_number: customerNumber,
customer_name: customerName,
});
const duplicateCustomer = (id, customerNumber, customerName, transaction) => ({
id,
customer_number: customerNumber,
customer_name: customerName,
requires_action: true,
transactions: [transaction],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
});
return {
types: {
all: [],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [
duplicateCustomer(
71,
7201,
"Pleno Vognmandsforretning",
duplicateTransaction(9701, 7201, "Pleno Vognmandsforretning", 1523, "10")
),
duplicateCustomer(
72,
7202,
"Estland Alle ApS",
duplicateTransaction(9702, 7202, "Estland Alle ApS", 399, "11")
),
duplicateCustomer(73, 7203, "Single Wash ApS", {
...duplicateTransaction(9703, 7203, "Single Wash ApS", 50, "12"),
reg_1: "SINGLE1",
}),
],
},
};
}
function createPeriodPayloadForChangedRange() {
return {
types: {
all: [
{
id: 21,
customer_number: 5001,
customer_name: "April Logistics",
requires_action: true,
transactions: [{ id: 9101, date: "2026-04-10T10:00:00.000Z", amount: 250, booked: false, excluded: false }],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
],
invoice_per_order: [
{
id: 22,
customer_number: 5002,
customer_name: "April Per Order",
requires_action: true,
transactions: [{ id: 9102, date: "2026-04-11T11:00:00.000Z", amount: 150, booked: false, excluded: false }],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
};
}
function createQueuedPeriodPayload() {
return {
types: {
all: [
{
id: 31,
customer_number: 6001,
customer_name: "Queued Fleet",
requires_action: false,
transactions: [
{
id: 9201,
date: "2026-04-14T10:00:00.000Z",
amount: 210,
booked: false,
excluded: false,
invoice_collection_id: 14578,
queue_status: "QUEUED",
queue_job_id: 88,
},
],
queue: {
has_active_job: true,
statuses: ["QUEUED"],
invoice_collection_ids: [14578],
is_action_blocked: true,
},
meta: {},
},
],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
};
}
function createDraftBlockedPeriodPayload({ dateFrom = "2026-07-14" } = {}) {
const fixtureDate = periodFixtureDate(dateFrom);
return {
types: {
all: [
{
id: 41,
customer_number: 6101,
customer_name: "Drafted Fleet",
requires_action: false,
transactions: [
{
id: 9301,
date: `${fixtureDate}T10:00:00.000Z`,
amount: 210,
booked: false,
excluded: false,
invoice_collection_id: 16501,
queue_status: null,
queue_job_id: null,
},
],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
draft: {
has_valid_draft: true,
invoice_collection_ids: [16501],
is_action_blocked: true,
},
meta: {},
},
],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
};
}
function createStaleDraftPeriodPayload({ dateFrom = "2026-04-14" } = {}) {
const fixtureDate = periodFixtureDate(dateFrom, "2026-04-14");
return {
types: {
all: [
{
id: 42,
customer_number: 6102,
customer_name: "Deleted Draft Fleet",
requires_action: true,
transactions: [
{
id: 9302,
date: `${fixtureDate}T10:00:00.000Z`,
amount: 210,
booked: false,
excluded: false,
invoice_collection_id: 16502,
queue_status: null,
queue_job_id: null,
},
],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
draft: {
has_valid_draft: false,
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
};
}
function createAttributeStackPeriodPayload() {
const customer = (customerNumber, customerName, transactionId, amount = 210) => ({
id: customerNumber,
customer_number: customerNumber,
customer_name: customerName,
requires_action: true,
transactions: [
{
id: transactionId,
date: "2026-03-14T10:00:00.000Z",
amount,
booked: false,
excluded: false,
invoice_collection_id: transactionId + 1000,
queue_status: null,
queue_job_id: null,
},
],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
});
return {
types: {
all: [customer(4101, "Stacked Attributes Transport", 9401), customer(4102, "Single Attribute Logistics", 9402)],
invoice_per_order: [customer(4101, "Stacked Attributes Transport", 9401)],
fixed_pricing: [
customer(4101, "Stacked Attributes Transport", 9401),
customer(4102, "Single Attribute Logistics", 9402),
],
tank_cleaning: [customer(4101, "Stacked Attributes Transport", 9401)],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
};
}
function createParallelInvoicePeriodPayload() {
const readyCustomer = (customerNumber, invoiceCollectionId, name) => ({
id: customerNumber,
customer_number: customerNumber,
customer_name: name,
requires_action: true,
transactions: [
{
id: invoiceCollectionId - 1000,
date: "2026-04-14T10:00:00.000Z",
amount: 210,
booked: false,
excluded: false,
invoice_collection_id: invoiceCollectionId,
queue_status: null,
queue_job_id: null,
},
],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
});
return {
types: {
all: [
readyCustomer(4001, 55501, "Acme Fleet"),
readyCustomer(4003, 55503, "Baltic Freight"),
readyCustomer(4004, 55504, "Open Haulage"),
],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
};
}
function createParallelInvoiceTargetedPayload(customerNumbers) {
const completedCustomer = (customerNumber, name) => ({
id: customerNumber,
customer_number: customerNumber,
customer_name: name,
requires_action: false,
transactions: [
{
id: customerNumber + 100,
date: "2026-04-14T10:00:00.000Z",
amount: 210,
booked: true,
excluded: false,
invoice_collection_id: null,
queue_status: null,
queue_job_id: null,
},
],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
});
const customersByNumber = {
4001: completedCustomer(4001, "Acme Fleet"),
4003: completedCustomer(4003, "Baltic Freight"),
};
return {
types: {
all: customerNumbers.map((customerNumber) => customersByNumber[customerNumber]).filter(Boolean),
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
};
}
async function setupPeriodEndpoints(page, requests, options = {}) {
let initialRange = null;
await page.route("**/superuser/invoicing/period**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
const url = new URL(route.request().url());
if (!matchesApiPath(route.request().url(), "/superuser/invoicing/period")) {
await route.fallback();
return;
}
const dateFrom = url.searchParams.get("dateFrom");
const dateTo = url.searchParams.get("dateTo");
const isWarmRequest = url.searchParams.get("periodWarm") === "1";
if (!isWarmRequest) {
requests.push({
dateFrom,
dateTo,
});
}
if (!initialRange && !isWarmRequest) {
initialRange = { dateFrom, dateTo };
}
const didChangeRange = !initialRange || dateFrom !== initialRange.dateFrom || dateTo !== initialRange.dateTo;
const isDistributionFixtureRange =
dateTo === "2026-03-31" && ["2026-02-28", "2026-03-01", "2026-03-02", "2026-03-03"].includes(dateFrom);
let payload;
if (options.payloadFactory) {
payload = await options.payloadFactory({
dateFrom,
dateTo,
didChangeRange,
isDistributionFixtureRange,
url,
});
} else if (didChangeRange && !isDistributionFixtureRange) {
payload = createPeriodPayloadForChangedRange();
} else {
payload = createPeriodPayload();
}
// Mirror the real backend contract: only the requested periodView
// bucket carries full customer cards; every other bucket carries a
// lightweight `{customer_number, membership_only}` membership so the
// front-end can render category indicator chips on every subpage.
payload = projectPeriodMockPagedPayload(payload, url.searchParams.get("periodView"));
await route.fulfill(
json(
payload?.__rawResponse ?? {
data: payload,
}
)
);
});
await page.route("**/superuser/invoicing/period/distribution/fixed-pricing**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill(
json({
includes: {
collective_fixed_pricing_results: {
total_fixed_price: 1000,
total_original_price: 400,
total_department_totals: {
1: 220,
2: 180,
},
total_department_totals_parsed: {
Copenhagen: 220,
Odense: 180,
},
total_department_totals_relative_parsed: {
Copenhagen: 600,
Odense: 400,
},
},
},
})
);
});
await page.route("**/superuser/invoicing/period/distribution/v2/booked-department-75**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill(
json({
data: {
customers: [
{
customer_number: 4001,
customer_name: "Acme Fleet",
meta: {
booked_department_75: {
booked_net_amount: 1000,
distributed_net_amount: 1000,
undistributed_net_amount: 0,
department_distribution: {
1: 700,
2: 300,
},
booked_groups: [
{
month: "2026-03",
source_category: "fixed_pricing",
booked_net_amount: 1000,
department_distribution: {
1: 700,
2: 300,
},
undistributed_net_amount: 0,
},
{
month: "2026-03",
source_category: "wash_subscriptions",
booked_net_amount: 600,
department_distribution: {
1: 500,
2: 100,
},
undistributed_net_amount: 0,
},
],
},
},
},
],
collective_results: {
booked_net_amount: 1600,
distributed_net_amount: 1600,
undistributed_net_amount: 0,
department_distribution: {
1: 1200,
2: 400,
},
},
warnings: [],
},
})
);
});
await page.route("**/superuser/invoicing/period/distribution/wash-subscriptions**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill(
json({
includes: {
collective_subscription_results: {
total_subscription_price: 300,
subscription_price_department_distribution: {
1: 200,
2: 100,
},
subscription_price_department_distribution_parsed: {
Copenhagen: 200,
Odense: 100,
},
},
},
})
);
});
await page.route("**/departments**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
const url = new URL(route.request().url());
if (!(url.pathname === "/departments" || url.pathname === "/api/departments")) {
await route.fallback();
return;
}
await route.fulfill(
json({
data: [
{ id: 1, name: "Copenhagen" },
{ id: 2, name: "Odense" },
],
})
);
});
}
async function openPeriodView(page, options = {}) {
const periodRequests = [];
const token = "superuser-period-e2e-token";
await suppressVueDevtoolsOverlay(page);
await seedAuthenticatedState(page, token);
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
loginToken: token,
invoiceDistribution: true,
});
await setupPeriodEndpoints(page, periodRequests, options);
if (typeof options.beforeGoto === "function") {
await options.beforeGoto(page);
}
await page.goto("/superuser/invoices?activeTab=period&startDate=2026-07-01&endDate=2026-07-31&periodView=all", {
waitUntil: "domcontentloaded",
});
await expect(page).toHaveURL(/activeTab=period/);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({
timeout: periodRouteReadyTimeout,
});
return { periodRequests };
}
const periodDateInput = (page, testId) =>
page.getByTestId("invoicing-period-view").getByTestId(testId).locator("input").first();
async function fillPeriodDate(page, testId, value) {
const input = periodDateInput(page, testId);
await expect(input).toBeVisible();
await input.fill(value);
await input.blur();
return input;
}
async function setEntireMarchPeriod(page) {
await fillPeriodDate(page, "date-period-start", "2026-03-01");
await fillPeriodDate(page, "date-period-end", "2026-03-31");
await expect(page.getByTestId("invoicing-period-view-selector-fixed_pricing")).toBeVisible();
}
async function selectPeriodView(page, viewName) {
await page.getByTestId(`invoicing-period-view-selector-${viewName}`).click();
await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute("data-current-view", viewName);
}
async function expandPeriodAnalytics(page) {
const toggle = page.getByTestId("invoicing-period-analytics").locator(".period-analytics__toggle");
if ((await toggle.getAttribute("aria-expanded")) !== "true") {
await toggle.click();
}
await expect(toggle).toHaveAttribute("aria-expanded", "true");
}
async function seedInvoicesPage(page, token = "superuser-period-e2e-token") {
await suppressVueDevtoolsOverlay(page);
await seedAuthenticatedState(page, token);
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
loginToken: token,
invoiceDistribution: true,
});
}
async function routeFlaggedPeriodOrders(
page,
{ invoiceCollectionId = null, includeHiddenCollectionOrder = false } = {}
) {
await page.route("**/orders**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/orders")) {
await route.fallback();
return;
}
await route.fulfill(
json({
data: [
{
id: 9001,
customer_id: 4001,
customer_name: "Acme Fleet",
cashier_id: 7,
cashier_name: "Copenhagen",
department_id: 1,
reference: "",
notes: "",
po: "",
reg_1: "AC4001",
reg_2: "",
reg_3: "",
created_at: "2026-03-10 10:00:00",
total_net_amount: 120,
invoice_collection_id: invoiceCollectionId,
invoice_collection: invoiceCollectionId
? {
id: invoiceCollectionId,
closed_at: "2026-03-10 10:30:00",
booked_invoice_id: 28368,
}
: null,
economic_invoice_module: null,
stripe_invoice_module: null,
error_message: null,
completed_at: null,
pending_handheld: false,
user_id: 11,
},
],
meta: {
pagination: {
limit: 20,
total: 1,
},
},
})
);
});
if (includeHiddenCollectionOrder && invoiceCollectionId) {
await page.route("**/collected-invoices**", async (route) => {
const url = new URL(route.request().url());
if (
route.request().method() !== "GET" ||
!matchesApiPath(route.request().url(), "/collected-invoices") ||
url.searchParams.get("id") !== String(invoiceCollectionId)
) {
await route.fallback();
return;
}
await route.fulfill(
json({
data: {
id: invoiceCollectionId,
orders: [
{ id: 9001, invoice_collection_id: invoiceCollectionId },
{ id: 9006, invoice_collection_id: invoiceCollectionId },
],
},
})
);
});
}
}
async function routeFlaggedPeriodOrderItems(page) {
await page.route("**/order/items**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/order/items")) {
await route.fallback();
return;
}
await route.fulfill(
json({
data: [
{
id: 7701,
order_id: 9001,
product_id: 101,
reference: "",
notes: "",
price: 99,
quantity: 1,
related_item_id: 0,
include_in_invoice: true,
product: {
id: 101,
name: "Spot Free",
price: 100,
},
},
{
id: 7702,
order_id: 9001,
product_id: 102,
reference: "",
notes: "",
price: 199,
quantity: 1,
related_item_id: 0,
include_in_invoice: true,
product: {
id: 102,
name: "Wash",
price: 199,
},
},
],
})
);
});
}
async function routeObjectTreeOrderEndpoints(page) {
const order = createObjectTreePeriodPayload().types.all[0].transactions[0];
await page.route("**/order**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/order")) {
await route.fallback();
return;
}
await route.fulfill(
json({
success: true,
data: order,
})
);
});
await page.route("**/order/items**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/order/items")) {
await route.fallback();
return;
}
await route.fulfill(
json({
data: [
{
id: 7701,
order_id: 9001,
product_id: 101,
product_name: "Forvogn med ekstra langt produktnavn til visuel afkortning",
reference: "",
notes: "",
price: 240,
quantity: 1,
},
{
id: 7702,
order_id: 9001,
related_item_id: 7701,
product_id: 102,
product_name: "Bagvogn",
reference: "TRAILER",
notes: "",
price: 120,
quantity: 1,
},
],
})
);
});
}
async function routeObjectTreeV2Endpoints(page, state) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url());
if (route.request().method() === "GET" && url.pathname.endsWith("/superuser/invoicing/period/tree")) {
state.snapshotRequests.push(url.searchParams.get("customerNumber"));
const revision = state.snapshotRequests.length === 1 ? "snapshot-rev-1" : "snapshot-rev-2";
await route.fulfill(json({ data: createObjectTreeV2Snapshot(revision) }));
return;
}
if (route.request().method() === "POST" && url.pathname.endsWith("/tree-actions/preview")) {
state.previewRequests.push(route.request().postDataJSON());
await route.fulfill(
json({
data: {
preview_id: `preview-${state.previewRequests.length}`,
confirmation_phrase: "CONFIRM",
summary: {
collection_count: 1,
changed_count: 1,
off_period_order_count: 1,
off_period_total_net_amount: 100,
},
changes: [{ message: "Queue invoice collection #3001" }],
blockers: [],
},
})
);
return;
}
if (route.request().method() === "POST" && url.pathname.endsWith("/tree-actions/apply")) {
state.applyRequests.push(route.request().postDataJSON());
await route.fulfill(
state.applyRequests.length === 1
? json({ message: "Snapshot revision is stale" }, 409)
: json({ data: { jobs: [{ id: 701 }], result: { changed_count: 1 } } })
);
return;
}
await route.fallback();
});
}
async function expandTreeNode(page, key) {
const node = page.locator(`[data-node-key="${key}"]`).first();
await expect(node).toBeVisible();
if ((await node.getAttribute("aria-expanded")) !== "true") {
await node.locator(":scope > .b-tree-node-content > .b-tree-node-toggle").click();
await expect(node).toHaveAttribute("aria-expanded", "true");
}
}
async function getBoundingBox(locator, label) {
await expect(locator).toBeVisible();
let box = null;
await expect
.poll(
async () => {
box = await locator.boundingBox();
return box !== null;
},
{ message: `${label} did not produce a bounding box` }
)
.toBe(true);
return box;
}
function boxesOverlap(firstBox, secondBox, tolerance = 1) {
const horizontalOverlap =
Math.max(firstBox.x, secondBox.x) <
Math.min(firstBox.x + firstBox.width, secondBox.x + secondBox.width) - tolerance;
const verticalOverlap =
Math.max(firstBox.y, secondBox.y) <
Math.min(firstBox.y + firstBox.height, secondBox.y + secondBox.height) - tolerance;
return horizontalOverlap && verticalOverlap;
}
function expectBoxInside(innerBox, outerBox, label, tolerance = 1) {
expect(innerBox.x, `${label} left edge`).toBeGreaterThanOrEqual(outerBox.x - tolerance);
expect(innerBox.y, `${label} top edge`).toBeGreaterThanOrEqual(outerBox.y - tolerance);
expect(innerBox.x + innerBox.width, `${label} right edge`).toBeLessThanOrEqual(
outerBox.x + outerBox.width + tolerance
);
expect(innerBox.y + innerBox.height, `${label} bottom edge`).toBeLessThanOrEqual(
outerBox.y + outerBox.height + tolerance
);
}
test.describe("Invoicing period tab", () => {
test("@smoke @pr period view waits for an inline month selection when dates are absent", async ({ page }) => {
const periodRequests = [];
const selectedPeriodRequests = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (
request.method() === "GET" &&
matchesApiPath(request.url(), "/superuser/invoicing/period") &&
url.searchParams.has("periodView") &&
url.searchParams.get("periodWarm") !== "1"
) {
selectedPeriodRequests.push({
dateFrom: url.searchParams.get("dateFrom"),
dateTo: url.searchParams.get("dateTo"),
});
}
});
await page.clock.setFixedTime(new Date("2026-07-15T10:00:00.000Z"));
await seedInvoicesPage(page);
await setupPeriodEndpoints(page, periodRequests);
await page.goto("/superuser/invoices?activeTab=period", { waitUntil: "domcontentloaded" });
const monthSelector = page.getByTestId("invoicing-period-inline-month-selector");
await expect(monthSelector).toBeVisible({ timeout: periodRouteReadyTimeout });
await expect(monthSelector.locator(".datepicker-months .datepicker-cell")).toHaveCount(12);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toHaveAttribute("aria-disabled", "true");
await expect(page.getByTestId("date-period-start")).toHaveCount(0);
expect(selectedPeriodRequests).toHaveLength(0);
const mainRegion = page.getByTestId("invoicing-period-layout-main");
const navigationRegion = page.getByTestId("invoicing-period-layout-navigation");
const mainBox = await getBoundingBox(mainRegion, "period main region");
const navigationBox = await getBoundingBox(navigationRegion, "period navigation region");
const monthSelectorBox = await getBoundingBox(monthSelector, "inline month selector");
const viewportWidth = page.viewportSize()?.width ?? 0;
expectBoxInside(monthSelectorBox, mainBox, "inline month selector");
expect(monthSelectorBox.width).toBeGreaterThanOrEqual(Math.min(320, mainBox.width - 2));
expect(monthSelectorBox.width).toBeLessThanOrEqual(514);
if (viewportWidth >= 1024) {
expect(navigationBox.x).toBeGreaterThanOrEqual(mainBox.x + mainBox.width + 8);
expect(navigationBox.width / (mainBox.width + navigationBox.width)).toBeGreaterThanOrEqual(0.2);
expect(navigationBox.width / (mainBox.width + navigationBox.width)).toBeLessThanOrEqual(0.4);
} else {
expect(navigationBox.y + navigationBox.height).toBeLessThanOrEqual(mainBox.y + 1);
}
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth))
.toBeLessThanOrEqual(2);
const julyOption = monthSelector.locator(".datepicker-months .datepicker-cell").filter({ hasText: /jul/i });
await expect(julyOption).toHaveCount(1);
await julyOption.click();
await expect
.poll(() => selectedPeriodRequests.at(-1))
.toEqual({
dateFrom: "2026-07-01",
dateTo: "2026-07-31",
});
await expect(page).toHaveURL(/startDate=2026-07-01/);
await expect(page).toHaveURL(/endDate=2026-07-31/);
await expect(page).toHaveURL(/periodView=all/);
await expect(page.getByTestId("invoicing-period-inline-month-selector")).toHaveCount(0);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toHaveAttribute("aria-disabled", "false");
});
test("@smoke @pr period view does not throw queue refresh errors on load", async ({ page }) => {
const pageErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
await openPeriodView(page);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible();
await page.waitForTimeout(250);
expect(pageErrors).not.toEqual(expect.arrayContaining([expect.stringContaining("reading 'value'")]));
});
test("@smoke period view loads selectors and displays all-customer list", async ({ page }) => {
const { periodRequests } = await openPeriodView(page);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible();
await expect(page.getByTestId("invoicing-period-view-selector-invoice_per_order")).toBeVisible();
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute("data-current-view", "all");
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-4002")).toBeVisible();
expect(periodRequests.length).toBeGreaterThan(0);
});
test("@pr period view keeps every right-rail selector column aligned", async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await openPeriodView(page);
const selectorNames = [
"all",
"possible_duplicates",
"invoice_per_order",
"fixed_pricing",
"special_arrangements",
"vehicle_subscriptions",
"tank_cleaning",
"self_wash",
];
const readRailGeometry = async () => {
const selectors = selectorNames.map((name) => page.getByTestId(`invoicing-period-view-selector-${name}`));
await Promise.all(selectors.map((selector) => expect(selector).toBeVisible()));
return page.getByTestId("invoicing-period-view-selectors").evaluate((navigation, names) => {
const readRect = (element) => {
const rect = element.getBoundingClientRect();
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
centerY: rect.y + rect.height / 2,
};
};
const selectorsByName = names.map((name) => {
const selector = navigation.querySelector(`[data-testid="invoicing-period-view-selector-${name}"]`);
return {
name,
selector: readRect(selector),
row: readRect(selector.querySelector(".period-selector-row")),
icon: readRect(selector.querySelector(".period-selector-label .icon")),
label: readRect(selector.querySelector(".period-selector-label .icon-text > span:last-child")),
progress: readRect(selector.querySelector(".period-selector-progress-slot")),
chevron: readRect(selector.querySelector(".period-selector-chevron")),
};
});
const groups = [...navigation.querySelectorAll(".period-view-navigation__group")].map((group) => {
const heading = readRect(group.querySelector(".period-view-navigation__heading"));
const cards = [...group.querySelectorAll(".is-selector-view")].map(readRect);
return { heading, cards };
});
return { selectors: selectorsByName, groups };
}, selectorNames);
};
for (const viewportWidth of [1920, 1280]) {
await page.setViewportSize({ width: viewportWidth, height: 1080 });
const geometry = await readRailGeometry();
const first = geometry.selectors[0];
for (const item of geometry.selectors) {
expect(
Math.abs(item.selector.height - first.selector.height),
`${viewportWidth}px ${item.name} height`
).toBeLessThanOrEqual(1);
expect(Math.abs(item.row.x - first.row.x), `${viewportWidth}px ${item.name} row left`).toBeLessThanOrEqual(1);
expect(Math.abs(item.icon.x - first.icon.x), `${viewportWidth}px ${item.name} icon left`).toBeLessThanOrEqual(
1
);
expect(
Math.abs(item.label.x - first.label.x),
`${viewportWidth}px ${item.name} label left`
).toBeLessThanOrEqual(1);
expect(
Math.abs(item.progress.x - first.progress.x),
`${viewportWidth}px ${item.name} progress left`
).toBeLessThanOrEqual(1);
expect(
Math.abs(item.progress.width - first.progress.width),
`${viewportWidth}px ${item.name} progress width`
).toBeLessThanOrEqual(1);
expect(
Math.abs(item.chevron.x - first.chevron.x),
`${viewportWidth}px ${item.name} chevron left`
).toBeLessThanOrEqual(1);
expect(
Math.abs(item.row.centerY - item.selector.centerY),
`${viewportWidth}px ${item.name} vertical center`
).toBeLessThanOrEqual(1);
}
const cardGaps = [];
for (const group of geometry.groups) {
expect(
Math.abs(group.heading.x - group.cards[0].x),
`${viewportWidth}px heading left edge`
).toBeLessThanOrEqual(1);
for (let index = 1; index < group.cards.length; index += 1) {
cardGaps.push(group.cards[index].y - (group.cards[index - 1].y + group.cards[index - 1].height));
}
}
expect(Math.max(...cardGaps) - Math.min(...cardGaps), `${viewportWidth}px card gaps`).toBeLessThanOrEqual(1);
}
});
test("@smoke @pr period review workspace restores filters and supports keyboard master-detail navigation", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
await openPeriodView(page);
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-review-detail")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-analytics")).toBeVisible();
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
.toBe(true);
const secondCustomerActivator = page.getByTestId("invoicing-period-customer-select-4002");
await secondCustomerActivator.focus();
await secondCustomerActivator.press("Enter");
await expect(secondCustomerActivator).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("invoicing-period-customer-expanded-4002")).toBeVisible();
await expect(page).toHaveURL(/periodCustomer=4002/);
await page.getByTestId("invoicing-period-review-state").selectOption("ready");
await expect(page).toHaveURL(/periodReviewState=ready/);
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-4002")).toHaveCount(0);
await page.getByRole("button", { name: /Ryd filtre|Clear filters/i }).click();
await expect(page).not.toHaveURL(/periodReviewState=/);
await expect(page.getByTestId("invoicing-period-customer-4002")).toBeVisible();
await page.setViewportSize({ width: 820, height: 1180 });
const tabletDetailBox = await getBoundingBox(
page.getByTestId("invoicing-period-review-detail"),
"tablet review detail"
);
const tabletQueueBox = await getBoundingBox(page.locator(".period-review-queue"), "tablet review queue");
expect(tabletDetailBox.y).toBeLessThan(tabletQueueBox.y);
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
.toBe(true);
await page.setViewportSize({ width: 390, height: 844 });
const detailBox = await getBoundingBox(page.getByTestId("invoicing-period-review-detail"), "mobile review detail");
const queueBox = await getBoundingBox(page.locator(".period-review-queue"), "mobile review queue");
expect(detailBox.y).toBeLessThan(queueBox.y);
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
.toBe(true);
});
test("@smoke @pr period review expands and collapses the selected customer's complete object tree", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
const orderItemRequests = [];
page.on("request", (request) => {
if (request.method() === "GET" && new URL(request.url()).pathname.endsWith("/order/items")) {
orderItemRequests.push(request.url());
}
});
await openPeriodView(page, {
payloadFactory: createObjectTreePeriodPayload,
beforeGoto: routeObjectTreeOrderEndpoints,
});
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4101")).toBeVisible();
const toggleAll = page.getByTestId("invoice-period-tree-toggle-all");
const firstInvoiceCollection = page.getByTestId("invoice-period-tree-node-collected_order_invoice:3001");
await expect(toggleAll).toBeVisible();
await expect(toggleAll).toBeEnabled();
await expect(toggleAll).toContainText(/Udfold alle|Expand all/i);
const toggleBox = await toggleAll.boundingBox();
const collectionBox = await firstInvoiceCollection.boundingBox();
expect(toggleBox).not.toBeNull();
expect(collectionBox).not.toBeNull();
expect(toggleBox.y + toggleBox.height).toBeLessThanOrEqual(collectionBox.y);
await toggleAll.click();
await expect(page.getByTestId("invoice-period-tree-node-order:9001")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-node-order_item:7701")).toBeVisible();
await expect(toggleAll).toContainText(/Fold alle sammen|Collapse all/i);
await expect(toggleAll).toHaveAttribute("aria-pressed", "true");
await expect
.poll(() =>
page
.getByTestId("invoice-period-object-tree")
.locator('[role="treeitem"][aria-expanded]')
.evaluateAll(
(nodes) => nodes.length > 0 && nodes.every((node) => node.getAttribute("aria-expanded") === "true")
)
)
.toBe(true);
const firstExpansionRequestCount = orderItemRequests.length;
expect(firstExpansionRequestCount).toBeGreaterThan(0);
await toggleAll.click();
await expect(toggleAll).toContainText(/Udfold alle|Expand all/i);
await expect(toggleAll).toHaveAttribute("aria-pressed", "false");
await expect(page.locator('[data-node-key="collected_order_invoice:3001"]')).toHaveAttribute(
"aria-expanded",
"false"
);
await toggleAll.click();
await expect(toggleAll).toContainText(/Fold alle sammen|Collapse all/i);
expect(orderItemRequests).toHaveLength(firstExpansionRequestCount);
for (const viewport of [
{ width: 820, height: 1180 },
{ width: 390, height: 844 },
]) {
await page.setViewportSize(viewport);
await expect(toggleAll).toBeVisible();
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
.toBe(true);
}
});
test("@invoice-tree-v2 selected-customer snapshot handles stale revision then applies against the refreshed tree", async ({
page,
}) => {
const state = { snapshotRequests: [], previewRequests: [], applyRequests: [] };
const orderItemRequests = [];
page.on("request", (request) => {
if (request.method() === "GET" && matchesApiPath(request.url(), "/order/items")) {
orderItemRequests.push(request.url());
}
});
await openPeriodView(page, {
payloadFactory: createObjectTreeV2PeriodPayload,
beforeGoto: (currentPage) => routeObjectTreeV2Endpoints(currentPage, state),
});
await page.getByTestId("invoicing-period-view-selector-all").click();
const snapshotCollection = page.getByTestId("invoice-period-tree-node-collected_order_invoice:3001");
await expect(snapshotCollection.locator(".invoice-period-tree-node__label-text")).toContainText(/1 Juli|1 July/i);
await expect(snapshotCollection.locator(".invoice-period-tree-node__label-text")).not.toContainText(
/Fakturasamling|Collection\s+#/i
);
await expect(snapshotCollection.locator(".invoice-period-tree-node__label-text")).not.toContainText(
/29 Juni|29 June/i
);
await expect(page.getByTestId("invoice-period-tree-node-collected_order_invoice:3001")).toContainText(
/Valgt periode|Selected period/i
);
await expect(page.getByTestId("invoice-period-tree-node-collected_order_invoice:3001")).toContainText(
/Hele samlingen|Whole collection/i
);
await page.getByTestId("invoice-period-tree-toggle-all").click();
await expect(page.getByTestId("invoice-period-tree-node-order:9002")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-node-order_item:7701")).toBeVisible();
await expect(page.getByTestId("invoice-period-order-item-table-header-9001")).toHaveCount(1);
await expect(page.locator('[data-node-key="category:9001:order_items"]')).toHaveCount(0);
expect(orderItemRequests).toHaveLength(0);
const collectionNode = page.locator('[data-node-key="collected_order_invoice:3001"]');
await collectionNode.locator(":scope > .b-tree-node-content .b-tree-node-checkbox label").click();
const runQueueAction = async () => {
await page.getByTestId("invoice-period-tree-actions-trigger-collected_order_invoice").click();
await page.getByTestId("invoice-period-tree-action-collection:queue-economic").click();
await expect(page.locator(".swal2-html-container")).toContainText(
/Uden for valgt periode|Outside selected period/i
);
await page.locator(".swal2-input").fill("CONFIRM");
await page.locator(".swal2-confirm").click();
};
await runQueueAction();
await expect.poll(() => state.applyRequests.length).toBe(1);
await expect.poll(() => state.snapshotRequests.length).toBeGreaterThanOrEqual(2);
await expect(page.locator(".swal2-popup")).toBeVisible();
await page.locator(".swal2-confirm").click();
await runQueueAction();
await expect.poll(() => state.applyRequests.length).toBe(2);
await expect.poll(() => state.snapshotRequests.length).toBeGreaterThanOrEqual(3);
expect(state.previewRequests[0]).toMatchObject({
action: "queue_economic",
customer_number: 4101,
snapshot_revision: "snapshot-rev-1",
invoice_collection_ids: [3001],
});
expect(state.previewRequests[1]).toMatchObject({ snapshot_revision: "snapshot-rev-2" });
expect(state.applyRequests).toEqual([
{ preview_id: "preview-1", confirmation_text: "CONFIRM" },
{ preview_id: "preview-2", confirmation_text: "CONFIRM" },
]);
});
test("@smoke period view shows flags and saves automatic flag decisions", async ({ page }) => {
const automaticStatusRequests = [];
const manualStatusRequests = [];
const resolvedManualFlagIds = new Set();
const resolvedAutomaticFingerprints = new Set();
await openPeriodView(page, {
payloadFactory: ({ dateFrom }) =>
createFlaggedPeriodPayload({
dateFrom,
resolvedManualFlagIds: Array.from(resolvedManualFlagIds),
resolvedAutomaticFingerprints: Array.from(resolvedAutomaticFingerprints),
flaggedTransactionBooked: true,
flaggedCustomerRequiresAction: false,
}),
});
await page.route("**/*", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (request.method() === "PATCH" && /\/superuser\/invoicing\/period\/flags\/\d+\/status$/.test(path)) {
manualStatusRequests.push({
path,
payload: JSON.parse(request.postData() || "{}"),
});
const flagId = Number(path.match(/\/flags\/(\d+)\/status$/)?.[1] || 0);
if (flagId > 0) {
resolvedManualFlagIds.add(flagId);
}
await route.fulfill(
json({
data: {
id: 501,
source: "manual",
status: "resolved",
},
})
);
return;
}
if (request.method() !== "POST" || !path.endsWith("/superuser/invoicing/period/flags/automatic/status")) {
await route.fallback();
return;
}
const payload = JSON.parse(request.postData() || "{}");
automaticStatusRequests.push(payload);
if (payload.fingerprint) {
resolvedAutomaticFingerprints.add(String(payload.fingerprint));
}
await route.fulfill(
json({
data: {
id: 9001,
source: "automatic",
status: "ignored",
},
})
);
});
await routeFlaggedPeriodOrders(page);
await routeFlaggedPeriodOrderItems(page);
await page.getByTestId("invoicing-period-view-selector-all").click();
const customerRow = page.getByTestId("invoicing-period-customer-4001");
await expect(customerRow.locator(".color-indicator i.fa-flag").first()).toHaveClass(/has-text-danger/);
await expect(customerRow.getByText("Manual red flag for customer review.")).toBeVisible();
await expect(customerRow.getByText(/Alle bogført|All booked/i)).toHaveCount(0);
await expect(customerRow.getByText(/Spot Free/)).toHaveCount(0);
const manualFlag = page.getByTestId("invoice-period-flag-501");
const manualFlagBox = await getBoundingBox(manualFlag, "customer flag row");
const manualFlagMessage = manualFlag.locator(".invoice-period-flag-row__message");
const manualFlagMessageBox = await getBoundingBox(manualFlagMessage, "customer flag message");
expect(manualFlagMessageBox.width).toBeGreaterThan(manualFlagBox.width * 0.7);
expect(manualFlagMessageBox.x).toBeGreaterThan(manualFlagBox.x);
await expect(manualFlagMessage).toHaveCSS("text-align", "left");
const manualFlagActions = manualFlag.locator(".invoice-period-flag-row__actions");
await expect(manualFlagActions).toHaveCSS("opacity", "0");
await manualFlagActions.getByTitle(/Resolved|Løst/i).focus();
await expect(manualFlagActions).toHaveCSS("opacity", "1");
const revealedManualFlagMessageBox = await getBoundingBox(manualFlagMessage, "focused customer flag message");
const revealedManualFlagActionsBox = await getBoundingBox(manualFlagActions, "focused customer flag actions");
expect(revealedManualFlagActionsBox.y).toBeGreaterThanOrEqual(
revealedManualFlagMessageBox.y + revealedManualFlagMessageBox.height - 1
);
await manualFlag.locator(".invoice-period-flag-row__icon").hover();
const manualFlagTooltip = page.getByTestId("invoice-period-flag-created-tooltip-501");
await expect(manualFlagTooltip).toContainText("Oprettet");
await expect(manualFlagTooltip).toContainText("Af");
await expect(manualFlagTooltip).toContainText("Jeppe");
await manualFlag.hover();
await manualFlag.getByTitle(/Resolved|Løst/i).click();
await page.locator(".swal2-textarea").fill("Handled manually");
await page.locator(".swal2-confirm").click();
await expect(manualFlag).toHaveCount(0);
await expect(customerRow.locator(".color-indicator i.fa-flag").first()).toHaveClass(/has-text-warning/);
await customerRow.getByText("Acme Fleet").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toBeVisible();
await expandTreeNode(page, "category:orders_without_collection:4001");
const referenceField = page.getByTestId("invoice-period-tree-field-order:9001-reference");
await referenceField.getByTestId("invoice-period-flag-badge").locator("button").first().click();
const orderFlag = page.getByTestId("invoice-period-flag-auto-order-reference-1");
await expect(orderFlag).toBeVisible();
await expect(orderFlag).toContainText("Ordren mangler en påkrævet reference.");
const orderFlagToken = orderFlag.locator(".invoice-period-flag-token", { hasText: /^Ordre$|^Order$/i });
await expect(orderFlagToken).toBeVisible();
await orderFlagToken.hover();
await expect(page.getByText("Ordrelinjer").last()).toBeVisible();
await expect(page.getByText("Spot Free").last()).toBeVisible();
const orderPopupPromise = page.waitForEvent("popup");
await orderFlagToken.click();
const orderPopup = await orderPopupPromise;
await expect(orderPopup).toHaveURL(/\/admin\/1\/modules\/pos\/orders\/9001/);
await orderPopup.close();
await orderFlag.hover();
await orderFlag.getByTitle(/Ignored|Ignoreret/i).click();
await page.locator(".swal2-textarea").fill("Accepted reference warning");
await page.locator(".swal2-confirm").click();
await expect(orderFlag).toHaveCount(0);
if ((await page.getByTestId("invoicing-period-customer-expanded-4001").count()) === 0) {
await customerRow.getByText("Acme Fleet").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toBeVisible();
}
await expect(page.getByTestId("invoice-period-object-tree")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-node-category:orders_without_collection:4001")).toBeVisible();
await expandTreeNode(page, "category:orders_without_collection:4001");
await expandTreeNode(page, "order:9001");
const priceField = page.getByTestId("invoice-period-tree-field-order_item:7701-price");
const priceFlagButton = priceField.getByTestId("invoice-period-flag-badge").locator("button").first();
const priceActionWheel = page.getByTestId("invoice-period-tree-action-wheel-order_item:7701");
const [priceFlagBox, priceActionBox] = await Promise.all([
getBoundingBox(priceFlagButton, "price flag button"),
getBoundingBox(priceActionWheel, "price action wheel"),
]);
expect(boxesOverlap(priceFlagBox, priceActionBox), "price flag overlaps its row action wheel").toBe(false);
await priceFlagButton.click();
await expect(priceFlagButton).toHaveAttribute("aria-expanded", "true");
const automaticFlag = page.getByTestId("invoice-period-flag-auto-price-1");
await expect(automaticFlag).toBeVisible();
await automaticFlag.hover();
await automaticFlag.getByTitle(/Ignored|Ignoreret/i).click();
await page.locator(".swal2-textarea").fill("Accepted for this period");
await page.locator(".swal2-confirm").click();
await expect(automaticFlag).toHaveCount(0);
await expect(customerRow.locator(".color-indicator i.fa-flag")).toHaveCount(0);
await expect
.poll(() => manualStatusRequests.map((request) => request.payload))
.toEqual([
{
status: "resolved",
reason: "Handled manually",
},
]);
await expect
.poll(() => automaticStatusRequests)
.toEqual([
{
fingerprint: "order-reference-fingerprint-1",
status: "ignored",
target_type: "order_field",
target_id: 9001,
field: "reference",
definition_key: "customer_rule_requires_reference",
reason: "Accepted reference warning",
},
{
fingerprint: "price-fingerprint-1",
status: "ignored",
target_type: "order_item_field",
target_id: 7701,
field: "price",
definition_key: "price_mismatch",
reason: "Accepted for this period",
},
]);
});
test("@smoke period expanded booked collection shows hidden flag reasons", async ({ page }) => {
await openPeriodView(page, {
payloadFactory: ({ dateFrom }) =>
createFlaggedPeriodPayload({
dateFrom,
resolvedManualFlagIds: [501],
resolvedAutomaticFingerprints: ["order-reference-fingerprint-1", "price-fingerprint-1"],
flaggedTransactionBooked: true,
flaggedCustomerRequiresAction: false,
includeHiddenFilteredFlag: true,
invoiceCollectionId: 16891,
}),
});
await routeFlaggedPeriodOrders(page, {
invoiceCollectionId: 16891,
includeHiddenCollectionOrder: true,
});
await routeFlaggedPeriodOrderItems(page);
await page.getByTestId("invoicing-period-view-selector-all").click();
const customerRow = page.getByTestId("invoicing-period-customer-4001");
await expect(customerRow.locator(".color-indicator i.fa-flag").first()).toHaveClass(/has-text-warning/);
await expect(customerRow.getByText(/Alle bogført|All booked/i)).toHaveCount(0);
await customerRow.getByText("Acme Fleet").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-node-collected_order_invoice:16891")).toBeVisible();
await expandTreeNode(page, "collected_order_invoice:16891");
await expandTreeNode(page, "category:16891:collection_orders");
const collectionNode = page.getByTestId("invoice-period-tree-node-collected_order_invoice:16891");
await collectionNode
.locator(".invoice-period-tree-node__identity")
.getByTestId("invoice-period-flag-badge")
.locator("button")
.first()
.click();
const hiddenFlag = page.getByTestId("invoice-period-flag-auto-hidden-order-reference-1");
await expect(hiddenFlag).toBeVisible();
await expect(hiddenFlag).toContainText(
/Ordren mangler en påkrævet reference|Order is missing a required reference/i
);
});
test("@smoke period customer unfolding is single-open and action wheel shows active state", async ({ page }) => {
await page.setViewportSize({ width: 1900, height: 900 });
await openPeriodView(page);
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-4002")).toBeVisible();
const firstCustomer = page.getByTestId("invoicing-period-customer-4001");
const secondCustomer = page.getByTestId("invoicing-period-customer-4002");
await firstCustomer.getByText("Acme Fleet").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-expanded-4002")).toHaveCount(0);
await secondCustomer.getByText("Nordic Transport").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toHaveCount(0);
await expect(page.getByTestId("invoicing-period-customer-expanded-4002")).toBeVisible();
const firstWheel = firstCustomer.locator(".action-settings-wheel-trigger").first();
const secondWheel = secondCustomer.locator(".action-settings-wheel-trigger").first();
await firstWheel.click();
await expect(firstWheel).toHaveClass(/action-settings-wheel-trigger--active/);
await expect(firstWheel).toHaveAttribute("aria-expanded", "true");
const sectionIds = await firstWheel
.locator("xpath=ancestor::div[contains(@class, 'dropdown')]")
.getByTestId("action-settings-wheel-sections")
.locator("[data-testid^='action-settings-wheel-section-']")
.evaluateAll((sections) => sections.map((section) => section.getAttribute("data-testid")));
expect(sectionIds.at(-1)).toBe("action-settings-wheel-section-invoice-period-flags");
const isFlatLayout = (await page.getByTestId("action-settings-wheel-flyout").count()) === 0;
if (isFlatLayout) {
const customerFlagAction = firstCustomer.locator("button.dropdown-item-action").filter({ hasText: /^Kunde$/ });
await expect(customerFlagAction).toBeVisible();
} else {
await firstCustomer.getByTestId("action-settings-wheel-section-invoice-period-flags").hover();
const flagSubmenu = page.getByTestId("action-settings-wheel-submenu-invoice-period-flags");
const customerFlagAction = flagSubmenu.locator("button.dropdown-item-action", { hasText: "Kunde" });
await expect(customerFlagAction).toBeVisible();
await expect(flagSubmenu).not.toContainText(/Dette m.*l/);
}
await page.keyboard.press("Escape");
await expect(firstWheel).not.toHaveClass(/action-settings-wheel-trigger--active/);
await expect(firstWheel).toHaveAttribute("aria-expanded", "false");
await secondWheel.click();
await expect(firstWheel).not.toHaveClass(/action-settings-wheel-trigger--active/);
await expect(firstWheel).toHaveAttribute("aria-expanded", "false");
await expect(secondWheel).toHaveClass(/action-settings-wheel-trigger--active/);
await expect(secondWheel).toHaveAttribute("aria-expanded", "true");
});
test("@smoke period object tree aligns row actions and visible fields", async ({ page }) => {
await page.setViewportSize({ width: 1900, height: 900 });
await openPeriodView(page, {
payloadFactory: createObjectTreePeriodPayload,
beforeGoto: routeObjectTreeOrderEndpoints,
});
await page.getByTestId("invoicing-period-view-selector-all").click();
const customerRow = page.getByTestId("invoicing-period-customer-4101");
await expect(customerRow).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-select-4101")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("invoice-period-object-tree")).toBeVisible();
await expandTreeNode(page, "collected_order_invoice:3001");
await expandTreeNode(page, "category:3001:collection_orders");
await expandTreeNode(page, "order:9001");
const collectionFieldRows = await Promise.all(
[
["state", "po_number", "notes", "booked_invoice_id"],
["external_id", "closed_at", "processor", "total_net_amount"],
["error_message", "created_at", "updated_at"],
].map((fields) =>
Promise.all(
fields.map((field) =>
getBoundingBox(
page.getByTestId(`invoice-period-tree-field-collected_order_invoice:3001-${field}`),
`collection ${field}`
)
)
)
)
);
collectionFieldRows.forEach((row, rowIndex) => {
const rowTops = row.map((box) => Math.round(box.y));
expect(
Math.max(...rowTops) - Math.min(...rowTops),
`collection metadata row ${rowIndex + 1}`
).toBeLessThanOrEqual(2);
});
const primaryCollectionFields = [...collectionFieldRows[0], ...collectionFieldRows[1]];
const primaryCollectionWidths = primaryCollectionFields.map((box) => Math.round(box.width));
expect(Math.max(...primaryCollectionWidths) - Math.min(...primaryCollectionWidths)).toBeLessThanOrEqual(2);
expect(Math.min(...primaryCollectionWidths)).toBeGreaterThanOrEqual(60);
const usesCompactMetadataGrid = collectionFieldRows[1][0].y > collectionFieldRows[0][0].y + 2;
const lastPrimaryRowY = usesCompactMetadataGrid ? collectionFieldRows[1][0].y : collectionFieldRows[0][0].y;
expect(collectionFieldRows[2][0].y).toBeGreaterThan(lastPrimaryRowY);
expect(collectionFieldRows[2][0].width).toBeGreaterThan(collectionFieldRows[2][1].width * 1.8);
const wheelLocators = [
page.getByTestId("invoice-period-tree-action-wheel-collected_order_invoice:3001"),
page.getByTestId("invoice-period-tree-action-wheel-order:9001"),
page.getByTestId("invoice-period-tree-action-wheel-order_item:7701"),
];
const wheelBoxes = [];
for (const [index, locator] of wheelLocators.entries()) {
wheelBoxes.push(await getBoundingBox(locator, `object tree action wheel ${index}`));
}
const wheelRightEdges = wheelBoxes.map((box) => Math.round(box.x + box.width));
expect(Math.max(...wheelRightEdges) - Math.min(...wheelRightEdges)).toBeLessThanOrEqual(6);
await expect(page.getByTestId("invoice-period-tree-field-order:9001-notes")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-field-order:9001-department")).toContainText(
/Afdeling|Department/i
);
await expect(page.getByTestId("invoice-period-tree-field-value-order:9001-department")).toHaveText("Copenhagen");
await expect(page.getByTestId("invoice-period-tree-field-order:9001-registrations")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-field-order:9001-reg_2")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-field-order:9001-reg_3")).toHaveCount(0);
await expect(page.getByTestId("invoice-period-tree-field-value-order:9001-notes")).toContainText(/Tom|Empty/i);
await expect(page.locator("[data-testid^='invoice-period-tree-field-empty-toggle-']")).toHaveCount(0);
await expect(page.getByTestId("invoice-period-tree-node-order:9001")).toContainText("Vask #9001");
await expect(page.locator("[data-testid$='-product_id']")).toHaveCount(0);
const firstOrderFieldRow = await Promise.all(
["reference", "po", "notes", "department"].map((field) =>
getBoundingBox(page.getByTestId(`invoice-period-tree-field-order:9001-${field}`), field)
)
);
const firstOrderFieldTops = firstOrderFieldRow.map((box) => Math.round(box.y));
expect(Math.max(...firstOrderFieldTops) - Math.min(...firstOrderFieldTops)).toBeLessThanOrEqual(2);
const firstOrderFieldWidths = firstOrderFieldRow.map((box) => Math.round(box.width));
expect(Math.max(...firstOrderFieldWidths) - Math.min(...firstOrderFieldWidths)).toBeLessThanOrEqual(2);
const registrationBox = await getBoundingBox(
page.getByTestId("invoice-period-tree-field-order:9001-registrations"),
"registrations"
);
const includeInInvoiceBox = await getBoundingBox(
page.getByTestId("invoice-period-tree-field-order:9001-include_in_invoice"),
"include in invoice"
);
const orderTotalBox = await getBoundingBox(
page.getByTestId("invoice-period-tree-field-order:9001-total_net_amount"),
"order total"
);
expect(Math.abs(registrationBox.y - includeInInvoiceBox.y)).toBeLessThanOrEqual(2);
expect(Math.abs(orderTotalBox.y - includeInInvoiceBox.y)).toBeLessThanOrEqual(2);
expect(registrationBox.y).toBeGreaterThan(firstOrderFieldRow[0].y);
expect(registrationBox.width).toBeGreaterThan(includeInInvoiceBox.width * 1.8);
expect(Math.abs(orderTotalBox.width - includeInInvoiceBox.width)).toBeLessThanOrEqual(2);
const quantityBox = await getBoundingBox(
page.getByTestId("invoice-period-tree-field-order_item:7701-quantity"),
"quantity"
);
const priceBox = await getBoundingBox(page.getByTestId("invoice-period-tree-field-order_item:7701-price"), "price");
const firstOrderItem = page.getByTestId("invoice-period-tree-node-order_item:7701");
const associatedOrderItem = page.getByTestId("invoice-period-tree-node-order_item:7702");
const itemTableHeader = page.getByTestId("invoice-period-order-item-table-header-9001");
await expect(firstOrderItem.locator(".invoice-period-tree-node__subtitle")).toHaveCount(0);
await expect(itemTableHeader).toHaveCount(1);
await expect(itemTableHeader).toContainText(/Produkt|Product/i);
await expect(itemTableHeader).toContainText(/Antal|Quantity/i);
await expect(itemTableHeader).toContainText(/Pris|Price/i);
await expect(
page
.getByTestId("invoice-period-tree-field-order_item:7701-quantity")
.locator(".invoice-period-order-item-table__value")
).not.toContainText(/Antal|Quantity/i);
await expect(
page
.getByTestId("invoice-period-tree-field-order_item:7701-price")
.locator(".invoice-period-order-item-table__value")
).not.toContainText(/Pris|Price/i);
await expect(
page.getByTestId("invoice-period-tree-field-order_item:7701-quantity").locator(".is-sr-only")
).toContainText(/Antal|Quantity/i);
await expect(
page.getByTestId("invoice-period-tree-field-order_item:7702-reference").locator(".is-sr-only")
).toContainText(/Reference/i);
expect(quantityBox.x + quantityBox.width).toBeLessThanOrEqual(priceBox.x + 2);
const alignedAmountBoxes = await Promise.all([
getBoundingBox(
page.getByTestId("invoice-period-tree-field-collected_order_invoice:3001-total_net_amount"),
"collection total"
),
getBoundingBox(page.getByTestId("invoice-period-tree-field-order:9001-total_net_amount"), "order total"),
getBoundingBox(page.getByTestId("invoice-period-tree-field-order_item:7701-price"), "item price"),
]);
const amountRightEdges = alignedAmountBoxes.map((box) => Math.round(box.x + box.width));
expect(Math.max(...amountRightEdges) - Math.min(...amountRightEdges)).toBeLessThanOrEqual(8);
await expect(associatedOrderItem).toBeVisible();
await expect(
page.locator('[data-node-key="order:9001"] > .b-tree-children > [data-node-key="order_item:7702"]')
).toBeVisible();
await expect(page.locator('[data-node-key="category:9001:order_items"]')).toHaveCount(0);
await expect(
page.locator('[data-node-key="order_item:7701"] > .b-tree-node-content > .b-tree-node-icon')
).toHaveCount(0);
await expect(
page.locator('[data-node-key="order_item:7702"] > .b-tree-node-content > .b-tree-node-icon')
).toHaveCount(0);
await expect(
page.locator('[data-node-key="order_item:7701"] > .b-tree-node-content > .b-tree-node-toggle')
).toHaveCount(0);
await expect(
page.locator('[data-node-key="order_item:7702"] > .b-tree-node-content > .b-tree-node-toggle')
).toHaveCount(0);
await expect(associatedOrderItem.locator(".invoice-period-order-item-table__relation")).toHaveText("+");
const associatedItemAriaSnapshot = await page.locator('[data-node-key="order_item:7702"]').ariaSnapshot();
expect(associatedItemAriaSnapshot).toMatch(/Tilknyttet|Associated/i);
expect(associatedItemAriaSnapshot).toContain("Forvogn med ekstra langt produktnavn til visuel afkortning");
await expect(
page.getByTestId("invoice-period-tree-node-order_item:7701").locator(".invoice-period-tree-node__label-text")
).toHaveCSS("white-space", "normal");
const rootItemColumns = await Promise.all(
["reference", "notes", "quantity", "price"].map((field) =>
getBoundingBox(page.getByTestId(`invoice-period-tree-field-order_item:7701-${field}`), `root item ${field}`)
)
);
const associatedItemColumns = await Promise.all(
["reference", "notes", "quantity", "price"].map((field) =>
getBoundingBox(
page.getByTestId(`invoice-period-tree-field-order_item:7702-${field}`),
`associated item ${field}`
)
)
);
rootItemColumns.forEach((box, index) => {
expect(Math.abs(box.x - associatedItemColumns[index].x)).toBeLessThanOrEqual(2);
expect(Math.abs(box.width - associatedItemColumns[index].width)).toBeLessThanOrEqual(2);
});
await page.getByTestId("invoice-period-order-item-table-select-all-9001").check();
await expect(page.locator('[data-node-key="order_item:7701"]')).toHaveAttribute("aria-checked", "true");
await expect(page.locator('[data-node-key="order_item:7702"]')).toHaveAttribute("aria-checked", "true");
const orderWheelRoot = page.getByTestId("invoice-period-tree-action-wheel-order:9001");
await orderWheelRoot.locator(".action-settings-wheel-trigger").click();
await expect(orderWheelRoot.getByTestId("action-settings-wheel-section-order")).toBeVisible();
const orderOpenActionText = /Åbn ordre i ny fane|view order in new tab/i;
const isFlatWheelLayout = (await orderWheelRoot.getByTestId("action-settings-wheel-flyout").count()) === 0;
if (isFlatWheelLayout) {
await expect(
orderWheelRoot.locator("button.dropdown-item-action").filter({ hasText: orderOpenActionText })
).toBeVisible();
} else {
await orderWheelRoot.getByTestId("action-settings-wheel-section-order").hover();
await expect(
page
.getByTestId("action-settings-wheel-submenu-order")
.locator("button.dropdown-item-action")
.filter({ hasText: orderOpenActionText })
).toBeVisible();
}
await page.keyboard.press("Escape");
const objectLineMetrics = await page.getByTestId("invoice-period-tree-node-order:9001").evaluate((node) => {
const main = node.querySelector(".invoice-period-tree-node__main");
const subtitle = node.querySelector(".invoice-period-tree-node__subtitle");
const mainStyle = main ? window.getComputedStyle(main) : null;
const subtitleStyle = subtitle ? window.getComputedStyle(subtitle) : null;
return {
mainHeight: main?.getBoundingClientRect().height || 0,
mainLineHeight: Number.parseFloat(mainStyle?.lineHeight || "0"),
subtitleHeight: subtitle?.getBoundingClientRect().height || 0,
subtitleLineHeight: Number.parseFloat(subtitleStyle?.lineHeight || "0"),
};
});
expect(objectLineMetrics.mainHeight).toBeLessThanOrEqual(objectLineMetrics.mainLineHeight * 1.6);
expect(objectLineMetrics.subtitleHeight).toBeLessThanOrEqual(objectLineMetrics.subtitleLineHeight * 1.6);
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByTestId("invoice-period-tree-toolbar")).toBeVisible();
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
.toBe(true);
const itemScrollMetrics = await page.locator(".invoice-period-object-tree__scroll").evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}));
expect(itemScrollMetrics.scrollWidth).toBeGreaterThan(itemScrollMetrics.clientWidth);
});
test("@smoke period expanded order object tree omits table filters", async ({ page }) => {
await openPeriodView(page, { payloadFactory: createObjectTreePeriodPayload });
await page.getByTestId("invoicing-period-view-selector-all").click();
const firstCustomer = page.getByTestId("invoicing-period-customer-4101");
await expect(firstCustomer).toBeVisible();
await firstCustomer.getByText("Object Tree Logistics").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4101")).toBeVisible();
await expect(page.getByTestId("invoice-period-object-tree")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-node-collected_order_invoice:3001")).toBeVisible();
await expect(page.getByTestId("invoice-orders-toggle-filters")).toHaveCount(0);
await expect(page.getByTestId("table-labeled-pagination-filters")).toHaveCount(0);
});
test("@smoke period possible duplicates are grouped by license plate and date", async ({ page }) => {
const pageErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
await page.clock.setFixedTime(new Date("2026-05-11T10:00:00.000Z"));
await openPeriodView(page, { payloadFactory: createPossibleDuplicatesPeriodPayload });
await page.getByTestId("invoicing-period-view-selector-possible_duplicates").click({
position: { x: 12, y: 12 },
});
await page.waitForTimeout(100);
expect(pageErrors).toEqual([]);
await expect(page.getByTestId("invoicing-period-view-selector-possible_duplicates")).toHaveClass(
/is-selected-view/
);
await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute(
"data-current-view",
"possible_duplicates"
);
const duplicateSelector = page.getByTestId("invoicing-period-view-selector-possible_duplicates");
await expect(duplicateSelector).toContainText(/Mulige|Possible/i);
await expect(duplicateSelector).toContainText("(0/1)");
const duplicateGroup = page.getByTestId(/^invoicing-period-duplicate-group-EC21233-\d{4}-\d{2}-\d{2}$/);
await expect(duplicateGroup).toBeVisible();
const duplicateGroupTestId = await duplicateGroup.getAttribute("data-testid");
const duplicateGroupKey = duplicateGroupTestId?.replace("invoicing-period-duplicate-group-", "");
expect(duplicateGroupKey).toMatch(/^EC21233-\d{4}-\d{2}-\d{2}$/);
const duplicateDate = duplicateGroupKey?.replace("EC21233-", "");
await expect(page.getByTestId("invoicing-period-customer-7201")).toHaveCount(0);
await expect(page.getByTestId("invoicing-period-customer-7202")).toHaveCount(0);
await expect(duplicateGroup).toContainText("EC21233");
await expect(duplicateGroup).toContainText("2 kunder");
await expect(duplicateGroup).toContainText("2 vaskelog");
await expect(duplicateGroup.getByText(/^EC21233\s+-\s+/)).toHaveCount(0);
await expect(duplicateGroup).toContainText("Pleno Vognmandsforretning #7201");
await expect(duplicateGroup).toContainText("Estland Alle ApS #7202");
await expect(page.getByTestId(`invoicing-period-duplicate-group-SINGLE1-${duplicateDate}`)).toHaveCount(0);
const plateTag = page.getByTestId(`invoicing-period-duplicate-plate-tag-${duplicateGroupKey}`);
const dateTag = page.getByTestId(`invoicing-period-duplicate-date-tag-${duplicateGroupKey}`);
const plateBox = await plateTag.boundingBox();
const dateBox = await dateTag.boundingBox();
expect(plateBox).not.toBeNull();
expect(dateBox).not.toBeNull();
expect(Math.abs((plateBox?.x ?? 0) - (dateBox?.x ?? 0))).toBeLessThan(4);
expect(dateBox?.y ?? 0).toBeGreaterThan(plateBox?.y ?? 0);
await duplicateGroup.click();
const expandedGroup = page.getByTestId(`invoicing-period-duplicate-group-expanded-${duplicateGroupKey}`);
await expect(expandedGroup.locator("[data-testid='pagination-search-input']")).toHaveCount(0);
await expect(expandedGroup.locator("[data-testid='pagination-reload-actions']")).toHaveCount(0);
await expect(expandedGroup.locator("nav.pagination")).toHaveCount(0);
await expect(expandedGroup.getByText(/Side\s+\d+\s+af/i)).toHaveCount(0);
const comparison = page.getByTestId(`invoicing-period-duplicate-comparison-${duplicateGroupKey}`);
await expect(comparison).toBeVisible();
await expect(comparison.getByTestId("invoicing-period-duplicate-comparison-row-9701")).toContainText(
"Pleno Vognmandsforretning #7201"
);
await expect(comparison.getByTestId("invoicing-period-duplicate-comparison-row-9702")).toContainText(
"Estland Alle ApS #7202"
);
await expect(comparison.getByText("EC21233")).toHaveCount(2);
});
test("@smoke period customer attribute tags stack vertically without resizing cards", async ({ page }) => {
await openPeriodView(page, { payloadFactory: createAttributeStackPeriodPayload });
await page.getByTestId("invoicing-period-view-selector-all").click();
const multiAttributeCard = page.getByTestId("invoicing-period-customer-4101");
const singleAttributeCard = page.getByTestId("invoicing-period-customer-4102");
const attributeStack = page.getByTestId("invoicing-period-customer-attributes-4101");
const tagLocators = ["invoice_per_order", "fixed_pricing", "tank_cleaning"].map((viewKey) =>
page.getByTestId(`invoicing-period-customer-attribute-4101-${viewKey}`)
);
const multiCardBox = await getBoundingBox(multiAttributeCard, "multi-attribute customer card");
const singleCardBox = await getBoundingBox(singleAttributeCard, "single-attribute customer card");
const stackBox = await getBoundingBox(attributeStack, "customer attribute stack");
const identityBox = await getBoundingBox(
multiAttributeCard.locator(".period-customer-card__identity"),
"customer identity"
);
const selectorBox = await getBoundingBox(
multiAttributeCard.locator(".period-customer-card__selector"),
"customer selector"
);
const settingsBox = await getBoundingBox(
multiAttributeCard.locator(".period-customer-card__settings"),
"customer settings"
);
const countBox = await getBoundingBox(
multiAttributeCard.locator(".period-customer-card__count"),
"customer transaction count"
);
const amountBox = await getBoundingBox(
multiAttributeCard.locator(".period-customer-card__amount"),
"customer amount"
);
const tagBoxes = [];
for (const [index, tagLocator] of tagLocators.entries()) {
const tagBox = await getBoundingBox(tagLocator, `customer attribute tag ${index + 1}`);
const fontSize = await tagLocator.evaluate((element) => {
return Number.parseFloat(window.getComputedStyle(element).fontSize || "0");
});
tagBoxes.push(tagBox);
expectBoxInside(tagBox, stackBox, `customer attribute tag ${index + 1}`);
expect(fontSize, `customer attribute tag ${index + 1} font size`).toBeGreaterThanOrEqual(10);
}
// Customer cards are sized to their content (no fixed height) per the
// "taller cards, no internal scroll" design change. Multi-attribute
// customers naturally render slightly taller than single-attribute ones
// when attribute chips wrap on narrow viewports. Tolerance widened from
// 3px (fixed-height era) to 32px to accommodate that without forcing
// uniform heights that would either crop content or reintroduce scroll.
expect(Math.abs(multiCardBox.height - singleCardBox.height)).toBeLessThanOrEqual(32);
expect(identityBox.x).toBeLessThan(settingsBox.x);
expect(settingsBox.x + settingsBox.width).toBeLessThanOrEqual(multiCardBox.x + multiCardBox.width);
expect(Math.abs(countBox.y - amountBox.y)).toBeLessThanOrEqual(2);
expect(stackBox.y).toBeGreaterThan(countBox.y);
expect(Math.abs(stackBox.x - selectorBox.x)).toBeLessThanOrEqual(2);
expect(stackBox.width).toBeGreaterThan(multiCardBox.width * 0.8);
tagBoxes.slice(1).forEach((tagBox, index) => {
expect(Math.abs(tagBox.x - tagBoxes[0].x)).toBeLessThanOrEqual(1);
expect(tagBox.y).toBeGreaterThan(tagBoxes[index].y);
});
const controlLocators = [
multiAttributeCard.getByText(/1\s+(Transaktion|Transaction|order|orders|ordre|Vaskelog)/i).first(),
multiAttributeCard.getByText(/210/).first(),
page.getByTestId("invoicing-period-customer-invoice-4101"),
multiAttributeCard.locator(".action-settings-wheel-trigger").first(),
];
for (const [index, controlLocator] of controlLocators.entries()) {
const controlBox = await getBoundingBox(controlLocator, `customer row control ${index + 1}`);
expect(boxesOverlap(stackBox, controlBox), `attribute stack overlaps row control ${index + 1}`).toBe(false);
}
});
test("@smoke period customer attribute chips render on every subpage including Alle", async ({ page }) => {
// Pin down the regression: customer indicator chips (e.g. "Faktura pr.
// ordre") were previously invisible on the Alle tab because the backend
// only returned full customer data for the active view bucket. The
// backend now projects lightweight `{customer_number, membership_only}`
// entries on every non-active bucket so the front-end can resolve
// category membership regardless of the active view.
await openPeriodView(page, { payloadFactory: createAttributeStackPeriodPayload });
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page).toHaveURL(/periodView=all/);
const multiAttributeCard = page.getByTestId("invoicing-period-customer-4101");
await expect(multiAttributeCard).toBeVisible();
const multiAttributeStack = multiAttributeCard.locator('[data-testid="invoicing-period-customer-attributes-4101"]');
await expect(multiAttributeStack).toBeVisible();
for (const viewKey of ["invoice_per_order", "fixed_pricing", "tank_cleaning"]) {
const chip = page.getByTestId(`invoicing-period-customer-attribute-4101-${viewKey}`);
await expect(chip, `expected chip "${viewKey}" on the Alle tab`).toBeVisible();
const chipText = (await chip.textContent())?.trim() ?? "";
expect(chipText, `chip ${viewKey} carries a non-empty label`).not.toBe("");
}
// The single-attribute customer should still show exactly one chip
// (matches the "fixed_pricing" bucket in the fixture).
const singleAttributeCard = page.getByTestId("invoicing-period-customer-4102");
await expect(singleAttributeCard).toBeVisible();
const singleStack = singleAttributeCard.locator('[data-testid="invoicing-period-customer-attributes-4102"]');
await expect(singleStack).toBeVisible();
await expect(
page.getByTestId("invoicing-period-customer-attribute-4102-fixed_pricing"),
"single-category customer renders its fixed_pricing chip on the Alle tab"
).toBeVisible();
await expect(
page.getByTestId("invoicing-period-customer-attribute-4102-invoice_per_order"),
"single-category customer does not render an invoice_per_order chip"
).toHaveCount(0);
});
test("@smoke period view selector switch updates visible customer set", async ({ page }) => {
await openPeriodView(page);
await page.getByTestId("invoicing-period-view-selector-invoice_per_order").click();
await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute(
"data-current-view",
"invoice_per_order"
);
await expect(page.getByTestId("invoicing-period-customer-7001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-4001")).toHaveCount(0);
await expect(page).toHaveURL(/activeTab=period/);
});
test("@smoke period distribution calculations handle department 75 payloads in browser", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const module = await import(
"/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/invoicingBillingPeriodDistributionCalculations.js"
);
const subscriptionDistribution = {
total_subscription_price: 300,
subscription_price_department_distribution: {
1: 200,
2: 100,
},
};
const bookedDepartment75Distribution = {
customers: [
{
meta: {
booked_department_75: {
booked_groups: [
{
source_category: "fixed_pricing",
booked_net_amount: 1000,
department_distribution: {
1: 700,
2: 300,
},
},
{
source_category: "wash_subscriptions",
booked_net_amount: 600,
department_distribution: {
1: 500,
2: 100,
},
},
],
},
},
},
],
};
return {
subscriptionBookedCopenhagen: module.getDepartmentRelativeBookedAmount(
{ id: 1, name: "Copenhagen" },
subscriptionDistribution,
600
),
fixedPricingBookedCopenhagen: module.getBookedDepartment75SourceDepartmentAmount(
{ id: 1, name: "Copenhagen" },
bookedDepartment75Distribution,
"fixed_pricing"
),
washSubscriptionBookedOdense: module.getBookedDepartment75SourceDepartmentAmount(
{ id: 2, name: "Odense" },
bookedDepartment75Distribution,
"wash_subscriptions"
),
};
});
expect(result).toEqual({
subscriptionBookedCopenhagen: 400,
fixedPricingBookedCopenhagen: 700,
washSubscriptionBookedOdense: 100,
});
});
test("@smoke period all view shows combined department 75 distribution", async ({ page }) => {
await openPeriodView(page);
await setEntireMarchPeriod(page);
await selectPeriodView(page, "all");
await expandPeriodAnalytics(page);
const combinedTable = page.getByTestId("department-75-combined-distribution-table");
await expect(combinedTable).toBeVisible();
const combinedTableContainer = page.getByTestId("department-75-combined-distribution-table-container");
await expect(combinedTableContainer).toBeVisible();
const copenhagenCells = combinedTable.locator("tr", { hasText: "Copenhagen" }).locator("td");
const odenseCells = combinedTable.locator("tr", { hasText: "Odense" }).locator("td");
const totalCells = combinedTable.locator("tr", { hasText: "Total" }).locator("td");
await expect(copenhagenCells.nth(1)).toContainText("220");
await expect(copenhagenCells.nth(2)).toContainText("600");
await expect(copenhagenCells.nth(3)).toContainText("200");
await expect(copenhagenCells.nth(4)).toContainText("800");
await expect(copenhagenCells.nth(5)).toContainText("700");
await expect(copenhagenCells.nth(6)).toContainText("500");
await expect(copenhagenCells.nth(7)).toContainText("1.200");
await expect(odenseCells.nth(5)).toContainText("300");
await expect(odenseCells.nth(6)).toContainText("100");
await expect(odenseCells.nth(7)).toContainText("400");
await expect(totalCells.nth(4)).toContainText("1.300");
await expect(totalCells.nth(7)).toContainText("1.600");
const isDesktop = page.viewportSize().width >= 1024;
if (isDesktop) {
await expect
.poll(() =>
page.evaluate(() => {
const tableContainer = document.querySelector(
'[data-testid="department-75-combined-distribution-table-container"]'
);
const content = document.querySelector('[data-testid="invoicing-period-layout-main"]');
const navigation = document.querySelector('[data-testid="invoicing-period-layout-navigation"]');
if (!tableContainer || !content || !navigation) {
return false;
}
const tableContainerRect = tableContainer.getBoundingClientRect();
const contentRect = content.getBoundingClientRect();
const navigationRect = navigation.getBoundingClientRect();
const selectorRects = Array.from(navigation.querySelectorAll(".is-selector-view")).map((selector) =>
selector.getBoundingClientRect()
);
return (
document.documentElement.scrollWidth - document.documentElement.clientWidth <= 2 &&
navigation.scrollWidth - navigation.clientWidth <= 2 &&
tableContainer.scrollWidth - tableContainer.clientWidth <= 2 &&
tableContainerRect.left >= contentRect.left - 1 &&
tableContainerRect.right <= contentRect.right + 1 &&
contentRect.right <= navigationRect.left - 8 &&
selectorRects.every(
(rect) =>
rect.left >= navigationRect.left - 1 &&
rect.right <= navigationRect.right + 1 &&
rect.left >= -1 &&
rect.right <= window.innerWidth + 1
)
);
})
)
.toBe(true);
}
});
test("@smoke period subscription distribution uses e-conomic booked department 75 amounts", async ({ page }) => {
await openPeriodView(page);
await setEntireMarchPeriod(page);
await selectPeriodView(page, "vehicle_subscriptions");
await expandPeriodAnalytics(page);
const subscriptionTable = page.locator("table").filter({ hasText: "Abonnementspris" });
await expect(subscriptionTable).toBeVisible();
await expect(subscriptionTable.locator("tr", { hasText: "Copenhagen" }).locator("td").nth(3)).toContainText("500");
await expect(subscriptionTable.locator("tr", { hasText: "Odense" }).locator("td").nth(3)).toContainText("100");
});
test("@smoke period fixed pricing uses e-conomic booked department 75 amounts", async ({ page }) => {
await openPeriodView(page);
await setEntireMarchPeriod(page);
await selectPeriodView(page, "fixed_pricing");
await expandPeriodAnalytics(page);
const fixedPricingTable = page.locator("table").filter({ hasText: "Original pris" });
await expect(fixedPricingTable).toBeVisible();
const copenhagenBookedCell = fixedPricingTable.locator("tr", { hasText: "Copenhagen" }).locator("td").nth(5);
const odenseBookedCell = fixedPricingTable.locator("tr", { hasText: "Odense" }).locator("td").nth(5);
await expect(copenhagenBookedCell).toContainText("700");
await expect(odenseBookedCell).toContainText("300");
await expect(copenhagenBookedCell).not.toContainText("550");
await expect(odenseBookedCell).not.toContainText("450");
});
test("@smoke period view invoices required-action customers without opening collection tab", async ({ page }) => {
await openPeriodView(page);
const orderLookups = [];
const economicInvoiceRequests = [];
const popupUrls = [];
page.on("popup", (popup) => {
popupUrls.push(popup.url());
});
await page.route("**/order**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/order")) {
await route.fallback();
return;
}
const url = new URL(route.request().url());
const orderId = Number(url.searchParams.get("id") || 0);
orderLookups.push(orderId);
await route.fulfill(
json({
success: true,
data: {
id: orderId,
invoice_collection_id: 55501,
},
})
);
});
await page.route("**/collected-invoices/economic**", async (route) => {
if (matchesApiPath(route.request().url(), "/collected-invoices/economic/queue/status")) {
await route.fulfill(
json({
data: {
job: {
id: 77001,
status: "COMPLETED",
progress_percent: 100,
progress_message: "Done",
result: {},
},
},
})
);
return;
}
if (
route.request().method() !== "POST" ||
!matchesApiPath(route.request().url(), "/collected-invoices/economic")
) {
await route.fallback();
return;
}
const payload = JSON.parse(route.request().postData() || "{}");
economicInvoiceRequests.push(payload);
await route.fulfill(
json({
data: {
job: {
id: 77001,
status: "QUEUED",
progress_percent: 0,
progress_message: "Queued",
result: null,
},
},
})
);
});
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-invoice-4001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-4002")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-invoice-4002")).toHaveCount(0);
await page.getByTestId("invoicing-period-customer-invoice-4001").click();
await expect.poll(() => orderLookups).toEqual([9001]);
await expect.poll(() => economicInvoiceRequests.length).toBe(1);
expect(economicInvoiceRequests[0]).toEqual({
id: 55501,
send_as_is: false,
});
await page.waitForTimeout(250);
expect(popupUrls).toEqual([]);
await expect(page).toHaveURL(/activeTab=period/);
});
test("@smoke period view warns and splits selected multi-month invoice collections", async ({ page }) => {
const splitRequests = [];
const economicInvoiceRequests = [];
await openPeriodView(page, {
payloadFactory: () => ({
types: {
all: [
{
id: 31,
customer_number: 4301,
customer_name: "Multi Month Fleet",
requires_action: true,
transactions: [
{
id: 8801,
date: "2026-03-28T10:00:00.000Z",
amount: 120,
booked: false,
excluded: false,
invoice_collection_id: 88001,
},
{
id: 8802,
date: "2026-04-02T10:00:00.000Z",
amount: 180,
booked: false,
excluded: false,
invoice_collection_id: 88001,
},
],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
}),
});
await page.route("**/collected-invoices/split-by-month**", async (route) => {
if (
route.request().method() !== "POST" ||
!matchesApiPath(route.request().url(), "/collected-invoices/split-by-month")
) {
await route.fallback();
return;
}
const payload = JSON.parse(route.request().postData() || "{}");
splitRequests.push(payload);
await route.fulfill(
json({
data: {
preview: false,
processed_count: 1,
changed_count: 1,
skipped_count: 0,
},
})
);
});
await page.route("**/collected-invoices/economic**", async (route) => {
if (
route.request().method() === "POST" &&
matchesApiPath(route.request().url(), "/collected-invoices/economic")
) {
economicInvoiceRequests.push(JSON.parse(route.request().postData() || "{}"));
await route.fulfill(json({ data: {} }));
return;
}
await route.fallback();
});
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-4301")).toBeVisible();
await page.getByTestId("invoicing-period-customer-invoice-4301").click();
await expect(
page.getByRole("heading", { name: /Orders from multiple months|Ordrer fra flere måneder/i })
).toBeVisible();
await page.getByRole("button", { name: /Split by month|Opdel efter måned/i }).click();
await expect.poll(() => splitRequests.length).toBe(1);
expect(splitRequests[0]).toEqual({
dateFrom: "2026-03-28",
dateTo: "2026-04-02",
invoice_collection_ids: [88001],
preview: false,
});
expect(economicInvoiceRequests).toEqual([]);
await expect(page.getByText(/Monthly split completed|Månedsopdeling fuldført/i)).toBeVisible();
});
test("@smoke period view invoices multiple customers with page refresh and independent loading", async ({ page }) => {
const token = "superuser-period-parallel-token";
const periodRequests = [];
const economicInvoiceRequests = [];
const statusResolvers = new Map();
await suppressVueDevtoolsOverlay(page);
await seedAuthenticatedState(page, token);
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
loginToken: token,
invoiceDistribution: true,
});
await page.route("**/superuser/invoicing/period**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/superuser/invoicing/period")) {
await route.fallback();
return;
}
const url = new URL(route.request().url());
const customerNumbers =
url.searchParams
.get("customerNumbers")
?.split(",")
.map((value) => Number(value))
.filter((value) => Number.isInteger(value) && value > 0) ?? [];
periodRequests.push({
dateFrom: url.searchParams.get("dateFrom"),
dateTo: url.searchParams.get("dateTo"),
customerNumbers: url.searchParams.get("customerNumbers"),
});
await route.fulfill(
json({
data:
customerNumbers.length > 0
? createParallelInvoiceTargetedPayload(customerNumbers)
: createParallelInvoicePeriodPayload(),
})
);
});
await page.route("**/collected-invoices/economic/queue/status**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
const url = new URL(route.request().url());
const jobId = Number(url.searchParams.get("job_id") || 0);
await new Promise((resolve) => {
statusResolvers.set(jobId, resolve);
});
await route.fulfill(
json({
data: {
job: {
id: jobId,
status: "COMPLETED",
progress_percent: 100,
progress_message: "Done",
result: {},
},
},
})
);
});
await page.route("**/collected-invoices/economic**", async (route) => {
if (
route.request().method() !== "POST" ||
!matchesApiPath(route.request().url(), "/collected-invoices/economic")
) {
await route.fallback();
return;
}
const payload = JSON.parse(route.request().postData() || "{}");
economicInvoiceRequests.push(payload);
await route.fulfill(
json({
data: {
job: {
id: payload.id === 55501 ? 77001 : 77003,
status: "QUEUED",
progress_percent: 0,
progress_message: "Queued",
result: null,
},
},
})
);
});
await page.route("**/departments**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
const url = new URL(route.request().url());
if (!(url.pathname === "/departments" || url.pathname === "/api/departments")) {
await route.fallback();
return;
}
await route.fulfill(
json({
data: [
{ id: 1, name: "Copenhagen" },
{ id: 2, name: "Odense" },
],
})
);
});
await page.goto("/superuser/invoices?activeTab=period&startDate=2026-07-01&endDate=2026-07-31&periodView=all");
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("invoicing-period-view-selector-all").click();
const visibleCustomerOrder = () =>
page
.locator(".period-customer-card[data-testid^='invoicing-period-customer-']")
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid")?.replace("invoicing-period-customer-", ""))
);
const fullRequestCountBeforeInvoiceClicks = periodRequests.filter((request) => !request.customerNumbers).length;
await expect.poll(visibleCustomerOrder).toEqual(["4001", "4003", "4004"]);
await page.getByTestId("invoicing-period-customer-invoice-4001").click();
await page.getByTestId("invoicing-period-customer-invoice-4003").click();
await expect.poll(() => economicInvoiceRequests.map((request) => request.id).sort()).toEqual([55501, 55503]);
expect(periodRequests.filter((request) => !request.customerNumbers)).toHaveLength(
fullRequestCountBeforeInvoiceClicks
);
expect(periodRequests.filter((request) => request.customerNumbers)).toHaveLength(0);
await expect(page.getByTestId("invoicing-period-customer-invoice-4001")).toBeDisabled();
await expect(page.getByTestId("invoicing-period-customer-invoice-4001")).toHaveClass(/is-loading/);
await expect(page.getByTestId("invoicing-period-customer-invoice-4003")).toBeDisabled();
await expect(page.getByTestId("invoicing-period-customer-invoice-4003")).toHaveClass(/is-loading/);
await expect(page.getByTestId("invoicing-period-customer-invoice-4004")).toBeEnabled();
await expect(page.getByTestId("invoicing-period-customer-invoice-4004")).not.toHaveClass(/is-loading/);
await expect.poll(() => statusResolvers.has(77001)).toBeTruthy();
statusResolvers.get(77001)?.();
await expect
.poll(() => periodRequests.filter((request) => !request.customerNumbers).length)
.toBeGreaterThan(fullRequestCountBeforeInvoiceClicks);
const fullRequestCountAfterFirstCompletion = periodRequests.filter((request) => !request.customerNumbers).length;
await expect(page.getByTestId("invoicing-period-customer-invoice-4003")).toBeDisabled();
await expect(page.getByTestId("invoicing-period-customer-invoice-4003")).toHaveClass(/is-loading/);
await expect(page.getByTestId("invoicing-period-customer-invoice-4004")).toBeEnabled();
await expect.poll(visibleCustomerOrder).toEqual(["4001", "4003", "4004"]);
await expect.poll(() => statusResolvers.has(77003)).toBeTruthy();
statusResolvers.get(77003)?.();
await expect
.poll(() => periodRequests.filter((request) => !request.customerNumbers).length)
.toBeGreaterThan(fullRequestCountAfterFirstCompletion);
await expect.poll(visibleCustomerOrder).toEqual(["4001", "4003", "4004"]);
expect(periodRequests.filter((request) => request.customerNumbers)).toHaveLength(0);
});
test("@smoke period view date changes trigger a refreshed period query", async ({ page }) => {
let didDelayDateRefresh = false;
let releaseDateRefresh;
const { periodRequests } = await openPeriodView(page);
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
await page.route("**/superuser/invoicing/period**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/superuser/invoicing/period")) {
await route.fallback();
return;
}
const url = new URL(route.request().url());
if (url.searchParams.get("dateFrom") !== "2026-04-02" || url.searchParams.get("periodWarm") === "1") {
await route.fallback();
return;
}
periodRequests.push({
dateFrom: url.searchParams.get("dateFrom"),
dateTo: url.searchParams.get("dateTo"),
});
if (!didDelayDateRefresh) {
didDelayDateRefresh = true;
await new Promise((resolve) => {
releaseDateRefresh = resolve;
});
}
await route.fulfill(json({ data: createPeriodPayloadForChangedRange() }));
});
await fillPeriodDate(page, "date-period-start", "2026-04-02");
await expect(page.getByTestId("invoicing-period-view-selector-all").locator("progress").first()).toBeVisible();
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth))
.toBeLessThanOrEqual(1);
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
releaseDateRefresh?.();
await fillPeriodDate(page, "date-period-end", "2026-04-30");
const firstRange = periodRequests[0];
await expect
.poll(() =>
periodRequests.some(
(request) => request.dateFrom !== firstRange?.dateFrom || request.dateTo !== firstRange?.dateTo
)
)
.toBeTruthy();
await expect(page.getByTestId("invoicing-period-customer-5001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-view-selector-all").locator("progress").first()).toHaveCount(0);
await expect(page).toHaveURL(/activeTab=period/);
});
test("@smoke period partial month warning selects the whole calendar month", async ({ page }) => {
const { periodRequests } = await openPeriodView(page);
await fillPeriodDate(page, "date-period-start", "2026-05-04");
await fillPeriodDate(page, "date-period-end", "2026-05-05");
const requestCountBeforeClick = periodRequests.length;
await page.getByTestId("date-period-set-entire-month").click();
await expect.poll(() => periodRequests.length).toBeGreaterThan(requestCountBeforeClick);
await expect
.poll(() => periodRequests.at(-1))
.toEqual({
dateFrom: "2026-05-01",
dateTo: "2026-05-31",
});
});
test("@smoke period month shortcuts select whole calendar months", async ({ page }) => {
await page.clock.setFixedTime(new Date("2026-05-04T10:00:00.000Z"));
const { periodRequests } = await openPeriodView(page);
const initialRequestCount = periodRequests.length;
await page.getByTestId("date-period-other-dropdown-trigger").click();
await page.getByTestId("date-period-other-last_month").click();
await expect.poll(() => periodRequests.length).toBeGreaterThan(initialRequestCount);
await expect
.poll(() => periodRequests.at(-1))
.toEqual({
dateFrom: "2026-04-01",
dateTo: "2026-04-30",
});
});
test("@smoke period view reload button triggers a fresh period query", async ({ page }) => {
const pageErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
const { periodRequests } = await openPeriodView(page);
await expect(page.getByTestId("invoicing-period-page-reload-button")).toBeVisible();
await expect(page.getByTestId("invoicing-period-reload-button")).toHaveCount(0);
const initialRequestCount = periodRequests.length;
await page.getByTestId("invoicing-period-page-reload-button").click();
await expect.poll(() => periodRequests.length).toBeGreaterThan(initialRequestCount);
await page.waitForTimeout(250);
await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute("data-current-view", "all");
await expect(page).toHaveURL(/activeTab=period/);
expect(pageErrors).not.toEqual(expect.arrayContaining([expect.stringContaining("reading 'types'")]));
});
test("@smoke period page keeps cached cards visible during refresh, search, and pagination", async ({ page }) => {
const periodRequests = [];
let delayNextPeriodResponse = false;
let releaseDelayedResponse = null;
const pageCustomer = (customerNumber, customerName, transactionId) => ({
id: customerNumber,
customer_number: customerNumber,
customer_name: customerName,
requires_action: true,
transactions: [
{
id: transactionId,
date: "2026-04-14T10:00:00.000Z",
amount: 210,
booked: false,
excluded: false,
invoice_collection_id: transactionId + 1000,
queue_status: null,
queue_job_id: null,
},
],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
});
const paginatedPeriodResponse = ({ search, pageNumber }) => {
const isSearch = String(search || "")
.toLowerCase()
.includes("beta");
const customer = isSearch
? pageCustomer(9102, "Beta Search", 9912)
: pageNumber === 2
? pageCustomer(9103, "Gamma Page", 9913)
: pageCustomer(9101, "Alpha Cached", 9911);
const total = isSearch ? 1 : 30;
return {
__rawResponse: {
data: {
dateFrom: "2026-04-01 00:00:00",
dateTo: "2026-04-30 23:59:59",
types: {
all: [customer],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
type_counts: {
all: {
requires_action: total,
draft: 0,
manual_flags: 0,
automatic_flags: 0,
completed: 0,
total,
},
},
type_totals: {
all: {
total: isSearch ? 1200 : 9000,
booked: isSearch ? 400 : 6000,
not_booked: isSearch ? 800 : 3000,
},
},
},
meta: {
pagination: {
page: pageNumber,
per_page: 1,
total,
search: search || "",
filters: {
includeRequiresAction: true,
includeBooked: true,
},
order: null,
},
},
},
};
};
await openPeriodView(page, {
payloadFactory: async ({ url }) => {
const pageNumber = Number(url.searchParams.get("page") || 1);
const search = url.searchParams.get("search") || "";
const isWarmRequest = url.searchParams.get("periodWarm") === "1";
if (!isWarmRequest) {
periodRequests.push({
page: pageNumber,
search,
limit: url.searchParams.get("limit"),
periodView: url.searchParams.get("periodView"),
});
}
if (delayNextPeriodResponse && !isWarmRequest) {
delayNextPeriodResponse = false;
await new Promise((resolve) => {
releaseDelayedResponse = resolve;
});
}
return paginatedPeriodResponse({ search, pageNumber });
},
});
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-9101")).toBeVisible();
await expect(page.getByTestId("invoicing-period-view-selector-all")).toContainText("(0/30)");
await page.getByTestId("invoicing-period-flag-tabs").getByText("Filters").first().click();
await expect(page.getByTestId("invoicing-period-limit-select")).toHaveValue("100");
await expect.poll(() => periodRequests.some((request) => request.limit === "100")).toBeTruthy();
const statistics = page.getByTestId("invoicing-period-headline-statistics");
await expect(statistics.getByTestId("invoicing-period-headline-total")).toContainText("9.000,00");
await expect(statistics.getByTestId("invoicing-period-headline-booked")).toContainText("6.000,00");
await expect(statistics.getByTestId("invoicing-period-headline-not-booked")).toContainText("3.000,00");
const periodPagination = page.getByTestId("invoicing-period-pagination-navigation");
await periodPagination.locator(".pagination-next").click();
await expect.poll(() => periodRequests.some((request) => request.page === 2)).toBeTruthy();
await expect(page.getByTestId("invoicing-period-customer-9103")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-9101")).toHaveCount(0);
delayNextPeriodResponse = true;
await periodPagination.locator(".pagination-previous").click();
await expect(page.getByTestId("invoicing-period-customer-9101")).toBeVisible();
await expect(page.getByTestId("invoicing-period-page-reload-button")).toHaveClass(/is-loading/);
releaseDelayedResponse?.();
await expect(page.getByTestId("invoicing-period-page-reload-button")).not.toHaveClass(/is-loading/);
await page.getByTestId("invoicing-period-search-input").fill("Beta");
await expect.poll(() => periodRequests.some((request) => request.search === "Beta")).toBeTruthy();
await expect(page.getByTestId("invoicing-period-customer-9102")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-9101")).toHaveCount(0);
await expect(page).toHaveURL(/periodSearch=Beta/);
await expect(page).toHaveURL(/periodLimit=100/);
await page.getByTestId("invoicing-period-limit-select").selectOption("all");
await expect.poll(() => periodRequests.some((request) => request.limit === "all")).toBeTruthy();
await expect(page).toHaveURL(/periodLimit=all/);
});
test("@smoke period monthly split posts selected range and refreshes period data", async ({ page }) => {
const { periodRequests } = await openPeriodView(page);
const splitRequests = [];
const initialRequestCount = periodRequests.length;
await page.route("**/collected-invoices/split-by-month**", async (route) => {
if (
route.request().method() !== "POST" ||
!matchesApiPath(route.request().url(), "/collected-invoices/split-by-month")
) {
await route.fallback();
return;
}
const payload = JSON.parse(route.request().postData() || "{}");
splitRequests.push(payload);
if (payload.preview) {
await route.fulfill(
json({
data: {
preview: true,
processed_count: 2,
changed_count: 1,
skipped_count: 1,
changed: [
{
invoice_collection_id: 101,
months: [
{
month: "2026-03",
order_count: 2,
will_create_collection: false,
target_invoice_collection_id: 101,
},
{
month: "2026-04",
order_count: 1,
will_create_collection: true,
target_invoice_collection_id: null,
},
],
},
],
skipped: [
{
invoice_collection_id: 202,
message: "Invoice collection already belongs to one month",
},
],
},
})
);
return;
}
await route.fulfill(
json({
data: {
preview: false,
processed_count: 2,
changed_count: 1,
skipped_count: 1,
},
})
);
});
await page.getByTestId("invoicing-period-split-by-month-button").click();
await expect(page.getByText(/Preview monthly split|Forhåndsvis månedsopdeling/i)).toBeVisible();
await expect(
page.locator(".swal2-html-container").filter({ hasText: /(?:Collection|Fakturasamling|fakturasamling)?\s*#101/i })
).toBeVisible();
await expect(page.locator(".swal2-html-container td").filter({ hasText: /^2026-04$/ })).toBeVisible();
await expect(page.getByText(/Create new collection|Opret ny fakturasamling/i)).toBeVisible();
await page.getByRole("button", { name: /apply split|udfør opdeling/i }).click();
await expect.poll(() => splitRequests.length).toBe(2);
expect(splitRequests[0]).toEqual({
dateFrom: periodRequests[0]?.dateFrom,
dateTo: periodRequests[0]?.dateTo,
preview: true,
});
expect(splitRequests[1]).toEqual({
dateFrom: periodRequests[0]?.dateFrom,
dateTo: periodRequests[0]?.dateTo,
preview: false,
});
await expect(page.getByText(/Processed 2 invoice collections|Behandlede 2 fakturasamlinger/i)).toBeVisible();
await page.getByRole("button", { name: "OK" }).click();
await expect.poll(() => periodRequests.length).toBeGreaterThan(initialRequestCount);
});
test("@smoke period view shows a queued CTA when backend queue metadata blocks invoicing", async ({ page }) => {
const periodRequests = [];
await seedInvoicesPage(page);
await setupPeriodEndpoints(page, periodRequests);
await page.route("**/superuser/invoicing/period**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
const url = new URL(route.request().url());
periodRequests.push({
dateFrom: url.searchParams.get("dateFrom"),
dateTo: url.searchParams.get("dateTo"),
});
await route.fulfill(
json({
data: createQueuedPeriodPayload(),
})
);
});
await page.goto("/superuser/invoices?activeTab=period&startDate=2026-07-01&endDate=2026-07-31&periodView=all");
await expect(page).toHaveURL(/activeTab=period/);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-6001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-queue-6001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-queue-6001")).toBeDisabled();
await expect(page.getByTestId("invoicing-period-customer-invoice-6001")).toHaveCount(0);
expect(periodRequests.length).toBeGreaterThan(0);
});
test("@smoke period view shows a disabled Kladde CTA when backend draft metadata blocks invoicing", async ({
page,
}) => {
await openPeriodView(page, { payloadFactory: createDraftBlockedPeriodPayload });
const draftedOrder = {
id: 9301,
customer_id: 6101,
customer_name: "Drafted Fleet",
cashier_id: 7,
cashier_name: "Jeppe",
department_id: 1,
reg_1: "EC21233",
reg_2: "",
reg_3: "",
reference: "EC21233",
notes: "",
po: "",
created_at: "2026-07-14 10:00:00",
total_net_amount: 210,
invoice_collection_id: 16501,
invoice_collection: {
id: 16501,
closed_at: "2026-07-14",
booked_invoice_id: null,
},
economic_invoice_module: null,
stripe_invoice_module: null,
error_message: null,
completed_at: null,
pending_handheld: false,
user_id: 41,
};
await page.route("**/orders**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/orders")) {
await route.fallback();
return;
}
await route.fulfill(
json({
data: [draftedOrder],
meta: {
pagination: {
limit: 20,
total: 1,
},
},
})
);
});
await page.route("**/collected-invoices/16501**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill(
json({
data: {
id: 16501,
orders: [draftedOrder],
},
})
);
});
await page.getByTestId("invoicing-period-view-selector-all").click();
const allSelector = page.getByTestId("invoicing-period-view-selector-all");
await expect(allSelector).toContainText("(0/1)");
const draftProgressSegment = page.getByTestId("invoicing-period-view-selector-progress-draft-all");
await expect(draftProgressSegment).toBeVisible();
await expect(draftProgressSegment).toHaveCSS("background-color", "rgb(246, 195, 68)");
const customerRow = page.getByTestId("invoicing-period-customer-6101");
await expect(customerRow).toBeVisible();
await expect(customerRow.locator(".color-indicator i.fa-circle").first()).toHaveClass(/has-text-warning/);
const draftTag = page.getByTestId("invoicing-period-customer-draft-6101");
await expect(draftTag).toBeVisible();
await expect(draftTag).toHaveClass(/tag/);
await expect(draftTag).toHaveClass(/is-warning/);
await expect(draftTag).toHaveAttribute("aria-disabled", "true");
await expect(draftTag.locator("i")).toHaveClass(/fa-file-alt/);
await expect(draftTag).toContainText("Kladde");
await expect(page.getByTestId("invoicing-period-customer-invoice-6101")).toHaveCount(0);
await customerRow.getByText("Drafted Fleet").click();
await expect(page.getByTestId("invoice-period-object-tree")).toBeVisible();
const collectionTotal = page
.getByTestId("invoice-period-tree-node-collected_order_invoice:16501")
.locator(".invoice-period-tree-node__subtitle");
await expect(collectionTotal).toContainText("210");
});
test("@smoke period view restores Fakturer nu when draft metadata is stale or deleted", async ({ page }) => {
await openPeriodView(page, { payloadFactory: createStaleDraftPeriodPayload });
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-6102")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-draft-6102")).toHaveCount(0);
const invoiceButton = page.getByTestId("invoicing-period-customer-invoice-6102");
await expect(invoiceButton).toBeVisible();
await expect(invoiceButton).toBeEnabled();
await expect(invoiceButton).toContainText(/Fakturer nu|Invoice now/i);
});
test("@smoke period view refreshes queue state when the Period route becomes active again", async ({ page }) => {
const periodRequests = [];
let requestCount = 0;
let returnQueuedPayload = false;
await seedInvoicesPage(page);
await setupPeriodEndpoints(page, periodRequests);
await page.route("**/superuser/invoicing/period**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
requestCount += 1;
const url = new URL(route.request().url());
periodRequests.push({
dateFrom: url.searchParams.get("dateFrom"),
dateTo: url.searchParams.get("dateTo"),
});
await route.fulfill(
json({
data: returnQueuedPayload ? createQueuedPeriodPayload() : createPeriodPayload(),
})
);
});
await page.goto("/superuser/invoices?activeTab=overview");
await expect(page).toHaveURL(/activeTab=overview/);
returnQueuedPayload = true;
await page.goto("/superuser/invoices?activeTab=period&startDate=2026-07-01&endDate=2026-07-31&periodView=all");
await expect(page).toHaveURL(/activeTab=period/);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({
timeout: periodRouteReadyTimeout,
});
await expect.poll(() => requestCount, { timeout: periodRouteReadyTimeout }).toBeGreaterThan(0);
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-queue-6001")).toBeVisible();
});
});