- 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
334 lines
13 KiB
HTML
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('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"');
|
|
}
|
|
|
|
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 %}
|