463 lines
20 KiB
HTML
463 lines
20 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}Ordre - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.ordre-card {
|
|
background: var(--bg-card);
|
|
border-radius: 12px;
|
|
padding: 1rem 1.25rem;
|
|
border: 1px solid rgba(0,0,0,0.06);
|
|
}
|
|
.ordre-title {
|
|
font-size: 0.8rem;
|
|
text-transform: uppercase;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 0.35rem;
|
|
letter-spacing: 0.6px;
|
|
}
|
|
.ordre-value {
|
|
font-size: 1.35rem;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
}
|
|
.table thead th {
|
|
font-size: 0.8rem;
|
|
text-transform: uppercase;
|
|
color: var(--text-secondary);
|
|
background: var(--accent);
|
|
color: white;
|
|
}
|
|
.order-row {
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
}
|
|
.order-row:hover {
|
|
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.05);
|
|
}
|
|
.sync-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
}
|
|
.sync-actions .form-select {
|
|
min-width: 128px;
|
|
}
|
|
.latest-event {
|
|
max-width: 210px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.selected-counter {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 0.25rem 0.6rem;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--accent);
|
|
color: var(--accent);
|
|
background: rgba(var(--accent-rgb, 15, 76, 117), 0.08);
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid py-4">
|
|
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
|
|
<div>
|
|
<h2 class="mb-1"><i class="bi bi-receipt me-2"></i>Ordre</h2>
|
|
<div class="text-muted">Oversigt over alle ordre</div>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<a href="/ordre/create/new" class="btn btn-success"><i class="bi bi-plus-circle me-1"></i>Opret ny ordre</a>
|
|
<select id="syncStatusFilter" class="form-select" style="min-width: 170px;" onchange="renderOrders()">
|
|
<option value="all">Alle sync-status</option>
|
|
<option value="pending">pending</option>
|
|
<option value="exported">exported</option>
|
|
<option value="failed">failed</option>
|
|
<option value="posted">posted</option>
|
|
<option value="paid">paid</option>
|
|
</select>
|
|
<span id="selectedCountBadge" class="selected-counter">Valgte: 0</span>
|
|
<button class="btn btn-outline-success" onclick="markSelectedOrdersPaid()"><i class="bi bi-cash-stack me-1"></i>Markér valgte som betalt</button>
|
|
<button class="btn btn-outline-primary" onclick="loadOrders()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3 mb-3">
|
|
<div class="col-md-3"><div class="ordre-card"><div class="ordre-title">Total ordre</div><div id="sumOrders" class="ordre-value">0</div></div></div>
|
|
<div class="col-md-3"><div class="ordre-card"><div class="ordre-title">Seneste måned</div><div id="sumRecent" class="ordre-value">0</div></div></div>
|
|
<div class="col-md-3"><div class="ordre-card"><div class="ordre-title">Eksporteret</div><div id="sumExported" class="ordre-value">0</div></div></div>
|
|
<div class="col-md-3"><div class="ordre-card"><div class="ordre-title">Ikke eksporteret</div><div id="sumNotExported" class="ordre-value">0</div></div></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 42px;"><input id="selectAllOrders" type="checkbox" onchange="toggleSelectAll(this.checked)"></th>
|
|
<th>Ordre #</th>
|
|
<th>Titel</th>
|
|
<th>Kunde</th>
|
|
<th>Linjer</th>
|
|
<th>Oprettet</th>
|
|
<th>Sidst opdateret</th>
|
|
<th>Sidst eksporteret</th>
|
|
<th>Seneste event</th>
|
|
<th>Status</th>
|
|
<th>Sync</th>
|
|
<th>Handlinger</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="ordersTableBody">
|
|
<tr><td colspan="12" class="text-muted text-center py-4">Indlæser...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080;">
|
|
<div id="ordersToast" class="toast align-items-center text-bg-dark border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
|
<div class="d-flex">
|
|
<div class="toast-body" id="ordersToastBody">-</div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
let orders = [];
|
|
let ordersToast = null;
|
|
let selectedOrderIds = new Set();
|
|
|
|
function showToast(message, variant = 'dark') {
|
|
const toastEl = document.getElementById('ordersToast');
|
|
const bodyEl = document.getElementById('ordersToastBody');
|
|
if (!toastEl || !bodyEl || typeof bootstrap === 'undefined') {
|
|
console.log(message);
|
|
return;
|
|
}
|
|
|
|
toastEl.className = 'toast align-items-center border-0';
|
|
toastEl.classList.add(`text-bg-${variant}`);
|
|
bodyEl.textContent = message;
|
|
if (!ordersToast) {
|
|
ordersToast = new bootstrap.Toast(toastEl, { delay: 2800 });
|
|
}
|
|
ordersToast.show();
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function getFilteredOrders() {
|
|
const filter = (document.getElementById('syncStatusFilter')?.value || 'all').toLowerCase();
|
|
if (filter === 'all') return orders;
|
|
return orders.filter(order => String(order.sync_status || 'pending').toLowerCase() === filter);
|
|
}
|
|
|
|
function renderOrders() {
|
|
const visibleOrders = getFilteredOrders();
|
|
const tbody = document.getElementById('ordersTableBody');
|
|
if (!orders.length || !visibleOrders.length) {
|
|
const message = orders.length
|
|
? 'Ingen ordre matcher det valgte filter'
|
|
: 'Ingen ordre fundet';
|
|
tbody.innerHTML = `<tr><td colspan="12" class="text-muted text-center py-4">${message}</td></tr>`;
|
|
updateSummary(visibleOrders);
|
|
syncSelectAllCheckbox(visibleOrders);
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = visibleOrders.map(order => {
|
|
const lines = Array.isArray(order.lines_json) ? order.lines_json : [];
|
|
const hasExported = order.last_exported_at ? true : false;
|
|
const statusBadge = hasExported
|
|
? '<span class="badge bg-success">Eksporteret</span>'
|
|
: '<span class="badge bg-warning text-dark">Ikke eksporteret</span>';
|
|
|
|
const syncStatus = String(order.sync_status || 'pending').toLowerCase();
|
|
let syncBadge = '<span class="badge bg-warning text-dark">pending</span>';
|
|
if (syncStatus === 'failed') syncBadge = '<span class="badge bg-danger">failed</span>';
|
|
if (syncStatus === 'exported') syncBadge = '<span class="badge bg-primary">exported</span>';
|
|
if (syncStatus === 'posted') syncBadge = '<span class="badge bg-info text-dark">posted</span>';
|
|
if (syncStatus === 'paid') syncBadge = '<span class="badge bg-success">paid</span>';
|
|
|
|
const isChecked = selectedOrderIds.has(order.id);
|
|
const latestEventType = order.latest_event_type || '-';
|
|
const latestEventAt = order.latest_event_at ? formatDate(order.latest_event_at) : '-';
|
|
|
|
return `
|
|
<tr class="order-row" onclick="window.location.href='/ordre/${order.id}'">
|
|
<td onclick="event.stopPropagation();">
|
|
<input type="checkbox" ${isChecked ? 'checked' : ''} onchange="toggleOrderSelection(${order.id}, this.checked)">
|
|
</td>
|
|
<td><strong>#${order.id}</strong></td>
|
|
<td>${order.title || '-'}</td>
|
|
<td>${order.customer_id ? `Kunde ${order.customer_id}` : '-'}</td>
|
|
<td><span class="badge bg-primary">${lines.length} linjer</span></td>
|
|
<td>${formatDate(order.created_at)}</td>
|
|
<td>${formatDate(order.updated_at)}</td>
|
|
<td>${formatDate(order.last_exported_at)}</td>
|
|
<td class="latest-event" title="${latestEventType} · ${latestEventAt}">
|
|
<div class="small fw-semibold">${latestEventType}</div>
|
|
<div class="small text-muted">${latestEventAt}</div>
|
|
</td>
|
|
<td>${statusBadge}</td>
|
|
<td>
|
|
<div class="sync-actions" onclick="event.stopPropagation();">
|
|
<span>${syncBadge}</span>
|
|
<select class="form-select form-select-sm" id="syncStatus-${order.id}">
|
|
<option value="pending" ${syncStatus === 'pending' ? 'selected' : ''}>pending</option>
|
|
<option value="exported" ${syncStatus === 'exported' ? 'selected' : ''}>exported</option>
|
|
<option value="failed" ${syncStatus === 'failed' ? 'selected' : ''}>failed</option>
|
|
<option value="posted" ${syncStatus === 'posted' ? 'selected' : ''}>posted</option>
|
|
<option value="paid" ${syncStatus === 'paid' ? 'selected' : ''}>paid</option>
|
|
</select>
|
|
<button class="btn btn-sm btn-outline-primary" title="Gem sync" onclick="saveQuickSyncStatus(${order.id})">
|
|
<i class="bi bi-check2"></i>
|
|
</button>
|
|
${syncStatus === 'posted' ? `
|
|
<button class="btn btn-sm btn-outline-success" title="Markér som betalt" onclick="markOrderPaid(${order.id})">
|
|
<i class="bi bi-cash-coin"></i>
|
|
</button>
|
|
` : ''}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); window.location.href='/ordre/${order.id}'">
|
|
<i class="bi bi-eye"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" onclick="event.stopPropagation(); deleteOrder(${order.id})">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
syncSelectAllCheckbox(visibleOrders);
|
|
updateSelectedCounter();
|
|
|
|
updateSummary(visibleOrders);
|
|
}
|
|
|
|
function toggleOrderSelection(orderId, checked) {
|
|
if (checked) {
|
|
selectedOrderIds.add(orderId);
|
|
} else {
|
|
selectedOrderIds.delete(orderId);
|
|
}
|
|
syncSelectAllCheckbox();
|
|
updateSelectedCounter();
|
|
}
|
|
|
|
function toggleSelectAll(checked) {
|
|
const visibleOrders = getFilteredOrders();
|
|
if (checked) {
|
|
visibleOrders.forEach(order => selectedOrderIds.add(order.id));
|
|
} else {
|
|
visibleOrders.forEach(order => selectedOrderIds.delete(order.id));
|
|
}
|
|
renderOrders();
|
|
}
|
|
|
|
function updateSelectedCounter() {
|
|
const badge = document.getElementById('selectedCountBadge');
|
|
if (!badge) return;
|
|
const visibleIds = new Set(getFilteredOrders().map(order => order.id));
|
|
const selectedVisibleCount = Array.from(selectedOrderIds).filter(id => visibleIds.has(id)).length;
|
|
badge.textContent = `Valgte: ${selectedVisibleCount}`;
|
|
}
|
|
|
|
function syncSelectAllCheckbox(visibleOrders = null) {
|
|
const selectAll = document.getElementById('selectAllOrders');
|
|
if (!selectAll) return;
|
|
const rows = Array.isArray(visibleOrders) ? visibleOrders : getFilteredOrders();
|
|
if (!rows.length) {
|
|
selectAll.checked = false;
|
|
return;
|
|
}
|
|
selectAll.checked = rows.every(order => selectedOrderIds.has(order.id));
|
|
}
|
|
|
|
function updateSummary(visibleOrders = null) {
|
|
const rows = Array.isArray(visibleOrders) ? visibleOrders : getFilteredOrders();
|
|
const now = new Date();
|
|
const oneMonthAgo = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
|
|
|
const recentOrders = rows.filter(order => new Date(order.created_at) >= oneMonthAgo);
|
|
const exportedOrders = rows.filter(order => order.last_exported_at);
|
|
const notExportedOrders = rows.filter(order => !order.last_exported_at);
|
|
|
|
document.getElementById('sumOrders').textContent = rows.length;
|
|
document.getElementById('sumRecent').textContent = recentOrders.length;
|
|
document.getElementById('sumExported').textContent = exportedOrders.length;
|
|
document.getElementById('sumNotExported').textContent = notExportedOrders.length;
|
|
}
|
|
|
|
async function loadOrders() {
|
|
const tbody = document.getElementById('ordersTableBody');
|
|
tbody.innerHTML = '<tr><td colspan="12" class="text-muted text-center py-4">Indlæser...</td></tr>';
|
|
|
|
try {
|
|
const res = await fetch('/api/v1/ordre/drafts?limit=100');
|
|
if (!res.ok) {
|
|
const errorData = await res.json().catch(() => ({}));
|
|
console.error('API Error:', res.status, errorData);
|
|
throw new Error(errorData.detail || `HTTP ${res.status}: Kunne ikke hente ordre`);
|
|
}
|
|
|
|
const data = await res.json();
|
|
console.log('Fetched orders:', data);
|
|
|
|
orders = Array.isArray(data) ? data : [];
|
|
const availableIds = new Set(orders.map(order => order.id));
|
|
selectedOrderIds = new Set(Array.from(selectedOrderIds).filter(id => availableIds.has(id)));
|
|
updateSelectedCounter();
|
|
|
|
if (orders.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="12" class="text-muted text-center py-4">Ingen ordre fundet. <a href="/ordre/create/new" class="btn btn-sm btn-success ms-2">Opret første ordre</a></td></tr>';
|
|
updateSummary([]);
|
|
return;
|
|
}
|
|
|
|
// Fetch lines_json for each order to get line count
|
|
const detailPromises = orders.map(async (order) => {
|
|
try {
|
|
const detailRes = await fetch(`/api/v1/ordre/drafts/${order.id}`);
|
|
if (detailRes.ok) {
|
|
const detail = await detailRes.json();
|
|
order.lines_json = detail.lines_json || [];
|
|
} else {
|
|
order.lines_json = [];
|
|
}
|
|
} catch (e) {
|
|
console.error(`Failed to fetch details for order ${order.id}:`, e);
|
|
order.lines_json = [];
|
|
}
|
|
});
|
|
|
|
await Promise.all(detailPromises);
|
|
|
|
renderOrders();
|
|
} catch (error) {
|
|
console.error('Load orders error:', error);
|
|
tbody.innerHTML = `<tr><td colspan="12" class="text-danger text-center py-4">${error.message || 'Kunne ikke hente ordre'}</td></tr>`;
|
|
orders = [];
|
|
updateSummary([]);
|
|
}
|
|
}
|
|
|
|
async function markSelectedOrdersPaid() {
|
|
const ids = Array.from(selectedOrderIds).map(Number).filter(Boolean);
|
|
if (!ids.length) {
|
|
showToast('Vælg mindst én ordre først', 'warning');
|
|
return;
|
|
}
|
|
|
|
const postedEligibleIds = ids.filter(id => {
|
|
const order = orders.find(item => item.id === id);
|
|
return String(order?.sync_status || '').toLowerCase() === 'posted';
|
|
});
|
|
|
|
if (!postedEligibleIds.length) {
|
|
showToast('Ingen af de valgte ordre er i status posted', 'warning');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Markér ${postedEligibleIds.length} posted ordre som betalt?`)) return;
|
|
|
|
try {
|
|
const res = await fetch('/api/v1/billing/drafts/reconcile-sync-status', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ apply: true, mark_paid_ids: postedEligibleIds }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.detail || 'Kunne ikke opdatere valgte ordre');
|
|
|
|
await loadOrders();
|
|
const paidCount = postedEligibleIds.filter(id => {
|
|
const order = orders.find(item => item.id === id);
|
|
return String(order?.sync_status || '').toLowerCase() === 'paid';
|
|
}).length;
|
|
showToast(`${paidCount}/${postedEligibleIds.length} posted ordre sat til paid`, paidCount > 0 ? 'success' : 'warning');
|
|
} catch (error) {
|
|
showToast(`Fejl: ${error.message}`, 'danger');
|
|
}
|
|
}
|
|
|
|
async function saveQuickSyncStatus(orderId) {
|
|
const select = document.getElementById(`syncStatus-${orderId}`);
|
|
const syncStatus = (select?.value || '').trim().toLowerCase();
|
|
if (!syncStatus) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/ordre/drafts/${orderId}/sync-status`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sync_status: syncStatus }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.detail || 'Kunne ikke opdatere sync status');
|
|
await loadOrders();
|
|
showToast(`Sync status gemt for ordre #${orderId}`, 'success');
|
|
} catch (error) {
|
|
showToast(`Fejl: ${error.message}`, 'danger');
|
|
}
|
|
}
|
|
|
|
async function markOrderPaid(orderId) {
|
|
if (!confirm('Markér ordren som betalt?')) return;
|
|
|
|
try {
|
|
const res = await fetch('/api/v1/billing/drafts/reconcile-sync-status', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ apply: true, mark_paid_ids: [orderId] }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.detail || 'Kunne ikke markere som betalt');
|
|
await loadOrders();
|
|
showToast(`Ordre #${orderId} markeret som betalt`, 'success');
|
|
} catch (error) {
|
|
showToast(`Fejl: ${error.message}`, 'danger');
|
|
}
|
|
}
|
|
|
|
async function deleteOrder(orderId) {
|
|
if (!confirm('Er du sikker på, at du vil slette denne ordre?')) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/ordre/drafts/${orderId}`, { method: 'DELETE' });
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
throw new Error(error.detail || 'Kunne ikke slette ordre');
|
|
}
|
|
|
|
await loadOrders();
|
|
showToast('Ordre slettet', 'success');
|
|
} catch (error) {
|
|
showToast(`Fejl: ${error.message}`, 'danger');
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadOrders();
|
|
});
|
|
</script>
|
|
{% endblock %}
|