352 lines
14 KiB
HTML
352 lines
14 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}FedEx Overblik - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.fedex-shell {
|
|
display: grid;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.fedex-hero {
|
|
border-radius: 14px;
|
|
border: 1px solid rgba(15, 76, 117, 0.16);
|
|
background: linear-gradient(135deg, rgba(15, 76, 117, 0.1), rgba(26, 117, 159, 0.08));
|
|
padding: 1rem 1.1rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.fedex-kpis {
|
|
display: grid;
|
|
gap: 0.7rem;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
}
|
|
|
|
.fedex-kpi {
|
|
border: 1px solid rgba(15, 76, 117, 0.16);
|
|
border-radius: 12px;
|
|
background: var(--bg-card);
|
|
padding: 0.75rem 0.85rem;
|
|
}
|
|
|
|
.fedex-kpi .label {
|
|
color: var(--text-secondary);
|
|
font-size: 0.76rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.fedex-kpi .value {
|
|
font-size: 1.35rem;
|
|
font-weight: 800;
|
|
margin-top: 0.2rem;
|
|
}
|
|
|
|
.fedex-filter-card {
|
|
border: 1px solid rgba(15, 76, 117, 0.16);
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.fedex-table-wrap {
|
|
border: 1px solid rgba(15, 76, 117, 0.14);
|
|
border-radius: 12px;
|
|
overflow: auto;
|
|
max-height: 70vh;
|
|
}
|
|
|
|
.fedex-table thead th {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 2;
|
|
background: var(--bg-card);
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
font-size: 0.74rem;
|
|
letter-spacing: 0.04em;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.fedex-table tbody td {
|
|
vertical-align: middle;
|
|
font-size: 0.88rem;
|
|
}
|
|
|
|
.fedex-status {
|
|
border-radius: 999px;
|
|
padding: 0.2rem 0.55rem;
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
border: 1px solid transparent;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.fedex-status.draft { background: rgba(108, 117, 125, 0.15); border-color: rgba(108, 117, 125, 0.3); color: #5f6b76; }
|
|
.fedex-status.submitted, .fedex-status.booked { background: rgba(13, 110, 253, 0.12); border-color: rgba(13, 110, 253, 0.3); color: #0a58ca; }
|
|
.fedex-status.in_transit { background: rgba(255, 193, 7, 0.15); border-color: rgba(255, 193, 7, 0.35); color: #996f00; }
|
|
.fedex-status.delivered { background: rgba(25, 135, 84, 0.14); border-color: rgba(25, 135, 84, 0.3); color: #146c43; }
|
|
.fedex-status.cancelled, .fedex-status.failed { background: rgba(220, 53, 69, 0.14); border-color: rgba(220, 53, 69, 0.3); color: #b02a37; }
|
|
|
|
.fedex-row-title {
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.fedex-row-meta {
|
|
color: var(--text-secondary);
|
|
font-size: 0.78rem;
|
|
margin-top: 0.15rem;
|
|
}
|
|
|
|
@media (max-width: 1100px) {
|
|
.fedex-kpis {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.fedex-kpis {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="fedex-shell">
|
|
<div class="fedex-hero">
|
|
<div>
|
|
<h2 class="fw-bold mb-1">FedEx Overblik</h2>
|
|
<div class="text-muted">Samlet visning af alle FedEx bestillinger og deres status.</div>
|
|
</div>
|
|
<button class="btn btn-outline-primary" id="refreshFedexBtn"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
|
|
</div>
|
|
|
|
<div class="fedex-kpis">
|
|
<div class="fedex-kpi"><div class="label">Total</div><div class="value" id="kpiTotal">0</div></div>
|
|
<div class="fedex-kpi"><div class="label">Aktive</div><div class="value" id="kpiActive">0</div></div>
|
|
<div class="fedex-kpi"><div class="label">Leveret</div><div class="value" id="kpiDelivered">0</div></div>
|
|
<div class="fedex-kpi"><div class="label">Fejl/Annulleret</div><div class="value" id="kpiFailed">0</div></div>
|
|
</div>
|
|
|
|
<div class="card fedex-filter-card">
|
|
<div class="card-body">
|
|
<div class="row g-3">
|
|
<div class="col-lg-5">
|
|
<label class="form-label small text-muted" for="fedexSearchInput">Søg</label>
|
|
<input id="fedexSearchInput" class="form-control" placeholder="Booking ref, tracking, modtager, by, case id...">
|
|
</div>
|
|
<div class="col-lg-3 col-md-6">
|
|
<label class="form-label small text-muted" for="fedexStatusFilter">Status</label>
|
|
<select id="fedexStatusFilter" class="form-select">
|
|
<option value="all">Alle</option>
|
|
<option value="draft">Draft</option>
|
|
<option value="submitted">Submitted</option>
|
|
<option value="booked">Booked</option>
|
|
<option value="in_transit">In transit</option>
|
|
<option value="delivered">Delivered</option>
|
|
<option value="cancelled">Cancelled</option>
|
|
<option value="failed">Failed</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-lg-2 col-md-6">
|
|
<label class="form-label small text-muted" for="fedexSortSelect">Sortering</label>
|
|
<select id="fedexSortSelect" class="form-select">
|
|
<option value="newest">Nyeste først</option>
|
|
<option value="oldest">Ældste først</option>
|
|
<option value="status">Status</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-lg-2 d-flex align-items-end">
|
|
<button class="btn btn-light border w-100" id="fedexClearBtn">Ryd filtre</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fedex-table-wrap">
|
|
<table class="table table-hover fedex-table mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Bestilling</th>
|
|
<th>Status</th>
|
|
<th>Tracking</th>
|
|
<th>Case</th>
|
|
<th>Afhentning</th>
|
|
<th>Pris</th>
|
|
<th class="text-end">Handlinger</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="fedexTableBody">
|
|
<tr><td colspan="7" class="text-center py-4 text-muted">Henter FedEx bestillinger...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
(() => {
|
|
const state = {
|
|
bookings: [],
|
|
filtered: [],
|
|
};
|
|
|
|
function escapeHtml(value) {
|
|
const span = document.createElement('span');
|
|
span.textContent = value ?? '';
|
|
return span.innerHTML;
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '-';
|
|
const dt = new Date(value);
|
|
if (Number.isNaN(dt.getTime())) return '-';
|
|
return dt.toLocaleString('da-DK');
|
|
}
|
|
|
|
function formatMoney(amount, currency) {
|
|
if (amount === null || amount === undefined || Number.isNaN(Number(amount))) return '-';
|
|
return `${Number(amount).toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency || 'DKK'}`;
|
|
}
|
|
|
|
function statusBadge(status) {
|
|
const s = String(status || 'draft').toLowerCase();
|
|
return `<span class="fedex-status ${escapeHtml(s)}">${escapeHtml(s.replaceAll('_', ' '))}</span>`;
|
|
}
|
|
|
|
function applyFilters() {
|
|
const q = (document.getElementById('fedexSearchInput')?.value || '').trim().toLowerCase();
|
|
const status = (document.getElementById('fedexStatusFilter')?.value || 'all').toLowerCase();
|
|
const sortBy = (document.getElementById('fedexSortSelect')?.value || 'newest').toLowerCase();
|
|
|
|
const rows = state.bookings.filter((item) => {
|
|
const itemStatus = String(item.shipment_status || '').toLowerCase();
|
|
if (status !== 'all' && itemStatus !== status) return false;
|
|
|
|
if (!q) return true;
|
|
const haystack = [
|
|
item.booking_ref,
|
|
item.tracking_number,
|
|
item.recipient_name,
|
|
item.city,
|
|
item.country_code,
|
|
item.service_type,
|
|
item.case_id,
|
|
].map((v) => String(v || '').toLowerCase()).join(' ');
|
|
return haystack.includes(q);
|
|
});
|
|
|
|
rows.sort((a, b) => {
|
|
if (sortBy === 'status') {
|
|
return String(a.shipment_status || '').localeCompare(String(b.shipment_status || ''), 'da');
|
|
}
|
|
const ta = new Date(a.created_at || 0).getTime() || 0;
|
|
const tb = new Date(b.created_at || 0).getTime() || 0;
|
|
return sortBy === 'oldest' ? ta - tb : tb - ta;
|
|
});
|
|
|
|
state.filtered = rows;
|
|
renderTable();
|
|
renderKpis();
|
|
}
|
|
|
|
function renderKpis() {
|
|
const total = state.filtered.length;
|
|
const delivered = state.filtered.filter((item) => item.shipment_status === 'delivered').length;
|
|
const failed = state.filtered.filter((item) => ['failed', 'cancelled'].includes(String(item.shipment_status || '').toLowerCase())).length;
|
|
const active = state.filtered.filter((item) => ['draft', 'submitted', 'booked', 'in_transit'].includes(String(item.shipment_status || '').toLowerCase())).length;
|
|
|
|
document.getElementById('kpiTotal').textContent = String(total);
|
|
document.getElementById('kpiActive').textContent = String(active);
|
|
document.getElementById('kpiDelivered').textContent = String(delivered);
|
|
document.getElementById('kpiFailed').textContent = String(failed);
|
|
}
|
|
|
|
function renderTable() {
|
|
const tbody = document.getElementById('fedexTableBody');
|
|
if (!tbody) return;
|
|
|
|
if (!state.filtered.length) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4 text-muted">Ingen FedEx bestillinger matcher filteret.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = state.filtered.map((item) => {
|
|
const trackingNumber = String(item.tracking_number || '').trim();
|
|
const trackingUrl = String(item.tracking_url || (trackingNumber ? `https://www.fedex.com/fedextrack/?trknbr=${encodeURIComponent(trackingNumber)}` : '')).trim();
|
|
const labelUrl = String(item.label_url || '').trim();
|
|
const openCaseUrl = Number(item.case_id) > 0 ? `/sag/${Number(item.case_id)}/v3` : '/sag';
|
|
|
|
return `
|
|
<tr>
|
|
<td>
|
|
<div class="fedex-row-title">${escapeHtml(item.booking_ref || '-')}</div>
|
|
<div class="fedex-row-meta">${escapeHtml(item.recipient_name || '-')} • ${escapeHtml(item.city || '-')} (${escapeHtml(item.country_code || '-')})</div>
|
|
</td>
|
|
<td>${statusBadge(item.shipment_status)}</td>
|
|
<td>
|
|
${trackingNumber ? `<span class="small fw-semibold">${escapeHtml(trackingNumber)}</span>` : '<span class="text-muted">-</span>'}
|
|
</td>
|
|
<td><a href="${openCaseUrl}" class="text-decoration-none">#${Number(item.case_id || 0)}</a></td>
|
|
<td>${escapeHtml(formatDate(item.pickup_window_start))}</td>
|
|
<td>${escapeHtml(formatMoney(item.total_amount, item.currency))}</td>
|
|
<td class="text-end">
|
|
${trackingUrl ? `<a href="${trackingUrl}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right"></i></a>` : ''}
|
|
${labelUrl ? `<a href="${labelUrl}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary ms-1"><i class="bi bi-file-earmark-text"></i></a>` : ''}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
async function loadBookings() {
|
|
const tbody = document.getElementById('fedexTableBody');
|
|
if (tbody) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4 text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter FedEx bestillinger...</td></tr>';
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/fedex/bookings', { credentials: 'include' });
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const payload = await response.json();
|
|
state.bookings = Array.isArray(payload?.items) ? payload.items : [];
|
|
applyFilters();
|
|
} catch (error) {
|
|
if (tbody) {
|
|
tbody.innerHTML = `<tr><td colspan="7" class="text-center py-4 text-danger">Kunne ikke hente FedEx bestillinger: ${escapeHtml(error.message || 'ukendt fejl')}</td></tr>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
document.getElementById('fedexSearchInput')?.addEventListener('input', applyFilters);
|
|
document.getElementById('fedexStatusFilter')?.addEventListener('change', applyFilters);
|
|
document.getElementById('fedexSortSelect')?.addEventListener('change', applyFilters);
|
|
|
|
document.getElementById('fedexClearBtn')?.addEventListener('click', () => {
|
|
document.getElementById('fedexSearchInput').value = '';
|
|
document.getElementById('fedexStatusFilter').value = 'all';
|
|
document.getElementById('fedexSortSelect').value = 'newest';
|
|
applyFilters();
|
|
});
|
|
|
|
document.getElementById('refreshFedexBtn')?.addEventListener('click', loadBookings);
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
bindEvents();
|
|
loadBookings();
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|