release: v2.2.56 email layout + supplier invoice stabilization
This commit is contained in:
parent
074ab6a62a
commit
eb5e14e2a1
22
RELEASE_NOTES_v2.2.56.md
Normal file
22
RELEASE_NOTES_v2.2.56.md
Normal 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
|
||||||
@ -1703,6 +1703,10 @@ async def delete_supplier_invoice(invoice_id: int):
|
|||||||
class ApproveRequest(BaseModel):
|
class ApproveRequest(BaseModel):
|
||||||
approved_by: str
|
approved_by: str
|
||||||
|
|
||||||
|
|
||||||
|
class MarkPaidRequest(BaseModel):
|
||||||
|
paid_date: Optional[date] = None
|
||||||
|
|
||||||
@router.post("/supplier-invoices/{invoice_id}/approve")
|
@router.post("/supplier-invoices/{invoice_id}/approve")
|
||||||
async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
|
async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
|
||||||
"""Approve supplier invoice for payment"""
|
"""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))
|
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")
|
@router.post("/supplier-invoices/{invoice_id}/send-to-economic")
|
||||||
async def send_to_economic(invoice_id: int):
|
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):
|
async def send_invoice_to_economic(invoice_id: int):
|
||||||
"""Send supplier invoice to e-conomic - requires separate implementation"""
|
"""Send supplier invoice to e-conomic - requires separate implementation"""
|
||||||
raise HTTPException(status_code=501, detail="e-conomic integration kommer senere")
|
raise HTTPException(status_code=501, detail="e-conomic integration kommer senere")
|
||||||
|
|||||||
@ -173,6 +173,11 @@
|
|||||||
<i class="bi bi-calendar-check me-2"></i>Til Betaling
|
<i class="bi bi-calendar-check me-2"></i>Til Betaling
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a class="nav-link" id="lines-tab" data-bs-toggle="tab" href="#lines-content" onclick="switchToLinesTab()">
|
<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
|
<i class="bi bi-list-ul me-2"></i>Varelinjer
|
||||||
@ -248,7 +253,7 @@
|
|||||||
<strong><span id="selectedKassekladdeCount">0</span> fakturaer valgt</strong>
|
<strong><span id="selectedKassekladdeCount">0</span> fakturaer valgt</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group" role="group">
|
<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
|
<i class="bi bi-send me-1"></i>Send til e-conomic
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -1392,7 +1397,7 @@ async function markSingleAsPaid(invoiceId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to send single invoice to e-conomic
|
// 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;
|
if (!confirm('Send denne faktura til e-conomic?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -1680,7 +1685,7 @@ async function loadReadyForBookingView() {
|
|||||||
<button class="btn btn-sm btn-outline-primary" onclick="viewInvoiceDetails(${invoice.id})" title="Se/Rediger detaljer">
|
<button class="btn btn-sm btn-outline-primary" onclick="viewInvoiceDetails(${invoice.id})" title="Se/Rediger detaljer">
|
||||||
<i class="bi bi-pencil-square"></i>
|
<i class="bi bi-pencil-square"></i>
|
||||||
</button>
|
</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>
|
<i class="bi bi-send"></i>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@ -4051,12 +4056,11 @@ async function bulkMarkAsPaid() {
|
|||||||
|
|
||||||
for (const invoiceId of invoiceIds) {
|
for (const invoiceId of invoiceIds) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, {
|
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
|
||||||
method: 'PATCH',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
status: 'paid',
|
paid_date: new Date().toISOString().split('T')[0]
|
||||||
payment_date: new Date().toISOString().split('T')[0]
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -4087,12 +4091,11 @@ async function markInvoiceAsPaid(invoiceId) {
|
|||||||
if (!confirm('Marker denne faktura som betalt?')) return;
|
if (!confirm('Marker denne faktura som betalt?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, {
|
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
|
||||||
method: 'PATCH',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
status: 'paid',
|
paid_date: new Date().toISOString().split('T')[0]
|
||||||
payment_date: new Date().toISOString().split('T')[0]
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -4557,7 +4560,7 @@ async function approveInvoice() {
|
|||||||
const response = await fetch(`/api/v1/supplier-invoices/${currentInvoiceId}/approve`, {
|
const response = await fetch(`/api/v1/supplier-invoices/${currentInvoiceId}/approve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ approved_by: 'CurrentUser' }) // TODO: Get from auth
|
body: JSON.stringify({ approved_by: getApprovalUser() })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@ -4610,7 +4613,7 @@ async function quickApprove(invoiceId) {
|
|||||||
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/approve`, {
|
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/approve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ approved_by: 'CurrentUser' })
|
body: JSON.stringify({ approved_by: getApprovalUser() })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@ -4955,7 +4958,7 @@ async function createTemplateFromInvoice(invoiceId, vendorId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: AI analyze
|
// 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',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -5117,7 +5120,7 @@ async function sendSingleToEconomic(invoiceId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bulk send selected invoices to e-conomic
|
// Bulk send selected invoices to e-conomic
|
||||||
async function bulkSendToEconomic() {
|
async function bulkSendToEconomicKassekladde() {
|
||||||
const checkboxes = document.querySelectorAll('.kassekladde-checkbox:checked');
|
const checkboxes = document.querySelectorAll('.kassekladde-checkbox:checked');
|
||||||
const invoiceIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.invoiceId));
|
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)
|
// Select vendor for file (when <100% match)
|
||||||
async function selectVendorForFile(fileId, vendorId) {
|
async function selectVendorForFile(fileId, vendorId) {
|
||||||
if (!vendorId) return;
|
if (!vendorId) return;
|
||||||
|
|||||||
@ -11,6 +11,10 @@
|
|||||||
height: calc(100vh - 140px);
|
height: calc(100vh - 140px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.email-container > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Left Sidebar - Email List (25%) */
|
/* Left Sidebar - Email List (25%) */
|
||||||
.email-list-sidebar {
|
.email-list-sidebar {
|
||||||
@ -210,6 +214,7 @@
|
|||||||
/* Center Pane - Email Content (50%) */
|
/* Center Pane - Email Content (50%) */
|
||||||
.email-content-pane {
|
.email-content-pane {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
@ -220,6 +225,7 @@
|
|||||||
.email-content-header {
|
.email-content-header {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-bottom: 1px solid rgba(0,0,0,0.1);
|
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-content-subject {
|
.email-content-subject {
|
||||||
@ -227,6 +233,8 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-content-meta {
|
.email-content-meta {
|
||||||
@ -234,17 +242,20 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender-info {
|
.sender-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender-details {
|
.sender-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender-name {
|
.sender-name {
|
||||||
@ -255,6 +266,8 @@
|
|||||||
.sender-email {
|
.sender-email {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-timestamp {
|
.email-timestamp {
|
||||||
@ -267,6 +280,14 @@
|
|||||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||||
background: var(--bg-body);
|
background: var(--bg-body);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-chip {
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-actions .btn-primary {
|
.email-actions .btn-primary {
|
||||||
@ -284,7 +305,25 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
line-height: 1.6;
|
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 {
|
.email-body iframe {
|
||||||
@ -307,6 +346,14 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
color: var(--text-primary);
|
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 {
|
.attachment-item:hover {
|
||||||
@ -342,6 +389,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-card {
|
.analysis-card {
|
||||||
@ -1813,12 +1861,12 @@ function renderEmailDetail(email) {
|
|||||||
return `
|
return `
|
||||||
${canPreview ? `
|
${canPreview ? `
|
||||||
<button onclick="previewAttachment(${email.id}, ${att.id}, '${escapeHtml(att.filename)}', '${att.content_type}')"
|
<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}
|
<i class="bi bi-eye me-1"></i>${att.filename}
|
||||||
</button>
|
</button>
|
||||||
` : `
|
` : `
|
||||||
<a href="/api/v1/emails/${email.id}/attachments/${att.id}"
|
<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}"
|
download="${att.filename}"
|
||||||
title="Download ${att.filename}">
|
title="Download ${att.filename}">
|
||||||
<i class="bi bi-download me-1"></i>${att.filename}
|
<i class="bi bi-download me-1"></i>${att.filename}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user