-
-
-
+
+
-
+ }"
+ v-bind:is_loading="isLoading"
+ />
-
@@ -1268,53 +1256,71 @@ watch(
}
.is-selector-view {
+ align-items: center;
+ display: flex;
+ min-height: 4rem;
overflow: hidden;
+ padding: 0.875rem 1rem;
width: 100%;
}
-.is-selector-view :deep(.level),
-.is-selector-view :deep(.level-left),
-.is-selector-view :deep(.level-right),
-.is-selector-view :deep(.level-item),
+.period-selector-row,
+.period-selector-label,
.is-selector-view :deep(.color-indicator),
.is-selector-view :deep(.icon-text) {
min-width: 0;
}
-.is-selector-view :deep(.level-left) {
- flex: 1 1 auto;
- overflow: hidden;
-}
-
-.is-selector-view :deep(.level) {
+.period-selector-row {
align-items: center;
display: grid;
- gap: 0.35rem;
- grid-template-columns: minmax(0, 1fr) minmax(84px, 104px);
+ column-gap: 0.75rem;
+ grid-template-columns: minmax(0, 1fr) 72px 1rem;
width: 100%;
}
-.is-selector-view :deep(.level-left .icon-text > span:last-child) {
+.period-selector-label {
+ overflow: hidden;
+}
+
+.period-selector-label :deep(.color-indicator),
+.period-selector-label :deep(.icon-text) {
+ width: 100%;
+}
+
+.period-selector-label :deep(.icon-text) {
+ align-items: center;
+ display: grid;
+ grid-template-columns: 1.5rem minmax(0, 1fr);
+}
+
+.period-selector-label :deep(.icon) {
+ margin: 0;
+}
+
+.period-selector-label :deep(.icon-text > span:last-child) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
-.is-selector-view :deep(.level-right) {
+.period-selector-progress-slot {
+ align-items: center;
display: flex;
- flex: none;
- justify-content: flex-end;
- max-width: 104px;
- min-width: 84px;
+ justify-content: stretch;
+ min-width: 0;
width: 100%;
}
-.is-selector-view :deep(.period-selector-progress-slot) {
- flex: 1 1 72px;
- max-width: 72px;
- min-width: 56px;
- width: auto;
+.period-selector-progress-slot > .progress {
+ margin: 0;
+ width: 100%;
+}
+
+.period-selector-chevron {
+ justify-self: end;
+ margin: 0;
}
.period-view-navigation__heading {
diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeNodes.js b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeNodes.js
index d2ae483f..e502e62e 100644
--- a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeNodes.js
+++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeNodes.js
@@ -95,39 +95,14 @@ const formatMonthName = (date, locale = DEFAULT_LOCALE) => capitalizeFirst(new I
month: "long",
}).format(date));
const formatDayMonth = (date, locale = DEFAULT_LOCALE) => `${date.getDate()} ${formatMonthName(date, locale)}`;
-const lastDayOfMonth = (date) => new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
-const isSameMonth = (left, right) => left && right && left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth();
-const isWholeMonthRange = (dateFrom, dateTo) => (
- dateFrom
- && dateTo
- && isSameMonth(dateFrom, dateTo)
- && dateFrom.getDate() === 1
- && dateTo.getDate() === lastDayOfMonth(dateFrom)
-);
const getOrderDate = (order) => parseLocalDate(order?.date ?? order?.created_at ?? order?.closed_at ?? order?.updated_at);
export const buildRelativeCollectionLabel = ({
collectionId,
orders = [],
- dateFrom = null,
- dateTo = null,
- collectionCount = 1,
locale = DEFAULT_LOCALE,
fallbackLabel = null,
} = {}) => {
- const rangeStart = parseLocalDate(dateFrom);
- const rangeEnd = parseLocalDate(dateTo);
-
- if (Number(collectionCount) === 1 && isWholeMonthRange(rangeStart, rangeEnd)) {
- const monthLabel = formatMonthName(rangeStart, locale);
- return {
- label: monthLabel,
- kind: "month",
- monthLabel,
- collectionId,
- };
- }
-
const orderDates = (orders || [])
.map(getOrderDate)
.filter(Boolean)
@@ -247,7 +222,8 @@ export const makeOrderItemNode = (item, options = {}) => {
children: [],
selectable: true,
actionable: true,
- icon: "fa-list-check",
+ hideToggle: true,
+ hideIcon: true,
meta: {
item,
itemId,
@@ -331,6 +307,32 @@ export const buildOrderItemTree = (items = [], options = {}) => {
return roots;
};
+const flattenOrderItemNodes = (nodes, depth = 0, parent = null) => nodes.flatMap((node) => [
+ {
+ ...node,
+ isLeaf: true,
+ children: [],
+ meta: {
+ ...node.meta,
+ tableDepth: depth,
+ isAssociatedItem: depth > 0,
+ associatedParentId: parent?.meta?.itemId || null,
+ associatedParentLabel: parent?.label || null,
+ },
+ },
+ ...flattenOrderItemNodes(node.children || [], depth + 1, node),
+]);
+
+export const buildOrderItemTableRows = (items = [], options = {}) =>
+ flattenOrderItemNodes(buildOrderItemTree(items, options)).map((node, index) => ({
+ ...node,
+ meta: {
+ ...node.meta,
+ orderId: node.meta.orderId || toPositiveInteger(options.orderId),
+ showTableHeader: index === 0,
+ },
+ }));
+
export const classifyAttachment = (attachment) => {
const rawName = normalizeString(
attachment?.file_name
@@ -514,7 +516,6 @@ export const buildCollectionRootNodes = (customer, transactions = [], excludedOr
}
});
- const collectionCount = groups.size;
const nodes = Array.from(groups.entries())
.sort(([left], [right]) => left - right)
.map(([collectionId, orders]) => {
@@ -522,9 +523,6 @@ export const buildCollectionRootNodes = (customer, transactions = [], excludedOr
const relativeLabel = buildRelativeCollectionLabel({
collectionId,
orders,
- dateFrom: labels.dateFrom,
- dateTo: labels.dateTo,
- collectionCount,
locale: labels.locale,
fallbackLabel,
});
diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeSnapshot.ts b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeSnapshot.ts
index e49155d4..f26ed09d 100644
--- a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeSnapshot.ts
+++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeSnapshot.ts
@@ -1,7 +1,8 @@
import {
TREE_CATEGORY_TYPES,
TREE_NODE_TYPES,
- buildOrderItemTree,
+ buildRelativeCollectionLabel,
+ buildOrderItemTableRows,
classifyAttachment,
getTreeAmount,
makeAttachmentNode,
@@ -78,6 +79,7 @@ export type InvoicingPeriodTreeSnapshot =
export interface SnapshotTreeLabels {
collection: (id: number) => string;
+ locale?: string;
ordersWithoutCollection: string;
orders: string;
items: string;
@@ -258,9 +260,10 @@ const buildBookingNode = (booking: Record
, labels: SnapshotTreeLabe
const buildCompleteOrderNode = (order: InvoicingPeriodSnapshotOrder, labels: SnapshotTreeLabels) => {
const node = makeOrderNode(order, { label: labels.order(order.id) });
node.meta.inSelectedPeriod = order.in_selected_period;
- const itemNodes = buildOrderItemTree(order.items, {
+ const itemNodes = buildOrderItemTableRows(order.items, {
fallbackLabel: (item: any, index: number) => labels.orderItemFallback(positiveInteger(item?.id) ?? index + 1),
invoiceState: order.invoice_state,
+ orderId: order.id,
});
const attachmentGroups = new Map([["certificate", []], ["image", []], ["other", []]]);
order.attachments.forEach((attachment: any, index: number) => {
@@ -281,9 +284,6 @@ const buildCompleteOrderNode = (order: InvoicingPeriodSnapshotOrder, labels: Sna
return xlvaskNode;
});
const categories = [
- itemNodes.length > 0
- ? category(makeNodeId(TREE_NODE_TYPES.CATEGORY, order.id, TREE_CATEGORY_TYPES.ORDER_ITEMS), labels.items, TREE_CATEGORY_TYPES.ORDER_ITEMS, node.type, node.id, itemNodes)
- : null,
...(["certificate", "image", "other"] as const).map((kind) => {
const children = attachmentGroups.get(kind) ?? [];
const categoryType = {
@@ -302,8 +302,8 @@ const buildCompleteOrderNode = (order: InvoicingPeriodSnapshotOrder, labels: Sna
? category(makeNodeId(TREE_NODE_TYPES.CATEGORY, order.id, TREE_CATEGORY_TYPES.ORDER_XLVASK), labels.xlvask, TREE_CATEGORY_TYPES.ORDER_XLVASK, node.type, node.id, xlvaskNodes)
: null,
].filter(Boolean);
- node.children = categories;
- node.isLeaf = categories.length === 0;
+ node.children = [...itemNodes, ...categories];
+ node.isLeaf = node.children.length === 0;
return node;
};
@@ -325,8 +325,16 @@ const genericObjectNode = (type: string, object: Record, label: str
export const buildCompleteSnapshotRootNodes = (snapshot: CompleteInvoicingPeriodTreeSnapshot, labels: SnapshotTreeLabels) => {
const customer = snapshot.customer;
const collectionNodes = [...snapshot.collections].sort((left, right) => left.id - right.id).map((collection) => {
+ const fallbackLabel = labels.collection(collection.id);
+ const relativeLabel = buildRelativeCollectionLabel({
+ collectionId: collection.id,
+ orders: collection.orders.filter((order) => order.in_selected_period),
+ locale: labels.locale,
+ fallbackLabel,
+ });
const node = makeCollectionNode(collection.id, collection.orders, customer, {
- label: labels.collection(collection.id),
+ label: relativeLabel?.label || fallbackLabel,
+ relativeLabel,
collectionSummary: collection,
invoiceState: asString(collection.invoice_state ?? collection.state),
completeOrderCount: collection.complete_order_count,
diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue
index 148cbe86..8faf3714 100644
--- a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue
+++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue
@@ -1757,7 +1757,8 @@ const getTransactionQueryParameters = () => {
display: grid;
grid-template-areas:
"identity identity settings"
- "attributes count amount"
+ "count amount amount"
+ "attributes attributes attributes"
"activity state state";
grid-template-columns: minmax(0, 1fr) auto auto;
margin: 0 !important;
@@ -1765,14 +1766,14 @@ const getTransactionQueryParameters = () => {
padding: 0.25rem;
}
.period-customer-card__row > .column { min-width: 0; padding: 0.35rem; }
-.period-customer-card__identity { min-width: 0; }
+.period-customer-card__identity { min-width: 0; overflow: hidden; }
.period-customer-card__identity { grid-area: identity; }
-.period-customer-card__attributes { grid-area: attributes; }
+.period-customer-card__attributes { grid-area: attributes; justify-self: stretch; width: 100%; }
.period-customer-card__activity { grid-area: activity; }
-.period-customer-card__count { grid-area: count; white-space: nowrap; }
-.period-customer-card__amount { grid-area: amount; white-space: nowrap; }
+.period-customer-card__count { grid-area: count; justify-self: end; white-space: nowrap; }
+.period-customer-card__amount { grid-area: amount; justify-self: end; white-space: nowrap; }
.period-customer-card__state { grid-area: state; justify-self: end; }
-.period-customer-card__settings { grid-area: settings; }
+.period-customer-card__settings { align-self: start; grid-area: settings; justify-self: end; }
.period-customer-card__selector {
background: transparent;
border: 0;
@@ -1788,6 +1789,13 @@ const getTransactionQueryParameters = () => {
.period-customer-card__selector:focus-visible { box-shadow: 0 0 0 3px rgba(50, 115, 220, 0.28); outline: none; }
.period-customer-card__selector :deep(.color-indicator),
.period-customer-card__selector :deep(.icon-text) { min-width: 0; }
+.period-customer-card__selector :deep(.icon-text) {
+ align-items: center;
+ display: grid;
+ gap: 0.35rem;
+ grid-template-columns: 1.25rem minmax(0, 1fr);
+ width: 100%;
+}
.period-customer-card__selector :deep(.icon-text > span:last-child) {
min-width: 0;
overflow: hidden;
@@ -1798,13 +1806,22 @@ const getTransactionQueryParameters = () => {
.period-customer-review {
align-items: center;
color: #485466;
- display: flex;
+ display: grid;
font-size: 0.72rem;
gap: 0.25rem;
+ grid-template-columns: 1.25rem auto minmax(0, 1fr);
margin-top: 0.25rem;
+ min-width: 0;
}
-.period-customer-review__next { color: #6b7280; margin-left: 0.25rem; }
+.period-customer-review__next {
+ color: #6b7280;
+ margin-left: 0.25rem;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
.review-state--red { color: #b42318; }
.review-state--yellow { color: #9a6700; }
.review-state--blue { color: #175cd3; }
@@ -1873,8 +1890,9 @@ const getTransactionQueryParameters = () => {
.period-customer-card__row {
grid-template-areas:
"identity identity settings"
- "attributes count amount"
- "attributes state state";
+ "count amount amount"
+ "attributes attributes attributes"
+ "state state state";
}
.period-customer-card__attributes { align-self: stretch; grid-area: attributes; }
.period-customer-card__activity { display: none; }
diff --git a/tests/e2e/invoicing-period.smoke.spec.js b/tests/e2e/invoicing-period.smoke.spec.js
index 2821f1be..ff34a4ca 100644
--- a/tests/e2e/invoicing-period.smoke.spec.js
+++ b/tests/e2e/invoicing-period.smoke.spec.js
@@ -167,8 +167,9 @@ function createObjectTreePeriodPayload({ dateFrom = "2026-07-14" } = {}) {
customer_number: 4101,
customer_name: "Object Tree Logistics",
cashier_id: 7,
- cashier_name: "Copenhagen",
+ cashier_name: "Jeppe",
department_id: 1,
+ department_name: "Copenhagen",
invoice_collection_id: 3001,
invoice_collection: {
id: 3001,
@@ -1465,6 +1466,107 @@ test.describe("Invoicing period tab", () => {
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,
}) => {
@@ -1601,6 +1703,14 @@ test.describe("Invoicing period tab", () => {
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
);
@@ -1610,6 +1720,9 @@ test.describe("Invoicing period tab", () => {
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"]');
@@ -1930,6 +2043,21 @@ test.describe("Invoicing period tab", () => {
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");
@@ -2143,6 +2271,38 @@ test.describe("Invoicing period tab", () => {
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"),
@@ -2156,6 +2316,10 @@ test.describe("Invoicing period tab", () => {
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);
@@ -2164,13 +2328,33 @@ test.describe("Invoicing period tab", () => {
await expect(page.getByTestId("invoice-period-tree-node-order:9001")).toContainText("Vask #9001");
await expect(page.locator("[data-testid$='-product_id']")).toHaveCount(0);
- const fieldBoxes = await Promise.all(
- ["reference", "notes", "po", "registrations", "include_in_invoice", "total_net_amount"].map((field) =>
+ const firstOrderFieldRow = await Promise.all(
+ ["reference", "po", "notes", "department"].map((field) =>
getBoundingBox(page.getByTestId(`invoice-period-tree-field-order:9001-${field}`), field)
)
);
- const fieldTops = fieldBoxes.map((box) => Math.round(box.y));
- expect(Math.max(...fieldTops) - Math.min(...fieldTops)).toBeLessThanOrEqual(2);
+ 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"),
@@ -2178,11 +2362,29 @@ test.describe("Invoicing period tab", () => {
);
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(page.getByTestId("invoice-period-tree-field-order_item:7701-quantity")).toContainText(
- /Antal|Quantity/i
- );
- await expect(page.getByTestId("invoice-period-tree-field-order_item:7701-price")).toContainText(/Pris|Price/i);
+ 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(
@@ -2194,12 +2396,53 @@ test.describe("Invoicing period tab", () => {
]);
const amountRightEdges = alignedAmountBoxes.map((box) => Math.round(box.x + box.width));
expect(Math.max(...amountRightEdges) - Math.min(...amountRightEdges)).toBeLessThanOrEqual(8);
- await expandTreeNode(page, "order_item:7701");
- await expect(page.locator('[data-node-key="order_item:7701"] [data-node-key="order_item:7702"]')).toBeVisible();
+ 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();
@@ -2234,6 +2477,17 @@ test.describe("Invoicing period tab", () => {
});
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 }) => {
@@ -2333,6 +2587,26 @@ test.describe("Invoicing period tab", () => {
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()) {
@@ -2346,6 +2620,12 @@ test.describe("Invoicing period tab", () => {
}
expect(Math.abs(multiCardBox.height - singleCardBox.height)).toBeLessThanOrEqual(3);
+ 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);
diff --git a/tests/unit/buefy-tree-selection.spec.js b/tests/unit/buefy-tree-selection.spec.js
index 8eb52f91..49ec61e3 100644
--- a/tests/unit/buefy-tree-selection.spec.js
+++ b/tests/unit/buefy-tree-selection.spec.js
@@ -27,6 +27,24 @@ vi.mock("buefy", () => ({
}));
describe("BuefyTree checkbox selection", () => {
+ it("removes the icon wrapper only for nodes that explicitly opt out", () => {
+ const wrapper = mount(BuefyTree, {
+ props: {
+ data: [
+ { id: "default-icon", label: "Default icon", isLeaf: true },
+ { id: "compact", label: "Compact", isLeaf: true, hideIcon: true, hideToggle: true },
+ ],
+ },
+ });
+
+ expect(wrapper.find('[data-node-key="default-icon"] > .b-tree-node-content .b-tree-node-icon').exists()).toBe(true);
+ expect(wrapper.find('[data-node-key="compact"] > .b-tree-node-content .b-tree-node-icon').exists()).toBe(false);
+ expect(wrapper.find('[data-node-key="default-icon"] > .b-tree-node-content .b-tree-node-toggle').exists()).toBe(
+ true
+ );
+ expect(wrapper.find('[data-node-key="compact"] > .b-tree-node-content .b-tree-node-toggle').exists()).toBe(false);
+ });
+
it("uses non-selectable checkable categories as lazy select-all branch controllers", async () => {
const load = vi.fn(async () => [
{ id: "order:70128", label: "Ordre #70128", selectable: true, isLeaf: true },
diff --git a/tests/unit/invoicing-period-object-tree.spec.js b/tests/unit/invoicing-period-object-tree.spec.js
index 8e2e1122..9c3c1d92 100644
--- a/tests/unit/invoicing-period-object-tree.spec.js
+++ b/tests/unit/invoicing-period-object-tree.spec.js
@@ -111,6 +111,10 @@ vi.mock("vue-i18n", () => ({
}),
}));
+vi.mock("@/components/pagination/departmentTabs.vue", () => ({
+ getDepartmentName: (id) => (Number(id) === 75 ? "Department 75" : String(id)),
+}));
+
vi.mock("@/components/displays/buttons/EditableTableColumn.vue", () => ({
default: {
name: "EditableTableColumn",
@@ -381,7 +385,27 @@ describe("InvoicingPeriodObjectTree", () => {
});
});
- it("auto-expands order items when an order node is expanded", async () => {
+ it("refreshes a legacy collection date label when an existing order date changes", async () => {
+ const firstOrder = {
+ ...mocks.order,
+ created_at: "2026-06-01 10:00:00",
+ booked: true,
+ };
+ const wrapper = mountTree({ transactions: [firstOrder] });
+ const collectionLabel = () =>
+ wrapper.get('[data-node-key="collected_order_invoice:3001"] .invoice-period-tree-node__label-text');
+
+ expect(collectionLabel().text()).toContain("1 Juni");
+
+ await wrapper.setProps({
+ transactions: [{ ...firstOrder, created_at: "2026-06-30 10:00:00" }],
+ });
+ await flushPromises();
+
+ expect(collectionLabel().text()).toContain("30 Juni");
+ });
+
+ it("shows order items directly when an order node is expanded", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
@@ -389,11 +413,14 @@ describe("InvoicingPeriodObjectTree", () => {
await expandNode(wrapper, "order:9001");
expect(mocks.request).toHaveBeenCalledWith("/order/items", "GET", { order_id: 9001 });
- expect(wrapper.find('[data-node-key="category:9001:order_items"]').classes()).toContain("is-expanded");
+ expect(wrapper.find('[data-node-key="category:9001:order_items"]').exists()).toBe(false);
+ expect(
+ wrapper.find('[data-node-key="order:9001"] > .b-tree-children > [data-node-key="order_item:501"]').exists()
+ ).toBe(true);
expect(wrapper.text()).toContain("Premium wash");
});
- it("shows order-item quantity and price once through the standard editable fields", async () => {
+ it("uses one shared table header instead of repeating order-item field labels", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
@@ -405,12 +432,22 @@ describe("InvoicingPeriodObjectTree", () => {
const quantity = wrapper.get('[data-testid="invoice-period-tree-field-order_item:501-quantity"]');
const price = wrapper.get('[data-testid="invoice-period-tree-field-order_item:501-price"]');
- expect(quantity.text()).toContain("Antal");
- expect(quantity.text()).toContain("2");
- expect(price.text()).toContain("Pris");
- expect(price.text()).toContain("120 DKK");
+ const header = wrapper.get('[data-testid="invoice-period-order-item-table-header-9001"]');
+ expect(header.text()).toContain("Produkt");
+ expect(header.text()).toContain("Antal");
+ expect(header.text()).toContain("Pris");
+ expect(quantity.get(".invoice-period-order-item-table__value").text()).toBe("2");
+ expect(price.get(".invoice-period-order-item-table__value").text()).toBe("120 DKK");
+ expect(quantity.find(".invoice-period-tree-node-field__label").exists()).toBe(false);
+ expect(price.find(".invoice-period-tree-node-field__label").exists()).toBe(false);
+ expect(quantity.get(".is-sr-only").text()).toBe("Antal:");
+ expect(price.get(".is-sr-only").text()).toBe("Pris:");
expect(quantity.find(".editable-table-column-stub").exists()).toBe(true);
expect(price.find(".editable-table-column-stub").exists()).toBe(true);
+
+ await wrapper.get('[data-testid="invoice-period-order-item-table-select-all-9001"]').setValue(true);
+ await flushPromises();
+ expect(wrapper.get('[data-node-key="order_item:501"]').attributes("aria-checked")).toBe("true");
});
it("preserves untouched snapshot item values during an inline edit", async () => {
@@ -429,7 +466,6 @@ describe("InvoicingPeriodObjectTree", () => {
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
- await expandNode(wrapper, "category:9001:order_items");
await wrapper.get('[data-testid="invoice-period-tree-field-editor-order_item:501-price"]').trigger("click");
await flushPromises();
@@ -457,7 +493,6 @@ describe("InvoicingPeriodObjectTree", () => {
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
- await expandNode(wrapper, "category:9001:order_items");
await wrapper.get('[data-testid="invoice-period-tree-field-editor-order_item:501-price"]').trigger("click");
await flushPromises();
@@ -484,7 +519,6 @@ describe("InvoicingPeriodObjectTree", () => {
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
- await expandNode(wrapper, "category:9001:order_items");
await wrapper.get('[data-testid="invoice-period-tree-field-editor-order_item:501-price"]').trigger("click");
await flushPromises();
@@ -513,7 +547,8 @@ describe("InvoicingPeriodObjectTree", () => {
expect(wrapper.get('[data-node-key="collected_order_invoice:3001"]').classes()).toContain("is-expanded");
expect(wrapper.get('[data-node-key="category:3001:collection_orders"]').classes()).toContain("is-expanded");
expect(wrapper.get('[data-node-key="order:9001"]').classes()).toContain("is-expanded");
- expect(wrapper.get('[data-node-key="category:9001:order_items"]').classes()).toContain("is-expanded");
+ expect(wrapper.find('[data-node-key="category:9001:order_items"]').exists()).toBe(false);
+ expect(wrapper.get('[data-node-key="order_item:501"]').exists()).toBe(true);
expect(toggle().text()).toContain("Fold alle sammen");
expect(toggle().attributes("aria-pressed")).toBe("true");
@@ -536,7 +571,8 @@ describe("InvoicingPeriodObjectTree", () => {
expect(wrapper.get('[data-testid="invoice-period-tree-toggle-all"]').text()).toContain("Fold alle sammen");
expect(wrapper.get('[data-node-key="order:9001"]').classes()).toContain("is-expanded");
- expect(wrapper.get('[data-node-key="category:9001:order_items"]').classes()).toContain("is-expanded");
+ expect(wrapper.find('[data-node-key="category:9001:order_items"]').exists()).toBe(false);
+ expect(wrapper.get('[data-node-key="order_item:501"]').exists()).toBe(true);
});
it("renders row action wheels for actionable collection, order, and order item nodes", async () => {
@@ -580,7 +616,7 @@ describe("InvoicingPeriodObjectTree", () => {
expect(wrapper.find('[data-testid^="invoice-period-tree-field-empty-toggle-"]').exists()).toBe(false);
});
- it("uses wash labels, nests related order items, and omits product ids and type tags", async () => {
+ it("uses wash labels and aligns associated order items as direct table rows", async () => {
mocks.orderItems.push({
id: 502,
order_id: 9001,
@@ -595,10 +631,23 @@ describe("InvoicingPeriodObjectTree", () => {
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
- await expandNode(wrapper, "order_item:501");
expect(wrapper.get('[data-node-key="order:9001"]').text()).toContain("Vask #9001");
- expect(wrapper.find('[data-node-key="order_item:501"] [data-node-key="order_item:502"]').exists()).toBe(true);
+ expect(
+ wrapper.find('[data-node-key="order:9001"] > .b-tree-children > [data-node-key="order_item:502"]').exists()
+ ).toBe(true);
+ expect(
+ wrapper
+ .get('[data-testid="invoice-period-order-item-table-row-502"] .invoice-period-order-item-table__relation')
+ .text()
+ ).toBe("+");
+ expect(wrapper.get('[data-testid="invoice-period-order-item-table-row-502"] .is-sr-only').text()).toContain(
+ "Premium wash"
+ );
+ expect(wrapper.findAll('[data-testid="invoice-period-order-item-table-header-9001"]')).toHaveLength(1);
+ expect(wrapper.get('[data-testid="invoice-period-tree-field-order_item:502-reference"] .is-sr-only').text()).toBe(
+ "Reference:"
+ );
expect(wrapper.text()).toContain("Interior wash with a complete descriptive product label");
expect(wrapper.find('[data-testid$="-product_id"]').exists()).toBe(false);
expect(wrapper.find(".tag.is-white").exists()).toBe(false);
@@ -791,7 +840,7 @@ describe("InvoicingPeriodObjectTree", () => {
expect(wrapper.text()).not.toContain("E-conomic kladde #88");
});
- it("colors collection, invoice, order, and item icons from the booked state", async () => {
+ it("colors structural icons from the booked state and omits the redundant order-item icon", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
@@ -816,12 +865,13 @@ describe("InvoicingPeriodObjectTree", () => {
expect(
wrapper.get('[data-node-key="order:9001"] > .b-tree-node-content .b-tree-node-icon span').classes()
).toContain("has-text-success");
- expect(
- wrapper.get('[data-node-key="category:9001:order_items"] > .b-tree-node-content .b-tree-node-icon span').classes()
- ).toContain("has-text-success");
- expect(
- wrapper.get('[data-node-key="order_item:501"] > .b-tree-node-content .b-tree-node-icon span').classes()
- ).toContain("has-text-success");
+ expect(wrapper.find('[data-node-key="category:9001:order_items"]').exists()).toBe(false);
+ expect(wrapper.find('[data-node-key="order_item:501"] > .b-tree-node-content .b-tree-node-icon').exists()).toBe(
+ false
+ );
+ expect(wrapper.find('[data-node-key="order_item:501"] > .b-tree-node-content .b-tree-node-toggle').exists()).toBe(
+ false
+ );
});
it("downloads booked e-conomic invoice pdfs for booked invoice nodes", async () => {
diff --git a/tests/unit/invoicing-period-tree-nodes.spec.js b/tests/unit/invoicing-period-tree-nodes.spec.js
index d47b2032..8a2367d3 100644
--- a/tests/unit/invoicing-period-tree-nodes.spec.js
+++ b/tests/unit/invoicing-period-tree-nodes.spec.js
@@ -4,6 +4,7 @@ import {
TREE_NODE_TYPES,
buildRelativeCollectionLabel,
buildCollectionRootNodes,
+ buildOrderItemTableRows,
buildOrderItemTree,
classifyAttachment,
getTreeAmount,
@@ -66,7 +67,7 @@ describe("invoicing period tree node builders", () => {
});
});
- it("uses relative collection labels for whole-month and multi-collection ranges", () => {
+ it("uses order date ranges for both whole-month and multi-collection views", () => {
const wholeMonthNodes = buildCollectionRootNodes(
{ customer_number: 2001, customer_name: "ACME" },
[
@@ -82,10 +83,11 @@ describe("invoicing period tree node builders", () => {
}
);
- expect(wholeMonthNodes[0].label).toBe("Juni");
+ expect(wholeMonthNodes[0].label).toBe("2 Juni → 18 Juni");
expect(wholeMonthNodes[0].meta.relativeLabel).toMatchObject({
- kind: "month",
- monthLabel: "Juni",
+ kind: "range",
+ startLabel: "2 Juni",
+ endLabel: "18 Juni",
});
const rangeNodes = buildCollectionRootNodes(
@@ -304,7 +306,13 @@ describe("invoicing period tree node builders", () => {
washId: "W-12",
},
});
- expect(itemNode).toMatchObject({ type: TREE_NODE_TYPES.ORDER_ITEM, isLeaf: true, actionable: true });
+ expect(itemNode).toMatchObject({
+ type: TREE_NODE_TYPES.ORDER_ITEM,
+ isLeaf: true,
+ actionable: true,
+ hideToggle: true,
+ hideIcon: true,
+ });
expect(attachmentNode).toMatchObject({
type: TREE_NODE_TYPES.ATTACHMENT,
meta: { attachmentId: 8, orderId: 91, attachmentType: "image" },
@@ -347,6 +355,25 @@ describe("invoicing period tree node builders", () => {
expect(nodes.slice(1).every((node) => node.isLeaf)).toBe(true);
});
+ it("flattens associated order items into stable direct table rows with one shared header", () => {
+ const rows = buildOrderItemTableRows(
+ [
+ { id: 501, product_name: "Wash" },
+ { id: 502, related_item_id: 501, product_name: "Interior" },
+ { id: 503, related_item_id: 502, product_name: "Mat cleaning" },
+ { id: 504, related_item_id: 999, product_name: "Orphan" },
+ { id: 505, related_item_id: 506, product_name: "Cycle A" },
+ { id: 506, related_item_id: 505, product_name: "Cycle B" },
+ ],
+ { orderId: 91 }
+ );
+
+ expect(rows.map((node) => node.meta.itemId)).toEqual([501, 502, 503, 504, 505, 506]);
+ expect(rows.map((node) => node.meta.tableDepth)).toEqual([0, 1, 2, 0, 0, 0]);
+ expect(rows.map((node) => node.meta.showTableHeader)).toEqual([true, false, false, false, false, false]);
+ expect(rows.every((node) => node.isLeaf && node.children.length === 0 && node.meta.orderId === 91)).toBe(true);
+ });
+
it("creates economic invoice nodes with draft/booked metadata", () => {
const node = makeEconomicInvoiceNode({
collectionId: 3001,
diff --git a/tests/unit/invoicing-period-tree-snapshot.spec.js b/tests/unit/invoicing-period-tree-snapshot.spec.js
index 6fabd58c..0b38b2ca 100644
--- a/tests/unit/invoicing-period-tree-snapshot.spec.js
+++ b/tests/unit/invoicing-period-tree-snapshot.spec.js
@@ -35,7 +35,10 @@ const payload = (revision = "rev-1") => ({
invoice_collection_id: 3001,
in_selected_period: true,
total_net_amount: 200,
- items: [{ id: 501, order_id: 9001, product_name: "Wash", quantity: 1, price: 200 }],
+ items: [
+ { id: 501, product_name: "Wash", quantity: 1, price: 200 },
+ { id: 502, related_item_id: 501, product_name: "Interior", quantity: 1, price: 25 },
+ ],
attachments: [],
bookings: [],
xlvask: [],
@@ -79,6 +82,25 @@ const labels = {
};
describe("invoicing period selected-customer tree snapshot", () => {
+ it("keeps date-range collection labels when building the complete snapshot tree", () => {
+ const rangePayload = payload("rev-range");
+ rangePayload.date_from = "2026-01-01";
+ rangePayload.date_to = "2026-01-31";
+ rangePayload.collections[0].orders[0].created_at = "2026-01-01 10:00:00";
+ rangePayload.collections[0].orders[1].created_at = "2026-01-31 10:00:00";
+ rangePayload.collections[0].orders[1].in_selected_period = true;
+
+ const snapshot = normalizeInvoicingPeriodTreeSnapshot({ data: { data: rangePayload } });
+ const roots = buildCompleteSnapshotRootNodes(snapshot, { ...labels, locale: "da-DK" });
+
+ expect(roots[0].label).toBe("1 Januar → 31 Januar");
+ expect(roots[0].meta.relativeLabel).toMatchObject({
+ kind: "range",
+ startLabel: "1 Januar",
+ endLabel: "31 Januar",
+ });
+ });
+
it("normalizes a complete capability-gated snapshot and keeps off-period orders in the complete collection", () => {
const snapshot = normalizeInvoicingPeriodTreeSnapshot({ data: { data: payload() } });
expect(snapshot).toMatchObject({
@@ -101,7 +123,15 @@ describe("invoicing period selected-customer tree snapshot", () => {
const orderNodes = roots[0].children[0].children;
expect(orderNodes.map((node) => node.id)).toEqual(["order:9001", "order:9002"]);
expect(orderNodes[1].meta.inSelectedPeriod).toBe(false);
- expect(orderNodes[0].children[0].children[0].id).toBe("order_item:501");
+ expect(orderNodes[0].children[0].id).toBe("order_item:501");
+ expect(orderNodes[0].children[0].meta.showTableHeader).toBe(true);
+ expect(orderNodes[0].children[0].meta.orderId).toBe(9001);
+ expect(orderNodes[0].children[1]).toMatchObject({
+ id: "order_item:502",
+ isLeaf: true,
+ meta: { orderId: 9001, tableDepth: 1, showTableHeader: false },
+ });
+ expect(orderNodes[0].children.some((node) => node.category === "order_items")).toBe(false);
});
it("rejects incomplete or capability-disabled payloads", () => {
diff --git a/tests/unit/superuser-invoices-view.spec.js b/tests/unit/superuser-invoices-view.spec.js
index a2be3d75..857ba1d2 100644
--- a/tests/unit/superuser-invoices-view.spec.js
+++ b/tests/unit/superuser-invoices-view.spec.js
@@ -104,6 +104,10 @@ const periodObjectTreeSource = readFileSync(
join(root, "src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue"),
"utf8"
);
+const periodFlagListSource = readFileSync(
+ join(root, "src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue"),
+ "utf8"
+);
const periodTreeSnapshotSource = readFileSync(
join(root, "src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeSnapshot.ts"),
"utf8"
@@ -440,6 +444,9 @@ describe("Periode tab contract", () => {
it("stacks grouped selectors in the desktop rail and keeps compact navigation responsive", () => {
expect(periodRightSource).toContain("grid-template-columns: minmax(0, 1fr);");
+ expect(periodRightSource).toContain('class="period-selector-row"');
+ expect(periodRightSource).toContain("grid-template-columns: minmax(0, 1fr) 72px 1rem;");
+ expect(periodRightSource).toContain("min-height: 4rem;");
expect(periodRightSource).toContain("@media screen and (min-width: 601px) and (max-width: 1023px)");
expect(periodRightSource).toContain("grid-template-columns: repeat(2, minmax(0, 1fr));");
expect(periodRightSource).toContain("@media screen and (max-width: 600px)");
@@ -453,6 +460,16 @@ describe("Periode tab contract", () => {
expect(periodViewAllSource).toContain("@container (max-width: 1024px)");
});
+ it("keeps customer card content and actions on stable grid rows", () => {
+ expect(periodViewAllSource).toContain('"count amount amount"');
+ expect(periodViewAllSource).toContain('"attributes attributes attributes"');
+ expect(periodViewAllSource).toContain("grid-template-columns: 1.25rem auto minmax(0, 1fr);");
+ expect(periodFlagListSource).toContain("grid-template-columns: 1.2rem minmax(0, 1fr);");
+ expect(periodFlagListSource).toContain("grid-column: 2;");
+ expect(periodFlagListSource).toContain("max-height: 2.5rem;");
+ expect(periodFlagListSource).toContain(".invoice-period-flag-row:focus-within");
+ });
+
it("sizes the inline month selector to the available content width with a readable cap", () => {
expect(periodViewHomeSource).toContain('class="invoicing-period-month-entry__selector"');
expect(periodViewHomeSource).toContain("max-width: 32rem;");
@@ -738,13 +755,29 @@ describe("Periode tab contract", () => {
expect(periodObjectTreeSource).toContain("overflow-wrap: anywhere;");
});
+ it("switches narrow object-tree metadata to a consistent four-column grid", () => {
+ expect(periodObjectTreeSource).toContain("@container (max-width: 56rem)");
+ expect(periodObjectTreeSource).toContain("grid-template-columns: repeat(4, minmax(0, 1fr));");
+ expect(periodObjectTreeSource).toContain("`invoice-period-tree-node-field--${field.key}`");
+ expect(periodObjectTreeSource).toContain(".invoice-period-tree-node-field--total_net_amount");
+ expect(periodObjectTreeSource).toContain("grid-column: 4 !important;");
+ });
+
+ it("shows the order department in the object-tree metadata", () => {
+ expect(periodObjectTreeSource).toContain('key: "department"');
+ expect(periodObjectTreeSource).toContain('label: commonText("department", "Afdeling")');
+ expect(periodObjectTreeSource).toContain("const orderDepartmentLabel = (order: any) =>");
+ expect(periodObjectTreeSource).toContain("order?.department_name || order?.department?.name");
+ expect(periodObjectTreeSource).toContain("getDepartmentName(departmentId)");
+ expect(periodObjectTreeSource).not.toContain("object.cashier_name ||");
+ });
+
it("exposes object-specific lazy child categories and multi-select operations", () => {
[
"COLLECTION_ORDERS",
"COLLECTION_AGREEMENTS",
"COLLECTION_PAYMENTS",
"COLLECTION_ECONOMIC",
- "ORDER_ITEMS",
"ORDER_ATTACHMENTS_CERTIFICATES",
"ORDER_ATTACHMENTS_IMAGES",
"ORDER_ATTACHMENTS_OTHER",
@@ -755,6 +788,8 @@ describe("Periode tab contract", () => {
].forEach((category) => {
expect(periodTreeNodeServiceSource).toContain(category);
});
+ expect(periodObjectTreeSource).toContain("buildOrderItemTableRows");
+ expect(periodObjectTreeSource).not.toContain("TREE_CATEGORY_TYPES.ORDER_ITEMS");
[
"runCollectionBulkAction",