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:
BIN
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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" }
|
||||
} } }
|
||||
}
|
||||
+84
-10
@@ -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 {
|
||||
|
||||
+19
-11
@@ -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>
|
||||
|
||||
+226
-13
@@ -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>
|
||||
|
||||
+38
@@ -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("|");
|
||||
|
||||
+89
@@ -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,
|
||||
};
|
||||
};
|
||||
+13
-2
@@ -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,
|
||||
|
||||
+615
-76
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user