Add E2E and unit tests for invoicing period flag handling, enhance duplicate groups filtering by date, and implement Excel export for department 75 distribution

This commit is contained in:
Jeppe Bundgaard
2026-05-13 13:58:00 +02:00
parent fba85a72c1
commit f7f01dcd2f
7 changed files with 438 additions and 8 deletions
@@ -8,6 +8,8 @@ import {view} from "../../imports/InvoicingBillingPeriodImportView.vue";
import {departments} from "@/components/pagination/departmentTabs.vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import {BTooltip} from "buefy";
import { exportRowsToExcel } from '@/services/TableExcelExportService.js';
import { extractVisibleRowsFromTable } from '@/services/AutoTableExportService.js';
import {
getDepartmentRelativeBookedAmount as calculateDepartmentRelativeBookedAmount,
getBookedDepartment75SourceBookedTotal,
@@ -380,6 +382,24 @@ const hasDepartment75CombinedBasis = computed(() => {
|| vehicleSubscriptionDistribution.value?.total_subscription_price !== undefined;
});
const isDepartment75CombinedFullyLoaded = computed(() => {
return fixed_pricing_department_distribution.value?.total_fixed_price !== undefined
&& vehicleSubscriptionDistribution.value?.total_subscription_price !== undefined;
});
const department75Table = ref<HTMLTableElement | null>(null);
const downloadExcel = () => {
if (!department75Table.value) return;
// @ts-ignore
const rows = extractVisibleRowsFromTable(department75Table.value);
exportRowsToExcel(rows, {
filename: `invoicing-period-department-75-${dates.computed.formattedStartDate.value}-${dates.computed.formattedEndDate.value}.xlsx`,
sheetName: 'Department 75 Distribution'
});
};
const formatCurrencyAmount = (amount: any) => {
return SessionUser.functions.currency.toLocal(toFiniteNumber(amount));
};
@@ -509,12 +529,14 @@ const formatCurrencyAmountOrDash = (amount: any) => {
<div class="column is-full">
<WhiteBoxCard :has-selection-style="true" :has-hover-effect="true" :has-border="true">
<div
class="table-container invoicing-period-distribution-table-container"
class="table-container invoicing-period-distribution-table-container mb-0"
data-testid="department-75-combined-distribution-table-container"
data-disable-auto-excel-export="1"
>
<table
class="table is-fullwidth is-striped is-hoverable is-bordered is-narrow invoicing-period-distribution-table"
data-testid="department-75-combined-distribution-table"
ref="department75Table"
>
<thead>
<tr>
@@ -576,6 +598,57 @@ const formatCurrencyAmountOrDash = (amount: any) => {
</tbody>
</table>
</div>
<div class="mt-0 has-text-right">
<!-- If any of the department 75 data is not fully loaded, show the loading status buttons -->
<template v-if="!isDepartment75CombinedFullyLoaded">
<div class="columns is-mobile">
<!-- Loading state: 1. Fixed pricing distribution loading -->
<div class="column is-one-third">
<button class="button is-dark is-small is-fullwidth is-justify-content-space-between has-text-white" :class="{'is-active': fixed_pricing_department_distribution?.total_fixed_price === undefined}">
<!-- [Label] [Icon pulled right] -->
<span>Fast-pris fordeling</span>
<span class="icon is-small">
<i class="fas" :class="fixed_pricing_department_distribution?.total_fixed_price === undefined ? 'fa-spinner fa-spin' : 'fa-check'"></i>
</span>
</button>
</div>
<!-- Loading state: 2. Vehicle subscription distribution loading -->
<div class="column is-one-third">
<button class="button is-dark is-small is-fullwidth is-justify-content-space-between has-text-white" :class="{'is-active': vehicleSubscriptionDistribution?.total_subscription_price === undefined}">
<!-- [Label] [Icon pulled right] -->
<span>Vaske-aftale fordeling</span>
<span class="icon is-small">
<i class="fas" :class="vehicleSubscriptionDistribution?.total_subscription_price === undefined ? 'fa-spinner fa-spin' : 'fa-check'"></i>
</span>
</button>
</div>
<!-- Loading state: 3. Booked department 75 distribution loading -->
<div class="column is-one-third">
<button class="button is-dark is-small is-fullwidth is-justify-content-space-between has-text-white" :class="{'is-active': !bookedDepartment75DistributionLoaded || bookedDepartment75DistributionFailed}">
<!-- [Label] [Icon pulled right] -->
<span>Afdeling 75 bogført fordeling</span>
<span class="icon is-small">
<i class="fas" :class="!bookedDepartment75DistributionLoaded || bookedDepartment75DistributionFailed ? 'fa-spinner fa-spin' : 'fa-check'"></i>
</span>
</button>
</div>
</div>
</template>
<button
class="button is-dark is-small is-fullwidth"
:class="{'is-loading': !isDepartment75CombinedFullyLoaded}"
@click="downloadExcel"
v-else
>
<span class="icon is-small">
<i class="fas fa-file-excel"></i>
</span>
<span>Download Excel</span>
</button>
</div>
</WhiteBoxCard>
</div>
</template>
@@ -386,6 +386,7 @@ const getPeriodRequestParameters = () => ({
page: periodPaging.page,
limit: periodPaging.limit,
search: periodPaging.search.trim(),
flagTab: periodPaging.flagTab,
includeRequiresAction: periodPaging.includeRequiresAction ? 1 : 0,
includeBooked: periodPaging.includeBooked ? 1 : 0,
});
@@ -1003,6 +1004,7 @@ watch(
periodPaging.page,
periodPaging.limit,
periodPaging.search,
periodPaging.flagTab,
periodPaging.includeRequiresAction,
periodPaging.includeBooked,
],
@@ -24,6 +24,7 @@ export const periodPaging = reactive({
page: 1,
limit: DEFAULT_LIMIT,
search: "",
flagTab: "all",
includeRequiresAction: true,
includeBooked: true,
meta: defaultMeta(),
@@ -82,6 +83,7 @@ export const resetPeriodPagingState = ({ clearCache = true } = {}) => {
periodPaging.page = 1;
periodPaging.limit = DEFAULT_LIMIT;
periodPaging.search = "";
periodPaging.flagTab = "all";
periodPaging.includeRequiresAction = true;
periodPaging.includeBooked = true;
periodPaging.meta = defaultMeta();
@@ -125,6 +127,7 @@ export const buildPeriodCacheKey = ({
page,
limit,
search,
flagTab,
includeRequiresAction,
includeBooked,
}) =>
@@ -135,6 +138,7 @@ export const buildPeriodCacheKey = ({
normalizePage(page),
normalizeLimit(limit),
String(search || "").trim(),
String(flagTab || "all").trim(),
includeRequiresAction ? "1" : "0",
includeBooked ? "1" : "0",
]
@@ -156,6 +156,80 @@ const customersInCurrentView = computed(
() => view.variables.sharedVariables.value?.types?.[view.computed.componentName.value] ?? []
);
type PeriodFlagTab = "all" | "red" | "yellow" | "none" | "filters";
type CustomerFlagTab = "red" | "yellow" | "none";
const selectedFlagTab = computed<PeriodFlagTab>({
get: () => (periodPaging.flagTab as PeriodFlagTab) || "all",
set: (value) => {
if (periodPaging.flagTab !== value) {
periodPaging.flagTab = value;
periodPaging.page = 1;
}
},
});
const getCustomerFlagTabType = (customer: any): CustomerFlagTab => {
const flagCounts = getCustomerActiveFlagCounts(customer);
if (flagCounts.manual > 0) {
return "red";
}
if (flagCounts.automatic > 0) {
return "yellow";
}
return "none";
};
const toNonNegativeInteger = (value: any) => {
const parsed = Number.parseInt(String(value ?? "0"), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
};
const periodFlagTabCounts = computed(() => {
const currentTypeName = view.computed.componentName.value;
const backendCounts = periodPaging.typeCounts?.[currentTypeName];
if (backendCounts && typeof backendCounts === "object") {
const all = toNonNegativeInteger(backendCounts.total);
const red = Math.min(all, toNonNegativeInteger(backendCounts.manual_flags));
const yellow = Math.min(Math.max(0, all - red), toNonNegativeInteger(backendCounts.automatic_flags));
return {
all,
red,
yellow,
none: Math.max(0, all - red - yellow),
};
}
const counts = { all: 0, red: 0, yellow: 0, none: 0 };
customersInCurrentView.value.forEach((customer: any) => {
counts.all += 1;
counts[getCustomerFlagTabType(customer)] += 1;
});
return counts;
});
const filteredCustomersInCurrentView = computed(() => {
return customersInCurrentView.value;
});
const getPeriodFlagTabIconClass = (tab: PeriodFlagTab) => {
if (tab === "red") {
return "has-text-danger";
}
if (tab === "yellow") {
return "has-text-warning";
}
if (tab === "none") {
return "has-text-success";
}
return "has-text-grey-dark";
};
const isPossibleDuplicatesView = computed(() => view.variables.currentView.value === "possible_duplicates");
const possibleDuplicateGroupsInCurrentView = computed(() =>
@@ -227,6 +301,16 @@ const getTransactionIds = (customer: any) => {
.map((transaction: any) => transaction.id);
};
const getAllTransactionIds = (customer: any) => {
if (!customer.transactions || customer.transactions.length === 0) {
return [];
}
return customer.transactions
.map((transaction: any) => transaction.id)
.filter((transactionId: any) => transactionId !== null && transactionId !== undefined);
};
const getExcludedTransactionIds = (customer: any) => {
if (!customer.transactions || customer.transactions.length === 0) {
return [];
@@ -513,7 +597,7 @@ const isFlagInCustomerCardScope = (customer: any, flag: any) => {
return Number(flag?.customer_number || flag?.target_id || 0) === Number(customer?.customer_number || 0);
}
const transactionIds = getTransactionIds(customer);
const transactionIds = getAllTransactionIds(customer);
const transactionIdSet = toPositiveIntegerSet(transactionIds);
if (["order", "order_field", "order_item", "order_item_field"].includes(targetType)) {
@@ -672,12 +756,12 @@ const getTransactionQueryParameters = () => {
show_fixed_pricing: true,
};
};
</script>
<template>
<div data-testid="invoicing-period-view-all" :data-current-view="view.variables.currentView.value">
<InvoicingBillingPeriodStatistics />
<InvoicingBillingPeriodFilters @filterChanged="(filters) => onFilterChanged(filters)" />
<div
class="columns is-vcentered is-multiline pagination-general-search-reload"
data-testid="invoicing-period-page-controls"
@@ -724,8 +808,7 @@ const getTransactionQueryParameters = () => {
</button>
</div>
</div>
<div class="py-2">
<div v-if="isPossibleDuplicatesView" class="py-2">
<div class="columns is-vcentered is-multiline is-mobile">
<div class="column is-narrow my-3">
<label class="label is-small">Per page</label>
@@ -746,6 +829,93 @@ const getTransactionQueryParameters = () => {
</div>
</div>
<div v-if="!isPossibleDuplicatesView" class="py-2">
<b-tabs
v-model="selectedFlagTab"
expanded
:class="['period-flag-tabs', { 'is-filters-tab-selected': selectedFlagTab === 'filters' }]"
data-testid="invoicing-period-flag-tabs"
>
<b-tab-item value="all">
<template #header>
<span class="period-flag-tab-header">
<span class="icon is-small">
<i class="fas fa-flag" :class="getPeriodFlagTabIconClass('all')"></i>
</span>
<span>All</span>
<span class="tag is-light is-rounded">{{ periodFlagTabCounts.all }}</span>
</span>
</template>
</b-tab-item>
<b-tab-item value="red">
<template #header>
<span class="period-flag-tab-header">
<span class="icon is-small">
<i class="fas fa-flag" :class="getPeriodFlagTabIconClass('red')"></i>
</span>
<span>Red flags</span>
<span class="tag is-light is-rounded">{{ periodFlagTabCounts.red }}</span>
</span>
</template>
</b-tab-item>
<b-tab-item value="yellow">
<template #header>
<span class="period-flag-tab-header">
<span class="icon is-small">
<i class="fas fa-flag" :class="getPeriodFlagTabIconClass('yellow')"></i>
</span>
<span>Yellow flags</span>
<span class="tag is-light is-rounded">{{ periodFlagTabCounts.yellow }}</span>
</span>
</template>
</b-tab-item>
<b-tab-item value="none">
<template #header>
<span class="period-flag-tab-header">
<span class="icon is-small">
<i class="fas fa-flag" :class="getPeriodFlagTabIconClass('none')"></i>
</span>
<span>No flags</span>
<span class="tag is-light is-rounded">{{ periodFlagTabCounts.none }}</span>
</span>
</template>
</b-tab-item>
<b-tab-item value="filters" class="filters-tab" data-testid="invoicing-period-filters-tab">
<template #header>
<span class="period-flag-tab-header">
<span class="icon is-small">
<i class="fas fa-filter"></i>
</span>
<span>Filters</span>
</span>
</template>
<div class="period-flag-tab-content py-2">
<div class="columns is-vcentered is-multiline is-mobile" data-testid="invoicing-period-table-filters">
<div class="column is-narrow my-3">
<label class="label is-small">Per page</label>
<div class="control">
<div class="select">
<select
:value="periodPaging.limit"
data-testid="invoicing-period-limit-select"
@change="onPeriodLimitChange"
>
<option v-for="limit in PERIOD_LIMIT_OPTIONS" :key="limit" :value="limit">
{{ limit === "all" ? "All" : limit }}
</option>
</select>
</div>
</div>
</div>
</div>
<InvoicingBillingPeriodFilters @filterChanged="(filters) => onFilterChanged(filters)" />
</div>
</b-tab-item>
</b-tabs>
</div>
<div v-if="isPossibleDuplicatesView" class="columns is-multiline is-mobile">
<template v-for="duplicateGroup in possibleDuplicateGroupsInCurrentView" :key="duplicateGroup.key">
<div class="column is-12">
@@ -845,8 +1015,8 @@ const getTransactionQueryParameters = () => {
:limit-results="false"
:hide-pagination="true"
:dates="{
dateFrom: dates.computed.formattedStartDate.value,
dateTo: dates.computed.formattedEndDate.value,
dateFrom: duplicateGroup.dateKey || dates.computed.formattedStartDate.value,
dateTo: duplicateGroup.dateKey || dates.computed.formattedEndDate.value,
}"
:show-only-with-ids="getDuplicateGroupTransactionIds(duplicateGroup)"
:query-parameters="getTransactionQueryParameters()"
@@ -861,7 +1031,7 @@ const getTransactionQueryParameters = () => {
</div>
<div v-else class="columns is-multiline is-mobile">
<template v-for="customer in customersInCurrentView" :key="customer.customer_number">
<template v-for="customer in filteredCustomersInCurrentView" :key="customer.customer_number">
<div class="column is-12">
<WhiteBox
class="mb-2"
@@ -1195,6 +1365,16 @@ const getTransactionQueryParameters = () => {
margin: 0;
}
.period-flag-tabs:not(.is-filters-tab-selected) :deep(.tab-content) {
display: none;
}
.period-flag-tab-header {
align-items: center;
display: inline-flex;
gap: 0.35rem;
}
@media screen and (max-width: 900px) {
.duplicate-comparison-row {
grid-template-columns: 1fr 1fr;
+1
View File
@@ -2226,6 +2226,7 @@ test.describe("Invoicing period tab", () => {
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-statistics");
@@ -0,0 +1,78 @@
import { expect, test } from "@playwright/test";
test("InvoiceOrdersPagination uses specific date for duplicate groups", async ({ page }) => {
// We don't need full auth for this check if we mock the right things
const mockPeriodData = {
possible_duplicates: [
{
key: "ABC1234|2026-05-12",
plate: "ABC1234",
dateKey: "2026-05-12",
testId: "ABC1234-2026-05-12",
customers: [
{
customer_number: 1001,
customer_name: "Customer A",
transactions: [{ id: 1, plate: "ABC1234", date: "2026-05-12T10:00:00Z" }],
},
{
customer_number: 1002,
customer_name: "Customer B",
transactions: [{ id: 2, plate: "ABC1234", date: "2026-05-12T11:00:00Z" }],
},
],
},
],
types: { all: [] },
statistics: { total_amount: 0, total_count: 0 },
};
await page.route("**/superuser/invoicing/period**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mockPeriodData),
});
});
let capturedOrdersUrl = null;
await page.route("**/orders**", async (route) => {
if (route.request().method() === "GET" && route.request().url().includes("orders")) {
capturedOrdersUrl = route.request().url();
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: [], meta: { total: 0 } }),
});
});
// Mock initial session data
await page.route("**/auth/session**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: { user: { role: "superuser" } } }),
});
});
// Navigate directly to the view
await page.goto(
"/superuser/invoices?activeTab=period&periodView=possible_duplicates&startDate=2026-05-01&endDate=2026-05-31"
);
// Wait for the duplicate group and click it
const duplicateGroup = page.getByTestId("invoicing-period-duplicate-group-ABC1234-2026-05-12");
await expect(duplicateGroup).toBeVisible();
await duplicateGroup.click();
// Wait for the orders request
await page.waitForResponse((res) => res.url().includes("/orders") && res.request().method() === "GET");
console.log("Captured Orders URL:", capturedOrdersUrl);
// Verify the date range in the URL
expect(capturedOrdersUrl).toContain("created_at-date_from=2026-05-12");
expect(capturedOrdersUrl).toContain("created_at-date_to=2026-05-12");
});
@@ -157,6 +157,13 @@ const mountView = () =>
mount(InvoicingBillingPeriodViewAll, {
global: {
stubs: {
"b-tabs": {
template: "<div data-testid='b-tabs-stub'><slot /></div>",
},
"b-tab-item": {
template:
"<div class='b-tab-item-stub' v-bind='$attrs'><div class='b-tab-item-header'><slot name='header' /></div><slot /></div>",
},
InvoicingBillingPeriodStatistics: { template: "<div />" },
InvoicingBillingPeriodFilters: { template: "<div />" },
PaginationNavigation: {
@@ -346,6 +353,42 @@ describe("Invoicing period queue state", () => {
expect(periodPaging.limit).toBe(500);
});
it("stores table filters in the Filters tab for the standard view", async () => {
const wrapper = mountView();
await nextTick();
const filtersTab = wrapper.get("[data-testid='invoicing-period-filters-tab']");
expect(filtersTab.find("[data-testid='invoicing-period-table-filters']").exists()).toBe(true);
expect(filtersTab.find("[data-testid='invoicing-period-limit-select']").exists()).toBe(true);
});
it("keeps flag tab counts stable when only the page size changes", async () => {
periodPaging.typeCounts = {
all: {
total: 793,
manual_flags: 2,
automatic_flags: 0,
},
};
const wrapper = mountView();
await nextTick();
const getFlagTabCounts = () =>
wrapper
.findAll("[data-testid='invoicing-period-flag-tabs'] .period-flag-tab-header .tag")
.map((tag) => Number.parseInt(tag.text(), 10));
expect(getFlagTabCounts()).toEqual([793, 2, 0, 791]);
const select = wrapper.get("[data-testid='invoicing-period-limit-select']");
await select.setValue("10");
await nextTick();
expect(periodPaging.limit).toBe(10);
expect(getFlagTabCounts()).toEqual([793, 2, 0, 791]);
});
it("keeps only one customer unfolded at a time", async () => {
const wrapper = mountView();
await nextTick();
@@ -438,6 +481,55 @@ describe("Invoicing period queue state", () => {
expect(customer.get("[data-testid='invoice-period-flags-stub']").text()).toBe("2");
});
it("keeps collected invoice flags visible even when linked transactions are excluded", async () => {
sharedVariablesRef.value = {
types: {
all: [
{
id: 7,
customer_number: 1007,
customer_name: "Collection Flag Customer",
requires_action: false,
status_indicator: "flag_red",
flag_counts: {
manual: 1,
automatic: 0,
total: 1,
},
flags: [
{
id: 992,
source: "manual",
status: "active",
target_type: "collected_order_invoice",
target_id: 5101,
customer_number: 1007,
reason: "Invoice collection needs review",
},
],
transactions: [
{
id: 7201,
amount: 95,
booked: false,
excluded: true,
invoice_collection_id: 5101,
date: "2026-04-14T10:00:00.000Z",
},
],
},
],
},
};
const wrapper = mountView();
await nextTick();
const customer = wrapper.get("[data-testid='invoicing-period-customer-1007']");
expect(customer.get(".color-indicator-stub").attributes("data-icon")).toBe("fas fa-flag");
expect(customer.get("[data-testid='invoice-period-flags-stub']").text()).toBe("1");
});
it("removes the customer flag icon after the last active flag is marked inactive", async () => {
sharedVariablesRef.value = {
types: {