409 lines
15 KiB
HTML
409 lines
15 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}Sync Dashboard - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
:root {
|
|
--sync-accent: #0f4c75;
|
|
--sync-accent-soft: rgba(15, 76, 117, 0.1);
|
|
--sync-ok: #2f855a;
|
|
--sync-warn: #c05621;
|
|
--sync-danger: #c53030;
|
|
}
|
|
|
|
.sync-header {
|
|
background: linear-gradient(130deg, rgba(15, 76, 117, 0.14), rgba(22, 160, 133, 0.08));
|
|
border: 1px solid rgba(15, 76, 117, 0.15);
|
|
border-radius: 16px;
|
|
padding: 1.25rem;
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
|
|
.sync-kpi {
|
|
border-radius: 14px;
|
|
border: 1px solid var(--border-color);
|
|
background: var(--bg-card);
|
|
padding: 1rem;
|
|
height: 100%;
|
|
}
|
|
|
|
.sync-kpi .label {
|
|
color: var(--text-secondary);
|
|
font-size: 0.82rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
}
|
|
|
|
.sync-kpi .value {
|
|
font-size: 1.8rem;
|
|
font-weight: 700;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.sync-kpi.pending .value { color: var(--sync-warn); }
|
|
.sync-kpi.failed .value { color: var(--sync-danger); }
|
|
.sync-kpi.posted .value { color: var(--sync-accent); }
|
|
.sync-kpi.paid .value { color: var(--sync-ok); }
|
|
|
|
.status-badge {
|
|
padding: 0.3rem 0.55rem;
|
|
border-radius: 999px;
|
|
font-size: 0.78rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.status-pending { background: rgba(192, 86, 33, 0.14); color: var(--sync-warn); }
|
|
.status-exported { background: rgba(15, 76, 117, 0.14); color: var(--sync-accent); }
|
|
.status-failed { background: rgba(197, 48, 48, 0.14); color: var(--sync-danger); }
|
|
.status-posted { background: rgba(22, 101, 52, 0.14); color: #166534; }
|
|
.status-paid { background: rgba(47, 133, 90, 0.14); color: var(--sync-ok); }
|
|
|
|
.table thead th {
|
|
font-size: 0.78rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.mono {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.event-card {
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 12px;
|
|
padding: 0.75rem;
|
|
background: var(--bg-card);
|
|
}
|
|
|
|
[data-bs-theme="dark"] .sync-header {
|
|
background: linear-gradient(130deg, rgba(61, 139, 253, 0.14), rgba(44, 62, 80, 0.3));
|
|
border-color: rgba(61, 139, 253, 0.25);
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="sync-header d-flex flex-wrap justify-content-between align-items-start gap-3">
|
|
<div>
|
|
<h2 class="mb-1">Draft Sync Dashboard</h2>
|
|
<p class="text-muted mb-0">Overblik over ordre-draft sync, attention queue og seneste events.</p>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-outline-secondary" id="btnPreviewReconcile">
|
|
<i class="bi bi-search me-1"></i>Preview Reconcile
|
|
</button>
|
|
<button class="btn btn-primary" id="btnApplyReconcile">
|
|
<i class="bi bi-arrow-repeat me-1"></i>Kør Reconcile
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3 mb-4" id="kpiRow">
|
|
<div class="col-6 col-lg-2">
|
|
<div class="sync-kpi">
|
|
<div class="label">Total</div>
|
|
<div class="value" id="kpiTotal">0</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-2">
|
|
<div class="sync-kpi pending">
|
|
<div class="label">Pending</div>
|
|
<div class="value" id="kpiPending">0</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-2">
|
|
<div class="sync-kpi">
|
|
<div class="label">Exported</div>
|
|
<div class="value" id="kpiExported">0</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-2">
|
|
<div class="sync-kpi failed">
|
|
<div class="label">Failed</div>
|
|
<div class="value" id="kpiFailed">0</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-2">
|
|
<div class="sync-kpi posted">
|
|
<div class="label">Posted</div>
|
|
<div class="value" id="kpiPosted">0</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-2">
|
|
<div class="sync-kpi paid">
|
|
<div class="label">Paid</div>
|
|
<div class="value" id="kpiPaid">0</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-12 col-xl-7">
|
|
<div class="card h-100">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0">Attention Items</h5>
|
|
<button class="btn btn-sm btn-outline-secondary" id="btnRefreshAttention">Opdater</button>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Draft</th>
|
|
<th>Status</th>
|
|
<th>Order</th>
|
|
<th>Invoice</th>
|
|
<th>Seneste Event</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="attentionBody">
|
|
<tr><td colspan="6" class="text-center py-4 text-muted">Indlæser...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12 col-xl-5">
|
|
<div class="card h-100">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0">Recent Events</h5>
|
|
<button class="btn btn-sm btn-outline-secondary" id="btnRefreshEvents">Opdater</button>
|
|
</div>
|
|
<div class="card-body" id="recentEventsList">
|
|
<div class="text-muted">Indlæser...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="eventsModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Draft Events</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="row g-2 mb-3">
|
|
<div class="col-md-3">
|
|
<input class="form-control form-control-sm" id="filterEventType" placeholder="event_type">
|
|
</div>
|
|
<div class="col-md-3">
|
|
<input class="form-control form-control-sm" id="filterFromStatus" placeholder="from_status">
|
|
</div>
|
|
<div class="col-md-3">
|
|
<input class="form-control form-control-sm" id="filterToStatus" placeholder="to_status">
|
|
</div>
|
|
<div class="col-md-3 d-flex gap-2">
|
|
<button class="btn btn-sm btn-outline-primary" id="btnApplyEventFilters">Filtrer</button>
|
|
<button class="btn btn-sm btn-outline-secondary" id="btnClearEventFilters">Nulstil</button>
|
|
</div>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle">
|
|
<thead>
|
|
<tr>
|
|
<th>Tid</th>
|
|
<th>Event</th>
|
|
<th>Fra</th>
|
|
<th>Til</th>
|
|
<th>Payload</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="eventsModalBody"></tbody>
|
|
</table>
|
|
</div>
|
|
<div class="d-flex justify-content-between align-items-center mt-2">
|
|
<small class="text-muted" id="eventsPagerInfo"></small>
|
|
<div class="btn-group btn-group-sm">
|
|
<button class="btn btn-outline-secondary" id="btnPrevEvents">Forrige</button>
|
|
<button class="btn btn-outline-secondary" id="btnNextEvents">Næste</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
(() => {
|
|
const state = {
|
|
selectedDraftId: null,
|
|
eventsLimit: 20,
|
|
eventsOffset: 0,
|
|
eventsTotal: 0,
|
|
};
|
|
|
|
const el = (id) => document.getElementById(id);
|
|
|
|
const statusBadge = (status) => {
|
|
const s = (status || '').toLowerCase();
|
|
return `<span class="status-badge status-${s || 'pending'}">${s || 'pending'}</span>`;
|
|
};
|
|
|
|
const fetchJson = async (url, options = {}) => {
|
|
const res = await fetch(url, options);
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(text || `HTTP ${res.status}`);
|
|
}
|
|
return res.json();
|
|
};
|
|
|
|
const loadDashboard = async () => {
|
|
const data = await fetchJson('/api/v1/billing/drafts/sync-dashboard?limit=20');
|
|
const summary = data.summary || {};
|
|
|
|
el('kpiTotal').textContent = summary.total_count || 0;
|
|
el('kpiPending').textContent = summary.pending_count || 0;
|
|
el('kpiExported').textContent = summary.exported_count || 0;
|
|
el('kpiFailed').textContent = summary.failed_count || 0;
|
|
el('kpiPosted').textContent = summary.posted_count || 0;
|
|
el('kpiPaid').textContent = summary.paid_count || 0;
|
|
|
|
const attention = data.attention_items || [];
|
|
const tbody = el('attentionBody');
|
|
if (!attention.length) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-muted">Ingen attention items</td></tr>';
|
|
} else {
|
|
tbody.innerHTML = attention.map(row => `
|
|
<tr>
|
|
<td>
|
|
<div class="fw-semibold">#${row.id} ${row.title || ''}</div>
|
|
<div class="text-muted small">Kunde ${row.customer_id || '-'}</div>
|
|
</td>
|
|
<td>${statusBadge(row.sync_status)}</td>
|
|
<td class="mono">${row.economic_order_number || '-'}</td>
|
|
<td class="mono">${row.economic_invoice_number || '-'}</td>
|
|
<td>
|
|
<div class="small">${row.latest_event_type || '-'}</div>
|
|
<div class="text-muted small">${row.latest_event_at || ''}</div>
|
|
</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-outline-primary" data-open-events="${row.id}">Events</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
const recent = data.recent_events || [];
|
|
const list = el('recentEventsList');
|
|
if (!recent.length) {
|
|
list.innerHTML = '<div class="text-muted">Ingen events endnu.</div>';
|
|
} else {
|
|
list.innerHTML = recent.map(ev => `
|
|
<div class="event-card mb-2">
|
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
|
<strong>#${ev.draft_id} ${ev.event_type}</strong>
|
|
${statusBadge(ev.to_status || ev.sync_status || 'pending')}
|
|
</div>
|
|
<div class="small text-muted">${ev.created_at || ''}</div>
|
|
<div class="small">${ev.draft_title || ''}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
};
|
|
|
|
const runReconcile = async (applyChanges) => {
|
|
await fetchJson('/api/v1/billing/drafts/reconcile-sync-status', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ apply: applyChanges }),
|
|
});
|
|
await loadDashboard();
|
|
};
|
|
|
|
const loadEventsForDraft = async () => {
|
|
if (!state.selectedDraftId) return;
|
|
const qs = new URLSearchParams({
|
|
limit: String(state.eventsLimit),
|
|
offset: String(state.eventsOffset),
|
|
});
|
|
|
|
const eventType = el('filterEventType').value.trim();
|
|
const fromStatus = el('filterFromStatus').value.trim();
|
|
const toStatus = el('filterToStatus').value.trim();
|
|
if (eventType) qs.set('event_type', eventType);
|
|
if (fromStatus) qs.set('from_status', fromStatus);
|
|
if (toStatus) qs.set('to_status', toStatus);
|
|
|
|
const data = await fetchJson(`/api/v1/ordre/drafts/${state.selectedDraftId}/sync-events?${qs.toString()}`);
|
|
const items = data.items || [];
|
|
state.eventsTotal = data.total || 0;
|
|
|
|
const body = el('eventsModalBody');
|
|
body.innerHTML = items.map(ev => `
|
|
<tr>
|
|
<td class="small">${ev.created_at || ''}</td>
|
|
<td class="mono">${ev.event_type || ''}</td>
|
|
<td>${ev.from_status || '-'}</td>
|
|
<td>${ev.to_status || '-'}</td>
|
|
<td><pre class="small mb-0 mono">${JSON.stringify(ev.event_payload || {}, null, 2)}</pre></td>
|
|
</tr>
|
|
`).join('') || '<tr><td colspan="5" class="text-center text-muted py-3">Ingen events</td></tr>';
|
|
|
|
const start = state.eventsOffset + 1;
|
|
const end = Math.min(state.eventsOffset + state.eventsLimit, state.eventsTotal);
|
|
el('eventsPagerInfo').textContent = state.eventsTotal ? `${start}-${end} af ${state.eventsTotal}` : '0 resultater';
|
|
|
|
el('btnPrevEvents').disabled = state.eventsOffset <= 0;
|
|
el('btnNextEvents').disabled = (state.eventsOffset + state.eventsLimit) >= state.eventsTotal;
|
|
};
|
|
|
|
document.addEventListener('click', async (e) => {
|
|
const target = e.target;
|
|
if (target.matches('[data-open-events]')) {
|
|
state.selectedDraftId = Number(target.getAttribute('data-open-events'));
|
|
state.eventsOffset = 0;
|
|
await loadEventsForDraft();
|
|
const modal = new bootstrap.Modal(el('eventsModal'));
|
|
modal.show();
|
|
}
|
|
});
|
|
|
|
el('btnRefreshAttention').addEventListener('click', loadDashboard);
|
|
el('btnRefreshEvents').addEventListener('click', loadDashboard);
|
|
el('btnPreviewReconcile').addEventListener('click', async () => runReconcile(false));
|
|
el('btnApplyReconcile').addEventListener('click', async () => runReconcile(true));
|
|
|
|
el('btnApplyEventFilters').addEventListener('click', async () => {
|
|
state.eventsOffset = 0;
|
|
await loadEventsForDraft();
|
|
});
|
|
|
|
el('btnClearEventFilters').addEventListener('click', async () => {
|
|
el('filterEventType').value = '';
|
|
el('filterFromStatus').value = '';
|
|
el('filterToStatus').value = '';
|
|
state.eventsOffset = 0;
|
|
await loadEventsForDraft();
|
|
});
|
|
|
|
el('btnPrevEvents').addEventListener('click', async () => {
|
|
state.eventsOffset = Math.max(0, state.eventsOffset - state.eventsLimit);
|
|
await loadEventsForDraft();
|
|
});
|
|
|
|
el('btnNextEvents').addEventListener('click', async () => {
|
|
state.eventsOffset += state.eventsLimit;
|
|
await loadEventsForDraft();
|
|
});
|
|
|
|
loadDashboard().catch((err) => {
|
|
console.error(err);
|
|
alert('Kunne ikke indlæse sync dashboard.');
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|