bmc_hub/app/modules/orders/templates/list.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 %}