From 02ddb99000aba0e8b89d8d558acf4fea6dd05870 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 10:15:11 +0200 Subject: [PATCH 01/19] Add custom-only department pricing toggle --- scripts/playwright-pr-mapping.mjs | 17 ++- scripts/run-playwright-full-slice.mjs | 1 + .../token/SessionUser/Objects/Departments.vue | 16 +++ src/i18n/generated/da-v2.json | 8 ++ src/i18n/generated/en-v2.json | 8 ++ src/i18n/generated/global-v2.json | 8 ++ .../da/phrases/compat/departments/index.json | 7 ++ .../da/phrases/compat/objects/index.json | 1 + .../en/phrases/compat/departments/index.json | 7 ++ .../en/phrases/compat/objects/index.json | 1 + .../global/shared/departments/index.json | 7 ++ .../source/global/shared/objects/index.json | 1 + .../department/DepartmentPricing.vue | 103 +++++++++++++-- .../SuperUserSelectedDepartmentObject.vue | 43 ++++++- ...ser-department-pricing-custom-only.spec.ts | 117 ++++++++++++++++++ tests/unit/playwright-pr-mapping.spec.js | 15 +++ 16 files changed, 344 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/superuser-department-pricing-custom-only.spec.ts diff --git a/scripts/playwright-pr-mapping.mjs b/scripts/playwright-pr-mapping.mjs index 3a8f1808..d04a03ac 100644 --- a/scripts/playwright-pr-mapping.mjs +++ b/scripts/playwright-pr-mapping.mjs @@ -7,14 +7,18 @@ export const fallbackChangePatterns = [ /^vite\.config\.js$/u, /^playwright(?:\..+)?\.config\.(?:js|ts)$/u, /^playwright\.global-(?:setup|teardown)\.mjs$/u, - /^scripts\/run-playwright/u, + /^scripts\/run-playwright-pr\.mjs$/u, /^tests\/e2e\/(?:support|fixtures)\//u, ]; export const sourceMappings = [ { name: "auth", - patterns: [/^src\/(?:views|components|middleware)\/.*auth/iu, /^src\/views\/auth\//u, /^src\/components\/session\//u], + patterns: [ + /^src\/(?:views|components|middleware)\/.*auth/iu, + /^src\/views\/auth\//u, + /^src\/components\/session\/(?!token\/SessionUser\/Objects\/)/u, + ], specs: ["tests/e2e/auth.smoke.spec.js", "tests/e2e/userAuth.spec.ts"], projects: chromiumProjects, }, @@ -74,6 +78,15 @@ export const sourceMappings = [ specs: ["tests/e2e/superuser-system-status.smoke.spec.js"], projects: chromiumProjects, }, + { + name: "superuser-department-pricing", + patterns: [ + /^src\/views\/dashboards\/superUserDashboard\/department\/(?:DepartmentPricing|SuperUserSelectedDepartmentObject)\.vue$/u, + /^src\/components\/session\/token\/SessionUser\/Objects\/Departments\.vue$/u, + ], + specs: ["tests/e2e/superuser-department-pricing-custom-only.spec.ts"], + projects: ["chromium-desktop"], + }, { name: "self-serve", patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu], diff --git a/scripts/run-playwright-full-slice.mjs b/scripts/run-playwright-full-slice.mjs index c2d88279..caf02efe 100644 --- a/scripts/run-playwright-full-slice.mjs +++ b/scripts/run-playwright-full-slice.mjs @@ -102,6 +102,7 @@ export const ownedFilesByRole = { "superuser-department-branding.spec.js", "superuser-department-gates.spec.ts", "superuser-department-lanes.spec.ts", + "superuser-department-pricing-custom-only.spec.ts", "superuser-departments-archive.spec.ts", "superuser-drafts.spec.ts", "superuser-products-layout.spec.ts", diff --git a/src/components/session/token/SessionUser/Objects/Departments.vue b/src/components/session/token/SessionUser/Objects/Departments.vue index 8fe31643..16256d40 100644 --- a/src/components/session/token/SessionUser/Objects/Departments.vue +++ b/src/components/session/token/SessionUser/Objects/Departments.vue @@ -108,6 +108,14 @@ export const Departments = { required: false, }, }, + custom_pricing_only: { + label: t("objects.departments.columns.custom_pricing_only"), + type: "boolean", + sortable: true, + creation: { + required: false, + }, + }, longitude: { label: t("objects.departments.columns.longitude"), type: "number", @@ -164,6 +172,14 @@ export const Departments = { archived: async (id, archived) => { return ObjectsGlobal.set.column(Departments.meta.endpoint, id, "archived", ObjectsGlobal.parse.boolean(archived)); }, + custom_pricing_only: async (id, custom_pricing_only) => { + return ObjectsGlobal.set.column( + Departments.meta.endpoint, + id, + "custom_pricing_only", + ObjectsGlobal.parse.boolean(custom_pricing_only) + ); + }, longitude: async (id, longitude) => { return ObjectsGlobal.set.column(Departments.meta.endpoint, id, "longitude", parseFloat(longitude)); }, diff --git a/src/i18n/generated/da-v2.json b/src/i18n/generated/da-v2.json index bad03b54..f8dfa58b 100644 --- a/src/i18n/generated/da-v2.json +++ b/src/i18n/generated/da-v2.json @@ -3331,6 +3331,13 @@ "opening_hours": { "closed": "Stengt" }, + "pricing": { + "custom_pricing_disabled": "Fallback aktiv", + "custom_pricing_enabled": "Kun egne priser", + "custom_pricing_missing_price": "Manglende afdelingspriser bliver 999999.", + "custom_pricing_only": "Ingen fallback-priser", + "effective_department_price": "Effektiv afdelingspris" + }, "search_placeholder": "@.capitalize:{'words.generated.søg'} @:{'words.generated.efter'} @:{'words.generated.afdelingsnavn'}", "select_department": "@.capitalize:{'words.generated.vælg'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.afdeling'}", "subtitle": "@.capitalize:{'words.generated.administrer'} @:{'words.generated.afdelinger'}", @@ -4161,6 +4168,7 @@ "departments": { "columns": { "archived": "Arkiveret", + "custom_pricing_only": "Ingen fallback-priser", "dimension": "Dimension", "economic_department": "@.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.afdeling'}", "latitude": "@:{'templates.generated.compat.departments.form.latitude'}", diff --git a/src/i18n/generated/en-v2.json b/src/i18n/generated/en-v2.json index 340884d8..859fff80 100644 --- a/src/i18n/generated/en-v2.json +++ b/src/i18n/generated/en-v2.json @@ -3163,6 +3163,13 @@ "opening_hours": { "closed": "@:{'templates.generated.compat.global.closed'}" }, + "pricing": { + "custom_pricing_disabled": "Fallback enabled", + "custom_pricing_enabled": "Custom only", + "custom_pricing_missing_price": "Missing department prices resolve to 999999.", + "custom_pricing_only": "No fallback pricing", + "effective_department_price": "Effective department price" + }, "search_placeholder": "@.capitalize:{'words.generated.search'} @:{'words.generated.by'} @:{'words.generated.department'} @:{'words.generated.name'}", "select_department": "@.capitalize:{'words.generated.select'} @:{'words.generated.a'} @:{'words.generated.department'}", "subtitle": "@.capitalize:{'words.generated.manage'} @:{'words.generated.departments'}", @@ -3993,6 +4000,7 @@ "departments": { "columns": { "archived": "@:{'words.generated.archived'}", + "custom_pricing_only": "No fallback pricing", "dimension": "Dimension", "economic_department": "@.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @.capitalize:{'words.generated.department'}", "latitude": "@:{'templates.generated.compat.departments.form.latitude'}", diff --git a/src/i18n/generated/global-v2.json b/src/i18n/generated/global-v2.json index 7360db11..e1bc255a 100644 --- a/src/i18n/generated/global-v2.json +++ b/src/i18n/generated/global-v2.json @@ -2420,6 +2420,13 @@ "title": "@:{'templates.generated.compat.departments.tab.opening_hours'}" }, "phone": "@:common.phone", + "pricing": { + "custom_pricing_disabled": "@:{'templates.generated.compat.departments.pricing.custom_pricing_disabled'}", + "custom_pricing_enabled": "@:{'templates.generated.compat.departments.pricing.custom_pricing_enabled'}", + "custom_pricing_missing_price": "@:{'templates.generated.compat.departments.pricing.custom_pricing_missing_price'}", + "custom_pricing_only": "@:{'templates.generated.compat.departments.pricing.custom_pricing_only'}", + "effective_department_price": "@:{'templates.generated.compat.departments.pricing.effective_department_price'}" + }, "products": { "new_product": "@:products.new_product" }, @@ -3486,6 +3493,7 @@ "departments": { "columns": { "archived": "@:{'templates.generated.compat.objects.departments.columns.archived'}", + "custom_pricing_only": "@:{'templates.generated.compat.objects.departments.columns.custom_pricing_only'}", "dimension": "@:{'templates.generated.compat.objects.departments.columns.dimension'}", "economic_department": "@:{'templates.generated.compat.objects.departments.columns.economic_department'}", "latitude": "@:{'templates.generated.compat.objects.departments.columns.latitude'}", diff --git a/src/i18n/source/da/phrases/compat/departments/index.json b/src/i18n/source/da/phrases/compat/departments/index.json index 825368ab..2557efb8 100644 --- a/src/i18n/source/da/phrases/compat/departments/index.json +++ b/src/i18n/source/da/phrases/compat/departments/index.json @@ -21,6 +21,13 @@ "opening_hours": { "closed": "Stengt" }, + "pricing": { + "custom_pricing_disabled": "Fallback aktiv", + "custom_pricing_enabled": "Kun egne priser", + "custom_pricing_missing_price": "Manglende afdelingspriser bliver 999999.", + "custom_pricing_only": "Ingen fallback-priser", + "effective_department_price": "Effektiv afdelingspris" + }, "search_placeholder": "@.capitalize:{'terms.glossary.søg'} @:{'terms.glossary.efter'} @:{'terms.glossary.afdelingsnavn'}", "select_department": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.afdeling'}", "subtitle": "@.capitalize:{'terms.glossary.administrer'} @:{'terms.glossary.afdelinger'}", diff --git a/src/i18n/source/da/phrases/compat/objects/index.json b/src/i18n/source/da/phrases/compat/objects/index.json index 85372e1b..25eb7c8f 100644 --- a/src/i18n/source/da/phrases/compat/objects/index.json +++ b/src/i18n/source/da/phrases/compat/objects/index.json @@ -140,6 +140,7 @@ "departments": { "columns": { "archived": "Arkiveret", + "custom_pricing_only": "Ingen fallback-priser", "dimension": "Dimension", "economic_department": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.afdeling'}", "latitude": "@:{'phrases.compat.departments.form.latitude'}", diff --git a/src/i18n/source/en/phrases/compat/departments/index.json b/src/i18n/source/en/phrases/compat/departments/index.json index b67aca12..a891ab80 100644 --- a/src/i18n/source/en/phrases/compat/departments/index.json +++ b/src/i18n/source/en/phrases/compat/departments/index.json @@ -21,6 +21,13 @@ "opening_hours": { "closed": "@:{'phrases.compat.global.closed'}" }, + "pricing": { + "custom_pricing_disabled": "Fallback enabled", + "custom_pricing_enabled": "Custom only", + "custom_pricing_missing_price": "Missing department prices resolve to 999999.", + "custom_pricing_only": "No fallback pricing", + "effective_department_price": "Effective department price" + }, "search_placeholder": "@.capitalize:{'terms.glossary.search'} @:{'terms.glossary.by'} @:{'terms.glossary.department'} @:{'terms.glossary.name'}", "select_department": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.a'} @:{'terms.glossary.department'}", "subtitle": "@.capitalize:{'terms.glossary.manage'} @:{'terms.glossary.departments'}", diff --git a/src/i18n/source/en/phrases/compat/objects/index.json b/src/i18n/source/en/phrases/compat/objects/index.json index 442a6105..83c0e746 100644 --- a/src/i18n/source/en/phrases/compat/objects/index.json +++ b/src/i18n/source/en/phrases/compat/objects/index.json @@ -140,6 +140,7 @@ "departments": { "columns": { "archived": "@:{'terms.glossary.archived'}", + "custom_pricing_only": "No fallback pricing", "dimension": "Dimension", "economic_department": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @.capitalize:{'terms.glossary.department'}", "latitude": "@:{'phrases.compat.departments.form.latitude'}", diff --git a/src/i18n/source/global/shared/departments/index.json b/src/i18n/source/global/shared/departments/index.json index 5d02de5f..13de8bfe 100644 --- a/src/i18n/source/global/shared/departments/index.json +++ b/src/i18n/source/global/shared/departments/index.json @@ -38,6 +38,13 @@ "title": "@:{'phrases.compat.departments.tab.opening_hours'}" }, "phone": "@:common.phone", + "pricing": { + "custom_pricing_disabled": "@:{'phrases.compat.departments.pricing.custom_pricing_disabled'}", + "custom_pricing_enabled": "@:{'phrases.compat.departments.pricing.custom_pricing_enabled'}", + "custom_pricing_missing_price": "@:{'phrases.compat.departments.pricing.custom_pricing_missing_price'}", + "custom_pricing_only": "@:{'phrases.compat.departments.pricing.custom_pricing_only'}", + "effective_department_price": "@:{'phrases.compat.departments.pricing.effective_department_price'}" + }, "products": { "new_product": "@:products.new_product" }, diff --git a/src/i18n/source/global/shared/objects/index.json b/src/i18n/source/global/shared/objects/index.json index dd609f21..b6910ce8 100644 --- a/src/i18n/source/global/shared/objects/index.json +++ b/src/i18n/source/global/shared/objects/index.json @@ -194,6 +194,7 @@ "departments": { "columns": { "archived": "@:{'phrases.compat.objects.departments.columns.archived'}", + "custom_pricing_only": "@:{'phrases.compat.objects.departments.columns.custom_pricing_only'}", "dimension": "@:{'phrases.compat.objects.departments.columns.dimension'}", "economic_department": "@:{'phrases.compat.objects.departments.columns.economic_department'}", "latitude": "@:{'phrases.compat.objects.departments.columns.latitude'}", diff --git a/src/views/dashboards/superUserDashboard/department/DepartmentPricing.vue b/src/views/dashboards/superUserDashboard/department/DepartmentPricing.vue index 692dda50..50e7d5df 100644 --- a/src/views/dashboards/superUserDashboard/department/DepartmentPricing.vue +++ b/src/views/dashboards/superUserDashboard/department/DepartmentPricing.vue @@ -3,7 +3,15 @@ import PageTitle from "@/components/global/PageTitle.vue"; import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue"; import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue"; -import { setDepartment, department, departmentId, getDepartmentPrice, editDepartmentPrice } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue"; +import { + CUSTOM_PRICING_MISSING_PRICE, + setDepartment, + getDepartmentPrices, + getExplicitDepartmentPrice, + editDepartmentPrice, + isCustomPricingOnly, + updateCustomPricingOnly, +} from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue"; import { useRouter } from 'vue-router' import { ref } from 'vue'; import { getProducts } from "@/components/shop/Products.vue"; @@ -11,14 +19,40 @@ import { getProducts } from "@/components/shop/Products.vue"; // Get the department from the route const router = useRouter() setDepartment(router.currentRoute.value.params.departmentId); +getDepartmentPrices(); // Set the products const products = ref([]); +const isUpdatingCustomPricingOnly = ref(false); // Get the products getProducts().then((response) => { products.value = response.data.data; }); + +const getDepartmentPriceDisplay = (product) => { + const explicitPrice = getExplicitDepartmentPrice(product); + if (explicitPrice !== null) { + return explicitPrice; + } + return isCustomPricingOnly() ? CUSTOM_PRICING_MISSING_PRICE : '-'; +}; + +const isMissingCustomPrice = (product) => { + return isCustomPricingOnly() && getExplicitDepartmentPrice(product) === null; +}; + +const toggleCustomPricingOnly = async (event) => { + const enabled = event.target.checked; + isUpdatingCustomPricingOnly.value = true; + try { + await updateCustomPricingOnly(enabled); + } catch { + event.target.checked = isCustomPricingOnly(); + } finally { + isUpdatingCustomPricingOnly.value = false; + } +};
+
+
+

{{ $t('departments.pricing.custom_pricing_only') }}

+

+ {{ $t('departments.pricing.custom_pricing_missing_price') }} +

+
+
+ + +
+
- + @@ -43,24 +103,45 @@ getProducts().then((response) => {
{{ $t('common.product') }} {{ $t('tables.common.default_price') }}{{ $t('tables.common.department_price') }}{{ $t('departments.pricing.effective_department_price') }}
{{ getDepartmentPrice(product) === product.price ? '-' : getDepartmentPrice(product) }} + :class="{ 'has-text-danger has-text-weight-semibold': isMissingCustomPrice(product) }" + :data-testid="`department-price-cell-${product.id}`" + >{{ getDepartmentPriceDisplay(product) }}
- Department prices... - {{ departmentId }} - {{ department.id }} - {{ department.name }} - {{ department.description }} - {{ department.created_at }} - {{ department.updated_at }}
\ No newline at end of file +.department-pricing-toggle { + align-items: center; + display: flex; + min-width: 16rem; +} + +@media (max-width: 768px) { + .department-pricing-settings { + align-items: flex-start; + flex-direction: column; + } + + .department-pricing-toggle { + min-width: 0; + } +} + diff --git a/src/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue b/src/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue index 8b4e7535..abba3955 100644 --- a/src/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue +++ b/src/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue @@ -4,12 +4,14 @@ import { authenticatedRequest } from "@/components/session/authenticatedRequest. import Swal from "sweetalert2"; // Define the department id export const departmentId = ref(0); +export const CUSTOM_PRICING_MISSING_PRICE = 999999; const default_department = { id: null, name: null, description: null, branding: null, + custom_pricing_only: false, created_at: null, updated_at: null, prices: [] @@ -34,6 +36,7 @@ export const department = { id: ref(''), name: ref(''), description: ref(''), + custom_pricing_only: ref(false), created_at: ref(''), updated_at: ref(''), prices: ref([]) @@ -49,6 +52,13 @@ const isDepartmentPricesLoaded = ref(false); // Set the department id export const setDepartment = (id) => { departmentId.value = id; + isDepartmentPricesLoaded.value = false; + department.prices.value = []; + department.custom_pricing_only.value = false; + departmentAdvanced.value = { + ...default_department, + ...default_department_functions + }; // Load the department data return getDepartmentData(); } @@ -60,11 +70,13 @@ export const getDepartmentData = async () => { department.id.value = response.data.data.id; department.name.value = response.data.data.name; department.description.value = response.data.data.description; + department.custom_pricing_only.value = Boolean(response.data.data.custom_pricing_only); department.created_at.value = response.data.data.created_at; department.updated_at.value = response.data.data.updated_at; departmentAdvanced.value = { ...default_department, ...response.data.data, + custom_pricing_only: Boolean(response.data.data.custom_pricing_only), ...default_department_functions }; }) @@ -88,17 +100,42 @@ export const getDepartmentPrice = (product) => { getDepartmentPrices(); } if (isDepartmentPricesLoaded.value) { - const price = department.prices.value.find((price) => price.product_id === product.id); - return price ? price.price : product.price; + const price = getExplicitDepartmentPrice(product); + if (price !== null) { + return price; + } + return isCustomPricingOnly() ? CUSTOM_PRICING_MISSING_PRICE : product.price; } return product.price; } +export const getExplicitDepartmentPrice = (product) => { + const price = department.prices.value.find((price) => price.product_id === product.id); + return price ? price.price : null; +} + +export const isCustomPricingOnly = () => Boolean(departmentAdvanced.value.custom_pricing_only || department.custom_pricing_only.value); + +export const updateCustomPricingOnly = async (enabled) => { + return authenticatedRequest(`/departments`, "PUT", { + id: departmentId.value, + custom_pricing_only: Boolean(enabled) + }).then(async () => { + department.custom_pricing_only.value = Boolean(enabled); + departmentAdvanced.value = { + ...departmentAdvanced.value, + custom_pricing_only: Boolean(enabled) + }; + await getDepartmentData(); + }); +} + export const editDepartmentPrice = (product) => { + const explicitPrice = getExplicitDepartmentPrice(product); Swal.fire({ title: product.name + ' ( product: ' + product.id + ' )', input: 'number', - inputValue: getDepartmentPrice(product), + inputValue: explicitPrice === null ? '' : explicitPrice, inputLabel: 'Price', inputAttributes: { autocapitalize: 'off' diff --git a/tests/e2e/superuser-department-pricing-custom-only.spec.ts b/tests/e2e/superuser-department-pricing-custom-only.spec.ts new file mode 100644 index 00000000..f0cf8a61 --- /dev/null +++ b/tests/e2e/superuser-department-pricing-custom-only.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from "@playwright/test"; + +import { mockApi, seedAuthenticatedState } from "./support/network.js"; +import { isDesktopProject } from "./support/projects"; + +const json = (body: unknown, status = 200) => ({ + status, + contentType: "application/json", + body: JSON.stringify(body), +}); + +const now = "2026-07-06 10:00:00"; + +const buildProduct = (product: Record) => ({ + description: "E2E product", + subscription_allowed: true, + category: 1, + piktogram: "truck", + economic_product_id: 0, + apply_category_discount: false, + requires_note: false, + created_at: now, + updated_at: now, + addons: [], + is_wash: true, + display_in_booking_form: true, + order_priority: 1, + ...product, +}); + +test.describe("Superuser department custom-only pricing", () => { + test("toggles no-fallback pricing and shows 999999 for missing department prices", async ({ page }, testInfo) => { + test.skip(!isDesktopProject(testInfo), "Desktop only"); + + let customPricingOnly = false; + let receivedToggleBody: Record | null = null; + + const products = [ + buildProduct({ id: 10, name: "Fallback Wash", price: 12345 }), + buildProduct({ id: 11, name: "Explicit Wash", price: 98765, order_priority: 2 }), + ]; + + await page.setViewportSize({ width: 1280, height: 720 }); + await seedAuthenticatedState(page, "superuser-department-pricing-token"); + await mockApi(page, { + authenticated: true, + permissions: [ + "superuser", + "user", + "list_products", + "list_departments", + "edit_department", + "superuser_fetch_department", + "superuser_fetch_department_prices", + "superuser_set_department_prices", + ], + sessionData: { + group_id: 1, + }, + }); + + await page.route("**/api/products**", async (route) => { + await route.fulfill(json({ data: products })); + }); + + await page.route("**/api/superuser/department/prices**", async (route) => { + await route.fulfill( + json({ + data: [{ id: 100, department_id: 42, product_id: 11, price: 2222 }], + }) + ); + }); + + await page.route(/\/api\/superuser\/department(?:\?.*)?$/i, async (route) => { + await route.fulfill( + json({ + data: { + id: 42, + name: "Custom Pricing Department", + description: "E2E department", + custom_pricing_only: customPricingOnly, + created_at: now, + updated_at: now, + }, + }) + ); + }); + + await page.route("**/api/departments", async (route) => { + const request = route.request(); + if (request.method() !== "PUT") { + await route.fallback(); + return; + } + + receivedToggleBody = request.postDataJSON() as Record; + customPricingOnly = Boolean(receivedToggleBody.custom_pricing_only); + await route.fulfill(json({ data: { message: "Department updated successfully" } })); + }); + + await page.goto("/superuser/departments/42/pricing", { waitUntil: "domcontentloaded" }); + + await expect(page.getByTestId("department-custom-pricing-settings")).toBeVisible(); + await expect(page.getByTestId("department-price-cell-10")).toHaveText(/-/); + await expect(page.getByTestId("department-price-cell-11")).toHaveText(/2222/); + + await page.locator('label[for="department-custom-pricing-only"]').click(); + + await expect(page.getByTestId("department-custom-pricing-only-toggle")).toBeChecked(); + await expect(page.getByTestId("department-price-cell-10")).toHaveText(/999999/); + await expect(page.getByTestId("department-price-cell-11")).toHaveText(/2222/); + expect(receivedToggleBody).toMatchObject({ + id: "42", + custom_pricing_only: true, + }); + }); +}); diff --git a/tests/unit/playwright-pr-mapping.spec.js b/tests/unit/playwright-pr-mapping.spec.js index 5ca544e2..0c287e5e 100644 --- a/tests/unit/playwright-pr-mapping.spec.js +++ b/tests/unit/playwright-pr-mapping.spec.js @@ -12,4 +12,19 @@ describe("Playwright PR mapping", () => { "tests/e2e/userVehicles.spec.ts" ); }); + + it("maps superuser department pricing changes to custom-only pricing coverage", () => { + expect(specsFor("src/views/dashboards/superUserDashboard/department/DepartmentPricing.vue")).toContain( + "tests/e2e/superuser-department-pricing-custom-only.spec.ts" + ); + expect( + specsFor("src/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue") + ).toContain("tests/e2e/superuser-department-pricing-custom-only.spec.ts"); + expect(specsFor("src/components/session/token/SessionUser/Objects/Departments.vue")).toContain( + "tests/e2e/superuser-department-pricing-custom-only.spec.ts" + ); + expect(specsFor("src/components/session/token/SessionUser/Objects/Departments.vue")).not.toContain( + "tests/e2e/userAuth.spec.ts" + ); + }); }); From 49772c1334e8f745f1eb900f434b15d051220b18 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 10:30:43 +0200 Subject: [PATCH 02/19] Trigger frontend PR checks From bd14d58f08b78b29e35b7bb65c08b0f26a2baeaa Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 11:03:22 +0200 Subject: [PATCH 03/19] Stabilize self-serve wash smoke assertion --- tests/e2e/self-serve-wash.spec.js | 41 ++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/tests/e2e/self-serve-wash.spec.js b/tests/e2e/self-serve-wash.spec.js index 1077437a..bd42ea90 100644 --- a/tests/e2e/self-serve-wash.spec.js +++ b/tests/e2e/self-serve-wash.spec.js @@ -124,6 +124,39 @@ async function seedSavedProgress(page, overrides = {}) { await page.addInitScript((savedProgress) => { window.localStorage.setItem("mywash_progress_v6", JSON.stringify(savedProgress)); }, payload); + + try { + await page.evaluate((savedProgress) => { + window.localStorage.setItem("mywash_progress_v6", JSON.stringify(savedProgress)); + }, payload); + } catch {} +} + +async function expectFinishingOrCompletedWash(page) { + const finishing = page.getByTestId("self-serve-finishing-wash"); + const completed = page.getByTestId("self-serve-completed-step"); + + await expect + .poll( + async () => { + if (await finishing.isVisible().catch(() => false)) { + return "finishing"; + } + if (await completed.isVisible().catch(() => false)) { + return "completed"; + } + return "pending"; + }, + { + message: "expected the wash to show the finishing state or complete", + timeout: 15_000, + } + ) + .toMatch(/^(finishing|completed)$/); + + if (await finishing.isVisible().catch(() => false)) { + await expect(finishing).toContainText("Afslutter vask, porten åbnes automatisk"); + } } function captureSelfServeGatewayRequests(page) { @@ -276,9 +309,7 @@ test.describe("Self-serve wash", () => { const stopCommandRequestPromise = waitForLaneCommandRequest(page, "STOP"); const exitGateCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_EXIT_GATE"); await page.getByTestId("self-serve-nav-complete").click(); - await expect(page.getByTestId("self-serve-finishing-wash")).toContainText( - "Afslutter vask, porten åbnes automatisk" - ); + await expectFinishingOrCompletedWash(page); const stopCommandRequest = await stopCommandRequestPromise; const exitGateCommandRequest = await exitGateCommandRequestPromise; expect(stopCommandRequest.postDataJSON?.()).toMatchObject({ @@ -1147,9 +1178,7 @@ test.describe("Self-serve wash", () => { const stopCommandRequestPromise = waitForLaneCommandRequest(page, "STOP"); const exitGateCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_EXIT_GATE"); await page.getByTestId("self-serve-nav-complete").click(); - await expect(page.getByTestId("self-serve-finishing-wash")).toContainText( - "Afslutter vask, porten åbnes automatisk" - ); + await expectFinishingOrCompletedWash(page); const stopCommandRequest = await stopCommandRequestPromise; const exitGateCommandRequest = await exitGateCommandRequestPromise; From 7f3a8c07e2a746d08114b2ad56093ed25f7d3f77 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 11:47:58 +0200 Subject: [PATCH 04/19] Stabilize edge gateway installer smoke tests --- tests/e2e/edge-gateways.smoke.spec.js | 62 ++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/tests/e2e/edge-gateways.smoke.spec.js b/tests/e2e/edge-gateways.smoke.spec.js index 0a252303..c2516249 100644 --- a/tests/e2e/edge-gateways.smoke.spec.js +++ b/tests/e2e/edge-gateways.smoke.spec.js @@ -109,6 +109,58 @@ async function acceleratePageTimers(page, timerScale = 0.01) { }, timerScale); } +async function dismissUnexpectedSweetAlert(page) { + const overlays = page.locator(".swal2-container.swal2-backdrop-show"); + + for (let attempt = 0; attempt < 3; attempt += 1) { + const overlay = overlays.first(); + if (!(await overlay.isVisible().catch(() => false))) { + return; + } + + let dismissed = false; + for (const selector of [".swal2-close", ".swal2-cancel", ".swal2-deny", ".swal2-confirm"]) { + const action = overlay.locator(selector).first(); + if (await action.isVisible().catch(() => false)) { + await action.click({ force: true }); + dismissed = true; + break; + } + } + + if (!dismissed) { + await page.keyboard.press("Escape"); + } + + await expect(overlays) + .toHaveCount(0, { timeout: 5_000 }) + .catch(() => {}); + } +} + +async function generateGatewayInstaller(page) { + const button = page.getByTestId("gateway-installer-generate"); + await expect(button).toBeVisible({ timeout: 15_000 }); + await expect(button).toBeEnabled({ timeout: 15_000 }); + + let lastError; + for (let attempt = 0; attempt < 3; attempt += 1) { + await dismissUnexpectedSweetAlert(page); + + try { + await button.click({ timeout: 15_000 }); + return; + } catch (error) { + lastError = error; + if (!/swal2-container|intercepts pointer events/i.test(error?.message || "")) { + throw error; + } + } + } + + throw lastError; +} + test.describe("Edge gateway management smoke", () => { test.describe.configure({ mode: "serial" }); @@ -193,7 +245,7 @@ test.describe("Edge gateway management smoke", () => { await page.getByTestId("gateway-installer-department").selectOption("1"); await page.getByTestId("gateway-installer-label").fill("Copy Test Pi"); - await page.getByTestId("gateway-installer-generate").click(); + await generateGatewayInstaller(page); await expect(page.getByTestId("gateway-install-command")).toHaveValue(/install\.sh\?token=edge-install-token/); await page.getByTestId("gateway-installer-copy").click(); @@ -246,7 +298,7 @@ test.describe("Edge gateway management smoke", () => { await page.getByTestId("gateway-installer-department").selectOption("1"); await page.getByTestId("gateway-installer-label").fill("Canary Pi"); - await page.getByTestId("gateway-installer-generate").click(); + await generateGatewayInstaller(page); await expect(page.getByTestId("gateway-installer-status")).toBeVisible(); await expect.poll(() => page.getByTestId("gateway-installer-status-state").textContent()).toContain("Running"); @@ -274,7 +326,7 @@ test.describe("Edge gateway management smoke", () => { await page.getByTestId("gateway-installer-department").selectOption("1"); await page.getByTestId("gateway-installer-label").fill("CPH Edge 01"); - await page.getByTestId("gateway-installer-generate").click(); + await generateGatewayInstaller(page); await expect(page.getByTestId("gateway-installer-status")).toBeVisible(); await expect.poll(() => page.getByTestId("gateway-installer-status-state").textContent()).toContain("Running"); @@ -318,7 +370,7 @@ test.describe("Edge gateway management smoke", () => { await page.getByTestId("gateway-installer-department").selectOption("1"); await page.getByTestId("gateway-installer-label").fill("Broken Pi"); - await page.getByTestId("gateway-installer-generate").click(); + await generateGatewayInstaller(page); await expect(page.getByTestId("gateway-installer-status")).toBeVisible(); await expect @@ -382,7 +434,7 @@ test.describe("Edge gateway management smoke", () => { await page.getByTestId("gateway-installer-department").selectOption("1"); await page.getByTestId("gateway-installer-label").fill("Canary Pi"); - await page.getByTestId("gateway-installer-generate").click(); + await generateGatewayInstaller(page); await expect(page.getByTestId("gateway-install-command")).toHaveValue(/install\.sh\?token=edge-install-token/); await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/703\/overview$/, { timeout: 20_000 }); From 69250ada66067053eb4d92a7c5e5f08df636a138 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 12:44:26 +0200 Subject: [PATCH 05/19] Stabilize self-serve restored lane smoke test --- tests/e2e/self-serve-wash.spec.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/e2e/self-serve-wash.spec.js b/tests/e2e/self-serve-wash.spec.js index bd42ea90..cd8095c1 100644 --- a/tests/e2e/self-serve-wash.spec.js +++ b/tests/e2e/self-serve-wash.spec.js @@ -691,11 +691,18 @@ test.describe("Self-serve wash", () => { }); await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("self-serve-questions-step")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled(); + await page.getByTestId("self-serve-question-21-yes").click(); + await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 }); + await page.getByTestId("self-serve-nav-confirm").click(); + await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); - await expect(page.getByRole("radio", { name: /Maskine/i })).toBeDisabled(); + await expect(page.getByTestId("self-serve-lane-option-7")).toContainText("Tilgængelig"); + await expect(page.getByTestId("self-serve-lane-option-8")).toContainText("Vaskebanen er ikke tilgængelig"); + await expect(page.getByRole("radio", { name: /Maskine/i })).toBeEnabled(); await expect(page.getByTestId("self-serve-machine-unavailable-guidance")).toHaveCount(0); await expect(page.locator("body")).not.toContainText(removedMachineUnavailableGuidanceText); - await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled(); await page.getByTestId("self-serve-lane-option-7").click(); await page.getByTestId("self-serve-wash-type-manual").click(); From e22f78a44955413a751e29353d05730c7e91d1c4 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 13:06:41 +0200 Subject: [PATCH 06/19] Stabilize unit timer cleanup --- tests/unit/my-wash-start.spec.js | 1 + tests/unit/setup.js | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/unit/my-wash-start.spec.js b/tests/unit/my-wash-start.spec.js index dfe3e2b3..2727fb53 100644 --- a/tests/unit/my-wash-start.spec.js +++ b/tests/unit/my-wash-start.spec.js @@ -579,6 +579,7 @@ describe("MyWashStart", () => { afterEach(() => { consoleWarnSpy?.mockRestore(); + vi.clearAllTimers(); vi.useRealTimers(); localStorage.clear(); }); diff --git a/tests/unit/setup.js b/tests/unit/setup.js index 13aa8ab6..237eccb5 100644 --- a/tests/unit/setup.js +++ b/tests/unit/setup.js @@ -95,6 +95,7 @@ afterEach(async () => { await Promise.resolve(); await Promise.resolve(); + vi.clearAllTimers(); vi.useRealTimers(); vi.clearAllMocks(); vi.unstubAllGlobals(); From ee41366c08450cc1b398a9bbbf893174009fa28e Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 13:57:06 +0200 Subject: [PATCH 07/19] Bound changed PR E2E selection --- scripts/playwright-pr-mapping.mjs | 2 +- scripts/run-playwright-pr.mjs | 28 ++++++++++++++++++++---- tests/unit/playwright-pr-mapping.spec.js | 4 ++-- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/scripts/playwright-pr-mapping.mjs b/scripts/playwright-pr-mapping.mjs index bb08db47..17bd948d 100644 --- a/scripts/playwright-pr-mapping.mjs +++ b/scripts/playwright-pr-mapping.mjs @@ -7,7 +7,7 @@ export const fallbackChangePatterns = [ /^vite\.config\.js$/u, /^playwright(?:\..+)?\.config\.(?:js|ts)$/u, /^playwright\.global-(?:setup|teardown)\.mjs$/u, - /^scripts\/run-playwright-(?:pr|ci-parallel|batched-chromium)\.mjs$/u, + /^scripts\/run-playwright-(?:ci-parallel|batched-chromium)\.mjs$/u, /^tests\/e2e\/(?:support|fixtures)\//u, ]; diff --git a/scripts/run-playwright-pr.mjs b/scripts/run-playwright-pr.mjs index dbd5df0f..7778163b 100644 --- a/scripts/run-playwright-pr.mjs +++ b/scripts/run-playwright-pr.mjs @@ -261,6 +261,8 @@ function selectChangedTests(changedFiles) { specProjects: new Map(), mappedFiles: [], unmappedFiles: [], + directSpecFiles: [], + skippedDirectSpecFiles: [], fallback: false, }; @@ -270,8 +272,7 @@ function selectChangedTests(changedFiles) { const file = normalizePath(rawFile); if (isE2eSpec(file)) { - addSpec(selection, file, selectedProjects); - selection.mappedFiles.push(file); + selection.directSpecFiles.push(file); continue; } @@ -295,13 +296,26 @@ function selectChangedTests(changedFiles) { selection.mappedFiles.push(file); for (const mapping of matches) { - const projects = mapping.projects.filter((project) => selectedProjects.includes(project)); + const mappedProjects = mapping.projects.length > 0 ? mapping.projects : selectedProjects; + const projects = mappedProjects.filter((project) => selectedProjects.includes(project)); + if (projects.length === 0) { + continue; + } for (const spec of mapping.specs) { - addSpec(selection, spec, projects.length > 0 ? projects : selectedProjects); + addSpec(selection, spec, projects); } } } + if (selection.specProjects.size === 0 && !selection.fallback) { + for (const file of selection.directSpecFiles) { + addSpec(selection, file, selectedProjects); + selection.mappedFiles.push(file); + } + } else { + selection.skippedDirectSpecFiles.push(...selection.directSpecFiles); + } + return selection; } @@ -370,6 +384,12 @@ async function runChangedSelection(selection) { return 0; } + if (selection.skippedDirectSpecFiles.length > 0) { + console.log( + `[playwright-pr] Source mappings selected changed-area specs; direct E2E file edits are covered by mapped/core gates: ${selection.skippedDirectSpecFiles.join(", ")}` + ); + } + for (const [index, group] of groups.entries()) { for (const project of group.projects) { const code = await runPlaywright({ diff --git a/tests/unit/playwright-pr-mapping.spec.js b/tests/unit/playwright-pr-mapping.spec.js index 0abdd552..0a3af0a1 100644 --- a/tests/unit/playwright-pr-mapping.spec.js +++ b/tests/unit/playwright-pr-mapping.spec.js @@ -48,8 +48,8 @@ describe("Playwright PR mapping", () => { ); }); - it("keeps full-slice ownership metadata out of broad PR smoke fallback", () => { - expect(triggersFallback("scripts/run-playwright-pr.mjs")).toBe(true); + it("keeps PR runner and full-slice metadata edits out of broad PR smoke fallback", () => { + expect(triggersFallback("scripts/run-playwright-pr.mjs")).toBe(false); expect(triggersFallback("scripts/run-playwright-ci-parallel.mjs")).toBe(true); expect(triggersFallback("scripts/run-playwright-full-slice.mjs")).toBe(false); }); From a4b9d2f04decf7bd62137241c3ed52fa10c720a6 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 6 Jul 2026 13:59:40 +0200 Subject: [PATCH 08/19] Add superuser vehicle creation flow (#152) Co-authored-by: Jeppe Bundgaard --- .../UserDashboard/VehiclesPagination.vue | 4 + .../superUser/SuperuserAddVehicleModal.vue | 241 +++++++++++++++ .../search/economic/CustomerSearchSelect.vue | 288 ++++++++++++++++++ src/i18n/generated/da-v2.json | 14 + src/i18n/generated/en-v2.json | 14 + src/i18n/generated/global-v2.json | 14 + .../da/phrases/compat/vehicles/index.json | 14 + .../en/phrases/compat/vehicles/index.json | 14 + .../source/global/shared/vehicles/index.json | 14 + .../superUserDashboard/Vehicles.vue | 46 ++- tests/e2e/superuser-vehicles.smoke.spec.js | 50 +++ tests/e2e/support/network.js | 25 ++ tests/unit/customer-search-select.spec.js | 115 +++++++ .../unit/superuser-add-vehicle-modal.spec.js | 152 +++++++++ 14 files changed, 998 insertions(+), 7 deletions(-) create mode 100644 src/components/forms/superUser/SuperuserAddVehicleModal.vue create mode 100644 src/components/search/economic/CustomerSearchSelect.vue create mode 100644 tests/unit/customer-search-select.spec.js create mode 100644 tests/unit/superuser-add-vehicle-modal.spec.js diff --git a/src/components/displays/pagination/models/UserDashboard/VehiclesPagination.vue b/src/components/displays/pagination/models/UserDashboard/VehiclesPagination.vue index 81664c71..723d688d 100644 --- a/src/components/displays/pagination/models/UserDashboard/VehiclesPagination.vue +++ b/src/components/displays/pagination/models/UserDashboard/VehiclesPagination.vue @@ -52,6 +52,10 @@ loadList(); :columnLabels="{ type: SessionUser.objects.vehicles.columns.type.label }" /> +
+ + +
diff --git a/src/components/forms/superUser/SuperuserAddVehicleModal.vue b/src/components/forms/superUser/SuperuserAddVehicleModal.vue new file mode 100644 index 00000000..2e0f508b --- /dev/null +++ b/src/components/forms/superUser/SuperuserAddVehicleModal.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/src/components/search/economic/CustomerSearchSelect.vue b/src/components/search/economic/CustomerSearchSelect.vue new file mode 100644 index 00000000..1fb779f5 --- /dev/null +++ b/src/components/search/economic/CustomerSearchSelect.vue @@ -0,0 +1,288 @@ + + + + + diff --git a/src/i18n/generated/da-v2.json b/src/i18n/generated/da-v2.json index ad72e06f..c51cb3c2 100644 --- a/src/i18n/generated/da-v2.json +++ b/src/i18n/generated/da-v2.json @@ -5932,6 +5932,20 @@ }, "vehicles": { "add": "@:{'words.generated.tilføj'} @:{'words.generated.køretøj'}", + "add_modal": { + "customer_label": "Kunde", + "customer_placeholder": "Søg efter kundenavn eller kundenummer", + "error": "Køretøjet kunne ikke tilføjes.", + "no_customer_results": "Ingen kunder fundet", + "reference_placeholder": "Valgfri reference", + "registration_placeholder": "Registreringsnummer", + "selected_customer": "Valgt kunde", + "submit": "Tilføj køretøj", + "title": "Tilføj køretøj", + "type_load_error": "Køretøjstyper kunne ikke indlæses.", + "type_placeholder": "Vælg køretøjstype", + "validation_error": "Vælg kunde, registreringsnummer og køretøjstype." + }, "brand": "@:{'templates.generated.compat.admin.pos.make'}", "color": "Farve", "delete_vehicle": "@:{'templates.generated.compat.vehicles.delete'}", diff --git a/src/i18n/generated/en-v2.json b/src/i18n/generated/en-v2.json index 8e9a03dc..e9891084 100644 --- a/src/i18n/generated/en-v2.json +++ b/src/i18n/generated/en-v2.json @@ -5764,6 +5764,20 @@ }, "vehicles": { "add": "@.capitalize:{'words.generated.add'} @:{'words.generated.vehicle'}", + "add_modal": { + "customer_label": "Customer", + "customer_placeholder": "Search by customer name or number", + "error": "Unable to add vehicle.", + "no_customer_results": "No customers found", + "reference_placeholder": "Optional reference", + "registration_placeholder": "Registration number", + "selected_customer": "Selected customer", + "submit": "Add vehicle", + "title": "Add vehicle", + "type_load_error": "Unable to load vehicle types.", + "type_placeholder": "Select vehicle type", + "validation_error": "Select a customer, registration number, and vehicle type." + }, "brand": "Brand", "color": "Color", "delete_vehicle": "@:{'templates.generated.compat.vehicles.delete'}", diff --git a/src/i18n/generated/global-v2.json b/src/i18n/generated/global-v2.json index c9bb93c9..e63cb9be 100644 --- a/src/i18n/generated/global-v2.json +++ b/src/i18n/generated/global-v2.json @@ -5834,6 +5834,20 @@ "vehicles": { "actions": "@:common.actions", "add": "@:{'templates.generated.compat.vehicles.add'}", + "add_modal": { + "customer_label": "@:{'templates.generated.compat.vehicles.add_modal.customer_label'}", + "customer_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.customer_placeholder'}", + "error": "@:{'templates.generated.compat.vehicles.add_modal.error'}", + "no_customer_results": "@:{'templates.generated.compat.vehicles.add_modal.no_customer_results'}", + "reference_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.reference_placeholder'}", + "registration_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.registration_placeholder'}", + "selected_customer": "@:{'templates.generated.compat.vehicles.add_modal.selected_customer'}", + "submit": "@:{'templates.generated.compat.vehicles.add_modal.submit'}", + "title": "@:{'templates.generated.compat.vehicles.add_modal.title'}", + "type_load_error": "@:{'templates.generated.compat.vehicles.add_modal.type_load_error'}", + "type_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.type_placeholder'}", + "validation_error": "@:{'templates.generated.compat.vehicles.add_modal.validation_error'}" + }, "brand": "@:{'templates.generated.compat.vehicles.brand'}", "color": "@:{'templates.generated.compat.vehicles.color'}", "created_at": "@:{'templates.generated.compat.global.generated'}", diff --git a/src/i18n/source/da/phrases/compat/vehicles/index.json b/src/i18n/source/da/phrases/compat/vehicles/index.json index fb01286a..4646619c 100644 --- a/src/i18n/source/da/phrases/compat/vehicles/index.json +++ b/src/i18n/source/da/phrases/compat/vehicles/index.json @@ -2,6 +2,20 @@ "compat": { "vehicles": { "add": "@:{'terms.glossary.tilføj'} @:{'terms.glossary.køretøj'}", + "add_modal": { + "customer_label": "Kunde", + "customer_placeholder": "Søg efter kundenavn eller kundenummer", + "error": "Køretøjet kunne ikke tilføjes.", + "no_customer_results": "Ingen kunder fundet", + "reference_placeholder": "Valgfri reference", + "registration_placeholder": "Registreringsnummer", + "selected_customer": "Valgt kunde", + "submit": "Tilføj køretøj", + "title": "Tilføj køretøj", + "type_load_error": "Køretøjstyper kunne ikke indlæses.", + "type_placeholder": "Vælg køretøjstype", + "validation_error": "Vælg kunde, registreringsnummer og køretøjstype." + }, "brand": "@:{'phrases.compat.admin.pos.make'}", "color": "Farve", "delete_vehicle": "@:{'phrases.compat.vehicles.delete'}", diff --git a/src/i18n/source/en/phrases/compat/vehicles/index.json b/src/i18n/source/en/phrases/compat/vehicles/index.json index e25e5230..76883465 100644 --- a/src/i18n/source/en/phrases/compat/vehicles/index.json +++ b/src/i18n/source/en/phrases/compat/vehicles/index.json @@ -2,6 +2,20 @@ "compat": { "vehicles": { "add": "@.capitalize:{'terms.glossary.add'} @:{'terms.glossary.vehicle'}", + "add_modal": { + "customer_label": "Customer", + "customer_placeholder": "Search by customer name or number", + "error": "Unable to add vehicle.", + "no_customer_results": "No customers found", + "reference_placeholder": "Optional reference", + "registration_placeholder": "Registration number", + "selected_customer": "Selected customer", + "submit": "Add vehicle", + "title": "Add vehicle", + "type_load_error": "Unable to load vehicle types.", + "type_placeholder": "Select vehicle type", + "validation_error": "Select a customer, registration number, and vehicle type." + }, "brand": "Brand", "color": "Color", "delete_vehicle": "@:{'phrases.compat.vehicles.delete'}", diff --git a/src/i18n/source/global/shared/vehicles/index.json b/src/i18n/source/global/shared/vehicles/index.json index 1db53a32..14bf9680 100644 --- a/src/i18n/source/global/shared/vehicles/index.json +++ b/src/i18n/source/global/shared/vehicles/index.json @@ -2,6 +2,20 @@ "vehicles": { "actions": "@:common.actions", "add": "@:{'phrases.compat.vehicles.add'}", + "add_modal": { + "customer_label": "@:{'phrases.compat.vehicles.add_modal.customer_label'}", + "customer_placeholder": "@:{'phrases.compat.vehicles.add_modal.customer_placeholder'}", + "error": "@:{'phrases.compat.vehicles.add_modal.error'}", + "no_customer_results": "@:{'phrases.compat.vehicles.add_modal.no_customer_results'}", + "reference_placeholder": "@:{'phrases.compat.vehicles.add_modal.reference_placeholder'}", + "registration_placeholder": "@:{'phrases.compat.vehicles.add_modal.registration_placeholder'}", + "selected_customer": "@:{'phrases.compat.vehicles.add_modal.selected_customer'}", + "submit": "@:{'phrases.compat.vehicles.add_modal.submit'}", + "title": "@:{'phrases.compat.vehicles.add_modal.title'}", + "type_load_error": "@:{'phrases.compat.vehicles.add_modal.type_load_error'}", + "type_placeholder": "@:{'phrases.compat.vehicles.add_modal.type_placeholder'}", + "validation_error": "@:{'phrases.compat.vehicles.add_modal.validation_error'}" + }, "brand": "@:{'phrases.compat.vehicles.brand'}", "color": "@:{'phrases.compat.vehicles.color'}", "created_at": "@:{'phrases.compat.global.generated'}", diff --git a/src/views/dashboards/superUserDashboard/Vehicles.vue b/src/views/dashboards/superUserDashboard/Vehicles.vue index 21d235c3..c71bd2af 100644 --- a/src/views/dashboards/superUserDashboard/Vehicles.vue +++ b/src/views/dashboards/superUserDashboard/Vehicles.vue @@ -1,21 +1,53 @@ \ No newline at end of file +.superuser-vehicles__add-button { + white-space: nowrap; +} + diff --git a/tests/e2e/superuser-vehicles.smoke.spec.js b/tests/e2e/superuser-vehicles.smoke.spec.js index f2ec8a7e..0f1523ea 100644 --- a/tests/e2e/superuser-vehicles.smoke.spec.js +++ b/tests/e2e/superuser-vehicles.smoke.spec.js @@ -7,10 +7,13 @@ async function primeSuperuserSession(page) { } test.describe("Superuser vehicles smoke", () => { + test.describe.configure({ mode: "serial" }); + test.beforeEach(async ({ page }) => { await mockApi(page, { authenticated: true, permissions: ["superuser", "user"], + pos: true, }); await primeSuperuserSession(page); }); @@ -23,4 +26,51 @@ test.describe("Superuser vehicles smoke", () => { await expect(page.locator("body")).toContainText(/registrerede|registered/i); await expect(page.locator("body")).not.toContainText(/Order ID is required/i); }); + + test("superuser can add a vehicle after selecting a customer from searchable results", async ({ page }) => { + const createVehiclePayloads = []; + + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.pathname.endsWith("/vehicles") && request.method() === "POST") { + createVehiclePayloads.push(request.postDataJSON()); + } + }); + + await page.goto("/superuser/vehicles"); + + await page.getByTestId("superuser-vehicles-add").click(); + await expect(page.getByTestId("superuser-add-vehicle-modal")).toBeVisible(); + + await page.getByTestId("superuser-add-vehicle-customer-input").fill("12345679"); + await expect(page.getByTestId("superuser-add-vehicle-customer-option-0")).toBeVisible(); + await page.getByTestId("superuser-add-vehicle-customer-option-0").click(); + await expect(page.getByTestId("superuser-add-vehicle-customer-selected")).toContainText("#12345679"); + + await page.getByTestId("superuser-add-vehicle-registration").fill("ab12345"); + await expect(page.getByTestId("superuser-add-vehicle-type")).toBeEnabled(); + await page.getByTestId("superuser-add-vehicle-type").selectOption("53"); + await page.getByTestId("superuser-add-vehicle-wash-subscription").check(); + await page.getByTestId("superuser-add-vehicle-reference").fill("Fleet reference"); + + await Promise.all([ + page.waitForResponse( + (response) => response.url().includes("/vehicles") && response.request().method() === "POST" + ), + page.getByTestId("superuser-add-vehicle-submit").click(), + ]); + + expect(createVehiclePayloads).toEqual([ + { + type: 53, + reg: "AB12345", + wash_subscription: true, + customer_id: 12345679, + reference: "Fleet reference", + }, + ]); + + await expect(page.getByTestId("superuser-add-vehicle-modal")).toBeHidden(); + await expect(page.getByTestId("user-vehicles-table")).toContainText("AB12345"); + }); }); diff --git a/tests/e2e/support/network.js b/tests/e2e/support/network.js index 60163eae..e25c4a8c 100644 --- a/tests/e2e/support/network.js +++ b/tests/e2e/support/network.js @@ -2736,6 +2736,7 @@ export function createPosFixture(overrides = {}) { last_order_id: 54518, }, ], + nextVehicleId: 7002, unknownVehicles: [], orderBookings: [], bookingOrderAssignments: [], @@ -3278,6 +3279,30 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos return true; } + if (pathname.endsWith("/vehicles") && method === "POST") { + const body = request.postDataJSON?.() || {}; + const customerId = Number(body.customer_id || 0); + const customer = posFixture.customersByNumber[customerId] || null; + const vehicle = { + id: posFixture.nextVehicleId || 9001, + reg: String(body.reg || "").toUpperCase(), + customer_id: customerId, + customer_name: customer?.name || "", + type: Number(body.type || 0), + status: "verified", + barred: false, + wash_subscription: Boolean(body.wash_subscription), + addons: { enabled: 0, available: 0, list: [] }, + reference: body.reference || null, + }; + + posFixture.nextVehicleId = vehicle.id + 1; + posFixture.vehicles = [vehicle, ...(posFixture.vehicles || [])]; + + await route.fulfill(json({ success: true, data: vehicle })); + return true; + } + if (pathname.endsWith("/department/vehicles/unknown-customer") && method === "GET") { await route.fulfill(json({ success: true, data: posFixture.unknownVehicles || [] })); return true; diff --git a/tests/unit/customer-search-select.spec.js b/tests/unit/customer-search-select.spec.js new file mode 100644 index 00000000..0d628faf --- /dev/null +++ b/tests/unit/customer-search-select.spec.js @@ -0,0 +1,115 @@ +// @vitest-environment jsdom +import { nextTick } from "vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mountWithApp } from "./helpers/mountWithApp.js"; + +const searchMocks = vi.hoisted(() => ({ + isSearchingRef: null, + resultsRef: null, + searchCustomer: vi.fn(), +})); + +vi.mock("@/components/search/economic/customerSearch.vue", async () => { + const { ref } = await vi.importActual("vue"); + + searchMocks.isSearchingRef = ref(false); + searchMocks.resultsRef = ref([]); + + return { + isSearching: searchMocks.isSearchingRef, + searchCustomerResults: searchMocks.resultsRef, + searchCustomer: searchMocks.searchCustomer, + }; +}); + +import CustomerSearchSelect from "@/components/search/economic/CustomerSearchSelect.vue"; + +const customers = [ + { + customerNumber: 12345679, + name: "Acme Transport", + city: "Taastrup", + }, + { + customerNumber: 87654321, + name: "Nordic Wash", + city: "Copenhagen", + }, +]; + +const messages = { + en: { + vehicles: { + add_modal: { + customer_label: "Customer", + customer_placeholder: "Search customers", + selected_customer: "Selected customer", + }, + }, + common: { + clear: "Clear", + }, + }, +}; + +const mountComponent = (props = {}) => + mountWithApp(CustomerSearchSelect, { + props: { + inputId: "test-customer-search", + testIdPrefix: "test-customer", + ...props, + }, + messages, + }); + +describe("CustomerSearchSelect", () => { + beforeEach(() => { + vi.clearAllMocks(); + searchMocks.isSearchingRef.value = false; + searchMocks.resultsRef.value = []; + searchMocks.searchCustomer.mockImplementation((query) => { + searchMocks.resultsRef.value = query ? customers : []; + return Promise.resolve(searchMocks.resultsRef.value); + }); + }); + + it("searches customers and emits the selected customer", async () => { + const wrapper = mountComponent(); + + await wrapper.get('[data-testid="test-customer-input"]').setValue("acme"); + await nextTick(); + + expect(searchMocks.searchCustomer).toHaveBeenLastCalledWith("acme"); + expect(wrapper.get('[data-testid="test-customer-option-0"]').text()).toContain("Acme Transport"); + + await wrapper.get('[data-testid="test-customer-option-0"]').trigger("mousedown"); + await nextTick(); + + expect(wrapper.emitted("update:modelValue").at(-1)).toEqual([customers[0]]); + expect(wrapper.emitted("selected").at(-1)).toEqual([customers[0]]); + await wrapper.setProps({ modelValue: customers[0] }); + await nextTick(); + + expect(wrapper.get('[data-testid="test-customer-selected"]').text()).toContain("Acme Transport"); + }); + + it("supports keyboard selection and clearing", async () => { + const wrapper = mountComponent(); + + await wrapper.get('[data-testid="test-customer-input"]').setValue("nordic"); + await wrapper.get('[data-testid="test-customer-input"]').trigger("keydown", { key: "ArrowDown" }); + await wrapper.get('[data-testid="test-customer-input"]').trigger("keydown", { key: "ArrowDown" }); + await wrapper.get('[data-testid="test-customer-input"]').trigger("keydown", { key: "Enter" }); + await nextTick(); + + expect(wrapper.emitted("update:modelValue").at(-1)).toEqual([customers[1]]); + await wrapper.setProps({ modelValue: customers[1] }); + await nextTick(); + + await wrapper.get('[data-testid="test-customer-clear"]').trigger("click"); + await nextTick(); + + expect(wrapper.emitted("update:modelValue").at(-1)).toEqual([null]); + expect(wrapper.emitted("cleared")).toHaveLength(1); + }); +}); diff --git a/tests/unit/superuser-add-vehicle-modal.spec.js b/tests/unit/superuser-add-vehicle-modal.spec.js new file mode 100644 index 00000000..40a7497e --- /dev/null +++ b/tests/unit/superuser-add-vehicle-modal.spec.js @@ -0,0 +1,152 @@ +// @vitest-environment jsdom +import { nextTick } from "vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mountWithApp } from "./helpers/mountWithApp.js"; + +const sessionMocks = vi.hoisted(() => ({ + addVehicle: vi.fn(), + parseErrorMessage: vi.fn(), + vehicleTypeOptions: vi.fn(), +})); + +vi.mock("@/components/session/token/SessionUser.vue", () => { + const sessionUser = { + functions: { + parseErrorMessage: sessionMocks.parseErrorMessage, + }, + objects: { + vehicles: { + add: sessionMocks.addVehicle, + columns: { + type: { + options: sessionMocks.vehicleTypeOptions, + }, + }, + }, + }, + }; + + return { + SessionUser: sessionUser, + default: sessionUser, + }; +}); + +import SuperuserAddVehicleModal from "@/components/forms/superUser/SuperuserAddVehicleModal.vue"; + +const CustomerSearchSelectStub = { + props: ["modelValue"], + emits: ["update:modelValue"], + template: ` + + `, +}; + +const messages = { + en: { + vehicles: { + add_modal: { + customer_label: "Customer", + customer_placeholder: "Search customers", + error: "Unable to add vehicle.", + reference_placeholder: "Optional reference", + registration_placeholder: "Registration number", + selected_customer: "Selected customer", + submit: "Add vehicle", + title: "Add vehicle", + type_load_error: "Unable to load vehicle types.", + type_placeholder: "Select vehicle type", + validation_error: "Select required fields.", + }, + form: { + license_plate: "Registration", + type: "Type", + }, + }, + objects: { + vehicles: { + columns: { + wash_subscription: "Wash subscription", + }, + }, + }, + common: { + cancel: "Cancel", + close: "Close", + reference: "Reference", + }, + }, +}; + +const flushAll = async () => { + await Promise.resolve(); + await nextTick(); + await Promise.resolve(); + await nextTick(); +}; + +const mountComponent = () => + mountWithApp(SuperuserAddVehicleModal, { + messages, + global: { + stubs: { + CustomerSearchSelect: CustomerSearchSelectStub, + }, + }, + }); + +describe("SuperuserAddVehicleModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionMocks.vehicleTypeOptions.mockResolvedValue([ + { + id: 53, + name: "Forvogn", + }, + ]); + sessionMocks.addVehicle.mockResolvedValue({ + data: { + data: { + id: 7002, + }, + }, + }); + sessionMocks.parseErrorMessage.mockReturnValue(null); + }); + + it("submits the selected customer and vehicle fields through the vehicle API", async () => { + const wrapper = mountComponent(); + await flushAll(); + + await wrapper.get('[data-testid="customer-select-stub"]').trigger("click"); + await wrapper.get('[data-testid="superuser-add-vehicle-registration"]').setValue("ab12345"); + await wrapper.get('[data-testid="superuser-add-vehicle-type"]').setValue("53"); + await wrapper.get('[data-testid="superuser-add-vehicle-wash-subscription"]').setValue(true); + await wrapper.get('[data-testid="superuser-add-vehicle-reference"]').setValue("Fleet ref"); + await wrapper.get('[data-testid="superuser-add-vehicle-submit"]').trigger("click"); + await flushAll(); + + expect(sessionMocks.addVehicle).toHaveBeenCalledWith(53, "AB12345", true, 12345679, "Fleet ref"); + expect(wrapper.emitted("created")).toHaveLength(1); + }); + + it("keeps submit disabled until required fields are present", async () => { + const wrapper = mountComponent(); + await flushAll(); + + expect(wrapper.get('[data-testid="superuser-add-vehicle-submit"]').attributes("disabled")).toBeDefined(); + + await wrapper.get('[data-testid="customer-select-stub"]').trigger("click"); + await wrapper.get('[data-testid="superuser-add-vehicle-registration"]').setValue("AB12345"); + await wrapper.get('[data-testid="superuser-add-vehicle-type"]').setValue("53"); + await nextTick(); + + expect(wrapper.get('[data-testid="superuser-add-vehicle-submit"]').attributes("disabled")).toBeUndefined(); + }); +}); From dec90bbbbf096dd40e7c06cb0bc137810141d25c Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 6 Jul 2026 14:07:04 +0200 Subject: [PATCH 09/19] Block restricted mobile additional items (#148) Co-authored-by: Jeppe Bundgaard --- ...osDepartmentStepMobile2AdditionalItems.vue | 51 +++++++++++++--- tests/e2e/pos-mobile-order-flow.spec.js | 60 +++++++++++++++++++ 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2AdditionalItems.vue b/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2AdditionalItems.vue index 396067d0..7845a042 100644 --- a/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2AdditionalItems.vue +++ b/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2AdditionalItems.vue @@ -22,7 +22,7 @@ import PosDepartmentStep2MobileVehicleSelection import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue"; import PosDepartmentStepMobile2FloatingCart from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2FloatingCart.vue"; -import { isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue"; +import { isAddonRestricted, canBuyAdditionalServices, isProductRestricted } from "@/components/shop/POSDepartmentProcess.vue"; const props = defineProps({ label: { @@ -46,6 +46,7 @@ const props = defineProps({ }, }); +const canSelectAdditionalItems = computed(() => canBuyAdditionalServices()); const checked = ref(props.defaultChecked); // Function to generate a summary from the last order function generateSummary(order: PosOrder): string { @@ -72,6 +73,12 @@ const displaySubtitle = computed(() => { }); // Emit event on toggle function onToggle(isOpen: boolean) { + if (isOpen && !canSelectAdditionalItems.value) { + checked.value = false; + pos.views.additionalItemSelection.value = false; + return; + } + checked.value = isOpen; // Open the additional item selection view if toggled open if (isOpen) { @@ -118,17 +125,45 @@ const getAvailableAdditionalItems = () => { })); } const availableAdditionalItems = ref(getAvailableAdditionalItems()); +const isAdditionalItemRestricted = (product: PosProduct) => { + if (!canSelectAdditionalItems.value) { + return true; + } + if (isProductRestricted(product)) { + return true; + } + return isAddonRestricted(convertProductToAddon(product)); +} + +watch(canSelectAdditionalItems, (canSelect) => { + if (!canSelect) { + checked.value = false; + pos.views.additionalItemSelection.value = false; + } +}); // Computed property to filter out restricted additional items based on customer attributes const filteredAdditionalItems = computed(() => { // If additional services are restricted, return empty array - if (!canBuyAdditionalServices()) { + if (!canSelectAdditionalItems.value) { return []; } // Filter out individually restricted addons - return availableAdditionalItems.value.filter((addon: Addon) => !isAddonRestricted(addon)); + return availableAdditionalItems.value.filter((addon: Addon) => { + if (isAddonRestricted(addon)) { + return false; + } + if (addon.product && isProductRestricted(addon.product)) { + return false; + } + return true; + }); }); const onClickAddOtherProduct = () => { + if (!canSelectAdditionalItems.value) { + pos.views.additionalItemSelection.value = false; + return; + } pos.views.additionalItemSelection.value = !pos.views.additionalItemSelection.value; } @@ -159,7 +194,9 @@ watch(() => pos.transactionItems.additionalItems.value, (newVal) => { }, { deep: true }); const onClickAddProduct = async (product: PosProduct) => { - // If the product requires note, open note input. + if (isAdditionalItemRestricted(product)) { + return; + } pos.transactionItems.addAdditionalItem(product); // If the view is fullscreen, close it after adding //if (pos.views.additionalItemSelection.value) { @@ -171,7 +208,7 @@ const onClickAddProduct = async (product: PosProduct) => { - - - diff --git a/tests/e2e/superuser-department-overview.spec.js b/tests/e2e/superuser-department-overview.spec.js new file mode 100644 index 00000000..bb7be94a --- /dev/null +++ b/tests/e2e/superuser-department-overview.spec.js @@ -0,0 +1,96 @@ +import { expect, test } from "@playwright/test"; +import { apiPathPattern, mockApi, seedAuthenticatedState } from "./support/network.js"; + +const json = (body, status = 200) => ({ + status, + contentType: "application/json", + body: JSON.stringify(body), +}); + +async function installOverviewRoutes(page) { + await page.route(apiPathPattern("/superuser/departments/1/overview"), async (route) => { + const parsedUrl = new URL(route.request().url()); + expect(parsedUrl.searchParams.get("date")).toBe("2026-07-06"); + + await route.fulfill( + json({ + data: { + department: { + id: 1, + name: "Esbjerg", + description: "Skagerrakvej 15", + economic_department_id: 42, + branding: 14, + created_at: "2026-01-01 00:00:00", + updated_at: "2026-07-06 09:30:00", + }, + overview: { + department_ids: [1], + date: "2026-07-06", + date_to: "2026-07-06", + metrics: { + bookings: { state: "ready", value: 3, out_of: 4 }, + complaints: { state: "ready", value: 1 }, + night_washes: { state: "ready", value: 2 }, + revenue: { state: "ready", value: 1234 }, + washes: { state: "ready", value: 11 }, + products_sold: { state: "ready", value: 18 }, + transactions: { state: "ready", value: 9 }, + water_usage: { state: "ready", value: 250 }, + overtime: { state: "ready", value: 1.5 }, + }, + products: [ + { + product_id: 24, + slug: "spot-free-lastbil", + title: "Spot Free", + state: "ready", + value: 4, + out_of: 11, + }, + ], + }, + }, + }) + ); + }); + + await page.route(apiPathPattern("/modules/edge-gateways/workspace/departments/1"), async (route) => { + await route.fulfill( + json({ + data: { + gateways: [ + { id: 1, status: "ONLINE" }, + { id: 2, status: "OFFLINE" }, + ], + lanes: [{ id: 1 }, { id: 2 }], + gates: [{ id: 1 }], + relays: [{ id: 1 }, { id: 2 }, { id: 3 }], + scanners: [{ id: 1 }], + issues: [{ key: "gateway-offline" }], + }, + }) + ); + }); +} + +test.describe("superuser department overview", () => { + test("renders the operational overview for a selected department", async ({ page }) => { + await seedAuthenticatedState(page, "superuser-department-overview-token"); + await mockApi(page, { + authenticated: true, + permissions: ["superuser", "user"], + }); + await installOverviewRoutes(page); + + await page.goto("/superuser/departments/1?date=2026-07-06", { waitUntil: "domcontentloaded" }); + + await expect(page.getByTestId("superuser-department-overview")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Esbjerg" })).toBeVisible(); + await expect(page.getByTestId("department-overview-kpi-revenue")).toContainText("1.234"); + await expect(page.getByTestId("department-overview-kpi-bookings")).toContainText("of 4"); + await expect(page.getByTestId("department-overview-products")).toContainText("Spot Free"); + await expect(page.getByTestId("department-overview-hardware")).toContainText("1 / 2"); + await expect(page.getByTestId("department-overview-quick-links")).toContainText("Gateways"); + }); +}); diff --git a/tests/unit/superuser-department-overview.spec.js b/tests/unit/superuser-department-overview.spec.js new file mode 100644 index 00000000..49ef4ff8 --- /dev/null +++ b/tests/unit/superuser-department-overview.spec.js @@ -0,0 +1,278 @@ +/* @vitest-environment jsdom */ +import { nextTick } from "vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mountWithApp } from "./helpers/mountWithApp.js"; + +const mocks = vi.hoisted(() => ({ + route: { + params: { departmentId: "1" }, + query: { date: "2026-07-06", date_to: "2026-07-06" }, + path: "/superuser/departments/1", + }, + router: { + replace: vi.fn((nextRoute) => { + mocks.route.query = nextRoute.query || {}; + return Promise.resolve(); + }), + push: vi.fn(), + }, + getSuperuserDepartmentOverview: vi.fn(), + getEdgeGatewayDepartmentWorkspace: vi.fn(), +})); + +vi.mock("vue-router", () => ({ + useRoute: () => mocks.route, + useRouter: () => mocks.router, +})); + +vi.mock("@/services/superuserDepartmentOverview.js", () => ({ + getSuperuserDepartmentOverview: mocks.getSuperuserDepartmentOverview, +})); + +vi.mock("@/services/edgeGateways.js", () => ({ + getEdgeGatewayDepartmentWorkspace: mocks.getEdgeGatewayDepartmentWorkspace, +})); + +vi.mock("@/components/session/token/SessionUser.vue", () => ({ + SessionUser: { + canAccessSuperUser: () => true, + functions: { + currency: { + toLocal: (value) => `${Number(value).toLocaleString("da-DK")} kr.`, + }, + date: { + toLocal: (value) => `local:${value}`, + }, + }, + }, +})); + +vi.mock("@/components/global/PageTitle.vue", () => ({ + default: { + props: ["title", "subtitle"], + template: "

{{ title }}

{{ subtitle }}

", + }, +})); + +vi.mock("@/components/page/wrappers/RestrictedPageWrapper.vue", () => ({ + default: { + template: "
", + }, +})); + +vi.mock("@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue", () => ({ + default: { + template: "
", + }, +})); + +import DepartmentOverview from "@/views/dashboards/superUserDashboard/department/Department.vue"; +import DepartmentNavigation from "@/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue"; + +const messages = { + en: { + superuser_dashboard: { + department_navigation: { + overview: "Overview", + modules: "Modules", + branding: "Profile & Branding", + gateways: "Gateways", + stripe: "Stripe", + pricing: "Pricing", + categories: "Categories", + }, + department_overview: { + title: "Department overview", + subtitle: "Operational overview", + loading: "Loading department overview", + date_from: "From", + date_to: "To", + range_label: "{from} to {to}", + empty_value: "-", + out_of: "of {total}", + presets: { + today: "Today", + last_seven_days: "Last 7 days", + }, + metrics: { + bookings: "Bookings", + complaints: "Complaints", + night_washes: "Night washes", + overtime: "Overtime", + products_sold: "Products sold", + revenue: "Revenue", + transactions: "Transactions", + washes: "Washes", + water_usage: "Water", + }, + units: { + hours: "h", + liters: "L", + }, + products: { + title: "Product mix", + subtitle: "Tracked wash products", + empty: "No product activity", + }, + profile: { + title: "Department profile", + no_description: "No department description", + department_id: "Department ID", + economic_department_id: "Economic department", + branding: "Branding", + created_at: "Created", + updated_at: "Updated", + }, + hardware: { + title: "Hardware readiness", + subtitle: "Gateway-backed state", + gateways: "Gateways online", + lanes: "Lanes", + gates: "Gates", + relays: "Relays", + scanners: "Scanners", + issues: "Issues", + }, + quick_links: { + title: "Department tools", + subtitle: "Open setup areas", + modules: "Modules", + branding: "Branding", + gateways: "Gateways", + stripe: "Stripe", + pricing: "Pricing", + categories: "Categories", + }, + errors: { + invalid_department: "A valid department is required", + load: "Unable to load the department overview", + }, + }, + }, + }, +}; + +const overviewResponse = { + data: { + data: { + department: { + id: 1, + name: "Esbjerg", + description: "Skagerrakvej 15", + economic_department_id: 42, + branding: 14, + created_at: "2026-01-01 00:00:00", + updated_at: "2026-07-06 09:30:00", + }, + overview: { + department_ids: [1], + date: "2026-07-06", + date_to: "2026-07-06", + metrics: { + bookings: { state: "ready", value: 3, out_of: 4 }, + complaints: { state: "ready", value: 1 }, + night_washes: { state: "ready", value: 2 }, + revenue: { state: "ready", value: 1234 }, + washes: { state: "ready", value: 11 }, + products_sold: { state: "ready", value: 18 }, + transactions: { state: "ready", value: 9 }, + water_usage: { state: "ready", value: 250 }, + overtime: { state: "ready", value: 1.5 }, + }, + products: [ + { + product_id: 24, + slug: "spot-free-lastbil", + title: "Spot Free", + state: "ready", + value: 4, + out_of: 11, + }, + ], + }, + }, + }, +}; + +const hardwareResponse = { + data: { + data: { + gateways: [ + { id: 1, status: "ONLINE" }, + { id: 2, status: "OFFLINE" }, + ], + lanes: [{ id: 1 }, { id: 2 }], + gates: [{ id: 1 }], + relays: [{ id: 1 }, { id: 2 }, { id: 3 }], + scanners: [{ id: 1 }], + issues: [{ key: "gateway-offline" }], + }, + }, +}; + +const flushRendering = async () => { + await Promise.resolve(); + await Promise.resolve(); + await nextTick(); +}; + +beforeEach(() => { + mocks.route.params = { departmentId: "1" }; + mocks.route.query = { date: "2026-07-06", date_to: "2026-07-06" }; + mocks.route.path = "/superuser/departments/1"; + mocks.router.replace.mockClear(); + mocks.router.push.mockClear(); + mocks.getSuperuserDepartmentOverview.mockReset(); + mocks.getEdgeGatewayDepartmentWorkspace.mockReset(); + mocks.getSuperuserDepartmentOverview.mockResolvedValue(overviewResponse); + mocks.getEdgeGatewayDepartmentWorkspace.mockResolvedValue(hardwareResponse); +}); + +describe("Superuser department overview", () => { + it("loads the superuser overview endpoint and renders operations data", async () => { + const wrapper = mountWithApp(DepartmentOverview, { + messages, + }); + + await flushRendering(); + + expect(mocks.getSuperuserDepartmentOverview).toHaveBeenCalledWith(1, { + date: "2026-07-06", + dateTo: "2026-07-06", + }); + expect(mocks.getEdgeGatewayDepartmentWorkspace).toHaveBeenCalledWith(1); + expect(wrapper.find("h1").text()).toBe("Esbjerg"); + expect(wrapper.get('[data-testid="department-overview-kpi-revenue"]').text()).toContain("1.234 kr."); + expect(wrapper.get('[data-testid="department-overview-kpi-bookings"]').text()).toContain("of 4"); + expect(wrapper.get('[data-testid="department-overview-products"]').text()).toContain("Spot Free"); + expect(wrapper.get('[data-testid="department-overview-profile"]').text()).toContain("Economic department"); + expect(wrapper.get('[data-testid="department-overview-hardware"]').text()).toContain("1 / 2"); + }); + + it("keeps the overview visible when the optional hardware summary is unavailable", async () => { + mocks.getEdgeGatewayDepartmentWorkspace.mockRejectedValue(new Error("Forbidden")); + + const wrapper = mountWithApp(DepartmentOverview, { + messages, + }); + + await flushRendering(); + + expect(wrapper.get('[data-testid="department-overview-kpi-revenue"]').text()).toContain("1.234 kr."); + expect(wrapper.find('[data-testid="department-overview-hardware"]').exists()).toBe(false); + }); + + it("uses translated reactive department tabs and pushes the selected route", async () => { + mocks.route.path = "/superuser/departments/1/gateways"; + + const wrapper = mountWithApp(DepartmentNavigation, { messages }); + const activeTab = wrapper.find(".tabs li.is-active"); + + expect(activeTab.text()).toBe("Gateways"); + + const tabs = wrapper.findAll(".tabs li"); + await tabs.find((tab) => tab.text() === "Pricing").trigger("click"); + + expect(mocks.router.push).toHaveBeenCalledWith("/superuser/departments/1/pricing"); + }); +}); From da86b69b2ca630fbbfdaa8aa5940e4509356f356 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 6 Jul 2026 16:02:10 +0200 Subject: [PATCH 15/19] Warn before invoicing multiple months together (#155) Co-authored-by: Jeppe Bundgaard --- .../superuser/tables/InvoiceOrderTable.vue | 48 +++-- .../Objects/CollectedOrderInvoices.vue | 4 + src/i18n/generated/da-v2.json | 9 + src/i18n/generated/de-v2.json | 9 + src/i18n/generated/en-v2.json | 9 + src/i18n/generated/global-v2.json | 9 + src/i18n/generated/no-v2.json | 9 + src/i18n/generated/sv-v2.json | 9 + .../multi_month_invoice_warning.json | 15 ++ .../multi_month_invoice_warning.json | 15 ++ .../multi_month_invoice_warning.json | 15 ++ .../multi_month_invoice_warning.json | 13 ++ .../multi_month_invoice_warning.json | 15 ++ .../multi_month_invoice_warning.json | 15 ++ src/services/invoiceMonthSplitWarning.js | 139 +++++++++++++ .../views/InvoicingBillingPeriodViewAll.vue | 40 +++- tests/e2e/invoicing-period.smoke.spec.js | 105 ++++++++++ .../unit/invoice-month-split-warning.spec.js | 123 ++++++++++++ ...ce-order-table-multi-month-warning.spec.js | 170 ++++++++++++++++ ...oicing-period-queue-state.behavior.spec.js | 185 +++++++++++++++++- 20 files changed, 931 insertions(+), 25 deletions(-) create mode 100644 src/i18n/source/da/phrases/compat/invoicing_period/multi_month_invoice_warning.json create mode 100644 src/i18n/source/de/phrases/compat/invoicing_period/multi_month_invoice_warning.json create mode 100644 src/i18n/source/en/phrases/compat/invoicing_period/multi_month_invoice_warning.json create mode 100644 src/i18n/source/global/shared/invoicing_period/multi_month_invoice_warning.json create mode 100644 src/i18n/source/no/phrases/compat/invoicing_period/multi_month_invoice_warning.json create mode 100644 src/i18n/source/sv/phrases/compat/invoicing_period/multi_month_invoice_warning.json create mode 100644 src/services/invoiceMonthSplitWarning.js create mode 100644 tests/unit/invoice-month-split-warning.spec.js create mode 100644 tests/unit/invoice-order-table-multi-month-warning.spec.js diff --git a/src/components/displays/superuser/tables/InvoiceOrderTable.vue b/src/components/displays/superuser/tables/InvoiceOrderTable.vue index 0e96125c..ff3ed956 100644 --- a/src/components/displays/superuser/tables/InvoiceOrderTable.vue +++ b/src/components/displays/superuser/tables/InvoiceOrderTable.vue @@ -12,6 +12,11 @@ import UserOtherVaskeabonnement import Swal from "sweetalert2"; import OrderItemsTable from "@/components/displays/department/pos/order/orderItemsTable.vue"; import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue"; +import { + buildMultiMonthInvoiceContext, + MULTI_MONTH_INVOICE_ACTION, + promptMultiMonthInvoiceWarning, +} from "@/services/invoiceMonthSplitWarning.js"; const props = defineProps({ orders: { @@ -126,32 +131,34 @@ const isAnyOrderSelected = () => { return selectedInvoiceCollections.value.length > 0; } +const getSelectedOrders = () => { + return props.orders.filter((order) => selectedInvoiceCollections.value.includes(order.invoice_collection_id)); +} + /** Invoice collections */ const onInvoiceCollections = async () => { // Check if any orders are selected if (selectedInvoiceCollections.value.length === 0) { return; } - /** Create the invoice */ - const onCreateInvoiceDraft = async () => { - // Create the invoice - console.log('Create invoice'); - await SessionUser.objects.collectedOrderInvoices.functions.economic.invoice(parseInt(props.collectedOrderInvoice.id)).then((response) => { - console.log('Invoice created successfully', response); - Swal.fire({ - title: 'Fakturaen er oprettet', - text: 'Fakturaen er oprettet i E-conomic', - icon: 'success', - showConfirmButton: false, - timer: 2000 - }).then(() => { - location.reload(); - }); - }).catch((error) => { - console.log('Error creating invoice', error); - } - ) + const selectedOrders = getSelectedOrders(); + const invoiceWarningContext = buildMultiMonthInvoiceContext(selectedOrders, { + getDate: (order) => order?.created_at ?? order?.date, + getInvoiceCollectionId: (order) => order?.invoice_collection_id, + }); + const invoiceWarningAction = await promptMultiMonthInvoiceWarning({ + context: invoiceWarningContext, + splitByMonth: SessionUser.objects.collectedOrderInvoices.functions.split_by_month, + parseErrorMessage: SessionUser.functions.parseErrorMessage, + }); + if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.SPLIT) { + location.reload(); + return; } + if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.CANCEL) { + return; + } + for (let i = 0; i < selectedInvoiceCollections.value.length; i++) { const selectedInvoiceCollectionId = selectedInvoiceCollections.value[i]; // Check if the invoice collection is already booked @@ -210,6 +217,7 @@ const isOrderContentVisible = (order) => { class="button is-small" @click="onInvoiceCollections()" :disabled="!isAnyOrderSelected()" + data-testid="invoice-order-table-invoice-button" > {{ $t('global.invoice_now') }} @@ -274,4 +282,4 @@ const isOrderContentVisible = (order) => { \ No newline at end of file + diff --git a/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue b/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue index c81b500c..dcafa150 100644 --- a/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue +++ b/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue @@ -444,10 +444,14 @@ export const CollectedOrderInvoices = { }); }, split_by_month: async (dateFrom, dateTo, options = {}) => { + const invoiceCollectionIds = Array.isArray(options.invoiceCollectionIds) + ? options.invoiceCollectionIds + : options.invoice_collection_ids; return authenticatedRequest('/collected-invoices/split-by-month', 'POST', { dateFrom, dateTo, ...(options.preview !== undefined ? { preview: !!options.preview } : {}), + ...(Array.isArray(invoiceCollectionIds) ? { invoice_collection_ids: invoiceCollectionIds } : {}), }).then((response) => { console.log(response); return response; diff --git a/src/i18n/generated/da-v2.json b/src/i18n/generated/da-v2.json index cfc6d9d4..795b85ff 100644 --- a/src/i18n/generated/da-v2.json +++ b/src/i18n/generated/da-v2.json @@ -4043,6 +4043,15 @@ "preview_title": "@:{'words.generated.forhandsvis'} @:{'words.generated.manedsopdeling'}", "success_text": "@:{'words.generated.behandlede'} {processed} @:{'words.generated.fakturasamlinger'}. Opdelte {changed} @:{'words.generated.og'} sprang {skipped} @:{'words.generated.over'}.", "success_title": "@.capitalize:{'words.generated.manedsopdeling'} @:{'words.generated.fuldført'}" + }, + "multi_month_invoice_warning": { + "invoice_together": "Fakturer sammen", + "split_by_month": "Opdel efter måned", + "split_error_title": "Månedsopdeling mislykkedes", + "split_success_text": "Behandlede {processed} fakturasamlinger. Opdelte {changed} og sprang {skipped} over.", + "split_success_title": "Månedsopdeling fuldført", + "text": "Du er ved at fakturere ordrer fra flere måneder sammen ({months}). Skal de i stedet opdeles efter måned?", + "title": "Ordrer fra flere måneder" } }, "invoicing": { diff --git a/src/i18n/generated/de-v2.json b/src/i18n/generated/de-v2.json index 8936ff7f..2d4d7964 100644 --- a/src/i18n/generated/de-v2.json +++ b/src/i18n/generated/de-v2.json @@ -4147,6 +4147,15 @@ "preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}", "success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.", "success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}" + }, + "multi_month_invoice_warning": { + "invoice_together": "Zusammen abrechnen", + "split_by_month": "Nach Monat aufteilen", + "split_error_title": "Monatsaufteilung fehlgeschlagen", + "split_success_text": "{processed} Rechnungssammlungen verarbeitet. {changed} aufgeteilt, {skipped} übersprungen.", + "split_success_title": "Monatsaufteilung abgeschlossen", + "text": "Sie sind dabei, Aufträge aus mehreren Monaten gemeinsam abzurechnen ({months}). Sollen sie stattdessen nach Monat aufgeteilt werden?", + "title": "Aufträge aus mehreren Monaten" } }, "invoicing": { diff --git a/src/i18n/generated/en-v2.json b/src/i18n/generated/en-v2.json index 3809e6ae..3ea6b944 100644 --- a/src/i18n/generated/en-v2.json +++ b/src/i18n/generated/en-v2.json @@ -3875,6 +3875,15 @@ "preview_title": "@.capitalize:{'words.generated.preview'} @:{'words.generated.monthly'} @:{'words.generated.split'}", "success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.", "success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}" + }, + "multi_month_invoice_warning": { + "invoice_together": "Invoice together", + "split_by_month": "Split by month", + "split_error_title": "Monthly split failed", + "split_success_text": "Processed {processed} invoice collections. Split {changed}, skipped {skipped}.", + "split_success_title": "Monthly split completed", + "text": "You are about to invoice orders from multiple months together ({months}). Should they be split by month instead?", + "title": "Orders from multiple months" } }, "invoicing": { diff --git a/src/i18n/generated/global-v2.json b/src/i18n/generated/global-v2.json index 0ca3304c..2657f84b 100644 --- a/src/i18n/generated/global-v2.json +++ b/src/i18n/generated/global-v2.json @@ -3156,6 +3156,15 @@ "preview_title": "@:{'templates.generated.compat.invoicing_period.monthly_split.preview_title'}", "success_text": "@:{'templates.generated.compat.invoicing_period.monthly_split.success_text'}", "success_title": "@:{'templates.generated.compat.invoicing_period.monthly_split.success_title'}" + }, + "multi_month_invoice_warning": { + "invoice_together": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.invoice_together'}", + "split_by_month": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_by_month'}", + "split_error_title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_error_title'}", + "split_success_text": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_success_text'}", + "split_success_title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_success_title'}", + "text": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.text'}", + "title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.title'}" } }, "invoicing": { diff --git a/src/i18n/generated/no-v2.json b/src/i18n/generated/no-v2.json index 25d94a03..af65bd69 100644 --- a/src/i18n/generated/no-v2.json +++ b/src/i18n/generated/no-v2.json @@ -4150,6 +4150,15 @@ "preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}", "success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.", "success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}" + }, + "multi_month_invoice_warning": { + "invoice_together": "Fakturer samlet", + "split_by_month": "Del opp etter måned", + "split_error_title": "Månedsdeling mislyktes", + "split_success_text": "Behandlet {processed} fakturasamlinger. Delte opp {changed}, hoppet over {skipped}.", + "split_success_title": "Månedsdeling fullført", + "text": "Du er i ferd med å fakturere ordrer fra flere måneder samlet ({months}). Skal de i stedet deles opp etter måned?", + "title": "Ordrer fra flere måneder" } }, "invoicing": { diff --git a/src/i18n/generated/sv-v2.json b/src/i18n/generated/sv-v2.json index f24912ea..ab8e4f12 100644 --- a/src/i18n/generated/sv-v2.json +++ b/src/i18n/generated/sv-v2.json @@ -4200,6 +4200,15 @@ "preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}", "success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.", "success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}" + }, + "multi_month_invoice_warning": { + "invoice_together": "Fakturera tillsammans", + "split_by_month": "Dela upp per månad", + "split_error_title": "Månadsuppdelning misslyckades", + "split_success_text": "Bearbetade {processed} fakturasamlingar. Delade upp {changed}, hoppade över {skipped}.", + "split_success_title": "Månadsuppdelning klar", + "text": "Du håller på att fakturera ordrar från flera månader tillsammans ({months}). Ska de delas upp per månad i stället?", + "title": "Ordrar från flera månader" } }, "invoicing": { diff --git a/src/i18n/source/da/phrases/compat/invoicing_period/multi_month_invoice_warning.json b/src/i18n/source/da/phrases/compat/invoicing_period/multi_month_invoice_warning.json new file mode 100644 index 00000000..9807d0df --- /dev/null +++ b/src/i18n/source/da/phrases/compat/invoicing_period/multi_month_invoice_warning.json @@ -0,0 +1,15 @@ +{ + "compat": { + "invoicing_period": { + "multi_month_invoice_warning": { + "invoice_together": "Fakturer sammen", + "split_by_month": "Opdel efter måned", + "split_error_title": "Månedsopdeling mislykkedes", + "split_success_text": "Behandlede {processed} fakturasamlinger. Opdelte {changed} og sprang {skipped} over.", + "split_success_title": "Månedsopdeling fuldført", + "text": "Du er ved at fakturere ordrer fra flere måneder sammen ({months}). Skal de i stedet opdeles efter måned?", + "title": "Ordrer fra flere måneder" + } + } + } +} diff --git a/src/i18n/source/de/phrases/compat/invoicing_period/multi_month_invoice_warning.json b/src/i18n/source/de/phrases/compat/invoicing_period/multi_month_invoice_warning.json new file mode 100644 index 00000000..d6b70119 --- /dev/null +++ b/src/i18n/source/de/phrases/compat/invoicing_period/multi_month_invoice_warning.json @@ -0,0 +1,15 @@ +{ + "compat": { + "invoicing_period": { + "multi_month_invoice_warning": { + "invoice_together": "Zusammen abrechnen", + "split_by_month": "Nach Monat aufteilen", + "split_error_title": "Monatsaufteilung fehlgeschlagen", + "split_success_text": "{processed} Rechnungssammlungen verarbeitet. {changed} aufgeteilt, {skipped} übersprungen.", + "split_success_title": "Monatsaufteilung abgeschlossen", + "text": "Sie sind dabei, Aufträge aus mehreren Monaten gemeinsam abzurechnen ({months}). Sollen sie stattdessen nach Monat aufgeteilt werden?", + "title": "Aufträge aus mehreren Monaten" + } + } + } +} diff --git a/src/i18n/source/en/phrases/compat/invoicing_period/multi_month_invoice_warning.json b/src/i18n/source/en/phrases/compat/invoicing_period/multi_month_invoice_warning.json new file mode 100644 index 00000000..91dfaa7f --- /dev/null +++ b/src/i18n/source/en/phrases/compat/invoicing_period/multi_month_invoice_warning.json @@ -0,0 +1,15 @@ +{ + "compat": { + "invoicing_period": { + "multi_month_invoice_warning": { + "invoice_together": "Invoice together", + "split_by_month": "Split by month", + "split_error_title": "Monthly split failed", + "split_success_text": "Processed {processed} invoice collections. Split {changed}, skipped {skipped}.", + "split_success_title": "Monthly split completed", + "text": "You are about to invoice orders from multiple months together ({months}). Should they be split by month instead?", + "title": "Orders from multiple months" + } + } + } +} diff --git a/src/i18n/source/global/shared/invoicing_period/multi_month_invoice_warning.json b/src/i18n/source/global/shared/invoicing_period/multi_month_invoice_warning.json new file mode 100644 index 00000000..7c24323e --- /dev/null +++ b/src/i18n/source/global/shared/invoicing_period/multi_month_invoice_warning.json @@ -0,0 +1,13 @@ +{ + "invoicing_period": { + "multi_month_invoice_warning": { + "invoice_together": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.invoice_together'}", + "split_by_month": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_by_month'}", + "split_error_title": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_error_title'}", + "split_success_text": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_success_text'}", + "split_success_title": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_success_title'}", + "text": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.text'}", + "title": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.title'}" + } + } +} diff --git a/src/i18n/source/no/phrases/compat/invoicing_period/multi_month_invoice_warning.json b/src/i18n/source/no/phrases/compat/invoicing_period/multi_month_invoice_warning.json new file mode 100644 index 00000000..fea6eb4d --- /dev/null +++ b/src/i18n/source/no/phrases/compat/invoicing_period/multi_month_invoice_warning.json @@ -0,0 +1,15 @@ +{ + "compat": { + "invoicing_period": { + "multi_month_invoice_warning": { + "invoice_together": "Fakturer samlet", + "split_by_month": "Del opp etter måned", + "split_error_title": "Månedsdeling mislyktes", + "split_success_text": "Behandlet {processed} fakturasamlinger. Delte opp {changed}, hoppet over {skipped}.", + "split_success_title": "Månedsdeling fullført", + "text": "Du er i ferd med å fakturere ordrer fra flere måneder samlet ({months}). Skal de i stedet deles opp etter måned?", + "title": "Ordrer fra flere måneder" + } + } + } +} diff --git a/src/i18n/source/sv/phrases/compat/invoicing_period/multi_month_invoice_warning.json b/src/i18n/source/sv/phrases/compat/invoicing_period/multi_month_invoice_warning.json new file mode 100644 index 00000000..b8056782 --- /dev/null +++ b/src/i18n/source/sv/phrases/compat/invoicing_period/multi_month_invoice_warning.json @@ -0,0 +1,15 @@ +{ + "compat": { + "invoicing_period": { + "multi_month_invoice_warning": { + "invoice_together": "Fakturera tillsammans", + "split_by_month": "Dela upp per månad", + "split_error_title": "Månadsuppdelning misslyckades", + "split_success_text": "Bearbetade {processed} fakturasamlingar. Delade upp {changed}, hoppade över {skipped}.", + "split_success_title": "Månadsuppdelning klar", + "text": "Du håller på att fakturera ordrar från flera månader tillsammans ({months}). Ska de delas upp per månad i stället?", + "title": "Ordrar från flera månader" + } + } + } +} diff --git a/src/services/invoiceMonthSplitWarning.js b/src/services/invoiceMonthSplitWarning.js new file mode 100644 index 00000000..95182bb9 --- /dev/null +++ b/src/services/invoiceMonthSplitWarning.js @@ -0,0 +1,139 @@ +import Swal from "sweetalert2"; +import i18n from "@/i18n"; + +export const MULTI_MONTH_INVOICE_ACTION = { + CONTINUE: "continue", + CANCEL: "cancel", + SPLIT: "split", +}; + +const toPositiveInteger = (value) => { + const parsed = Number.parseInt(String(value ?? ""), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +}; + +const getDateKey = (value) => { + const rawValue = String(value ?? "").trim(); + const directMatch = rawValue.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (directMatch) { + return `${directMatch[1]}-${directMatch[2]}-${directMatch[3]}`; + } + + const parsedDate = new Date(rawValue); + if (Number.isNaN(parsedDate.getTime())) { + return null; + } + + const year = parsedDate.getFullYear(); + const month = String(parsedDate.getMonth() + 1).padStart(2, "0"); + const day = String(parsedDate.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +export const getMonthKey = (value) => { + const dateKey = getDateKey(value); + return dateKey ? dateKey.slice(0, 7) : null; +}; + +const translate = (key, params = {}) => i18n.global.t(key, params); + +const getSplitResponsePayload = (response = {}) => response?.data?.data ?? response?.data ?? {}; + +export const buildMultiMonthInvoiceContext = ( + items = [], + { + getDate = (item) => item?.created_at ?? item?.date, + getInvoiceCollectionId = (item) => item?.invoice_collection_id, + } = {} +) => { + const months = new Set(); + const dateKeys = []; + const invoiceCollectionIds = new Set(); + + (Array.isArray(items) ? items : []).forEach((item) => { + const dateValue = getDate(item); + const dateKey = getDateKey(dateValue); + if (dateKey) { + dateKeys.push(dateKey); + months.add(dateKey.slice(0, 7)); + } + + const invoiceCollectionId = toPositiveInteger(getInvoiceCollectionId(item)); + if (invoiceCollectionId) { + invoiceCollectionIds.add(invoiceCollectionId); + } + }); + + dateKeys.sort(); + + return { + months: Array.from(months).sort(), + dateFrom: dateKeys[0] ?? null, + dateTo: dateKeys[dateKeys.length - 1] ?? null, + invoiceCollectionIds: Array.from(invoiceCollectionIds).sort((left, right) => left - right), + }; +}; + +export const shouldWarnAboutMultiMonthInvoice = (context = {}) => ( + Array.isArray(context.months) && + context.months.length > 1 && + Array.isArray(context.invoiceCollectionIds) && + context.invoiceCollectionIds.length > 0 && + Boolean(context.dateFrom) && + Boolean(context.dateTo) +); + +export const promptMultiMonthInvoiceWarning = async ({ + context, + splitByMonth, + parseErrorMessage = (error) => error?.message ?? String(error), +} = {}) => { + if (!shouldWarnAboutMultiMonthInvoice(context)) { + return MULTI_MONTH_INVOICE_ACTION.CONTINUE; + } + + const months = context.months.join(", "); + const confirmation = await Swal.fire({ + icon: "warning", + title: translate("invoicing_period.multi_month_invoice_warning.title"), + text: translate("invoicing_period.multi_month_invoice_warning.text", { months }), + showCancelButton: true, + showDenyButton: true, + confirmButtonText: translate("invoicing_period.multi_month_invoice_warning.split_by_month"), + denyButtonText: translate("invoicing_period.multi_month_invoice_warning.invoice_together"), + cancelButtonText: translate("common.cancel"), + }); + + if (confirmation.isDenied) { + return MULTI_MONTH_INVOICE_ACTION.CONTINUE; + } + + if (!confirmation.isConfirmed) { + return MULTI_MONTH_INVOICE_ACTION.CANCEL; + } + + try { + const response = await splitByMonth(context.dateFrom, context.dateTo, { + invoiceCollectionIds: context.invoiceCollectionIds, + preview: false, + }); + const result = getSplitResponsePayload(response); + await Swal.fire({ + icon: "success", + title: translate("invoicing_period.multi_month_invoice_warning.split_success_title"), + text: translate("invoicing_period.multi_month_invoice_warning.split_success_text", { + processed: result.processed_count ?? 0, + changed: result.changed_count ?? 0, + skipped: result.skipped_count ?? 0, + }), + }); + return MULTI_MONTH_INVOICE_ACTION.SPLIT; + } catch (error) { + await Swal.fire({ + icon: "error", + title: translate("invoicing_period.multi_month_invoice_warning.split_error_title"), + text: parseErrorMessage(error), + }); + return MULTI_MONTH_INVOICE_ACTION.CANCEL; + } +}; diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue index dfaeb399..537d62d6 100644 --- a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue +++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue @@ -22,6 +22,11 @@ import InvoicingBillingPeriodFilters from "@/views/dashboards/superUserDashboard import InvoicingBillingPeriodCustomerAttributes from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue"; import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue"; import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue"; +import { + buildMultiMonthInvoiceContext, + MULTI_MONTH_INVOICE_ACTION, + promptMultiMonthInvoiceWarning, +} from "@/services/invoiceMonthSplitWarning.js"; import { buildPossibleDuplicateGroups, formatDuplicateDateLabel, @@ -330,6 +335,13 @@ const parsePositiveInteger = (value: any) => { const getCustomerNumber = (customer: any) => parsePositiveInteger(customer?.customer_number); +const markCustomerPeriodRefreshLoading = (customer: any) => { + const customerNumber = getCustomerNumber(customer); + if (customerNumber) { + invoiceQueue.markPeriodRefreshLoading?.([customerNumber], []); + } +}; + const queueInvoiceCollections = (invoiceCollectionIds: number[], customer: any = null) => { const uniqueInvoiceCollectionIds = Array.from( new Set( @@ -351,12 +363,10 @@ const queueInvoiceCollections = (invoiceCollectionIds: number[], customer: any = const onClickInvoiceNow = async (customer: any, transactionIds: number[]) => { const customerNumber = getCustomerNumber(customer); - if (customerNumber) { - invoiceQueue.markPeriodRefreshLoading?.([customerNumber], []); - } try { if (transactionIds.length === 0) { + markCustomerPeriodRefreshLoading(customer); const month = dates.variables.start.value.getMonth() + 1; const year = dates.variables.start.value.getFullYear(); @@ -392,10 +402,32 @@ const onClickInvoiceNow = async (customer: any, transactionIds: number[]) => { await fetchMissingInvoiceCollections(customer, transactionIds); const invoiceCollectionIds = getInvoiceCollectionIdsForTransactionIds(customer, transactionIds); if (invoiceCollectionIds.length === 0) { - invoiceQueue.finishPeriodRefresh?.([customerNumber], []); return; } + const invoiceWarningContext = buildMultiMonthInvoiceContext( + transactionIds + .map((transactionId) => getTransactionById(customer, transactionId)) + .filter((transaction: any) => transaction !== null), + { + getDate: (transaction: any) => transaction?.date ?? transaction?.created_at, + getInvoiceCollectionId: (transaction: any) => getTransactionInvoiceCollectionId(transaction), + } + ); + const invoiceWarningAction = await promptMultiMonthInvoiceWarning({ + context: invoiceWarningContext, + splitByMonth: SessionUser.objects.collectedOrderInvoices.functions.split_by_month, + parseErrorMessage: SessionUser.functions.parseErrorMessage, + }); + if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.SPLIT) { + reloadPeriodPage(); + return; + } + if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.CANCEL) { + return; + } + + markCustomerPeriodRefreshLoading(customer); queueInvoiceCollections(invoiceCollectionIds, customer); } catch (error: any) { invoiceQueue.finishPeriodRefresh?.([customerNumber], []); diff --git a/tests/e2e/invoicing-period.smoke.spec.js b/tests/e2e/invoicing-period.smoke.spec.js index 6ee85f7f..dd284da3 100644 --- a/tests/e2e/invoicing-period.smoke.spec.js +++ b/tests/e2e/invoicing-period.smoke.spec.js @@ -1933,6 +1933,111 @@ test.describe("Invoicing period tab", () => { 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 = []; diff --git a/tests/unit/invoice-month-split-warning.spec.js b/tests/unit/invoice-month-split-warning.spec.js new file mode 100644 index 00000000..268c3686 --- /dev/null +++ b/tests/unit/invoice-month-split-warning.spec.js @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("sweetalert2", () => ({ + default: { + fire: vi.fn(), + }, +})); + +import Swal from "sweetalert2"; +import { + buildMultiMonthInvoiceContext, + MULTI_MONTH_INVOICE_ACTION, + promptMultiMonthInvoiceWarning, + shouldWarnAboutMultiMonthInvoice, +} from "@/services/invoiceMonthSplitWarning.js"; + +describe("invoice month split warning", () => { + beforeEach(() => { + Swal.fire.mockReset(); + }); + + it("builds a scoped multi-month context without timezone shifting date strings", () => { + const context = buildMultiMonthInvoiceContext([ + { id: 1, created_at: "2026-03-31 23:30:00", invoice_collection_id: 501 }, + { id: 2, created_at: "2026-04-01T00:30:00.000Z", invoice_collection_id: "501" }, + { id: 3, created_at: "2026-04-03 09:00:00", invoice_collection_id: 502 }, + { id: 4, created_at: "invalid", invoice_collection_id: null }, + ]); + + expect(context).toEqual({ + months: ["2026-03", "2026-04"], + dateFrom: "2026-03-31", + dateTo: "2026-04-03", + invoiceCollectionIds: [501, 502], + }); + expect(shouldWarnAboutMultiMonthInvoice(context)).toBe(true); + }); + + it("continues without a modal for single-month selections", async () => { + const action = await promptMultiMonthInvoiceWarning({ + context: buildMultiMonthInvoiceContext([ + { created_at: "2026-04-01 10:00:00", invoice_collection_id: 501 }, + { created_at: "2026-04-02 10:00:00", invoice_collection_id: 501 }, + ]), + splitByMonth: vi.fn(), + }); + + expect(action).toBe(MULTI_MONTH_INVOICE_ACTION.CONTINUE); + expect(Swal.fire).not.toHaveBeenCalled(); + }); + + it("splits selected invoice collections when the warning is confirmed", async () => { + const splitByMonth = vi.fn().mockResolvedValue({ + data: { + data: { + processed_count: 2, + changed_count: 1, + skipped_count: 1, + }, + }, + }); + Swal.fire.mockResolvedValueOnce({ isConfirmed: true }).mockResolvedValueOnce({ isConfirmed: true }); + + const action = await promptMultiMonthInvoiceWarning({ + context: buildMultiMonthInvoiceContext([ + { created_at: "2026-03-20 10:00:00", invoice_collection_id: 7001 }, + { created_at: "2026-04-02 10:00:00", invoice_collection_id: 7001 }, + ]), + splitByMonth, + }); + + expect(action).toBe(MULTI_MONTH_INVOICE_ACTION.SPLIT); + expect(splitByMonth).toHaveBeenCalledWith("2026-03-20", "2026-04-02", { + invoiceCollectionIds: [7001], + preview: false, + }); + expect(Swal.fire).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + icon: "warning", + showDenyButton: true, + }) + ); + expect(Swal.fire).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + icon: "success", + }) + ); + }); + + it("continues invoicing together when the warning deny button is selected", async () => { + const splitByMonth = vi.fn(); + Swal.fire.mockResolvedValueOnce({ isDenied: true }); + + const action = await promptMultiMonthInvoiceWarning({ + context: buildMultiMonthInvoiceContext([ + { created_at: "2026-03-20 10:00:00", invoice_collection_id: 7001 }, + { created_at: "2026-04-02 10:00:00", invoice_collection_id: 7001 }, + ]), + splitByMonth, + }); + + expect(action).toBe(MULTI_MONTH_INVOICE_ACTION.CONTINUE); + expect(splitByMonth).not.toHaveBeenCalled(); + }); + + it("cancels invoicing when the warning is dismissed", async () => { + const splitByMonth = vi.fn(); + Swal.fire.mockResolvedValueOnce({ isDismissed: true }); + + const action = await promptMultiMonthInvoiceWarning({ + context: buildMultiMonthInvoiceContext([ + { created_at: "2026-03-20 10:00:00", invoice_collection_id: 7001 }, + { created_at: "2026-04-02 10:00:00", invoice_collection_id: 7001 }, + ]), + splitByMonth, + }); + + expect(action).toBe(MULTI_MONTH_INVOICE_ACTION.CANCEL); + expect(splitByMonth).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/invoice-order-table-multi-month-warning.spec.js b/tests/unit/invoice-order-table-multi-month-warning.spec.js new file mode 100644 index 00000000..3330c710 --- /dev/null +++ b/tests/unit/invoice-order-table-multi-month-warning.spec.js @@ -0,0 +1,170 @@ +// @vitest-environment jsdom +import { flushPromises, mount } from "@vue/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("vue-i18n", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useI18n: () => ({ + t: (key) => key, + }), + }; +}); + +vi.mock("sweetalert2", () => ({ + default: { + fire: vi.fn(), + }, +})); + +vi.mock("@/components/session/token/SessionUser.vue", () => ({ + SessionUser: { + objects: { + global: { + language: { + hide_content: "Hide", + show_content: "Show", + status: "Status", + completed: "Completed", + not_completed: "Not completed", + }, + }, + orders: { + columns: { + id: { label: "ID", visible: true }, + created_at: { label: "Created", visible: true }, + }, + }, + collectedOrderInvoices: { + functions: { + split_by_month: vi.fn(), + economic: { + invoice: vi.fn(), + }, + }, + }, + vehicles: { + columns: { + wash_subscription: { + label: "Subscription", + }, + }, + }, + }, + functions: { + currency: { + toLocal: (value) => String(value), + }, + parseErrorMessage: (error) => error?.message ?? String(error), + }, + }, +})); + +vi.mock("@/views/dashboards/superUserDashboard/user/displays/other/UserOtherSpecialArrangement.vue", () => ({ + default: { template: "
" }, +})); + +vi.mock("@/views/dashboards/superUserDashboard/user/displays/other/UserOtherVaskeabonnement.vue", () => ({ + default: { template: "
" }, +})); + +vi.mock("@/components/displays/department/pos/order/orderItemsTable.vue", () => ({ + default: { template: "
" }, +})); + +vi.mock("@/components/displays/superuser/tables/OrderContentTable.vue", () => ({ + default: { template: "
" }, +})); + +import Swal from "sweetalert2"; +import InvoiceOrderTable from "@/components/displays/superuser/tables/InvoiceOrderTable.vue"; +import { SessionUser } from "@/components/session/token/SessionUser.vue"; + +const mountTable = (orders) => + mount(InvoiceOrderTable, { + props: { + orders, + options: {}, + columns: {}, + user_id: 42, + }, + global: { + mocks: { + $t: (key) => + ({ + "global.invoice_now": "Invoice now", + "global.unselect": "Unselect", + "common.all": "All", + "common.select": "Select", + }[key] ?? key), + }, + }, + }); + +describe("InvoiceOrderTable multi-month warning", () => { + beforeEach(() => { + Swal.fire.mockReset(); + SessionUser.objects.collectedOrderInvoices.functions.split_by_month.mockReset(); + SessionUser.objects.collectedOrderInvoices.functions.economic.invoice.mockReset(); + SessionUser.objects.collectedOrderInvoices.functions.economic.invoice.mockResolvedValue({}); + }); + + it("warns for selected invoice collections containing orders from multiple months before invoicing together", async () => { + Swal.fire.mockResolvedValueOnce({ isDenied: true }).mockReturnValueOnce(new Promise(() => {})); + const wrapper = mountTable([ + { + id: 1, + invoice_collection_id: 9001, + created_at: "2026-03-15 10:00:00", + }, + { + id: 2, + invoice_collection_id: 9001, + created_at: "2026-04-02 10:00:00", + }, + ]); + + await wrapper.find("tbody input[type='checkbox']").trigger("click"); + await wrapper.get("[data-testid='invoice-order-table-invoice-button']").trigger("click"); + await flushPromises(); + + expect(Swal.fire).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + icon: "warning", + showDenyButton: true, + }) + ); + expect(SessionUser.objects.collectedOrderInvoices.functions.split_by_month).not.toHaveBeenCalled(); + expect(SessionUser.objects.collectedOrderInvoices.functions.economic.invoice).toHaveBeenCalledWith(9001, 42); + }); + + it("does not warn for selected invoice collections containing only one month", async () => { + Swal.fire.mockReturnValueOnce(new Promise(() => {})); + const wrapper = mountTable([ + { + id: 1, + invoice_collection_id: 9002, + created_at: "2026-04-01 10:00:00", + }, + { + id: 2, + invoice_collection_id: 9002, + created_at: "2026-04-02 10:00:00", + }, + ]); + + await wrapper.find("tbody input[type='checkbox']").trigger("click"); + await wrapper.get("[data-testid='invoice-order-table-invoice-button']").trigger("click"); + await flushPromises(); + + expect(Swal.fire).toHaveBeenCalledTimes(1); + expect(Swal.fire).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Fakturaer oprettet", + }) + ); + expect(SessionUser.objects.collectedOrderInvoices.functions.economic.invoice).toHaveBeenCalledWith(9002, 42); + }); +}); diff --git a/tests/unit/invoicing-period-queue-state.behavior.spec.js b/tests/unit/invoicing-period-queue-state.behavior.spec.js index 0e4d2f7f..d208f40e 100644 --- a/tests/unit/invoicing-period-queue-state.behavior.spec.js +++ b/tests/unit/invoicing-period-queue-state.behavior.spec.js @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { computed, nextTick } from "vue"; -import { mount } from "@vue/test-utils"; +import { flushPromises, mount } from "@vue/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { @@ -34,6 +34,12 @@ vi.mock("@/services/economicTransferQueue.js", () => ({ }, })); +vi.mock("sweetalert2", () => ({ + default: { + fire: vi.fn(), + }, +})); + vi.mock("@/components/session/token/SessionUser.vue", () => ({ SessionUser: { objects: { @@ -62,6 +68,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({ createVehicleSubscriptionInvoice: vi.fn(), add_fixed_pricing: vi.fn(), add_vehicle_subscriptions: vi.fn(), + split_by_month: vi.fn(), }, }, vehicles: { @@ -76,6 +83,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({ currency: { toLocal: (value) => String(value), }, + parseErrorMessage: (error) => error?.message ?? String(error), }, }, })); @@ -148,6 +156,7 @@ vi.mock( import InvoicingBillingPeriodViewAll from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { invoiceQueue } from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue"; +import Swal from "sweetalert2"; import { periodPaging, resetPeriodPagingState, @@ -222,11 +231,23 @@ describe("Invoicing period queue state", () => { loadingCustomerNumbersRef.value = []; SessionUser.objects.orders.get.multiple.mockReset(); SessionUser.objects.orders.get.multiple.mockResolvedValue([]); + SessionUser.objects.collectedOrderInvoices.functions.split_by_month.mockReset(); + SessionUser.objects.collectedOrderInvoices.functions.split_by_month.mockResolvedValue({ + data: { + data: { + processed_count: 1, + changed_count: 1, + skipped_count: 0, + }, + }, + }); invoiceQueue.addInvoiceCollectionsToQueue.mockReset(); invoiceQueue.processInvoiceCollectionQueue.mockReset(); invoiceQueue.markPeriodRefreshLoading.mockClear(); invoiceQueue.finishPeriodRefresh.mockClear(); invoiceQueue.isPeriodCustomerRefreshLoading.mockClear(); + Swal.fire.mockReset(); + Swal.fire.mockResolvedValue({ isDenied: true }); resetPeriodPagingState(); sharedVariablesRef.value = { types: { @@ -644,6 +665,168 @@ describe("Invoicing period queue state", () => { expect(windowOpenSpy).not.toHaveBeenCalled(); }); + it("splits instead of queueing when multi-month invoicing warning is confirmed", async () => { + sharedVariablesRef.value = { + types: { + all: [ + { + id: 8, + customer_number: 1008, + customer_name: "Multi Month Customer", + requires_action: true, + queue: { + has_active_job: false, + statuses: [], + invoice_collection_ids: [], + is_action_blocked: false, + }, + transactions: [ + { + id: 8101, + amount: 75, + booked: false, + excluded: false, + invoice_collection_id: 8100, + date: "2026-03-28T10:00:00.000Z", + }, + { + id: 8102, + amount: 125, + booked: false, + excluded: false, + invoice_collection_id: 8100, + date: "2026-04-02T10:00:00.000Z", + }, + ], + }, + ], + }, + }; + Swal.fire.mockResolvedValueOnce({ isConfirmed: true }).mockResolvedValueOnce({ isConfirmed: true }); + + const wrapper = mountView(); + await nextTick(); + + await wrapper.get("[data-testid='invoicing-period-customer-invoice-1008']").trigger("click"); + await flushPromises(); + await nextTick(); + + expect(SessionUser.objects.collectedOrderInvoices.functions.split_by_month).toHaveBeenCalledWith( + "2026-03-28", + "2026-04-02", + { + invoiceCollectionIds: [8100], + preview: false, + } + ); + expect(invoiceQueue.addInvoiceCollectionsToQueue).not.toHaveBeenCalled(); + expect(invoiceQueue.processInvoiceCollectionQueue).not.toHaveBeenCalled(); + }); + + it("continues queueing together when multi-month invoicing warning is denied", async () => { + sharedVariablesRef.value = { + types: { + all: [ + { + id: 9, + customer_number: 1009, + customer_name: "Invoice Together Customer", + requires_action: true, + queue: { + has_active_job: false, + statuses: [], + invoice_collection_ids: [], + is_action_blocked: false, + }, + transactions: [ + { + id: 8201, + amount: 75, + booked: false, + excluded: false, + invoice_collection_id: 8200, + date: "2026-03-28T10:00:00.000Z", + }, + { + id: 8202, + amount: 125, + booked: false, + excluded: false, + invoice_collection_id: 8200, + date: "2026-04-02T10:00:00.000Z", + }, + ], + }, + ], + }, + }; + Swal.fire.mockResolvedValueOnce({ isDenied: true }); + + const wrapper = mountView(); + await nextTick(); + + await wrapper.get("[data-testid='invoicing-period-customer-invoice-1009']").trigger("click"); + await flushPromises(); + await nextTick(); + + expect(SessionUser.objects.collectedOrderInvoices.functions.split_by_month).not.toHaveBeenCalled(); + expect(invoiceQueue.addInvoiceCollectionsToQueue).toHaveBeenCalledWith([8200], { + customerNumber: 1009, + }); + expect(invoiceQueue.processInvoiceCollectionQueue).toHaveBeenCalledTimes(1); + }); + + it("does not queue when multi-month invoicing warning is dismissed", async () => { + sharedVariablesRef.value = { + types: { + all: [ + { + id: 10, + customer_number: 1010, + customer_name: "Cancel Multi Month Customer", + requires_action: true, + queue: { + has_active_job: false, + statuses: [], + invoice_collection_ids: [], + is_action_blocked: false, + }, + transactions: [ + { + id: 8301, + amount: 75, + booked: false, + excluded: false, + invoice_collection_id: 8300, + date: "2026-03-28T10:00:00.000Z", + }, + { + id: 8302, + amount: 125, + booked: false, + excluded: false, + invoice_collection_id: 8300, + date: "2026-04-02T10:00:00.000Z", + }, + ], + }, + ], + }, + }; + Swal.fire.mockResolvedValueOnce({ isDismissed: true }); + + const wrapper = mountView(); + await nextTick(); + + await wrapper.get("[data-testid='invoicing-period-customer-invoice-1010']").trigger("click"); + await flushPromises(); + await nextTick(); + + expect(SessionUser.objects.collectedOrderInvoices.functions.split_by_month).not.toHaveBeenCalled(); + expect(invoiceQueue.addInvoiceCollectionsToQueue).not.toHaveBeenCalled(); + expect(invoiceQueue.processInvoiceCollectionQueue).not.toHaveBeenCalled(); + }); + it("applies invoice-now loading only to the affected customer", async () => { sharedVariablesRef.value = { types: { From d8d67301ed97b72b65256b1ed723e5cb5c7b8fa5 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 6 Jul 2026 16:26:46 +0200 Subject: [PATCH 16/19] Add autosave for limited backoffice prices (#143) --- scripts/playwright-pr-mapping.mjs | 6 + src/i18n/generated/da-v2.json | 6 + src/i18n/generated/de-v2.json | 6 + src/i18n/generated/en-v2.json | 6 + src/i18n/generated/no-v2.json | 6 + src/i18n/generated/sv-v2.json | 6 + src/i18n/source/da/phrases/index.json | 20 +- src/i18n/source/de/phrases/index.json | 20 +- src/i18n/source/en/phrases/index.json | 20 +- src/i18n/source/no/phrases/index.json | 20 +- src/i18n/source/sv/phrases/index.json | 20 +- .../backoffice/LimitedBackofficeHome.vue | 5 + .../backoffice/LimitedBackofficePrices.vue | 287 +++++++++++++++--- .../components/LimitedBackofficeLayout.vue | 5 +- .../InvoiceDistributionMonthView.vue | 12 +- .../InvoiceDistributionOverview.vue | 3 +- .../userDashboard/wash/MyWashStart.vue | 24 +- tests/e2e/invoice-distribution.smoke.spec.js | 29 +- tests/e2e/limited-backoffice.spec.ts | 184 ++++++++++- tests/e2e/support/network.js | 7 +- tests/unit/playwright-pr-mapping.spec.js | 9 + 21 files changed, 583 insertions(+), 118 deletions(-) diff --git a/scripts/playwright-pr-mapping.mjs b/scripts/playwright-pr-mapping.mjs index 39b9819d..a125ed3d 100644 --- a/scripts/playwright-pr-mapping.mjs +++ b/scripts/playwright-pr-mapping.mjs @@ -28,6 +28,12 @@ export const sourceMappings = [ specs: ["tests/e2e/navigation.smoke.spec.js"], projects: chromiumProjects, }, + { + name: "backoffice", + patterns: [/^src\/views\/backoffice\//u], + specs: ["tests/e2e/limited-backoffice.spec.ts"], + projects: chromiumProjects, + }, { name: "superuser-roles-permissions", patterns: [ diff --git a/src/i18n/generated/da-v2.json b/src/i18n/generated/da-v2.json index 795b85ff..1f4e5374 100644 --- a/src/i18n/generated/da-v2.json +++ b/src/i18n/generated/da-v2.json @@ -1176,6 +1176,9 @@ "title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}", "save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}", "saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}", + "autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}", + "autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}", + "autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}", "product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}", "price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}", "price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}", @@ -6035,6 +6038,9 @@ "title": "@.capitalize:{'words.generated.afdelingspriser'}", "save": "@:{'words.generated.gem'} @:{'words.generated.priser'}", "saved": "Priserne @:{'words.generated.er'} @:{'words.generated.gemt'}.", + "autosave_saving": "Gemmer priser...", + "autosave_pending": "Prisændringer afventer.", + "autosave_saved": "Alle prisændringer er gemt.", "product": "Produkt", "price": "Afdelingspris", "price_required": "@.capitalize:{'words.generated.alle'} @:{'words.generated.produkter'} @:{'words.generated.skal'} have @:{'words.replication.host_definite_suffix'} @:{'words.generated.ikke'}-negativ numerisk pris.", diff --git a/src/i18n/generated/de-v2.json b/src/i18n/generated/de-v2.json index 2d4d7964..b70c2ebd 100644 --- a/src/i18n/generated/de-v2.json +++ b/src/i18n/generated/de-v2.json @@ -1287,6 +1287,9 @@ "title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}", "save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}", "saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}", + "autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}", + "autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}", + "autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}", "product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}", "price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}", "price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}", @@ -6124,6 +6127,9 @@ "title": "@.capitalize:{'words.generated.abteilungspreise'}", "save": "@:{'words.generated.preise'} @:{'words.generated.speichern'}", "saved": "@:{'words.generated.preise'} @:{'words.generated.gespeichert'}.", + "autosave_saving": "Preise werden gespeichert...", + "autosave_pending": "Preisaenderungen ausstehend.", + "autosave_saved": "Alle Preisaenderungen gespeichert.", "product": "@.capitalize:{'words.generated.produkt'}", "price": "Abteilungspreis", "price_required": "Jedes @.capitalize:{'words.generated.produkt'} @:{'words.generated.muss'} einen @:{'words.generated.nicht'}-negativen numerischen Preis @:{'words.generated.haben'}.", diff --git a/src/i18n/generated/en-v2.json b/src/i18n/generated/en-v2.json index 3ea6b944..9fd28b4e 100644 --- a/src/i18n/generated/en-v2.json +++ b/src/i18n/generated/en-v2.json @@ -1008,6 +1008,9 @@ "title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}", "save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}", "saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}", + "autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}", + "autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}", + "autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}", "product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}", "price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}", "price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}", @@ -5867,6 +5870,9 @@ "title": "@.capitalize:{'words.generated.department'} @:{'words.generated.prices'}", "save": "@.capitalize:{'words.generated.save'} @:{'words.generated.prices'}", "saved": "@.capitalize:{'words.generated.prices'} @:{'words.generated.saved'}.", + "autosave_saving": "Saving prices...", + "autosave_pending": "Price changes pending.", + "autosave_saved": "All price changes saved.", "product": "@.capitalize:{'words.generated.product'}", "price": "@.capitalize:{'words.generated.department'} @:{'words.generated.price'}", "price_required": "Every @:{'words.generated.product'} @:{'words.generated.must'} @:{'words.generated.have'} @:{'words.generated.a'} non-negative numeric @:{'words.generated.price'}.", diff --git a/src/i18n/generated/no-v2.json b/src/i18n/generated/no-v2.json index af65bd69..03312126 100644 --- a/src/i18n/generated/no-v2.json +++ b/src/i18n/generated/no-v2.json @@ -1290,6 +1290,9 @@ "title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}", "save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}", "saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}", + "autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}", + "autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}", + "autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}", "product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}", "price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}", "price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}", @@ -6127,6 +6130,9 @@ "title": "@.capitalize:{'words.generated.avdelingspriser'}", "save": "@.capitalize:{'words.generated.lagre'} @:{'words.generated.priser'}", "saved": "Prisene @:{'words.generated.er'} @:{'words.generated.lagret'}.", + "autosave_saving": "Lagrer priser...", + "autosave_pending": "Prisendringer venter.", + "autosave_saved": "Alle prisendringer er lagret.", "product": "Produkt", "price": "Avdelingspris", "price_required": "@.capitalize:{'words.generated.alle'} @:{'words.generated.produkter'} @:{'words.generated.ma'} ha @:{'words.replication.host_definite_suffix'} @:{'words.generated.ikke'}-negativ numerisk pris.", diff --git a/src/i18n/generated/sv-v2.json b/src/i18n/generated/sv-v2.json index ab8e4f12..5156b6be 100644 --- a/src/i18n/generated/sv-v2.json +++ b/src/i18n/generated/sv-v2.json @@ -1340,6 +1340,9 @@ "title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}", "save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}", "saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}", + "autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}", + "autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}", + "autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}", "product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}", "price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}", "price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}", @@ -6177,6 +6180,9 @@ "title": "@.capitalize:{'words.generated.avdelningspriser'}", "save": "@.capitalize:{'words.generated.spara'} @:{'words.generated.priser'}", "saved": "Priserna @:{'words.generated.har'} @:{'words.generated.sparats'}.", + "autosave_saving": "Sparar priser...", + "autosave_pending": "Prisandringar vantar.", + "autosave_saved": "Alla prisandringar har sparats.", "product": "Produkt", "price": "Avdelningspris", "price_required": "@.capitalize:{'words.generated.alla'} @:{'words.generated.produkter'} @:{'words.generated.maste'} ha ett icke-negativt numeriskt pris.", diff --git a/src/i18n/source/da/phrases/index.json b/src/i18n/source/da/phrases/index.json index 7b5bd811..eb28456d 100644 --- a/src/i18n/source/da/phrases/index.json +++ b/src/i18n/source/da/phrases/index.json @@ -28,13 +28,16 @@ "title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}", "department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}" }, - "prices": { - "title": "@:{'phrases.compat.limited_backoffice.prices.title'}", - "save": "@:{'phrases.compat.limited_backoffice.prices.save'}", - "saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}", - "product": "@:{'phrases.compat.limited_backoffice.prices.product'}", - "price": "@:{'phrases.compat.limited_backoffice.prices.price'}", - "price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}", + "prices": { + "title": "@:{'phrases.compat.limited_backoffice.prices.title'}", + "save": "@:{'phrases.compat.limited_backoffice.prices.save'}", + "saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}", + "autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}", + "autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}", + "autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}", + "product": "@:{'phrases.compat.limited_backoffice.prices.product'}", + "price": "@:{'phrases.compat.limited_backoffice.prices.price'}", + "price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}", "setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}", "setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}", "no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}" @@ -273,6 +276,9 @@ "title": "@.capitalize:{'terms.glossary.afdelingspriser'}", "save": "@:{'terms.glossary.gem'} @:{'terms.glossary.priser'}", "saved": "Priserne @:{'terms.glossary.er'} @:{'terms.glossary.gemt'}.", + "autosave_saving": "Gemmer priser...", + "autosave_pending": "Prisændringer afventer.", + "autosave_saved": "Alle prisændringer er gemt.", "product": "Produkt", "price": "Afdelingspris", "price_required": "@.capitalize:{'terms.glossary.alle'} @:{'terms.glossary.produkter'} @:{'terms.glossary.skal'} have @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.ikke'}-negativ numerisk pris.", diff --git a/src/i18n/source/de/phrases/index.json b/src/i18n/source/de/phrases/index.json index 5868636c..ea3984e4 100644 --- a/src/i18n/source/de/phrases/index.json +++ b/src/i18n/source/de/phrases/index.json @@ -28,13 +28,16 @@ "title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}", "department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}" }, - "prices": { - "title": "@:{'phrases.compat.limited_backoffice.prices.title'}", - "save": "@:{'phrases.compat.limited_backoffice.prices.save'}", - "saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}", - "product": "@:{'phrases.compat.limited_backoffice.prices.product'}", - "price": "@:{'phrases.compat.limited_backoffice.prices.price'}", - "price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}", + "prices": { + "title": "@:{'phrases.compat.limited_backoffice.prices.title'}", + "save": "@:{'phrases.compat.limited_backoffice.prices.save'}", + "saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}", + "autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}", + "autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}", + "autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}", + "product": "@:{'phrases.compat.limited_backoffice.prices.product'}", + "price": "@:{'phrases.compat.limited_backoffice.prices.price'}", + "price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}", "setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}", "setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}", "no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}" @@ -273,6 +276,9 @@ "title": "@.capitalize:{'terms.glossary.abteilungspreise'}", "save": "@:{'terms.glossary.preise'} @:{'terms.glossary.speichern'}", "saved": "@:{'terms.glossary.preise'} @:{'terms.glossary.gespeichert'}.", + "autosave_saving": "Preise werden gespeichert...", + "autosave_pending": "Preisaenderungen ausstehend.", + "autosave_saved": "Alle Preisaenderungen gespeichert.", "product": "@.capitalize:{'terms.glossary.produkt'}", "price": "Abteilungspreis", "price_required": "Jedes @.capitalize:{'terms.glossary.produkt'} @:{'terms.glossary.muss'} einen @:{'terms.glossary.nicht'}-negativen numerischen Preis @:{'terms.glossary.haben'}.", diff --git a/src/i18n/source/en/phrases/index.json b/src/i18n/source/en/phrases/index.json index 7af8b641..38d67974 100644 --- a/src/i18n/source/en/phrases/index.json +++ b/src/i18n/source/en/phrases/index.json @@ -28,13 +28,16 @@ "title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}", "department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}" }, - "prices": { - "title": "@:{'phrases.compat.limited_backoffice.prices.title'}", - "save": "@:{'phrases.compat.limited_backoffice.prices.save'}", - "saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}", - "product": "@:{'phrases.compat.limited_backoffice.prices.product'}", - "price": "@:{'phrases.compat.limited_backoffice.prices.price'}", - "price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}", + "prices": { + "title": "@:{'phrases.compat.limited_backoffice.prices.title'}", + "save": "@:{'phrases.compat.limited_backoffice.prices.save'}", + "saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}", + "autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}", + "autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}", + "autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}", + "product": "@:{'phrases.compat.limited_backoffice.prices.product'}", + "price": "@:{'phrases.compat.limited_backoffice.prices.price'}", + "price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}", "setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}", "setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}", "no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}" @@ -273,6 +276,9 @@ "title": "@.capitalize:{'terms.glossary.department'} @:{'terms.glossary.prices'}", "save": "@.capitalize:{'terms.glossary.save'} @:{'terms.glossary.prices'}", "saved": "@.capitalize:{'terms.glossary.prices'} @:{'terms.glossary.saved'}.", + "autosave_saving": "Saving prices...", + "autosave_pending": "Price changes pending.", + "autosave_saved": "All price changes saved.", "product": "@.capitalize:{'terms.glossary.product'}", "price": "@.capitalize:{'terms.glossary.department'} @:{'terms.glossary.price'}", "price_required": "Every @:{'terms.glossary.product'} @:{'terms.glossary.must'} @:{'terms.glossary.have'} @:{'terms.glossary.a'} non-negative numeric @:{'terms.glossary.price'}.", diff --git a/src/i18n/source/no/phrases/index.json b/src/i18n/source/no/phrases/index.json index 5fa723d5..853bbe06 100644 --- a/src/i18n/source/no/phrases/index.json +++ b/src/i18n/source/no/phrases/index.json @@ -28,13 +28,16 @@ "title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}", "department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}" }, - "prices": { - "title": "@:{'phrases.compat.limited_backoffice.prices.title'}", - "save": "@:{'phrases.compat.limited_backoffice.prices.save'}", - "saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}", - "product": "@:{'phrases.compat.limited_backoffice.prices.product'}", - "price": "@:{'phrases.compat.limited_backoffice.prices.price'}", - "price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}", + "prices": { + "title": "@:{'phrases.compat.limited_backoffice.prices.title'}", + "save": "@:{'phrases.compat.limited_backoffice.prices.save'}", + "saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}", + "autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}", + "autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}", + "autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}", + "product": "@:{'phrases.compat.limited_backoffice.prices.product'}", + "price": "@:{'phrases.compat.limited_backoffice.prices.price'}", + "price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}", "setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}", "setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}", "no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}" @@ -273,6 +276,9 @@ "title": "@.capitalize:{'terms.glossary.avdelingspriser'}", "save": "@.capitalize:{'terms.glossary.lagre'} @:{'terms.glossary.priser'}", "saved": "Prisene @:{'terms.glossary.er'} @:{'terms.glossary.lagret'}.", + "autosave_saving": "Lagrer priser...", + "autosave_pending": "Prisendringer venter.", + "autosave_saved": "Alle prisendringer er lagret.", "product": "Produkt", "price": "Avdelingspris", "price_required": "@.capitalize:{'terms.glossary.alle'} @:{'terms.glossary.produkter'} @:{'terms.glossary.ma'} ha @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.ikke'}-negativ numerisk pris.", diff --git a/src/i18n/source/sv/phrases/index.json b/src/i18n/source/sv/phrases/index.json index 4ab1e324..5912cc4b 100644 --- a/src/i18n/source/sv/phrases/index.json +++ b/src/i18n/source/sv/phrases/index.json @@ -28,13 +28,16 @@ "title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}", "department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}" }, - "prices": { - "title": "@:{'phrases.compat.limited_backoffice.prices.title'}", - "save": "@:{'phrases.compat.limited_backoffice.prices.save'}", - "saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}", - "product": "@:{'phrases.compat.limited_backoffice.prices.product'}", - "price": "@:{'phrases.compat.limited_backoffice.prices.price'}", - "price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}", + "prices": { + "title": "@:{'phrases.compat.limited_backoffice.prices.title'}", + "save": "@:{'phrases.compat.limited_backoffice.prices.save'}", + "saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}", + "autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}", + "autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}", + "autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}", + "product": "@:{'phrases.compat.limited_backoffice.prices.product'}", + "price": "@:{'phrases.compat.limited_backoffice.prices.price'}", + "price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}", "setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}", "setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}", "no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}" @@ -273,6 +276,9 @@ "title": "@.capitalize:{'terms.glossary.avdelningspriser'}", "save": "@.capitalize:{'terms.glossary.spara'} @:{'terms.glossary.priser'}", "saved": "Priserna @:{'terms.glossary.har'} @:{'terms.glossary.sparats'}.", + "autosave_saving": "Sparar priser...", + "autosave_pending": "Prisandringar vantar.", + "autosave_saved": "Alla prisandringar har sparats.", "product": "Produkt", "price": "Avdelningspris", "price_required": "@.capitalize:{'terms.glossary.alla'} @:{'terms.glossary.produkter'} @:{'terms.glossary.maste'} ha ett icke-negativt numeriskt pris.", diff --git a/src/views/backoffice/LimitedBackofficeHome.vue b/src/views/backoffice/LimitedBackofficeHome.vue index 3a166d55..14e3b11b 100644 --- a/src/views/backoffice/LimitedBackofficeHome.vue +++ b/src/views/backoffice/LimitedBackofficeHome.vue @@ -38,6 +38,10 @@ const loadDepartments = async () => { } }; +const changeDepartment = async (departmentId) => { + await router.push(`/backoffice/departments/${departmentId}/prices`); +}; + onMounted(() => { void loadDepartments(); }); @@ -50,6 +54,7 @@ onMounted(() => { :departments="departments" :loading-departments="loading" show-department-switcher + @change-department="changeDepartment" >
{{ t("templates.limited_backoffice.loading") }} diff --git a/src/views/backoffice/LimitedBackofficePrices.vue b/src/views/backoffice/LimitedBackofficePrices.vue index 5e1a2ff5..dd067777 100644 --- a/src/views/backoffice/LimitedBackofficePrices.vue +++ b/src/views/backoffice/LimitedBackofficePrices.vue @@ -1,5 +1,5 @@