release: v2.2.56 email layout + supplier invoice stabilization

This commit is contained in:
Christian 2026-03-18 07:14:28 +01:00
parent 074ab6a62a
commit eb5e14e2a1
4 changed files with 157 additions and 18 deletions

22
RELEASE_NOTES_v2.2.56.md Normal file
View File

@ -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

View File

@ -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")

View File

@ -173,6 +173,11 @@
<i class="bi bi-calendar-check me-2"></i>Til Betaling
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="ready-tab" data-bs-toggle="tab" href="#ready-content" onclick="switchToReadyTab()">
<i class="bi bi-check-circle me-2"></i>Klar til Bogføring
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="lines-tab" data-bs-toggle="tab" href="#lines-content" onclick="switchToLinesTab()">
<i class="bi bi-list-ul me-2"></i>Varelinjer
@ -248,7 +253,7 @@
<strong><span id="selectedKassekladdeCount">0</span> fakturaer valgt</strong>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-success" onclick="bulkSendToEconomic()" title="Send til e-conomic kassekladde">
<button type="button" class="btn btn-sm btn-success" onclick="bulkSendToEconomicKassekladde()" title="Send til e-conomic kassekladde">
<i class="bi bi-send me-1"></i>Send til e-conomic
</button>
</div>
@ -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() {
<button class="btn btn-sm btn-outline-primary" onclick="viewInvoiceDetails(${invoice.id})" title="Se/Rediger detaljer">
<i class="bi bi-pencil-square"></i>
</button>
<button class="btn btn-sm btn-primary" onclick="sendToEconomic(${invoice.id})" title="Send til e-conomic">
<button class="btn btn-sm btn-primary" onclick="sendToEconomicById(${invoice.id})" title="Send til e-conomic">
<i class="bi bi-send"></i>
</button>
</td>
@ -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;

View File

@ -12,6 +12,10 @@
overflow: hidden;
}
.email-container > * {
min-width: 0;
}
/* Left Sidebar - Email List (25%) */
.email-list-sidebar {
flex: 0 0 400px;
@ -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 ? `
<button onclick="previewAttachment(${email.id}, ${att.id}, '${escapeHtml(att.filename)}', '${att.content_type}')"
class="btn btn-sm btn-outline-primary" title="Se ${att.filename}">
class="btn btn-sm btn-outline-primary attachment-chip" title="Se ${att.filename}">
<i class="bi bi-eye me-1"></i>${att.filename}
</button>
` : `
<a href="/api/v1/emails/${email.id}/attachments/${att.id}"
class="btn btn-sm btn-outline-secondary"
class="btn btn-sm btn-outline-secondary attachment-chip"
download="${att.filename}"
title="Download ${att.filename}">
<i class="bi bi-download me-1"></i>${att.filename}