bmc_hub/app/modules/invoice_error_finder/templates/issues.html

568 lines
24 KiB
HTML
Raw Permalink Normal View History

{% 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">Opret ordrekladde</option>
<option value="invoiced">Faktureret</option>
<option value="ignored">Ignoreret</option>
<option value="resolved">Løst</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 class="modal fade" id="historyModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable" style="max-width: 90vw; width: 90vw;">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title mb-1">Fakturahistorik</h5>
<div class="text-muted small" id="historyModalMeta"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div id="historyModalStatus" class="alert d-none mb-3"></div>
<div class="d-flex flex-wrap gap-2 mb-3" id="historyModalActions">
<button type="button" class="btn btn-outline-success btn-sm" id="historyApproveBtn" disabled>
<i class="bi bi-check2 me-1"></i>Godkend
</button>
<button type="button" class="btn btn-outline-warning btn-sm" id="historyInvestigatingBtn" disabled>
<i class="bi bi-search me-1"></i>Undersøgelse
</button>
<button type="button" class="btn btn-outline-primary btn-sm" id="historyReadyBtn" disabled>
<i class="bi bi-receipt me-1"></i>Opret ordrekladde
</button>
<button type="button" class="btn btn-outline-info btn-sm" id="historyCreateSagBtn" disabled>
<i class="bi bi-folder-plus me-1"></i>Opret sag
</button>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th>Måned</th>
<th>Status</th>
<th>Antal linjer</th>
<th>Samlet mængde</th>
<th>Samlet beløb</th>
<th>Fakturaer</th>
</tr>
</thead>
<tbody id="historyModalBody">
<tr><td colspan="6" class="text-muted text-center py-4">Vælg en fejl for at se historik</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
let currentOffset = 0;
const pageSize = 100;
let currentHistoryIssueId = null;
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: 'Opret ordrekladde',
invoiced: 'Faktureret',
ignored: 'Ignoreret',
resolved: 'Løst'
};
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 formatMonth(value) {
if (!value) return '-';
return new Intl.DateTimeFormat('da-DK', { year: 'numeric', month: 'short' }).format(new Date(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',
resolved: 'bg-secondary-subtle 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') {
2026-07-10 08:10:31 +02:00
params.set('assigned_user_id', 'null');
} 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.resolved_product_name || 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) {
const historyButton = `<button class="btn btn-outline-dark" title="Se 13 måneder tilbage og 2 frem" onclick="showInvoiceHistory(${issue.id})"><i class="bi bi-clock-history"></i></button>`;
if (issue.status === 'ignored') return `${historyButton}<span class="text-muted small ms-2">Ignoreret</span>`;
if (issue.status === 'invoiced') return `${historyButton}<span class="text-muted small ms-2">Faktureret</span>`;
if (issue.status === 'resolved') return `${historyButton}<span class="text-muted small ms-2">Løst</span>`;
return `
${historyButton}
<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="Opret ordrekladde" 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, options = {}) {
const { target = 'page', successMessage = 'Status opdateret' } = options;
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');
if (target === 'history') {
showHistoryStatus(successMessage, 'success');
} else {
showStatus(successMessage, 'success');
}
loadIssues();
} catch (err) {
if (target === 'history') {
showHistoryStatus('Fejl: ' + err.message, 'danger');
} else {
showStatus('Fejl: ' + err.message, 'danger');
}
}
}
async function createSag(issueId, options = {}) {
const { target = 'page' } = options;
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');
if (target === 'history') {
showHistoryStatus(`Sag #${data.sag_id} oprettet`, 'success');
} else {
showStatus(`Sag #${data.sag_id} oprettet`, 'success');
}
loadIssues();
} catch (err) {
if (target === 'history') {
showHistoryStatus('Fejl: ' + err.message, 'danger');
} else {
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);
}
function showHistoryStatus(message, type) {
const el = document.getElementById('historyModalStatus');
el.className = `alert alert-${type} mb-3`;
el.textContent = message;
el.classList.remove('d-none');
}
function setHistoryActionState(enabled) {
['historyApproveBtn', 'historyInvestigatingBtn', 'historyReadyBtn', 'historyCreateSagBtn']
.forEach(id => {
const btn = document.getElementById(id);
if (btn) btn.disabled = !enabled;
});
}
function renderInvoiceLines(lines) {
if (!lines || lines.length === 0) {
return '<div class="text-muted small">Ingen linjer fundet</div>';
}
return `
<div class="table-responsive mt-2">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>Linje</th>
<th>Varenr</th>
<th>Beskrivelse</th>
<th>Antal</th>
<th>Pris</th>
<th>Beløb</th>
</tr>
</thead>
<tbody>
${lines.map(line => `
<tr>
<td>${formatNumber(line.line_number)}</td>
<td>${escapeHtml(line.product_number || '-')}</td>
<td class="text-truncate" style="max-width: 420px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(line.description || line.product_name || '-')}">${escapeHtml(line.description || line.product_name || '-')}</td>
<td>${formatNumber(line.quantity)}</td>
<td>${formatCurrency(line.unit_price)}</td>
<td>${formatCurrency(line.line_net_amount)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
function renderInvoiceNotes(invoice) {
const parts = [invoice.heading, invoice.note_text].filter(Boolean);
if (parts.length === 0) {
return '';
}
return `
<div class="border rounded bg-light p-2 mb-2 small">
${parts.map(part => `<div>${escapeHtml(part)}</div>`).join('')}
</div>
`;
}
function renderInvoiceCard(invoice, monthKey, index) {
const collapseId = `invoice-lines-${monthKey}-${index}`;
return `
<div class="border rounded p-2 mb-2 bg-white">
<div class="d-flex justify-content-between align-items-center gap-2">
<div class="text-truncate small" style="min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(`${invoice.invoice_number || '-'} (${invoice.invoice_date || '-'}) · ${invoice.heading || invoice.source_type || ''} · Netto: ${formatCurrency(invoice.net_amount)} · Moms: ${formatCurrency(invoice.vat_amount)} · Total: ${formatCurrency(invoice.total_amount)}`)}">
<strong>${escapeHtml(invoice.invoice_number || '-')}</strong>
<span class="text-muted">(${escapeHtml(invoice.invoice_date || '-')})</span>
<span class="text-muted">· ${escapeHtml(invoice.heading || invoice.source_type || '')}</span>
<span>· Netto: ${formatCurrency(invoice.net_amount)} · Moms: ${formatCurrency(invoice.vat_amount)} · Total: ${formatCurrency(invoice.total_amount)}</span>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="false">
Vis linjer
</button>
</div>
<div class="collapse" id="${collapseId}">
${renderInvoiceNotes(invoice)}
${renderInvoiceLines(invoice.lines)}
</div>
</div>
`;
}
async function showInvoiceHistory(issueId) {
const body = document.getElementById('historyModalBody');
const meta = document.getElementById('historyModalMeta');
const status = document.getElementById('historyModalStatus');
currentHistoryIssueId = issueId;
setHistoryActionState(true);
status.classList.add('d-none');
meta.textContent = '';
body.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-4">Indlæser historik...</td></tr>';
const modal = new bootstrap.Modal(document.getElementById('historyModal'));
modal.show();
try {
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/invoice-history`);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke hente historik');
meta.textContent = `${data.customer_name || 'Ukendt kunde'} · ${data.product_name || data.product_number || '-'}`;
body.innerHTML = data.months.map(row => {
const stateBadge = row.line_count > 0
? '<span class="badge bg-success">Faktureret</span>'
: '<span class="badge bg-danger">Manglende</span>';
const monthBadge = row.is_reference_month
? ' <span class="badge bg-warning text-dark">Reference</span>'
: row.is_fallback_history
? ` <span class="badge bg-info text-dark">${escapeHtml(row.fallback_label || 'Seneste tidligere faktura')}</span>`
: '';
const invoiceList = row.invoices && row.invoices.length
? row.invoices.map((invoice, index) => renderInvoiceCard(invoice, row.month_start || 'month', index)).join('')
: '<span class="text-muted">Ingen faktura</span>';
return `
<tr class="${row.is_reference_month ? 'table-warning' : row.is_fallback_history ? 'table-info' : ''}">
<td>${formatMonth(row.month_start)}${monthBadge}</td>
<td>${stateBadge}</td>
<td>${formatNumber(row.line_count)}</td>
<td>${formatNumber(row.total_quantity)}</td>
<td>${formatCurrency(row.total_amount)}</td>
<td>${invoiceList}</td>
</tr>
`;
}).join('');
} catch (err) {
body.innerHTML = '<tr><td colspan="6" class="text-danger text-center py-4">Kunne ikke hente historik</td></tr>';
showHistoryStatus(`Fejl: ${err.message}`, 'danger');
}
}
document.getElementById('historyApproveBtn')?.addEventListener('click', function() {
if (!currentHistoryIssueId) return;
updateStatus(currentHistoryIssueId, 'approved_change', { target: 'history', successMessage: 'Fejlen er godkendt' });
});
document.getElementById('historyInvestigatingBtn')?.addEventListener('click', function() {
if (!currentHistoryIssueId) return;
updateStatus(currentHistoryIssueId, 'investigating', { target: 'history', successMessage: 'Fejlen er sat til undersøgelse' });
});
document.getElementById('historyReadyBtn')?.addEventListener('click', function() {
if (!currentHistoryIssueId) return;
updateStatus(currentHistoryIssueId, 'ready_to_invoice', { target: 'history', successMessage: 'Ordrekladde markeret som klar' });
});
document.getElementById('historyCreateSagBtn')?.addEventListener('click', function() {
if (!currentHistoryIssueId) return;
createSag(currentHistoryIssueId, { target: 'history' });
});
loadIssues();
</script>
{% endblock %}