From d03f4d9aae77240068d185dea16a97e91a93628a Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 8 Jul 2026 12:27:45 +0200 Subject: [PATCH] Add subusers management features and enhance department navigation --- scripts/playwright-pr-mapping.mjs | 11 +- scripts/run-playwright-full-slice.mjs | 1 + src/components/displays/Subusers.vue | 7 +- .../displays/buttons/DatePeriodSelector.vue | 77 +- .../steps/mobile/PosDepartmentStepMobile2.vue | 29 +- .../pos/steps/mobile/objects/PosAddon.vue | 3 +- .../objects/PosDepartmentStepMobileFlow.vue | 1 + .../pos/steps/mobile/objects/PosProduct.vue | 3 +- .../DepartmentCustomerPricingEditor.vue | 55 +- .../PaginationOtherFiltersDropdown.vue | 14 +- .../SuperUserDashboard/SubusersPagination.vue | 10 +- .../UserDashboard/VehiclesPagination.vue | 38 +- .../selectors/SubuserGrantPermissionNodes.vue | 297 +++- .../overview/SuperuserOverviewMetricGrid.vue | 79 + .../overview/SuperuserOverviewPanelGrid.vue | 44 + .../SuperuserOverviewSegmentedTabs.vue | 117 ++ .../superuser/tables/SubusersTable.vue | 368 ++++- .../displays/user/vehicles/addVehicleForm.vue | 133 +- .../displays/user/vehicles/vehiclesTable.vue | 69 +- src/components/forms/BuefyMonthField.vue | 2 + .../SessionUser/Objects/SubuserGrants.vue | 39 +- .../token/SessionUser/Objects/Subusers.vue | 85 +- .../token/SessionUser/Objects/Vehicles.vue | 78 +- .../session/user/UserVehicleRequest.vue | 10 +- src/components/shop/OrdersItems.vue | 11 +- src/components/shop/POSDepartmentProcess.vue | 8 +- .../EdgeGatewayDepartmentWorkspace.vue | 197 +-- src/i18n/generated/da-v2.json | 90 +- src/i18n/generated/de-v2.json | 75 +- src/i18n/generated/en-v2.json | 88 +- src/i18n/generated/global-v2.json | 93 +- src/i18n/generated/no-v2.json | 75 +- src/i18n/generated/sv-v2.json | 75 +- .../da/phrases/compat/global/index.json | 2 +- .../da/phrases/compat/superuser/index.json | 88 +- src/i18n/source/da/phrases/index.json | 606 +------- .../split-compat-limited-backoffice.json | 306 ++++ .../da/phrases/split-limited-backoffice.json | 304 ++++ .../de/phrases/compat/superuser/index.json | 75 +- src/i18n/source/de/phrases/index.json | 606 +------- .../split-compat-limited-backoffice.json | 306 ++++ .../de/phrases/split-limited-backoffice.json | 304 ++++ .../en/phrases/compat/superuser/index.json | 88 +- src/i18n/source/en/phrases/index.json | 606 +------- .../split-compat-limited-backoffice.json | 306 ++++ .../en/phrases/split-limited-backoffice.json | 304 ++++ .../source/global/shared/superuser/index.json | 88 +- .../shared/superuser_dashboard/index.json | 5 + .../no/phrases/compat/superuser/index.json | 75 +- src/i18n/source/no/phrases/index.json | 606 +------- .../split-compat-limited-backoffice.json | 306 ++++ .../no/phrases/split-limited-backoffice.json | 304 ++++ .../sv/phrases/compat/superuser/index.json | 75 +- src/i18n/source/sv/phrases/index.json | 606 +------- .../split-compat-limited-backoffice.json | 306 ++++ .../sv/phrases/split-limited-backoffice.json | 304 ++++ src/router.js | 18 +- .../backoffice/LimitedBackofficeEmployees.vue | 1329 +--------------- .../LimitedBackofficeEmployeesManager.vue | 1349 +++++++++++++++++ .../department/Department.vue | 38 +- .../department/DepartmentCategories.vue | 9 +- .../department/DepartmentCustomerPricing.vue | 1 + .../department/DepartmentEmployees.vue | 55 + .../department/DepartmentPricing.vue | 136 +- .../department/DepartmentProfile.vue | 46 +- .../department/DepartmentSubPageWrapper.vue | 31 +- ...SuperUserDashboardDepartmentNavigation.vue | 77 +- .../SuperUserSelectedDepartmentObject.vue | 70 +- .../modules/DepartmentModulesSetup.vue | 53 +- ...erDashboardDepartmentModulesNavigation.vue | 35 +- .../stripe/DepartmentStripeSetup.vue | 18 +- .../DepartmentStripeTerminalsReaders.vue | 63 +- ...serDashboardDepartmentStripeNavigation.vue | 34 +- .../user/SuperUserDashboardUserNavigation.vue | 1 + .../superUserDashboard/user/User.vue | 8 + .../superUserDashboard/user/UserSubusers.vue | 178 +++ .../superUserDashboard/user/UserVehicles.vue | 205 +++ .../user/UserWashSubscriptions.vue | 85 -- .../user/superuserUserPageDefinitions.js | 1 + .../userDashboard/bookings/MyBookingsBook.vue | 50 +- .../displays/steps/NewBookingStep5.vue | 2 +- tests/e2e/date-period-selector.smoke.spec.js | 10 +- tests/e2e/pos-flow.spec.js | 30 +- tests/e2e/pos-mobile-order-flow.spec.js | 20 + tests/e2e/subuser-management.spec.ts | 185 ++- .../superuser-department-employees.spec.ts | 226 +++ ...ser-department-pricing-custom-only.spec.ts | 27 +- .../e2e/superuser-orders-date-filters.spec.ts | 20 +- tests/e2e/superuser-users.spec.ts | 353 ++++- tests/e2e/support/bookingFlow.ts | 4 +- tests/e2e/support/network.js | 30 +- tests/e2e/userBookWash.spec.ts | 101 +- tests/e2e/userVehicles.spec.ts | 57 + tests/unit/buefy-datepicker.spec.js | 16 + tests/unit/date-period-selector.spec.js | 5 +- tests/unit/playwright-pr-mapping.spec.js | 15 + .../subuser-grant-permission-nodes.spec.js | 103 +- .../superuser-department-overview.spec.js | 22 +- .../superuser-user-overview-source.spec.js | 4 +- tests/unit/user-add-vehicle-form.spec.js | 138 ++ tests/unit/user-vehicle-request.spec.js | 16 +- 101 files changed, 8953 insertions(+), 5179 deletions(-) create mode 100644 src/components/displays/superuser/overview/SuperuserOverviewMetricGrid.vue create mode 100644 src/components/displays/superuser/overview/SuperuserOverviewPanelGrid.vue create mode 100644 src/components/displays/superuser/overview/SuperuserOverviewSegmentedTabs.vue create mode 100644 src/i18n/source/da/phrases/split-compat-limited-backoffice.json create mode 100644 src/i18n/source/da/phrases/split-limited-backoffice.json create mode 100644 src/i18n/source/de/phrases/split-compat-limited-backoffice.json create mode 100644 src/i18n/source/de/phrases/split-limited-backoffice.json create mode 100644 src/i18n/source/en/phrases/split-compat-limited-backoffice.json create mode 100644 src/i18n/source/en/phrases/split-limited-backoffice.json create mode 100644 src/i18n/source/no/phrases/split-compat-limited-backoffice.json create mode 100644 src/i18n/source/no/phrases/split-limited-backoffice.json create mode 100644 src/i18n/source/sv/phrases/split-compat-limited-backoffice.json create mode 100644 src/i18n/source/sv/phrases/split-limited-backoffice.json create mode 100644 src/views/backoffice/components/LimitedBackofficeEmployeesManager.vue create mode 100644 src/views/dashboards/superUserDashboard/department/DepartmentEmployees.vue create mode 100644 src/views/dashboards/superUserDashboard/user/UserSubusers.vue create mode 100644 src/views/dashboards/superUserDashboard/user/UserVehicles.vue delete mode 100644 src/views/dashboards/superUserDashboard/user/UserWashSubscriptions.vue create mode 100644 tests/e2e/superuser-department-employees.spec.ts create mode 100644 tests/unit/user-add-vehicle-form.spec.js diff --git a/scripts/playwright-pr-mapping.mjs b/scripts/playwright-pr-mapping.mjs index dd7e0229..aaad3bd3 100644 --- a/scripts/playwright-pr-mapping.mjs +++ b/scripts/playwright-pr-mapping.mjs @@ -39,6 +39,15 @@ export const sourceMappings = [ specs: ["tests/e2e/limited-backoffice.spec.ts"], projects: chromiumProjects, }, + { + name: "superuser-department-employees", + patterns: [ + /^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentEmployees\.vue$/u, + /^src\/views\/backoffice\/components\/LimitedBackofficeEmployeesManager\.vue$/u, + ], + specs: ["tests/e2e/superuser-department-employees.spec.ts"], + projects: ["chromium-desktop"], + }, { name: "superuser-roles-permissions", patterns: [ @@ -71,7 +80,7 @@ export const sourceMappings = [ /^src\/views\/dashboards\/superUserDashboard\/department\/Department\.vue$/u, /^src\/views\/dashboards\/superUserDashboard\/department\/SuperUserDashboardDepartmentNavigation\.vue$/u, ], - specs: ["tests/e2e/superuser-department-overview.spec.js"], + specs: ["tests/e2e/superuser-department-overview.spec.js", "tests/e2e/superuser-department-employees.spec.ts"], projects: chromiumProjects, }, { diff --git a/scripts/run-playwright-full-slice.mjs b/scripts/run-playwright-full-slice.mjs index 1a9a71e9..177f756e 100644 --- a/scripts/run-playwright-full-slice.mjs +++ b/scripts/run-playwright-full-slice.mjs @@ -104,6 +104,7 @@ export const ownedFilesByRole = { "superuser-department-branding.spec.js", "superuser-department-shells.spec.js", "superuser-department-overview.spec.js", + "superuser-department-employees.spec.ts", "superuser-department-gates.spec.ts", "superuser-department-lanes.spec.ts", "superuser-department-pricing-custom-only.spec.ts", diff --git a/src/components/displays/Subusers.vue b/src/components/displays/Subusers.vue index 6d5a6aa6..a0d80590 100644 --- a/src/components/displays/Subusers.vue +++ b/src/components/displays/Subusers.vue @@ -19,6 +19,10 @@ const props = defineProps({ type: Boolean, default: false, }, + userScopedUserId: { + type: [String, Number], + default: null, + }, }); const { t } = useI18n(); @@ -42,7 +46,7 @@ const paginationKey = computed(() => const onInviteClick = async () => { await SessionUser.objects.subusers.functions.showInviteForm(() => { paginationVersion.value += 1; - }, { superuser: props.superuserPage }); + }, { superuser: props.superuserPage, userId: props.userScopedUserId }); }; @@ -75,6 +79,7 @@ const onInviteClick = async () => { :key="paginationKey" :endpoint="endpoint" :show-customer="showCustomer" + :user-scoped-user-id="userScopedUserId" auto-load="true" /> diff --git a/src/components/displays/buttons/DatePeriodSelector.vue b/src/components/displays/buttons/DatePeriodSelector.vue index a8d06c5d..b3cb4052 100644 --- a/src/components/displays/buttons/DatePeriodSelector.vue +++ b/src/components/displays/buttons/DatePeriodSelector.vue @@ -1,6 +1,6 @@ \ No newline at end of file + diff --git a/src/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue b/src/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue index ab252504..f2cc6326 100644 --- a/src/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue +++ b/src/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue @@ -1458,6 +1458,7 @@ const selectLastVehicleOrder = (vehicleIndex: number) => { notes: item?.notes ?? item?.product?.notes ?? "", addons: Array.isArray(item?.product?.addons) ? item.product.addons : [], subscription_allowed: Boolean(item?.product?.subscription_allowed ?? false), + skip_price_override: true, } as PosProduct; }; diff --git a/src/components/displays/department/pos/steps/mobile/objects/PosProduct.vue b/src/components/displays/department/pos/steps/mobile/objects/PosProduct.vue index e1ee0c9a..f5b94124 100644 --- a/src/components/displays/department/pos/steps/mobile/objects/PosProduct.vue +++ b/src/components/displays/department/pos/steps/mobile/objects/PosProduct.vue @@ -23,5 +23,6 @@ export type PosProduct = { quantity?: number; notes?: string; // Optional notes for the product, used as a part of order item generation related_item_id?: number; + skip_price_override?: boolean; }; - \ No newline at end of file + diff --git a/src/components/displays/department/pricing/DepartmentCustomerPricingEditor.vue b/src/components/displays/department/pricing/DepartmentCustomerPricingEditor.vue index de7f7551..f203e931 100644 --- a/src/components/displays/department/pricing/DepartmentCustomerPricingEditor.vue +++ b/src/components/displays/department/pricing/DepartmentCustomerPricingEditor.vue @@ -3,6 +3,7 @@ import { computed, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; import Swal from "sweetalert2"; +import SuperuserOverviewPanel from "@/components/displays/superuser/overview/SuperuserOverviewPanel.vue"; import { getLimitedBackofficeDepartmentCustomerPricing, getSuperuserDepartmentCustomerPricing, @@ -37,6 +38,11 @@ const props = defineProps({ type: [Number, String], default: "", }, + presentation: { + type: String, + default: "plain", + validator: (value) => ["plain", "superuser-tiles"].includes(value), + }, }); const { t, locale } = useI18n({ useScope: "global" }); @@ -115,6 +121,7 @@ const getOverride = (isCategory, objectId) => const getDiscountDisplay = (override) => (override.percentage > 0 ? `${override.percentage}%` : "-"); const getFixedPriceDisplay = (override) => (override.fixed_price !== null ? formatPrice(override.fixed_price) : "-"); const customerIdentifier = computed(() => ({ customerNumber: parsedCustomerNumber.value })); +const tilePresentation = computed(() => props.presentation === "superuser-tiles"); const requestLoad = () => props.scope === "limited" @@ -305,7 +312,11 @@ watch( @@ -178,13 +212,15 @@ const editPriceIfAllowed = (product) => { \ No newline at end of file +.superuser-department-detail-shell__content { + display: flex; + flex-direction: column; + gap: 1rem; + min-width: 0; +} + +@media (max-width: 768px) { + .superuser-department-detail-shell { + padding: 0 0.75rem 1.5rem; + } +} + diff --git a/src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue b/src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue index 684e0b78..5734121e 100644 --- a/src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue +++ b/src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue @@ -1,5 +1,6 @@ diff --git a/src/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue b/src/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue index abba3955..8ad3d068 100644 --- a/src/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue +++ b/src/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue @@ -1,6 +1,7 @@ - - - diff --git a/src/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeSetup.vue b/src/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeSetup.vue index 155ce643..fe68b068 100644 --- a/src/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeSetup.vue +++ b/src/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeSetup.vue @@ -9,9 +9,8 @@ import { computed, ref } from "vue"; import { useI18n } from "vue-i18n"; import SuperUserDashboardDepartmentStripeNavigation from "@/views/dashboards/superUserDashboard/department/stripe/SuperUserDashboardDepartmentStripeNavigation.vue"; -import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue"; -import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue"; import ConfigurationSelect from "@/components/displays/superuser/configuration/ConfigurationSelect.vue"; +import SuperuserOverviewPanel from "@/components/displays/superuser/overview/SuperuserOverviewPanel.vue"; // Get the department from the route const router = useRouter() @@ -67,14 +66,13 @@ const setDepartmentStripeLocation = async (locationId) => { -
+
- - +
diff --git a/src/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeTerminalsReaders.vue b/src/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeTerminalsReaders.vue index 5097bcec..368f8d85 100644 --- a/src/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeTerminalsReaders.vue +++ b/src/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeTerminalsReaders.vue @@ -12,6 +12,7 @@ import SuperUserDashboardDepartmentStripeNavigation import { parseError } from "@/components/request/HandleGlobalError.vue"; import ShowErrorField from "@/components/global/ShowErrorField.vue"; import { formatLocaleDateTime } from "@/services/localeFormatting.js"; +import SuperuserOverviewPanel from "@/components/displays/superuser/overview/SuperuserOverviewPanel.vue"; // Get the department from the route const router = useRouter() @@ -46,36 +47,48 @@ getTerminals(); -
+
- - - - - - - - - - - - - - - - - - - - - - -
{{ $t('tables.common.id') }}{{ $t('tables.common.type') }}{{ $t('tables.common.label') }}{{ $t('tables.common.last_seen') }}{{ $t('tables.common.serial_number') }}{{ $t('common.status') }}
{{ terminal.id }}{{ terminal.device_type }}{{ terminal.label }}{{ formatLocaleDateTime(terminal.last_seen_at, locale.value) || "-" }}{{ terminal.serial_number }}{{ terminal.status }}
+ + +
+ + + + + + + + + + + + + + + + + + + + + +
{{ $t('tables.common.id') }}{{ $t('tables.common.type') }}{{ $t('tables.common.label') }}{{ $t('tables.common.last_seen') }}{{ $t('tables.common.serial_number') }}{{ $t('common.status') }}
{{ terminal.id }}{{ terminal.device_type }}{{ terminal.label }}{{ formatLocaleDateTime(terminal.last_seen_at, locale.value) || "-" }}{{ terminal.serial_number }}{{ terminal.status }}
+
+
diff --git a/src/views/dashboards/superUserDashboard/department/stripe/SuperUserDashboardDepartmentStripeNavigation.vue b/src/views/dashboards/superUserDashboard/department/stripe/SuperUserDashboardDepartmentStripeNavigation.vue index f5590fc8..37c52746 100644 --- a/src/views/dashboards/superUserDashboard/department/stripe/SuperUserDashboardDepartmentStripeNavigation.vue +++ b/src/views/dashboards/superUserDashboard/department/stripe/SuperUserDashboardDepartmentStripeNavigation.vue @@ -1,5 +1,6 @@ diff --git a/src/views/dashboards/superUserDashboard/user/superuserUserPageDefinitions.js b/src/views/dashboards/superUserDashboard/user/superuserUserPageDefinitions.js index 881924ea..209cc17c 100644 --- a/src/views/dashboards/superUserDashboard/user/superuserUserPageDefinitions.js +++ b/src/views/dashboards/superUserDashboard/user/superuserUserPageDefinitions.js @@ -4,6 +4,7 @@ export const SUPERUSER_USER_PAGE_KEYS = Object.freeze({ pricing: "pricing", other: "other", security: "security", + subusers: "subusers", vehicles: "vehicles", xlvask: "xlvask", }); diff --git a/src/views/dashboards/userDashboard/bookings/MyBookingsBook.vue b/src/views/dashboards/userDashboard/bookings/MyBookingsBook.vue index 3f0f1657..bd001e9b 100644 --- a/src/views/dashboards/userDashboard/bookings/MyBookingsBook.vue +++ b/src/views/dashboards/userDashboard/bookings/MyBookingsBook.vue @@ -441,7 +441,8 @@ const isConfirmDisabled = computed(() => { if (guestMode.value) { return !(hasDepartment.value && hasVehicle.value && !!dateTime.value && isCustomerNumberValid.value === true); } - return !(hasDepartment.value && !!dateTime.value && isCustomerNumberValid.value === true && completeBasket.value.length > 0); + return !(hasDepartment.value && !!dateTime.value && isCustomerNumberValid.value === true && completeBasket.value.length > 0) + || !isPricedWashCertificateReady(); }); const onClickComplete = () => { @@ -647,29 +648,42 @@ const washExterior = ref(true); // default true // Wash certificate product (id: 41) to auto-attach when interior wash is selected const washCertificateProduct: Ref = ref(null); +const washCertificateProductLoading = ref(false); +const latestWashCertificateProductRequestId = ref(0); const loadWashCertificateProduct = async () => { if (guestMode.value) { // Not available for guests (no auth); fall back to stub later washCertificateProduct.value = null; + washCertificateProductLoading.value = false; return; } + const requestId = ++latestWashCertificateProductRequestId.value; + washCertificateProductLoading.value = true; try { const deptId = (department.value && (department.value as any).id) ? (department.value as any).id : null; - // Try to load with department context to get correct pricing if possible - if (deptId) { - // @ts-ignore - runtime object provides get.single - washCertificateProduct.value = await SessionUser.objects.products.get.single(41, { department_id: deptId }); - } else { - // @ts-ignore - washCertificateProduct.value = await SessionUser.objects.products.get.single(41); + const customerId = customerNumber.value && Number(customerNumber.value) > 0 ? Number(customerNumber.value) : null; + const product = await SessionUser.objects.products.get.single(41, { + final_price: true, + ...(deptId ? { department_id: deptId } : {}), + ...(customerId ? { customer_id: customerId } : {}), + }); + if (requestId === latestWashCertificateProductRequestId.value) { + washCertificateProduct.value = product; } } catch (e) { console.warn('Could not load wash certificate product (id:41). Will fall back to stub.', e); - washCertificateProduct.value = null; + if (requestId === latestWashCertificateProductRequestId.value) { + washCertificateProduct.value = null; + } + } finally { + if (requestId === latestWashCertificateProductRequestId.value) { + washCertificateProductLoading.value = false; + } } }; // Reload wash certificate product when department changes to keep pricing accurate watch(() => department.value, () => { loadWashCertificateProduct(); }); +watch(() => customerNumber.value, () => { loadWashCertificateProduct(); }); const onClickInteriorWashButton = () => { // Toggle the wash interior flag washInterior.value = !washInterior.value; @@ -761,6 +775,14 @@ const isInteriorWashPrimary = computed(() => { return selectedProduct.value?.name?.includes('Indvendig'); }) +function requiresPricedWashCertificate() { + return hasShowPriceOnBookingPageAttribute() && washInterior.value && !!interiorAddon.value; +} + +function isPricedWashCertificateReady() { + return !requiresPricedWashCertificate() || (!!washCertificateProduct.value && !washCertificateProductLoading.value); +} + const formatDkk = (amount?: number | null) => { return (amount || 0).toLocaleString('da-DK', { style: 'currency', currency: 'DKK' }); }; @@ -849,7 +871,10 @@ const completeBasket = computed({ if (hasInteriorInBasket) { const alreadyHasCertificate = result.some(r => r.id === 41); if (!alreadyHasCertificate) { - // Prefer the fetched product to preserve pricing/meta; else provide a minimal stub + // Visible-price customers must wait for the resolved product so the summary never shows a synthetic zero price. + if (!washCertificateProduct.value && hasShowPriceOnBookingPageAttribute()) { + return result; + } const cert = washCertificateProduct.value ? { ...(washCertificateProduct.value as PosProduct) } : { id: 41, name: 'Vaskecertifikat', @@ -882,6 +907,9 @@ const summaryOverlay = ref(null); const openSummary = () => { showSummary.value = true; nextTick(() => summaryOverlay.value?.focus()); }; const onSummaryBack = () => { showSummary.value = false; }; const onSummaryConfirm = () => { + if (!isPricedWashCertificateReady()) { + return; + } showSummary.value = false; if (guestMode.value) { // Guests cannot complete booking; ask them to log in @@ -1915,7 +1943,7 @@ watch(customerNumber, (newVal) => {

{{ $t('user_dashboard.bookings.book.confirm_booking') }}

- +
diff --git a/src/views/dashboards/userDashboard/bookings/displays/steps/NewBookingStep5.vue b/src/views/dashboards/userDashboard/bookings/displays/steps/NewBookingStep5.vue index 8d9fba2c..e4f97355 100644 --- a/src/views/dashboards/userDashboard/bookings/displays/steps/NewBookingStep5.vue +++ b/src/views/dashboards/userDashboard/bookings/displays/steps/NewBookingStep5.vue @@ -193,7 +193,7 @@ const showWarning = computed(() => {
- +
diff --git a/tests/e2e/date-period-selector.smoke.spec.js b/tests/e2e/date-period-selector.smoke.spec.js index 7d02b131..5c12c376 100644 --- a/tests/e2e/date-period-selector.smoke.spec.js +++ b/tests/e2e/date-period-selector.smoke.spec.js @@ -36,7 +36,7 @@ test.describe("date period selector harness", () => { await page.getByTestId("date-period-other-dropdown-trigger").click(); const otherMonthOption = page.getByTestId("date-period-other-other_month"); await expect(otherMonthOption).toBeVisible(); - await expect(otherMonthOption).toContainText(/other month|andet måned|annen måned|annan månad|anderer monat/i); + await expect(otherMonthOption).toContainText(/other month|anden måned|annen måned|annan månad|anderer monat/i); const optionBox = await otherMonthOption.boundingBox(); expect(optionBox?.width).toBeGreaterThan(120); @@ -48,6 +48,12 @@ test.describe("date period selector harness", () => { const otherMonthPicker = page.getByTestId("date-period-other-month-picker"); await expect(otherMonthModal).toBeVisible(); await expect(otherMonthPicker).toBeVisible(); - await expect(otherMonthPicker.locator("input").first()).toBeVisible(); + await expect(otherMonthPicker.locator(".datepicker-table")).toBeVisible(); + + const previousBox = await otherMonthPicker.locator(".pagination-previous").boundingBox(); + const yearBox = await otherMonthPicker.locator(".pagination-list").boundingBox(); + const nextBox = await otherMonthPicker.locator(".pagination-next").boundingBox(); + expect(previousBox?.x).toBeLessThan(yearBox?.x ?? 0); + expect(yearBox?.x).toBeLessThan(nextBox?.x ?? 0); }); }); diff --git a/tests/e2e/pos-flow.spec.js b/tests/e2e/pos-flow.spec.js index aa21d2d8..87e8e0e9 100644 --- a/tests/e2e/pos-flow.spec.js +++ b/tests/e2e/pos-flow.spec.js @@ -1267,6 +1267,11 @@ test.describe("POS flow", () => { price: 149, }; fixture.products = [...fixture.products, additionalProduct]; + fixture.departmentProductPrices = { + 41: 222, + 53: 799, + 63: 333, + }; fixture.vehicles = [ { ...fixture.vehicles[0], @@ -1289,7 +1294,7 @@ test.describe("POS flow", () => { notes: "Primary note", reference: "", related_item_id: null, - price: fixture.products[0].price, + price: 111, }, { id: 92012, @@ -1300,7 +1305,7 @@ test.describe("POS flow", () => { notes: "Addon note", reference: "", related_item_id: 92011, - price: fixture.products[1].price, + price: 112, }, { id: 92013, @@ -1311,11 +1316,17 @@ test.describe("POS flow", () => { notes: "Additional note", reference: "", related_item_id: null, - price: additionalProduct.price, + price: 113, }, ]; + const orderItemCreatePayloads = []; await setupDesktopPosPage(page, fixture, { token: "pos-desktop-copy-last-wash" }); + page.on("request", (request) => { + if (request.method() === "POST" && request.url().includes("/order/items")) { + orderItemCreatePayloads.push(request.postDataJSON?.() || {}); + } + }); await page.locator("#reg_1").fill("AB12345"); @@ -1342,6 +1353,19 @@ test.describe("POS flow", () => { 41, 53, 63, ]); expect(copiedItems.map((item) => item.notes).sort()).toEqual(["Additional note", "Addon note", "Primary note"]); + expect(orderItemCreatePayloads).toHaveLength(3); + expect(orderItemCreatePayloads.every((payload) => !Object.prototype.hasOwnProperty.call(payload, "price"))).toBe( + true + ); + expect( + copiedItems + .map((item) => [Number(item.product_id), Number(item.price)]) + .sort(([leftProductId], [rightProductId]) => leftProductId - rightProductId) + ).toEqual([ + [41, 222], + [53, 799], + [63, 333], + ]); }); test("desktop keeps only one visible customer selector for an unlinked vehicle after the refactor", async ({ diff --git a/tests/e2e/pos-mobile-order-flow.spec.js b/tests/e2e/pos-mobile-order-flow.spec.js index 526f673d..752e3ab8 100644 --- a/tests/e2e/pos-mobile-order-flow.spec.js +++ b/tests/e2e/pos-mobile-order-flow.spec.js @@ -2958,6 +2958,7 @@ test.describe("POS mobile order flow", () => { { order_id: DEFAULT_LAST_ORDER_ID, quantity: 1, + price: 111, }, 9501 ), @@ -2968,6 +2969,7 @@ test.describe("POS mobile order flow", () => { order_id: DEFAULT_LAST_ORDER_ID, quantity: 2, related_item_id: 9501, + price: 112, }, 9502 ), @@ -2978,6 +2980,7 @@ test.describe("POS mobile order flow", () => { order_id: DEFAULT_LAST_ORDER_ID, quantity: 1, related_item_id: null, + price: 113, }, 9503 ), @@ -3058,6 +3061,23 @@ test.describe("POS mobile order flow", () => { } ); await expect(page.getByTestId("pos-mobile-copy-last-wash-matched")).toBeVisible({ timeout: 10_000 }); + + await page.getByTestId("pos-mobile-next-step").click(); + + await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(3); + await expect.poll(() => (fixture.orderItemsByOrderId[orderId] || []).length, { timeout: 10_000 }).toBe(3); + expect( + fixture.requestLog.orderItemCreates.every((payload) => !Object.prototype.hasOwnProperty.call(payload, "price")) + ).toBe(true); + expect( + (fixture.orderItemsByOrderId[orderId] || []) + .map((item) => [Number(item.product_id), Number(item.price)]) + .sort(([leftProductId], [rightProductId]) => leftProductId - rightProductId) + ).toEqual([ + [41, 25], + [53, 599], + [71, 99], + ]); }); test("copy previous wash skips restricted addon and additional items", async ({ page }) => { diff --git a/tests/e2e/subuser-management.spec.ts b/tests/e2e/subuser-management.spec.ts index 0a3a51b0..7c889706 100644 --- a/tests/e2e/subuser-management.spec.ts +++ b/tests/e2e/subuser-management.spec.ts @@ -21,9 +21,27 @@ type MockSubuser = { grant_enabled: boolean; grant_note: string | null; grant_permissions: string[]; + permission_template_key?: string; + permission_groups?: Array<{ key: string; capabilities: string[] }>; + grants?: MockSubuserGrant[]; + grant_count?: number; + customer_numbers?: number[]; access_state: "active" | "pending_setup" | "disabled" | "inactive"; }; +type MockSubuserGrant = Pick< + MockSubuser, + | "customer_number" + | "customer_name" + | "grant_id" + | "grant_enabled" + | "grant_note" + | "grant_permissions" + | "permission_template_key" + | "permission_groups" + | "access_state" +>; + const API_PATTERN = /https?:\/\/(?:api\.truckwash\.io(?::\d+)?|localhost(?::\d+)?\/api|127\.0\.0\.1(?::\d+)?\/api)\//; const baseUserSession = { @@ -132,28 +150,88 @@ const initialSubusers = (): MockSubuser[] => [ }, ]; -const permissionNodePayload = [ - { - group: "Subusers", - description: "Subuser management", - nodes: [ - { - key: "SUBUSERS_LIST", - name: "List chauffeurs", - description: "Can view chauffeurs", - type: "LIST", - default: false, - }, - { - key: "SUBUSERS_EDIT", - name: "Edit grants", - description: "Can edit grants", - type: "EDIT", - default: false, - }, - ], - }, -]; +const permissionTemplatePayload = { + templates: [ + { + key: "deactivated", + label: "Deactivated", + description: "Keeps the driver linked without active access.", + enabled: false, + permissions: [], + permission_groups: [], + }, + { + key: "driver", + label: "Driver", + description: "Can use self-service, bookings, vehicles, and orders.", + enabled: true, + permissions: ["VEHICLES_LIST", "SELFSERVE_LIST", "SELFSERVE_ADD", "BOOKINGS_LIST", "BOOKINGS_ADD", "ORDERS_LIST"], + permission_groups: [ + { key: "vehicles", capabilities: ["view_vehicles"] }, + { key: "selfserve", capabilities: ["view_selfserve", "start_selfserve"] }, + { key: "bookings", capabilities: ["view_bookings", "add_bookings"] }, + { key: "orders", capabilities: ["view_orders"] }, + ], + }, + { + key: "booking_coordinator", + label: "Booking coordinator", + description: "Can coordinate bookings.", + enabled: true, + permissions: ["VEHICLES_LIST", "BOOKINGS_LIST", "BOOKINGS_ADD", "BOOKINGS_EDIT", "ORDERS_LIST"], + permission_groups: [ + { key: "vehicles", capabilities: ["view_vehicles"] }, + { key: "bookings", capabilities: ["view_bookings", "add_bookings", "edit_bookings"] }, + { key: "orders", capabilities: ["view_orders"] }, + ], + }, + { + key: "fleet_admin", + label: "Fleet admin", + description: "Can manage drivers and customer access.", + enabled: true, + permissions: [ + "VEHICLES_LIST", + "BOOKINGS_LIST", + "SUBUSERS_LIST", + "SUBUSERS_EDIT", + "SUBUSERS_DELETE", + "SUBUSERS_ADD", + ], + permission_groups: [ + { key: "vehicles", capabilities: ["view_vehicles"] }, + { key: "bookings", capabilities: ["view_bookings"] }, + { + key: "driver_management", + capabilities: ["view_drivers", "edit_driver_access", "disable_driver_access", "invite_drivers"], + }, + ], + }, + ], + groups: [ + { key: "vehicles", capabilities: ["view_vehicles", "edit_vehicles", "delete_vehicles", "add_vehicles"] }, + { key: "selfserve", capabilities: ["view_selfserve", "edit_selfserve", "delete_selfserve", "start_selfserve"] }, + { key: "bookings", capabilities: ["view_bookings", "edit_bookings", "delete_bookings", "add_bookings"] }, + { key: "orders", capabilities: ["view_orders", "edit_orders"] }, + { + key: "driver_management", + capabilities: ["view_drivers", "edit_driver_access", "disable_driver_access", "invite_drivers"], + }, + ], +}; + +const templateByKey = (key: string) => permissionTemplatePayload.templates.find((template) => template.key === key); + +const applyTemplate = (subuser: MockSubuser, key: string) => { + const template = templateByKey(key); + if (!template) { + return; + } + subuser.permission_template_key = template.key; + subuser.grant_enabled = template.enabled; + subuser.grant_permissions = [...template.permissions]; + subuser.permission_groups = [...template.permission_groups]; +}; const initialSuperuserSubusers = (): MockSubuser[] => [ { @@ -178,6 +256,28 @@ const initialSuperuserSubusers = (): MockSubuser[] => [ grant_note: null, grant_permissions: ["BOOKINGS_LIST"], access_state: "pending_setup", + grant_count: 2, + customer_numbers: [12345678, 87654321], + grants: [ + { + customer_number: 12345678, + customer_name: "Nordic Transport", + grant_id: 510, + grant_enabled: true, + grant_note: null, + grant_permissions: ["BOOKINGS_LIST"], + access_state: "pending_setup", + }, + { + customer_number: 87654321, + customer_name: "City Logistics", + grant_id: 511, + grant_enabled: true, + grant_note: "Ekstra kunde", + grant_permissions: ["ORDERS_LIST"], + access_state: "pending_setup", + }, + ], }, { id: 52, @@ -334,6 +434,8 @@ async function mockManagementApi( grant_permissions: [], access_state: "pending_setup", }; + applyTemplate(created, String(payload.permission_template_key || "driver")); + recalculateAccessState(created); subusers.unshift(created); return jsonResponse(route, { subuser: created, @@ -392,6 +494,10 @@ async function mockManagementApi( if (Array.isArray(payload.permissions)) { target.grant_permissions = payload.permissions as string[]; + target.permission_template_key = "custom"; + } + if (typeof payload.permission_template_key === "string") { + applyTemplate(target, payload.permission_template_key); } if (Object.prototype.hasOwnProperty.call(payload, "note")) { target.grant_note = (payload.note as string) || null; @@ -410,8 +516,8 @@ async function mockManagementApi( }); } - if (pathname === "/subusers/permission-nodes" && request.method() === "GET") { - return jsonResponse(route, permissionNodePayload); + if (pathname === "/subusers/permission-templates" && request.method() === "GET") { + return jsonResponse(route, permissionTemplatePayload); } if (pathname === "/subusers/me" && request.method() === "PUT") { @@ -497,6 +603,8 @@ async function mockSuperuserManagementApi(page: Page) { grant_permissions: [], access_state: "pending_setup", }; + applyTemplate(created, String(payload.permission_template_key || "driver")); + recalculateAccessState(created); subusers.unshift(created); return jsonResponse(route, { subuser: created, @@ -536,6 +644,10 @@ async function mockSuperuserManagementApi(page: Page) { return jsonResponse(route, { ok: true }); } + if (pathname === "/subusers/permission-templates" && request.method() === "GET") { + return jsonResponse(route, permissionTemplatePayload); + } + return route.fulfill({ status: 404, contentType: "application/json", @@ -595,10 +707,13 @@ test("customer user with own-subuser permissions can invite and manage grant acc let actions = await openSubuserActions(page, 100); await actions.getByTestId("subuser-permissions-100").click(); - await expect(page.getByText("Tilladelsesnoder for Invited Driver")).toBeVisible(); - await page.getByTestId("permission-node-checkbox-SUBUSERS_LIST").click(); + await expect(page.getByText("Adgangsprofil for Invited Driver")).toBeVisible(); + await expect(page.getByTestId("permission-template-driver")).toBeVisible(); + await expect(page.getByTestId("permission-template-custom")).toHaveCount(0); + await page.getByTestId("permission-template-booking_coordinator").click(); await page.getByRole("button", { name: "Gem" }).click(); - await expect(page.getByText(/SUBUSERS_LIST/)).toBeVisible(); + await expect(page.getByText("Bookingkoordinator")).toBeVisible(); + await expect(page.getByText(/SUBUSERS_LIST/)).toHaveCount(0); actions = await openSubuserActions(page, 100); await actions.getByTestId("subuser-toggle-100").click(); @@ -653,14 +768,19 @@ test("superusers can list and invite chauffeurs across customers", async ({ page await expect(page.getByRole("heading", { name: "Chauffører" })).toBeVisible({ timeout: 30_000 }); await expect(page.getByText("12345678 - Nordic Transport")).toBeVisible(); await expect(page.getByText("87654321 - City Logistics")).toBeVisible(); - await expect(page.getByText("Grant #510")).toBeVisible(); - const actions = await openSubuserActions(page, 51); - await expect(actions.getByTestId("subuser-resend-51")).toBeVisible(); + await expect(page.getByText("Pending Super Driver")).toHaveCount(1); + await expect(page.getByTestId("subuser-expand-51")).toBeVisible(); + await page.getByTestId("subuser-expand-51").click(); + await expect(page.getByTestId("subuser-grant-51-510")).toContainText("12345678 - Nordic Transport"); + await expect(page.getByTestId("subuser-grant-51-511")).toContainText("87654321 - City Logistics"); + const actions = page.getByTestId("subuser-grant-actions-51-510"); + await actions.locator(".action-settings-wheel-trigger").click(); + await expect(actions.getByTestId("subuser-resend-51-510")).toBeVisible(); - await actions.getByTestId("subuser-resend-51").click(); + await actions.getByTestId("subuser-resend-51-510").click(); await expect(page.getByRole("heading", { name: "Chauffør oprettet" })).toBeVisible(); await page.getByRole("button", { name: "Luk" }).click(); - expect(resendPayloads).toContainEqual({ id: 51, grant_id: 510 }); + expect(resendPayloads).toContainEqual({ id: 51, grant_id: 510, customer_number: 12345678 }); await page.getByRole("button", { name: /Invit.*chauff/i }).click(); await expect(page.locator("#subuser-form-customer-number")).toBeVisible(); @@ -677,6 +797,7 @@ test("superusers can list and invite chauffeurs across customers", async ({ page name: "Super Invited Driver", phone_country_code: 45, phone: 77777777, + permission_template_key: "driver", }); }); diff --git a/tests/e2e/superuser-department-employees.spec.ts b/tests/e2e/superuser-department-employees.spec.ts new file mode 100644 index 00000000..83e5390f --- /dev/null +++ b/tests/e2e/superuser-department-employees.spec.ts @@ -0,0 +1,226 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { API_HOST, apiPathPattern, 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 departments = [ + { id: 1, name: "Assigned Depot", description: "", visible: true, archived: false, custom_pricing_only: true }, + { id: 2, name: "Remote Depot", description: "", visible: true, archived: false, custom_pricing_only: false }, +]; + +const role = { + key: "department_admin", + label: "Department admin", + description: "Can manage department prices, customer pricing, and employee access.", + permission_groups: [ + { + key: "limited_backoffice", + capabilities: ["open_limited_backoffice", "manage_employee_access"], + }, + ], +}; + +const roles = [ + { + key: "viewer", + label: "Deactivated", + description: "Keeps the employee registered without order, booking, or management permissions.", + permission_groups: [{ key: "account", capabilities: ["sign_in", "view_own_permissions"] }], + }, + role, +]; + +const employees = [ + { + id: 501, + user_id: 501, + customer_number: 0, + display_name: "Casey Clerk", + email: "casey@example.com", + phone_country_code: 45, + phone: 12345678, + active: true, + role, + departments, + created_at: "2026-01-01 00:00:00", + updated_at: "2026-01-01 00:00:00", + }, + { + id: 502, + user_id: 502, + customer_number: 0, + display_name: "Riley Remote", + email: "riley@example.com", + phone_country_code: null, + phone: null, + active: true, + role: roles[0], + departments: [departments[1]], + created_at: "2026-01-01 00:00:00", + updated_at: "2026-01-01 00:00:00", + }, +]; + +async function seedSuperuser(page: Page) { + await page.addInitScript(() => { + window.localStorage.setItem("locale", "en"); + }); + await seedAuthenticatedState(page, "superuser-department-employees-token"); + await mockApi(page, { + authenticated: true, + permissions: [ + "superuser", + "user", + "limited_backoffice_access", + "limited_backoffice_employees_manage", + "superuser_fetch_department", + ], + }); +} + +async function installRoutes(page: Page) { + const calls: string[] = []; + const createCalls: unknown[] = []; + const updateCalls: unknown[] = []; + const currentEmployees = { value: structuredClone(employees) }; + + await page.route(apiPathPattern("/superuser/department"), async (route) => { + const departmentId = Number(new URL(route.request().url()).searchParams.get("department_id")); + const department = departments.find((item) => item.id === departmentId) || departments[0]; + await route.fulfill( + json({ + data: { + ...department, + branding: null, + created_at: "2026-01-01 00:00:00", + updated_at: "2026-07-06 09:30:00", + }, + }) + ); + }); + + await page.route(API_HOST, async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const pathname = url.pathname; + const method = request.method().toUpperCase(); + calls.push(`${method} ${pathname}`); + + if (pathname.endsWith("/limited-backoffice/departments") && method === "GET") { + await route.fulfill(json({ data: departments })); + return; + } + + if (pathname.endsWith("/limited-backoffice/roles") && method === "GET") { + await route.fulfill(json({ data: roles })); + return; + } + + if (pathname.endsWith("/limited-backoffice/employees") && method === "GET") { + await route.fulfill(json({ data: currentEmployees.value })); + return; + } + + if (pathname.endsWith("/limited-backoffice/employees") && method === "POST") { + const body = request.postDataJSON?.() || {}; + createCalls.push(body); + currentEmployees.value.unshift({ + id: 900, + user_id: 900, + customer_number: 0, + display_name: body.display_name, + email: body.email, + phone_country_code: body.phone_country_code, + phone: body.phone, + active: true, + role, + departments: departments.filter((department) => body.department_ids?.includes(department.id)), + created_at: "2026-07-08 00:00:00", + updated_at: "2026-07-08 00:00:00", + }); + await route.fulfill(json({ data: currentEmployees.value[0] })); + return; + } + + const employeeMatch = pathname.match(/\/limited-backoffice\/employees\/(\d+)$/); + if (employeeMatch && method === "PUT") { + const body = request.postDataJSON?.() || {}; + updateCalls.push(body); + await route.fulfill(json({ data: { ...currentEmployees.value[0], ...body } })); + return; + } + + await route.fallback(); + }); + + return { calls, createCalls, updateCalls }; +} + +test.describe("Superuser department employees", () => { + test("renders department-scoped employees and keeps multi-department access editable", async ({ page }, testInfo) => { + test.skip(!isDesktopProject(testInfo), "Desktop only"); + + await seedSuperuser(page); + const api = await installRoutes(page); + + await page.goto("/superuser/departments/1/employees", { waitUntil: "domcontentloaded" }); + + await expect(page.getByRole("heading", { name: "Assigned Depot" })).toBeVisible(); + await expect(page.getByTestId("limited-backoffice")).toHaveCount(0); + await expect(page.getByText("Employees", { exact: true })).toBeVisible(); + await expect(page.getByTestId("limited-employee-row-501")).toContainText("Casey Clerk"); + await expect(page.getByTestId("limited-employee-row-502")).toHaveCount(0); + await expect(page.getByTestId("limited-employee-department-1")).toBeChecked(); + await expect(page.getByTestId("limited-employee-department-2")).not.toBeChecked(); + + await page.getByTestId("limited-employee-department-2").check(); + await page.getByTestId("limited-employee-name").fill("Morgan Manager"); + await page.getByTestId("limited-employee-email").fill("morgan@example.com"); + await page.getByTestId("limited-employee-password").fill("Secret123!"); + await page.getByTestId("limited-employee-role").selectOption("department_admin"); + await page.getByTestId("limited-employee-save").click(); + + expect(api.createCalls[0]).toMatchObject({ + display_name: "Morgan Manager", + email: "morgan@example.com", + role_key: "department_admin", + department_ids: [1, 2], + }); + + await page.getByTestId("limited-employee-edit-501").click(); + await expect(page.getByTestId("limited-employee-department-1")).toBeChecked(); + await expect(page.getByTestId("limited-employee-department-2")).toBeChecked(); + await page.getByTestId("limited-employee-name").fill("Casey Lead"); + await page.getByTestId("limited-employee-save").click(); + + expect(api.updateCalls[0]).toMatchObject({ + display_name: "Casey Lead", + department_ids: [1, 2], + }); + + await page.getByTestId("limited-employee-role-help").click(); + await expect(page.getByTestId("limited-role-permissions-modal")).toBeVisible(); + await expect(page.locator("body")).not.toContainText("department_access_1"); + await expect(page.locator("body")).not.toContainText("limited_backoffice_employees_manage"); + await expect(page.locator("body")).not.toContainText("raw_permissions"); + }); + + test("does not fetch employees for unknown department tabs", async ({ page }, testInfo) => { + test.skip(!isDesktopProject(testInfo), "Desktop only"); + + await seedSuperuser(page); + const api = await installRoutes(page); + + await page.goto("/superuser/departments/3/employees", { waitUntil: "domcontentloaded" }); + + await expect(page.getByTestId("limited-employees-forbidden")).toBeVisible(); + await expect(page.getByTestId("limited-employees-table")).toHaveCount(0); + expect(api.calls.some((call) => call.includes("/limited-backoffice/employees"))).toBe(false); + }); +}); diff --git a/tests/e2e/superuser-department-pricing-custom-only.spec.ts b/tests/e2e/superuser-department-pricing-custom-only.spec.ts index 2704d757..708024d4 100644 --- a/tests/e2e/superuser-department-pricing-custom-only.spec.ts +++ b/tests/e2e/superuser-department-pricing-custom-only.spec.ts @@ -32,7 +32,10 @@ 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; + const customPricingOnlyByDepartment: Record = { + 42: "0", + 43: "0", + }; let receivedToggleBody: Record | null = null; let receivedCustomerPricingBody: Record | null = null; @@ -45,7 +48,7 @@ test.describe("Superuser department custom-only pricing", () => { id: 42, name: "Custom Pricing Department", description: "E2E department", - custom_pricing_only: true, + custom_pricing_only: customPricingOnlyByDepartment[42], }, customer: { id: 601, @@ -101,21 +104,23 @@ test.describe("Superuser department custom-only pricing", () => { }); await page.route("**/api/superuser/department/prices**", async (route) => { + const departmentId = Number(new URL(route.request().url()).searchParams.get("department_id")); await route.fulfill( json({ - data: [{ id: 100, department_id: 42, product_id: 11, price: 2222 }], + data: departmentId === 42 ? [{ id: 100, department_id: 42, product_id: 11, price: 2222 }] : [], }) ); }); await page.route(/\/api\/superuser\/department(?:\?.*)?$/i, async (route) => { + const departmentId = Number(new URL(route.request().url()).searchParams.get("department_id")); await route.fulfill( json({ data: { - id: 42, - name: "Custom Pricing Department", + id: departmentId, + name: departmentId === 43 ? "Fallback Pricing Department" : "Custom Pricing Department", description: "E2E department", - custom_pricing_only: customPricingOnly, + custom_pricing_only: customPricingOnlyByDepartment[departmentId] ?? "0", created_at: now, updated_at: now, }, @@ -131,7 +136,7 @@ test.describe("Superuser department custom-only pricing", () => { } receivedToggleBody = request.postDataJSON() as Record; - customPricingOnly = Boolean(receivedToggleBody.custom_pricing_only); + customPricingOnlyByDepartment[Number(receivedToggleBody.id)] = receivedToggleBody.custom_pricing_only ? "1" : "0"; await route.fulfill(json({ data: { message: "Department updated successfully" } })); }); @@ -161,6 +166,7 @@ test.describe("Superuser department custom-only pricing", () => { await expect(page.getByRole("heading", { name: "Custom Pricing Department" })).toBeVisible(); await expect(page.getByTestId("department-custom-pricing-settings")).toBeVisible(); + await expect(page.getByTestId("department-custom-pricing-only-toggle")).not.toBeChecked(); await expect(page.getByTestId("department-customer-pricing-link")).toHaveCount(0); await expect(page.getByTestId("department-price-cell-10")).toContainText("-"); await expect(page.getByTestId("department-price-cell-11")).toContainText("2.222"); @@ -201,5 +207,12 @@ test.describe("Superuser department custom-only pricing", () => { }, ], }); + + await page.goto("/superuser/departments/43/prices", { waitUntil: "domcontentloaded" }); + + await expect(page.getByRole("heading", { name: "Fallback Pricing Department" })).toBeVisible(); + await expect(page.getByTestId("department-custom-pricing-only-toggle")).not.toBeChecked(); + await expect(page.getByTestId("department-customer-pricing-link")).toHaveCount(0); + await expect(page.getByTestId("department-price-cell-10")).toContainText("-"); }); }); diff --git a/tests/e2e/superuser-orders-date-filters.spec.ts b/tests/e2e/superuser-orders-date-filters.spec.ts index 62bcdfb5..4f136472 100644 --- a/tests/e2e/superuser-orders-date-filters.spec.ts +++ b/tests/e2e/superuser-orders-date-filters.spec.ts @@ -82,7 +82,23 @@ test.describe("superuser order date filters", () => { await expect(page.getByText("Fejlstatus", { exact: true })).toHaveCount(0); await expect(otherFiltersButton).toBeVisible(); - await expect(page.getByTestId("invoice-orders-other-filters-count")).toHaveText("3"); + const otherFiltersCount = page.getByTestId("invoice-orders-other-filters-count"); + await expect(otherFiltersCount).toHaveText("3"); + await expect + .poll(async () => { + const buttonBox = await otherFiltersButton.boundingBox(); + const countBox = await otherFiltersCount.boundingBox(); + + if (!buttonBox || !countBox) { + return false; + } + + const countCenterX = countBox.x + countBox.width / 2; + const countCenterY = countBox.y + countBox.height / 2; + + return countCenterX > buttonBox.x + buttonBox.width - 12 && countCenterY < buttonBox.y + 8; + }) + .toBe(true); await expect(otherFiltersMenu).toBeHidden(); await otherFiltersButton.click(); @@ -100,7 +116,7 @@ test.describe("superuser order date filters", () => { } await page.getByTestId("invoice-orders-other-filter-processor").selectOption("2"); - await expect(page.getByTestId("invoice-orders-other-filters-count")).toHaveText("4"); + await expect(otherFiltersCount).toHaveText("4"); await expect.poll(() => orderRequests.some((request) => request.filters.includes("processor:2"))).toBeTruthy(); await page.keyboard.press("Escape"); diff --git a/tests/e2e/superuser-users.spec.ts b/tests/e2e/superuser-users.spec.ts index 06a0a684..d9755d33 100644 --- a/tests/e2e/superuser-users.spec.ts +++ b/tests/e2e/superuser-users.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, type Request } from "@playwright/test"; import { apiPathPattern, mockApi, seedAuthenticatedState } from "./support/network.js"; import { isDesktopProject } from "./support/projects"; @@ -173,12 +173,65 @@ const overviewVehicles = [ }, ]; +const scopedSubusers = [ + { + id: 701, + username: "driver-one", + name: "Driver One", + email: "driver-one@example.test", + phone_country_code: 45, + phone: 70100100, + created_at: "2026-03-01 08:00:00", + updated_at: "2026-03-02 09:00:00", + setup_required: false, + can_resend_invite: false, + customer_number: 12345, + customer_name: "Anna Transport", + grant_id: 801, + grant_enabled: true, + grant_note: "Primary driver", + grant_permissions: ["VEHICLES_LIST", "BOOKINGS_LIST"], + permissions: ["VEHICLES_LIST", "BOOKINGS_LIST"], + access_state: "active", + }, + { + id: 702, + username: null, + name: "Pending Driver", + email: null, + phone_country_code: 45, + phone: 70200200, + created_at: "2026-03-03 08:00:00", + updated_at: "2026-03-03 08:00:00", + setup_required: true, + can_resend_invite: true, + customer_number: 12345, + customer_name: "Anna Transport", + grant_id: 802, + grant_enabled: true, + grant_note: "", + grant_permissions: ["VEHICLES_LIST"], + permissions: ["VEHICLES_LIST"], + access_state: "pending_setup", + }, +]; + +const scopedSubusersSummary = { + total: 2, + active: 1, + pending_setup: 1, + disabled: 0, +}; + const pricingProducts = [ { id: 101, category: 7, name: "Foam Deluxe", price: 199, + is_wash: true, + subscription_allowed: true, + addons: [], }, ]; @@ -212,6 +265,14 @@ const clickRolePermissionSwitch = async (page: Page, permission: string) => { await page.locator(`label[for="role-permission-${permission}"]`).click(); }; +const readRequestJson = (request: Request): Record | null => { + try { + return request.postDataJSON(); + } catch { + return null; + } +}; + const setupOverviewApi = async (page, options: { failDetailUntilEnabled?: boolean } = {}) => { let detailRequests = 0; let detailSuccessEnabled = !options.failDetailUntilEnabled; @@ -220,6 +281,8 @@ const setupOverviewApi = async (page, options: { failDetailUntilEnabled?: boolea let customerAttributes = Array.isArray(detailUser.attributes) ? [...detailUser.attributes] : []; const customerAttributeCalls: Array<{ action: string; attribute: string }> = []; const rolePermissionCalls: Array<{ action: string; groupId: number; permission: string }> = []; + const subuserRequests: Array<{ method: string; pathname: string; body: Record | null }> = []; + const vehicleRequests: Array<{ method: string; pathname: string; body: Record | null }> = []; const rolePermissionsById = new Map([ [1, [...overviewUser.permissions, "list_orders"]], [2, ["user", "get_user"]], @@ -387,6 +450,180 @@ const setupOverviewApi = async (page, options: { failDetailUntilEnabled?: boolea await route.fulfill(json({ data: customerAttributes })); }); + await page.route(apiPathPattern("/superuser/users/11/vehicles"), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const method = request.method(); + const body = method === "GET" ? null : readRequestJson(request); + vehicleRequests.push({ method, pathname: url.pathname, body }); + + if (method === "GET") { + await route.fulfill( + json({ + data: overviewVehicles, + meta: { + pagination: { + page: 1, + per_page: 100, + total: overviewVehicles.length, + }, + user_context: { + user_id: 11, + customer_number: 12345, + customer_name: "Anna Transport", + }, + }, + }) + ); + return; + } + + if (method === "POST") { + await route.fulfill( + json({ + data: { + id: 303, + customer_id: 12345, + reg: body?.reg || "NEW123", + type: Number(body?.type || 1), + reference: body?.reference || null, + wash_subscription: Boolean(body?.wash_subscription), + xlvask: false, + addons: { list: [] }, + }, + }) + ); + return; + } + + if (method === "PUT" || method === "DELETE") { + await route.fulfill(json({ data: { message: "OK" } })); + return; + } + + await route.fulfill(json({ data: { message: "Unsupported" } }, 405)); + }); + + await page.route(apiPathPattern("/superuser/users/11/vehicles/summary"), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const method = request.method(); + const body = method === "GET" ? null : readRequestJson(request); + vehicleRequests.push({ method, pathname: url.pathname, body }); + + await route.fulfill( + json({ + data: { + total: 2, + wash_subscription: 1, + self_service: 1, + }, + meta: { + user_context: { + user_id: 11, + customer_number: 12345, + customer_name: "Anna Transport", + }, + }, + }) + ); + }); + + await page.route(apiPathPattern("/superuser/users/11/subusers"), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const method = request.method(); + const body = method === "GET" ? null : readRequestJson(request); + subuserRequests.push({ method, pathname: url.pathname, body }); + + if (method === "GET" && url.pathname.endsWith("/summary")) { + await route.fulfill( + json({ data: scopedSubusersSummary, meta: { user_context: { user_id: 11, customer_number: 12345 } } }) + ); + return; + } + + if (method === "GET") { + await route.fulfill( + json({ + data: scopedSubusers, + meta: { + pagination: { + page: 1, + per_page: 100, + total: scopedSubusers.length, + }, + user_context: { + user_id: 11, + customer_number: 12345, + customer_name: "Anna Transport", + }, + subusers_summary: scopedSubusersSummary, + }, + }) + ); + return; + } + + if (method === "PATCH") { + await route.fulfill(json({ data: { message: "OK" } })); + return; + } + + if (method === "POST") { + await route.fulfill( + json({ + data: { + subuser: scopedSubusers[1], + invite: { + setup_link: "https://example.test/subusers/setup?token=test-token", + delivery: { + status: "sent", + }, + }, + }, + }) + ); + return; + } + + await route.fulfill(json({ data: { message: "Unsupported" } }, 405)); + }); + + await page.route(apiPathPattern("/superuser/users/11/subusers/summary"), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const method = request.method(); + const body = method === "GET" ? null : readRequestJson(request); + subuserRequests.push({ method, pathname: url.pathname, body }); + + await route.fulfill( + json({ data: scopedSubusersSummary, meta: { user_context: { user_id: 11, customer_number: 12345 } } }) + ); + }); + + await page.route(apiPathPattern("/superuser/users/11/subusers/invite"), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const method = request.method(); + const body = method === "GET" ? null : readRequestJson(request); + subuserRequests.push({ method, pathname: url.pathname, body }); + + await route.fulfill( + json({ + data: { + subuser: scopedSubusers[1], + invite: { + setup_link: "https://example.test/subusers/setup?token=test-token", + delivery: { + status: "sent", + }, + }, + }, + }) + ); + }); + await page.route(usersApiPattern, async (route) => { const request = route.request(); @@ -445,6 +682,8 @@ const setupOverviewApi = async (page, options: { failDetailUntilEnabled?: boolea detailUser: () => detailUser, lastUserUpdatePayload: () => lastUserUpdatePayload, rolePermissionCalls: () => rolePermissionCalls, + subuserRequests: () => subuserRequests, + vehicleRequests: () => vehicleRequests, }; }; @@ -620,7 +859,16 @@ test.describe("Superuser user overview", () => { await seedAuthenticatedState(page, "superuser-user-overview-token"); await mockApi(page, { authenticated: true, - permissions: ["superuser", "user", "get_user", "set_custom_price"], + permissions: [ + "superuser", + "user", + "get_user", + "set_custom_price", + "list_vehicles_other", + "add_vehicle_other", + "edit_vehicle_other", + "delete_vehicle_other", + ], pos: { vehicles: overviewVehicles, }, @@ -702,9 +950,15 @@ test.describe("Superuser user overview", () => { path: "/superuser/users/11/security", contentTestId: "superuser-user-security-page", }, + { + key: "subusers", + path: "/superuser/users/11/subusers", + contentTestId: "superuser-user-subusers-page", + }, { key: "vehicles", path: "/superuser/users/11/vehicles", + contentTestId: "superuser-user-vehicles-page", }, { key: "overview", @@ -1022,7 +1276,7 @@ test.describe("Superuser user overview", () => { }); test("renders the user-specific subpages with named headers and contextual summaries", async ({ page }, testInfo) => { - await setupOverviewApi(page); + const overviewApi = await setupOverviewApi(page); const expectMultipleTileColumns = isDesktopProject(testInfo); await page.goto("/superuser/users/11/orders", { waitUntil: "domcontentloaded" }); @@ -1079,7 +1333,7 @@ test.describe("Superuser user overview", () => { ); await page.goto("/superuser/users/11/vehicles", { waitUntil: "domcontentloaded" }); - await expectUserDetailShell(page, "vehicles"); + await expectUserDetailShell(page, "vehicles", "superuser-user-vehicles-page"); await expect(page).toHaveTitle("Anna Transport | Truck Wash"); await expect( page.getByText( @@ -1087,9 +1341,41 @@ test.describe("Superuser user overview", () => { ) ).toBeVisible(); await expectTileGridLayout(page, "superuser-user-vehicles-tiles", expectMultipleTileColumns); + await expect + .poll(() => + overviewApi + .vehicleRequests() + .some( + (request) => request.method === "GET" && request.pathname.endsWith("/superuser/users/11/vehicles/summary") + ) + ) + .toBe(true); + await expectTileGridLayout(page, "superuser-user-vehicles-metrics", expectMultipleTileColumns); await expect(page.getByTestId("superuser-user-vehicles-summary")).toContainText("2"); + await expect(page.getByTestId("superuser-user-vehicles-summary")).toContainText("1"); await expect(page.getByTestId("superuser-user-vehicles-subscription-invoicing")).toContainText("694"); await expect(page.getByTestId("superuser-user-vehicles-mass-insert")).toBeVisible(); + await expect(page.getByTestId("superuser-user-vehicles-table-panel")).toContainText("AA11223"); + await expect(page.getByTestId("superuser-user-vehicles-table-panel")).toContainText("BB44556"); + await expect(page.getByTestId("superuser-user-vehicles-table-panel")).not.toContainText(/Anna Transport/); + + await page.goto("/superuser/users/11/subusers", { waitUntil: "domcontentloaded" }); + await expectUserDetailShell(page, "subusers", "superuser-user-subusers-page"); + await expect(page).toHaveTitle("Anna Transport | Truck Wash"); + await expect(page.getByText(/Drivers and customer access grants|Chauffører og kundeadgange/)).toBeVisible(); + await expect + .poll(() => + overviewApi + .subuserRequests() + .some( + (request) => request.method === "GET" && request.pathname.endsWith("/superuser/users/11/subusers/summary") + ) + ) + .toBe(true); + await expectTileGridLayout(page, "superuser-user-subusers-metrics", expectMultipleTileColumns); + await expect(page.getByTestId("superuser-user-subusers-summary")).toContainText("2"); + await expect(page.getByTestId("superuser-user-subusers-table-panel")).toContainText("Driver One"); + await expect(page.getByTestId("superuser-user-subusers-table-panel")).not.toContainText(/Anna Transport/); await page.goto("/superuser/users/11/xlvask", { waitUntil: "domcontentloaded" }); await expectUserDetailShell(page, "xlvask"); @@ -1103,4 +1389,63 @@ test.describe("Superuser user overview", () => { await expect(page.getByTestId("superuser-user-xlvask-summary")).toContainText("Anna XLVask"); await expect(page.getByTestId("superuser-user-xlvask-vehicles")).toContainText("XL11223"); }); + + test("invites drivers from the user-scoped subusers page without sending a customer number", async ({ page }) => { + await setupOverviewApi(page); + + await page.goto("/superuser/users/11/subusers", { waitUntil: "domcontentloaded" }); + await expectUserDetailShell(page, "subusers", "superuser-user-subusers-page"); + + await page.getByTestId("superuser-user-subusers-invite").click(); + await page.locator("#subuser-form-name").fill("Scoped Invite Driver"); + await page.locator("#subuser-form-phone-country-code").fill("45"); + await page.locator("#subuser-form-phone").fill("70300300"); + const inviteRequestPromise = page.waitForRequest((request) => { + const url = new URL(request.url()); + + return request.method() === "POST" && url.pathname.endsWith("/superuser/users/11/subusers/invite"); + }); + await page.getByRole("button", { name: /Send invitation|Send invitation|Send invitation/i }).click(); + const inviteRequest = await inviteRequestPromise; + await expect(page.getByText(/Invitationen blev sendt på SMS|SMS/i)).toBeVisible(); + + expect(inviteRequest.postDataJSON()).toMatchObject({ + name: "Scoped Invite Driver", + phone_country_code: 45, + phone: 70300300, + }); + expect(inviteRequest.postDataJSON()).not.toHaveProperty("customer_number"); + }); + + test("adds vehicles from the user-scoped vehicles page without changing customer scope", async ({ page }) => { + const overviewApi = await setupOverviewApi(page); + + await page.goto("/superuser/users/11/vehicles", { waitUntil: "domcontentloaded" }); + await expectUserDetailShell(page, "vehicles", "superuser-user-vehicles-page"); + + await page.getByTestId("superuser-user-vehicles-add").click(); + await page.locator("#type").selectOption("101"); + await page.locator("#reg").fill("CC77889"); + await page.locator("#wash_subscription").selectOption("true"); + const vehicleRequestPromise = page.waitForRequest((request) => { + const url = new URL(request.url()); + + return request.method() === "POST" && url.pathname.endsWith("/superuser/users/11/vehicles"); + }); + await page.locator(".swal2-confirm").click(); + const vehicleRequest = await vehicleRequestPromise; + + expect(vehicleRequest.postDataJSON()).toMatchObject({ + customer_id: 12345, + reg: "CC77889", + type: 101, + wash_subscription: true, + }); + expect(overviewApi.vehicleRequests()).toContainEqual( + expect.objectContaining({ + method: "POST", + pathname: expect.stringMatching(/\/superuser\/users\/11\/vehicles$/), + }) + ); + }); }); diff --git a/tests/e2e/support/bookingFlow.ts b/tests/e2e/support/bookingFlow.ts index b1daf575..e1e70a0c 100644 --- a/tests/e2e/support/bookingFlow.ts +++ b/tests/e2e/support/bookingFlow.ts @@ -34,7 +34,7 @@ function formatSummaryDate(value: Date) { }); } -async function selectBookingDateTime(page: Page) { +export async function selectBookingDateTime(page: Page) { const selectedDate = addDays(new Date(), 2); const selectedDayTestId = `booking-date-time-day-${formatDateKey(selectedDate)}`; const selectedSlotTestId = "booking-date-time-slot-14-00"; @@ -139,7 +139,7 @@ async function ensureVehicleTypeSelection(page: Page) { .toBeGreaterThan(0); } -async function fillBookingField(page: Page, toggleTestId: string, inputTestId: string, value: string) { +export async function fillBookingField(page: Page, toggleTestId: string, inputTestId: string, value: string) { const input = page.locator(`[data-testid="${inputTestId}"]:visible`).first(); const toggle = page.locator(`[data-testid="${toggleTestId}"]:visible`).first(); diff --git a/tests/e2e/support/network.js b/tests/e2e/support/network.js index c492e847..69a066de 100644 --- a/tests/e2e/support/network.js +++ b/tests/e2e/support/network.js @@ -2949,6 +2949,7 @@ export function createPosFixture(overrides = {}) { ...baseFixture.paymentIntentsByOrderId, ...(overrides.paymentIntentsByOrderId || {}), }, + products: overrides.products || [...baseFixture.products, ...(overrides.extraProducts || [])], }; } @@ -2967,6 +2968,23 @@ function buildPosOrderItem(product, body, id) { }; } +function applyFixtureFinalProductPrice(product, parsedUrl, posFixture) { + if (!product || parsedUrl.searchParams.get("final_price") !== "true") { + return product; + } + + const finalPricesById = posFixture.productFinalPricesById || {}; + const price = finalPricesById[Number(product.id)] ?? finalPricesById[String(product.id)]; + if (price === undefined || price === null) { + return product; + } + + return { + ...product, + price: Number(price), + }; +} + function matchesProductCategory(product, category) { const normalizedCategory = Number(category || 0); const productCategory = Number(product?.category || 0); @@ -3473,16 +3491,20 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos await maybeDelayFixtureResponse(categoryDelayMs); if (productId > 0) { - await route.fulfill( - json({ success: true, data: (posFixture.products || []).find((product) => product.id === productId) || null }) - ); + const product = (posFixture.products || []).find((product) => product.id === productId) || null; + await route.fulfill(json({ success: true, data: applyFixtureFinalProductPrice(product, parsedUrl, posFixture) })); return true; } const products = category > 0 ? (posFixture.products || []).filter((product) => matchesProductCategory(product, category)) : posFixture.products || []; - await route.fulfill(json({ success: true, data: products })); + await route.fulfill( + json({ + success: true, + data: products.map((product) => applyFixtureFinalProductPrice(product, parsedUrl, posFixture)), + }) + ); return true; } diff --git a/tests/e2e/userBookWash.spec.ts b/tests/e2e/userBookWash.spec.ts index 06d8dc3c..23a2c8b6 100644 --- a/tests/e2e/userBookWash.spec.ts +++ b/tests/e2e/userBookWash.spec.ts @@ -2,18 +2,25 @@ import { expect, test, type Page } from "@playwright/test"; import { bookingTestData } from "./fixtures"; import { completeBookingCreationFlow, + fillBookingField, goToBookingProductSelectionStep, goToBookingProductSelectionStepWithOptions, + selectBookingDateTime, } from "./support/bookingFlow"; import { mockApi, primeMockSession } from "./support/network.js"; -async function prepareBookingPage(page: Page, pos: true | Record = true) { +async function prepareBookingPage( + page: Page, + pos: true | Record = true, + sessionData: Record = {} +) { await mockApi(page, { authenticated: true, sessionData: { display_name: "Booking User", customer_number: 12345679, permissions: ["user"], + ...sessionData, }, pos, }); @@ -72,3 +79,95 @@ test("[BOOKINGS][User][Creation] should show desktop loading states while catego await expect(page.locator('[data-testid="pos-product-card-63"]:visible').first()).toBeVisible({ timeout: 10_000 }); await expect(page.locator('[data-testid="pos-products-loading"]:visible')).toHaveCount(0, { timeout: 10_000 }); }); + +test("[BOOKINGS][User][Creation] should submit resolved wash certificate price for visible-price customers", async ({ + page, +}) => { + await prepareBookingPage( + page, + { + extraProducts: [ + { + id: 41, + name: "Vaskecertifikat", + description: "Wash certificate", + price: 0, + subscription_allowed: false, + category: 8, + piktogram: "certificate", + apply_category_discount: false, + requires_note: false, + is_wash: false, + display_in_booking_form: false, + order_priority: 5, + addons: [], + }, + ], + productFinalPricesById: { + 41: 175, + }, + }, + { + permissions: ["user", "has_attribute_showPricesOnBookingPage"], + } + ); + + const { departmentId, registrationNumber, reference, poNumber, notes, mobileWizard } = + await goToBookingProductSelectionStepWithOptions(page, bookingTestData, { + selectInitialProduct: false, + }); + + const interiorCategoryTab = page.locator('[data-testid="pos-product-category-tab-2"]:visible').first(); + if ((await interiorCategoryTab.count()) > 0) { + await interiorCategoryTab.click(); + } else { + await page.locator('[data-testid="pos-product-category-select"]:visible').first().selectOption("2"); + } + + const interiorProductCard = page.locator('[data-testid="pos-product-card-63"]:visible').first(); + await expect(interiorProductCard).toBeVisible(); + await interiorProductCard.click(); + + await expect(page.locator("body")).toContainText("Vaskecertifikat"); + await expect(page.locator("body")).toContainText(/175,00\s*kr\./); + + if (mobileWizard) { + await expect(page.getByTestId("booking-mobile-next")).toBeEnabled(); + await page.getByTestId("booking-mobile-next").click(); + } + + const selectedDateTime = await selectBookingDateTime(page); + await fillBookingField(page, "booking-reference-toggle", "booking-reference-input", reference); + await fillBookingField(page, "booking-po-toggle", "booking-po-input", poNumber); + await fillBookingField(page, "booking-notes-toggle", "booking-notes-input", notes); + + if (mobileWizard) { + await page.check("#pickup-mobile"); + await expect(page.getByTestId("booking-mobile-next")).toBeEnabled(); + await page.getByTestId("booking-mobile-next").click(); + } else { + await page.check("#pickup"); + await page.click("#go-to-confirmation-button"); + } + + await expect(page.locator("#booking-summary-title")).toBeVisible(); + await expect(page.getByTestId("booking-summary-date-time")).toContainText(selectedDateTime.summaryDateLabel); + const summaryOverlay = page.locator(".booking-summary-overlay:visible").first(); + await expect(summaryOverlay).toContainText("Vaskecertifikat"); + await expect(summaryOverlay).toContainText(/175,00\s*kr\./); + + const [request] = await Promise.all([ + page.waitForRequest((request) => request.method() === "POST" && request.url().includes("/order-bookings"), { + timeout: 10_000, + }), + summaryOverlay.getByTestId("booking-summary-confirm-body").click({ force: true }), + ]); + const payload = request.postDataJSON(); + const certificateItem = payload.items.find((item: { id?: number }) => Number(item.id) === 41); + + expect(certificateItem).toBeTruthy(); + expect(Number(certificateItem.price)).toBe(175); + expect(Number(certificateItem.quantity)).toBe(1); + expect(Number(payload.department)).toBe(departmentId); + expect(payload.reg_1).toBe(registrationNumber); +}); diff --git a/tests/e2e/userVehicles.spec.ts b/tests/e2e/userVehicles.spec.ts index 5382d8c7..070e3fca 100644 --- a/tests/e2e/userVehicles.spec.ts +++ b/tests/e2e/userVehicles.spec.ts @@ -23,6 +23,31 @@ const suppressVersionCheck = async (page) => { }); }; +const mockVehicleTypeProducts = async (page) => { + await page.route(/\/products(?:\?.*)?$/i, async (route) => { + await route.fulfill( + json({ + data: [ + { + id: 53, + name: "Forvogn", + is_wash: true, + subscription_allowed: true, + category: 4, + }, + { + id: 54, + name: "Trailer", + is_wash: true, + subscription_allowed: true, + category: 4, + }, + ], + }) + ); + }); +}; + test("[PAGES][User][/user/vehicles] shows the current vehicles overview shell", async ({ page }) => { await suppressVersionCheck(page); await loginAsUser(page); @@ -46,3 +71,35 @@ test("[PAGES][User][/user/vehicles] opens the add-vehicle dialog", async ({ page await expect(dialog.getByRole("button", { name: /gem/i })).toBeVisible(); await expect(dialog.getByRole("button", { name: /annuller/i })).toBeVisible(); }); + +test("[PAGES][User][/user/vehicles/new] creates a vehicle with a predefined type", async ({ page }) => { + await suppressVersionCheck(page); + await loginAsUser(page); + await mockVehicleTypeProducts(page); + await page.goto("/user/vehicles/new"); + + const typeSelect = page.getByTestId("user-add-vehicle-type"); + await expect(page.getByTestId("user-add-vehicle-registration")).toBeVisible(); + await expect(typeSelect).toBeVisible(); + await expect(typeSelect).toContainText("Forvogn"); + await expect(page.locator('input[placeholder="Type"]')).toHaveCount(0); + + await page.getByTestId("user-add-vehicle-registration").fill("ab12345"); + await typeSelect.selectOption("53"); + await page.getByTestId("user-add-vehicle-reference").fill("Fleet ref"); + + const [request] = await Promise.all([ + page.waitForRequest((request) => request.method() === "POST" && request.url().includes("/vehicles"), { + timeout: 10_000, + }), + page.getByTestId("user-add-vehicle-submit").click(), + ]); + + expect(request.postDataJSON()).toMatchObject({ + reg: "AB12345", + type: 53, + wash_subscription: false, + reference: "Fleet ref", + }); + await expect(page).toHaveURL(/\/user\/vehicles(?:[?#].*)?$/); +}); diff --git a/tests/unit/buefy-datepicker.spec.js b/tests/unit/buefy-datepicker.spec.js index 30c941f2..80c62bd0 100644 --- a/tests/unit/buefy-datepicker.spec.js +++ b/tests/unit/buefy-datepicker.spec.js @@ -35,6 +35,7 @@ vi.mock("buefy", async () => { default: "", }, openOnFocus: Boolean, + inline: Boolean, }, emits: ["icon-right-click"], template: ` @@ -45,6 +46,7 @@ vi.mock("buefy", async () => { :data-append-to-body="String(appendToBody)" :data-position="position" :data-open-on-focus="String(openOnFocus)" + :data-inline="String(inline)" >
@@ -141,9 +143,23 @@ describe("BuefyMonthField", () => { expect(wrapper.get("[data-testid='b-datepicker']").attributes("data-icon-right")).toBe("times-circle"); expect(wrapper.get("[data-testid='b-datepicker']").attributes("data-append-to-body")).toBe("true"); expect(wrapper.get("[data-testid='b-datepicker']").attributes("data-open-on-focus")).toBe("true"); + expect(wrapper.get("[data-testid='b-datepicker']").attributes("data-inline")).toBe("false"); await wrapper.get("[data-testid='date-clear']").trigger("click"); expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([""]); }); + + it("can render the Buefy month picker inline", () => { + const wrapper = mount(BuefyMonthField, { + props: { + modelValue: "2026-03", + inline: true, + appendToBody: false, + }, + }); + + expect(wrapper.get("[data-testid='b-datepicker']").attributes("data-inline")).toBe("true"); + expect(wrapper.get("[data-testid='b-datepicker']").attributes("data-append-to-body")).toBe("false"); + }); }); diff --git a/tests/unit/date-period-selector.spec.js b/tests/unit/date-period-selector.spec.js index a6527a04..2b2e7afd 100644 --- a/tests/unit/date-period-selector.spec.js +++ b/tests/unit/date-period-selector.spec.js @@ -179,6 +179,7 @@ const BuefyMonthFieldStub = defineComponent({ default: "", }, clearable: Boolean, + inline: Boolean, }, emits: ["update:modelValue", "change"], methods: { @@ -198,7 +199,7 @@ const BuefyMonthFieldStub = defineComponent({ }, }, template: - "", + "", }); const componentStubs = { @@ -250,6 +251,7 @@ describe("DatePeriodSelector mobile layout", () => { expect(wrapper.findAll("select")).toHaveLength(1); expect(wrapper.get("[data-testid='date-period-month']").exists()).toBe(true); + expect(wrapper.get("[data-testid='date-period-month']").attributes("data-inline")).toBe("false"); }); it("preserves the update:selection and onSelectionChange contract on mobile", async () => { @@ -414,6 +416,7 @@ describe("DatePeriodSelector month warning", () => { expect(wrapper.get("[data-testid='date-period-other-month-modal']").exists()).toBe(true); expect(wrapper.get("[data-testid='date-period-other-month-picker']").exists()).toBe(true); + expect(wrapper.get("[data-testid='date-period-other-month-picker']").attributes("data-inline")).toBe("true"); }); it("selects Anytime for empty ranges without highlighting Today", () => { diff --git a/tests/unit/playwright-pr-mapping.spec.js b/tests/unit/playwright-pr-mapping.spec.js index 1554e5cb..3e77037d 100644 --- a/tests/unit/playwright-pr-mapping.spec.js +++ b/tests/unit/playwright-pr-mapping.spec.js @@ -51,6 +51,21 @@ describe("Playwright PR mapping", () => { ).toContain("tests/e2e/superuser-department-shells.spec.js"); }); + it("maps superuser department employee changes to department employee coverage", () => { + expect(specsFor("src/views/dashboards/superUserDashboard/department/DepartmentEmployees.vue")).toContain( + "tests/e2e/superuser-department-employees.spec.ts" + ); + expect(specsFor("src/views/backoffice/components/LimitedBackofficeEmployeesManager.vue")).toContain( + "tests/e2e/superuser-department-employees.spec.ts" + ); + expect(specsFor("src/views/backoffice/components/LimitedBackofficeEmployeesManager.vue")).toContain( + "tests/e2e/limited-backoffice.spec.ts" + ); + expect( + specsFor("src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue") + ).toContain("tests/e2e/superuser-department-employees.spec.ts"); + }); + it("maps department notification table changes to the admin notification E2E coverage", () => { const notificationPaginationSpecs = specsFor( "src/components/displays/pagination/models/DepartmentPos/NotificationsPhonePagination.vue" diff --git a/tests/unit/subuser-grant-permission-nodes.spec.js b/tests/unit/subuser-grant-permission-nodes.spec.js index bf3f2d7a..a364c2be 100644 --- a/tests/unit/subuser-grant-permission-nodes.spec.js +++ b/tests/unit/subuser-grant-permission-nodes.spec.js @@ -3,35 +3,44 @@ import { mount } from "@vue/test-utils"; import { describe, expect, it, vi } from "vitest"; import SubuserGrantPermissionNodes from "@/components/displays/selectors/SubuserGrantPermissionNodes.vue"; -const getPermissionNodesMock = vi.hoisted(() => - vi.fn(async () => [ - { - group: "Bookings", - description: "Booking permissions", - nodes: [ - { - key: "BOOKINGS_LIST", - name: "List bookings", - description: "Can view bookings", - type: "LIST", - default: false, - }, - ], - }, - ]) +const getPermissionTemplatesMock = vi.hoisted(() => + vi.fn(async () => ({ + templates: [ + { + key: "deactivated", + label: "Deactivated", + description: "No active access", + enabled: false, + permissions: [], + permission_groups: [], + }, + { + key: "driver", + label: "Driver", + description: "Basic driver access", + enabled: true, + permissions: ["VEHICLES_LIST", "BOOKINGS_LIST"], + permission_groups: [{ key: "vehicles", capabilities: ["view_vehicles"] }], + }, + { + key: "booking_coordinator", + label: "Booking coordinator", + description: "Booking access", + enabled: true, + permissions: ["VEHICLES_LIST", "BOOKINGS_LIST", "BOOKINGS_EDIT"], + permission_groups: [{ key: "bookings", capabilities: ["view_bookings", "edit_bookings"] }], + }, + ], + groups: [], + })) ); -const savePermissionsMock = vi.hoisted(() => vi.fn()); - vi.mock("@/components/session/token/SessionUser.vue", () => ({ SessionUser: { objects: { subuser_grants: { functions: { - getPermissionNodes: getPermissionNodesMock, - }, - set: { - permissions: savePermissionsMock, + getPermissionTemplates: getPermissionTemplatesMock, }, }, }, @@ -44,28 +53,58 @@ const flushMicrotasks = async () => { }; describe("SubuserGrantPermissionNodes", () => { - it("keeps permission changes local and emits the draft instead of auto-saving", async () => { + it("emits a template payload when a simple access profile is selected", async () => { const wrapper = mount(SubuserGrantPermissionNodes, { props: { grant: { id: 91, name: "Demo driver", - permissions: ["BOOKINGS_LIST"], + permissions: ["VEHICLES_LIST", "BOOKINGS_LIST"], + permission_template_key: "driver", }, - permissions: ["BOOKINGS_LIST"], + permissions: ["VEHICLES_LIST", "BOOKINGS_LIST"], + templateKey: "driver", }, }); await flushMicrotasks(); - expect(getPermissionNodesMock).toHaveBeenCalledTimes(1); - - const checkbox = wrapper.get('input[type="checkbox"]'); - await checkbox.setValue(false); + expect(getPermissionTemplatesMock).toHaveBeenCalledTimes(1); + await wrapper.get('[data-testid="permission-template-booking_coordinator"]').trigger("click"); await flushMicrotasks(); - const emitted = wrapper.emitted("update:permissions") || []; - expect(emitted.at(-1)?.[0]).toEqual([]); - expect(savePermissionsMock).not.toHaveBeenCalled(); + const emitted = wrapper.emitted("update:access") || []; + expect(emitted.at(-1)?.[0]).toMatchObject({ + permission_template_key: "booking_coordinator", + permissions: ["VEHICLES_LIST", "BOOKINGS_LIST", "BOOKINGS_EDIT"], + }); + expect(wrapper.find('[data-testid="permission-node-checkbox-SUBUSERS_LIST"]').exists()).toBe(false); + }); + + it("only exposes custom permission checkboxes when advanced access is allowed", async () => { + const wrapper = mount(SubuserGrantPermissionNodes, { + props: { + grant: { + id: 91, + name: "Demo driver", + permissions: ["VEHICLES_LIST"], + permission_template_key: "custom", + }, + permissions: ["VEHICLES_LIST"], + templateKey: "custom", + allowAdvanced: true, + }, + }); + + await flushMicrotasks(); + + await wrapper.get('[data-testid="permission-node-checkbox-SUBUSERS_LIST"] input').setValue(true); + await flushMicrotasks(); + + const emitted = wrapper.emitted("update:access") || []; + expect(emitted.at(-1)?.[0]).toMatchObject({ + permission_template_key: "custom", + permissions: expect.arrayContaining(["VEHICLES_LIST", "SUBUSERS_LIST"]), + }); }); }); diff --git a/tests/unit/superuser-department-overview.spec.js b/tests/unit/superuser-department-overview.spec.js index 07a0327c..1b66b629 100644 --- a/tests/unit/superuser-department-overview.spec.js +++ b/tests/unit/superuser-department-overview.spec.js @@ -288,17 +288,25 @@ describe("Superuser department overview", () => { expect(wrapper.find('[data-testid="department-overview-hardware"]').exists()).toBe(false); }); - it("uses translated reactive department tabs and pushes the selected route", async () => { + it("uses translated reactive department segmented tabs", async () => { mocks.route.path = "/superuser/departments/1/gateways"; - const wrapper = mountWithApp(DepartmentNavigation, { messages }); - const activeTab = wrapper.find(".tabs li.is-active"); + const wrapper = mountWithApp(DepartmentNavigation, { + messages, + global: { + stubs: { + RouterLink: { + props: ["to"], + template: "", + }, + }, + }, + }); + const activeTab = wrapper.get(".superuser-overview-segmented-tabs__item.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/prices"); + const tabs = wrapper.findAll(".superuser-overview-segmented-tabs__item"); + expect(tabs.find((tab) => tab.text() === "Pricing").attributes("href")).toBe("/superuser/departments/1/prices"); }); }); diff --git a/tests/unit/superuser-user-overview-source.spec.js b/tests/unit/superuser-user-overview-source.spec.js index 8f8fb8ae..cd0d9c5b 100644 --- a/tests/unit/superuser-user-overview-source.spec.js +++ b/tests/unit/superuser-user-overview-source.spec.js @@ -13,7 +13,7 @@ describe("Superuser user overview source", () => { "src/views/dashboards/superUserDashboard/user/UserOrders.vue", "src/views/dashboards/superUserDashboard/user/UserPricing.vue", "src/views/dashboards/superUserDashboard/user/UserOther.vue", - "src/views/dashboards/superUserDashboard/user/UserWashSubscriptions.vue", + "src/views/dashboards/superUserDashboard/user/UserVehicles.vue", "src/views/dashboards/superUserDashboard/user/UserXLVask.vue", "src/views/dashboards/superUserDashboard/user/displays/UserDefaultDepartment.vue", "src/views/dashboards/superUserDashboard/user/displays/UserFixedPricing.vue", @@ -50,7 +50,7 @@ describe("Superuser user overview source", () => { "superuser-user-other-tiles", ], [ - "src/views/dashboards/superUserDashboard/user/UserWashSubscriptions.vue", + "src/views/dashboards/superUserDashboard/user/UserVehicles.vue", "SuperuserUserPanelGrid", "superuser-user-vehicles-tiles", ], diff --git a/tests/unit/user-add-vehicle-form.spec.js b/tests/unit/user-add-vehicle-form.spec.js new file mode 100644 index 00000000..08408a3b --- /dev/null +++ b/tests/unit/user-add-vehicle-form.spec.js @@ -0,0 +1,138 @@ +// @vitest-environment jsdom +import { nextTick } from "vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mountWithApp } from "./helpers/mountWithApp.js"; + +const formMocks = vi.hoisted(() => ({ + addVehicleUser: vi.fn(), + parseErrorMessage: vi.fn(), + push: vi.fn(), + vehicleTypeOptions: vi.fn(), +})); + +vi.mock("vue-router", () => ({ + useRouter: () => ({ + push: formMocks.push, + }), +})); + +vi.mock("@/components/session/user/UserVehicleRequest.vue", () => ({ + addVehicleUser: formMocks.addVehicleUser, +})); + +vi.mock("@/components/session/token/SessionUser.vue", () => { + const sessionUser = { + functions: { + parseErrorMessage: formMocks.parseErrorMessage, + }, + objects: { + vehicles: { + columns: { + type: { + options: formMocks.vehicleTypeOptions, + }, + }, + }, + }, + }; + + return { + SessionUser: sessionUser, + default: sessionUser, + }; +}); + +import AddVehicleForm from "@/components/displays/user/vehicles/addVehicleForm.vue"; + +const messages = { + en: { + common: { + reference: "Reference", + }, + user_vehicles: { + add_vehicle: "Add vehicle", + license_plate_label: "Registration", + type_label: "Type", + }, + vehicles: { + add_modal: { + reference_placeholder: "Optional reference", + registration_placeholder: "Registration number", + type_load_error: "Unable to load vehicle types.", + type_placeholder: "Select vehicle type", + }, + }, + }, +}; + +const flushAll = async () => { + await Promise.resolve(); + await nextTick(); + await Promise.resolve(); + await nextTick(); +}; + +const mountComponent = () => mountWithApp(AddVehicleForm, { messages }); + +describe("user add vehicle form", () => { + beforeEach(() => { + vi.clearAllMocks(); + formMocks.vehicleTypeOptions.mockResolvedValue([ + { + id: 53, + name: "Forvogn", + }, + ]); + formMocks.addVehicleUser.mockResolvedValue({ + id: 123, + }); + formMocks.parseErrorMessage.mockReturnValue(null); + }); + + it("loads predefined vehicle type options and submits the selected vehicle", async () => { + const wrapper = mountComponent(); + await flushAll(); + + const typeSelect = wrapper.get('[data-testid="user-add-vehicle-type"]'); + expect(typeSelect.findAll("option").map((option) => option.text())).toContain("Forvogn"); + expect(wrapper.get('[data-testid="user-add-vehicle-submit"]').attributes("disabled")).toBeDefined(); + + await wrapper.get('[data-testid="user-add-vehicle-registration"]').setValue("ab12345"); + await typeSelect.setValue("53"); + await wrapper.get('[data-testid="user-add-vehicle-reference"]').setValue("Fleet ref"); + await nextTick(); + + expect(wrapper.get('[data-testid="user-add-vehicle-submit"]').attributes("disabled")).toBeUndefined(); + await wrapper.get("form").trigger("submit.prevent"); + await flushAll(); + + expect(formMocks.addVehicleUser).toHaveBeenCalledWith("AB12345", 53, "Fleet ref"); + expect(formMocks.push).toHaveBeenCalledWith("/user/vehicles"); + }); + + it("shows vehicle type load errors and keeps submit disabled", async () => { + formMocks.vehicleTypeOptions.mockRejectedValue(new Error("No types")); + formMocks.parseErrorMessage.mockReturnValue("Could not load types"); + + const wrapper = mountComponent(); + await flushAll(); + + expect(wrapper.get('[data-testid="user-add-vehicle-type-error"]').text()).toBe("Could not load types"); + expect(wrapper.get('[data-testid="user-add-vehicle-submit"]').attributes("disabled")).toBeDefined(); + }); + + it("does not redirect when vehicle creation fails", async () => { + const error = new Error("Create failed"); + formMocks.addVehicleUser.mockRejectedValue(error); + + const wrapper = mountComponent(); + await flushAll(); + + await wrapper.get('[data-testid="user-add-vehicle-registration"]').setValue("AB12345"); + await wrapper.get('[data-testid="user-add-vehicle-type"]').setValue("53"); + await wrapper.get("form").trigger("submit.prevent"); + await flushAll(); + + expect(formMocks.push).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/user-vehicle-request.spec.js b/tests/unit/user-vehicle-request.spec.js index 00a7aad4..b8e2f738 100644 --- a/tests/unit/user-vehicle-request.spec.js +++ b/tests/unit/user-vehicle-request.spec.js @@ -28,13 +28,18 @@ describe("UserVehicleRequest", () => { }, }); - await addVehicleUser("AB12345", "53", "Driver note"); + const createdVehicle = await addVehicleUser("AB12345", "53", "Fleet ref"); expect(authenticatedRequest).toHaveBeenCalledWith("/vehicles", "POST", { reg: "AB12345", type: 53, wash_subscription: false, - notes: "Driver note", + reference: "Fleet ref", + }); + expect(createdVehicle).toEqual({ + id: 123, + reg: "AB12345", + wash_subscription: false, }); expect(vehicles.value).toEqual([ { @@ -44,4 +49,11 @@ describe("UserVehicleRequest", () => { }, ]); }); + + it("parses and rethrows create errors so forms can keep the user on the page", async () => { + const error = new Error("Request failed"); + authenticatedRequest.mockRejectedValue(error); + + await expect(addVehicleUser("AB12345", "53")).rejects.toBe(error); + }); });