bmc_hub/app/modules/invoice_error_finder/templates/issues.html
Christian 128a2b83d0 feat(invoice_error_finder): add invoice error finder module
- Detect missing invoice lines, open orders not invoiced, quantity drops, price changes
- Import e-conomic invoices and Simply CRM sales orders
- Dashboard and issues UI with sag/ordre-draft actions
- Scheduled daily sync job at 05:00
- Add invoice_error_finder permissions
2026-07-10 07:08:49 +02:00

334 lines
13 KiB
HTML

{% extends "shared/frontend/base.html" %}
{% block title %}Faktura-fejl-finder - Fejlliste - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h1 class="h3 mb-1">📋 Faktura-fejl-liste</h1>
<p class="text-muted mb-0">Gennemgå, godkend og håndter registrerede fakturaafvigelser.</p>
</div>
<div class="d-flex gap-2">
<a href="/invoice-error-finder" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Tilbage til dashboard
</a>
<button class="btn btn-primary" onclick="loadIssues()">
<i class="bi bi-arrow-clockwise me-1"></i>Opdater
</button>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-3">
<label class="form-label">Status</label>
<select id="filterStatus" class="form-select" onchange="loadIssues()">
<option value="">Alle</option>
<option value="open" selected>Åben</option>
<option value="investigating">Under undersøgelse</option>
<option value="approved_change">Godkendt ændring</option>
<option value="error_found">Fejl fundet</option>
<option value="ready_to_invoice">Klar til fakturering</option>
<option value="invoiced">Faktureret</option>
<option value="ignored">Ignoreret</option>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label">Fejltype</label>
<select id="filterType" class="form-select" onchange="loadIssues()">
<option value="">Alle</option>
<option value="missing_line">Manglende varelinje</option>
<option value="open_order_not_invoiced">Åben salgsordre ikke faktureret</option>
<option value="quantity_drop">Antalsfald</option>
<option value="price_change">Prisændring</option>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label">Kunde</label>
<select id="filterCustomer" class="form-select" onchange="loadIssues()">
<option value="">Alle</option>
{% for customer in customers %}
<option value="{{ customer.id }}">{{ customer.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label">Ansvarlig</label>
<select id="filterAssigned" class="form-select" onchange="loadIssues()">
<option value="">Alle</option>
<option value="null">Ikke tildelt</option>
{% for user in users %}
<option value="{{ user.user_id }}">{{ user.display_name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
<div id="issuesStatus" class="alert d-none mb-4"></div>
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Kunde</th>
<th>Fejltype</th>
<th>Vare</th>
<th>Forventet</th>
<th>Faktisk</th>
<th>Periode</th>
<th>Beløb/impact</th>
<th>Status</th>
<th>Ansvarlig</th>
<th style="min-width: 220px;">Handling</th>
</tr>
</thead>
<tbody id="issuesBody">
<tr><td colspan="10" class="text-muted text-center py-4">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<span class="text-muted small" id="paginationInfo"></span>
<div class="btn-group" id="paginationControls"></div>
</div>
</div>
<script>
let currentOffset = 0;
const pageSize = 100;
const issueTypeLabels = {
missing_line: 'Manglende varelinje',
open_order_not_invoiced: 'Åben salgsordre ikke faktureret',
quantity_drop: 'Antalsfald',
price_change: 'Prisændring',
new_item_never_invoiced: 'Ny vare aldrig faktureret'
};
const statusLabels = {
open: 'Åben',
investigating: 'Under undersøgelse',
approved_change: 'Godkendt ændring',
error_found: 'Fejl fundet',
ready_to_invoice: 'Klar til fakturering',
invoiced: 'Faktureret',
ignored: 'Ignoreret'
};
function escapeHtml(text) {
if (text == null) return '';
return String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
function formatCurrency(value) {
if (value == null) return '-';
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(value);
}
function formatNumber(value) {
if (value == null) return '-';
return new Intl.NumberFormat('da-DK').format(value);
}
function statusBadge(status) {
const map = {
open: 'bg-danger',
investigating: 'bg-warning text-dark',
approved_change: 'bg-info text-dark',
error_found: 'bg-danger',
ready_to_invoice: 'bg-success',
invoiced: 'bg-secondary',
ignored: 'bg-light text-dark'
};
const cls = map[status] || 'bg-light text-dark';
return `<span class="badge ${cls}">${statusLabels[status] || status}</span>`;
}
function buildQueryParams() {
const params = new URLSearchParams();
params.set('limit', pageSize);
params.set('offset', currentOffset);
const status = document.getElementById('filterStatus').value;
if (status) params.set('status', status);
const type = document.getElementById('filterType').value;
if (type) params.set('issue_type', type);
const customer = document.getElementById('filterCustomer').value;
if (customer) params.set('customer_id', customer);
const assigned = document.getElementById('filterAssigned').value;
if (assigned === 'null') {
params.set('assigned_user_id', '');
} else if (assigned) {
params.set('assigned_user_id', assigned);
}
return params;
}
async function loadIssues() {
const body = document.getElementById('issuesBody');
body.innerHTML = '<tr><td colspan="10" class="text-muted text-center py-4">Indlæser...</td></tr>';
try {
const params = buildQueryParams();
const res = await fetch('/api/v1/invoice-error-finder/issues?' + params.toString());
if (!res.ok) throw new Error('Kunne ikke hente fejlliste');
const data = await res.json();
if (data.items.length === 0) {
body.innerHTML = '<tr><td colspan="10" class="text-muted text-center py-4">Ingen fejl fundet</td></tr>';
} else {
body.innerHTML = data.items.map(issue => `
<tr>
<td>${escapeHtml(issue.customer_name || 'Ukendt kunde')}</td>
<td>${issueTypeLabels[issue.issue_type] || issue.issue_type}</td>
<td>${escapeHtml(issue.product_name || issue.product_number || '-')}</td>
<td>${formatNumber(issue.expected_quantity ?? issue.expected_price)}</td>
<td>${formatNumber(issue.actual_quantity ?? issue.actual_price)}</td>
<td>${issue.reference_period_start || '-'}</td>
<td>${formatCurrency(issue.amount_impact)}</td>
<td>${statusBadge(issue.status)}</td>
<td>${escapeHtml(issue.assigned_user_name || '-')}</td>
<td>
<div class="btn-group btn-group-sm">
${renderActionButtons(issue)}
</div>
</td>
</tr>
`).join('');
}
document.getElementById('paginationInfo').textContent = `Viser ${data.items.length} af ${data.total} fejl`;
renderPagination(data.total);
} catch (err) {
body.innerHTML = `<tr><td colspan="10" class="text-danger text-center py-4">Fejl: ${escapeHtml(err.message)}</td></tr>`;
}
}
function renderActionButtons(issue) {
if (issue.status === 'ignored') return '<span class="text-muted small">Ignoreret</span>';
if (issue.status === 'invoiced') return '<span class="text-muted small">Faktureret</span>';
return `
<button class="btn btn-outline-success" title="Godkend" onclick="updateStatus(${issue.id}, 'approved_change')"><i class="bi bi-check"></i></button>
<button class="btn btn-outline-warning" title="Under undersøgelse" onclick="updateStatus(${issue.id}, 'investigating')"><i class="bi bi-search"></i></button>
<button class="btn btn-outline-primary" title="Klar til fakturering" onclick="createOrdreDraft(${issue.id})"><i class="bi bi-receipt"></i></button>
<button class="btn btn-outline-info" title="Opret sag" onclick="createSag(${issue.id})"><i class="bi bi-folder-plus"></i></button>
<button class="btn btn-outline-secondary" title="Ignorér" onclick="ignoreIssue(${issue.id})"><i class="bi bi-eye-slash"></i></button>
`;
}
function renderPagination(total) {
const controls = document.getElementById('paginationControls');
const pages = Math.ceil(total / pageSize);
if (pages <= 1) {
controls.innerHTML = '';
return;
}
const currentPage = Math.floor(currentOffset / pageSize);
let html = `<button class="btn btn-outline-secondary" ${currentOffset === 0 ? 'disabled' : ''} onclick="goToPage(0)">«</button>`;
for (let i = 0; i < pages; i++) {
const active = i === currentPage ? 'active' : '';
html += `<button class="btn btn-outline-secondary ${active}" onclick="goToPage(${i})">${i + 1}</button>`;
}
html += `<button class="btn btn-outline-secondary" ${currentOffset + pageSize >= total ? 'disabled' : ''} onclick="goToPage(${pages - 1})">»</button>`;
controls.innerHTML = html;
}
function goToPage(page) {
currentOffset = page * pageSize;
loadIssues();
}
async function updateStatus(issueId, status) {
try {
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (!res.ok) throw new Error('Opdatering fejlede');
showStatus('Status opdateret', 'success');
loadIssues();
} catch (err) {
showStatus('Fejl: ' + err.message, 'danger');
}
}
async function createSag(issueId) {
const titel = prompt('Titel på sag:');
if (!titel) return;
try {
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/create-sag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ titel })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Opret sag fejlede');
showStatus(`Sag #${data.sag_id} oprettet`, 'success');
loadIssues();
} catch (err) {
showStatus('Fejl: ' + err.message, 'danger');
}
}
async function createOrdreDraft(issueId) {
try {
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/create-ordre-draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Opret kladde fejlede');
showStatus(`Ordrekladde #${data.draft_id} oprettet`, 'success');
loadIssues();
} catch (err) {
showStatus('Fejl: ' + err.message, 'danger');
}
}
async function ignoreIssue(issueId) {
if (!confirm('Ignorér denne fejl?')) return;
try {
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/ignore`, {
method: 'POST'
});
if (!res.ok) throw new Error('Ignorér fejlede');
showStatus('Fejl ignoreret', 'success');
loadIssues();
} catch (err) {
showStatus('Fejl: ' + err.message, 'danger');
}
}
function showStatus(message, type) {
const el = document.getElementById('issuesStatus');
el.className = `alert alert-${type} mb-4`;
el.textContent = message;
el.classList.remove('d-none');
setTimeout(() => el.classList.add('d-none'), 4000);
}
loadIssues();
</script>
{% endblock %}