402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
import { expect, test, Page } from "@playwright/test";
|
|
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
|
import { isCompactProject } from "./support/projects";
|
|
|
|
const adminPermissions = [
|
|
"admin",
|
|
"list_department_daily_reports",
|
|
"list_bookings",
|
|
"department_access_1",
|
|
"department_access_2",
|
|
"department_access_3",
|
|
];
|
|
|
|
const json = (body: unknown, status = 200) => ({
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
const departments = [
|
|
{ id: 1, name: "North" },
|
|
{ id: 2, name: "South" },
|
|
{ id: 3, name: "Missing Hours" },
|
|
];
|
|
|
|
const overviewByRange: Record<string, Record<string, unknown>> = {
|
|
"2026-03-23|2026-03-24": {
|
|
department_ids: [1, 2, 3],
|
|
date: "2026-03-23",
|
|
date_to: "2026-03-24",
|
|
metrics: {
|
|
bookings: { state: "ready", value: 9, out_of: 12, message: null },
|
|
complaints: {
|
|
state: "unavailable",
|
|
value: null,
|
|
out_of: null,
|
|
message: "Kundeklager er ikke tilgængelig endnu.",
|
|
},
|
|
night_washes: {
|
|
state: "ready",
|
|
value: 5,
|
|
out_of: null,
|
|
message: null,
|
|
by_source: {
|
|
orders: 1,
|
|
xlvask: 2,
|
|
selfserve: 2,
|
|
},
|
|
has_missing_opening_hours: true,
|
|
missing_department_ids: [3],
|
|
},
|
|
revenue: { state: "ready", value: 5400, out_of: null, message: null },
|
|
washes: { state: "ready", value: 18, out_of: null, message: null },
|
|
products_sold: { state: "ready", value: 26, out_of: null, message: null },
|
|
transactions: { state: "ready", value: 15, out_of: null, message: null },
|
|
water_usage: { state: "ready", value: 44, out_of: null, message: null },
|
|
overtime: { state: "ready", value: 2.5, out_of: null, message: null },
|
|
},
|
|
products: [],
|
|
},
|
|
"2026-03-23|2026-03-25": {
|
|
department_ids: [1, 2, 3],
|
|
date: "2026-03-23",
|
|
date_to: "2026-03-25",
|
|
metrics: {
|
|
bookings: { state: "ready", value: 12, out_of: 16, message: null },
|
|
complaints: {
|
|
state: "unavailable",
|
|
value: null,
|
|
out_of: null,
|
|
message: "Kundeklager er ikke tilgængelig endnu.",
|
|
},
|
|
night_washes: {
|
|
state: "ready",
|
|
value: 7,
|
|
out_of: null,
|
|
message: null,
|
|
by_source: {
|
|
orders: 2,
|
|
xlvask: 2,
|
|
selfserve: 3,
|
|
},
|
|
has_missing_opening_hours: true,
|
|
missing_department_ids: [3],
|
|
},
|
|
revenue: { state: "ready", value: 6900, out_of: null, message: null },
|
|
washes: { state: "ready", value: 23, out_of: null, message: null },
|
|
products_sold: { state: "ready", value: 31, out_of: null, message: null },
|
|
transactions: { state: "ready", value: 19, out_of: null, message: null },
|
|
water_usage: { state: "ready", value: 58, out_of: null, message: null },
|
|
overtime: { state: "ready", value: 3.25, out_of: null, message: null },
|
|
},
|
|
products: [],
|
|
},
|
|
};
|
|
|
|
const transactionCountsByRange: Record<string, Record<number, Record<string, unknown>>> = {
|
|
"2026-03-23|2026-03-24": {
|
|
1: {
|
|
earnings: 2200,
|
|
products: 9,
|
|
quantity: 6,
|
|
washes: 8,
|
|
outside_hours: {
|
|
total: 2,
|
|
by_source: { orders: 1, xlvask: 1, selfserve: 0 },
|
|
has_missing_opening_hours: false,
|
|
missing_department_ids: [],
|
|
},
|
|
},
|
|
2: {
|
|
earnings: 3200,
|
|
products: 12,
|
|
quantity: 9,
|
|
washes: 10,
|
|
outside_hours: {
|
|
total: 3,
|
|
by_source: { orders: 0, xlvask: 1, selfserve: 2 },
|
|
has_missing_opening_hours: false,
|
|
missing_department_ids: [],
|
|
},
|
|
},
|
|
3: {
|
|
earnings: 500,
|
|
products: 2,
|
|
quantity: 1,
|
|
washes: 2,
|
|
outside_hours: {
|
|
total: 0,
|
|
by_source: { orders: 0, xlvask: 0, selfserve: 0 },
|
|
has_missing_opening_hours: true,
|
|
missing_department_ids: [3],
|
|
},
|
|
},
|
|
},
|
|
"2026-03-23|2026-03-25": {
|
|
1: {
|
|
earnings: 2700,
|
|
products: 11,
|
|
quantity: 8,
|
|
washes: 10,
|
|
outside_hours: {
|
|
total: 3,
|
|
by_source: { orders: 1, xlvask: 1, selfserve: 1 },
|
|
has_missing_opening_hours: false,
|
|
missing_department_ids: [],
|
|
},
|
|
},
|
|
2: {
|
|
earnings: 3700,
|
|
products: 14,
|
|
quantity: 10,
|
|
washes: 11,
|
|
outside_hours: {
|
|
total: 4,
|
|
by_source: { orders: 1, xlvask: 1, selfserve: 2 },
|
|
has_missing_opening_hours: false,
|
|
missing_department_ids: [],
|
|
},
|
|
},
|
|
3: {
|
|
earnings: 500,
|
|
products: 2,
|
|
quantity: 1,
|
|
washes: 2,
|
|
outside_hours: {
|
|
total: 0,
|
|
by_source: { orders: 0, xlvask: 0, selfserve: 0 },
|
|
has_missing_opening_hours: true,
|
|
missing_department_ids: [3],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const trendByRange: Record<string, Record<string, unknown>> = {
|
|
"2026-03-23|2026-03-24": {
|
|
points: [
|
|
{
|
|
date: "2026-03-23",
|
|
total: 2,
|
|
by_source: {
|
|
orders: 1,
|
|
xlvask: 0,
|
|
selfserve: 1,
|
|
},
|
|
},
|
|
{
|
|
date: "2026-03-24",
|
|
total: 3,
|
|
by_source: {
|
|
orders: 0,
|
|
xlvask: 2,
|
|
selfserve: 1,
|
|
},
|
|
},
|
|
],
|
|
has_missing_opening_hours: true,
|
|
missing_department_ids: [3],
|
|
},
|
|
"2026-03-23|2026-03-25": {
|
|
points: [
|
|
{
|
|
date: "2026-03-23",
|
|
total: 2,
|
|
by_source: {
|
|
orders: 1,
|
|
xlvask: 0,
|
|
selfserve: 1,
|
|
},
|
|
},
|
|
{
|
|
date: "2026-03-24",
|
|
total: 3,
|
|
by_source: {
|
|
orders: 0,
|
|
xlvask: 2,
|
|
selfserve: 1,
|
|
},
|
|
},
|
|
{
|
|
date: "2026-03-25",
|
|
total: 2,
|
|
by_source: {
|
|
orders: 1,
|
|
xlvask: 0,
|
|
selfserve: 1,
|
|
},
|
|
},
|
|
],
|
|
has_missing_opening_hours: true,
|
|
missing_department_ids: [3],
|
|
},
|
|
};
|
|
|
|
async function mockOverviewDependencies(page) {
|
|
await seedAuthenticatedState(page);
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: adminPermissions,
|
|
});
|
|
|
|
await page.route(/\/departments(\?.*)?$/i, async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const departmentId = Number(url.searchParams.get("id"));
|
|
if (departmentId > 0) {
|
|
await route.fulfill(
|
|
json({
|
|
data: departments.find((department) => department.id === departmentId) || null,
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
await route.fulfill(json({ data: departments }));
|
|
});
|
|
|
|
await page.route(/\/departments\/weather(\?.*)?$/i, async (route) => {
|
|
await route.fulfill(json({ data: [] }));
|
|
});
|
|
|
|
await page.route(/\/departments\/daily-reports\/get(\?.*)?$/i, async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const date = url.searchParams.get("date") || "2026-03-23";
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
id: 10,
|
|
department_id: 1,
|
|
water_usage: 32,
|
|
notes: "",
|
|
filled_by: 1,
|
|
created_at: `${date} 00:00:00`,
|
|
},
|
|
})
|
|
);
|
|
});
|
|
|
|
await page.route(/\/departments\/daily-reports\/overview(\?.*)?$/i, async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const key = `${url.searchParams.get("date")}|${url.searchParams.get("date_to") || url.searchParams.get("date")}`;
|
|
await route.fulfill(
|
|
json({
|
|
data: overviewByRange[key] || overviewByRange["2026-03-23|2026-03-24"],
|
|
})
|
|
);
|
|
});
|
|
|
|
await page.route(/\/departments\/daily-reports\/transaction-count(\?.*)?$/i, async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const key = `${url.searchParams.get("date")}|${url.searchParams.get("date_to") || url.searchParams.get("date")}`;
|
|
const departmentId = Number(url.searchParams.get("department_id"));
|
|
const payload =
|
|
transactionCountsByRange[key]?.[departmentId] || transactionCountsByRange["2026-03-23|2026-03-24"][departmentId];
|
|
await route.fulfill(
|
|
json({
|
|
data: payload,
|
|
})
|
|
);
|
|
});
|
|
|
|
await page.route(/\/departments\/daily-reports\/outside-hours-trend(\?.*)?$/i, async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const key = `${url.searchParams.get("date")}|${url.searchParams.get("date_to")}`;
|
|
await route.fulfill(
|
|
json({
|
|
data: trendByRange[key] || trendByRange["2026-03-23|2026-03-24"],
|
|
})
|
|
);
|
|
});
|
|
|
|
await page.route(
|
|
/\/departments\/daily-reports(?!\/overview|\/transaction-count|\/outside-hours-trend|\/get)(\?.*)?$/i,
|
|
async (route) => {
|
|
await route.fulfill(json({ data: [] }));
|
|
}
|
|
);
|
|
}
|
|
|
|
async function expandOverviewReportsOnMobile(page, testInfo) {
|
|
if (!isCompactProject(testInfo)) {
|
|
return;
|
|
}
|
|
|
|
if (await page.getByTestId("daily-report-page").count()) {
|
|
return;
|
|
}
|
|
|
|
await page.getByTestId("admin-mobile-reports-toggle").click();
|
|
await expect(page.getByTestId("daily-report-page")).toBeVisible();
|
|
}
|
|
|
|
async function setOverviewEndDate(page: Page, value: string) {
|
|
const endDateInput = page
|
|
.locator('[data-testid="daily-report-date-controls"] [data-testid="date-period-end"]')
|
|
.last();
|
|
await endDateInput.evaluate((element, nextValue) => {
|
|
const input = element as HTMLInputElement;
|
|
input.value = nextValue;
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}, value);
|
|
}
|
|
|
|
test.describe("Admin overview night washes", () => {
|
|
test("renders deduped Døgnvask totals across cards, list, and chart with stable range updates", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await mockOverviewDependencies(page);
|
|
|
|
await page.goto("/admin?departmentIds=1,2,3&dateFrom=2026-03-23&dateTo=2026-03-24");
|
|
await expandOverviewReportsOnMobile(page, testInfo);
|
|
|
|
await expect(page.getByTestId("daily-report-page")).toBeVisible();
|
|
await expect(page.getByTestId("daily-report-tile-night-washes")).toContainText("5");
|
|
await expect(page.getByTestId("daily-report-night-washes-breakdown")).toContainText("Ordre: 1");
|
|
await expect(page.getByTestId("daily-report-night-washes-breakdown")).toContainText("XL Vask: 2");
|
|
await expect(page.getByTestId("daily-report-night-washes-breakdown")).toContainText("Self-serve: 2");
|
|
await expect(page.getByTestId("daily-report-night-washes-warning")).toContainText("Missing Hours");
|
|
|
|
const tabs = page.locator(".tabs li");
|
|
await tabs.nth(1).click();
|
|
|
|
await expect(page.getByTestId("overview-summary-night-washes-total")).toContainText("5");
|
|
await expect(page.getByTestId("overview-summary-night-washes-breakdown")).toContainText("Self-serve: 2");
|
|
await expect(page.getByTestId("overview-summary-night-washes-warning")).toContainText("Missing Hours");
|
|
await expect(page.getByTestId("overview-department-1-night-washes-total")).toContainText("2");
|
|
await expect(page.getByTestId("overview-department-2-night-washes-total")).toContainText("3");
|
|
await expect(page.getByTestId("overview-department-3-night-washes-total")).toContainText("0");
|
|
await expect(page.getByTestId("overview-department-3-night-washes")).not.toContainText(
|
|
/mangler|Opening hours missing|Missing Hours/
|
|
);
|
|
|
|
await tabs.nth(2).click();
|
|
await expect(page.getByTestId("outside-hours-chart")).toBeVisible();
|
|
await expect(page.getByTestId("outside-hours-chart-warning")).toContainText("Missing Hours");
|
|
|
|
await setOverviewEndDate(page, "2026-03-25");
|
|
await expect(page).toHaveURL(/dateTo=2026-03-25/);
|
|
|
|
await tabs.nth(0).click();
|
|
await expect(page.getByTestId("daily-report-tile-night-washes")).toContainText("7");
|
|
await expect(page.getByTestId("daily-report-night-washes-breakdown")).toContainText("Self-serve: 3");
|
|
|
|
await tabs.nth(1).click();
|
|
await expect(page.getByTestId("overview-summary-night-washes-total")).toContainText("7");
|
|
await expect(page.getByTestId("overview-summary-night-washes-breakdown")).toContainText("Ordre: 2");
|
|
await expect(page.getByTestId("overview-summary-night-washes-breakdown")).toContainText("XL Vask: 2");
|
|
await expect(page.getByTestId("overview-summary-night-washes-breakdown")).toContainText("Self-serve: 3");
|
|
|
|
await tabs.nth(2).click();
|
|
await expect(page.getByTestId("outside-hours-chart")).toBeVisible();
|
|
|
|
if (isCompactProject(testInfo)) {
|
|
await tabs.nth(0).click();
|
|
await expect(page.getByTestId("daily-report-tile-night-washes")).toBeVisible();
|
|
await tabs.nth(1).click();
|
|
await expect(page.getByTestId("overview-summary-night-washes-total")).toBeVisible();
|
|
}
|
|
});
|
|
});
|