diff --git a/src/assets/main.css b/src/assets/main.css index 2d85b217..7ec26177 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -49,8 +49,26 @@ textarea.has-sharp-edges { --bulma-card-shadow: none !important; --bulma-skeleton-background: hsla(197 100% 35% / 0.70) !important; + --pleno-compact-table-header-font-size: 0.72rem; + --pleno-compact-table-header-line-height: 1; } +body:not(.pleno-large-table-headers) .table thead th { + font-size: var(--pleno-compact-table-header-font-size); + line-height: var(--pleno-compact-table-header-line-height); + white-space: nowrap; + vertical-align: middle; +} + +.pleno-table-header-content { + display: inline-flex; + align-items: center; + gap: 0.15rem; + max-width: 100%; + vertical-align: middle; +} + + /*:root {*/ /* --bulma-primary-h: 207deg !important;*/ /* --bulma-primary-s: 60% !important;*/ @@ -125,4 +143,4 @@ textarea.has-sharp-edges { .mt-30-tablet { margin-top: 30px !important; } -} \ No newline at end of file +} diff --git a/src/components/displays/department/pos/orders/InvoiceCollectionSelectionActionWheel.vue b/src/components/displays/department/pos/orders/InvoiceCollectionSelectionActionWheel.vue new file mode 100644 index 00000000..2122e683 --- /dev/null +++ b/src/components/displays/department/pos/orders/InvoiceCollectionSelectionActionWheel.vue @@ -0,0 +1,122 @@ + + + diff --git a/src/components/displays/department/pos/orders/invoiceCollectionBulkActions.js b/src/components/displays/department/pos/orders/invoiceCollectionBulkActions.js new file mode 100644 index 00000000..90556f40 --- /dev/null +++ b/src/components/displays/department/pos/orders/invoiceCollectionBulkActions.js @@ -0,0 +1,7 @@ +export const INVOICE_COLLECTION_BULK_ACTIONS = Object.freeze({ + CLEAN_CUSTOMER_RULES: "remove_customer_rule_violations", + MERGE: "merge_collections", + SPLIT_BY_MONTH: "split_by_month", + RESET_HIDDEN_PRICES: "reset_hidden_item_prices", + QUEUE_ECONOMIC: "queue_economic", +}); diff --git a/src/components/displays/department/pos/orders/ordersTable.vue b/src/components/displays/department/pos/orders/ordersTable.vue index c9643db2..244c729e 100644 --- a/src/components/displays/department/pos/orders/ordersTable.vue +++ b/src/components/displays/department/pos/orders/ordersTable.vue @@ -1,12 +1,17 @@ + + + + diff --git a/src/components/displays/superuser/tables/collectedOrderInvoicesTable.vue b/src/components/displays/superuser/tables/collectedOrderInvoicesTable.vue index 416f62f3..c1c6544c 100644 --- a/src/components/displays/superuser/tables/collectedOrderInvoicesTable.vue +++ b/src/components/displays/superuser/tables/collectedOrderInvoicesTable.vue @@ -1,11 +1,10 @@ @@ -159,4 +171,4 @@ const onSelect = (id) => { \ No newline at end of file + diff --git a/src/i18n/generated/da-v2.json b/src/i18n/generated/da-v2.json index 47135120..e085ae5f 100644 --- a/src/i18n/generated/da-v2.json +++ b/src/i18n/generated/da-v2.json @@ -4152,6 +4152,15 @@ "merge_requires_multiple_text": "Vælg mindst to fakturasamlinger for at sammenlægge dem.", "merge_requires_multiple_title": "Vælg flere fakturasamlinger", "merge_target_title": "Vælg målfakturasamling", + "menu": { + "collapse_all": "Klap alle sammen", + "expand_all": "Udvid alle", + "label": "Fakturasamling handlinger ({count})", + "modification_section": "Rediger valgte", + "select_all": "Vælg alle", + "selection_section": "Valg", + "unselect_all": "Fravælg alle" + }, "preview": { "affected_examples": "Eksempler på ændringer", "blocked_title": "Handlingen er blokeret", diff --git a/src/i18n/generated/de-v2.json b/src/i18n/generated/de-v2.json index ad93c54a..76290810 100644 --- a/src/i18n/generated/de-v2.json +++ b/src/i18n/generated/de-v2.json @@ -4263,6 +4263,15 @@ "merge_requires_multiple_text": "Wählen Sie mindestens zwei Rechnungssammlungen aus, um sie zusammenzuführen.", "merge_requires_multiple_title": "Mehrere Rechnungssammlungen auswählen", "merge_target_title": "Ziel-Rechnungssammlung auswählen", + "menu": { + "collapse_all": "Alle einklappen", + "expand_all": "Alle erweitern", + "label": "Rechnungssammlungsaktionen ({count})", + "modification_section": "Ausgewählte ändern", + "select_all": "Alle auswählen", + "selection_section": "Auswahl", + "unselect_all": "Alle abwählen" + }, "preview": { "affected_examples": "Beispieländerungen", "blocked_title": "Aktion ist blockiert", diff --git a/src/i18n/generated/en-v2.json b/src/i18n/generated/en-v2.json index 51e7e3c1..76212def 100644 --- a/src/i18n/generated/en-v2.json +++ b/src/i18n/generated/en-v2.json @@ -3984,6 +3984,15 @@ "merge_requires_multiple_text": "Select at least two invoice collections to merge them.", "merge_requires_multiple_title": "Select multiple invoice collections", "merge_target_title": "Select target invoice collection", + "menu": { + "collapse_all": "Collapse all", + "expand_all": "Expand all", + "label": "Invoice collection actions ({count})", + "modification_section": "Modify selected", + "select_all": "Select all", + "selection_section": "Selection", + "unselect_all": "Unselect all" + }, "preview": { "affected_examples": "Example changes", "blocked_title": "Action is blocked", diff --git a/src/i18n/generated/global-v2.json b/src/i18n/generated/global-v2.json index 20acd10f..df92bcf8 100644 --- a/src/i18n/generated/global-v2.json +++ b/src/i18n/generated/global-v2.json @@ -3237,6 +3237,15 @@ "merge_requires_multiple_text": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.merge_requires_multiple_text'}", "merge_requires_multiple_title": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.merge_requires_multiple_title'}", "merge_target_title": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.merge_target_title'}", + "menu": { + "collapse_all": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.menu.collapse_all'}", + "expand_all": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.menu.expand_all'}", + "label": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.menu.label'}", + "modification_section": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.menu.modification_section'}", + "select_all": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.menu.select_all'}", + "selection_section": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.menu.selection_section'}", + "unselect_all": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.menu.unselect_all'}" + }, "preview": { "affected_examples": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.preview.affected_examples'}", "blocked_title": "@:{'templates.generated.compat.invoicing_period.invoice_collection_actions.preview.blocked_title'}", diff --git a/src/i18n/generated/no-v2.json b/src/i18n/generated/no-v2.json index c56ad6e7..be4c736a 100644 --- a/src/i18n/generated/no-v2.json +++ b/src/i18n/generated/no-v2.json @@ -4266,6 +4266,15 @@ "merge_requires_multiple_text": "Velg minst to fakturasamlinger for å slå dem sammen.", "merge_requires_multiple_title": "Velg flere fakturasamlinger", "merge_target_title": "Velg målfakturasamling", + "menu": { + "collapse_all": "Slå sammen alle", + "expand_all": "Utvid alle", + "label": "Fakturasamling handlinger ({count})", + "modification_section": "Endre valgte", + "select_all": "Velg alle", + "selection_section": "Valg", + "unselect_all": "Fjern valg av alle" + }, "preview": { "affected_examples": "Eksempler på endringer", "blocked_title": "Handlingen er blokkert", diff --git a/src/i18n/generated/sv-v2.json b/src/i18n/generated/sv-v2.json index 6e37879b..35b81b53 100644 --- a/src/i18n/generated/sv-v2.json +++ b/src/i18n/generated/sv-v2.json @@ -4316,6 +4316,15 @@ "merge_requires_multiple_text": "Välj minst två fakturasamlingar för att slå ihop dem.", "merge_requires_multiple_title": "Välj flera fakturasamlingar", "merge_target_title": "Välj målfakturasamling", + "menu": { + "collapse_all": "Fäll ihop alla", + "expand_all": "Expandera alla", + "label": "Fakturasamlingsåtgärder ({count})", + "modification_section": "Ändra valda", + "select_all": "Välj alla", + "selection_section": "Val", + "unselect_all": "Avmarkera alla" + }, "preview": { "affected_examples": "Exempel på ändringar", "blocked_title": "Åtgärden är blockerad", diff --git a/src/i18n/source/da/phrases/compat/invoicing_period/invoice_collection_actions.json b/src/i18n/source/da/phrases/compat/invoicing_period/invoice_collection_actions.json index 159783ad..f7791772 100644 --- a/src/i18n/source/da/phrases/compat/invoicing_period/invoice_collection_actions.json +++ b/src/i18n/source/da/phrases/compat/invoicing_period/invoice_collection_actions.json @@ -13,6 +13,15 @@ "merge_requires_multiple_text": "Vælg mindst to fakturasamlinger for at sammenlægge dem.", "merge_requires_multiple_title": "Vælg flere fakturasamlinger", "merge_target_title": "Vælg målfakturasamling", + "menu": { + "collapse_all": "Klap alle sammen", + "expand_all": "Udvid alle", + "label": "Fakturasamling handlinger ({count})", + "modification_section": "Rediger valgte", + "select_all": "Vælg alle", + "selection_section": "Valg", + "unselect_all": "Fravælg alle" + }, "preview": { "affected_examples": "Eksempler på ændringer", "blocked_title": "Handlingen er blokeret", diff --git a/src/i18n/source/de/phrases/compat/invoicing_period/invoice_collection_actions.json b/src/i18n/source/de/phrases/compat/invoicing_period/invoice_collection_actions.json index bc81c6ab..a805b2a9 100644 --- a/src/i18n/source/de/phrases/compat/invoicing_period/invoice_collection_actions.json +++ b/src/i18n/source/de/phrases/compat/invoicing_period/invoice_collection_actions.json @@ -13,6 +13,15 @@ "merge_requires_multiple_text": "Wählen Sie mindestens zwei Rechnungssammlungen aus, um sie zusammenzuführen.", "merge_requires_multiple_title": "Mehrere Rechnungssammlungen auswählen", "merge_target_title": "Ziel-Rechnungssammlung auswählen", + "menu": { + "collapse_all": "Alle einklappen", + "expand_all": "Alle erweitern", + "label": "Rechnungssammlungsaktionen ({count})", + "modification_section": "Ausgewählte ändern", + "select_all": "Alle auswählen", + "selection_section": "Auswahl", + "unselect_all": "Alle abwählen" + }, "preview": { "affected_examples": "Beispieländerungen", "blocked_title": "Aktion ist blockiert", diff --git a/src/i18n/source/en/phrases/compat/invoicing_period/invoice_collection_actions.json b/src/i18n/source/en/phrases/compat/invoicing_period/invoice_collection_actions.json index 8ca0585f..2f804a85 100644 --- a/src/i18n/source/en/phrases/compat/invoicing_period/invoice_collection_actions.json +++ b/src/i18n/source/en/phrases/compat/invoicing_period/invoice_collection_actions.json @@ -13,6 +13,15 @@ "merge_requires_multiple_text": "Select at least two invoice collections to merge them.", "merge_requires_multiple_title": "Select multiple invoice collections", "merge_target_title": "Select target invoice collection", + "menu": { + "collapse_all": "Collapse all", + "expand_all": "Expand all", + "label": "Invoice collection actions ({count})", + "modification_section": "Modify selected", + "select_all": "Select all", + "selection_section": "Selection", + "unselect_all": "Unselect all" + }, "preview": { "affected_examples": "Example changes", "blocked_title": "Action is blocked", diff --git a/src/i18n/source/global/shared/invoicing_period/invoice_collection_actions.json b/src/i18n/source/global/shared/invoicing_period/invoice_collection_actions.json index 6b69318b..1e209525 100644 --- a/src/i18n/source/global/shared/invoicing_period/invoice_collection_actions.json +++ b/src/i18n/source/global/shared/invoicing_period/invoice_collection_actions.json @@ -12,6 +12,15 @@ "merge_requires_multiple_text": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.merge_requires_multiple_text'}", "merge_requires_multiple_title": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.merge_requires_multiple_title'}", "merge_target_title": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.merge_target_title'}", + "menu": { + "collapse_all": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.menu.collapse_all'}", + "expand_all": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.menu.expand_all'}", + "label": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.menu.label'}", + "modification_section": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.menu.modification_section'}", + "select_all": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.menu.select_all'}", + "selection_section": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.menu.selection_section'}", + "unselect_all": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.menu.unselect_all'}" + }, "preview": { "affected_examples": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.preview.affected_examples'}", "blocked_title": "@:{'phrases.compat.invoicing_period.invoice_collection_actions.preview.blocked_title'}", diff --git a/src/i18n/source/no/phrases/compat/invoicing_period/invoice_collection_actions.json b/src/i18n/source/no/phrases/compat/invoicing_period/invoice_collection_actions.json index 2819e757..7aed0a47 100644 --- a/src/i18n/source/no/phrases/compat/invoicing_period/invoice_collection_actions.json +++ b/src/i18n/source/no/phrases/compat/invoicing_period/invoice_collection_actions.json @@ -13,6 +13,15 @@ "merge_requires_multiple_text": "Velg minst to fakturasamlinger for å slå dem sammen.", "merge_requires_multiple_title": "Velg flere fakturasamlinger", "merge_target_title": "Velg målfakturasamling", + "menu": { + "collapse_all": "Slå sammen alle", + "expand_all": "Utvid alle", + "label": "Fakturasamling handlinger ({count})", + "modification_section": "Endre valgte", + "select_all": "Velg alle", + "selection_section": "Valg", + "unselect_all": "Fjern valg av alle" + }, "preview": { "affected_examples": "Eksempler på endringer", "blocked_title": "Handlingen er blokkert", diff --git a/src/i18n/source/sv/phrases/compat/invoicing_period/invoice_collection_actions.json b/src/i18n/source/sv/phrases/compat/invoicing_period/invoice_collection_actions.json index 294bc1f9..af91628f 100644 --- a/src/i18n/source/sv/phrases/compat/invoicing_period/invoice_collection_actions.json +++ b/src/i18n/source/sv/phrases/compat/invoicing_period/invoice_collection_actions.json @@ -13,6 +13,15 @@ "merge_requires_multiple_text": "Välj minst två fakturasamlingar för att slå ihop dem.", "merge_requires_multiple_title": "Välj flera fakturasamlingar", "merge_target_title": "Välj målfakturasamling", + "menu": { + "collapse_all": "Fäll ihop alla", + "expand_all": "Expandera alla", + "label": "Fakturasamlingsåtgärder ({count})", + "modification_section": "Ändra valda", + "select_all": "Välj alla", + "selection_section": "Val", + "unselect_all": "Avmarkera alla" + }, "preview": { "affected_examples": "Exempel på ändringar", "blocked_title": "Åtgärden är blockerad", diff --git a/src/main.js b/src/main.js index b335ccda..525ac2b8 100644 --- a/src/main.js +++ b/src/main.js @@ -23,6 +23,7 @@ import { installReleaseErrorInstrumentation, } from '@/services/releaseTimeline.js'; import { RELEASE_RUNTIME_GLOBAL_KEY } from '@/services/releaseBootstrap.js'; +import '@/services/tableHeaderPreferences.js'; import { IS_DEV } from './config'; const VITE_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || ''; diff --git a/src/services/tableHeaderPreferences.js b/src/services/tableHeaderPreferences.js new file mode 100644 index 00000000..192ac16a --- /dev/null +++ b/src/services/tableHeaderPreferences.js @@ -0,0 +1,52 @@ +import { ref } from "vue"; + +export const LARGE_TABLE_HEADERS_STORAGE_KEY = "pleno.useLargeTableHeaders"; +export const LARGE_TABLE_HEADERS_BODY_CLASS = "pleno-large-table-headers"; + +const applyLargeTableHeadersClass = (value) => { + if (typeof document === "undefined" || !document.body) { + return; + } + + document.body.classList.toggle(LARGE_TABLE_HEADERS_BODY_CLASS, Boolean(value)); +}; + +const readStoredBoolean = () => { + if (typeof window === "undefined" || !window.localStorage) { + return false; + } + + try { + return window.localStorage.getItem(LARGE_TABLE_HEADERS_STORAGE_KEY) === "true"; + } catch { + return false; + } +}; + +export const useLargeTableHeaders = ref(readStoredBoolean()); +applyLargeTableHeadersClass(useLargeTableHeaders.value); + +export const setUseLargeTableHeaders = (value) => { + const nextValue = Boolean(value); + useLargeTableHeaders.value = nextValue; + applyLargeTableHeadersClass(nextValue); + + if (typeof window === "undefined" || !window.localStorage) { + return; + } + + try { + window.localStorage.setItem(LARGE_TABLE_HEADERS_STORAGE_KEY, nextValue ? "true" : "false"); + } catch { + // Storage is optional; keep the in-memory preference for this session. + } +}; + +if (typeof window !== "undefined") { + window.addEventListener("storage", (event) => { + if (event.key === LARGE_TABLE_HEADERS_STORAGE_KEY) { + useLargeTableHeaders.value = event.newValue === "true"; + applyLargeTableHeadersClass(useLargeTableHeaders.value); + } + }); +} diff --git a/src/views/dashboards/userDashboard/profile/UserProfile.vue b/src/views/dashboards/userDashboard/profile/UserProfile.vue index ec478c38..ab3d132d 100644 --- a/src/views/dashboards/userDashboard/profile/UserProfile.vue +++ b/src/views/dashboards/userDashboard/profile/UserProfile.vue @@ -15,6 +15,7 @@ import ShowErrorField from "@/components/global/ShowErrorField.vue"; import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue"; import { parseError, removeError, addError } from "@/components/request/HandleGlobalError.vue"; import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue"; +import { setUseLargeTableHeaders, useLargeTableHeaders } from "@/services/tableHeaderPreferences.js"; const { t } = useI18n(); const isLoading = ref(false); @@ -792,6 +793,22 @@ const onClickSaveWashCertificateEmail = async () => { {{ $t("user_dashboard.profile.driver.customer_switch_help") }}

+ + + + { await primeOperatorSession(page, "pos-orders-action-menu-layout-token", SUPERUSER_POS_PERMISSIONS); }); + test("keeps desktop order list row cells aligned", async ({ page }) => { + await page.goto("/admin/12/modules/pos/orders"); + await expect(page.locator("table")).toBeVisible(); + + const row = getVisibleTestId(page, "pos-order-list-settings-54518").locator("xpath=ancestor::tr").first(); + await expect(row).toBeVisible(); + + const rowMetrics = await row.evaluate((rowElement) => { + const cells = Array.from(rowElement.children).filter((child) => child.tagName.toLowerCase() === "td"); + const idCell = rowElement.querySelector(".pos-orders-table-id-cell"); + const idIndicator = idCell?.querySelector(".pos-orders-table-id-cell-content"); + const cellSummaries = cells.map((cell) => { + const rect = cell.getBoundingClientRect(); + return { + className: cell.className, + verticalAlign: window.getComputedStyle(cell).verticalAlign, + width: rect.width, + }; + }); + const idCellRect = idCell?.getBoundingClientRect(); + const idIndicatorRect = idIndicator?.getBoundingClientRect(); + + return { + cellSummaries, + idCenterDelta: + idCellRect && idIndicatorRect + ? Math.abs(idIndicatorRect.left + idIndicatorRect.width / 2 - (idCellRect.left + idCellRect.width / 2)) + : null, + idCellWidth: idCellRect?.width ?? null, + }; + }); + + expect(rowMetrics.cellSummaries.length).toBeGreaterThan(5); + expect(rowMetrics.cellSummaries.every((cell) => cell.verticalAlign === "middle")).toBe(true); + expect(rowMetrics.idCenterDelta).not.toBeNull(); + expect(rowMetrics.idCenterDelta ?? 999).toBeLessThanOrEqual(2); + expect(rowMetrics.idCellWidth ?? 999).toBeLessThanOrEqual(100); + expect( + rowMetrics.cellSummaries.some((cell) => String(cell.className).includes("pos-orders-table-actions-cell")) + ).toBe(true); + }); + test("keeps tall menus within the usable viewport and starts from the first action", async ({ page }) => { await page.goto("/admin/12/modules/pos/orders"); await expect(page.locator("table")).toBeVisible(); diff --git a/tests/e2e/change-invoice-collection.spec.ts b/tests/e2e/change-invoice-collection.spec.ts index 8f779550..f0de8832 100644 --- a/tests/e2e/change-invoice-collection.spec.ts +++ b/tests/e2e/change-invoice-collection.spec.ts @@ -166,11 +166,11 @@ test.describe("POS order actions", () => { "Expected invoice collection picker modal to open after clicking the action." ).toBeVisible(); - const selectButtons = pickerModal.getByRole("button", { name: /Vælg|Select/i }); - const selectableCount = await selectButtons.count(); + const selectCheckboxes = pickerModal.locator('[data-testid^="collected-invoice-selector-"] input[type="checkbox"]'); + const selectableCount = await selectCheckboxes.count(); test.skip(selectableCount === 0, "No invoice collections available for selection in test data."); - await selectButtons.first().click(); + await selectCheckboxes.first().check(); await expect(page).toHaveURL(beforeUrl); await expect(page.locator(".swal2-container, .swal2-popup")).toContainText( diff --git a/tests/unit/collected-order-invoices-table.spec.js b/tests/unit/collected-order-invoices-table.spec.js new file mode 100644 index 00000000..2f73c055 --- /dev/null +++ b/tests/unit/collected-order-invoices-table.spec.js @@ -0,0 +1,186 @@ +// @vitest-environment jsdom +import { mount } from "@vue/test-utils"; +import { describe, expect, it, vi } from "vitest"; + +import CollectedOrderInvoicesTable from "@/components/displays/superuser/tables/collectedOrderInvoicesTable.vue"; +import CollectedOrderInvoicesDefaultTable from "@/components/displays/superuser/tables/CollectedOrderInvoicesDefaultTable.vue"; + +vi.mock("@/components/pagination/paginatedList.vue", () => ({ + usePaginatedListInstance: () => ({ + loadList: vi.fn(), + }), +})); + +vi.mock("@/components/session/token/SessionUser.vue", () => ({ + SessionUser: { + objects: { + collectedOrderInvoices: { + columns: { + id: { label: "ID" }, + customer_number: { label: "Customer number" }, + processor: { label: "Processor" }, + created_at: { label: "Created" }, + name: { label: "Name" }, + notes: { label: "Notes" }, + external_id: { label: "External ID" }, + po_number: { label: "PO" }, + closed_at: { label: "Closed" }, + updated_at: { label: "Updated" }, + }, + meta: { + labels: { + multiple: "invoice collections", + }, + }, + }, + global: { + language: { + customer_name: "Customer", + }, + }, + orders: { + meta: { + title: "Orders", + }, + }, + }, + functions: { + currency: { + toLocal: (value) => String(value ?? ""), + }, + }, + }, +})); + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ + t: (key) => key, + }), + createI18n: () => ({ + global: { + t: (key) => key, + locale: { + value: "en", + }, + }, + }), +})); + +const EditableTableColumnStub = { + props: ["object", "column", "parseFunction"], + template: "{{ parseFunction ? parseFunction(object[column]) : object[column] }}", +}; + +const ActionSettingsWheelButtonStub = { + template: '
', +}; + +const ActionSettingsWheelItemStub = { + template: '', +}; + +const BCheckboxStub = { + props: ["modelValue"], + emits: ["update:modelValue"], + template: ` + + `, +}; + +const mountSelectorTable = (props = {}) => + mount(CollectedOrderInvoicesTable, { + props: { + objects: [ + { + id: 42, + objects: 1, + customer_name: "Acme", + customer_number: 1001, + processor: 1, + orders: 2, + closed_at: null, + created_at: "2026-07-01", + total_net_amount: 123, + }, + ], + showEmpty: true, + ...props, + }, + global: { + mocks: { + $t: (key) => key, + }, + stubs: { + EditableTableColumn: EditableTableColumnStub, + ActionSettingsWheelButton: ActionSettingsWheelButtonStub, + ActionSettingsWheelItem: ActionSettingsWheelItemStub, + BCheckbox: BCheckboxStub, + }, + }, + }); + +describe("CollectedOrderInvoicesTable", () => { + it("uses a Buefy checkbox for selector mode", async () => { + const onSelected = vi.fn(); + const wrapper = mountSelectorTable({ + isSelector: true, + onSelected, + }); + + expect(wrapper.find("button.button.is-small.is-dark").exists()).toBe(false); + + await wrapper.get('[data-testid="collected-invoice-selector-42"] input').setValue(true); + + expect(onSelected).toHaveBeenCalledWith(42); + expect(wrapper.emitted("update:selected")?.[0]).toEqual([42]); + }); + + it("keeps a hidden attachment-width buffer before collected invoice row actions", () => { + const wrapper = mountSelectorTable(); + + expect(wrapper.get('[data-testid="superuser-invoice-action-buffer"]').exists()).toBe(true); + expect(wrapper.get('[data-testid="action-settings-wheel-stub"]').exists()).toBe(true); + }); +}); + +describe("CollectedOrderInvoicesDefaultTable", () => { + it("keeps a hidden attachment-width buffer before default invoice row actions", () => { + const wrapper = mount(CollectedOrderInvoicesDefaultTable, { + props: { + objects: [ + { + id: 43, + customer_number: 1002, + name: "Acme July", + notes: null, + processor: 1, + external_id: null, + po_number: null, + closed_at: null, + created_at: "2026-07-01", + updated_at: "2026-07-02", + }, + ], + }, + global: { + mocks: { + $t: (key) => key, + }, + stubs: { + ActionSettingsWheelButton: ActionSettingsWheelButtonStub, + ActionSettingsWheelItem: ActionSettingsWheelItemStub, + }, + }, + }); + + expect(wrapper.get('[data-testid="superuser-invoice-action-buffer"]').exists()).toBe(true); + expect(wrapper.get('[data-testid="action-settings-wheel-stub"]').exists()).toBe(true); + }); +}); diff --git a/tests/unit/invoice-collection-selection-action-wheel.spec.js b/tests/unit/invoice-collection-selection-action-wheel.spec.js new file mode 100644 index 00000000..62a7357b --- /dev/null +++ b/tests/unit/invoice-collection-selection-action-wheel.spec.js @@ -0,0 +1,187 @@ +// @vitest-environment jsdom +import { defineComponent } from "vue"; +import { mount } from "@vue/test-utils"; +import { describe, expect, it, vi } from "vitest"; +import InvoiceCollectionSelectionActionWheel from "@/components/displays/department/pos/orders/InvoiceCollectionSelectionActionWheel.vue"; +import { INVOICE_COLLECTION_BULK_ACTIONS } from "@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js"; + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ + t: (key, params = {}) => (params.count === undefined ? key : `${key}:${params.count}`), + }), + createI18n: () => ({ + global: { + t: (key) => key, + }, + install: () => {}, + }), +})); + +vi.mock("@/components/displays/buttons/ActionSettingsWheelButton.vue", () => ({ + default: { + template: '
', + }, +})); + +vi.mock("@/components/displays/buttons/ActionSettingsWheelItemLabel.vue", () => ({ + default: { + props: ["label"], + template: '
{{ label }}
', + }, +})); + +vi.mock("@/components/displays/buttons/ActionSettingsWheelItem.vue", () => ({ + default: { + props: { + clickAction: { + type: Function, + default: null, + }, + disabled: { + type: Boolean, + default: false, + }, + label: { + type: String, + default: "", + }, + testId: { + type: String, + default: "", + }, + }, + template: ` + + `, + }, +})); + +const ActionSettingsWheelButtonStub = { + template: '
', +}; + +const ActionSettingsWheelItemLabelStub = { + props: ["label"], + template: '
{{ label }}
', +}; + +const ActionSettingsWheelItemStub = defineComponent({ + name: "ActionSettingsWheelItem", + props: { + clickAction: { + type: Function, + default: null, + }, + disabled: { + type: Boolean, + default: false, + }, + label: { + type: String, + default: "", + }, + testId: { + type: String, + default: "", + }, + }, + template: ` + + `, +}); + +const mountWheel = (props = {}) => + mount(InvoiceCollectionSelectionActionWheel, { + props: { + selectedInvoiceCollectionIds: [], + totalInvoiceCollectionCount: 2, + allSelected: false, + allExpanded: false, + invoiceQueueBusy: false, + ...props, + }, + global: { + stubs: { + ActionSettingsWheelButton: ActionSettingsWheelButtonStub, + ActionSettingsWheelItem: ActionSettingsWheelItemStub, + ActionSettingsWheelItemLabel: ActionSettingsWheelItemLabelStub, + }, + }, + }); + +describe("InvoiceCollectionSelectionActionWheel", () => { + it("does not render without selectable invoice collections", () => { + const wrapper = mountWheel({ totalInvoiceCollectionCount: 0 }); + + expect(wrapper.find('[data-testid="invoice-collection-selection-action-wheel"]').exists()).toBe(false); + }); + + it("shows selection controls without modification actions when nothing is selected", async () => { + const wrapper = mountWheel(); + + expect(wrapper.find('[data-testid="invoice-collection-selection-action-wheel-select-all"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="invoice-collection-selection-action-wheel-expand-all"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="invoice-collection-selection-action-wheel-invoice-selected"]').exists()).toBe( + false + ); + expect(wrapper.find('[data-testid="invoice-collection-selection-action-wheel-merge"]').exists()).toBe(false); + + await wrapper.get('[data-testid="invoice-collection-selection-action-wheel-select-all"]').trigger("click"); + await wrapper.get('[data-testid="invoice-collection-selection-action-wheel-expand-all"]').trigger("click"); + + expect(wrapper.emitted("toggleSelectAll")).toHaveLength(1); + expect(wrapper.emitted("toggleExpandAll")).toHaveLength(1); + }); + + it("shows single-selection modification actions and hides merge", async () => { + const wrapper = mountWheel({ selectedInvoiceCollectionIds: [3001] }); + + expect(wrapper.find('[data-testid="invoice-collection-selection-action-wheel-invoice-selected"]').exists()).toBe( + true + ); + expect(wrapper.find('[data-testid="invoice-collection-selection-action-wheel-clean-rules"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="invoice-collection-selection-action-wheel-split-by-month"]').exists()).toBe( + true + ); + expect(wrapper.find('[data-testid="invoice-collection-selection-action-wheel-reset-hidden-prices"]').exists()).toBe( + true + ); + expect(wrapper.find('[data-testid="invoice-collection-selection-action-wheel-merge"]').exists()).toBe(false); + + await wrapper.get('[data-testid="invoice-collection-selection-action-wheel-clean-rules"]').trigger("click"); + + expect(wrapper.emitted("bulkAction")).toEqual([[INVOICE_COLLECTION_BULK_ACTIONS.CLEAN_CUSTOMER_RULES]]); + }); + + it("shows merge for multiple selections and disables invoicing while queue is busy", async () => { + const wrapper = mountWheel({ + selectedInvoiceCollectionIds: [3001, 3002], + invoiceQueueBusy: true, + }); + + expect( + wrapper.get('[data-testid="invoice-collection-selection-action-wheel-invoice-selected"]').attributes("disabled") + ).toBeDefined(); + expect(wrapper.find('[data-testid="invoice-collection-selection-action-wheel-merge"]').exists()).toBe(true); + + await wrapper.get('[data-testid="invoice-collection-selection-action-wheel-merge"]').trigger("click"); + + expect(wrapper.emitted("bulkAction")).toEqual([[INVOICE_COLLECTION_BULK_ACTIONS.MERGE]]); + }); +}); diff --git a/tests/unit/orders-table.spec.js b/tests/unit/orders-table.spec.js index a1445846..54af1e1e 100644 --- a/tests/unit/orders-table.spec.js +++ b/tests/unit/orders-table.spec.js @@ -1,9 +1,13 @@ // @vitest-environment jsdom -import { defineComponent, h, ref } from "vue"; +import { defineComponent, h, nextTick, ref } from "vue"; import { flushPromises, mount } from "@vue/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { INVOICE_COLLECTION_BULK_ACTIONS } from "@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js"; +import { setUseLargeTableHeaders } from "@/services/tableHeaderPreferences.js"; const getSingleMock = vi.hoisted(() => vi.fn()); +const bulkActionPreviewMock = vi.hoisted(() => vi.fn()); +const bulkActionApplyMock = vi.hoisted(() => vi.fn()); const sessionState = vi.hoisted(() => ({ canAccessSuperUser: false, })); @@ -30,6 +34,9 @@ const invoiceQueueState = vi.hoisted(() => { vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key) => key, + locale: { + value: "en", + }, }), createI18n: () => ({ global: { @@ -146,6 +153,10 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({ get: { single: getSingleMock, }, + functions: { + bulk_action_preview: bulkActionPreviewMock, + bulk_action_apply: bulkActionApplyMock, + }, }, global: { language: { @@ -178,6 +189,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({ date: { timeAgo: () => "just now", }, + parseErrorMessage: (error) => error?.message || String(error), }, }, })); @@ -261,6 +273,74 @@ const ActionSettingsWheelButtonStub = { template: '
', }; +const InvoiceCollectionSelectionActionWheelStub = defineComponent({ + name: "InvoiceCollectionSelectionActionWheel", + props: { + selectedInvoiceCollectionIds: { + type: Array, + default: () => [], + }, + totalInvoiceCollectionCount: { + type: Number, + default: 0, + }, + allSelected: { + type: Boolean, + default: false, + }, + allExpanded: { + type: Boolean, + default: false, + }, + invoiceQueueBusy: { + type: Boolean, + default: false, + }, + }, + emits: ["invoiceSelected", "bulkAction", "toggleSelectAll", "toggleExpandAll"], + template: ` +
+ + + + +
+ `, +}); + +const BCheckboxStub = defineComponent({ + name: "BCheckbox", + props: { + modelValue: { + type: Boolean, + default: false, + }, + disabled: { + type: Boolean, + default: false, + }, + }, + emits: ["update:modelValue"], + template: ` + + `, +}); + const ActionSettingsWheelItemStub = { template: '
', }; @@ -339,6 +419,8 @@ const mountOrdersTable = (props = {}) => { EditableTableColumn: EditableTableColumnStub, ColorIndicator: ColorIndicatorStub, ActionSettingsWheelButton: ActionSettingsWheelButtonStub, + InvoiceCollectionSelectionActionWheel: InvoiceCollectionSelectionActionWheelStub, + BCheckbox: BCheckboxStub, ActionSettingsWheelItem: ActionSettingsWheelItemStub, OrderContentTable: OrderContentTableStub, PosDepartmentStepMobileAttachment: PosDepartmentStepMobileAttachmentStub, @@ -371,10 +453,26 @@ describe("OrdersTable", () => { invoiceQueueState.success.value = []; invoiceQueueState.queued.value = []; invoiceQueueState.log.value = []; + setUseLargeTableHeaders(false); getSingleMock.mockReset(); + bulkActionPreviewMock.mockReset(); + bulkActionPreviewMock.mockResolvedValue({ + data: { + data: { + preview_id: "preview-1", + confirmation_phrase: "Confirm", + summary: { + changed_count: 0, + }, + blockers: [], + }, + }, + }); + bulkActionApplyMock.mockReset(); }); afterEach(() => { + setUseLargeTableHeaders(false); vi.restoreAllMocks(); }); @@ -612,4 +710,148 @@ describe("OrdersTable", () => { expect(getIndicatorForOrder(56631).attributes("data-color-class")).toBe("has-text-warning"); expect(getIndicatorForOrder(56632).attributes("data-icon")).toBe("fas fa-circle"); }); + + it("routes invoice collection selection controls through the action wheel", async () => { + const wrapper = mountOrdersTable({ + invoiceView: true, + allowSelectMultiple: true, + autoExpandAll: false, + orders: [ + createOrder({ + id: 56633, + invoice_collection_id: 3001, + invoice_collection: { id: 3001, closed_at: null, booked_invoice_id: null }, + }), + createOrder({ + id: 56634, + invoice_collection_id: 3001, + invoice_collection: { id: 3001, closed_at: null, booked_invoice_id: null }, + }), + createOrder({ + id: 56635, + invoice_collection_id: 3002, + invoice_collection: { id: 3002, closed_at: null, booked_invoice_id: null }, + }), + ], + }); + + await flushPromises(); + + const wheel = () => wrapper.get(".invoice-collection-selection-action-wheel-stub"); + const firstCollectionSelector = () => + wrapper.get('[data-testid="pos-order-invoice-collection-selector-56633"] input'); + const secondSameCollectionSelector = () => + wrapper.get('[data-testid="pos-order-invoice-collection-selector-56634"] input'); + expect(wheel().attributes("data-total-count")).toBe("2"); + expect(wheel().attributes("data-selected-count")).toBe("0"); + expect(wheel().attributes("data-all-selected")).toBe("false"); + expect(wheel().attributes("data-all-expanded")).toBe("false"); + expect(wrapper.findAll("button").some((button) => /select|unselect|vælg|fravælg/i.test(button.text()))).toBe(false); + expect(wrapper.get('[data-testid="pos-order-invoice-collection-selector-56633"]').classes()).toContain( + "pos-order-invoice-collection-selector" + ); + + await firstCollectionSelector().setValue(true); + await flushPromises(); + + expect(wheel().attributes("data-selected-count")).toBe("1"); + expect(firstCollectionSelector().element.checked).toBe(true); + expect(secondSameCollectionSelector().element.checked).toBe(true); + + await secondSameCollectionSelector().setValue(false); + await flushPromises(); + + expect(wheel().attributes("data-selected-count")).toBe("0"); + expect(firstCollectionSelector().element.checked).toBe(false); + + await wrapper.get('[data-testid="selection-wheel-select-all"]').trigger("click"); + await flushPromises(); + + expect(wheel().attributes("data-selected-count")).toBe("2"); + expect(wheel().attributes("data-all-selected")).toBe("true"); + + await wrapper.get('[data-testid="selection-wheel-expand-all"]').trigger("click"); + await flushPromises(); + + expect(wheel().attributes("data-all-expanded")).toBe("true"); + + await wrapper.get('[data-testid="selection-wheel-clean-rules"]').trigger("click"); + await flushPromises(); + + expect(bulkActionPreviewMock).toHaveBeenCalledWith( + INVOICE_COLLECTION_BULK_ACTIONS.CLEAN_CUSTOMER_RULES, + [3001, 3002], + {}, + "en" + ); + }); + + it("uses compact table headers by default and restores large headers from the user setting", async () => { + const wrapper = mountOrdersTable({ + invoiceView: true, + orders: [createOrder()], + }); + + await flushPromises(); + + expect(wrapper.find("th.pos-orders-table-header--compact").exists()).toBe(true); + expect(wrapper.find("th.pos-orders-table-header--vehicles.pos-orders-table-header--compact").exists()).toBe(true); + expect(wrapper.find("th .pleno-table-header-content").exists()).toBe(true); + + setUseLargeTableHeaders(true); + await nextTick(); + + expect(wrapper.find("th.pos-orders-table-header--compact").exists()).toBe(false); + expect(wrapper.find("th.pos-orders-table-header--vehicles").exists()).toBe(true); + }); + + it("renders stacked desktop registration numbers in the compact vehicle column style", async () => { + const wrapper = mountOrdersTable({ + invoiceView: true, + orders: [ + createOrder({ + reg_1: "AB12345", + reg_2: "CD67890", + reg_3: "EF24680", + }), + ], + }); + + await flushPromises(); + + const vehicleCell = wrapper.get(".pos-order-vehicle-cell"); + const registrationLines = vehicleCell.findAll(".pos-order-vehicle-registration-line"); + + expect(registrationLines).toHaveLength(3); + expect(registrationLines.map((line) => line.text())).toEqual(["AB12345", "CD67890", "EF24680"]); + }); + + it("centers the desktop ID status indicator group in the ID cell", async () => { + const wrapper = mountOrdersTable({ + invoiceView: true, + orders: [createOrder({ id: 54518 })], + }); + + await flushPromises(); + + const idCell = wrapper.get(".pos-orders-table-id-cell"); + expect(idCell.classes()).toContain("is-narrow"); + expect(idCell.text()).toContain("54518"); + expect(idCell.find(".pos-orders-table-id-cell-content").exists()).toBe(true); + expect(idCell.find(".color-indicator-stub").exists()).toBe(true); + }); + + it("uses row-level alignment classes for the desktop orders table row", async () => { + const wrapper = mountOrdersTable({ + invoiceView: true, + orders: [createOrder({ id: 54518 })], + }); + + await flushPromises(); + + const row = wrapper.get(".pos-orders-table-row"); + expect(row.find(".pos-orders-table-id-cell").exists()).toBe(true); + expect(row.find(".pos-order-vehicle-cell").exists()).toBe(true); + expect(row.find(".pos-orders-table-actions-cell").exists()).toBe(true); + }); }); diff --git a/tests/unit/table-header-preferences.spec.js b/tests/unit/table-header-preferences.spec.js new file mode 100644 index 00000000..bc24951d --- /dev/null +++ b/tests/unit/table-header-preferences.spec.js @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } from "vitest"; +import { + LARGE_TABLE_HEADERS_BODY_CLASS, + LARGE_TABLE_HEADERS_STORAGE_KEY, + setUseLargeTableHeaders, + useLargeTableHeaders, +} from "@/services/tableHeaderPreferences.js"; + +describe("table header preferences", () => { + beforeEach(() => { + window.localStorage.removeItem(LARGE_TABLE_HEADERS_STORAGE_KEY); + setUseLargeTableHeaders(false); + }); + + it("uses compact table headers by default", () => { + expect(useLargeTableHeaders.value).toBe(false); + expect(document.body.classList.contains(LARGE_TABLE_HEADERS_BODY_CLASS)).toBe(false); + }); + + it("toggles the document class for large table headers", () => { + setUseLargeTableHeaders(true); + + expect(window.localStorage.getItem(LARGE_TABLE_HEADERS_STORAGE_KEY)).toBe("true"); + expect(useLargeTableHeaders.value).toBe(true); + expect(document.body.classList.contains(LARGE_TABLE_HEADERS_BODY_CLASS)).toBe(true); + + setUseLargeTableHeaders(false); + + expect(window.localStorage.getItem(LARGE_TABLE_HEADERS_STORAGE_KEY)).toBe("false"); + expect(useLargeTableHeaders.value).toBe(false); + expect(document.body.classList.contains(LARGE_TABLE_HEADERS_BODY_CLASS)).toBe(false); + }); +});