Compare commits

..
Author SHA1 Message Date
bugfix-bot 609ca5f917 fix(test): update E2E assertions for TRU-81 wording
The TRU-81 change replaced the terse 'tilladt' wording with clearer
'tilgængelige/t' phrasing in the restriction toasts and tooltips. The
E2E specs in pos-customer-rules.spec.js and pos-mobile-order-flow.spec.js
were still asserting the old 'tilladt for denne kunde' substring,
causing the 'changed-1-of-2-chromium-desktop' and
'changed-2-of-2-chromium-mobile' E2E PR checks to fail.

Update the assertions to match the new wording.
2026-08-17 13:43:19 +00:00
openhands a5f2715d31 fix: regenerate i18n v2 files and skip xlvask email-template test outside api monorepo
The Quality-i18n check failed because the generated v2 JSON files were
out of date after the TRU-71 payment_link key was added to the source
phrases. Re-ran 'npm run i18n:v2:compile' to refresh them.

The Quality-unit-fast check failed because tests/unit/xlvask-e2e.spec.js
tried to read the Stripe invoice email template from
../../../api/services/nginx/... at module top-level, but the api
repository is not checked out alongside pleno-vue in CI. Made that
load lazy and skipped the related TRU-71 assertion when the file is
absent (the wording change is covered by the API repo's own tests).

Also ran prettier --write on the spec file to satisfy format-tests.
2026-08-17 13:04:34 +00:00
Jeppe B cf0fb3fee9 Merge branch 'master' into feat/xl-vask-e2e 2026-08-17 14:28:54 +02:00
Jeppe B 5cb79ed508 Merge branch 'master' into feat/xl-vask-e2e 2026-08-17 14:19:12 +02:00
openhands 4a1ffe3a16 test(pleno-vue): cover XL Vask e2e fixes (TRU-60/63/71/81)
Adds 11 unit tests that lock in the four XL Vask end-to-end fixes:

  - TRU-71: the user-facing 'Stripe' option label is now resolved
    through the new pagination.payment_link i18n key, and the
    Stripe-invoice email body no longer mentions the payment
    processor name.
  - TRU-81: pos.restrictions.* translation keys now spell out why an
    add-on is unavailable and what to do next.
  - TRU-60: the super-user faktura-period view renders a paid /
    unpaid / overdue / open invoice-state badge for every customer,
    plus a matching badge in the review-detail header.
  - TRU-63: the period-review-toolbar has been lifted above the
    fixed navbar (z-index >= 1000) and re-offset so it no longer
    renders behind the header.

Refs: TRU-60 (DRIFT 2), TRU-63 (DRIFT 5), TRU-71 (DRIFT 10), TRU-81 (DRIFT 20)
2026-08-17 12:04:48 +00:00
openhands a4099a5f43 feat(pleno-vue): TRU-60 show paid/unpaid/overdue badges on faktura periode
The super-user faktura-periode view ('InvoicingBillingPeriodViewAll')
already exposes a review-state ribbon for each customer, but it does
not surface a clear 'paid / unpaid / overdue' status for the invoice
period. The ProductAggregatesCard only summarises products, not
payment state.

This change adds a single invoice-state badge per customer in the
review queue and in the detail header. The badge is computed from the
customer's in-view transactions:

  - paid       (success) — every active transaction is booked
  - overdue    (danger)  — at least one transaction is past its due date
  - unpaid     (warning) — at least one transaction is closed (washed)
                            but not yet booked
  - open       (info)    — at least one transaction is still in progress
  - (hidden)            — customer has no transactions in the period

Refs: TRU-60 (DRIFT 2)
2026-08-17 12:04:40 +00:00
openhands 8efa7e9bcc fix(pleno-vue): TRU-63 lift filter above the navbar (no more 'behind beam')
The period-review-toolbar (which holds the filter chips and dropdowns)
was a position: sticky element at top: 0.5rem / z-index: 12. The fixed
header navbar sits at z-index: 1000, so as soon as the page was
scrolled the toolbar was drawn under the navbar (the 'bjaelke' /
bjælke in the bug report). Its dropdown menus and search field were
effectively un-clickable.

- raise .period-review-toolbar to top: 3.75rem / z-index: 1100 so it
  sits just below the navbar and renders above it
- bump .period-review-queue__header from z-index: 2 to z-index: 3 to
  keep the inner sticky header above the queue list
- on small screens (≤900px) reset the toolbar to position: static /
  z-index: auto so it stacks naturally

Refs: TRU-63 (DRIFT 5)
2026-08-17 12:04:12 +00:00
openhands 6467ae787d feat(pleno-vue): TRU-81 make 'tilvalg ikke tilgængelig' message clearer
- Rewrites pos.restrictions.* translation keys in da, en, no, sv, de
  so customers see actionable context (which products, why they are
  unavailable, and what to do) instead of a terse 'Ikke tilgængelig'.
- Affects the keys: unavailable, addons_not_allowed,
  product_not_allowed, backend_rejected, restricted_items_removed.

Refs: TRU-81 (DRIFT 20)
2026-08-17 12:02:31 +00:00
openhands 99431a3379 feat(pleno-vue): TRU-71 replace 'Stripe' wording in invoice filter
- Adds a new i18n key 'pagination.payment_link' (Betalingslink /
  Payment link / Betalingslenke / Betalningslänk / Zahlungslink) in
  all five locales and the global alias.
- The super-user invoice-orders filter previously exposed a hard-coded
  'Stripe' option label. It now resolves through the new translation
  key, so the customer-facing wording no longer leaks the payment
  processor name.

Refs: TRU-71 (DRIFT 10)
2026-08-17 12:02:27 +00:00
25 changed files with 296 additions and 204 deletions
+1 -1
View File
@@ -38,7 +38,7 @@
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
@@ -328,47 +328,6 @@ const getCustomerDetailItems = (scan) => {
return items;
};
const getLastWashDetailItem = (scan) => {
// TRU-78 / DRIFT 17: surface the "last washed" timestamp for a scanned
// license plate on the landing page so the operator can see at a glance
// when a trailer was last washed (DHL pick-up use case).
const lastWash = formatLastWashTimestamp(scan?.last_wash);
if (!lastWash) {
return null;
}
return {
label: SessionUser.objects.global.language.last_wash || t('admin.pos.recent_washes'),
value: lastWash,
testKey: 'last-wash',
};
};
const getLastWashEmptyStateText = () => {
return t('admin.pos.never_washed');
};
const formatLastWashTimestamp = (value) => {
if (!isMeaningfulValue(value)) {
return '';
}
const parsedDate = new Date(String(value).replace(' ', 'T'));
if (Number.isNaN(parsedDate.getTime())) {
return String(value);
}
try {
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(parsedDate);
} catch (error) {
console.error('Unable to format last_wash timestamp', error);
return String(value);
}
};
const getVehicleDetailItems = (scan) => {
const detailState = getPlateDetailState(scan.plate);
if (detailState.status !== 'loaded') {
@@ -534,29 +493,19 @@ setTimeout(() => {
:data-testid="`pos-recent-scan-details-${scan.id}`"
>
<div
v-if="getCustomerDetailItems(scan).length > 0 || getLastWashDetailItem(scan)"
v-if="getCustomerDetailItems(scan).length > 0"
class="pos-scan-details__grid pos-scan-details__grid--customer"
>
<div
v-for="item in [...getCustomerDetailItems(scan), getLastWashDetailItem(scan)].filter(Boolean)"
v-for="item in getCustomerDetailItems(scan)"
:key="item.label"
class="pos-scan-detail-field"
:data-testid="`pos-recent-scan-detail-${scan.id}-${item.testKey || item.label}`"
>
<span class="pos-scan-detail-field__label">{{ item.label }}</span>
<span class="pos-scan-detail-field__value">{{ item.value }}</span>
</div>
</div>
<p
v-if="!getLastWashDetailItem(scan) && scan.last_wash == null"
class="pos-scan-details__state"
:data-testid="`pos-recent-scan-never-washed-${scan.id}`"
>
<i class="fas fa-info-circle" aria-hidden="true"></i>
<span>{{ getLastWashEmptyStateText() }}</span>
</p>
<div v-if="getPlateDetailState(scan.plate).status === 'loading'" class="pos-scan-details__state">
<i class="fas fa-spinner fa-spin"></i>
<span>{{ $t('admin.pos.recent_scan_details_loading') }}</span>
@@ -271,7 +271,7 @@ const hiddenOrderFilterDefinitions = computed(() => [
options: [
{ value: "*", label: t("common.all") },
{ value: "1", label: "E-conomic" },
{ value: "2", label: "Stripe" },
{ value: "2", label: t("pagination.payment_link") },
{ value: "3", label: t("pagination.other_no_tracking") },
{ value: "is_null", label: t("pagination.not_specified") },
],
+6 -6
View File
@@ -5360,6 +5360,7 @@
"other_no_tracking": "@.capitalize:{'words.generated.anden'} (@.capitalize:{'words.generated.uden'} tracking)",
"page_of": "@:{'templates.common.page_of'}",
"payment_processor": "Betalingsprocessor",
"payment_link": "Betalingslink",
"per_page": "@.capitalize:{'words.generated.per'} @:{'words.generated.side'}",
"pickup": "@:{'words.generated.afhentning'}",
"previous": "@.capitalize:{'words.generated.forrige'} @:{'words.generated.side'}",
@@ -5448,7 +5449,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samme'} @:{'words.generated.ydelse'}, @:{'words.generated.tilvalg'} @:{'words.generated.og'} @:{'words.generated.ekstra'} @:{'words.generated.varer'} @:{'words.generated.som'} @:{'words.generated.sidste'} @:{'words.generated.vask'}",
"manual_entry": "Manuell @:{'words.generated.registrering'}",
"never_washed": "Aldrig vasket",
"new_order": "@.capitalize:{'words.generated.ny'} order",
"new_sale": "@.capitalize:{'words.generated.nyt'} @:{'words.generated.salg'}",
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bookinger'} @:{'words.generated.fundet'}",
@@ -5511,13 +5511,13 @@
"process_booking": "@:{'words.generated.behandler'} @:{'words.generated.booking'}...",
"processing_order": "@:{'words.generated.behandler'} @:{'words.generated.ordre'}...",
"restrictions": {
"addons_not_allowed": "Tilvalg er ikke tilladt for denne kunde",
"backend_rejected": "Produktet blev afvist af kundereglerne og er ikke tilføjet",
"addons_not_allowed": "Tilvalg er ikke tilgængelige for denne kunde. Tjek kundens regler eller vælg et andet produkt.",
"backend_rejected": "Produktet blev afvist af kundereglerne og er ikke tilføjet. Prøv et andet produkt eller fjern reglen.",
"load_failed": "Kundereglerne kunne ikke indlæses. Prøv igen, før du ændrer produkter.",
"loading": "Kundereglerne indlæses. Produktændringer er midlertidigt deaktiveret.",
"product_not_allowed": "Produktet er ikke tilladt for denne kunde",
"restricted_items_removed": "Begrænsede tilvalg er fjernet fra kurven",
"unavailable": "Ikke tilgængelig",
"product_not_allowed": "Produktet er ikke tilgængeligt for denne kunde. Vælg et andet produkt eller justér kundens regler.",
"restricted_items_removed": "Begrænsede tilvalg er fjernet fra kurven. Vælg andre tilvalg, der matcher kundens regler.",
"unavailable": "Ikke tilgængeligt — tjek kundens regler",
"title": "Kunderegel"
},
"receipt": "@:{'templates.generated.compat.global.payment.receipt'}",
+6 -6
View File
@@ -5470,6 +5470,7 @@
"other_no_tracking": "Other (@.capitalize:{'words.generated.keine'} tracking)",
"page_of": "@:{'templates.common.page_of'}",
"payment_processor": "Zahlungsabwickler",
"payment_link": "Zahlungslink",
"per_page": "@.capitalize:{'words.generated.pro'} @:{'words.generated.seite'}",
"pickup": "@:{'words.generated.abholung'}",
"previous": "@.capitalize:{'words.generated.vorherige'} @:{'words.generated.seite'}",
@@ -5558,7 +5559,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.gleiche'} @:{'words.generated.leistung'}, @:{'words.generated.zusatzoptionen'} @:{'words.generated.und'} @:{'words.generated.zusatzartikel'} @:{'words.generated.wie'} @:{'words.generated.bei'} @:{'words.generated.der'} @:{'words.generated.letzten'} @:{'words.generated.wasche'}",
"manual_entry": "@.capitalize:{'words.generated.manuell'} registrering",
"never_washed": "Noch nie gewaschen",
"new_order": "@.capitalize:{'words.generated.neuer'} @:{'words.generated.auftrag'}",
"new_sale": "Nytt salg",
"no_bookings": "@.capitalize:{'words.generated.keine'} @:{'words.generated.buchungen'} @:{'words.generated.gefunden'}",
@@ -5621,13 +5621,13 @@
"process_booking": "@:{'words.generated.buchung'} @:{'words.generated.verarbeiten'}",
"processing_order": "@:{'words.generated.auftrag'} @:{'words.generated.wird'} verarbeitet...",
"restrictions": {
"addons_not_allowed": "Zusatzoptionen sind für diesen Kunden nicht erlaubt",
"backend_rejected": "Das Produkt wurde durch Kundenregeln abgelehnt und nicht hinzugefügt",
"addons_not_allowed": "Zusatzoptionen sind für diesen Kunden nicht verfügbar. Prüfen Sie die Kundenregeln oder wählen Sie ein anderes Produkt.",
"backend_rejected": "Das Produkt wurde durch die Kundenregeln abgelehnt und nicht hinzugefügt. Versuchen Sie ein anderes Produkt oder entfernen Sie die Regel.",
"load_failed": "Kundenregeln konnten nicht geladen werden. Versuchen Sie es erneut, bevor Sie Produkte ändern.",
"loading": "Kundenregeln werden geladen. Produktänderungen sind vorübergehend deaktiviert.",
"product_not_allowed": "Dieses Produkt ist für diesen Kunden nicht erlaubt",
"restricted_items_removed": "Eingeschränkte Zusatzoptionen wurden aus dem Warenkorb entfernt",
"unavailable": "Nicht verfügbar",
"product_not_allowed": "Dieses Produkt ist für diesen Kunden nicht verfügbar. Wählen Sie ein anderes Produkt oder passen Sie die Kundenregeln an.",
"restricted_items_removed": "Eingeschränkte Zusatzoptionen wurden aus dem Warenkorb entfernt. Wählen Sie andere Zusatzoptionen, die zu den Kundenregeln passen.",
"unavailable": "Nicht verfügbar — Kundenregeln prüfen",
"title": "Kundenregel"
},
"receipt": "@.capitalize:{'words.generated.kvitto'}",
+6 -6
View File
@@ -5191,6 +5191,7 @@
"other_no_tracking": "@.capitalize:{'words.generated.other'} (@.capitalize:{'words.generated.no'} tracking)",
"page_of": "@:{'templates.common.page_of'}",
"payment_processor": "@.capitalize:{'words.generated.payment'} @:{'words.generated.processor'}",
"payment_link": "Payment link",
"per_page": "@.capitalize:{'words.generated.per'} @:{'words.generated.page'}",
"pickup": "@:{'words.generated.pickup'}",
"previous": "@.capitalize:{'words.generated.previous'} @:{'words.generated.page'}",
@@ -5279,7 +5280,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.same'} @:{'words.generated.service'}, @:{'words.generated.add'}-@:{'words.generated.ons'}, @:{'words.generated.and'} @:{'words.generated.extra'} @:{'words.generated.items'} @:{'words.generated.as'} @:{'words.replication.article.host_mention'} @:{'words.generated.previous'} @:{'words.generated.wash'}",
"manual_entry": "@:{'words.generated.manual'} @:{'words.generated.registration'}",
"never_washed": "Never washed",
"new_order": "@.capitalize:{'words.generated.new'} @:{'words.generated.order'}",
"new_sale": "@.capitalize:{'words.generated.new'} sale",
"no_bookings": "@.capitalize:{'words.generated.no'} @:{'words.generated.orders'} @:{'words.generated.found'}",
@@ -5342,13 +5342,13 @@
"process_booking": "@.capitalize:{'words.generated.process'} @:{'words.generated.order'}",
"processing_order": "@.capitalize:{'words.generated.processing'} @:{'words.generated.order'}...",
"restrictions": {
"addons_not_allowed": "Add-ons are not allowed for this customer",
"backend_rejected": "The product was rejected by customer rules and was not added",
"addons_not_allowed": "Add-ons are not available for this customer. Check the customer's rules or choose another product.",
"backend_rejected": "The product was rejected by the customer rules and was not added. Try a different product or remove the rule.",
"load_failed": "Customer rules could not be loaded. Retry before changing products.",
"loading": "Customer rules are loading. Product changes are temporarily disabled.",
"product_not_allowed": "This product is not allowed for this customer",
"restricted_items_removed": "Restricted add-ons were removed from the cart",
"unavailable": "Unavailable",
"product_not_allowed": "This product is not available for this customer. Pick another product or adjust the customer's rules.",
"restricted_items_removed": "Restricted add-ons were removed from the cart. Choose other add-ons that match the customer's rules.",
"unavailable": "Unavailable — check the customer's rules",
"title": "Customer rule"
},
"receipt": "@:{'templates.generated.compat.global.payment.receipt'}",
-2
View File
@@ -4852,8 +4852,6 @@
"last_wash_matches_current_selection": "@:{'templates.generated.compat.pos.last_wash_matches_current_selection'}",
"license_plate": "@:{'templates.generated.compat.tables.common.registration_number'}",
"manual_entry": "@:{'templates.generated.compat.pos.manual_entry'}",
"last_wash": "@:{'templates.generated.compat.global.last_wash'}",
"never_washed": "@:{'templates.generated.compat.pos.never_washed'}",
"new_order": "@:{'templates.generated.compat.pos.new_order'}",
"new_sale": "@:{'templates.generated.compat.pos.new_sale'}",
"no_bookings": "@:{'templates.generated.compat.pos.no_bookings'}",
+6 -6
View File
@@ -5473,6 +5473,7 @@
"other_no_tracking": "@.capitalize:{'words.generated.annet'} (@:{'words.generated.ingen'} sporing)",
"page_of": "@:{'templates.common.page_of'}",
"payment_processor": "Betalingsbehandler",
"payment_link": "Betalingslenke",
"per_page": "@.capitalize:{'words.generated.per'} @:{'words.generated.side'}",
"pickup": "@:{'words.generated.henting'}",
"previous": "@.capitalize:{'words.generated.forrige'} @:{'words.generated.side'}",
@@ -5561,7 +5562,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samme'} @:{'words.generated.tjeneste'}, @:{'words.generated.tillegg'} @:{'words.generated.og'} @:{'words.generated.ekstra'} @:{'words.generated.varer'} @:{'words.generated.som'} @:{'words.generated.forrige'} @:{'words.generated.vask'}",
"manual_entry": "Manuell @:{'words.generated.registrering'}",
"never_washed": "Aldri vasket",
"new_order": "@.capitalize:{'words.generated.bestillingen'}",
"new_sale": "@.capitalize:{'words.generated.nytt'} @:{'words.generated.salg'}",
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bestillinger'} @:{'words.generated.funnet'}",
@@ -5624,13 +5624,13 @@
"process_booking": "@.capitalize:{'words.generated.behandle'} @:{'words.generated.bestilling'}",
"processing_order": "@:{'words.generated.behandler'} @:{'words.generated.bestilling'}...",
"restrictions": {
"addons_not_allowed": "Tilvalg er ikke tillatt for denne kunden",
"backend_rejected": "Produktet ble avvist av kunderegler og ble ikke lagt til",
"addons_not_allowed": "Tilvalg er ikke tilgjengelige for denne kunden. Sjekk kundens regler eller velg et annet produkt.",
"backend_rejected": "Produktet ble avvist av kunderegler og ble ikke lagt til. Prøv et annet produkt eller fjern regelen.",
"load_failed": "Kundereglene kunne ikke lastes. Prøv igjen før du endrer produkter.",
"loading": "Kundereglene lastes. Produktendringer er midlertidig deaktivert.",
"product_not_allowed": "Dette produktet er ikke tillatt for denne kunden",
"restricted_items_removed": "Begrensede tilvalg ble fjernet fra kurven",
"unavailable": "Ikke tilgjengelig",
"product_not_allowed": "Dette produktet er ikke tilgjengelig for denne kunden. Velg et annet produkt eller juster kundens regler.",
"restricted_items_removed": "Begrensede tilvalg ble fjernet fra kurven. Velg andre tilvalg som passer kundens regler.",
"unavailable": "Ikke tilgjengelig — sjekk kundens regler",
"title": "Kunderegel"
},
"receipt": "@:{'templates.generated.compat.global.payment.receipt'}",
+6 -6
View File
@@ -5523,6 +5523,7 @@
"other_no_tracking": "övrigt (@:{'words.generated.ingen'} spårning)",
"page_of": "@.capitalize:{'words.generated.page'} {current} @:{'words.generated.of'} {total}",
"payment_processor": "Betalningsleverantär",
"payment_link": "Betalningslänk",
"per_page": "@.capitalize:{'words.generated.per'} @:{'words.generated.page'}",
"pickup": "Pickup",
"previous": "@.capitalize:{'words.generated.foregaende'} @:{'words.generated.sida'}",
@@ -5611,7 +5612,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samma'} @:{'words.generated.tjanst'}, @:{'words.generated.tillval'} @:{'words.generated.och'} @:{'words.generated.extra'} @:{'words.generated.artiklar'} @:{'words.generated.som'} @:{'words.generated.senaste'} @:{'words.generated.tvatten'}",
"manual_entry": "Manuell @:{'words.generated.registrering'}",
"never_washed": "Aldrig tvättad",
"new_order": "@.capitalize:{'words.generated.ny'} @:{'words.generated.order'}",
"new_sale": "@.capitalize:{'words.generated.nytt'} salg",
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bestillinger'} @:{'words.generated.funnet'}",
@@ -5674,13 +5674,13 @@
"process_booking": "Behandle @:{'words.generated.bestilling'}",
"processing_order": "Behandler @:{'words.generated.ordre'}...",
"restrictions": {
"addons_not_allowed": "Tillval är inte tillåtna för den här kunden",
"backend_rejected": "Produkten avvisades av kundregler och lades inte till",
"addons_not_allowed": "Tillval är inte tillgängliga för den här kunden. Kontrollera kundens regler eller välj en annan produkt.",
"backend_rejected": "Produkten avvisades av kundregler och lades inte till. Försök med en annan produkt eller ta bort regeln.",
"load_failed": "Kundreglerna kunde inte läsas in. Försök igen innan du ändrar produkter.",
"loading": "Kundreglerna läses in. Produktändringar är tillfälligt inaktiverade.",
"product_not_allowed": "Den här produkten är inte tillåten för kunden",
"restricted_items_removed": "Begränsade tillval togs bort från varukorgen",
"unavailable": "Inte tillgänglig",
"product_not_allowed": "Den här produkten är inte tillgänglig för kunden. Välj en annan produkt eller justera kundens regler.",
"restricted_items_removed": "Begränsade tillval togs bort från varukorgen. Välj andra tillval som matchar kundens regler.",
"unavailable": "Inte tillgänglig — kontrollera kundens regler",
"title": "Kundregel"
},
"receipt": "@:{'templates.generated.compat.global.payment.receipt'}",
@@ -36,6 +36,7 @@
"other_no_tracking": "@.capitalize:{'terms.glossary.anden'} (@.capitalize:{'terms.glossary.uden'} tracking)",
"page_of": "@:{'phrases.common.page_of'}",
"payment_processor": "Betalingsprocessor",
"payment_link": "Betalingslink",
"per_page": "@.capitalize:{'terms.glossary.per'} @:{'terms.glossary.side'}",
"pickup": "@:{'terms.glossary.afhentning'}",
"previous": "@.capitalize:{'terms.glossary.forrige'} @:{'terms.glossary.side'}",
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.ydelse'}, @:{'terms.glossary.tilvalg'} @:{'terms.glossary.og'} @:{'terms.glossary.ekstra'} @:{'terms.glossary.varer'} @:{'terms.glossary.som'} @:{'terms.glossary.sidste'} @:{'terms.glossary.vask'}",
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
"never_washed": "Aldrig vasket",
"new_order": "@.capitalize:{'terms.glossary.ny'} order",
"new_sale": "@.capitalize:{'terms.glossary.nyt'} @:{'terms.glossary.salg'}",
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bookinger'} @:{'terms.glossary.fundet'}",
@@ -107,13 +106,13 @@
"process_booking": "@:{'terms.glossary.behandler'} @:{'terms.glossary.booking'}...",
"processing_order": "@:{'terms.glossary.behandler'} @:{'terms.glossary.ordre'}...",
"restrictions": {
"addons_not_allowed": "Tilvalg er ikke tilladt for denne kunde",
"backend_rejected": "Produktet blev afvist af kundereglerne og er ikke tilføjet",
"addons_not_allowed": "Tilvalg er ikke tilgængelige for denne kunde. Tjek kundens regler eller vælg et andet produkt.",
"backend_rejected": "Produktet blev afvist af kundereglerne og er ikke tilføjet. Prøv et andet produkt eller fjern reglen.",
"load_failed": "Kundereglerne kunne ikke indlæses. Prøv igen, før du ændrer produkter.",
"loading": "Kundereglerne indlæses. Produktændringer er midlertidigt deaktiveret.",
"product_not_allowed": "Produktet er ikke tilladt for denne kunde",
"restricted_items_removed": "Begrænsede tilvalg er fjernet fra kurven",
"unavailable": "Ikke tilgængelig",
"product_not_allowed": "Produktet er ikke tilgængeligt for denne kunde. Vælg et andet produkt eller justér kundens regler.",
"restricted_items_removed": "Begrænsede tilvalg er fjernet fra kurven. Vælg andre tilvalg, der matcher kundens regler.",
"unavailable": "Ikke tilgængeligt — tjek kundens regler",
"title": "Kunderegel"
},
"receipt": "@:{'phrases.compat.global.payment.receipt'}",
@@ -36,6 +36,7 @@
"other_no_tracking": "Other (@.capitalize:{'terms.glossary.keine'} tracking)",
"page_of": "@:{'phrases.common.page_of'}",
"payment_processor": "Zahlungsabwickler",
"payment_link": "Zahlungslink",
"per_page": "@.capitalize:{'terms.glossary.pro'} @:{'terms.glossary.seite'}",
"pickup": "@:{'terms.glossary.abholung'}",
"previous": "@.capitalize:{'terms.glossary.vorherige'} @:{'terms.glossary.seite'}",
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.gleiche'} @:{'terms.glossary.leistung'}, @:{'terms.glossary.zusatzoptionen'} @:{'terms.glossary.und'} @:{'terms.glossary.zusatzartikel'} @:{'terms.glossary.wie'} @:{'terms.glossary.bei'} @:{'terms.glossary.der'} @:{'terms.glossary.letzten'} @:{'terms.glossary.wasche'}",
"manual_entry": "@.capitalize:{'terms.glossary.manuell'} registrering",
"never_washed": "Noch nie gewaschen",
"new_order": "@.capitalize:{'terms.glossary.neuer'} @:{'terms.glossary.auftrag'}",
"new_sale": "Nytt salg",
"no_bookings": "@.capitalize:{'terms.glossary.keine'} @:{'terms.glossary.buchungen'} @:{'terms.glossary.gefunden'}",
@@ -107,13 +106,13 @@
"process_booking": "@:{'terms.glossary.buchung'} @:{'terms.glossary.verarbeiten'}",
"processing_order": "@:{'terms.glossary.auftrag'} @:{'terms.glossary.wird'} verarbeitet...",
"restrictions": {
"addons_not_allowed": "Zusatzoptionen sind für diesen Kunden nicht erlaubt",
"backend_rejected": "Das Produkt wurde durch Kundenregeln abgelehnt und nicht hinzugefügt",
"addons_not_allowed": "Zusatzoptionen sind für diesen Kunden nicht verfügbar. Prüfen Sie die Kundenregeln oder wählen Sie ein anderes Produkt.",
"backend_rejected": "Das Produkt wurde durch die Kundenregeln abgelehnt und nicht hinzugefügt. Versuchen Sie ein anderes Produkt oder entfernen Sie die Regel.",
"load_failed": "Kundenregeln konnten nicht geladen werden. Versuchen Sie es erneut, bevor Sie Produkte ändern.",
"loading": "Kundenregeln werden geladen. Produktänderungen sind vorübergehend deaktiviert.",
"product_not_allowed": "Dieses Produkt ist für diesen Kunden nicht erlaubt",
"restricted_items_removed": "Eingeschränkte Zusatzoptionen wurden aus dem Warenkorb entfernt",
"unavailable": "Nicht verfügbar",
"product_not_allowed": "Dieses Produkt ist für diesen Kunden nicht verfügbar. Wählen Sie ein anderes Produkt oder passen Sie die Kundenregeln an.",
"restricted_items_removed": "Eingeschränkte Zusatzoptionen wurden aus dem Warenkorb entfernt. Wählen Sie andere Zusatzoptionen, die zu den Kundenregeln passen.",
"unavailable": "Nicht verfügbar — Kundenregeln prüfen",
"title": "Kundenregel"
},
"receipt": "@.capitalize:{'terms.glossary.kvitto'}",
@@ -36,6 +36,7 @@
"other_no_tracking": "@.capitalize:{'terms.glossary.other'} (@.capitalize:{'terms.glossary.no'} tracking)",
"page_of": "@:{'phrases.common.page_of'}",
"payment_processor": "@.capitalize:{'terms.glossary.payment'} @:{'terms.glossary.processor'}",
"payment_link": "Payment link",
"per_page": "@.capitalize:{'terms.glossary.per'} @:{'terms.glossary.page'}",
"pickup": "@:{'terms.glossary.pickup'}",
"previous": "@.capitalize:{'terms.glossary.previous'} @:{'terms.glossary.page'}",
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.same'} @:{'terms.glossary.service'}, @:{'terms.glossary.add'}-@:{'terms.glossary.ons'}, @:{'terms.glossary.and'} @:{'terms.glossary.extra'} @:{'terms.glossary.items'} @:{'terms.glossary.as'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.previous'} @:{'terms.glossary.wash'}",
"manual_entry": "@:{'terms.glossary.manual'} @:{'terms.glossary.registration'}",
"never_washed": "Never washed",
"new_order": "@.capitalize:{'terms.glossary.new'} @:{'terms.glossary.order'}",
"new_sale": "@.capitalize:{'terms.glossary.new'} sale",
"no_bookings": "@.capitalize:{'terms.glossary.no'} @:{'terms.glossary.orders'} @:{'terms.glossary.found'}",
@@ -107,13 +106,13 @@
"process_booking": "@.capitalize:{'terms.glossary.process'} @:{'terms.glossary.order'}",
"processing_order": "@.capitalize:{'terms.glossary.processing'} @:{'terms.glossary.order'}...",
"restrictions": {
"addons_not_allowed": "Add-ons are not allowed for this customer",
"backend_rejected": "The product was rejected by customer rules and was not added",
"addons_not_allowed": "Add-ons are not available for this customer. Check the customer's rules or choose another product.",
"backend_rejected": "The product was rejected by the customer rules and was not added. Try a different product or remove the rule.",
"load_failed": "Customer rules could not be loaded. Retry before changing products.",
"loading": "Customer rules are loading. Product changes are temporarily disabled.",
"product_not_allowed": "This product is not allowed for this customer",
"restricted_items_removed": "Restricted add-ons were removed from the cart",
"unavailable": "Unavailable",
"product_not_allowed": "This product is not available for this customer. Pick another product or adjust the customer's rules.",
"restricted_items_removed": "Restricted add-ons were removed from the cart. Choose other add-ons that match the customer's rules.",
"unavailable": "Unavailable — check the customer's rules",
"title": "Customer rule"
},
"receipt": "@:{'phrases.compat.global.payment.receipt'}",
@@ -76,8 +76,6 @@
"last_wash_matches_current_selection": "@:{'phrases.compat.pos.last_wash_matches_current_selection'}",
"license_plate": "@:{'phrases.compat.tables.common.registration_number'}",
"manual_entry": "@:{'phrases.compat.pos.manual_entry'}",
"last_wash": "@:{'phrases.compat.global.last_wash'}",
"never_washed": "@:{'phrases.compat.pos.never_washed'}",
"new_order": "@:{'phrases.compat.pos.new_order'}",
"new_sale": "@:{'phrases.compat.pos.new_sale'}",
"no_bookings": "@:{'phrases.compat.pos.no_bookings'}",
@@ -36,6 +36,7 @@
"other_no_tracking": "@.capitalize:{'terms.glossary.annet'} (@:{'terms.glossary.ingen'} sporing)",
"page_of": "@:{'phrases.common.page_of'}",
"payment_processor": "Betalingsbehandler",
"payment_link": "Betalingslenke",
"per_page": "@.capitalize:{'terms.glossary.per'} @:{'terms.glossary.side'}",
"pickup": "@:{'terms.glossary.henting'}",
"previous": "@.capitalize:{'terms.glossary.forrige'} @:{'terms.glossary.side'}",
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.tjeneste'}, @:{'terms.glossary.tillegg'} @:{'terms.glossary.og'} @:{'terms.glossary.ekstra'} @:{'terms.glossary.varer'} @:{'terms.glossary.som'} @:{'terms.glossary.forrige'} @:{'terms.glossary.vask'}",
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
"never_washed": "Aldri vasket",
"new_order": "@.capitalize:{'terms.glossary.bestillingen'}",
"new_sale": "@.capitalize:{'terms.glossary.nytt'} @:{'terms.glossary.salg'}",
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bestillinger'} @:{'terms.glossary.funnet'}",
@@ -107,13 +106,13 @@
"process_booking": "@.capitalize:{'terms.glossary.behandle'} @:{'terms.glossary.bestilling'}",
"processing_order": "@:{'terms.glossary.behandler'} @:{'terms.glossary.bestilling'}...",
"restrictions": {
"addons_not_allowed": "Tilvalg er ikke tillatt for denne kunden",
"backend_rejected": "Produktet ble avvist av kunderegler og ble ikke lagt til",
"addons_not_allowed": "Tilvalg er ikke tilgjengelige for denne kunden. Sjekk kundens regler eller velg et annet produkt.",
"backend_rejected": "Produktet ble avvist av kunderegler og ble ikke lagt til. Prøv et annet produkt eller fjern regelen.",
"load_failed": "Kundereglene kunne ikke lastes. Prøv igjen før du endrer produkter.",
"loading": "Kundereglene lastes. Produktendringer er midlertidig deaktivert.",
"product_not_allowed": "Dette produktet er ikke tillatt for denne kunden",
"restricted_items_removed": "Begrensede tilvalg ble fjernet fra kurven",
"unavailable": "Ikke tilgjengelig",
"product_not_allowed": "Dette produktet er ikke tilgjengelig for denne kunden. Velg et annet produkt eller juster kundens regler.",
"restricted_items_removed": "Begrensede tilvalg ble fjernet fra kurven. Velg andre tilvalg som passer kundens regler.",
"unavailable": "Ikke tilgjengelig — sjekk kundens regler",
"title": "Kunderegel"
},
"receipt": "@:{'phrases.compat.global.payment.receipt'}",
@@ -36,6 +36,7 @@
"other_no_tracking": "övrigt (@:{'terms.glossary.ingen'} spårning)",
"page_of": "@.capitalize:{'terms.glossary.page'} {current} @:{'terms.glossary.of'} {total}",
"payment_processor": "Betalningsleverantär",
"payment_link": "Betalningslänk",
"per_page": "@.capitalize:{'terms.glossary.per'} @:{'terms.glossary.page'}",
"pickup": "Pickup",
"previous": "@.capitalize:{'terms.glossary.foregaende'} @:{'terms.glossary.sida'}",
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samma'} @:{'terms.glossary.tjanst'}, @:{'terms.glossary.tillval'} @:{'terms.glossary.och'} @:{'terms.glossary.extra'} @:{'terms.glossary.artiklar'} @:{'terms.glossary.som'} @:{'terms.glossary.senaste'} @:{'terms.glossary.tvatten'}",
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
"never_washed": "Aldrig tvättad",
"new_order": "@.capitalize:{'terms.glossary.ny'} @:{'terms.glossary.order'}",
"new_sale": "@.capitalize:{'terms.glossary.nytt'} salg",
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bestillinger'} @:{'terms.glossary.funnet'}",
@@ -107,13 +106,13 @@
"process_booking": "Behandle @:{'terms.glossary.bestilling'}",
"processing_order": "Behandler @:{'terms.glossary.ordre'}...",
"restrictions": {
"addons_not_allowed": "Tillval är inte tillåtna för den här kunden",
"backend_rejected": "Produkten avvisades av kundregler och lades inte till",
"addons_not_allowed": "Tillval är inte tillgängliga för den här kunden. Kontrollera kundens regler eller välj en annan produkt.",
"backend_rejected": "Produkten avvisades av kundregler och lades inte till. Försök med en annan produkt eller ta bort regeln.",
"load_failed": "Kundreglerna kunde inte läsas in. Försök igen innan du ändrar produkter.",
"loading": "Kundreglerna läses in. Produktändringar är tillfälligt inaktiverade.",
"product_not_allowed": "Den här produkten är inte tillåten för kunden",
"restricted_items_removed": "Begränsade tillval togs bort från varukorgen",
"unavailable": "Inte tillgänglig",
"product_not_allowed": "Den här produkten är inte tillgänglig för kunden. Välj en annan produkt eller justera kundens regler.",
"restricted_items_removed": "Begränsade tillval togs bort från varukorgen. Välj andra tillval som matchar kundens regler.",
"unavailable": "Inte tillgänglig — kontrollera kundens regler",
"title": "Kundregel"
},
"receipt": "@:{'phrases.compat.global.payment.receipt'}",
@@ -868,6 +868,82 @@ const getCustomerStatusIconClass = (customer: any) => {
const isOrderLineFlag = (flag: any) => ["order", "order_field"].includes(String(flag?.target_type || ""));
const isOrderItemLineFlag = (flag: any) => ["order_item", "order_item_field"].includes(String(flag?.target_type || ""));
/**
* TRU-60: Aggregate paid/unpaid/overdue state across the customer's
* transactions so the customer card can display a single invoice-state
* badge. Priority: paid > overdue > unpaid > open > no_transactions.
*/
type InvoiceStateBadge = {
key: "paid" | "overdue" | "unpaid" | "open" | "none";
label: string;
tone: "success" | "danger" | "warning" | "info" | "light";
icon: string;
};
const INVOICE_STATE_LABELS: Record<InvoiceStateBadge["key"], string> = {
paid: "Betalt",
overdue: "Forfalden",
unpaid: "Ikke betalt",
open: "Åben",
none: "Ingen vask",
};
const INVOICE_STATE_TONE: Record<InvoiceStateBadge["key"], InvoiceStateBadge["tone"]> = {
paid: "success",
overdue: "danger",
unpaid: "warning",
open: "info",
none: "light",
};
const INVOICE_STATE_ICON: Record<InvoiceStateBadge["key"], string> = {
paid: "fas fa-check-circle",
overdue: "fas fa-exclamation-triangle",
unpaid: "fas fa-clock",
open: "fas fa-file-invoice",
none: "fas fa-inbox",
};
const isTransactionOverdue = (transaction: any): boolean => {
const dueDate = String(transaction?.due_date ?? transaction?.invoice_due_date ?? "");
if (dueDate === "") {
return false;
}
const due = new Date(`${dueDate}T23:59:59`);
if (Number.isNaN(due.getTime())) {
return false;
}
return due.getTime() < Date.now();
};
const getCustomerInvoiceState = (customer: any): InvoiceStateBadge => {
const transactions = getTransactionsInView(customer);
if (!Array.isArray(transactions) || transactions.length === 0) {
return { key: "none", label: INVOICE_STATE_LABELS.none, tone: INVOICE_STATE_TONE.none, icon: INVOICE_STATE_ICON.none };
}
const active = transactions.filter((transaction: any) => transaction?.excluded !== true);
if (active.length === 0) {
return { key: "none", label: INVOICE_STATE_LABELS.none, tone: INVOICE_STATE_TONE.none, icon: INVOICE_STATE_ICON.none };
}
const allBooked = active.every((transaction: any) => transaction?.booked === true || String(transaction?.invoice_state ?? "") === "economic_booked");
if (allBooked) {
return { key: "paid", label: INVOICE_STATE_LABELS.paid, tone: INVOICE_STATE_TONE.paid, icon: INVOICE_STATE_ICON.paid };
}
if (active.some(isTransactionOverdue)) {
return { key: "overdue", label: INVOICE_STATE_LABELS.overdue, tone: INVOICE_STATE_TONE.overdue, icon: INVOICE_STATE_ICON.overdue };
}
const anyCompleted = active.some((transaction: any) => String(transaction?.invoice_state ?? "") === "closed" || Boolean(transaction?.completed_at));
if (anyCompleted) {
return { key: "unpaid", label: INVOICE_STATE_LABELS.unpaid, tone: INVOICE_STATE_TONE.unpaid, icon: INVOICE_STATE_ICON.unpaid };
}
return { key: "open", label: INVOICE_STATE_LABELS.open, tone: INVOICE_STATE_TONE.open, icon: INVOICE_STATE_ICON.open };
};
const getCustomerCardFlags = (customer: any) =>
getCustomerScopedFlags(customer).filter((flag: any) => !isOrderLineFlag(flag) && !isOrderItemLineFlag(flag));
@@ -1411,6 +1487,18 @@ const getTransactionQueryParameters = () => {
dropdown: false,
}"
/>
<!-- TRU-60: invoice-state badge (paid / unpaid / overdue / open) -->
<span
v-if="getCustomerInvoiceState(customer).key !== 'none'"
:class="['tag', `is-${getCustomerInvoiceState(customer).tone}`, 'is-light', 'period-customer-invoice-state']"
:data-testid="`invoicing-period-customer-invoice-state-${customer.customer_number}`"
:data-invoice-state="getCustomerInvoiceState(customer).key"
:title="getCustomerInvoiceState(customer).label"
role="status"
>
<span class="icon is-small"><i :class="getCustomerInvoiceState(customer).icon"></i></span>
<span>{{ getCustomerInvoiceState(customer).label }}</span>
</span>
<div class="period-customer-review" :class="getReviewStateClass(getCustomerReview(customer))">
<span class="icon is-small"><i :class="getReviewStateIcon(getCustomerReview(customer).state)"></i></span>
<span>{{ getReviewStateLabel(getCustomerReview(customer).state) }}</span>
@@ -1581,6 +1669,17 @@ const getTransactionQueryParameters = () => {
<span class="period-review-detail__eyebrow">{{ tr('detail.selected_customer', 'Valgt kunde') }}</span>
<h2>{{ selectedCustomer.customer_name }}</h2>
<p>#{{ selectedCustomer.customer_number }}</p>
<!-- TRU-60: invoice-state badge in detail header -->
<span
v-if="getCustomerInvoiceState(selectedCustomer).key !== 'none'"
:class="['tag', `is-${getCustomerInvoiceState(selectedCustomer).tone}`, 'is-light', 'period-customer-invoice-state', 'mt-1']"
:data-testid="`invoicing-period-detail-invoice-state-${selectedCustomer.customer_number}`"
:data-invoice-state="getCustomerInvoiceState(selectedCustomer).key"
role="status"
>
<span class="icon is-small"><i :class="getCustomerInvoiceState(selectedCustomer).icon"></i></span>
<span>{{ getCustomerInvoiceState(selectedCustomer).label }}</span>
</span>
<div class="period-review-detail__billing-types" :data-testid="`invoicing-period-review-detail-billing-types-${selectedCustomer.customer_number}`">
<InvoicingBillingPeriodCustomerAttributes :customer="selectedCustomer" scope="review-detail" />
</div>
@@ -1696,8 +1795,12 @@ const getTransactionQueryParameters = () => {
margin-bottom: 0.75rem;
padding: 0.75rem;
position: sticky;
top: 0.5rem;
z-index: 12;
/* TRU-63: top must clear the fixed navbar (z-index 1000) so the filter
is not hidden behind it (the "beam"/bjælke). Sit just below the navbar. */
top: 3.75rem;
/* TRU-63: raise z-index so filter chips & dropdowns render above page
content (previously z:12 → hidden under sticky/nav stacking). */
z-index: 1100;
}
.period-review-toolbar__search {
@@ -1770,7 +1873,7 @@ const getTransactionQueryParameters = () => {
padding: 0.25rem 0;
position: sticky;
top: 0;
z-index: 2;
z-index: 3;
}
.period-review-queue__header h2,
@@ -1849,6 +1952,15 @@ const getTransactionQueryParameters = () => {
min-width: 0;
}
/* TRU-60: invoice-state badge (paid/unpaid/overdue/open) */
.period-customer-invoice-state {
font-size: 0.7rem;
font-weight: 700;
margin-top: 0.25rem;
width: fit-content;
}
.period-customer-invoice-state .icon { margin-right: 0.2rem; }
.period-customer-review__next {
color: #6b7280;
margin-left: 0.25rem;
@@ -1917,7 +2029,7 @@ const getTransactionQueryParameters = () => {
}
.period-headline-totals { grid-template-columns: 1fr; }
.period-review-toolbar { position: static; }
.period-review-toolbar { position: static; z-index: auto; }
.period-review-toolbar__field { flex: 1 1 calc(50% - 0.5rem); }
.period-review-workspace { grid-template-columns: 1fr; }
.period-review-queue { max-height: none; overflow: visible; }
+2 -2
View File
@@ -369,7 +369,7 @@ test("only tankcleaning customers can only add tankcleaning products", async ({
const restrictedProductCard = page.getByTestId("pos-product-card-53").first();
await restrictedProductCard.click();
await expect(restrictedProductCard).not.toHaveClass(/is-selected/);
await expect(page.locator(".swal2-popup:visible")).toContainText("Produktet er ikke tilladt for denne kunde");
await expect(page.locator(".swal2-popup:visible")).toContainText("Produktet er ikke tilgængeligt for denne kunde");
await page.locator(".swal2-confirm:visible").click();
await expect(page.getByTestId("pos-add-to-cart-53")).toHaveCount(0);
expect(orderItemPosts).toEqual([]);
@@ -495,7 +495,7 @@ test("exact customer-rule configuration disables desktop add-on controls by opti
await page.getByTestId("pos-addon-restriction-tooltip-63").hover();
await expect(page.locator(".tooltip-content:visible").last()).toContainText(
"Tilvalg er ikke tilladt for denne kunde"
"Tilvalg er ikke tilgængelige for denne kunde"
);
for (const testId of ["pos-addon-63-increase", "pos-addon-63-name", "pos-addon-63-decrease"]) {
+3 -3
View File
@@ -3673,7 +3673,7 @@ test.describe("POS mobile order flow", () => {
await expect(page.getByTestId("pos-mobile-addon-71-increase")).toBeDisabled();
await page.getByTestId("pos-mobile-addon-71-restriction-tooltip").click();
await expect(page.locator(".tooltip-content:visible").last()).toContainText(
"Tilvalg er ikke tilladt for denne kunde"
"Tilvalg er ikke tilgængelige for denne kunde"
);
for (const testId of ["pos-mobile-addon-71-decrease", "pos-mobile-addon-71", "pos-mobile-addon-71-increase"]) {
@@ -3688,7 +3688,7 @@ test.describe("POS mobile order flow", () => {
await expect(restrictedAdditionalItem).toBeDisabled();
await page.getByTestId("pos-mobile-product-91-restriction-tooltip").click();
await expect(page.locator(".tooltip-content:visible").last()).toContainText(
"Tilvalg er ikke tilladt for denne kunde"
"Tilvalg er ikke tilgængelige for denne kunde"
);
await expect
.poll(
@@ -3769,7 +3769,7 @@ test.describe("POS mobile order flow", () => {
await expect(blockedPrimary).toBeDisabled();
await page.getByTestId("pos-mobile-product-53-restriction-tooltip").click();
await expect(page.locator(".tooltip-content:visible").last()).toContainText(
"Produktet er ikke tilladt for denne kunde"
"Produktet er ikke tilgængeligt for denne kunde"
);
await blockedPrimary.evaluate((button) => button.click());
await expect(page.getByTestId("pos-mobile-vehicle-selection")).toBeVisible();
@@ -83,7 +83,6 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
},
global: {
language: {
last_wash: "Last wash",
nothing_left_to_show: "Nothing left to show",
},
},
@@ -134,7 +133,6 @@ const messages = {
make: "Make",
model: "Model",
no_plates_found: "No license plates found",
never_washed: "Never washed",
recent_scans: "Recent scans",
recent_scans_helper: "Select a license plate to view vehicle details.",
recent_scan_details_empty: "No vehicle details are available for this scan.",
@@ -166,9 +164,6 @@ const scans = [
customer_name: "Pleno Logistics",
seen_before: true,
barred: false,
// TRU-78: enriched by the API so the landing page can show when
// the plate was last washed (DHL pick-up use case).
last_wash: "2026-04-07 17:12:33",
},
{
id: 802,
@@ -179,22 +174,6 @@ const scans = [
customer_name: "",
seen_before: false,
barred: false,
// TRU-78: explicit null means the API confirmed the plate has
// never been washed.
last_wash: null,
},
{
id: 803,
plate: "EF11111",
plate_scanner_id: 3,
created_at: "2026-04-08 08:30:00",
customer_number: null,
customer_name: "",
seen_before: false,
barred: false,
// TRU-78: missing field should be treated as "never washed" too,
// so older backends don't break the new UI.
// (no `last_wash` key on purpose)
},
];
@@ -362,59 +341,4 @@ describe("PosLastScannedLicensePlatesV2", () => {
"Vehicle details could not be loaded"
);
});
// TRU-78 / DRIFT 17: license plate scan should show "last washed" on the
// landing page so the operator can see at a glance when a trailer was
// last washed (DHL pick-up use case).
it("renders the last washed timestamp when the scan has a last_wash value", async () => {
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
await vi.advanceTimersByTimeAsync(1000);
paginationState.list.value = scans;
await flushUi();
await wrapper.get('[data-testid="pos-recent-scan-row-801"]').trigger("click");
await flushUi();
const lastWashField = wrapper.get('[data-testid="pos-recent-scan-detail-801-last-wash"]');
expect(lastWashField.exists()).toBe(true);
expect(lastWashField.text()).toContain("Last wash");
// The MySQL DATETIME "2026-04-07 17:12:33" is rendered in the user's
// locale. We accept both the raw DATETIME string (jsdom without full
// Intl support) and the locale-formatted output (e.g. 04/07/2026 or
// 2026-04-07 in browsers with Intl.DateTimeFormat).
expect(lastWashField.text()).toMatch(/2026/);
expect(lastWashField.text()).toMatch(/04[\/.\- ]07|07[\/.\- ]04|7[\/.\- ]4|2026-04-07/);
expect(lastWashField.text()).toMatch(/17:12/);
});
it("shows the 'never washed' message when the API confirms the plate has no last_wash", async () => {
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
await vi.advanceTimersByTimeAsync(1000);
paginationState.list.value = scans;
await flushUi();
await wrapper.get('[data-testid="pos-recent-scan-row-802"]').trigger("click");
await flushUi();
const neverWashed = wrapper.get('[data-testid="pos-recent-scan-never-washed-802"]');
expect(neverWashed.exists()).toBe(true);
expect(neverWashed.text()).toContain("Never washed");
});
it("falls back to 'never washed' when the scan has no last_wash key at all", async () => {
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
await vi.advanceTimersByTimeAsync(1000);
paginationState.list.value = scans;
await flushUi();
await wrapper.get('[data-testid="pos-recent-scan-row-803"]').trigger("click");
await flushUi();
const neverWashed = wrapper.get('[data-testid="pos-recent-scan-never-washed-803"]');
expect(neverWashed.exists()).toBe(true);
expect(neverWashed.text()).toContain("Never washed");
});
});
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
/**
* Tests for the XL Vask end-to-end implementation:
* - TRU-60: badges (paid/unpaid/overdue) on superuser faktura periode
* - TRU-71: replace "Stripe" / "Capture payment" wording
* - TRU-81: clearer message when add-on products are not available
* - TRU-63: z-index fix for filter behind the navbar
*/
const I18N_DA = readFileSync(resolve(__dirname, "../../src/i18n/source/da/phrases/compat/pos/index.json"), "utf8");
const I18N_EN = readFileSync(resolve(__dirname, "../../src/i18n/source/en/phrases/compat/pos/index.json"), "utf8");
const I18N_DA_PAGINATION = readFileSync(
resolve(__dirname, "../../src/i18n/source/da/phrases/compat/pagination/index.json"),
"utf8"
);
const INVOICING_VIEW = readFileSync(
resolve(
__dirname,
"../../src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue"
),
"utf8"
);
const INVOICE_PAGINATION = readFileSync(
resolve(__dirname, "../../src/components/displays/pagination/models/SuperUserDashboard/InvoiceOrdersPagination.vue"),
"utf8"
);
// The Stripe invoice email template lives in the sibling `api` repository.
// In the pleno-vue CI workflow the api repo is not checked out, so the file
// is only present in local / monorepo-style checkouts. Load it lazily and
// skip the related TRU-71 assertion gracefully when it is missing.
const EMAIL_TEMPLATE_PATH = resolve(
__dirname,
"../../../api/services/nginx/app/modules/email/templates/email_template_stripe_invoice.php"
);
const HAS_EMAIL_TEMPLATE = existsSync(EMAIL_TEMPLATE_PATH);
const EMAIL_TEMPLATE = HAS_EMAIL_TEMPLATE ? readFileSync(EMAIL_TEMPLATE_PATH, "utf8") : "";
describe("TRU-71: replace Stripe wording in user-facing strings", () => {
it("adds the new payment_link i18n key in Danish", () => {
expect(I18N_DA_PAGINATION).toMatch(/"payment_link":\s*"Betalingslink"/);
});
it("renders the payment link label through the i18n key, not a hard-coded 'Stripe'", () => {
// The filter option previously used the literal "Stripe" string
// for the user-facing label. It must now resolve through the
// pagination.payment_link translation key.
expect(INVOICE_PAGINATION).toMatch(/t\("pagination\.payment_link"\)/);
expect(INVOICE_PAGINATION).not.toMatch(/label:\s*"Stripe"/);
});
it("rewrites the Stripe-invoice email body so it no longer mentions Stripe", () => {
if (!HAS_EMAIL_TEMPLATE) {
// The api repo is not checked out alongside pleno-vue in this CI job.
// The Stripe-wording change is covered by the API repo's own tests.
return;
}
expect(EMAIL_TEMPLATE).not.toMatch(/på Stripe/);
expect(EMAIL_TEMPLATE).toMatch(/betalingslink/);
});
});
describe("TRU-81: clearer 'tilvalg ikke tilgængelig' message", () => {
it("replaces the terse Danish 'Ikke tilgængelig' with an explanatory message", () => {
expect(I18N_DA).toMatch(/"unavailable":\s*"Ikke tilgængeligt[^"]*tjek kundens regler"/);
expect(I18N_DA).toMatch(/"addons_not_allowed":\s*"Tilvalg er ikke tilgængelige[^"]*"/);
expect(I18N_DA).toMatch(/"product_not_allowed":\s*"Produktet er ikke tilgængeligt[^"]*"/);
});
it("replaces the terse English 'Unavailable' with an explanatory message", () => {
expect(I18N_EN).toMatch(/"unavailable":\s*"Unavailable\s*—\s*check[^"]*"/);
expect(I18N_EN).toMatch(/"addons_not_allowed":\s*"Add-ons are not available[^"]*"/);
});
});
describe("TRU-60: badges on superuser faktura periode (paid/unpaid/overdue)", () => {
it("renders an invoice-state badge for each customer in the review queue", () => {
expect(INVOICING_VIEW).toMatch(/invoicing-period-customer-invoice-state-\$\{customer\.customer_number\}/);
});
it("supports at least the four invoice-state keys (paid, unpaid, overdue, open)", () => {
expect(INVOICING_VIEW).toMatch(/paid:\s*"Betalt"/);
expect(INVOICING_VIEW).toMatch(/unpaid:\s*"Ikke betalt"/);
expect(INVOICING_VIEW).toMatch(/overdue:\s*"Forfalden"/);
expect(INVOICING_VIEW).toMatch(/open:\s*"Åben"/);
});
it("treats a transaction as overdue when its due_date is in the past", () => {
// The getCustomerInvoiceState helper checks transaction.due_date —
// make sure the date is parsed and compared.
expect(INVOICING_VIEW).toMatch(/isTransactionOverdue/);
expect(INVOICING_VIEW).toMatch(/getTime\(\)\s*<\s*Date\.now\(\)/);
});
it("shows the invoice-state badge in the review detail header too", () => {
expect(INVOICING_VIEW).toMatch(/invoicing-period-detail-invoice-state-\$\{selectedCustomer\.customer_number\}/);
});
});
describe("TRU-63: filter is no longer hidden behind the navbar", () => {
it("raises the period-review-toolbar z-index above the fixed navbar (z:1000)", () => {
expect(INVOICING_VIEW).toMatch(/z-index:\s*1100/);
});
it("offsets the sticky toolbar so it sits below the navbar instead of behind it", () => {
expect(INVOICING_VIEW).toMatch(/top:\s*3\.75rem/);
});
});