diff --git a/RELEASE_NOTES_v2.2.56.md b/RELEASE_NOTES_v2.2.56.md
new file mode 100644
index 0000000..7394c1d
--- /dev/null
+++ b/RELEASE_NOTES_v2.2.56.md
@@ -0,0 +1,22 @@
+# Release Notes v2.2.56
+
+Dato: 2026-03-18
+
+## Fokus
+Stabilisering af email-visning og hardening af supplier-invoices flows.
+
+## Aendringer
+- Rettet layout-overflow i email-detaljevisning, saa lange emner, afsenderadresser, HTML-indhold og filnavne ikke skubber kolonnerne ud af layoutet.
+- Tilfoejet robust wrapping/truncering i emails UI for bedre responsiv opfoersel.
+- Tilfoejet manglende "Klar til Bogforing" tab i supplier-invoices navigation.
+- Rettet endpoint mismatch for AI template-analyse i supplier-invoices frontend.
+- Fjernet JS-funktionskonflikter i supplier-invoices ved at adskille single/bulk send flows.
+- Tilfoejet backend endpoint til at markere supplier-invoices som betalt.
+- Fjernet route-konflikt for send-to-economic ved at flytte legacy placeholder til separat sti.
+- Forbedret approve-flow ved at bruge dynamisk brugeropslag i stedet for hardcoded vaerdi.
+
+## Berorte filer
+- app/emails/frontend/emails.html
+- app/billing/frontend/supplier_invoices.html
+- app/billing/backend/supplier_invoices.py
+- RELEASE_NOTES_v2.2.56.md
diff --git a/app/billing/backend/supplier_invoices.py b/app/billing/backend/supplier_invoices.py
index 2eed551..f92ef92 100644
--- a/app/billing/backend/supplier_invoices.py
+++ b/app/billing/backend/supplier_invoices.py
@@ -1703,6 +1703,10 @@ async def delete_supplier_invoice(invoice_id: int):
class ApproveRequest(BaseModel):
approved_by: str
+
+class MarkPaidRequest(BaseModel):
+ paid_date: Optional[date] = None
+
@router.post("/supplier-invoices/{invoice_id}/approve")
async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
"""Approve supplier invoice for payment"""
@@ -1735,6 +1739,58 @@ async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
raise HTTPException(status_code=500, detail=str(e))
+@router.post("/supplier-invoices/{invoice_id}/mark-paid")
+async def mark_supplier_invoice_paid(invoice_id: int, request: MarkPaidRequest):
+ """Mark supplier invoice as paid."""
+ try:
+ invoice = execute_query_single(
+ "SELECT id, invoice_number, status FROM supplier_invoices WHERE id = %s",
+ (invoice_id,)
+ )
+
+ if not invoice:
+ raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet")
+
+ if invoice['status'] == 'paid':
+ return {"success": True, "invoice_id": invoice_id, "status": "paid"}
+
+ if invoice['status'] not in ('approved', 'sent_to_economic'):
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"Faktura har status '{invoice['status']}' - "
+ "kun 'approved' eller 'sent_to_economic' kan markeres som betalt"
+ )
+ )
+
+ execute_update(
+ """UPDATE supplier_invoices
+ SET status = 'paid', updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s""",
+ (invoice_id,)
+ )
+
+ logger.info(
+ "✅ Marked supplier invoice %s (ID: %s) as paid (date: %s)",
+ invoice['invoice_number'],
+ invoice_id,
+ request.paid_date,
+ )
+
+ return {
+ "success": True,
+ "invoice_id": invoice_id,
+ "status": "paid",
+ "paid_date": request.paid_date,
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Failed to mark invoice {invoice_id} as paid: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@router.post("/supplier-invoices/{invoice_id}/send-to-economic")
async def send_to_economic(invoice_id: int):
"""
@@ -2204,7 +2260,7 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
-@router.post("/supplier-invoices/{invoice_id}/send-to-economic")
+@router.post("/supplier-invoices/{invoice_id}/send-to-economic-legacy-unimplemented")
async def send_invoice_to_economic(invoice_id: int):
"""Send supplier invoice to e-conomic - requires separate implementation"""
raise HTTPException(status_code=501, detail="e-conomic integration kommer senere")
diff --git a/app/billing/frontend/supplier_invoices.html b/app/billing/frontend/supplier_invoices.html
index 5337a71..a5e09f3 100644
--- a/app/billing/frontend/supplier_invoices.html
+++ b/app/billing/frontend/supplier_invoices.html
@@ -173,6 +173,11 @@
Til Betaling
+
+
+ Klar til Bogføring
+
+
Varelinjer
@@ -248,7 +253,7 @@
0 fakturaer valgt
-
@@ -1392,7 +1397,7 @@ async function markSingleAsPaid(invoiceId) {
}
// Helper function to send single invoice to e-conomic
-async function sendToEconomic(invoiceId) {
+async function sendToEconomicById(invoiceId) {
if (!confirm('Send denne faktura til e-conomic?')) return;
try {
@@ -1680,7 +1685,7 @@ async function loadReadyForBookingView() {
-
+
@@ -4051,12 +4056,11 @@ async function bulkMarkAsPaid() {
for (const invoiceId of invoiceIds) {
try {
- const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, {
- method: 'PATCH',
+ const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
+ method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
- status: 'paid',
- payment_date: new Date().toISOString().split('T')[0]
+ paid_date: new Date().toISOString().split('T')[0]
})
});
@@ -4087,12 +4091,11 @@ async function markInvoiceAsPaid(invoiceId) {
if (!confirm('Marker denne faktura som betalt?')) return;
try {
- const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, {
- method: 'PATCH',
+ const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
+ method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
- status: 'paid',
- payment_date: new Date().toISOString().split('T')[0]
+ paid_date: new Date().toISOString().split('T')[0]
})
});
@@ -4557,7 +4560,7 @@ async function approveInvoice() {
const response = await fetch(`/api/v1/supplier-invoices/${currentInvoiceId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ approved_by: 'CurrentUser' }) // TODO: Get from auth
+ body: JSON.stringify({ approved_by: getApprovalUser() })
});
if (response.ok) {
@@ -4610,7 +4613,7 @@ async function quickApprove(invoiceId) {
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ approved_by: 'CurrentUser' })
+ body: JSON.stringify({ approved_by: getApprovalUser() })
});
if (response.ok) {
@@ -4955,7 +4958,7 @@ async function createTemplateFromInvoice(invoiceId, vendorId) {
}
// Step 2: AI analyze
- const aiResp = await fetch('/api/v1/supplier-invoices/ai-analyze', {
+ const aiResp = await fetch('/api/v1/supplier-invoices/ai/analyze', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
@@ -5117,7 +5120,7 @@ async function sendSingleToEconomic(invoiceId) {
}
// Bulk send selected invoices to e-conomic
-async function bulkSendToEconomic() {
+async function bulkSendToEconomicKassekladde() {
const checkboxes = document.querySelectorAll('.kassekladde-checkbox:checked');
const invoiceIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.invoiceId));
@@ -5165,6 +5168,16 @@ async function bulkSendToEconomic() {
}
}
+function getApprovalUser() {
+ const bodyUser = document.body?.dataset?.currentUser;
+ if (bodyUser && bodyUser.trim()) return bodyUser.trim();
+
+ const metaUser = document.querySelector('meta[name="current-user"]')?.content;
+ if (metaUser && metaUser.trim()) return metaUser.trim();
+
+ return 'System';
+}
+
// Select vendor for file (when <100% match)
async function selectVendorForFile(fileId, vendorId) {
if (!vendorId) return;
diff --git a/app/emails/frontend/emails.html b/app/emails/frontend/emails.html
index 9da3bba..81e75c9 100644
--- a/app/emails/frontend/emails.html
+++ b/app/emails/frontend/emails.html
@@ -11,6 +11,10 @@
height: calc(100vh - 140px);
overflow: hidden;
}
+
+ .email-container > * {
+ min-width: 0;
+ }
/* Left Sidebar - Email List (25%) */
.email-list-sidebar {
@@ -210,6 +214,7 @@
/* Center Pane - Email Content (50%) */
.email-content-pane {
flex: 1;
+ min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg-card);
@@ -220,6 +225,7 @@
.email-content-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0,0,0,0.1);
+ min-width: 0;
}
.email-content-subject {
@@ -227,6 +233,8 @@
font-weight: 700;
color: var(--text-primary);
margin-bottom: 1rem;
+ overflow-wrap: anywhere;
+ word-break: break-word;
}
.email-content-meta {
@@ -234,17 +242,20 @@
align-items: center;
gap: 1rem;
flex-wrap: wrap;
+ min-width: 0;
}
.sender-info {
display: flex;
align-items: center;
gap: 0.75rem;
+ min-width: 0;
}
.sender-details {
display: flex;
flex-direction: column;
+ min-width: 0;
}
.sender-name {
@@ -255,6 +266,8 @@
.sender-email {
font-size: 0.8rem;
color: var(--text-secondary);
+ overflow-wrap: anywhere;
+ word-break: break-word;
}
.email-timestamp {
@@ -267,6 +280,14 @@
border-bottom: 1px solid rgba(0,0,0,0.05);
background: var(--bg-body);
flex-wrap: wrap;
+ overflow-x: hidden;
+ }
+
+ .attachment-chip {
+ max-width: 240px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.email-actions .btn-primary {
@@ -284,7 +305,25 @@
flex: 1;
padding: 1.5rem;
overflow-y: auto;
+ overflow-x: hidden;
line-height: 1.6;
+ min-width: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ }
+
+ .email-body pre,
+ .email-html-body,
+ .email-html-body * {
+ max-width: 100%;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ }
+
+ .email-html-body table {
+ display: block;
+ overflow-x: auto;
+ width: 100%;
}
.email-body iframe {
@@ -307,6 +346,14 @@
margin-bottom: 0.5rem;
transition: all 0.2s;
color: var(--text-primary);
+ min-width: 0;
+ }
+
+ .attachment-item .flex-grow-1 {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.attachment-item:hover {
@@ -342,6 +389,7 @@
flex-direction: column;
gap: 1rem;
overflow-y: auto;
+ min-width: 0;
}
.analysis-card {
@@ -1813,12 +1861,12 @@ function renderEmailDetail(email) {
return `
${canPreview ? `
+ class="btn btn-sm btn-outline-primary attachment-chip" title="Se ${att.filename}">
${att.filename}
` : `
${att.filename}