bmc_hub/app/billing/frontend/supplier_invoices.html
Christian dcb4d8a280 feat: Implement supplier invoices management with e-conomic integration
- Added FastAPI views for supplier invoices in the billing frontend.
- Created EconomicService for handling e-conomic API interactions, including safety modes for read-only and dry-run operations.
- Developed database migration for supplier invoices, including tables for invoices, line items, and settings.
- Documented kassekladde module features, architecture, API endpoints, and usage guide in KASSEKLADDE.md.
- Implemented views for overdue invoices and pending e-conomic sync.
2025-12-07 03:29:54 +01:00

1365 lines
53 KiB
HTML

<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Leverandørfakturaer (Kassekladde) - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
:root {
--bg-body: #f8f9fa;
--bg-card: #ffffff;
--text-primary: #2c3e50;
--text-secondary: #6c757d;
--accent: #0f4c75;
--accent-light: #eef2f5;
--border-radius: 12px;
--success: #28a745;
--warning: #ffc107;
--danger: #dc3545;
}
body {
background-color: var(--bg-body);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
padding-top: 80px;
}
.navbar {
background: var(--bg-card);
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
padding: 1rem 0;
border-bottom: 1px solid #eee;
}
.navbar-brand {
font-weight: 700;
color: var(--accent);
font-size: 1.25rem;
}
.nav-link {
color: var(--text-secondary);
padding: 0.6rem 1.2rem !important;
border-radius: var(--border-radius);
transition: all 0.2s;
font-weight: 500;
}
.nav-link:hover, .nav-link.active {
background-color: var(--accent-light);
color: var(--accent);
}
.card {
border: none;
border-radius: var(--border-radius);
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
transition: transform 0.2s;
background: var(--bg-card);
}
.stat-card {
text-align: center;
padding: 1.5rem;
}
.stat-card h3 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0;
}
.stat-card p {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 0;
}
.stat-card.overdue h3 { color: var(--danger); }
.stat-card.due-soon h3 { color: var(--warning); }
.stat-card.pending h3 { color: var(--accent); }
.stat-card.total h3 { color: var(--text-secondary); }
.table {
background: var(--bg-card);
border-radius: var(--border-radius);
}
.table th {
font-weight: 600;
color: var(--text-secondary);
border-bottom-width: 1px;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
background-color: var(--accent-light);
}
.table td {
vertical-align: middle;
}
.btn-primary {
background-color: var(--accent);
border-color: var(--accent);
padding: 0.6rem 1.5rem;
border-radius: 8px;
font-weight: 500;
}
.btn-primary:hover {
background-color: #0a3655;
border-color: #0a3655;
}
.badge {
padding: 0.4rem 0.8rem;
border-radius: 6px;
font-weight: 500;
}
.status-pending { background-color: #ffc107; color: #000; }
.status-approved { background-color: #17a2b8; color: #fff; }
.status-sent { background-color: var(--success); color: #fff; }
.status-paid { background-color: #6c757d; color: #fff; }
.status-overdue { background-color: var(--danger); color: #fff; }
.modal-content {
border: none;
border-radius: var(--border-radius);
}
.form-control, .form-select {
border-radius: 8px;
border: 1px solid #dee2e6;
padding: 0.6rem 1rem;
}
.form-control:focus, .form-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 0.2rem rgba(15, 76, 117, 0.15);
}
.line-item {
background: var(--accent-light);
padding: 1rem;
border-radius: 8px;
margin-bottom: 0.5rem;
}
.filter-pills {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.filter-pill {
padding: 0.5rem 1rem;
border-radius: 20px;
border: 1px solid #dee2e6;
background: var(--bg-card);
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
}
.filter-pill:hover, .filter-pill.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg fixed-top">
<div class="container-fluid px-4">
<a class="navbar-brand d-flex align-items-center" href="/dashboard">
<div class="bg-primary text-white rounded p-1 me-2 d-flex align-items-center justify-content-center" style="width: 32px; height: 32px; background-color: var(--accent) !important;">
<i class="bi bi-hdd-network-fill" style="font-size: 16px;"></i>
</div>
BMC Hub
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav mx-auto">
<li class="nav-item">
<a class="nav-link" href="/dashboard"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/customers"><i class="bi bi-people me-2"></i>Kunder</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/vendors"><i class="bi bi-truck me-2"></i>Leverandører</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Kassekladde</a>
</li>
</ul>
<div class="d-flex align-items-center">
<a href="/settings" class="btn btn-link text-secondary me-2">
<i class="bi bi-gear" style="font-size: 1.2rem;"></i>
</a>
<a href="/logout" class="btn btn-link text-secondary">
<i class="bi bi-box-arrow-right" style="font-size: 1.2rem;"></i>
</a>
</div>
</div>
</div>
</nav>
<!-- Main Content -->
<div class="container-fluid px-4 py-4">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">📋 Leverandørfakturaer</h2>
<p class="text-muted mb-0">Kassekladde - Integration med e-conomic</p>
</div>
<div>
<a href="/billing/templates" class="btn btn-outline-secondary me-2">
<i class="bi bi-grid-3x3 me-2"></i>Se Templates
</a>
<a href="/billing/template-builder" class="btn btn-outline-primary me-2">
<i class="bi bi-puzzle me-2"></i>Template Builder
</a>
<button class="btn btn-success" onclick="openUploadModal()">
<i class="bi bi-cloud-upload me-2"></i>Upload Faktura
</button>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4" id="statsCards">
<div class="col-md-3">
<div class="card stat-card overdue">
<h3 id="statOverdueCount">-</h3>
<p>Overskredet</p>
<small class="text-muted" id="statOverdueAmount">-</small>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card due-soon">
<h3 id="statDueSoonCount">-</h3>
<p>Forfald inden 7 dage</p>
<small class="text-muted" id="statDueSoonAmount">-</small>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card pending">
<h3 id="statPendingCount">-</h3>
<p>Afventer behandling</p>
<small class="text-muted" id="statPendingAmount">-</small>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card total">
<h3 id="statTotalCount">-</h3>
<p>Ubetalt i alt</p>
<small class="text-muted" id="statUnpaidAmount">-</small>
</div>
</div>
</div>
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<div class="filter-pills">
<div class="filter-pill active" data-filter="all" onclick="applyFilter('all', this)">
<i class="bi bi-list-ul me-1"></i>Alle
</div>
<div class="filter-pill" data-filter="pending" onclick="applyFilter('pending', this)">
<i class="bi bi-clock me-1"></i>Afventer
</div>
<div class="filter-pill" data-filter="approved" onclick="applyFilter('approved', this)">
<i class="bi bi-check-circle me-1"></i>Godkendt
</div>
<div class="filter-pill" data-filter="sent_to_economic" onclick="applyFilter('sent_to_economic', this)">
<i class="bi bi-send me-1"></i>Sendt til e-conomic
</div>
<div class="filter-pill" data-filter="overdue" onclick="applyFilter('overdue', this)">
<i class="bi bi-exclamation-triangle me-1"></i>Overskredet
</div>
</div>
</div>
</div>
<!-- Invoices Table -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Fakturanr.</th>
<th>Leverandør</th>
<th>Fakturadato</th>
<th>Forfaldsdato</th>
<th>Beløb</th>
<th>Status</th>
<th>e-conomic</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody id="invoicesTable">
<tr>
<td colspan="8" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Create/Edit Invoice Modal -->
<div class="modal fade" id="invoiceModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Ny Leverandørfaktura</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="invoiceForm">
<input type="hidden" id="invoiceId">
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Fakturanummer *</label>
<input type="text" class="form-control" id="invoiceNumber" required>
</div>
<div class="col-md-6">
<label class="form-label">Leverandør *</label>
<select class="form-select" id="vendorId" required>
<option value="">Vælg leverandør...</option>
</select>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Fakturadato *</label>
<input type="date" class="form-control" id="invoiceDate" required>
</div>
<div class="col-md-6">
<label class="form-label">Forfaldsdato</label>
<input type="date" class="form-control" id="dueDate">
</div>
</div>
<div class="row mb-3">
<div class="col-md-4">
<label class="form-label">Total beløb (inkl. moms) *</label>
<input type="number" step="0.01" class="form-control" id="totalAmount" required>
</div>
<div class="col-md-4">
<label class="form-label">Moms beløb</label>
<input type="number" step="0.01" class="form-control" id="vatAmount">
</div>
<div class="col-md-4">
<label class="form-label">Valuta</label>
<select class="form-select" id="currency">
<option value="DKK" selected>DKK</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="description" rows="3"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Noter (interne)</label>
<textarea class="form-control" id="notes" rows="2"></textarea>
</div>
<!-- Line Items -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">Linjer</label>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addLine()">
<i class="bi bi-plus-circle me-1"></i>Tilføj linje
</button>
</div>
<div id="lineItems">
<!-- Lines will be added dynamically -->
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveInvoice()">Gem</button>
</div>
</div>
</div>
</div>
<!-- View Invoice Details Modal -->
<div class="modal fade" id="viewInvoiceModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Fakturadetaljer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="invoiceDetails">
<!-- Details will be loaded dynamically -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-success" id="approveBtn" onclick="approveInvoice()">
<i class="bi bi-check-circle me-1"></i>Godkend
</button>
<button type="button" class="btn btn-primary" id="sendToEconomicBtn" onclick="sendToEconomic()">
<i class="bi bi-send me-1"></i>Send til e-conomic
</button>
</div>
</div>
</div>
</div>
<!-- Upload Invoice Modal -->
<div class="modal fade" id="uploadModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-cloud-upload me-2"></i>Upload Leverandørfaktura</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
<strong>AI-Analyse:</strong> Systemet vil automatisk udtrække fakturadata (CVR, beløb, linjer) fra PDF/billede via AI.
Leverandør matches automatisk via CVR nummer.
</div>
<form id="uploadForm" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label">Vælg fil (PDF, PNG, JPG) *</label>
<input type="file" class="form-control" id="fileInput" accept=".pdf,.png,.jpg,.jpeg" required>
<div class="form-text">Max 50 MB. AI vil udtrække CVR, fakturanummer, beløb og linjer.</div>
</div>
<!-- Upload Progress -->
<div id="uploadProgress" class="d-none mb-3">
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width: 0%" id="progressBar"></div>
</div>
<div class="text-center mt-2" id="progressText">Uploader...</div>
</div>
<!-- Upload Result -->
<div id="uploadResult" class="d-none">
<!-- Will show success/error/duplicate alerts -->
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-success" onclick="uploadInvoice()" id="uploadBtn">
<i class="bi bi-cloud-upload me-2"></i>Upload & Analyser
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Global state
let currentInvoiceId = null;
let currentFilter = 'all';
let allInvoices = [];
// Load data on page load
document.addEventListener('DOMContentLoaded', () => {
loadStats();
loadInvoices();
loadVendors();
setDefaultDates();
});
// Set default dates
function setDefaultDates() {
const today = new Date().toISOString().split('T')[0];
const dueDateObj = new Date();
dueDateObj.setDate(dueDateObj.getDate() + 30);
const dueDate = dueDateObj.toISOString().split('T')[0];
document.getElementById('invoiceDate').value = today;
document.getElementById('dueDate').value = dueDate;
}
// Load statistics
async function loadStats() {
try {
const response = await fetch('/api/v1/supplier-invoices/stats/overview');
const stats = await response.json();
document.getElementById('statOverdueCount').textContent = stats.overdue_count || 0;
document.getElementById('statOverdueAmount').textContent = formatCurrency(stats.overdue_amount);
document.getElementById('statDueSoonCount').textContent = stats.due_soon_count || 0;
document.getElementById('statDueSoonAmount').textContent = formatCurrency(stats.unpaid_amount);
document.getElementById('statPendingCount').textContent = stats.pending_count || 0;
document.getElementById('statPendingAmount').textContent = formatCurrency(stats.unpaid_amount);
document.getElementById('statTotalCount').textContent = stats.total_invoices || 0;
document.getElementById('statUnpaidAmount').textContent = formatCurrency(stats.unpaid_amount);
} catch (error) {
console.error('Failed to load stats:', error);
}
}
// Load invoices
async function loadInvoices(status = null) {
try {
let url = '/api/v1/supplier-invoices';
const params = new URLSearchParams();
if (status && status !== 'all') {
if (status === 'overdue') {
params.append('overdue_only', 'true');
} else {
params.append('status', status);
}
}
if (params.toString()) {
url += '?' + params.toString();
}
const response = await fetch(url);
allInvoices = await response.json();
renderInvoices(allInvoices);
} catch (error) {
console.error('Failed to load invoices:', error);
document.getElementById('invoicesTable').innerHTML = `
<tr>
<td colspan="8" class="text-center text-danger py-4">
<i class="bi bi-exclamation-triangle me-2"></i>
Fejl ved indlæsning af fakturaer
</td>
</tr>
`;
}
}
// Render invoices table
function renderInvoices(invoices) {
const tbody = document.getElementById('invoicesTable');
if (!invoices || invoices.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="8" class="text-center text-muted py-4">
<i class="bi bi-inbox me-2"></i>
Ingen fakturaer fundet
</td>
</tr>
`;
return;
}
tbody.innerHTML = invoices.map(inv => `
<tr onclick="viewInvoice(${inv.id})" style="cursor: pointer;">
<td><strong>${inv.invoice_number}</strong></td>
<td>${inv.vendor_full_name || inv.vendor_name || '-'}</td>
<td>${formatDate(inv.invoice_date)}</td>
<td>${formatDate(inv.due_date)}</td>
<td><strong>${formatCurrency(inv.total_amount)}</strong></td>
<td>${getStatusBadge(inv.computed_status || inv.status)}</td>
<td>${inv.economic_voucher_number ? `<span class="badge bg-success">Bilag #${inv.economic_voucher_number}</span>` : '-'}</td>
<td onclick="event.stopPropagation();">
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="viewInvoice(${inv.id})" title="Vis detaljer">
<i class="bi bi-eye"></i>
</button>
${inv.status === 'pending' ? `
<button class="btn btn-outline-success" onclick="quickApprove(${inv.id})" title="Godkend">
<i class="bi bi-check-circle"></i>
</button>
` : ''}
${inv.status === 'approved' && !inv.economic_voucher_number ? `
<button class="btn btn-outline-primary" onclick="quickSendToEconomic(${inv.id})" title="Send til e-conomic">
<i class="bi bi-send"></i>
</button>
` : ''}
${!inv.economic_voucher_number ? `
<button class="btn btn-outline-danger" onclick="deleteInvoice(${inv.id})" title="Slet">
<i class="bi bi-trash"></i>
</button>
` : ''}
</div>
</td>
</tr>
`).join('');
}
// Load vendors for dropdown
async function loadVendors() {
try {
const response = await fetch('/api/v1/vendors');
const vendors = await response.json();
const select = document.getElementById('vendorId');
select.innerHTML = '<option value="">Vælg leverandør...</option>' +
vendors.map(v => `<option value="${v.id}">${v.name}</option>`).join('');
} catch (error) {
console.error('Failed to load vendors:', error);
}
}
// Filter functions
function applyFilter(filter, element) {
currentFilter = filter;
// Update active pill
document.querySelectorAll('.filter-pill').forEach(pill => pill.classList.remove('active'));
element.classList.add('active');
// Reload invoices with filter
loadInvoices(filter);
}
// Open create modal
function openCreateModal() {
document.getElementById('invoiceForm').reset();
document.getElementById('invoiceId').value = '';
document.getElementById('lineItems').innerHTML = '';
setDefaultDates();
new bootstrap.Modal(document.getElementById('invoiceModal')).show();
}
// Add line item
let lineCounter = 0;
function addLine() {
lineCounter++;
const lineHtml = `
<div class="line-item" id="line-${lineCounter}">
<div class="row">
<div class="col-md-5">
<input type="text" class="form-control form-control-sm mb-2"
placeholder="Beskrivelse" name="line_description[]">
</div>
<div class="col-md-2">
<input type="number" step="1" class="form-control form-control-sm mb-2"
placeholder="Antal" name="line_quantity[]" value="1">
</div>
<div class="col-md-2">
<input type="number" step="0.01" class="form-control form-control-sm mb-2"
placeholder="Pris" name="line_price[]">
</div>
<div class="col-md-2">
<select class="form-select form-select-sm mb-2" name="line_vat_code[]">
<option value="I25">Moms 25%</option>
<option value="I0">Moms 0%</option>
<option value="IY25">Omvendt 25%</option>
</select>
</div>
<div class="col-md-1">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeLine(${lineCounter})">
<i class="bi bi-x"></i>
</button>
</div>
</div>
</div>
`;
document.getElementById('lineItems').insertAdjacentHTML('beforeend', lineHtml);
}
function removeLine(lineId) {
document.getElementById(`line-${lineId}`)?.remove();
}
// Save invoice
async function saveInvoice() {
try {
const form = document.getElementById('invoiceForm');
if (!form.checkValidity()) {
form.reportValidity();
return;
}
// Collect line items
const lines = [];
const descriptions = form.querySelectorAll('[name="line_description[]"]');
const quantities = form.querySelectorAll('[name="line_quantity[]"]');
const prices = form.querySelectorAll('[name="line_price[]"]');
const vatCodes = form.querySelectorAll('[name="line_vat_code[]"]');
for (let i = 0; i < descriptions.length; i++) {
if (descriptions[i].value.trim()) {
const qty = parseFloat(quantities[i].value) || 1;
const price = parseFloat(prices[i].value) || 0;
const lineTotal = qty * price;
// Calculate VAT based on code
let vatRate = 25;
if (vatCodes[i].value === 'I0') vatRate = 0;
const vatAmount = lineTotal * (vatRate / 100);
lines.push({
description: descriptions[i].value,
quantity: qty,
unit_price: price,
line_total: lineTotal + vatAmount,
vat_code: vatCodes[i].value,
vat_rate: vatRate,
vat_amount: vatAmount,
contra_account: '5810'
});
}
}
const data = {
invoice_number: document.getElementById('invoiceNumber').value,
vendor_id: parseInt(document.getElementById('vendorId').value),
invoice_date: document.getElementById('invoiceDate').value,
due_date: document.getElementById('dueDate').value,
total_amount: parseFloat(document.getElementById('totalAmount').value),
vat_amount: parseFloat(document.getElementById('vatAmount').value) || 0,
currency: document.getElementById('currency').value,
description: document.getElementById('description').value,
notes: document.getElementById('notes').value,
lines: lines
};
const response = await fetch('/api/v1/supplier-invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('invoiceModal')).hide();
loadInvoices(currentFilter);
loadStats();
alert('✅ Faktura oprettet');
} else {
const error = await response.json();
alert('❌ Fejl: ' + (error.detail || 'Kunne ikke oprette faktura'));
}
} catch (error) {
console.error('Failed to save invoice:', error);
alert('❌ Fejl ved oprettelse af faktura');
}
}
// View invoice details
async function viewInvoice(invoiceId) {
try {
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`);
const invoice = await response.json();
currentInvoiceId = invoiceId;
const detailsHtml = `
<div class="row mb-3">
<div class="col-md-6">
<p class="mb-1"><strong>Fakturanummer:</strong> ${invoice.invoice_number}</p>
<p class="mb-1"><strong>Leverandør:</strong> ${invoice.vendor_full_name || invoice.vendor_name}</p>
<p class="mb-1"><strong>Status:</strong> ${getStatusBadge(invoice.status)}</p>
</div>
<div class="col-md-6">
<p class="mb-1"><strong>Fakturadato:</strong> ${formatDate(invoice.invoice_date)}</p>
<p class="mb-1"><strong>Forfaldsdato:</strong> ${formatDate(invoice.due_date)}</p>
<p class="mb-1"><strong>Total:</strong> <strong>${formatCurrency(invoice.total_amount)}</strong></p>
</div>
</div>
${invoice.description ? `<p><strong>Beskrivelse:</strong> ${invoice.description}</p>` : ''}
${invoice.notes ? `<p><strong>Noter:</strong> ${invoice.notes}</p>` : ''}
${invoice.economic_voucher_number ? `
<div class="alert alert-success">
<i class="bi bi-check-circle me-2"></i>
Sendt til e-conomic - Bilagsnummer: <strong>${invoice.economic_voucher_number}</strong>
(Kassekladde ${invoice.economic_journal_number}, År ${invoice.economic_accounting_year})
</div>
` : ''}
<h6 class="mt-3">Linjer:</h6>
<table class="table table-sm">
<thead>
<tr>
<th>Beskrivelse</th>
<th>Antal</th>
<th>Pris</th>
<th>Moms</th>
<th>Total</th>
</tr>
</thead>
<tbody>
${(invoice.lines || []).map(line => `
<tr>
<td>${line.description}</td>
<td>${line.quantity}</td>
<td>${formatCurrency(line.unit_price)}</td>
<td>${line.vat_code} (${line.vat_rate}%)</td>
<td><strong>${formatCurrency(line.line_total)}</strong></td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('invoiceDetails').innerHTML = detailsHtml;
// Show/hide action buttons based on status
document.getElementById('approveBtn').style.display = invoice.status === 'pending' ? 'inline-block' : 'none';
document.getElementById('sendToEconomicBtn').style.display =
(invoice.status === 'approved' && !invoice.economic_voucher_number) ? 'inline-block' : 'none';
new bootstrap.Modal(document.getElementById('viewInvoiceModal')).show();
} catch (error) {
console.error('Failed to load invoice:', error);
alert('❌ Kunne ikke hente faktura');
}
}
// Approve invoice
async function approveInvoice() {
if (!currentInvoiceId) return;
if (!confirm('Godkend denne faktura?')) return;
try {
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
});
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('viewInvoiceModal')).hide();
loadInvoices(currentFilter);
loadStats();
alert('✅ Faktura godkendt');
} else {
const error = await response.json();
alert('❌ Fejl: ' + (error.detail || 'Kunne ikke godkende'));
}
} catch (error) {
console.error('Failed to approve:', error);
alert('❌ Fejl ved godkendelse');
}
}
// Send to e-conomic
async function sendToEconomic() {
if (!currentInvoiceId) return;
if (!confirm('Send denne faktura til e-conomic kassekladde?')) return;
try {
const response = await fetch(`/api/v1/supplier-invoices/${currentInvoiceId}/send-to-economic`, {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
bootstrap.Modal.getInstance(document.getElementById('viewInvoiceModal')).hide();
loadInvoices(currentFilter);
loadStats();
alert(`✅ Faktura sendt til e-conomic\nBilagsnummer: ${result.voucher_number}`);
} else {
const error = await response.json();
alert('❌ Fejl: ' + (error.detail || 'Kunne ikke sende til e-conomic'));
}
} catch (error) {
console.error('Failed to send to e-conomic:', error);
alert('❌ Fejl ved sending til e-conomic');
}
}
// Quick approve
async function quickApprove(invoiceId) {
if (!confirm('Godkend denne faktura?')) return;
try {
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved_by: 'CurrentUser' })
});
if (response.ok) {
loadInvoices(currentFilter);
loadStats();
} else {
alert('❌ Kunne ikke godkende');
}
} catch (error) {
console.error('Failed to approve:', error);
}
}
// Quick send to e-conomic
async function quickSendToEconomic(invoiceId) {
if (!confirm('Send til e-conomic?')) return;
try {
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/send-to-economic`, {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
loadInvoices(currentFilter);
loadStats();
alert(`✅ Sendt - Bilag #${result.voucher_number}`);
} else {
const error = await response.json();
alert('❌ Fejl: ' + (error.detail || 'Kunne ikke sende'));
}
} catch (error) {
console.error('Failed to send:', error);
}
}
// Delete invoice
async function deleteInvoice(invoiceId) {
if (!confirm('Slet denne faktura?')) return;
try {
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, {
method: 'DELETE'
});
if (response.ok) {
loadInvoices(currentFilter);
loadStats();
} else {
alert('❌ Kunne ikke slette');
}
} catch (error) {
console.error('Failed to delete:', error);
}
}
// Utility functions
function formatCurrency(amount) {
if (amount === null || amount === undefined) return '-';
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK'
}).format(amount);
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('da-DK');
}
function getStatusBadge(status) {
const badges = {
'pending': '<span class="badge status-pending">Afventer</span>',
'approved': '<span class="badge status-approved">Godkendt</span>',
'sent_to_economic': '<span class="badge status-sent">Sendt</span>',
'paid': '<span class="badge status-paid">Betalt</span>',
'overdue': '<span class="badge status-overdue">Overskredet</span>',
'cancelled': '<span class="badge bg-secondary">Annulleret</span>'
};
return badges[status] || `<span class="badge bg-secondary">${status}</span>`;
}
// ========== UPLOAD FUNCTIONS ==========
function openUploadModal() {
document.getElementById('uploadForm').reset();
document.getElementById('uploadProgress').classList.add('d-none');
document.getElementById('uploadResult').classList.add('d-none');
document.getElementById('uploadResult').innerHTML = '';
document.getElementById('uploadBtn').disabled = false;
new bootstrap.Modal(document.getElementById('uploadModal')).show();
}
async function uploadInvoice() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
if (!file) {
alert('Vælg venligst en fil');
return;
}
// Validate file type
const allowedTypes = ['.pdf', '.png', '.jpg', '.jpeg'];
const ext = '.' + file.name.split('.').pop().toLowerCase();
if (!allowedTypes.includes(ext)) {
alert('Kun PDF, PNG eller JPG filer tilladt');
return;
}
// Validate file size (50 MB)
if (file.size > 50 * 1024 * 1024) {
alert('Fil for stor. Max 50 MB.');
return;
}
const uploadBtn = document.getElementById('uploadBtn');
const progressDiv = document.getElementById('uploadProgress');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const resultDiv = document.getElementById('uploadResult');
try {
// Show progress
uploadBtn.disabled = true;
progressDiv.classList.remove('d-none');
resultDiv.classList.add('d-none');
progressBar.style.width = '30%';
progressText.textContent = 'Uploader fil...';
// Create FormData
const formData = new FormData();
formData.append('file', file);
// Upload file
const response = await fetch('/api/v1/supplier-invoices/upload', {
method: 'POST',
body: formData
});
progressBar.style.width = '60%';
progressText.textContent = 'AI analyserer faktura...';
const result = await response.json();
progressBar.style.width = '100%';
progressText.textContent = 'Færdig!';
// Hide progress after animation
setTimeout(() => {
progressDiv.classList.add('d-none');
showUploadResult(result, response.ok);
}, 500);
} catch (error) {
console.error('Upload failed:', error);
progressDiv.classList.add('d-none');
showUploadResult({
status: 'error',
message: 'Upload fejlede: ' + error.message
}, false);
} finally {
uploadBtn.disabled = false;
}
}
function showUploadResult(result, success) {
const resultDiv = document.getElementById('uploadResult');
resultDiv.classList.remove('d-none');
if (result.status === 'duplicate') {
// Duplicate file or invoice
resultDiv.innerHTML = `
<div class="alert alert-warning">
<h6><i class="bi bi-exclamation-triangle me-2"></i>Duplicate Detected</h6>
<p class="mb-2">${result.message}</p>
${result.invoice_id ? `
<button class="btn btn-sm btn-outline-primary" onclick="viewInvoice(${result.invoice_id})">
<i class="bi bi-eye me-1"></i>Vis eksisterende faktura
</button>
` : ''}
</div>
`;
} else if (success && result.status === 'success') {
// Success - show extraction results
const vendorBadge = result.vendor_matched
? `<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>CVR Match</span>`
: `<span class="badge bg-warning"><i class="bi bi-exclamation-circle me-1"></i>Ingen CVR Match</span>`;
const confidenceBadge = result.confidence >= 0.75
? `<span class="badge bg-success">${(result.confidence * 100).toFixed(0)}% Confidence</span>`
: `<span class="badge bg-warning">${(result.confidence * 100).toFixed(0)}% Confidence</span>`;
resultDiv.innerHTML = `
<div class="alert alert-success">
<h6><i class="bi bi-check-circle me-2"></i>Faktura Uploadet & Analyseret!</h6>
<div class="mt-3">
<strong>AI Udtrækning:</strong><br>
${vendorBadge} ${confidenceBadge}
<div class="mt-2">
<table class="table table-sm table-borderless mb-0">
<tr>
<td width="150"><strong>Leverandør:</strong></td>
<td>${result.vendor_name || '-'}</td>
</tr>
${result.vendor_cvr ? `
<tr>
<td><strong>CVR:</strong></td>
<td>${result.vendor_cvr}</td>
</tr>
` : ''}
<tr>
<td><strong>Fakturanr:</strong></td>
<td>${result.invoice_number || '-'}</td>
</tr>
<tr>
<td><strong>Beløb:</strong></td>
<td>${formatCurrency(result.total_amount)}</td>
</tr>
</table>
</div>
${result.needs_review ? `
<div class="alert alert-warning mt-3 mb-0">
<i class="bi bi-info-circle me-2"></i>
<small><strong>Bemærk:</strong> ${
!result.vendor_matched
? 'Ingen leverandør fundet med CVR ' + (result.vendor_cvr || 'N/A') + '. Du skal vælge leverandør manuelt.'
: 'Lav confidence score - gennemgå venligst data.'
}</small>
</div>
` : ''}
<div class="mt-3">
${result.invoice_id ? `
<button class="btn btn-primary btn-sm me-2" onclick="viewInvoice(${result.invoice_id})">
<i class="bi bi-eye me-1"></i>Vis Faktura
</button>
` : ''}
${!result.template_matched && result.vendor_id ? `
<button class="btn btn-warning btn-sm me-2" onclick="createTemplateFromInvoice(${result.invoice_id}, ${result.vendor_id})" title="Opret template så fremtidige fakturaer er hurtigere">
<i class="bi bi-magic me-1"></i>Opret Template
</button>
` : ''}
<button class="btn btn-success btn-sm" onclick="closeUploadAndRefresh()">
<i class="bi bi-check-lg me-1"></i>OK
</button>
</div>
</div>
</div>
`;
// Auto-refresh invoice list
setTimeout(() => {
loadInvoices(currentFilter);
loadStats();
}, 1000);
} else if (result.status === 'error' && result.needs_review) {
// AI parsing failed - needs manual review
resultDiv.innerHTML = `
<div class="alert alert-warning">
<h6><i class="bi bi-exclamation-triangle me-2"></i>AI-Analyse Fejlede - Manuel Gennemgang Nødvendig</h6>
<p class="mb-2">${result.message}</p>
${result.error_details ? `
<details class="mt-2">
<summary class="text-muted small">Tekniske detaljer</summary>
<pre class="small mt-2 text-muted">${result.error_details}</pre>
</details>
` : ''}
<div class="mt-3">
<small class="text-muted">
<i class="bi bi-info-circle me-1"></i>
Filen er gemt og kan behandles manuelt senere.
</small>
</div>
<button class="btn btn-secondary btn-sm mt-2" onclick="closeUploadAndRefresh()">
<i class="bi bi-check-lg me-1"></i>OK
</button>
</div>
`;
} else {
// Error
resultDiv.innerHTML = `
<div class="alert alert-danger">
<h6><i class="bi bi-x-circle me-2"></i>Upload Fejlede</h6>
<p class="mb-0">${result.message || result.detail || 'Ukendt fejl'}</p>
</div>
`;
}
}
function closeUploadAndRefresh() {
bootstrap.Modal.getInstance(document.getElementById('uploadModal')).hide();
loadInvoices(currentFilter);
loadStats();
}
// Create template from successfully uploaded invoice
async function createTemplateFromInvoice(invoiceId, vendorId) {
try {
const loadingDiv = document.getElementById('uploadResult');
loadingDiv.innerHTML = `
<div class="alert alert-info">
<div class="d-flex align-items-center">
<div class="spinner-border spinner-border-sm me-3" role="status"></div>
<div>
<strong>Opretter template...</strong><br>
<small>AI analyserer faktura og genererer patterns...</small>
</div>
</div>
</div>
`;
// Step 1: Get PDF text from invoice
const reprocessResp = await fetch(`/api/v1/supplier-invoices/reprocess/${invoiceId}`, {
method: 'POST'
});
const pdfData = await reprocessResp.json();
if (!pdfData.pdf_text) {
throw new Error('Kunne ikke hente PDF tekst');
}
// Step 2: AI analyze
const aiResp = await fetch('/api/v1/supplier-invoices/ai-analyze', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
pdf_text: pdfData.pdf_text,
vendor_id: vendorId
})
});
const aiData = await aiResp.json();
// Step 3: Convert AI data to template format
const fieldMappings = {};
const detectionPatterns = [];
// Build field mappings from AI response
if (aiData.vendor_cvr || aiData.cvr) {
const cvrValue = aiData.vendor_cvr?.value || aiData.vendor_cvr || aiData.cvr;
fieldMappings.vendor_cvr = {
pattern: aiData.vendor_cvr?.pattern || `DK\\s*(${cvrValue})`,
group: 1
};
}
if (aiData.invoice_number) {
const invNum = aiData.invoice_number?.value || aiData.invoice_number;
fieldMappings.invoice_number = {
pattern: aiData.invoice_number?.pattern || `(?:Nummer|Faktura|Invoice)\\s*(${invNum})`,
group: 1
};
}
if (aiData.invoice_date) {
fieldMappings.invoice_date = {
pattern: aiData.invoice_date?.pattern || `Dato\\s*(\\d{1,2}[\\/.\\-]\\d{1,2}[\\/.\\-]\\d{2,4})`,
group: 1,
format: "DD.MM.YYYY"
};
}
if (aiData.total_amount) {
fieldMappings.total_amount = {
pattern: aiData.total_amount?.pattern || `(?:Total|I alt|Beløb)\\s*([\\d.,]+)`,
group: 1
};
}
// Lines markers
if (aiData.lines_start) {
fieldMappings.lines_start = {
pattern: aiData.lines_start?.pattern || aiData.lines_start
};
}
if (aiData.lines_end) {
fieldMappings.lines_end = {
pattern: aiData.lines_end?.pattern || aiData.lines_end
};
}
// Detection patterns
if (aiData.detection_patterns && Array.isArray(aiData.detection_patterns)) {
aiData.detection_patterns.forEach((p, i) => {
const pattern = typeof p === 'string' ? p : p.pattern;
const weight = typeof p === 'object' ? p.weight : (0.5 - i * 0.1);
detectionPatterns.push({
type: 'text',
pattern: pattern,
weight: Math.max(0.1, weight)
});
});
} else {
// Default detection patterns if none provided
detectionPatterns.push({type: 'text', pattern: 'Faktura', weight: 0.3});
}
// Step 4: Create template
const createResp = await fetch('/api/v1/supplier-invoices/templates', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
vendor_id: vendorId,
template_name: `Auto-generated ${new Date().toISOString().split('T')[0]}`,
detection_patterns: detectionPatterns,
field_mappings: fieldMappings
})
});
const template = await createResp.json();
if (!createResp.ok) {
throw new Error(template.detail || 'Kunne ikke oprette template');
}
// Success!
loadingDiv.innerHTML = `
<div class="alert alert-success">
<h6><i class="bi bi-check-circle me-2"></i>Template Oprettet!</h6>
<p class="mb-2">Næste gang en faktura fra denne leverandør uploades, vil den blive behandlet automatisk på 0.1 sekunder i stedet for ${aiData._processing_time || '10'} sekunder!</p>
<div class="mt-3">
<a href="/billing/template-builder?template_id=${template.template_id}" class="btn btn-primary btn-sm me-2">
<i class="bi bi-pencil me-1"></i>Rediger Template
</a>
<a href="/billing/templates" class="btn btn-outline-primary btn-sm me-2">
<i class="bi bi-list me-1"></i>Se Alle Templates
</a>
<button class="btn btn-success btn-sm" onclick="closeUploadAndRefresh()">
<i class="bi bi-check-lg me-1"></i>Luk
</button>
</div>
</div>
`;
} catch (error) {
console.error('Template creation error:', error);
document.getElementById('uploadResult').innerHTML = `
<div class="alert alert-danger">
<h6><i class="bi bi-x-circle me-2"></i>Kunne ikke oprette template</h6>
<p class="mb-0">${error.message}</p>
<button class="btn btn-secondary btn-sm mt-2" onclick="closeUploadAndRefresh()">Luk</button>
</div>
`;
}
}
</script>
</body>
</html>