Redesign invoice period review workspace (#244)

Create an exception-first superuser invoice-period review workspace with responsive navigation, complete review states, and regression coverage.
This commit is contained in:
Jeppe B
2026-08-02 19:34:31 +02:00
committed by GitHub
parent eee9ba1c13
commit 832b362254
39 changed files with 2586 additions and 155 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,85 @@
{
"schemaVersion": 1,
"kind": "VisualEvidenceManifest",
"taskId": "a0edd464-44c0-4d77-96c1-d3562496a1b7",
"repository": "copenhagentruckwash/pleno-vue",
"baseSha": "eee9ba1c138f0c88c772ea284a5a97a46cb9412c",
"subjectSha": "89dec2c5690f9eaf1e0e34651bb40b7f441b85fb",
"views": [
{
"id": "invoicing-period-review",
"name": "Superuser invoice period review workspace",
"description": "Replaces the long mixed invoice-period page with grouped review navigation, compact totals, explicit review filters, and a responsive master-detail workspace while preserving every invoice category and action.",
"route": "/superuser/invoices?activeTab=period&startDate=2026-07-01&endDate=2026-07-31&periodView=all",
"fixture": "Synthetic superuser invoice-period fixture with three fictional customers and no production data",
"comparisons": {
"mobile": {
"width": 390,
"height": 844,
"before": {
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/before-mobile.png",
"sha256": "b3c25c072f45e912358cc61d21577bbf61d5216b114a6911f900ca19b90d477e",
"bytes": 28914,
"width": 390,
"height": 844,
"mimeType": "image/png",
"alt": "Invoice period mobile view before the review workspace redesign"
},
"after": {
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/after-mobile.png",
"sha256": "257db0e6e295b6b13ba5d0b29509c2a8b7170244ad6539a8e2ff18d2c9c0ffba",
"bytes": 31339,
"width": 390,
"height": 844,
"mimeType": "image/png",
"alt": "Invoice period mobile view after the review workspace redesign"
}
},
"tablet": {
"width": 768,
"height": 1024,
"before": {
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/before-tablet.png",
"sha256": "8cd17ea1e384ab6a9711a643d4e50e971cabcf704c3672d8f90cb2f5f2d36ba0",
"bytes": 38508,
"width": 768,
"height": 1024,
"mimeType": "image/png",
"alt": "Invoice period tablet view before the review workspace redesign"
},
"after": {
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/after-tablet.png",
"sha256": "1c627c30ade435ee004b8cdcd0223022594a351658639edec1f6e5396efaba18",
"bytes": 46062,
"width": 768,
"height": 1024,
"mimeType": "image/png",
"alt": "Invoice period tablet view after the review workspace redesign"
}
},
"desktop": {
"width": 1440,
"height": 900,
"before": {
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/before-desktop.png",
"sha256": "849ea0eedabc7f1e52172e26602cddfddbeed32c91d04672dfe8b713343a54fa",
"bytes": 142738,
"width": 1440,
"height": 900,
"mimeType": "image/png",
"alt": "Invoice period desktop view before the review workspace redesign"
},
"after": {
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/after-desktop.png",
"sha256": "d026925bc4f6ced62686ae7f8252594e3a3fe9bd559a7f53f38a1bfcb0b96892",
"bytes": 108740,
"width": 1440,
"height": 900,
"mimeType": "image/png",
"alt": "Invoice period desktop view after the review workspace redesign"
}
}
}
}
]
}
+103 -1
View File
@@ -4480,15 +4480,21 @@
"lines": "Linjer"
},
"fields": {
"booked_invoice_id": "Bogført faktura-ID",
"collection_state": "Status",
"created_at": "Oprettet",
"empty": "Tom",
"error_message": "Fejl",
"hide_empty": "Skjul tomme felter",
"notes": "Noter",
"price": "Pris",
"processor": "Behandler",
"product_id": "Produkt ID",
"quantity": "Antal",
"registration_number": "Registreringsnummer",
"reference": "Reference",
"show_empty": "Vis tomme felter"
"show_empty": "Vis tomme felter",
"updated_at": "Opdateret"
},
"nodes": {
"attachment_fallback": "Vedhæftning #{id}",
@@ -4535,6 +4541,102 @@
"order_item": "orderlinjer",
"xlvask": "selvvaske"
}
},
"review_workspace": {
"analytics": {
"title": "Overblik og nøgletal"
},
"detail": {
"next": "Næste kunde til gennemgang",
"open_prompt": "Vælg kunden i køen for at åbne fakturadetaljerne.",
"previous": "Forrige kunde til gennemgang",
"selected_customer": "Valgt kunde"
},
"headline": {
"aria_label": "Periodens hovedtal",
"booked": "Bogført",
"not_booked": "Ikke bogført",
"total": "I alt"
},
"navigation": {
"agreements": "Aftaler",
"aria_label": "Forretningsvisninger",
"billing": "Fakturering",
"operations": "Drift",
"review": "Gennemgang"
},
"next_actions": {
"create_invoice": "Opret faktura",
"none": "Ingen handling nødvendig",
"resolve_collection_errors": "Ret fejl i fakturasamling",
"resolve_draft": "Afklar kladde",
"resolve_manual_flags": "Afklar manuelle flag",
"review_warnings": "Gennemgå advarsler",
"wait_for_export": "Afvent eksport"
},
"queue": {
"count": "{count} kunder",
"title": "Kunder til gennemgang"
},
"reasons": {
"automatic_flags": "Automatiske advarsler",
"automatic_warnings": "Automatiske advarsler",
"collection_errors": "Fejl i fakturasamling",
"completed": "Alt er bogført",
"draft": "Kladde blokerer",
"draft_blocks_action": "Kladde blokerer",
"export_in_progress": "Eksport er i gang",
"manual_flags": "Manuelle flag kræver handling",
"queued": "Fakturaen er i kø",
"ready_to_invoice": "Klar til fakturering",
"requires_action": "Kræver gennemgang",
"unbooked_transactions": "Ikke-bogførte transaktioner"
},
"severities": {
"blue": "I kø",
"green": "Klar",
"red": "Blokeret",
"yellow": "Advarsel"
},
"sort": {
"customer_name": "Kundenavn",
"customer_number": "Kundenummer",
"priority": "Vigtigst først",
"total_amount": "Beløb"
},
"states": {
"attention": "Kræver opmærksomhed",
"blocked": "Blokeret",
"completed": "Færdig",
"empty_body": "Vælg en anden periode eller forretningsvisning.",
"empty_title": "Ingen fakturadata i perioden",
"error_body": "Prøv at genindlæse perioden.",
"error_title": "Fakturaperioden kunne ikke indlæses",
"filtered_empty_body": "Nulstil arbejdsgangsfiltrene eller prøv en anden søgning.",
"filtered_empty_title": "Ingen kunder matcher filtrene",
"loading_body": "Samler kunder, fakturasamlinger og kontrolpunkter…",
"loading_title": "Indlæser fakturaperioden",
"queued": "I kø",
"ready": "Klar til fakturering",
"refreshing": "Opdaterer eksisterende resultat…",
"retained_error": "Opdateringen mislykkedes. Det senest indlæste resultat vises stadig."
},
"toolbar": {
"all_departments": "Alle afdelinger",
"all_invoice_states": "Alle fakturastatusser",
"all_severities": "Alle niveauer",
"all_states": "Alle tilstande",
"change_direction": "Skift sorteringsretning",
"clear_filters": "Ryd filtre",
"department": "Afdeling",
"invoice_state": "Fakturastatus",
"reload": "Genindlæs",
"retry": "Prøv igen",
"search": "Søg i kunder og fakturadata",
"severity": "Alvor",
"sort": "Sortér",
"workflow": "Arbejdsgang"
}
}
},
"invoicing": {
+103 -1
View File
@@ -4590,15 +4590,21 @@
"lines": "Zeilen"
},
"fields": {
"booked_invoice_id": "Gebuchte Rechnungs-ID",
"collection_state": "Status",
"created_at": "Erstellt",
"empty": "Leer",
"error_message": "Fehler",
"hide_empty": "Leere Felder ausblenden",
"notes": "Notizen",
"price": "Preis",
"processor": "Verarbeitung",
"product_id": "Produkt-ID",
"quantity": "Anzahl",
"registration_number": "Kennzeichen",
"reference": "Referenz",
"show_empty": "Leere Felder anzeigen"
"show_empty": "Leere Felder anzeigen",
"updated_at": "Aktualisiert"
},
"nodes": {
"attachment_fallback": "Anhang #{id}",
@@ -4645,6 +4651,102 @@
"order_item": "Auftragspositionen",
"xlvask": "Selbstwäschen"
}
},
"review_workspace": {
"analytics": {
"title": "Übersicht und Kennzahlen"
},
"detail": {
"next": "Nächster zu prüfender Kunde",
"open_prompt": "Wählen Sie den Kunden in der Warteschlange, um die Rechnungsdetails zu öffnen.",
"previous": "Vorheriger zu prüfender Kunde",
"selected_customer": "Ausgewählter Kunde"
},
"headline": {
"aria_label": "Kennzahlen des Zeitraums",
"booked": "Gebucht",
"not_booked": "Nicht gebucht",
"total": "Gesamt"
},
"navigation": {
"agreements": "Vereinbarungen",
"aria_label": "Geschäftsansichten",
"billing": "Rechnungsstellung",
"operations": "Betrieb",
"review": "Prüfung"
},
"next_actions": {
"create_invoice": "Rechnung erstellen",
"none": "Keine Aktion erforderlich",
"resolve_collection_errors": "Sammlungsfehler beheben",
"resolve_draft": "Entwurf klären",
"resolve_manual_flags": "Manuelle Markierungen klären",
"review_warnings": "Warnungen prüfen",
"wait_for_export": "Auf Export warten"
},
"queue": {
"count": "{count} Kunden",
"title": "Zu prüfende Kunden"
},
"reasons": {
"automatic_flags": "Automatische Warnungen",
"automatic_warnings": "Automatische Warnungen",
"collection_errors": "Fehler in Rechnungssammlung",
"completed": "Alles verbucht",
"draft": "Entwurf blockiert",
"draft_blocks_action": "Entwurf blockiert",
"export_in_progress": "Export läuft",
"manual_flags": "Manuelle Markierungen erfordern Aktion",
"queued": "Rechnung ist eingereiht",
"ready_to_invoice": "Bereit zur Abrechnung",
"requires_action": "Prüfung erforderlich",
"unbooked_transactions": "Nicht gebuchte Transaktionen"
},
"severities": {
"blue": "Eingereiht",
"green": "Bereit",
"red": "Blockiert",
"yellow": "Warnung"
},
"sort": {
"customer_name": "Kundenname",
"customer_number": "Kundennummer",
"priority": "Priorität zuerst",
"total_amount": "Betrag"
},
"states": {
"attention": "Aufmerksamkeit erforderlich",
"blocked": "Blockiert",
"completed": "Abgeschlossen",
"empty_body": "Wählen Sie einen anderen Zeitraum oder eine andere Ansicht.",
"empty_title": "Keine Rechnungsdaten in diesem Zeitraum",
"error_body": "Laden Sie den Zeitraum erneut.",
"error_title": "Der Rechnungszeitraum konnte nicht geladen werden",
"filtered_empty_body": "Setzen Sie die Filter zurück oder suchen Sie erneut.",
"filtered_empty_title": "Keine Kunden entsprechen den Filtern",
"loading_body": "Kunden, Rechnungssammlungen und Prüfpunkte werden gesammelt…",
"loading_title": "Rechnungszeitraum wird geladen",
"queued": "Eingereiht",
"ready": "Bereit zur Abrechnung",
"refreshing": "Aktuelles Ergebnis wird aktualisiert…",
"retained_error": "Die Aktualisierung ist fehlgeschlagen. Das zuletzt geladene Ergebnis bleibt sichtbar."
},
"toolbar": {
"all_departments": "Alle Abteilungen",
"all_invoice_states": "Alle Rechnungsstatus",
"all_severities": "Alle Stufen",
"all_states": "Alle Zustände",
"change_direction": "Sortierrichtung ändern",
"clear_filters": "Filter löschen",
"department": "Abteilung",
"invoice_state": "Rechnungsstatus",
"reload": "Neu laden",
"retry": "Erneut versuchen",
"search": "Kunden und Rechnungsdaten durchsuchen",
"severity": "Schweregrad",
"sort": "Sortieren",
"workflow": "Arbeitsablauf"
}
}
},
"invoicing": {
+103 -1
View File
@@ -4311,15 +4311,21 @@
"lines": "Lines"
},
"fields": {
"booked_invoice_id": "Booked invoice ID",
"collection_state": "Status",
"created_at": "Created",
"empty": "Empty",
"error_message": "Error",
"hide_empty": "Hide empty fields",
"notes": "Notes",
"price": "Price",
"processor": "Processor",
"product_id": "Product ID",
"quantity": "Quantity",
"registration_number": "Registration number",
"reference": "Reference",
"show_empty": "Show empty fields"
"show_empty": "Show empty fields",
"updated_at": "Updated"
},
"nodes": {
"attachment_fallback": "Attachment #{id}",
@@ -4366,6 +4372,102 @@
"order_item": "order items",
"xlvask": "self-washes"
}
},
"review_workspace": {
"analytics": {
"title": "Overview and key figures"
},
"detail": {
"next": "Next customer needing review",
"open_prompt": "Select the customer in the queue to open invoice details.",
"previous": "Previous customer needing review",
"selected_customer": "Selected customer"
},
"headline": {
"aria_label": "Period headline figures",
"booked": "Booked",
"not_booked": "Not booked",
"total": "Total"
},
"navigation": {
"agreements": "Agreements",
"aria_label": "Business views",
"billing": "Invoicing",
"operations": "Operations",
"review": "Review"
},
"next_actions": {
"create_invoice": "Create invoice",
"none": "No action needed",
"resolve_collection_errors": "Fix collection errors",
"resolve_draft": "Resolve draft",
"resolve_manual_flags": "Resolve manual flags",
"review_warnings": "Review warnings",
"wait_for_export": "Wait for export"
},
"queue": {
"count": "{count} customers",
"title": "Customers to review"
},
"reasons": {
"automatic_flags": "Automatic warnings",
"automatic_warnings": "Automatic warnings",
"collection_errors": "Invoice collection errors",
"completed": "Everything is booked",
"draft": "Draft is blocking",
"draft_blocks_action": "Draft is blocking",
"export_in_progress": "Export is in progress",
"manual_flags": "Manual flags need action",
"queued": "Invoice is queued",
"ready_to_invoice": "Ready to invoice",
"requires_action": "Needs review",
"unbooked_transactions": "Unbooked transactions"
},
"severities": {
"blue": "Queued",
"green": "Ready",
"red": "Blocked",
"yellow": "Warning"
},
"sort": {
"customer_name": "Customer name",
"customer_number": "Customer number",
"priority": "Priority first",
"total_amount": "Amount"
},
"states": {
"attention": "Needs attention",
"blocked": "Blocked",
"completed": "Completed",
"empty_body": "Choose another period or business view.",
"empty_title": "No invoice data in this period",
"error_body": "Try reloading the period.",
"error_title": "The invoice period could not be loaded",
"filtered_empty_body": "Reset the workflow filters or try another search.",
"filtered_empty_title": "No customers match the filters",
"loading_body": "Collecting customers, invoice collections, and review points…",
"loading_title": "Loading invoice period",
"queued": "Queued",
"ready": "Ready to invoice",
"refreshing": "Refreshing the current result…",
"retained_error": "The refresh failed. The most recently loaded result is still shown."
},
"toolbar": {
"all_departments": "All departments",
"all_invoice_states": "All invoice states",
"all_severities": "All levels",
"all_states": "All states",
"change_direction": "Change sort direction",
"clear_filters": "Clear filters",
"department": "Department",
"invoice_state": "Invoice state",
"reload": "Reload",
"retry": "Try again",
"search": "Search customers and invoice data",
"severity": "Severity",
"sort": "Sort",
"workflow": "Workflow"
}
}
},
"invoicing": {
+103 -1
View File
@@ -3698,14 +3698,20 @@
"lines": "@:{'templates.generated.compat.invoicing_period.object_tree.economic.lines'}"
},
"fields": {
"booked_invoice_id": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.booked_invoice_id'}",
"collection_state": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.collection_state'}",
"created_at": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.created_at'}",
"empty": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.empty'}",
"error_message": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.error_message'}",
"hide_empty": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.hide_empty'}",
"notes": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.notes'}",
"price": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.price'}",
"processor": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.processor'}",
"product_id": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.product_id'}",
"quantity": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.quantity'}",
"reference": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.reference'}",
"show_empty": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.show_empty'}"
"show_empty": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.show_empty'}",
"updated_at": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.updated_at'}"
},
"nodes": {
"attachment_fallback": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.attachment_fallback'}",
@@ -3752,6 +3758,102 @@
"order_item": "@:{'templates.generated.compat.invoicing_period.object_tree.types.order_item'}",
"xlvask": "@:{'templates.generated.compat.invoicing_period.object_tree.types.xlvask'}"
}
},
"review_workspace": {
"analytics": {
"title": "@:{'templates.generated.compat.invoicing_period.review_workspace.analytics.title'}"
},
"detail": {
"next": "@:{'templates.generated.compat.invoicing_period.review_workspace.detail.next'}",
"open_prompt": "@:{'templates.generated.compat.invoicing_period.review_workspace.detail.open_prompt'}",
"previous": "@:{'templates.generated.compat.invoicing_period.review_workspace.detail.previous'}",
"selected_customer": "@:{'templates.generated.compat.invoicing_period.review_workspace.detail.selected_customer'}"
},
"headline": {
"aria_label": "@:{'templates.generated.compat.invoicing_period.review_workspace.headline.aria_label'}",
"booked": "@:{'templates.generated.compat.invoicing_period.review_workspace.headline.booked'}",
"not_booked": "@:{'templates.generated.compat.invoicing_period.review_workspace.headline.not_booked'}",
"total": "@:{'templates.generated.compat.invoicing_period.review_workspace.headline.total'}"
},
"navigation": {
"agreements": "@:{'templates.generated.compat.invoicing_period.review_workspace.navigation.agreements'}",
"aria_label": "@:{'templates.generated.compat.invoicing_period.review_workspace.navigation.aria_label'}",
"billing": "@:{'templates.generated.compat.invoicing_period.review_workspace.navigation.billing'}",
"operations": "@:{'templates.generated.compat.invoicing_period.review_workspace.navigation.operations'}",
"review": "@:{'templates.generated.compat.invoicing_period.review_workspace.navigation.review'}"
},
"next_actions": {
"create_invoice": "@:{'templates.generated.compat.invoicing_period.review_workspace.next_actions.create_invoice'}",
"none": "@:{'templates.generated.compat.invoicing_period.review_workspace.next_actions.none'}",
"resolve_collection_errors": "@:{'templates.generated.compat.invoicing_period.review_workspace.next_actions.resolve_collection_errors'}",
"resolve_draft": "@:{'templates.generated.compat.invoicing_period.review_workspace.next_actions.resolve_draft'}",
"resolve_manual_flags": "@:{'templates.generated.compat.invoicing_period.review_workspace.next_actions.resolve_manual_flags'}",
"review_warnings": "@:{'templates.generated.compat.invoicing_period.review_workspace.next_actions.review_warnings'}",
"wait_for_export": "@:{'templates.generated.compat.invoicing_period.review_workspace.next_actions.wait_for_export'}"
},
"queue": {
"count": "@:{'templates.generated.compat.invoicing_period.review_workspace.queue.count'}",
"title": "@:{'templates.generated.compat.invoicing_period.review_workspace.queue.title'}"
},
"reasons": {
"automatic_flags": "@:{'templates.generated.compat.invoicing_period.review_workspace.reasons.automatic_flags'}",
"automatic_warnings": "@:{'templates.generated.compat.invoicing_period.review_workspace.reasons.automatic_warnings'}",
"collection_errors": "@:{'templates.generated.compat.invoicing_period.review_workspace.reasons.collection_errors'}",
"completed": "@:{'templates.generated.compat.invoicing_period.review_workspace.reasons.completed'}",
"draft": "@:{'templates.generated.compat.invoicing_period.review_workspace.reasons.draft'}",
"draft_blocks_action": "@:{'templates.generated.compat.invoicing_period.review_workspace.reasons.draft_blocks_action'}",
"export_in_progress": "@:{'templates.generated.compat.invoicing_period.review_workspace.reasons.export_in_progress'}",
"manual_flags": "@:{'templates.generated.compat.invoicing_period.review_workspace.reasons.manual_flags'}",
"queued": "@:{'templates.generated.compat.invoicing_period.review_workspace.reasons.queued'}",
"ready_to_invoice": "@:{'templates.generated.compat.invoicing_period.review_workspace.reasons.ready_to_invoice'}",
"requires_action": "@:{'templates.generated.compat.invoicing_period.review_workspace.reasons.requires_action'}",
"unbooked_transactions": "@:{'templates.generated.compat.invoicing_period.review_workspace.reasons.unbooked_transactions'}"
},
"severities": {
"blue": "@:{'templates.generated.compat.invoicing_period.review_workspace.severities.blue'}",
"green": "@:{'templates.generated.compat.invoicing_period.review_workspace.severities.green'}",
"red": "@:{'templates.generated.compat.invoicing_period.review_workspace.severities.red'}",
"yellow": "@:{'templates.generated.compat.invoicing_period.review_workspace.severities.yellow'}"
},
"sort": {
"customer_name": "@:{'templates.generated.compat.invoicing_period.review_workspace.sort.customer_name'}",
"customer_number": "@:{'templates.generated.compat.invoicing_period.review_workspace.sort.customer_number'}",
"priority": "@:{'templates.generated.compat.invoicing_period.review_workspace.sort.priority'}",
"total_amount": "@:{'templates.generated.compat.invoicing_period.review_workspace.sort.total_amount'}"
},
"states": {
"attention": "@:{'templates.generated.compat.invoicing_period.review_workspace.states.attention'}",
"blocked": "@:{'templates.generated.compat.invoicing_period.review_workspace.states.blocked'}",
"completed": "@:{'templates.generated.compat.invoicing_period.review_workspace.states.completed'}",
"empty_body": "@:{'templates.generated.compat.invoicing_period.review_workspace.states.empty_body'}",
"empty_title": "@:{'templates.generated.compat.invoicing_period.review_workspace.states.empty_title'}",
"error_body": "@:{'templates.generated.compat.invoicing_period.review_workspace.states.error_body'}",
"error_title": "@:{'templates.generated.compat.invoicing_period.review_workspace.states.error_title'}",
"filtered_empty_body": "@:{'templates.generated.compat.invoicing_period.review_workspace.states.filtered_empty_body'}",
"filtered_empty_title": "@:{'templates.generated.compat.invoicing_period.review_workspace.states.filtered_empty_title'}",
"loading_body": "@:{'templates.generated.compat.invoicing_period.review_workspace.states.loading_body'}",
"loading_title": "@:{'templates.generated.compat.invoicing_period.review_workspace.states.loading_title'}",
"queued": "@:{'templates.generated.compat.invoicing_period.review_workspace.states.queued'}",
"ready": "@:{'templates.generated.compat.invoicing_period.review_workspace.states.ready'}",
"refreshing": "@:{'templates.generated.compat.invoicing_period.review_workspace.states.refreshing'}",
"retained_error": "@:{'templates.generated.compat.invoicing_period.review_workspace.states.retained_error'}"
},
"toolbar": {
"all_departments": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.all_departments'}",
"all_invoice_states": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.all_invoice_states'}",
"all_severities": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.all_severities'}",
"all_states": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.all_states'}",
"change_direction": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.change_direction'}",
"clear_filters": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.clear_filters'}",
"department": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.department'}",
"invoice_state": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.invoice_state'}",
"reload": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.reload'}",
"retry": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.retry'}",
"search": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.search'}",
"severity": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.severity'}",
"sort": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.sort'}",
"workflow": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.workflow'}"
}
}
},
"invoicing": {
+103 -1
View File
@@ -4593,15 +4593,21 @@
"lines": "Linjer"
},
"fields": {
"booked_invoice_id": "Bokført faktura-ID",
"collection_state": "Status",
"created_at": "Opprettet",
"empty": "Tom",
"error_message": "Feil",
"hide_empty": "Skjul tomme felter",
"notes": "Notater",
"price": "Pris",
"processor": "Behandler",
"product_id": "Produkt-ID",
"quantity": "Antall",
"registration_number": "Registreringsnummer",
"reference": "Referanse",
"show_empty": "Vis tomme felter"
"show_empty": "Vis tomme felter",
"updated_at": "Oppdatert"
},
"nodes": {
"attachment_fallback": "Vedlegg #{id}",
@@ -4648,6 +4654,102 @@
"order_item": "ordrelinjer",
"xlvask": "selvvaske"
}
},
"review_workspace": {
"analytics": {
"title": "Oversikt og nøkkeltall"
},
"detail": {
"next": "Neste kunde til gjennomgang",
"open_prompt": "Velg kunden i køen for å åpne fakturadetaljene.",
"previous": "Forrige kunde til gjennomgang",
"selected_customer": "Valgt kunde"
},
"headline": {
"aria_label": "Periodens hovedtall",
"booked": "Bokført",
"not_booked": "Ikke bokført",
"total": "Totalt"
},
"navigation": {
"agreements": "Avtaler",
"aria_label": "Forretningsvisninger",
"billing": "Fakturering",
"operations": "Drift",
"review": "Gjennomgang"
},
"next_actions": {
"create_invoice": "Opprett faktura",
"none": "Ingen handling nødvendig",
"resolve_collection_errors": "Rett feil i fakturasamling",
"resolve_draft": "Avklar utkast",
"resolve_manual_flags": "Avklar manuelle flagg",
"review_warnings": "Gjennomgå advarsler",
"wait_for_export": "Vent på eksport"
},
"queue": {
"count": "{count} kunder",
"title": "Kunder til gjennomgang"
},
"reasons": {
"automatic_flags": "Automatiske advarsler",
"automatic_warnings": "Automatiske advarsler",
"collection_errors": "Feil i fakturasamling",
"completed": "Alt er bokført",
"draft": "Utkast blokkerer",
"draft_blocks_action": "Utkast blokkerer",
"export_in_progress": "Eksport pågår",
"manual_flags": "Manuelle flagg krever handling",
"queued": "Fakturaen er i kø",
"ready_to_invoice": "Klar til fakturering",
"requires_action": "Krever gjennomgang",
"unbooked_transactions": "Ikke-bokførte transaksjoner"
},
"severities": {
"blue": "I kø",
"green": "Klar",
"red": "Blokkert",
"yellow": "Advarsel"
},
"sort": {
"customer_name": "Kundenavn",
"customer_number": "Kundenummer",
"priority": "Viktigst først",
"total_amount": "Beløp"
},
"states": {
"attention": "Krever oppmerksomhet",
"blocked": "Blokkert",
"completed": "Ferdig",
"empty_body": "Velg en annen periode eller forretningsvisning.",
"empty_title": "Ingen fakturadata i perioden",
"error_body": "Prøv å laste perioden på nytt.",
"error_title": "Fakturaperioden kunne ikke lastes",
"filtered_empty_body": "Nullstill arbeidsflytfiltrene eller prøv et annet søk.",
"filtered_empty_title": "Ingen kunder samsvarer med filtrene",
"loading_body": "Samler kunder, fakturasamlinger og kontrollpunkter…",
"loading_title": "Laster fakturaperioden",
"queued": "I kø",
"ready": "Klar til fakturering",
"refreshing": "Oppdaterer eksisterende resultat…",
"retained_error": "Oppdateringen mislyktes. Det sist innlastede resultatet vises fortsatt."
},
"toolbar": {
"all_departments": "Alle avdelinger",
"all_invoice_states": "Alle fakturastatuser",
"all_severities": "Alle nivåer",
"all_states": "Alle tilstander",
"change_direction": "Endre sorteringsretning",
"clear_filters": "Tøm filtre",
"department": "Avdeling",
"invoice_state": "Fakturastatus",
"reload": "Last på nytt",
"retry": "Prøv igjen",
"search": "Søk i kunder og fakturadata",
"severity": "Alvor",
"sort": "Sorter",
"workflow": "Arbeidsflyt"
}
}
},
"invoicing": {
+103 -1
View File
@@ -4643,15 +4643,21 @@
"lines": "Rader"
},
"fields": {
"booked_invoice_id": "Bokfört faktura-ID",
"collection_state": "Status",
"created_at": "Skapad",
"empty": "Tom",
"error_message": "Fel",
"hide_empty": "Dölj tomma fält",
"notes": "Anteckningar",
"price": "Pris",
"processor": "Behandlare",
"product_id": "Produkt-ID",
"quantity": "Antal",
"registration_number": "Registreringsnummer",
"reference": "Referens",
"show_empty": "Visa tomma fält"
"show_empty": "Visa tomma fält",
"updated_at": "Uppdaterad"
},
"nodes": {
"attachment_fallback": "Bilaga #{id}",
@@ -4698,6 +4704,102 @@
"order_item": "orderrader",
"xlvask": "självtvättar"
}
},
"review_workspace": {
"analytics": {
"title": "Översikt och nyckeltal"
},
"detail": {
"next": "Nästa kund att granska",
"open_prompt": "Välj kunden i kön för att öppna fakturadetaljerna.",
"previous": "Föregående kund att granska",
"selected_customer": "Vald kund"
},
"headline": {
"aria_label": "Periodens huvudtal",
"booked": "Bokfört",
"not_booked": "Inte bokfört",
"total": "Totalt"
},
"navigation": {
"agreements": "Avtal",
"aria_label": "Affärsvyer",
"billing": "Fakturering",
"operations": "Drift",
"review": "Granskning"
},
"next_actions": {
"create_invoice": "Skapa faktura",
"none": "Ingen åtgärd behövs",
"resolve_collection_errors": "Rätta samlingsfel",
"resolve_draft": "Hantera utkast",
"resolve_manual_flags": "Hantera manuella flaggor",
"review_warnings": "Granska varningar",
"wait_for_export": "Vänta på export"
},
"queue": {
"count": "{count} kunder",
"title": "Kunder att granska"
},
"reasons": {
"automatic_flags": "Automatiska varningar",
"automatic_warnings": "Automatiska varningar",
"collection_errors": "Fel i fakturasamling",
"completed": "Allt är bokfört",
"draft": "Utkast blockerar",
"draft_blocks_action": "Utkast blockerar",
"export_in_progress": "Export pågår",
"manual_flags": "Manuella flaggor kräver åtgärd",
"queued": "Fakturan ligger i kö",
"ready_to_invoice": "Klar för fakturering",
"requires_action": "Behöver granskas",
"unbooked_transactions": "Ej bokförda transaktioner"
},
"severities": {
"blue": "I kö",
"green": "Klar",
"red": "Blockerad",
"yellow": "Varning"
},
"sort": {
"customer_name": "Kundnamn",
"customer_number": "Kundnummer",
"priority": "Viktigast först",
"total_amount": "Belopp"
},
"states": {
"attention": "Behöver uppmärksamhet",
"blocked": "Blockerad",
"completed": "Klar",
"empty_body": "Välj en annan period eller affärsvy.",
"empty_title": "Inga fakturadata i perioden",
"error_body": "Försök ladda perioden igen.",
"error_title": "Fakturaperioden kunde inte laddas",
"filtered_empty_body": "Nollställ arbetsflödesfiltren eller prova en annan sökning.",
"filtered_empty_title": "Inga kunder matchar filtren",
"loading_body": "Samlar kunder, fakturasamlingar och kontrollpunkter…",
"loading_title": "Laddar fakturaperiod",
"queued": "I kö",
"ready": "Klar för fakturering",
"refreshing": "Uppdaterar befintligt resultat…",
"retained_error": "Uppdateringen misslyckades. Det senast laddade resultatet visas fortfarande."
},
"toolbar": {
"all_departments": "Alla avdelningar",
"all_invoice_states": "Alla fakturastatusar",
"all_severities": "Alla nivåer",
"all_states": "Alla tillstånd",
"change_direction": "Ändra sorteringsriktning",
"clear_filters": "Rensa filter",
"department": "Avdelning",
"invoice_state": "Fakturastatus",
"reload": "Ladda om",
"retry": "Försök igen",
"search": "Sök kunder och fakturadata",
"severity": "Allvar",
"sort": "Sortera",
"workflow": "Arbetsflöde"
}
}
},
"invoicing": {
@@ -138,15 +138,21 @@
"lines": "Linjer"
},
"fields": {
"booked_invoice_id": "Bogført faktura-ID",
"collection_state": "Status",
"created_at": "Oprettet",
"empty": "Tom",
"error_message": "Fejl",
"hide_empty": "Skjul tomme felter",
"notes": "Noter",
"price": "Pris",
"processor": "Behandler",
"product_id": "Produkt ID",
"quantity": "Antal",
"registration_number": "Registreringsnummer",
"reference": "Reference",
"show_empty": "Vis tomme felter"
"show_empty": "Vis tomme felter",
"updated_at": "Opdateret"
},
"nodes": {
"attachment_fallback": "Vedhæftning #{id}",
@@ -0,0 +1,19 @@
{
"compat": {
"invoicing_period": {
"review_workspace": {
"analytics": { "title": "Overblik og nøgletal" },
"detail": { "next": "Næste kunde til gennemgang", "open_prompt": "Vælg kunden i køen for at åbne fakturadetaljerne.", "previous": "Forrige kunde til gennemgang", "selected_customer": "Valgt kunde" },
"headline": { "aria_label": "Periodens hovedtal", "booked": "Bogført", "not_booked": "Ikke bogført", "total": "I alt" },
"navigation": { "agreements": "Aftaler", "aria_label": "Forretningsvisninger", "billing": "Fakturering", "operations": "Drift", "review": "Gennemgang" },
"next_actions": { "create_invoice": "Opret faktura", "none": "Ingen handling nødvendig", "resolve_collection_errors": "Ret fejl i fakturasamling", "resolve_draft": "Afklar kladde", "resolve_manual_flags": "Afklar manuelle flag", "review_warnings": "Gennemgå advarsler", "wait_for_export": "Afvent eksport" },
"queue": { "count": "{count} kunder", "title": "Kunder til gennemgang" },
"reasons": { "automatic_flags": "Automatiske advarsler", "automatic_warnings": "Automatiske advarsler", "collection_errors": "Fejl i fakturasamling", "completed": "Alt er bogført", "draft": "Kladde blokerer", "draft_blocks_action": "Kladde blokerer", "export_in_progress": "Eksport er i gang", "manual_flags": "Manuelle flag kræver handling", "queued": "Fakturaen er i kø", "ready_to_invoice": "Klar til fakturering", "requires_action": "Kræver gennemgang", "unbooked_transactions": "Ikke-bogførte transaktioner" },
"severities": { "blue": "I kø", "green": "Klar", "red": "Blokeret", "yellow": "Advarsel" },
"sort": { "customer_name": "Kundenavn", "customer_number": "Kundenummer", "priority": "Vigtigst først", "total_amount": "Beløb" },
"states": { "attention": "Kræver opmærksomhed", "blocked": "Blokeret", "completed": "Færdig", "empty_body": "Vælg en anden periode eller forretningsvisning.", "empty_title": "Ingen fakturadata i perioden", "error_body": "Prøv at genindlæse perioden.", "error_title": "Fakturaperioden kunne ikke indlæses", "filtered_empty_body": "Nulstil arbejdsgangsfiltrene eller prøv en anden søgning.", "filtered_empty_title": "Ingen kunder matcher filtrene", "loading_body": "Samler kunder, fakturasamlinger og kontrolpunkter…", "loading_title": "Indlæser fakturaperioden", "queued": "I kø", "ready": "Klar til fakturering", "refreshing": "Opdaterer eksisterende resultat…", "retained_error": "Opdateringen mislykkedes. Det senest indlæste resultat vises stadig." },
"toolbar": { "all_departments": "Alle afdelinger", "all_invoice_states": "Alle fakturastatusser", "all_severities": "Alle niveauer", "all_states": "Alle tilstande", "change_direction": "Skift sorteringsretning", "clear_filters": "Ryd filtre", "department": "Afdeling", "invoice_state": "Fakturastatus", "reload": "Genindlæs", "retry": "Prøv igen", "search": "Søg i kunder og fakturadata", "severity": "Alvor", "sort": "Sortér", "workflow": "Arbejdsgang" }
}
}
}
}
@@ -138,15 +138,21 @@
"lines": "Zeilen"
},
"fields": {
"booked_invoice_id": "Gebuchte Rechnungs-ID",
"collection_state": "Status",
"created_at": "Erstellt",
"empty": "Leer",
"error_message": "Fehler",
"hide_empty": "Leere Felder ausblenden",
"notes": "Notizen",
"price": "Preis",
"processor": "Verarbeitung",
"product_id": "Produkt-ID",
"quantity": "Anzahl",
"registration_number": "Kennzeichen",
"reference": "Referenz",
"show_empty": "Leere Felder anzeigen"
"show_empty": "Leere Felder anzeigen",
"updated_at": "Aktualisiert"
},
"nodes": {
"attachment_fallback": "Anhang #{id}",
@@ -0,0 +1,15 @@
{
"compat": { "invoicing_period": { "review_workspace": {
"analytics": { "title": "Übersicht und Kennzahlen" },
"detail": { "next": "Nächster zu prüfender Kunde", "open_prompt": "Wählen Sie den Kunden in der Warteschlange, um die Rechnungsdetails zu öffnen.", "previous": "Vorheriger zu prüfender Kunde", "selected_customer": "Ausgewählter Kunde" },
"headline": { "aria_label": "Kennzahlen des Zeitraums", "booked": "Gebucht", "not_booked": "Nicht gebucht", "total": "Gesamt" },
"navigation": { "agreements": "Vereinbarungen", "aria_label": "Geschäftsansichten", "billing": "Rechnungsstellung", "operations": "Betrieb", "review": "Prüfung" },
"next_actions": { "create_invoice": "Rechnung erstellen", "none": "Keine Aktion erforderlich", "resolve_collection_errors": "Sammlungsfehler beheben", "resolve_draft": "Entwurf klären", "resolve_manual_flags": "Manuelle Markierungen klären", "review_warnings": "Warnungen prüfen", "wait_for_export": "Auf Export warten" },
"queue": { "count": "{count} Kunden", "title": "Zu prüfende Kunden" },
"reasons": { "automatic_flags": "Automatische Warnungen", "automatic_warnings": "Automatische Warnungen", "collection_errors": "Fehler in Rechnungssammlung", "completed": "Alles verbucht", "draft": "Entwurf blockiert", "draft_blocks_action": "Entwurf blockiert", "export_in_progress": "Export läuft", "manual_flags": "Manuelle Markierungen erfordern Aktion", "queued": "Rechnung ist eingereiht", "ready_to_invoice": "Bereit zur Abrechnung", "requires_action": "Prüfung erforderlich", "unbooked_transactions": "Nicht gebuchte Transaktionen" },
"severities": { "blue": "Eingereiht", "green": "Bereit", "red": "Blockiert", "yellow": "Warnung" },
"sort": { "customer_name": "Kundenname", "customer_number": "Kundennummer", "priority": "Priorität zuerst", "total_amount": "Betrag" },
"states": { "attention": "Aufmerksamkeit erforderlich", "blocked": "Blockiert", "completed": "Abgeschlossen", "empty_body": "Wählen Sie einen anderen Zeitraum oder eine andere Ansicht.", "empty_title": "Keine Rechnungsdaten in diesem Zeitraum", "error_body": "Laden Sie den Zeitraum erneut.", "error_title": "Der Rechnungszeitraum konnte nicht geladen werden", "filtered_empty_body": "Setzen Sie die Filter zurück oder suchen Sie erneut.", "filtered_empty_title": "Keine Kunden entsprechen den Filtern", "loading_body": "Kunden, Rechnungssammlungen und Prüfpunkte werden gesammelt…", "loading_title": "Rechnungszeitraum wird geladen", "queued": "Eingereiht", "ready": "Bereit zur Abrechnung", "refreshing": "Aktuelles Ergebnis wird aktualisiert…", "retained_error": "Die Aktualisierung ist fehlgeschlagen. Das zuletzt geladene Ergebnis bleibt sichtbar." },
"toolbar": { "all_departments": "Alle Abteilungen", "all_invoice_states": "Alle Rechnungsstatus", "all_severities": "Alle Stufen", "all_states": "Alle Zustände", "change_direction": "Sortierrichtung ändern", "clear_filters": "Filter löschen", "department": "Abteilung", "invoice_state": "Rechnungsstatus", "reload": "Neu laden", "retry": "Erneut versuchen", "search": "Kunden und Rechnungsdaten durchsuchen", "severity": "Schweregrad", "sort": "Sortieren", "workflow": "Arbeitsablauf" }
} } }
}
@@ -138,15 +138,21 @@
"lines": "Lines"
},
"fields": {
"booked_invoice_id": "Booked invoice ID",
"collection_state": "Status",
"created_at": "Created",
"empty": "Empty",
"error_message": "Error",
"hide_empty": "Hide empty fields",
"notes": "Notes",
"price": "Price",
"processor": "Processor",
"product_id": "Product ID",
"quantity": "Quantity",
"registration_number": "Registration number",
"reference": "Reference",
"show_empty": "Show empty fields"
"show_empty": "Show empty fields",
"updated_at": "Updated"
},
"nodes": {
"attachment_fallback": "Attachment #{id}",
@@ -0,0 +1,19 @@
{
"compat": {
"invoicing_period": {
"review_workspace": {
"analytics": { "title": "Overview and key figures" },
"detail": { "next": "Next customer needing review", "open_prompt": "Select the customer in the queue to open invoice details.", "previous": "Previous customer needing review", "selected_customer": "Selected customer" },
"headline": { "aria_label": "Period headline figures", "booked": "Booked", "not_booked": "Not booked", "total": "Total" },
"navigation": { "agreements": "Agreements", "aria_label": "Business views", "billing": "Invoicing", "operations": "Operations", "review": "Review" },
"next_actions": { "create_invoice": "Create invoice", "none": "No action needed", "resolve_collection_errors": "Fix collection errors", "resolve_draft": "Resolve draft", "resolve_manual_flags": "Resolve manual flags", "review_warnings": "Review warnings", "wait_for_export": "Wait for export" },
"queue": { "count": "{count} customers", "title": "Customers to review" },
"reasons": { "automatic_flags": "Automatic warnings", "automatic_warnings": "Automatic warnings", "collection_errors": "Invoice collection errors", "completed": "Everything is booked", "draft": "Draft is blocking", "draft_blocks_action": "Draft is blocking", "export_in_progress": "Export is in progress", "manual_flags": "Manual flags need action", "queued": "Invoice is queued", "ready_to_invoice": "Ready to invoice", "requires_action": "Needs review", "unbooked_transactions": "Unbooked transactions" },
"severities": { "blue": "Queued", "green": "Ready", "red": "Blocked", "yellow": "Warning" },
"sort": { "customer_name": "Customer name", "customer_number": "Customer number", "priority": "Priority first", "total_amount": "Amount" },
"states": { "attention": "Needs attention", "blocked": "Blocked", "completed": "Completed", "empty_body": "Choose another period or business view.", "empty_title": "No invoice data in this period", "error_body": "Try reloading the period.", "error_title": "The invoice period could not be loaded", "filtered_empty_body": "Reset the workflow filters or try another search.", "filtered_empty_title": "No customers match the filters", "loading_body": "Collecting customers, invoice collections, and review points…", "loading_title": "Loading invoice period", "queued": "Queued", "ready": "Ready to invoice", "refreshing": "Refreshing the current result…", "retained_error": "The refresh failed. The most recently loaded result is still shown." },
"toolbar": { "all_departments": "All departments", "all_invoice_states": "All invoice states", "all_severities": "All levels", "all_states": "All states", "change_direction": "Change sort direction", "clear_filters": "Clear filters", "department": "Department", "invoice_state": "Invoice state", "reload": "Reload", "retry": "Try again", "search": "Search customers and invoice data", "severity": "Severity", "sort": "Sort", "workflow": "Workflow" }
}
}
}
}
@@ -135,14 +135,20 @@
"lines": "@:{'phrases.compat.invoicing_period.object_tree.economic.lines'}"
},
"fields": {
"booked_invoice_id": "@:{'phrases.compat.invoicing_period.object_tree.fields.booked_invoice_id'}",
"collection_state": "@:{'phrases.compat.invoicing_period.object_tree.fields.collection_state'}",
"created_at": "@:{'phrases.compat.invoicing_period.object_tree.fields.created_at'}",
"empty": "@:{'phrases.compat.invoicing_period.object_tree.fields.empty'}",
"error_message": "@:{'phrases.compat.invoicing_period.object_tree.fields.error_message'}",
"hide_empty": "@:{'phrases.compat.invoicing_period.object_tree.fields.hide_empty'}",
"notes": "@:{'phrases.compat.invoicing_period.object_tree.fields.notes'}",
"price": "@:{'phrases.compat.invoicing_period.object_tree.fields.price'}",
"processor": "@:{'phrases.compat.invoicing_period.object_tree.fields.processor'}",
"product_id": "@:{'phrases.compat.invoicing_period.object_tree.fields.product_id'}",
"quantity": "@:{'phrases.compat.invoicing_period.object_tree.fields.quantity'}",
"reference": "@:{'phrases.compat.invoicing_period.object_tree.fields.reference'}",
"show_empty": "@:{'phrases.compat.invoicing_period.object_tree.fields.show_empty'}"
"show_empty": "@:{'phrases.compat.invoicing_period.object_tree.fields.show_empty'}",
"updated_at": "@:{'phrases.compat.invoicing_period.object_tree.fields.updated_at'}"
},
"nodes": {
"attachment_fallback": "@:{'phrases.compat.invoicing_period.object_tree.nodes.attachment_fallback'}",
@@ -0,0 +1,98 @@
{
"invoicing_period": {
"review_workspace": {
"analytics": { "title": "@:{'phrases.compat.invoicing_period.review_workspace.analytics.title'}" },
"detail": {
"next": "@:{'phrases.compat.invoicing_period.review_workspace.detail.next'}",
"open_prompt": "@:{'phrases.compat.invoicing_period.review_workspace.detail.open_prompt'}",
"previous": "@:{'phrases.compat.invoicing_period.review_workspace.detail.previous'}",
"selected_customer": "@:{'phrases.compat.invoicing_period.review_workspace.detail.selected_customer'}"
},
"headline": {
"aria_label": "@:{'phrases.compat.invoicing_period.review_workspace.headline.aria_label'}",
"booked": "@:{'phrases.compat.invoicing_period.review_workspace.headline.booked'}",
"not_booked": "@:{'phrases.compat.invoicing_period.review_workspace.headline.not_booked'}",
"total": "@:{'phrases.compat.invoicing_period.review_workspace.headline.total'}"
},
"navigation": {
"agreements": "@:{'phrases.compat.invoicing_period.review_workspace.navigation.agreements'}",
"aria_label": "@:{'phrases.compat.invoicing_period.review_workspace.navigation.aria_label'}",
"billing": "@:{'phrases.compat.invoicing_period.review_workspace.navigation.billing'}",
"operations": "@:{'phrases.compat.invoicing_period.review_workspace.navigation.operations'}",
"review": "@:{'phrases.compat.invoicing_period.review_workspace.navigation.review'}"
},
"next_actions": {
"create_invoice": "@:{'phrases.compat.invoicing_period.review_workspace.next_actions.create_invoice'}",
"none": "@:{'phrases.compat.invoicing_period.review_workspace.next_actions.none'}",
"resolve_collection_errors": "@:{'phrases.compat.invoicing_period.review_workspace.next_actions.resolve_collection_errors'}",
"resolve_draft": "@:{'phrases.compat.invoicing_period.review_workspace.next_actions.resolve_draft'}",
"resolve_manual_flags": "@:{'phrases.compat.invoicing_period.review_workspace.next_actions.resolve_manual_flags'}",
"review_warnings": "@:{'phrases.compat.invoicing_period.review_workspace.next_actions.review_warnings'}",
"wait_for_export": "@:{'phrases.compat.invoicing_period.review_workspace.next_actions.wait_for_export'}"
},
"queue": {
"count": "@:{'phrases.compat.invoicing_period.review_workspace.queue.count'}",
"title": "@:{'phrases.compat.invoicing_period.review_workspace.queue.title'}"
},
"reasons": {
"automatic_flags": "@:{'phrases.compat.invoicing_period.review_workspace.reasons.automatic_flags'}",
"automatic_warnings": "@:{'phrases.compat.invoicing_period.review_workspace.reasons.automatic_warnings'}",
"collection_errors": "@:{'phrases.compat.invoicing_period.review_workspace.reasons.collection_errors'}",
"completed": "@:{'phrases.compat.invoicing_period.review_workspace.reasons.completed'}",
"draft": "@:{'phrases.compat.invoicing_period.review_workspace.reasons.draft'}",
"draft_blocks_action": "@:{'phrases.compat.invoicing_period.review_workspace.reasons.draft_blocks_action'}",
"export_in_progress": "@:{'phrases.compat.invoicing_period.review_workspace.reasons.export_in_progress'}",
"manual_flags": "@:{'phrases.compat.invoicing_period.review_workspace.reasons.manual_flags'}",
"queued": "@:{'phrases.compat.invoicing_period.review_workspace.reasons.queued'}",
"ready_to_invoice": "@:{'phrases.compat.invoicing_period.review_workspace.reasons.ready_to_invoice'}",
"requires_action": "@:{'phrases.compat.invoicing_period.review_workspace.reasons.requires_action'}",
"unbooked_transactions": "@:{'phrases.compat.invoicing_period.review_workspace.reasons.unbooked_transactions'}"
},
"severities": {
"blue": "@:{'phrases.compat.invoicing_period.review_workspace.severities.blue'}",
"green": "@:{'phrases.compat.invoicing_period.review_workspace.severities.green'}",
"red": "@:{'phrases.compat.invoicing_period.review_workspace.severities.red'}",
"yellow": "@:{'phrases.compat.invoicing_period.review_workspace.severities.yellow'}"
},
"sort": {
"customer_name": "@:{'phrases.compat.invoicing_period.review_workspace.sort.customer_name'}",
"customer_number": "@:{'phrases.compat.invoicing_period.review_workspace.sort.customer_number'}",
"priority": "@:{'phrases.compat.invoicing_period.review_workspace.sort.priority'}",
"total_amount": "@:{'phrases.compat.invoicing_period.review_workspace.sort.total_amount'}"
},
"states": {
"attention": "@:{'phrases.compat.invoicing_period.review_workspace.states.attention'}",
"blocked": "@:{'phrases.compat.invoicing_period.review_workspace.states.blocked'}",
"completed": "@:{'phrases.compat.invoicing_period.review_workspace.states.completed'}",
"empty_body": "@:{'phrases.compat.invoicing_period.review_workspace.states.empty_body'}",
"empty_title": "@:{'phrases.compat.invoicing_period.review_workspace.states.empty_title'}",
"error_body": "@:{'phrases.compat.invoicing_period.review_workspace.states.error_body'}",
"error_title": "@:{'phrases.compat.invoicing_period.review_workspace.states.error_title'}",
"filtered_empty_body": "@:{'phrases.compat.invoicing_period.review_workspace.states.filtered_empty_body'}",
"filtered_empty_title": "@:{'phrases.compat.invoicing_period.review_workspace.states.filtered_empty_title'}",
"loading_body": "@:{'phrases.compat.invoicing_period.review_workspace.states.loading_body'}",
"loading_title": "@:{'phrases.compat.invoicing_period.review_workspace.states.loading_title'}",
"queued": "@:{'phrases.compat.invoicing_period.review_workspace.states.queued'}",
"ready": "@:{'phrases.compat.invoicing_period.review_workspace.states.ready'}",
"refreshing": "@:{'phrases.compat.invoicing_period.review_workspace.states.refreshing'}",
"retained_error": "@:{'phrases.compat.invoicing_period.review_workspace.states.retained_error'}"
},
"toolbar": {
"all_departments": "@:{'phrases.compat.invoicing_period.review_workspace.toolbar.all_departments'}",
"all_invoice_states": "@:{'phrases.compat.invoicing_period.review_workspace.toolbar.all_invoice_states'}",
"all_severities": "@:{'phrases.compat.invoicing_period.review_workspace.toolbar.all_severities'}",
"all_states": "@:{'phrases.compat.invoicing_period.review_workspace.toolbar.all_states'}",
"change_direction": "@:{'phrases.compat.invoicing_period.review_workspace.toolbar.change_direction'}",
"clear_filters": "@:{'phrases.compat.invoicing_period.review_workspace.toolbar.clear_filters'}",
"department": "@:{'phrases.compat.invoicing_period.review_workspace.toolbar.department'}",
"invoice_state": "@:{'phrases.compat.invoicing_period.review_workspace.toolbar.invoice_state'}",
"reload": "@:{'phrases.compat.invoicing_period.review_workspace.toolbar.reload'}",
"retry": "@:{'phrases.compat.invoicing_period.review_workspace.toolbar.retry'}",
"search": "@:{'phrases.compat.invoicing_period.review_workspace.toolbar.search'}",
"severity": "@:{'phrases.compat.invoicing_period.review_workspace.toolbar.severity'}",
"sort": "@:{'phrases.compat.invoicing_period.review_workspace.toolbar.sort'}",
"workflow": "@:{'phrases.compat.invoicing_period.review_workspace.toolbar.workflow'}"
}
}
}
}
@@ -138,15 +138,21 @@
"lines": "Linjer"
},
"fields": {
"booked_invoice_id": "Bokført faktura-ID",
"collection_state": "Status",
"created_at": "Opprettet",
"empty": "Tom",
"error_message": "Feil",
"hide_empty": "Skjul tomme felter",
"notes": "Notater",
"price": "Pris",
"processor": "Behandler",
"product_id": "Produkt-ID",
"quantity": "Antall",
"registration_number": "Registreringsnummer",
"reference": "Referanse",
"show_empty": "Vis tomme felter"
"show_empty": "Vis tomme felter",
"updated_at": "Oppdatert"
},
"nodes": {
"attachment_fallback": "Vedlegg #{id}",
@@ -0,0 +1,15 @@
{
"compat": { "invoicing_period": { "review_workspace": {
"analytics": { "title": "Oversikt og nøkkeltall" },
"detail": { "next": "Neste kunde til gjennomgang", "open_prompt": "Velg kunden i køen for å åpne fakturadetaljene.", "previous": "Forrige kunde til gjennomgang", "selected_customer": "Valgt kunde" },
"headline": { "aria_label": "Periodens hovedtall", "booked": "Bokført", "not_booked": "Ikke bokført", "total": "Totalt" },
"navigation": { "agreements": "Avtaler", "aria_label": "Forretningsvisninger", "billing": "Fakturering", "operations": "Drift", "review": "Gjennomgang" },
"next_actions": { "create_invoice": "Opprett faktura", "none": "Ingen handling nødvendig", "resolve_collection_errors": "Rett feil i fakturasamling", "resolve_draft": "Avklar utkast", "resolve_manual_flags": "Avklar manuelle flagg", "review_warnings": "Gjennomgå advarsler", "wait_for_export": "Vent på eksport" },
"queue": { "count": "{count} kunder", "title": "Kunder til gjennomgang" },
"reasons": { "automatic_flags": "Automatiske advarsler", "automatic_warnings": "Automatiske advarsler", "collection_errors": "Feil i fakturasamling", "completed": "Alt er bokført", "draft": "Utkast blokkerer", "draft_blocks_action": "Utkast blokkerer", "export_in_progress": "Eksport pågår", "manual_flags": "Manuelle flagg krever handling", "queued": "Fakturaen er i kø", "ready_to_invoice": "Klar til fakturering", "requires_action": "Krever gjennomgang", "unbooked_transactions": "Ikke-bokførte transaksjoner" },
"severities": { "blue": "I kø", "green": "Klar", "red": "Blokkert", "yellow": "Advarsel" },
"sort": { "customer_name": "Kundenavn", "customer_number": "Kundenummer", "priority": "Viktigst først", "total_amount": "Beløp" },
"states": { "attention": "Krever oppmerksomhet", "blocked": "Blokkert", "completed": "Ferdig", "empty_body": "Velg en annen periode eller forretningsvisning.", "empty_title": "Ingen fakturadata i perioden", "error_body": "Prøv å laste perioden på nytt.", "error_title": "Fakturaperioden kunne ikke lastes", "filtered_empty_body": "Nullstill arbeidsflytfiltrene eller prøv et annet søk.", "filtered_empty_title": "Ingen kunder samsvarer med filtrene", "loading_body": "Samler kunder, fakturasamlinger og kontrollpunkter…", "loading_title": "Laster fakturaperioden", "queued": "I kø", "ready": "Klar til fakturering", "refreshing": "Oppdaterer eksisterende resultat…", "retained_error": "Oppdateringen mislyktes. Det sist innlastede resultatet vises fortsatt." },
"toolbar": { "all_departments": "Alle avdelinger", "all_invoice_states": "Alle fakturastatuser", "all_severities": "Alle nivåer", "all_states": "Alle tilstander", "change_direction": "Endre sorteringsretning", "clear_filters": "Tøm filtre", "department": "Avdeling", "invoice_state": "Fakturastatus", "reload": "Last på nytt", "retry": "Prøv igjen", "search": "Søk i kunder og fakturadata", "severity": "Alvor", "sort": "Sorter", "workflow": "Arbeidsflyt" }
} } }
}
@@ -138,15 +138,21 @@
"lines": "Rader"
},
"fields": {
"booked_invoice_id": "Bokfört faktura-ID",
"collection_state": "Status",
"created_at": "Skapad",
"empty": "Tom",
"error_message": "Fel",
"hide_empty": "Dölj tomma fält",
"notes": "Anteckningar",
"price": "Pris",
"processor": "Behandlare",
"product_id": "Produkt-ID",
"quantity": "Antal",
"registration_number": "Registreringsnummer",
"reference": "Referens",
"show_empty": "Visa tomma fält"
"show_empty": "Visa tomma fält",
"updated_at": "Uppdaterad"
},
"nodes": {
"attachment_fallback": "Bilaga #{id}",
@@ -0,0 +1,15 @@
{
"compat": { "invoicing_period": { "review_workspace": {
"analytics": { "title": "Översikt och nyckeltal" },
"detail": { "next": "Nästa kund att granska", "open_prompt": "Välj kunden i kön för att öppna fakturadetaljerna.", "previous": "Föregående kund att granska", "selected_customer": "Vald kund" },
"headline": { "aria_label": "Periodens huvudtal", "booked": "Bokfört", "not_booked": "Inte bokfört", "total": "Totalt" },
"navigation": { "agreements": "Avtal", "aria_label": "Affärsvyer", "billing": "Fakturering", "operations": "Drift", "review": "Granskning" },
"next_actions": { "create_invoice": "Skapa faktura", "none": "Ingen åtgärd behövs", "resolve_collection_errors": "Rätta samlingsfel", "resolve_draft": "Hantera utkast", "resolve_manual_flags": "Hantera manuella flaggor", "review_warnings": "Granska varningar", "wait_for_export": "Vänta på export" },
"queue": { "count": "{count} kunder", "title": "Kunder att granska" },
"reasons": { "automatic_flags": "Automatiska varningar", "automatic_warnings": "Automatiska varningar", "collection_errors": "Fel i fakturasamling", "completed": "Allt är bokfört", "draft": "Utkast blockerar", "draft_blocks_action": "Utkast blockerar", "export_in_progress": "Export pågår", "manual_flags": "Manuella flaggor kräver åtgärd", "queued": "Fakturan ligger i kö", "ready_to_invoice": "Klar för fakturering", "requires_action": "Behöver granskas", "unbooked_transactions": "Ej bokförda transaktioner" },
"severities": { "blue": "I kö", "green": "Klar", "red": "Blockerad", "yellow": "Varning" },
"sort": { "customer_name": "Kundnamn", "customer_number": "Kundnummer", "priority": "Viktigast först", "total_amount": "Belopp" },
"states": { "attention": "Behöver uppmärksamhet", "blocked": "Blockerad", "completed": "Klar", "empty_body": "Välj en annan period eller affärsvy.", "empty_title": "Inga fakturadata i perioden", "error_body": "Försök ladda perioden igen.", "error_title": "Fakturaperioden kunde inte laddas", "filtered_empty_body": "Nollställ arbetsflödesfiltren eller prova en annan sökning.", "filtered_empty_title": "Inga kunder matchar filtren", "loading_body": "Samlar kunder, fakturasamlingar och kontrollpunkter…", "loading_title": "Laddar fakturaperiod", "queued": "I kö", "ready": "Klar för fakturering", "refreshing": "Uppdaterar befintligt resultat…", "retained_error": "Uppdateringen misslyckades. Det senast laddade resultatet visas fortfarande." },
"toolbar": { "all_departments": "Alla avdelningar", "all_invoice_states": "Alla fakturastatusar", "all_severities": "Alla nivåer", "all_states": "Alla tillstånd", "change_direction": "Ändra sorteringsriktning", "clear_filters": "Rensa filter", "department": "Avdelning", "invoice_state": "Fakturastatus", "reload": "Ladda om", "retry": "Försök igen", "search": "Sök kunder och fakturadata", "severity": "Allvar", "sort": "Sortera", "workflow": "Arbetsflöde" }
} } }
}
@@ -72,6 +72,27 @@ const toPositiveInteger = (value: any) => {
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const getCollectionSummaryState = (summary: any) =>
String(summary?.invoice_state ?? summary?.economic_state ?? summary?.state ?? "").toLowerCase();
const getCollectionSummaryInvoiceId = (summary: any, invoiceType: "draft" | "booked") => {
const typeSpecificId = invoiceType === "draft"
? summary?.draft_invoice_id ?? summary?.economic_draft_id ?? summary?.draft_id
: summary?.booked_invoice_id ?? summary?.economic_booked_id ?? summary?.booked_id;
const normalizedSpecificId = toPositiveInteger(typeSpecificId);
if (normalizedSpecificId) {
return normalizedSpecificId;
}
const state = getCollectionSummaryState(summary);
const stateMatchesType = invoiceType === "draft"
? ["draft", "economic_draft"].includes(state)
: ["booked", "economic_booked"].includes(state);
return stateMatchesType
? toPositiveInteger(summary?.economic_invoice_id ?? summary?.invoice_id ?? summary?.external_id)
: null;
};
const tr = (key: string, fallback: string, params: Record<string, any> = {}) => {
const translated = t(key, params);
return translated === key ? fallback : translated;
@@ -352,6 +373,7 @@ const buildCollectionCategoryNodes = (collectionNode: TreeNode) => {
}
if (hasEconomicInvoiceCandidate(collectionNode)) {
const collectionSummary = collectionNode.meta.collectionSummary || null;
categories.push(makeCategoryNode({
id: makeNodeId(TREE_NODE_TYPES.CATEGORY, collectionNode.meta.collectionId, TREE_CATEGORY_TYPES.COLLECTION_ECONOMIC),
label: treeText("categories.economic", "Fakturaer"),
@@ -364,6 +386,7 @@ const buildCollectionCategoryNodes = (collectionNode: TreeNode) => {
draft: props.customer?.draft,
orders,
invoiceState: getNodeInvoiceState(collectionNode),
collectionSummary,
},
}));
}
@@ -374,6 +397,7 @@ const buildCollectionCategoryNodes = (collectionNode: TreeNode) => {
const hasEconomicInvoiceCandidate = (collectionNode: TreeNode) => {
const collectionId = toPositiveInteger(collectionNode.meta.collectionId);
const orders = collectionNode.meta.orders || [];
const collectionSummary = collectionNode.meta.collectionSummary || null;
const draftCollectionIds = Array.isArray(props.customer?.draft?.invoice_collection_ids)
? props.customer.draft.invoice_collection_ids.map((id: any) => toPositiveInteger(id)).filter(Boolean)
: [];
@@ -382,6 +406,14 @@ const hasEconomicInvoiceCandidate = (collectionNode: TreeNode) => {
return true;
}
if (
getCollectionSummaryInvoiceId(collectionSummary, "draft")
|| getCollectionSummaryInvoiceId(collectionSummary, "booked")
|| ["draft", "economic_draft", "booked", "economic_booked"].includes(getCollectionSummaryState(collectionSummary))
) {
return true;
}
return orders.some((order: any) => {
const invoiceCollection = order?.invoice_collection || {};
return Boolean(
@@ -514,13 +546,27 @@ const buildEconomicInvoiceNodes = async (node: TreeNode) => {
return nodes;
}
const details = await getEconomicDetails(collectionId);
const draftId = toPositiveInteger(details?.economic?.draft_id);
const bookedId = toPositiveInteger(details?.economic?.booked_id);
const economicState = String(details?.economic?.state || details?.economic_state || "").toLowerCase();
const collectionSummary = node.meta.collectionSummary || {};
const summaryDraftId = getCollectionSummaryInvoiceId(collectionSummary, "draft");
const summaryBookedId = getCollectionSummaryInvoiceId(collectionSummary, "booked");
let details: any;
try {
details = await getEconomicDetails(collectionId);
} catch (error) {
if (!summaryDraftId && !summaryBookedId) {
throw error;
}
details = { collection_summary: collectionSummary };
}
const draftId = toPositiveInteger(details?.economic?.draft_id) || summaryDraftId;
const bookedId = toPositiveInteger(details?.economic?.booked_id) || summaryBookedId;
const economicState = String(
details?.economic?.state || details?.economic_state || getCollectionSummaryState(collectionSummary)
).toLowerCase();
const availablePdfType = String(details?.economic?.available_pdf_type || details?.available_pdf_type || "").toLowerCase();
const isBookedState = economicState === "booked" || availablePdfType === "booked";
if (!isBookedState && draftId && details?.draft?.exists === true) {
const isBookedState = ["booked", "economic_booked"].includes(economicState) || availablePdfType === "booked";
const hasDraft = details?.draft?.exists === true || Boolean(summaryDraftId);
if (!isBookedState && draftId && hasDraft) {
nodes.push(makeEconomicInvoiceNode({
collectionId,
invoiceType: "draft",
@@ -1256,6 +1302,12 @@ const collectionObjectForNode = (node: TreeNode) => {
po_number: invoiceCollection.po_number ?? "",
external_id: invoiceCollection.external_id ?? "",
closed_at: invoiceCollection.closed_at ?? "",
state: invoiceCollection.state ?? invoiceCollection.invoice_state ?? node.meta.invoiceState ?? "",
error_message: invoiceCollection.error_message ?? invoiceCollection.error ?? "",
booked_invoice_id: invoiceCollection.booked_invoice_id ?? invoiceCollection.booked_id ?? "",
processor: invoiceCollection.processor ?? invoiceCollection.processor_name ?? "",
created_at: invoiceCollection.created_at ?? "",
updated_at: invoiceCollection.updated_at ?? "",
total_net_amount: node.meta.totalNetAmount,
};
};
@@ -1325,6 +1377,7 @@ type NodeField = {
formatter?: ((_value: any) => string) | null;
slot?: number;
span?: number;
row?: number;
numeric?: boolean;
lines?: NodeField[];
};
@@ -1339,6 +1392,7 @@ const makeNodeField = ({
formatter = null,
slot = 1,
span = 1,
row = undefined,
numeric = false,
lines = undefined,
}: Partial<NodeField> & { key: string; label: string; value: any }): NodeField => ({
@@ -1353,6 +1407,7 @@ const makeNodeField = ({
formatter,
slot,
span,
row,
numeric,
lines,
});
@@ -1388,7 +1443,13 @@ const getNodeFields = (node: TreeNode): NodeField[] => {
makeNodeField({ key: "notes", label: fieldLabel(columns, "notes", "Noter"), value: object.notes, object, column: "notes", editable: canEditCollectionField(), editFunction: SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm, slot: 3 }),
makeNodeField({ key: "external_id", label: fieldLabel(columns, "external_id", "Eksternt ID"), value: object.external_id, object, column: "external_id", editable: false, slot: 5 }),
makeNodeField({ key: "closed_at", label: fieldLabel(columns, "closed_at", "Lukket"), value: object.closed_at, object, column: "closed_at", editable: canEditCollectionField(), editFunction: SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm, slot: 6 }),
makeNodeField({ key: "state", label: treeText("fields.collection_state", "Status"), value: object.state, editable: false, slot: 1 }),
makeNodeField({ key: "booked_invoice_id", label: treeText("fields.booked_invoice_id", "Bogført faktura-ID"), value: object.booked_invoice_id, editable: false, slot: 4 }),
makeNodeField({ key: "total_net_amount", label: fieldLabel(columns, "total_net_amount", "Total"), value: object.total_net_amount, formatter: formatCurrency, slot: 8, numeric: true }),
makeNodeField({ key: "processor", label: treeText("fields.processor", "Behandler"), value: object.processor, editable: false, slot: 7 }),
makeNodeField({ key: "error_message", label: treeText("fields.error_message", "Fejl"), value: object.error_message, editable: false, slot: 1, span: 4, row: 2 }),
makeNodeField({ key: "created_at", label: treeText("fields.created_at", "Oprettet"), value: object.created_at, editable: false, slot: 5, row: 2 }),
makeNodeField({ key: "updated_at", label: treeText("fields.updated_at", "Opdateret"), value: object.updated_at, editable: false, slot: 6, span: 3, row: 2 }),
];
}
@@ -1693,9 +1754,17 @@ const runCollectionBulkAction = async (
const applied = getApiPayload(applyResponse);
if (action === INVOICE_COLLECTION_BULK_ACTIONS.QUEUE_ECONOMIC) {
const queuedInvoiceCollectionIds = applied?.result?.queued_invoice_collection_ids || ids;
invoiceQueue.addInvoiceCollectionsToQueue(queuedInvoiceCollectionIds, { customerNumber: props.customer?.customer_number });
invoiceQueue.processInvoiceCollectionQueue();
const serverQueueJobs = applied?.jobs ?? applied?.result?.jobs;
const serverQueueJobIds = applied?.queue_job_ids ?? applied?.result?.queue_job_ids;
const wasDurablyQueuedByServer = (
(Array.isArray(serverQueueJobs) && serverQueueJobs.length > 0)
|| (Array.isArray(serverQueueJobIds) && serverQueueJobIds.length > 0)
);
if (!wasDurablyQueuedByServer) {
const queuedInvoiceCollectionIds = applied?.result?.queued_invoice_collection_ids || ids;
invoiceQueue.addInvoiceCollectionsToQueue(queuedInvoiceCollectionIds, { customerNumber: props.customer?.customer_number });
invoiceQueue.processInvoiceCollectionQueue();
}
}
if (runOptions.clearSelection !== false) {
checkedKeys.value = [];
@@ -2695,7 +2764,10 @@ const nodeActionWheelProps = (node: TreeNode) => {
'is-numeric': field.numeric,
'is-composite': field.lines?.length,
}"
:style="{ gridColumn: `${field.slot || 1} / span ${field.span || 1}` }"
:style="{
gridColumn: `${field.slot || 1} / span ${field.span || 1}`,
gridRow: field.row ? String(field.row) : undefined,
}"
:data-testid="`invoice-period-tree-field-${node.id}-${field.key}`"
>
<span class="invoice-period-tree-node-field__label">
@@ -3098,6 +3170,7 @@ const nodeActionWheelProps = (node: TreeNode) => {
.invoice-period-tree-node-field {
grid-column: auto !important;
grid-row: auto !important;
}
.invoice-period-tree-node-field.is-composite {
@@ -3340,6 +3413,7 @@ const nodeActionWheelProps = (node: TreeNode) => {
.invoice-period-tree-node-field {
grid-column: auto !important;
grid-row: auto !important;
}
.invoice-period-tree-node__actions {
@@ -3,28 +3,36 @@
</script>
<template>
<div class="columns is-multiline">
<div class="period-layout">
<!-- Top column -->
<div class="column is-full">
<div class="period-layout__top">
<slot name="content-top"></slot>
</div>
<!-- Left column -->
<div class="column is-8">
<slot name="content-left"></slot>
</div>
<!-- Summary of the new invoicing billing period -->
<div class="column is-4">
<div class="period-layout__navigation">
<slot name="summary"></slot>
<!-- Additional slot -->
<slot name="content-right"></slot>
</div>
<main class="period-layout__content">
<slot name="content-left"></slot>
</main>
<!-- Bottom column -->
<div class="column is-full">
<div class="period-layout__bottom">
<slot name="content-bottom"></slot>
</div>
</div>
</template>
<style scoped>
.period-layout {
display: grid;
gap: 1rem;
min-width: 0;
}
</style>
.period-layout__top,
.period-layout__navigation,
.period-layout__content,
.period-layout__bottom {
min-width: 0;
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, reactive } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import i18n from '@/i18n';
import { SessionUser } from '@/components/session/token/SessionUser.vue';
import { dates } from '../../imports/InvoicingBillingPeriodImportDates.vue';
import { invoiceQueue } from '../../imports/InvoicingBillingPeriodImportInvoiceQueue.vue';
@@ -28,6 +29,10 @@ import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/
const period_result = ref<any>(null);
const route = useRoute();
const router = useRouter();
const tr = (key: string, fallback: string) => {
const translated = i18n.global.t(`invoicing_period.review_workspace.${key}`);
return translated === `invoicing_period.review_workspace.${key}` ? fallback : translated;
};
const DRAFT_STATUS_REFRESH_INTERVAL_MS = 130_000;
let draftStatusRefreshTimer: ReturnType<typeof setInterval> | null = null;
let draftStatusRefreshInFlight = false;
@@ -198,8 +203,20 @@ const getPeriodRouteSignature = (query: Record<string, any>) => JSON.stringify({
periodPage: parseRouteString(query.periodPage),
periodLimit: parseRouteString(query.periodLimit),
periodFlagTab: normalizePeriodFlagTab(query.periodFlagTab),
periodReviewState: parseRouteString(query.periodReviewState),
periodSeverity: parseRouteString(query.periodSeverity),
periodInvoiceState: parseRouteString(query.periodInvoiceState),
periodDepartmentId: parseRouteString(query.periodDepartmentId),
periodSort: parseRouteString(query.periodSort),
periodDirection: parseRouteString(query.periodDirection),
periodCustomer: parseRouteString(query.periodCustomer),
});
const normalizeChoice = (value: any, allowed: string[], fallback = "") => {
const choice = parseRouteString(value).trim();
return allowed.includes(choice) ? choice : fallback;
};
const applyRoutePeriodPaging = () => {
isApplyingRoutePeriodState = true;
try {
@@ -207,6 +224,16 @@ const applyRoutePeriodPaging = () => {
periodPaging.page = normalizePeriodPage(route.query.periodPage ?? 1);
periodPaging.limit = normalizePeriodLimit(route.query.periodLimit ?? periodPaging.limit);
periodPaging.flagTab = normalizePeriodFlagTab(route.query.periodFlagTab ?? periodPaging.flagTab);
periodPaging.reviewState = normalizeChoice(route.query.periodReviewState, ["blocked", "attention", "queued", "ready", "completed"]);
periodPaging.severity = normalizeChoice(route.query.periodSeverity, ["red", "yellow", "blue", "green"]);
periodPaging.invoiceState = parseRouteString(route.query.periodInvoiceState).trim();
periodPaging.departmentId = parseRouteString(route.query.periodDepartmentId).trim();
periodPaging.sort = normalizeChoice(route.query.periodSort, ["priority", "customer_name", "customer_number", "total_amount"], "priority");
periodPaging.direction = normalizeChoice(route.query.periodDirection, ["asc", "desc"], "asc");
const selectedCustomerNumber = Number.parseInt(parseRouteString(route.query.periodCustomer), 10);
periodPaging.selectedCustomerNumber = Number.isInteger(selectedCustomerNumber) && selectedCustomerNumber > 0
? selectedCustomerNumber
: null;
} finally {
queueMicrotask(() => {
isApplyingRoutePeriodState = false;
@@ -236,6 +263,8 @@ const syncPeriodRouteQuery = () => {
periodPage: String(periodPaging.page),
periodLimit: String(periodPaging.limit),
periodFlagTab: normalizePeriodFlagTab(periodPaging.flagTab),
periodSort: periodPaging.sort,
periodDirection: periodPaging.direction,
};
if (periodPaging.search.trim() !== "") {
@@ -244,6 +273,21 @@ const syncPeriodRouteQuery = () => {
delete nextQuery.periodSearch;
}
const optionalRouteValues: Record<string, any> = {
periodReviewState: periodPaging.reviewState,
periodSeverity: periodPaging.severity,
periodInvoiceState: periodPaging.invoiceState,
periodDepartmentId: periodPaging.departmentId,
periodCustomer: periodPaging.selectedCustomerNumber,
};
Object.entries(optionalRouteValues).forEach(([key, value]) => {
if (value === "" || value === null || value === undefined) {
delete nextQuery[key];
} else {
nextQuery[key] = String(value);
}
});
lastSyncedRouteSignature = getPeriodRouteSignature(nextQuery);
isSyncingRouteFromState = true;
void router.replace({ query: nextQuery })
@@ -260,6 +304,7 @@ const types = [
description: 'Alle kunder.',
view: 'all',
isAvailable: computed(() => true),
group: 'review',
},
{
name: 'invoice_per_order',
@@ -267,6 +312,7 @@ const types = [
description: 'Kunder med fakturering pr. ordre.',
view: 'invoice_per_order',
isAvailable: computed(() => true),
group: 'billing',
},
{
name: 'fixed_pricing',
@@ -277,6 +323,7 @@ const types = [
// Check if the current date range is an entire month
return dates.computed.isEntireMonth.value;
}),
group: 'agreements',
},
{
name: 'tank_cleaning',
@@ -284,6 +331,7 @@ const types = [
description: 'Kunder med tankcleaning.',
view: 'tank_cleaning',
isAvailable: computed(() => true),
group: 'operations',
},
{
name: 'special_arrangements',
@@ -291,6 +339,7 @@ const types = [
description: 'Kunder med specialaftaler.',
view: 'special_arrangements',
isAvailable: computed(() => true),
group: 'agreements',
},
{
name: 'vehicle_subscriptions',
@@ -302,6 +351,7 @@ const types = [
// Check if the current date range is an entire month
return dates.computed.isEntireMonth.value;
}),
group: 'agreements',
},
{
name: 'possible_duplicates',
@@ -309,6 +359,7 @@ const types = [
description: 'Kunder med mulige dubletter.',
view: 'possible_duplicates',
isAvailable: computed(() => true),
group: 'review',
},
{
name: 'self_wash',
@@ -317,9 +368,20 @@ const types = [
view: 'self_wash',
iconClass: 'fas fa-link',
isAvailable: computed(() => true),
group: 'operations',
},
];
const navigationGroups = computed(() => [
{ key: 'review', label: tr('navigation.review', 'Gennemgang') },
{ key: 'billing', label: tr('navigation.billing', 'Fakturering') },
{ key: 'agreements', label: tr('navigation.agreements', 'Aftaler') },
{ key: 'operations', label: tr('navigation.operations', 'Drift') },
].map((group) => ({
...group,
types: types.filter((type) => type.group === group.key),
})));
const buildSelfWashPeriodFilters = () => [
`StartTime-date_from:${dates.computed.formattedStartDate.value}`,
`StartTime-date_to:${dates.computed.formattedEndDate.value}`,
@@ -392,6 +454,12 @@ const getPeriodRequestParameters = () => ({
flagTab: normalizePeriodFlagTab(periodPaging.flagTab),
includeRequiresAction: periodPaging.includeRequiresAction ? 1 : 0,
includeBooked: periodPaging.includeBooked ? 1 : 0,
reviewState: periodPaging.reviewState,
severity: periodPaging.severity,
invoiceState: periodPaging.invoiceState,
departmentId: periodPaging.departmentId,
sort: periodPaging.sort,
direction: periodPaging.direction,
});
const buildFallbackPaginationMeta = (requestParameters: Record<string, any>, periodResult: any = {}) => ({
@@ -552,6 +620,9 @@ const applyPeriodResult = (
periodPaging.cacheUpdatedAt = cacheUpdatedAt;
status.loaded = true;
status.isEmpty = Number(periodPaging.meta?.total ?? 0) === 0;
periodPaging.hasLoaded = true;
periodPaging.loadError = false;
periodPaging.loadErrorMessage = "";
};
const getPeriod = async ({ forceRefresh = false, showLoading = false } = {}) => {
@@ -567,6 +638,8 @@ const getPeriod = async ({ forceRefresh = false, showLoading = false } = {}) =>
status.error = false;
status.errorMessage = '';
periodPaging.loadError = false;
periodPaging.loadErrorMessage = "";
void loadSelfWashCounts({ forceRefresh });
if (cachedPage) {
@@ -639,6 +712,8 @@ const getPeriod = async ({ forceRefresh = false, showLoading = false } = {}) =>
}
status.error = true;
status.errorMessage = error?.message || "Unknown error";
periodPaging.loadError = true;
periodPaging.loadErrorMessage = status.errorMessage;
} finally {
periodCacheRequestInFlight.delete(cacheKey);
if (requestSequence === periodRequestSequence) {
@@ -976,6 +1051,13 @@ watch(
route.query.periodPage,
route.query.periodLimit,
route.query.periodFlagTab,
route.query.periodReviewState,
route.query.periodSeverity,
route.query.periodInvoiceState,
route.query.periodDepartmentId,
route.query.periodSort,
route.query.periodDirection,
route.query.periodCustomer,
],
() => {
const routeSignature = getPeriodRouteSignature(route.query as Record<string, any>);
@@ -1015,6 +1097,12 @@ watch(
periodPaging.flagTab,
periodPaging.includeRequiresAction,
periodPaging.includeBooked,
periodPaging.reviewState,
periodPaging.severity,
periodPaging.invoiceState,
periodPaging.departmentId,
periodPaging.sort,
periodPaging.direction,
],
() => {
if (
@@ -1033,6 +1121,16 @@ watch(
}
);
watch(
() => periodPaging.selectedCustomerNumber,
() => {
if (!hasMountedPeriodState || isApplyingRoutePeriodState || !hasPeriodDateSelection()) {
return;
}
syncPeriodRouteQuery();
}
);
watch(
() => periodPaging.reloadSequence,
() => {
@@ -1062,15 +1160,12 @@ watch(
</script>
<template>
<div class="columns is-multiline is-mobile" data-testid="invoicing-period-view-selectors">
<!--
<div class="column is-12">
<h1 class="title is-3">Dashboard Right Display</h1>
<button class="button is-primary" @click="getPeriod">Fetch Period Data</button>
</div>
-->
<template v-for="type in types" :key="type.name">
<div class="column is-12 p-1">
<nav class="period-view-navigation" :aria-label="tr('navigation.aria_label', 'Forretningsvisninger')" data-testid="invoicing-period-view-selectors">
<section v-for="group in navigationGroups" :key="group.key" class="period-view-navigation__group">
<h2 class="period-view-navigation__heading">{{ group.label }}</h2>
<div class="period-view-navigation__items">
<template v-for="type in group.types" :key="type.name">
<div class="period-view-navigation__item">
<WhiteBox class="is-selector-view"
:data-testid="`invoicing-period-view-selector-${type.name}`"
v-bind:class="{
@@ -1078,8 +1173,13 @@ watch(
'is-clickable': canActivateView(type),
'is-disabled-view': !canActivateView(type),
}"
role="button"
:tabindex="canActivateView(type) ? 0 : -1"
:aria-disabled="!canActivateView(type)"
@click="(canActivateView(type) ? onClickType(type) : () => {})">
:aria-current="isViewActive(type) ? 'page' : undefined"
@click="(canActivateView(type) ? onClickType(type) : () => {})"
@keydown.enter.prevent="(canActivateView(type) ? onClickType(type) : () => {})"
@keydown.space.prevent="(canActivateView(type) ? onClickType(type) : () => {})">
<div class="level is-mobile is-align-items-center">
<div class="level-left">
<div class="level-item">
@@ -1099,7 +1199,7 @@ watch(
</div>
</div>
<div class="level-right">
<div class="level-item" style="width: 100px;">
<div class="level-item period-selector-progress-slot">
<progress class="progress is-small"
v-if="isLoading"
v-bind:max="1"
@@ -1131,8 +1231,10 @@ watch(
</div>
</WhiteBox>
</div>
</template>
</div>
</template>
</div>
</section>
</nav>
</template>
<style scoped>
@@ -1150,6 +1252,96 @@ watch(
opacity: 0.55;
}
.period-view-navigation {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(4, minmax(0, 1fr));
max-width: 100%;
min-width: 0;
width: 100%;
}
.period-view-navigation__group,
.period-view-navigation__items,
.period-view-navigation__item,
.is-selector-view {
max-width: 100%;
min-width: 0;
}
.is-selector-view {
overflow: hidden;
width: 100%;
}
.is-selector-view :deep(.level),
.is-selector-view :deep(.level-left),
.is-selector-view :deep(.level-right),
.is-selector-view :deep(.level-item),
.is-selector-view :deep(.color-indicator),
.is-selector-view :deep(.icon-text) {
min-width: 0;
}
.is-selector-view :deep(.level-left) {
flex: 1 1 auto;
overflow: hidden;
}
.is-selector-view :deep(.level) {
align-items: center;
display: grid;
gap: 0.35rem;
grid-template-columns: minmax(0, 1fr) minmax(88px, 132px);
width: 100%;
}
.is-selector-view :deep(.level-left .icon-text > span:last-child) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.is-selector-view :deep(.level-right) {
display: flex;
flex: none;
justify-content: flex-end;
max-width: 132px;
min-width: 88px;
width: 100%;
}
.is-selector-view :deep(.period-selector-progress-slot) {
flex: 1 1 72px;
max-width: 100px;
min-width: 56px;
width: auto;
}
.period-view-navigation__heading {
color: #596273;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.06em;
margin: 0 0 0.35rem;
text-transform: uppercase;
}
.period-view-navigation__items {
display: grid;
gap: 0.4rem;
}
.period-view-navigation__item {
min-width: 0;
}
.is-selector-view:focus-visible {
box-shadow: 0 0 0 3px rgba(50, 115, 220, 0.28);
outline: none;
}
.period-selector-progress {
position: relative;
width: 100%;
@@ -1178,4 +1370,25 @@ watch(
width: var(--draft-width, 0%);
background: #f6c344;
}
@media screen and (max-width: 1400px) {
.period-view-navigation {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media screen and (max-width: 600px) {
.period-view-navigation {
display: flex;
margin-inline: -0.75rem;
overflow-x: auto;
padding: 0 0.75rem 0.5rem;
scroll-snap-type: x proximity;
}
.period-view-navigation__group {
flex: 0 0 min(82vw, 320px);
scroll-snap-align: start;
}
}
</style>
@@ -18,6 +18,12 @@ const defaultMeta = () => ({
includeBooked: true,
},
order: null,
facets: {
review_states: [],
severities: [],
invoice_states: [],
department_ids: [],
},
});
export const periodPaging = reactive({
@@ -27,11 +33,21 @@ export const periodPaging = reactive({
flagTab: "all",
includeRequiresAction: true,
includeBooked: true,
reviewState: "",
severity: "",
invoiceState: "",
departmentId: "",
sort: "priority",
direction: "asc",
selectedCustomerNumber: null,
meta: defaultMeta(),
typeCounts: {},
isRefreshing: false,
isFromCache: false,
isDateLoading: false,
hasLoaded: false,
loadError: false,
loadErrorMessage: "",
cacheUpdatedAt: null,
reloadSequence: 0,
});
@@ -129,11 +145,21 @@ export const resetPeriodPagingState = ({ clearCache = true } = {}) => {
periodPaging.flagTab = "all";
periodPaging.includeRequiresAction = true;
periodPaging.includeBooked = true;
periodPaging.reviewState = "";
periodPaging.severity = "";
periodPaging.invoiceState = "";
periodPaging.departmentId = "";
periodPaging.sort = "priority";
periodPaging.direction = "asc";
periodPaging.selectedCustomerNumber = null;
periodPaging.meta = defaultMeta();
periodPaging.typeCounts = {};
periodPaging.isRefreshing = false;
periodPaging.isFromCache = false;
periodPaging.isDateLoading = false;
periodPaging.hasLoaded = false;
periodPaging.loadError = false;
periodPaging.loadErrorMessage = "";
periodPaging.cacheUpdatedAt = null;
periodPaging.reloadSequence = 0;
if (clearCache) {
@@ -173,6 +199,12 @@ export const buildPeriodCacheKey = ({
flagTab,
includeRequiresAction,
includeBooked,
reviewState,
severity,
invoiceState,
departmentId,
sort,
direction,
}) =>
[
dateFrom,
@@ -184,6 +216,12 @@ export const buildPeriodCacheKey = ({
String(flagTab || "all").trim(),
includeRequiresAction ? "1" : "0",
includeBooked ? "1" : "0",
String(reviewState || ""),
String(severity || ""),
String(invoiceState || ""),
String(departmentId || ""),
String(sort || "priority"),
String(direction || "asc"),
]
.map((part) => encodeURIComponent(String(part ?? "")))
.join("|");
@@ -0,0 +1,89 @@
const toCount = (value) => Math.max(0, Number(value) || 0);
const reviewReason = (code, severity, count) => ({
code,
severity,
count,
});
export const deriveLegacyPeriodCustomerReview = (
customer = {},
{ manualFlags = 0, automaticFlags = 0 } = {}
) => {
const counts = {
active_manual_flags: toCount(manualFlags),
active_automatic_flags: toCount(automaticFlags),
collection_errors: 0,
active_queue_jobs: 0,
booked_transactions: 0,
unbooked_transactions: 0,
};
(Array.isArray(customer.invoice_collections) ? customer.invoice_collections : []).forEach((collection) => {
if (String(collection?.error_message || "").trim() !== "") {
counts.collection_errors += 1;
}
});
(Array.isArray(customer.transactions) ? customer.transactions : []).forEach((transaction) => {
if (!transaction || transaction.excluded === true) {
return;
}
counts[transaction.booked === true ? "booked_transactions" : "unbooked_transactions"] += 1;
});
if (customer?.queue?.has_active_job === true) {
counts.active_queue_jobs = Math.max(1, customer.queue.invoice_collection_ids?.length || 0);
}
const reasons = [];
if (counts.active_manual_flags > 0) {
reasons.push(reviewReason("manual_flags", "red", counts.active_manual_flags));
}
if (counts.collection_errors > 0) {
reasons.push(reviewReason("collection_errors", "red", counts.collection_errors));
}
if (customer?.draft?.is_action_blocked === true) {
reasons.push(reviewReason("draft_blocks_action", "red", 1));
}
const requiresAttention = customer?.requires_action === true && counts.unbooked_transactions === 0;
if (requiresAttention) {
reasons.push(reviewReason("requires_action", "yellow", 1));
}
if (counts.active_automatic_flags > 0) {
reasons.push(reviewReason("automatic_warnings", "yellow", counts.active_automatic_flags));
}
if (counts.active_queue_jobs > 0) {
reasons.push(reviewReason("export_in_progress", "blue", counts.active_queue_jobs));
}
if (counts.unbooked_transactions > 0) {
reasons.push(reviewReason("unbooked_transactions", "green", counts.unbooked_transactions));
}
let state = "ready";
let severity = "green";
let nextAction = "create_invoice";
if (counts.active_manual_flags > 0) {
[state, severity, nextAction] = ["blocked", "red", "resolve_manual_flags"];
} else if (counts.collection_errors > 0) {
[state, severity, nextAction] = ["blocked", "red", "resolve_collection_errors"];
} else if (customer?.draft?.is_action_blocked === true) {
[state, severity, nextAction] = ["blocked", "red", "resolve_draft"];
} else if (counts.active_automatic_flags > 0 || requiresAttention) {
[state, severity, nextAction] = ["attention", "yellow", "review_warnings"];
} else if (counts.active_queue_jobs > 0) {
[state, severity, nextAction] = ["queued", "blue", "wait_for_export"];
} else if (counts.booked_transactions > 0 && counts.unbooked_transactions === 0) {
[state, severity, nextAction] = ["completed", "green", "none"];
}
return {
state,
severity,
reasons,
next_action: nextAction,
is_actionable: ["blocked", "attention", "ready"].includes(state),
counts,
};
};
@@ -186,7 +186,10 @@ export const makeCategoryNode = ({
});
export const makeCollectionNode = (collectionId, orders, customer, meta = {}) => {
const totalNetAmount = orders.reduce((sum, order) => sum + getTreeAmount(order), 0);
const ordersTotal = orders.reduce((sum, order) => sum + getTreeAmount(order), 0);
const totalNetAmount = orders.length > 0
? ordersTotal
: getTreeAmount(meta.collectionSummary || {});
return {
id: makeNodeId(TREE_NODE_TYPES.COLLECTION, collectionId),
label: resolveLabel(`Fakturasamling #${collectionId}`, meta.label),
@@ -503,6 +506,14 @@ export const buildCollectionRootNodes = (customer, transactions = [], excludedOr
groups.get(collectionId).push(transaction);
});
const collectionSummaries = Array.isArray(customer?.invoice_collections) ? customer.invoice_collections : [];
collectionSummaries.forEach((collection) => {
const collectionId = toPositiveInteger(collection?.id ?? collection?.invoice_collection_id);
if (collectionId && !groups.has(collectionId)) {
groups.set(collectionId, []);
}
});
const collectionCount = groups.size;
const nodes = Array.from(groups.entries())
.sort(([left], [right]) => left - right)
@@ -517,7 +528,7 @@ export const buildCollectionRootNodes = (customer, transactions = [], excludedOr
locale: labels.locale,
fallbackLabel,
});
const collectionSummary = (Array.isArray(customer?.invoice_collections) ? customer.invoice_collections : [])
const collectionSummary = collectionSummaries
.find((collection) => toPositiveInteger(collection?.id ?? collection?.invoice_collection_id) === collectionId) || null;
return makeCollectionNode(collectionId, orders, customer, {
label: relativeLabel?.label || fallbackLabel,
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from "vue";
import i18n from "@/i18n";
import { view } from "../imports/InvoicingBillingPeriodImportView.vue";
import { dates } from "../imports/InvoicingBillingPeriodImportDates.vue";
import { invoiceQueue } from "../imports/InvoicingBillingPeriodImportInvoiceQueue.vue";
@@ -35,16 +36,31 @@ import {
getDuplicateTransactionDateKey,
getDuplicateTransactionPlate,
} from "../utils/possibleDuplicateGroups.js";
import { deriveLegacyPeriodCustomerReview } from "../services/invoicingPeriodReview.js";
const expandedDuplicateGroupKey = ref<string | null>(null);
const tr = (key: string, fallback: string, params: Record<string, any> = {}) => {
const fullKey = `invoicing_period.review_workspace.${key}`;
const translated = i18n.global.t(fullKey, params);
return translated === fullKey ? fallback : translated;
};
const showAnalytics = ref(false);
const onClickCustomer = (customer: any) => {
const shouldExpandCustomer = customer.expanded !== true;
periodPaging.selectedCustomerNumber = Number(customer.customer_number) || null;
customersInCurrentView.value.forEach((currentCustomer: any) => {
currentCustomer.expanded = currentCustomer === customer ? shouldExpandCustomer : false;
currentCustomer.expanded = currentCustomer === customer;
});
};
const onClickCustomerRow = (event: MouseEvent, customer: any) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("button, a, input, select, textarea, [role='button']")) {
return;
}
onClickCustomer(customer);
};
const getTransactionsInView = (customer: any) => {
if (!customer.transactions || customer.transactions.length === 0) {
return [];
@@ -158,6 +174,105 @@ onUnmounted(() => {
const customersInCurrentView = computed(
() => view.variables.sharedVariables.value?.types?.[view.computed.componentName.value] ?? []
);
const hasVisiblePeriodData = computed(() => periodPaging.hasLoaded === true || view.variables.sharedVariables.value != null);
const REVIEW_STATES = ["blocked", "attention", "queued", "ready", "completed"];
const REVIEW_SEVERITIES = ["red", "yellow", "blue", "green"];
const getCustomerReview = (customer: any) => {
const flagCounts = getCustomerActiveFlagCounts(customer);
const backendReview = customer?.review && REVIEW_STATES.includes(String(customer.review.state))
? customer.review
: deriveLegacyPeriodCustomerReview(customer, {
manualFlags: flagCounts.manual,
automaticFlags: flagCounts.automatic,
});
const locallyQueuedCollectionIds = getLocalQueuedInvoiceCollectionIdsForCustomer(customer);
if (
locallyQueuedCollectionIds.length === 0
|| ["blocked", "attention"].includes(String(backendReview.state))
) {
return backendReview;
}
return {
...backendReview,
state: "queued",
severity: "blue",
reasons: [{ code: "export_in_progress", count: locallyQueuedCollectionIds.length }],
next_action: "wait_for_export",
is_actionable: false,
};
};
const getReviewStateLabel = (state: string) => tr(`states.${state}`, state);
const getReviewReasonLabel = (reason: any) => {
const code = String(reason?.code || "unknown");
const count = Number(reason?.count || 1);
return tr(`reasons.${code}`, code.replaceAll("_", " "), { count });
};
const getNextActionLabel = (action: string) => tr(`next_actions.${action}`, action.replaceAll("_", " "));
const getReviewStateIcon = (state: string) => ({
blocked: "fas fa-ban",
attention: "fas fa-exclamation-triangle",
queued: "fas fa-clock",
ready: "fas fa-file-invoice",
completed: "fas fa-check-circle",
}[state] || "fas fa-circle");
const getReviewStateClass = (review: any) => `review-state--${review?.severity || "green"}`;
const facetValues = (key: string) => {
const values = periodPaging.meta?.facets?.[key];
return Array.isArray(values) ? values : [];
};
const hasActiveReviewFilters = computed(() => Boolean(
periodPaging.reviewState
|| periodPaging.severity
|| periodPaging.invoiceState
|| periodPaging.departmentId
));
const clearReviewFilters = () => {
periodPaging.reviewState = "";
periodPaging.severity = "";
periodPaging.invoiceState = "";
periodPaging.departmentId = "";
onReviewFilterChange();
};
const reviewQueueCustomers = computed(() => customersInCurrentView.value.filter((customer: any) => {
const review = getCustomerReview(customer);
if (periodPaging.reviewState && review.state !== periodPaging.reviewState) {
return false;
}
if (periodPaging.severity && review.severity !== periodPaging.severity) {
return false;
}
return true;
}));
const selectedCustomer = computed(() => {
const selectedNumber = Number(periodPaging.selectedCustomerNumber || 0);
return reviewQueueCustomers.value.find((customer: any) => Number(customer.customer_number) === selectedNumber) ?? null;
});
const selectCustomerByOffset = (offset: number) => {
const needingReview = reviewQueueCustomers.value.filter((customer: any) => getCustomerReview(customer).state !== "completed");
if (needingReview.length === 0) {
return;
}
const currentIndex = needingReview.findIndex((customer: any) => customer === selectedCustomer.value);
const baseIndex = currentIndex < 0 ? (offset > 0 ? -1 : needingReview.length) : currentIndex;
const nextIndex = Math.max(0, Math.min(needingReview.length - 1, baseIndex + offset));
onClickCustomer(needingReview[nextIndex]);
};
const onReviewFilterChange = () => {
periodPaging.page = 1;
periodPaging.selectedCustomerNumber = null;
};
type PeriodFlagTab = "all" | "red" | "yellow" | "none" | "filters";
type CustomerFlagTab = "red" | "yellow" | "none";
@@ -214,7 +329,7 @@ const periodFlagTabCounts = computed(() => {
});
const filteredCustomersInCurrentView = computed(() => {
return customersInCurrentView.value;
return reviewQueueCustomers.value;
});
const getPeriodFlagTabIconClass = (tab: PeriodFlagTab) => {
@@ -682,6 +797,36 @@ const getCustomerActiveFlagCounts = (customer: any) => {
};
};
watch(
() => [
view.variables.currentView.value,
hasVisiblePeriodData.value,
periodPaging.selectedCustomerNumber,
reviewQueueCustomers.value.map((customer: any) => customer.customer_number).join(","),
],
() => {
if (!hasVisiblePeriodData.value) {
return;
}
const queue = reviewQueueCustomers.value;
const requestedNumber = Number(periodPaging.selectedCustomerNumber || 0);
const requestedCustomer = queue.find(
(customer: any) => Number(customer.customer_number) === requestedNumber
);
const nextCustomer = requestedCustomer ?? queue[0] ?? null;
const nextNumber = nextCustomer ? Number(nextCustomer.customer_number) || null : null;
if (periodPaging.selectedCustomerNumber !== nextNumber) {
periodPaging.selectedCustomerNumber = nextNumber;
}
customersInCurrentView.value.forEach((customer: any) => {
customer.expanded = customer === nextCustomer;
});
},
{ immediate: true }
);
const getCustomerStatusColorClass = (customer: any) => {
const flagCounts = getCustomerActiveFlagCounts(customer);
if (flagCounts.manual > 0) {
@@ -822,22 +967,107 @@ const getTransactionQueryParameters = () => {
<template>
<div data-testid="invoicing-period-view-all" :data-current-view="view.variables.currentView.value">
<InvoicingBillingPeriodStatistics />
<section
class="period-headline-totals"
:aria-label="tr('headline.aria_label', 'Periodens hovedtal')"
data-testid="invoicing-period-headline-statistics"
>
<div class="period-headline-total" data-testid="invoicing-period-headline-total">
<span>{{ tr('headline.total', 'I alt') }}</span>
<strong>{{ SessionUser.functions.currency.toLocal(view.computed.currentViewTotalNetAmount?.value ?? 0) }}</strong>
</div>
<div class="period-headline-total period-headline-total--booked" data-testid="invoicing-period-headline-booked">
<span>{{ tr('headline.booked', 'Bogført') }}</span>
<strong>{{ SessionUser.functions.currency.toLocal(view.computed.currentViewTotalNetAmountBooked?.value ?? 0) }}</strong>
</div>
<div class="period-headline-total period-headline-total--open" data-testid="invoicing-period-headline-not-booked">
<span>{{ tr('headline.not_booked', 'Ikke bogført') }}</span>
<strong>{{ SessionUser.functions.currency.toLocal(view.computed.currentViewTotalNetAmountNotBooked?.value ?? 0) }}</strong>
</div>
</section>
<section class="period-analytics" data-testid="invoicing-period-analytics">
<button
type="button"
class="button is-white period-analytics__toggle"
:aria-expanded="showAnalytics"
@click="showAnalytics = !showAnalytics"
>
<span class="icon is-small"><i class="fas fa-chart-bar"></i></span>
<span>{{ tr('analytics.title', 'Overblik og nøgletal') }}</span>
<span class="icon is-small"><i class="fas" :class="showAnalytics ? 'fa-chevron-up' : 'fa-chevron-down'"></i></span>
</button>
<InvoicingBillingPeriodStatistics v-if="showAnalytics" />
</section>
<div
class="columns is-vcentered is-multiline pagination-general-search-reload"
class="period-review-toolbar"
data-testid="invoicing-period-page-controls"
>
<div class="column pagination-general-search-reload__search-column">
<div class="period-review-toolbar__search">
<label class="is-sr-only" for="period-review-search">{{ tr('toolbar.search', 'Søg i kunder og fakturadata') }}</label>
<input
id="period-review-search"
v-model="periodSearchInput"
class="input"
type="text"
:placeholder="SessionUser.objects.global.language.search ?? 'Search'"
:placeholder="tr('toolbar.search', 'Søg i kunder og fakturadata')"
data-testid="invoicing-period-search-input"
@input="onPeriodSearchInput"
/>
</div>
<div class="column is-narrow pl-0 pagination-general-search-reload__action-column">
<label class="period-review-toolbar__field">
<span>{{ tr('toolbar.workflow', 'Arbejdsgang') }}</span>
<select v-model="periodPaging.reviewState" class="select-native" data-testid="invoicing-period-review-state" @change="onReviewFilterChange">
<option value="">{{ tr('toolbar.all_states', 'Alle tilstande') }}</option>
<option v-for="state in REVIEW_STATES" :key="state" :value="state">
{{ getReviewStateLabel(state) }}
</option>
</select>
</label>
<label v-if="facetValues('invoice_states').length" class="period-review-toolbar__field">
<span>{{ tr('toolbar.invoice_state', 'Fakturastatus') }}</span>
<select v-model="periodPaging.invoiceState" class="select-native" data-testid="invoicing-period-invoice-state" @change="onReviewFilterChange">
<option value="">{{ tr('toolbar.all_invoice_states', 'Alle fakturastatusser') }}</option>
<option v-for="facet in facetValues('invoice_states')" :key="facet.value" :value="String(facet.value)">{{ facet.value }} ({{ facet.count }})</option>
</select>
</label>
<label v-if="facetValues('department_ids').length" class="period-review-toolbar__field">
<span>{{ tr('toolbar.department', 'Afdeling') }}</span>
<select v-model="periodPaging.departmentId" class="select-native" data-testid="invoicing-period-department" @change="onReviewFilterChange">
<option value="">{{ tr('toolbar.all_departments', 'Alle afdelinger') }}</option>
<option v-for="facet in facetValues('department_ids')" :key="facet.value" :value="String(facet.value)">#{{ facet.value }} ({{ facet.count }})</option>
</select>
</label>
<label class="period-review-toolbar__field">
<span>{{ tr('toolbar.severity', 'Alvor') }}</span>
<select v-model="periodPaging.severity" class="select-native" data-testid="invoicing-period-severity" @change="onReviewFilterChange">
<option value="">{{ tr('toolbar.all_severities', 'Alle niveauer') }}</option>
<option v-for="severity in REVIEW_SEVERITIES" :key="severity" :value="severity">
{{ tr(`severities.${severity}`, severity) }}
</option>
</select>
</label>
<label class="period-review-toolbar__field">
<span>{{ tr('toolbar.sort', 'Sortér') }}</span>
<select v-model="periodPaging.sort" class="select-native" data-testid="invoicing-period-sort" @change="onReviewFilterChange">
<option value="priority">{{ tr('sort.priority', 'Vigtigst først') }}</option>
<option value="customer_name">{{ tr('sort.customer_name', 'Kundenavn') }}</option>
<option value="customer_number">{{ tr('sort.customer_number', 'Kundenummer') }}</option>
<option value="total_amount">{{ tr('sort.total_amount', 'Beløb') }}</option>
</select>
</label>
<button
type="button"
class="button is-light period-review-toolbar__direction"
:aria-label="tr('toolbar.change_direction', 'Skift sorteringsretning')"
:title="tr('toolbar.change_direction', 'Skift sorteringsretning')"
@click="periodPaging.direction = periodPaging.direction === 'asc' ? 'desc' : 'asc'"
>
<span class="icon"><i class="fas" :class="periodPaging.direction === 'asc' ? 'fa-sort-amount-down-alt' : 'fa-sort-amount-up-alt'"></i></span>
</button>
<button v-if="hasActiveReviewFilters" type="button" class="button is-text period-review-toolbar__clear" @click="clearReviewFilters">
{{ tr('toolbar.clear_filters', 'Ryd filtre') }}
</button>
<div class="pagination-general-search-reload__action-column">
<button
type="button"
class="button is-dark button-same-width action-dropdown-main is-inverted"
@@ -850,10 +1080,14 @@ const getTransactionQueryParameters = () => {
<i class="fas fa-sync-alt"></i>
</span>
<span>
{{ SessionUser.objects.global.language.reload ?? SessionUser.objects.global.language.update ?? "Reload" }}
{{ tr('toolbar.reload', 'Genindlæs') }}
</span>
</button>
</div>
<div v-if="periodPaging.isRefreshing && periodPaging.hasLoaded" class="period-review-toolbar__refresh" role="status">
<span class="icon is-small"><i class="fas fa-sync-alt fa-spin"></i></span>
<span>{{ tr('states.refreshing', 'Opdaterer eksisterende resultat…') }}</span>
</div>
</div>
<div v-if="isPossibleDuplicatesView" class="py-2">
<div class="columns is-vcentered is-multiline is-mobile">
@@ -961,6 +1195,36 @@ const getTransactionQueryParameters = () => {
</b-tabs>
</div>
<div v-if="periodPaging.loadError && hasVisiblePeriodData" class="notification is-warning is-light period-retained-error" role="alert" data-testid="invoicing-period-retained-error">
<span>{{ tr('states.retained_error', 'Opdateringen mislykkedes. Det senest indlæste resultat vises stadig.') }}</span>
<button type="button" class="button is-small is-warning is-light" @click="reloadPeriodPage">{{ tr('toolbar.retry', 'Prøv igen') }}</button>
</div>
<div v-if="!hasVisiblePeriodData && !periodPaging.loadError" class="period-state" role="status" data-testid="invoicing-period-initial-loading">
<span class="icon is-large"><i class="fas fa-circle-notch fa-spin fa-2x"></i></span>
<strong>{{ tr('states.loading_title', 'Indlæser fakturaperioden') }}</strong>
<span>{{ tr('states.loading_body', 'Samler kunder, fakturasamlinger og kontrolpunkter…') }}</span>
</div>
<div v-else-if="periodPaging.loadError && !hasVisiblePeriodData" class="period-state period-state--error" role="alert" data-testid="invoicing-period-initial-error">
<span class="icon is-large"><i class="fas fa-exclamation-circle fa-2x"></i></span>
<strong>{{ tr('states.error_title', 'Fakturaperioden kunne ikke indlæses') }}</strong>
<span>{{ periodPaging.loadErrorMessage || tr('states.error_body', 'Prøv at genindlæse perioden.') }}</span>
<button type="button" class="button is-danger is-light" @click="reloadPeriodPage">{{ tr('toolbar.retry', 'Prøv igen') }}</button>
</div>
<div v-else-if="customersInCurrentView.length === 0" class="period-state" data-testid="invoicing-period-true-empty">
<span class="icon is-large"><i class="fas fa-inbox fa-2x"></i></span>
<strong>{{ tr('states.empty_title', 'Ingen fakturadata i perioden') }}</strong>
<span>{{ tr('states.empty_body', 'Vælg en anden periode eller forretningsvisning.') }}</span>
</div>
<div v-else-if="reviewQueueCustomers.length === 0" class="period-state" data-testid="invoicing-period-filtered-empty">
<span class="icon is-large"><i class="fas fa-filter fa-2x"></i></span>
<strong>{{ tr('states.filtered_empty_title', 'Ingen kunder matcher filtrene') }}</strong>
<span>{{ tr('states.filtered_empty_body', 'Nulstil arbejdsgangsfiltrene eller prøv en anden søgning.') }}</span>
<button type="button" class="button is-light" @click="clearReviewFilters">
{{ tr('toolbar.clear_filters', 'Ryd filtre') }}
</button>
</div>
<div v-else>
<div v-if="isPossibleDuplicatesView" class="columns is-multiline is-mobile">
<template v-for="duplicateGroup in possibleDuplicateGroupsInCurrentView" :key="duplicateGroup.key">
<div class="column is-12">
@@ -1087,36 +1351,62 @@ const getTransactionQueryParameters = () => {
</template>
</div>
<div v-else class="columns is-multiline is-mobile">
<div v-else class="period-review-workspace">
<section class="period-review-queue" :aria-label="tr('queue.title', 'Kunder til gennemgang')">
<header class="period-review-queue__header">
<div>
<h2>{{ tr('queue.title', 'Kunder til gennemgang') }}</h2>
<p>{{ tr('queue.count', '{count} kunder', { count: filteredCustomersInCurrentView.length }) }}</p>
</div>
</header>
<template v-for="customer in filteredCustomersInCurrentView" :key="customer.customer_number">
<div class="column is-12">
<div class="period-customer-row">
<WhiteBox
class="mb-2"
class="period-customer-card"
:has-border="true"
:class="{ 'period-customer-card--selected': selectedCustomer === customer }"
:data-testid="`invoicing-period-customer-${customer.customer_number}`"
>
<div class="columns is-vcentered is-clickable" @click="onClickCustomer(customer)">
<div class="column">
<ColorIndicator
:color_class="getCustomerStatusColorClass(customer)"
:icon_class="getCustomerStatusIconClass(customer)"
:label="{
text: customer.customer_name,
classes: [],
}"
:visibility="{
icon: true,
dropdown: false,
}"
/>
<div
class="columns is-vcentered period-customer-card__row"
role="group"
:aria-label="customer.customer_name"
@click="onClickCustomerRow($event, customer)"
>
<div class="column period-customer-card__identity">
<button
type="button"
class="period-customer-card__selector is-clickable"
:aria-pressed="selectedCustomer === customer"
:data-testid="`invoicing-period-customer-select-${customer.customer_number}`"
@click="onClickCustomer(customer)"
>
<ColorIndicator
:color_class="getCustomerStatusColorClass(customer)"
:icon_class="getCustomerStatusIconClass(customer)"
:label="{
text: customer.customer_name,
classes: [],
}"
:visibility="{
icon: true,
dropdown: false,
}"
/>
<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>
<span class="period-customer-review__next">{{ getNextActionLabel(getCustomerReview(customer).next_action) }}</span>
</div>
</button>
</div>
<div class="column is-narrow">
<div class="column is-narrow period-customer-card__attributes">
<InvoicingBillingPeriodCustomerAttributes
:customer="customer"
:is-expanded="customer.expanded || false"
/>
</div>
<div class="column is-narrow">
<div class="column is-narrow period-customer-card__activity">
<SmallCustomerActivityChart
v-if="tmpFilters.displayStatistics"
v-show="customer.transactions.length > 1 && doesCustomerHaveTransactionsDifferentDays(customer)"
@@ -1127,7 +1417,7 @@ const getTransactionQueryParameters = () => {
:transactions="customer.transactions"
/>
</div>
<div class="column is-narrow">
<div class="column is-narrow period-customer-card__count">
<p v-if="customer.transactions.length !== 0">
{{ view.functions.filterExcluded(customer.transactions).length }}
{{
@@ -1140,7 +1430,7 @@ const getTransactionQueryParameters = () => {
{{ SessionUser.objects.global.language.none }} {{ SessionUser.objects.orders.meta.labels.multiple }}
</p>
</div>
<div class="column is-narrow">
<div class="column is-narrow period-customer-card__amount">
<p>
{{
SessionUser.functions.currency.toLocal(
@@ -1179,7 +1469,7 @@ const getTransactionQueryParameters = () => {
</p>
</div>
<div
class="column is-narrow"
class="column is-narrow period-customer-card__state"
v-if="
isAllCustomerTransactionsBooked(customer, getCustomerTransactionsInView(customer)) &&
!customer.requires_action &&
@@ -1194,7 +1484,7 @@ const getTransactionQueryParameters = () => {
<span>{{ SessionUser.objects.global.language.all_booked }}</span>
</span>
</div>
<div class="column is-narrow" v-else-if="isCustomerQueueBlocked(customer)">
<div class="column is-narrow period-customer-card__state" v-else-if="isCustomerQueueBlocked(customer)">
<button
class="button is-small is-dark"
:class="{
@@ -1209,7 +1499,7 @@ const getTransactionQueryParameters = () => {
<span>{{ getCustomerQueueLabel(customer) }}</span>
</button>
</div>
<div class="column is-narrow" v-else-if="isCustomerDraftBlocked(customer)">
<div class="column is-narrow period-customer-card__state" v-else-if="isCustomerDraftBlocked(customer)">
<span
class="tag is-warning is-light is-small"
aria-disabled="true"
@@ -1221,7 +1511,7 @@ const getTransactionQueryParameters = () => {
<span>{{ SessionUser.objects.global.language.draft ?? "Kladde" }}</span>
</span>
</div>
<div class="column is-narrow" v-else>
<div class="column is-narrow period-customer-card__state" v-else>
<button
v-if="tmpFilters.displayRequiresAction && customer.requires_action"
type="button"
@@ -1237,7 +1527,7 @@ const getTransactionQueryParameters = () => {
<span>{{ SessionUser.objects.global.language.invoice_now }}</span>
</button>
</div>
<div class="column is-narrow">
<div class="column is-narrow period-customer-card__settings">
<ActionSettingsWheelButton
:user_id="customer.id"
:customer_number="customer.customer_number"
@@ -1252,50 +1542,64 @@ const getTransactionQueryParameters = () => {
:flags="getCustomerCardFlags(customer)"
@statusChanged="(flag) => onFlagStatusChanged(customer, flag)"
/>
<div
v-if="customer.expanded"
:data-testid="`invoicing-period-customer-expanded-${customer.customer_number}`"
>
<template v-if="view.variables.currentView.value === 'possible_duplicates'">
<InvoiceOrdersPagination
:set-customer-filter="customer.customer_number"
:hide-search="true"
:hide-filter="true"
:invoice-view="true"
:apply-default-filters="false"
:group-invoice-collection="true"
:limit-results="false"
:hide-pagination="true"
:dates="{
dateFrom: dates.computed.formattedStartDate.value,
dateTo: dates.computed.formattedEndDate.value,
}"
:show-only-with-ids="getTransactionIds(customer)"
:query-parameters="getTransactionQueryParameters()"
:excluded-order-ids="getExcludedTransactionIds(customer)"
:invoice-period-flags="getCustomerScopedFlags(customer)"
@flagStatusChanged="(flag) => onFlagStatusChanged(customer, flag)"
@flagCreated="(flag) => onFlagCreated(customer, flag)"
/>
</template>
<template v-else>
<InvoicingPeriodObjectTree
:customer="customer"
:transactions="getTransactionsInView(customer)"
:excluded-order-ids="getExcludedTransactionIds(customer)"
:auto-expand-all="view.variables.currentView.value === 'invoice_per_order'"
:dates="{
dateFrom: dates.computed.formattedStartDate.value,
dateTo: dates.computed.formattedEndDate.value,
}"
:invoice-period-flags="getCustomerScopedFlags(customer)"
@refresh="reloadPeriodPage"
/>
</template>
</div>
</WhiteBox>
</div>
</template>
</section>
<aside v-if="selectedCustomer" class="period-review-detail" data-testid="invoicing-period-review-detail">
<header class="period-review-detail__header">
<div>
<span class="period-review-detail__eyebrow">{{ tr('detail.selected_customer', 'Valgt kunde') }}</span>
<h2>{{ selectedCustomer.customer_name }}</h2>
<p>#{{ selectedCustomer.customer_number }}</p>
</div>
<div class="period-review-detail__navigation">
<button type="button" class="button is-small is-light" :aria-label="tr('detail.previous', 'Forrige kunde til gennemgang')" @click="selectCustomerByOffset(-1)">
<span class="icon"><i class="fas fa-chevron-left"></i></span>
</button>
<button type="button" class="button is-small is-light" :aria-label="tr('detail.next', 'Næste kunde til gennemgang')" @click="selectCustomerByOffset(1)">
<span class="icon"><i class="fas fa-chevron-right"></i></span>
</button>
</div>
</header>
<div class="period-review-detail__status" :class="getReviewStateClass(getCustomerReview(selectedCustomer))">
<span class="icon"><i :class="getReviewStateIcon(getCustomerReview(selectedCustomer).state)"></i></span>
<div>
<strong>{{ getReviewStateLabel(getCustomerReview(selectedCustomer).state) }}</strong>
<p>{{ getNextActionLabel(getCustomerReview(selectedCustomer).next_action) }}</p>
</div>
</div>
<ul v-if="getCustomerReview(selectedCustomer).reasons?.length" class="period-review-detail__reasons">
<li v-for="reason in getCustomerReview(selectedCustomer).reasons" :key="`${reason.code}-${reason.count}`">
<span class="icon is-small"><i class="fas fa-info-circle"></i></span>
<span>{{ getReviewReasonLabel(reason) }}</span>
<span v-if="Number(reason.count || 0) > 1" class="tag is-light is-rounded">{{ reason.count }}</span>
</li>
</ul>
<div
v-if="selectedCustomer.expanded"
class="period-review-detail__tree"
:data-testid="`invoicing-period-customer-expanded-${selectedCustomer.customer_number}`"
>
<InvoicingPeriodObjectTree
:customer="selectedCustomer"
:transactions="getTransactionsInView(selectedCustomer)"
:excluded-order-ids="getExcludedTransactionIds(selectedCustomer)"
:auto-expand-all="view.variables.currentView.value === 'invoice_per_order'"
:dates="{
dateFrom: dates.computed.formattedStartDate.value,
dateTo: dates.computed.formattedEndDate.value,
}"
:invoice-period-flags="getCustomerScopedFlags(selectedCustomer)"
@refresh="reloadPeriodPage"
/>
</div>
<div v-else class="period-review-detail__prompt">
<span class="icon is-large"><i class="fas fa-mouse-pointer fa-2x"></i></span>
<span>{{ tr('detail.open_prompt', 'Vælg kunden i køen for at åbne fakturadetaljerne.') }}</span>
</div>
</aside>
</div>
</div>
<div class="mt-3" data-testid="invoicing-period-pagination-navigation">
@@ -1311,6 +1615,213 @@ const getTransactionQueryParameters = () => {
</template>
<style scoped>
.period-headline-totals {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 0.75rem;
}
.period-headline-total {
background: #fff;
border: 1px solid #dfe3e8;
border-left: 4px solid #536174;
border-radius: 8px;
display: grid;
gap: 0.15rem;
padding: 0.75rem 1rem;
}
.period-headline-total--booked { border-left-color: #257953; }
.period-headline-total--open { border-left-color: #cc8a00; }
.period-headline-total span { color: #687386; font-size: 0.78rem; font-weight: 700; text-transform: uppercase; }
.period-headline-total strong { color: #1f2937; font-size: 1.2rem; }
.period-analytics {
margin-bottom: 0.75rem;
}
.period-analytics__toggle {
color: #4a5568;
gap: 0.35rem;
padding-inline: 0;
}
.period-review-toolbar {
align-items: end;
backdrop-filter: blur(8px);
background: rgba(255, 255, 255, 0.96);
border: 1px solid #dfe3e8;
border-radius: 10px;
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-bottom: 0.75rem;
padding: 0.75rem;
position: sticky;
top: 0.5rem;
z-index: 12;
}
.period-review-toolbar__search {
flex: 1 1 240px;
}
.period-review-toolbar__field {
color: #4b5563;
display: grid;
flex: 0 1 180px;
font-size: 0.72rem;
font-weight: 700;
gap: 0.2rem;
}
.select-native {
background: #fff;
border: 1px solid #dbdbdb;
border-radius: 4px;
color: #363636;
font-size: 0.9rem;
height: 2.5em;
max-width: 100%;
padding: 0 2rem 0 0.65rem;
}
.period-review-toolbar__direction { flex: 0 0 auto; }
.period-review-toolbar__clear { align-self: center; }
.period-review-toolbar__refresh { align-items: center; color: #536174; display: flex; font-size: 0.8rem; gap: 0.25rem; width: 100%; }
.period-state {
align-items: center;
background: #f7f9fb;
border: 1px dashed #c9d1db;
border-radius: 10px;
color: #536174;
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: center;
min-height: 220px;
padding: 2rem;
text-align: center;
}
.period-state--error { background: #fff5f5; border-color: #e7a8a8; color: #9b2c2c; }
.period-retained-error { align-items: center; display: flex; justify-content: space-between; }
.period-review-workspace {
align-items: start;
display: grid;
gap: 1rem;
grid-template-columns: minmax(330px, 0.8fr) minmax(0, 1.6fr);
}
.period-review-queue {
display: grid;
gap: 0.5rem;
max-height: calc(100vh - 250px);
min-width: 0;
overflow: auto;
padding-right: 0.25rem;
}
.period-review-queue__header {
align-items: end;
background: #fff;
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
position: sticky;
top: 0;
z-index: 2;
}
.period-review-queue__header h2,
.period-review-detail__header h2 { color: #263142; font-size: 1.05rem; font-weight: 800; margin: 0; }
.period-review-queue__header p,
.period-review-detail__header p { color: #6b7280; font-size: 0.78rem; margin: 0; }
.period-customer-row { min-width: 0; }
.period-customer-card { border: 1px solid #dfe3e8 !important; border-radius: 8px; overflow: visible; }
.period-customer-card--selected { border-color: #3273dc !important; box-shadow: 0 0 0 2px rgba(50, 115, 220, 0.12); }
.period-customer-card__row {
align-items: center;
display: grid;
grid-template-areas:
"identity identity settings"
"attributes count amount"
"activity state state";
grid-template-columns: minmax(0, 1fr) auto auto;
margin: 0 !important;
min-height: 68px;
padding: 0.25rem;
}
.period-customer-card__row > .column { min-width: 0; padding: 0.35rem; }
.period-customer-card__identity { min-width: 0; }
.period-customer-card__identity { grid-area: identity; }
.period-customer-card__attributes { grid-area: attributes; }
.period-customer-card__activity { grid-area: activity; }
.period-customer-card__count { grid-area: count; white-space: nowrap; }
.period-customer-card__amount { grid-area: amount; white-space: nowrap; }
.period-customer-card__state { grid-area: state; justify-self: end; }
.period-customer-card__settings { grid-area: settings; }
.period-customer-card__selector {
background: transparent;
border: 0;
border-radius: 6px;
color: inherit;
display: block;
font: inherit;
min-width: 0;
padding: 0.25rem;
text-align: left;
width: 100%;
}
.period-customer-card__selector:focus-visible { box-shadow: 0 0 0 3px rgba(50, 115, 220, 0.28); outline: none; }
.period-customer-card__selector :deep(.color-indicator),
.period-customer-card__selector :deep(.icon-text) { min-width: 0; }
.period-customer-card__selector :deep(.icon-text > span:last-child) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.period-customer-review {
align-items: center;
color: #485466;
display: flex;
font-size: 0.72rem;
gap: 0.25rem;
margin-top: 0.25rem;
}
.period-customer-review__next { color: #6b7280; margin-left: 0.25rem; }
.review-state--red { color: #b42318; }
.review-state--yellow { color: #9a6700; }
.review-state--blue { color: #175cd3; }
.review-state--green { color: #067647; }
.period-review-detail {
background: #fff;
border: 1px solid #dfe3e8;
border-radius: 10px;
min-width: 0;
padding: 1rem;
position: sticky;
top: 7.5rem;
}
.period-review-detail__header { align-items: start; display: flex; justify-content: space-between; }
.period-review-detail__eyebrow { color: #6b7280; font-size: 0.68rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; }
.period-review-detail__navigation { display: flex; gap: 0.35rem; }
.period-review-detail__status { align-items: center; background: #f7f9fb; border-radius: 8px; display: flex; gap: 0.65rem; margin-top: 0.75rem; padding: 0.75rem; }
.period-review-detail__status p { color: #536174; font-size: 0.8rem; margin: 0; }
.period-review-detail__reasons { border-bottom: 1px solid #e5e7eb; display: grid; gap: 0.35rem; list-style: none; margin: 0; padding: 0.75rem 0; }
.period-review-detail__reasons li { align-items: center; display: flex; gap: 0.35rem; }
.period-review-detail__tree { margin-top: 0.75rem; max-height: calc(100vh - 350px); overflow: auto; }
.period-review-detail__prompt { align-items: center; color: #6b7280; display: flex; flex-direction: column; gap: 0.5rem; min-height: 180px; justify-content: center; text-align: center; }
.action-dropdown-main {
justify-content: center;
}
@@ -1342,6 +1853,34 @@ const getTransactionQueryParameters = () => {
.pagination-general-search-reload__action-column .button {
width: 100%;
}
.period-headline-totals { grid-template-columns: 1fr; }
.period-review-toolbar { position: static; }
.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; }
.period-review-detail { position: static; }
.period-review-detail { order: -1; }
.period-review-detail__tree { max-height: none; overflow: visible; }
.period-customer-card__row {
grid-template-areas:
"identity identity settings"
"attributes count amount"
"attributes state state";
}
.period-customer-card__attributes { align-self: stretch; grid-area: attributes; }
.period-customer-card__activity { display: none; }
.period-customer-review { min-width: 0; white-space: nowrap; }
.period-customer-review > span:not(.icon) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
@media screen and (min-width: 769px) and (max-width: 1100px) {
.period-review-workspace { grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.1fr); }
.period-review-toolbar__field { flex: 1 1 160px; }
}
.duplicate-group-card {
+116 -21
View File
@@ -980,6 +980,9 @@ async function openPeriodView(page, options = {}) {
invoiceDistribution: true,
});
await setupPeriodEndpoints(page, periodRequests, options);
if (typeof options.beforeGoto === "function") {
await options.beforeGoto(page);
}
await page.goto("/superuser/invoices?activeTab=period&startDate=2026-07-01&endDate=2026-07-31&periodView=all", {
waitUntil: "domcontentloaded",
});
@@ -1012,6 +1015,14 @@ async function selectPeriodView(page, viewName) {
await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute("data-current-view", viewName);
}
async function expandPeriodAnalytics(page) {
const toggle = page.getByTestId("invoicing-period-analytics").locator(".period-analytics__toggle");
if ((await toggle.getAttribute("aria-expanded")) !== "true") {
await toggle.click();
}
await expect(toggle).toHaveAttribute("aria-expanded", "true");
}
async function seedInvoicesPage(page, token = "superuser-period-e2e-token") {
await suppressVueDevtoolsOverlay(page);
await seedAuthenticatedState(page, token);
@@ -1208,7 +1219,10 @@ async function routeObjectTreeOrderEndpoints(page) {
async function expandTreeNode(page, key) {
const node = page.locator(`[data-node-key="${key}"]`).first();
await expect(node).toBeVisible();
await node.locator(".b-tree-node-toggle").click();
if ((await node.getAttribute("aria-expanded")) !== "true") {
await node.locator(":scope > .b-tree-node-content > .b-tree-node-toggle").click();
await expect(node).toHaveAttribute("aria-expanded", "true");
}
}
async function getBoundingBox(locator, label) {
@@ -1317,6 +1331,57 @@ test.describe("Invoicing period tab", () => {
expect(periodRequests.length).toBeGreaterThan(0);
});
test("@smoke @pr period review workspace restores filters and supports keyboard master-detail navigation", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
await openPeriodView(page);
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-review-detail")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-analytics")).toBeVisible();
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
.toBe(true);
const secondCustomerActivator = page.getByTestId("invoicing-period-customer-select-4002");
await secondCustomerActivator.focus();
await secondCustomerActivator.press("Enter");
await expect(secondCustomerActivator).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("invoicing-period-customer-expanded-4002")).toBeVisible();
await expect(page).toHaveURL(/periodCustomer=4002/);
await page.getByTestId("invoicing-period-review-state").selectOption("ready");
await expect(page).toHaveURL(/periodReviewState=ready/);
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-4002")).toHaveCount(0);
await page.getByRole("button", { name: /Ryd filtre|Clear filters/i }).click();
await expect(page).not.toHaveURL(/periodReviewState=/);
await expect(page.getByTestId("invoicing-period-customer-4002")).toBeVisible();
await page.setViewportSize({ width: 820, height: 1180 });
const tabletDetailBox = await page.getByTestId("invoicing-period-review-detail").boundingBox();
const tabletQueueBox = await page.locator(".period-review-queue").boundingBox();
expect(tabletDetailBox).not.toBeNull();
expect(tabletQueueBox).not.toBeNull();
expect(tabletDetailBox.x).toBeGreaterThan(tabletQueueBox.x);
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
.toBe(true);
await page.setViewportSize({ width: 390, height: 844 });
const detailBox = await page.getByTestId("invoicing-period-review-detail").boundingBox();
const queueBox = await page.locator(".period-review-queue").boundingBox();
expect(detailBox).not.toBeNull();
expect(queueBox).not.toBeNull();
expect(detailBox.y).toBeLessThan(queueBox.y);
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
.toBe(true);
});
test("@smoke period view opens Selvvask import and attaching view", async ({ page }) => {
const usageOrderRequests = [];
const fastLinkRequests = [];
@@ -1464,7 +1529,24 @@ test.describe("Invoicing period tab", () => {
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toBeVisible();
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (0/2)");
await expect(page.getByTestId("invoicing-period-view-selector-progress-self_wash")).toBeVisible();
const selfWashProgress = page.getByTestId("invoicing-period-view-selector-progress-self_wash");
const selfWashProgressMetrics = await selfWashProgress.evaluate((element) => {
const rect = element.getBoundingClientRect();
const slot = element.parentElement?.getBoundingClientRect();
const controls = element.parentElement?.parentElement?.getBoundingClientRect();
return {
width: rect.width,
height: rect.height,
slotWidth: slot?.width || 0,
controlsWidth: controls?.width || 0,
};
});
expect(
selfWashProgressMetrics.width,
`self-wash selector progress geometry: ${JSON.stringify(selfWashProgressMetrics)}`
).toBeGreaterThanOrEqual(56);
expect(selfWashProgressMetrics.height).toBeGreaterThan(0);
await expect(selfWashProgress).toBeVisible();
await page.getByTestId("invoicing-period-view-selector-self_wash").click();
await expect(page.getByTestId("invoicing-period-self-wash-view")).toBeVisible();
@@ -1782,13 +1864,15 @@ test.describe("Invoicing period tab", () => {
test("@smoke period object tree aligns row actions and visible fields", async ({ page }) => {
await page.setViewportSize({ width: 1900, height: 900 });
await openPeriodView(page, { payloadFactory: createObjectTreePeriodPayload });
await routeObjectTreeOrderEndpoints(page);
await openPeriodView(page, {
payloadFactory: createObjectTreePeriodPayload,
beforeGoto: routeObjectTreeOrderEndpoints,
});
await page.getByTestId("invoicing-period-view-selector-all").click();
const customerRow = page.getByTestId("invoicing-period-customer-4101");
await expect(customerRow).toBeVisible();
await customerRow.getByText("Object Tree Logistics").click();
await expect(page.getByTestId("invoicing-period-customer-select-4101")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("invoice-period-object-tree")).toBeVisible();
await expandTreeNode(page, "collected_order_invoice:3001");
@@ -2099,6 +2183,7 @@ test.describe("Invoicing period tab", () => {
await setEntireMarchPeriod(page);
await selectPeriodView(page, "all");
await expandPeriodAnalytics(page);
const combinedTable = page.getByTestId("department-75-combined-distribution-table");
await expect(combinedTable).toBeVisible();
@@ -2132,16 +2217,28 @@ test.describe("Invoicing period tab", () => {
const tableContainer = document.querySelector(
'[data-testid="department-75-combined-distribution-table-container"]'
);
const selector = document.querySelector('[data-testid="invoicing-period-view-selector-all"]');
if (!tableContainer || !selector) {
const navigation = document.querySelector('[data-testid="invoicing-period-view-selectors"]');
if (!tableContainer || !navigation) {
return false;
}
const tableContainerRect = tableContainer.getBoundingClientRect();
const selectorRect = selector.getBoundingClientRect();
const navigationRect = navigation.getBoundingClientRect();
const selectorRects = Array.from(navigation.querySelectorAll(".is-selector-view")).map((selector) =>
selector.getBoundingClientRect()
);
return (
document.documentElement.scrollWidth - document.documentElement.clientWidth <= 2 &&
navigation.scrollWidth - navigation.clientWidth <= 2 &&
tableContainer.scrollWidth - tableContainer.clientWidth <= 2 &&
tableContainerRect.right <= selectorRect.left - 8
tableContainerRect.top >= navigationRect.bottom - 1 &&
selectorRects.every(
(rect) =>
rect.left >= navigationRect.left - 1 &&
rect.right <= navigationRect.right + 1 &&
rect.left >= -1 &&
rect.right <= window.innerWidth + 1
)
);
})
)
@@ -2154,6 +2251,7 @@ test.describe("Invoicing period tab", () => {
await setEntireMarchPeriod(page);
await selectPeriodView(page, "vehicle_subscriptions");
await expandPeriodAnalytics(page);
const subscriptionTable = page.locator("table").filter({ hasText: "Abonnementspris" });
await expect(subscriptionTable).toBeVisible();
@@ -2166,6 +2264,7 @@ test.describe("Invoicing period tab", () => {
await setEntireMarchPeriod(page);
await selectPeriodView(page, "fixed_pricing");
await expandPeriodAnalytics(page);
const fixedPricingTable = page.locator("table").filter({ hasText: "Original pris" });
await expect(fixedPricingTable).toBeVisible();
@@ -2501,14 +2600,7 @@ test.describe("Invoicing period tab", () => {
await page.getByTestId("invoicing-period-view-selector-all").click();
const visibleCustomerOrder = () =>
page
.locator(
[
"[data-testid^='invoicing-period-customer-']",
":not([data-testid^='invoicing-period-customer-invoice-'])",
":not([data-testid^='invoicing-period-customer-queue-'])",
":not([data-testid^='invoicing-period-customer-draft-'])",
].join("")
)
.locator(".period-customer-card[data-testid^='invoicing-period-customer-']")
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid")?.replace("invoicing-period-customer-", ""))
);
@@ -2586,6 +2678,9 @@ test.describe("Invoicing period tab", () => {
await fillPeriodDate(page, "date-period-start", "2026-04-02");
await expect(page.getByTestId("invoicing-period-view-selector-all").locator("progress").first()).toBeVisible();
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth))
.toBeLessThanOrEqual(1);
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
releaseDateRefresh?.();
await fillPeriodDate(page, "date-period-end", "2026-04-30");
@@ -2778,10 +2873,10 @@ test.describe("Invoicing period tab", () => {
await page.getByTestId("invoicing-period-flag-tabs").getByText("Filters").first().click();
await expect(page.getByTestId("invoicing-period-limit-select")).toHaveValue("100");
await expect.poll(() => periodRequests.some((request) => request.limit === "100")).toBeTruthy();
const statistics = page.getByTestId("invoicing-period-statistics");
await expect(statistics.getByTestId("invoicing-period-stat-total")).toContainText("9.000,00");
await expect(statistics.getByTestId("invoicing-period-stat-booked")).toContainText("6.000,00");
await expect(statistics.getByTestId("invoicing-period-stat-not-booked")).toContainText("3.000,00");
const statistics = page.getByTestId("invoicing-period-headline-statistics");
await expect(statistics.getByTestId("invoicing-period-headline-total")).toContainText("9.000,00");
await expect(statistics.getByTestId("invoicing-period-headline-booked")).toContainText("6.000,00");
await expect(statistics.getByTestId("invoicing-period-headline-not-booked")).toContainText("3.000,00");
const periodPagination = page.getByTestId("invoicing-period-pagination-navigation");
await periodPagination.locator(".pagination-next").click();
@@ -507,6 +507,62 @@ describe("InvoicingPeriodObjectTree", () => {
expect(wrapper.text()).toContain("E-conomic faktura #99");
});
it("exposes summary-only booked invoices when no transaction or normalized details are available", async () => {
mocks.economicDetails.mockRejectedValueOnce(new Error("Details unavailable"));
const wrapper = mountTree({
customer: {
customer_number: 2001,
customer_name: "ACME",
invoice_collections: [
{
id: 4401,
state: "booked",
booked_invoice_id: 551,
total_net_amount: 875,
},
],
draft: { has_valid_draft: false, invoice_collection_ids: [] },
},
transactions: [],
});
await expandNode(wrapper, "collected_order_invoice:4401");
expect(wrapper.find('[data-node-key="category:4401:collection_economic"]').exists()).toBe(true);
await expandNode(wrapper, "category:4401:collection_economic");
const bookedInvoiceNode = wrapper.find('[data-node-key="economic_invoice:4401:booked:551"]');
expect(bookedInvoiceNode.exists()).toBe(true);
await bookedInvoiceNode.find(".b-tree-node-content").trigger("click");
await flushPromises();
expect(mocks.economicPdf).toHaveBeenCalledWith(4401, "booked");
});
it("exposes summary-only draft invoices from their identifier and state", async () => {
mocks.economicDetails.mockResolvedValueOnce({ data: { data: {} } });
const wrapper = mountTree({
customer: {
customer_number: 2001,
customer_name: "ACME",
invoice_collections: [
{
id: 4402,
state: "draft",
draft_invoice_id: 552,
total_net_amount: 875,
},
],
draft: { has_valid_draft: false, invoice_collection_ids: [] },
},
transactions: [],
});
await expandNode(wrapper, "collected_order_invoice:4402");
await expandNode(wrapper, "category:4402:collection_economic");
expect(wrapper.find('[data-node-key="economic_invoice:4402:draft:552"]').exists()).toBe(true);
});
it("keeps a persisted booked invoice downloadable when normalized booked details are unavailable", async () => {
mocks.economicDetails.mockResolvedValueOnce({
data: {
@@ -311,6 +311,12 @@ describe("Invoicing period queue-driven refresh", () => {
flagTab: "all",
includeRequiresAction: 1,
includeBooked: 1,
reviewState: "",
severity: "",
invoiceState: "",
departmentId: "",
sort: "priority",
direction: "asc",
},
]);
expect(sharedVariablesRef.value.types.all.map((customer) => customer.customer_number)).toEqual([1001, 1002, 1003]);
@@ -377,6 +383,12 @@ describe("Invoicing period queue-driven refresh", () => {
flagTab: "all",
includeRequiresAction: 1,
includeBooked: 1,
reviewState: "",
severity: "",
invoiceState: "",
departmentId: "",
sort: "priority",
direction: "asc",
},
]);
expect(wrapper.get("[data-testid='invoicing-period-view-selector-all']").text()).toContain("All (37/40)");
@@ -433,6 +445,12 @@ describe("Invoicing period queue-driven refresh", () => {
flagTab: "all",
includeRequiresAction: 1,
includeBooked: 1,
reviewState: "",
severity: "",
invoiceState: "",
departmentId: "",
sort: "priority",
direction: "asc",
periodWarm: 1,
});
expect(requestedParams).toContainEqual({
@@ -334,6 +334,214 @@ describe("Invoicing period queue state", () => {
expect(wrapper.find("[data-testid='invoicing-period-customer-invoice-1001']").exists()).toBe(false);
});
it("preserves a routed customer selection until the initial period data arrives", async () => {
sharedVariablesRef.value = null;
periodPaging.selectedCustomerNumber = 1002;
const wrapper = mountView();
await nextTick();
expect(periodPaging.selectedCustomerNumber).toBe(1002);
expect(wrapper.find("[data-testid='invoicing-period-review-detail']").exists()).toBe(false);
sharedVariablesRef.value = {
types: {
all: [
{
id: 2,
customer_number: 1002,
customer_name: "Routed Customer",
requires_action: true,
transactions: [],
},
],
},
};
await nextTick();
expect(periodPaging.selectedCustomerNumber).toBe(1002);
expect(wrapper.get("[data-testid='invoicing-period-review-detail'] h2").text()).toBe("Routed Customer");
});
it("keeps retained refresh errors visible for empty and filtered-empty results", async () => {
sharedVariablesRef.value = { types: { all: [] } };
periodPaging.loadError = true;
periodPaging.loadErrorMessage = "Refresh failed";
const wrapper = mountView();
await nextTick();
expect(wrapper.find("[data-testid='invoicing-period-retained-error']").exists()).toBe(true);
expect(wrapper.find("[data-testid='invoicing-period-true-empty']").exists()).toBe(true);
sharedVariablesRef.value = {
types: {
all: [
{
id: 3,
customer_number: 1003,
customer_name: "Ready Customer",
requires_action: true,
review: { state: "ready", severity: "green", reasons: [], next_action: "create_invoice" },
transactions: [],
},
],
},
};
periodPaging.reviewState = "blocked";
await nextTick();
expect(wrapper.find("[data-testid='invoicing-period-retained-error']").exists()).toBe(true);
expect(wrapper.find("[data-testid='invoicing-period-filtered-empty']").exists()).toBe(true);
});
it("lets local queue state override stale review state while a refresh is pending", async () => {
queueRef.value = [3001];
periodPaging.reviewState = "queued";
sharedVariablesRef.value = {
types: {
all: [
{
id: 3,
customer_number: 1003,
customer_name: "Locally Queued Customer",
requires_action: false,
review: {
state: "completed",
severity: "green",
reasons: [],
next_action: "none",
is_actionable: false,
},
transactions: [
{
id: 6001,
amount: 75,
booked: false,
excluded: false,
invoice_collection_id: 3001,
date: "2026-04-13T10:00:00.000Z",
},
],
},
],
},
};
const wrapper = mountView();
await nextTick();
const customer = wrapper.get("[data-testid='invoicing-period-customer-1003']");
expect(customer.get(".period-customer-review").classes()).toContain("review-state--blue");
expect(customer.get(".period-customer-review").text()).toContain("I kø");
expect(wrapper.find("[data-testid='invoicing-period-filtered-empty']").exists()).toBe(false);
});
it.each([
{
state: "blocked",
severity: "red",
reason: "manual_flags",
nextAction: "resolve_manual_flags",
expectedClass: "review-state--red",
expectedLabel: "Blokeret",
},
{
state: "attention",
severity: "yellow",
reason: "automatic_flags",
nextAction: "review_warnings",
expectedClass: "review-state--yellow",
expectedLabel: "Kræver opmærksomhed",
},
])(
"preserves $state review priority while a local export is queued",
async ({ state, severity, reason, nextAction, expectedClass, expectedLabel }) => {
queueRef.value = [3001];
sharedVariablesRef.value = {
types: {
all: [
{
id: 3,
customer_number: 1003,
customer_name: "Blocked Customer",
requires_action: true,
review: {
state,
severity,
reasons: [{ code: reason, count: 1 }],
next_action: nextAction,
is_actionable: false,
},
transactions: [
{
id: 6001,
amount: 75,
booked: false,
excluded: false,
invoice_collection_id: 3001,
date: "2026-04-13T10:00:00.000Z",
},
],
},
],
},
};
const wrapper = mountView();
await nextTick();
const customer = wrapper.get("[data-testid='invoicing-period-customer-1003']");
expect(customer.get(".period-customer-review").classes()).toContain(expectedClass);
expect(customer.get(".period-customer-review").text()).toContain(expectedLabel);
expect(customer.get(".period-customer-review").text()).not.toContain("I kø");
}
);
it("moves from a completed selection to the first reviewable customer", async () => {
periodPaging.selectedCustomerNumber = 1001;
sharedVariablesRef.value = {
types: {
all: [
{
id: 1,
customer_number: 1001,
customer_name: "Completed Customer",
requires_action: false,
review: { state: "completed", severity: "green", reasons: [], next_action: "none" },
transactions: [],
},
{
id: 2,
customer_number: 1002,
customer_name: "First Reviewable Customer",
requires_action: true,
review: { state: "ready", severity: "green", reasons: [], next_action: "create_invoice" },
transactions: [],
},
{
id: 3,
customer_number: 1003,
customer_name: "Second Reviewable Customer",
requires_action: true,
review: { state: "attention", severity: "yellow", reasons: [], next_action: "review_warnings" },
transactions: [],
},
],
},
};
const wrapper = mountView();
await nextTick();
expect(wrapper.get("[data-testid='invoicing-period-review-detail'] h2").text()).toBe("Completed Customer");
await wrapper.findAll(".period-review-detail__navigation button")[1].trigger("click");
await nextTick();
expect(periodPaging.selectedCustomerNumber).toBe(1002);
expect(wrapper.get("[data-testid='invoicing-period-review-detail'] h2").text()).toBe("First Reviewable Customer");
});
it("debounces period search input and resets pagination to the first page", async () => {
vi.useFakeTimers();
try {
@@ -433,9 +641,7 @@ describe("Invoicing period queue state", () => {
const wrapper = mountView();
await nextTick();
await wrapper
.get("[data-testid='invoicing-period-customer-1001'] .columns.is-vcentered.is-clickable")
.trigger("click");
await wrapper.get("[data-testid='invoicing-period-customer-select-1001']").trigger("click");
await nextTick();
expect(sharedVariablesRef.value.types.all[0].expanded).toBe(true);
@@ -445,9 +651,7 @@ describe("Invoicing period queue state", () => {
false
);
await wrapper
.get("[data-testid='invoicing-period-customer-1002'] .columns.is-vcentered.is-clickable")
.trigger("click");
await wrapper.get("[data-testid='invoicing-period-customer-select-1002']").trigger("click");
await nextTick();
expect(sharedVariablesRef.value.types.all[0].expanded).toBe(false);
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { buildPeriodCacheKey } from "../../src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js";
import { deriveLegacyPeriodCustomerReview } from "../../src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodReview.js";
const readProjectFile = (path) => readFileSync(fileURLToPath(new URL(`../../${path}`, import.meta.url)), "utf8");
describe("invoicing period review workspace", () => {
it("scopes cached pages by every server-side review and sorting input", () => {
const base = {
dateFrom: "2026-08-01",
dateTo: "2026-08-31",
periodView: "all",
page: 1,
limit: 100,
search: "",
flagTab: "all",
includeRequiresAction: true,
includeBooked: true,
reviewState: "blocked",
severity: "red",
invoiceState: "draft",
departmentId: "7",
sort: "priority",
direction: "asc",
};
expect(buildPeriodCacheKey(base)).not.toBe(buildPeriodCacheKey({ ...base, reviewState: "ready" }));
expect(buildPeriodCacheKey(base)).not.toBe(buildPeriodCacheKey({ ...base, sort: "total_amount" }));
expect(buildPeriodCacheKey(base)).not.toBe(buildPeriodCacheKey({ ...base, direction: "desc" }));
});
it("keeps selected-customer restoration route-only while review inputs reload the server page", () => {
const source = readProjectFile(
"src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/Right.vue"
);
expect(source).toContain("reviewState: periodPaging.reviewState");
expect(source).toContain("departmentId: periodPaging.departmentId");
expect(source).toContain("sort: periodPaging.sort");
expect(source).toContain("() => periodPaging.selectedCustomerNumber");
expect(source).toContain("syncPeriodRouteQuery();");
expect(source).not.toMatch(/periodPaging\.direction,\s*periodPaging\.selectedCustomerNumber/);
});
it("exposes a dedicated keyboard selection control, explicit states, and two-pane review semantics", () => {
const source = readProjectFile(
"src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue"
);
expect(source).toContain('class="period-review-workspace"');
expect(source).toContain('role="group"');
expect(source).toContain('data-testid="`invoicing-period-customer-select-${customer.customer_number}`"');
expect(source).toContain(':aria-pressed="selectedCustomer === customer"');
expect(source).not.toContain('@keydown.enter.prevent="onClickCustomer(customer)"');
expect(source).toContain('data-testid="invoicing-period-initial-loading"');
expect(source).toContain('data-testid="invoicing-period-filtered-empty"');
expect(source).toContain('data-testid="invoicing-period-retained-error"');
});
it.each([
{
name: "manual flags take blocking precedence",
customer: { invoice_collections: [{ error_message: "failed" }], transactions: [] },
flags: { manualFlags: 2 },
expected: ["blocked", "resolve_manual_flags", ["manual_flags", "collection_errors"]],
},
{
name: "collection errors block",
customer: { invoice_collections: [{ error_message: "failed" }], transactions: [] },
flags: {},
expected: ["blocked", "resolve_collection_errors", ["collection_errors"]],
},
{
name: "drafts block with the backend reason code",
customer: { draft: { is_action_blocked: true }, transactions: [] },
flags: {},
expected: ["blocked", "resolve_draft", ["draft_blocks_action"]],
},
{
name: "requires action only means attention when no transaction is unbooked",
customer: { requires_action: true, transactions: [] },
flags: {},
expected: ["attention", "review_warnings", ["requires_action"]],
},
{
name: "automatic warnings outrank an active export",
customer: { queue: { has_active_job: true, invoice_collection_ids: [1] }, transactions: [] },
flags: { automaticFlags: 1 },
expected: ["attention", "review_warnings", ["automatic_warnings", "export_in_progress"]],
},
{
name: "an active export queues otherwise ready work",
customer: {
queue: { has_active_job: true, invoice_collection_ids: [1, 2] },
transactions: [{ booked: false, excluded: false }],
},
flags: {},
expected: ["queued", "wait_for_export", ["export_in_progress", "unbooked_transactions"]],
},
{
name: "only booked work is completed",
customer: { transactions: [{ booked: true, excluded: false }] },
flags: {},
expected: ["completed", "none", []],
},
{
name: "unbooked work remains ready even when requires action is set",
customer: { requires_action: true, transactions: [{ booked: false, excluded: false }] },
flags: {},
expected: ["ready", "create_invoice", ["unbooked_transactions"]],
},
{
name: "an empty customer is ready rather than completed",
customer: { transactions: [] },
flags: {},
expected: ["ready", "create_invoice", []],
},
])("matches the backend fallback matrix: $name", ({ customer, flags, expected }) => {
const review = deriveLegacyPeriodCustomerReview(customer, flags);
expect([review.state, review.next_action, review.reasons.map((reason) => reason.code)]).toEqual(expected);
});
it("uses durable backend queue jobs and keeps browser enqueueing only as a legacy fallback", () => {
const source = readProjectFile(
"src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue"
);
expect(source).toContain("const wasDurablyQueuedByServer");
expect(source).toContain("applied?.queue_job_ids ?? applied?.result?.queue_job_ids");
expect(source).toMatch(/if \(!wasDurablyQueuedByServer\) \{[\s\S]*addInvoiceCollectionsToQueue/);
});
});
@@ -122,6 +122,42 @@ describe("invoicing period tree node builders", () => {
expect(makeOrderNode({ id: 91, amount: 240, total_net_amount: 0 }).meta.totalNetAmount).toBe(240);
});
it("keeps collection-summary roots even when no transaction from the page references them", () => {
const nodes = buildCollectionRootNodes(
{
customer_number: 2001,
customer_name: "ACME",
invoice_collections: [
{
id: 4401,
state: "error",
error_message: "Missing debtor",
booked_invoice_id: 551,
total_net_amount: 875,
},
],
},
[],
[],
{ collection: (id) => `Collection ${id}` }
);
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
id: "collected_order_invoice:4401",
meta: {
collectionId: 4401,
orderCount: 0,
totalNetAmount: 875,
invoiceState: "error",
collectionSummary: {
error_message: "Missing debtor",
booked_invoice_id: 551,
},
},
});
});
it("creates non-selectable but checkable category branch nodes for bulk selection", () => {
const node = makeCategoryNode({
id: "category:orders",
+6 -4
View File
@@ -590,7 +590,9 @@ describe("Periode tab contract", () => {
expect(periodViewAllSource).toContain('class="tag is-warning is-light is-small"');
expect(periodViewAllSource).toContain('aria-disabled="true"');
expect(periodViewAllSource).toContain("fa-file-alt");
expect(periodViewAllSource).not.toContain('class="button is-small is-warning is-light"');
expect(periodViewAllSource).toContain(
':data-testid="`invoicing-period-customer-draft-${customer.customer_number}`"'
);
});
it("scopes customer flag indicators to flags visible in the current customer card", () => {
@@ -610,7 +612,7 @@ describe("Periode tab contract", () => {
expect(periodViewAllSource).toContain("!isOrderLineFlag(flag) && !isOrderItemLineFlag(flag)");
expect(periodViewAllSource).toContain(':flags="getCustomerCardFlags(customer)"');
expect(periodViewAllSource).not.toContain("getCustomerExpandedFlags");
expect(periodViewAllSource).toContain(':invoice-period-flags="getCustomerScopedFlags(customer)"');
expect(periodViewAllSource).toContain(':invoice-period-flags="getCustomerScopedFlags(selectedCustomer)"');
expect(periodViewAllSource).toContain('targetType === "customer"');
expect(periodViewAllSource).toContain('targetType === "collected_order_invoice"');
});
@@ -632,8 +634,8 @@ describe("Periode tab contract", () => {
'import InvoicingPeriodObjectTree from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue";'
);
expect(periodViewAllSource).toContain("<InvoicingPeriodObjectTree");
expect(periodViewAllSource).toContain(':transactions="getTransactionsInView(customer)"');
expect(periodViewAllSource).toContain(':excluded-order-ids="getExcludedTransactionIds(customer)"');
expect(periodViewAllSource).toContain(':transactions="getTransactionsInView(selectedCustomer)"');
expect(periodViewAllSource).toContain(':excluded-order-ids="getExcludedTransactionIds(selectedCustomer)"');
expect(periodViewAllSource).toContain('@refresh="reloadPeriodPage"');
expect(periodViewAllSource).toContain("<InvoiceOrdersPagination");
expect(periodViewAllSource).toContain("duplicate-comparison-row");