398 lines
17 KiB
HTML
398 lines
17 KiB
HTML
|
|
{% extends "shared/frontend/base.html" %}
|
||
|
|
|
||
|
|
{% block title %}Simply Import Oversigt - 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">📥 Simply Import Oversigt</h1>
|
||
|
|
<p class="text-muted mb-0">Parkeringsplads for importerede abonnementer før godkendelse</p>
|
||
|
|
</div>
|
||
|
|
<div class="d-flex gap-2">
|
||
|
|
<a href="/subscriptions" class="btn btn-outline-secondary">
|
||
|
|
<i class="bi bi-arrow-left me-1"></i>Tilbage til abonnementer
|
||
|
|
</a>
|
||
|
|
<button class="btn btn-primary" onclick="importSimplyStaging()">
|
||
|
|
<i class="bi bi-arrow-down-circle me-1"></i>Importér fra Simply CRM
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="card border-0 shadow-sm mb-4">
|
||
|
|
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
|
||
|
|
<div>
|
||
|
|
<h5 class="mb-0">Kundekø + godkendelse</h5>
|
||
|
|
<small class="text-muted">Map kunde og godkend valgte rækker pr. kunde</small>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="card-body">
|
||
|
|
<div class="row g-3">
|
||
|
|
<div class="col-lg-5">
|
||
|
|
<h6 class="mb-2">Kundekø</h6>
|
||
|
|
<div class="table-responsive" style="max-height: 340px;">
|
||
|
|
<table class="table table-sm align-middle mb-0">
|
||
|
|
<thead class="table-light">
|
||
|
|
<tr>
|
||
|
|
<th>Kunde</th>
|
||
|
|
<th>Rækker</th>
|
||
|
|
<th>Mapped</th>
|
||
|
|
<th></th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody id="stagingCustomersBody">
|
||
|
|
<tr><td colspan="4" class="text-muted text-center py-3">Indlæser...</td></tr>
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="col-lg-7">
|
||
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||
|
|
<h6 class="mb-0">Valgt kunde: <span id="selectedStagingCustomerName" class="text-muted">Ingen</span></h6>
|
||
|
|
<button class="btn btn-success btn-sm" id="approveSelectedBtn" onclick="approveSelectedStagingRows()" disabled>
|
||
|
|
<i class="bi bi-check2-circle me-1"></i>Godkend valgte
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div class="table-responsive" style="max-height: 340px;">
|
||
|
|
<table class="table table-sm align-middle mb-0">
|
||
|
|
<thead class="table-light">
|
||
|
|
<tr>
|
||
|
|
<th style="width: 36px;"></th>
|
||
|
|
<th>Abonnement</th>
|
||
|
|
<th>Beløb</th>
|
||
|
|
<th>Map kunde</th>
|
||
|
|
<th>Sag (valgfri)</th>
|
||
|
|
<th>Status</th>
|
||
|
|
<th></th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody id="stagingRowsBody">
|
||
|
|
<tr><td colspan="7" class="text-muted text-center py-3">Vælg en kunde fra køen</td></tr>
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="card border-0 shadow-sm mb-4">
|
||
|
|
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
|
||
|
|
<div>
|
||
|
|
<h5 class="mb-0">Alle importerede rækker</h5>
|
||
|
|
<small class="text-muted">Viser seneste importerede subscriptions (maks 500)</small>
|
||
|
|
</div>
|
||
|
|
<div class="d-flex gap-2">
|
||
|
|
<select class="form-select form-select-sm" id="stagingOverviewStatusFilter" style="min-width: 160px;">
|
||
|
|
<option value="all" selected>Alle statuser</option>
|
||
|
|
<option value="pending">Pending</option>
|
||
|
|
<option value="mapped">Mapped</option>
|
||
|
|
<option value="approved">Approved</option>
|
||
|
|
<option value="error">Fejl</option>
|
||
|
|
</select>
|
||
|
|
<button class="btn btn-outline-secondary btn-sm" onclick="loadStagingOverview()">
|
||
|
|
<i class="bi bi-arrow-clockwise me-1"></i>Opdater
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="card-body p-0">
|
||
|
|
<div class="table-responsive" style="max-height: 460px;">
|
||
|
|
<table class="table table-sm table-hover align-middle mb-0">
|
||
|
|
<thead class="table-light" style="position: sticky; top: 0; z-index: 1;">
|
||
|
|
<tr>
|
||
|
|
<th>ID</th>
|
||
|
|
<th>SO#</th>
|
||
|
|
<th>Kunde (Simply)</th>
|
||
|
|
<th>Hub kunde</th>
|
||
|
|
<th>Sag</th>
|
||
|
|
<th>Produkt</th>
|
||
|
|
<th>Beløb</th>
|
||
|
|
<th>Status</th>
|
||
|
|
<th>Opdateret</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody id="stagingOverviewBody">
|
||
|
|
<tr><td colspan="9" class="text-muted text-center py-3">Indlæser...</td></tr>
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
let currentStagingCustomerKey = null;
|
||
|
|
|
||
|
|
function stagingStatusBadge(status) {
|
||
|
|
const badges = {
|
||
|
|
pending: '<span class="badge bg-light text-dark">Pending</span>',
|
||
|
|
mapped: '<span class="badge bg-info text-dark">Mapped</span>',
|
||
|
|
approved: '<span class="badge bg-success">Approved</span>',
|
||
|
|
error: '<span class="badge bg-danger">Fejl</span>'
|
||
|
|
};
|
||
|
|
return badges[status] || `<span class="badge bg-light text-dark">${status || '-'}</span>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function escapeHtml(value) {
|
||
|
|
return String(value || '')
|
||
|
|
.replaceAll('&', '&')
|
||
|
|
.replaceAll('<', '<')
|
||
|
|
.replaceAll('>', '>')
|
||
|
|
.replaceAll('"', '"')
|
||
|
|
.replaceAll("'", ''');
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatCurrency(amount) {
|
||
|
|
return new Intl.NumberFormat('da-DK', {
|
||
|
|
style: 'currency',
|
||
|
|
currency: 'DKK',
|
||
|
|
minimumFractionDigits: 0,
|
||
|
|
maximumFractionDigits: 2
|
||
|
|
}).format(amount || 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatDate(dateStr) {
|
||
|
|
if (!dateStr) return '-';
|
||
|
|
const date = new Date(dateStr);
|
||
|
|
return date.toLocaleDateString('da-DK') + ' ' + date.toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' });
|
||
|
|
}
|
||
|
|
|
||
|
|
async function importSimplyStaging() {
|
||
|
|
try {
|
||
|
|
const response = await fetch('/api/v1/simply-subscription-staging/import', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' }
|
||
|
|
});
|
||
|
|
const data = await response.json();
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(data.detail || 'Import fejlede');
|
||
|
|
}
|
||
|
|
|
||
|
|
alert(`✅ Import færdig\nHentet: ${data.fetched}\nUpserted: ${data.upserted}\nAuto-mapped: ${data.auto_mapped}`);
|
||
|
|
await loadStagingCustomers();
|
||
|
|
await loadStagingOverview();
|
||
|
|
} catch (err) {
|
||
|
|
alert(`❌ Import fejl: ${err.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadStagingCustomers() {
|
||
|
|
const tbody = document.getElementById('stagingCustomersBody');
|
||
|
|
if (!tbody) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await fetch('/api/v1/simply-subscription-staging/customers?status=all');
|
||
|
|
const rows = await response.json();
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(rows.detail || 'Kunne ikke hente kø');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!rows || rows.length === 0) {
|
||
|
|
tbody.innerHTML = '<tr><td colspan="4" class="text-muted text-center py-3">Ingen rækker i parkeringsplads</td></tr>';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody.innerHTML = rows.map(item => {
|
||
|
|
const encodedKey = encodeURIComponent(item.customer_key || '');
|
||
|
|
const safeName = escapeHtml(item.source_customer_name || 'Ukendt');
|
||
|
|
return `
|
||
|
|
<tr>
|
||
|
|
<td>${safeName}</td>
|
||
|
|
<td>${item.row_count || 0}</td>
|
||
|
|
<td>${item.mapped_count || 0}</td>
|
||
|
|
<td>
|
||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="openStagingCustomerEncoded('${encodedKey}', '${safeName}')">Åbn</button>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
`;
|
||
|
|
}).join('');
|
||
|
|
} catch (err) {
|
||
|
|
tbody.innerHTML = `<tr><td colspan="4" class="text-danger text-center py-3">${escapeHtml(err.message)}</td></tr>`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadStagingOverview() {
|
||
|
|
const tbody = document.getElementById('stagingOverviewBody');
|
||
|
|
if (!tbody) return;
|
||
|
|
|
||
|
|
const status = document.getElementById('stagingOverviewStatusFilter')?.value || 'all';
|
||
|
|
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-3">Indlæser...</td></tr>';
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await fetch(`/api/v1/simply-subscription-staging/rows?status=${encodeURIComponent(status)}&limit=500`);
|
||
|
|
const rows = await response.json();
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(rows.detail || 'Kunne ikke hente oversigt');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!rows || rows.length === 0) {
|
||
|
|
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-3">Ingen importerede rækker</td></tr>';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody.innerHTML = rows.map(row => {
|
||
|
|
const amount = formatCurrency(row.source_total_amount || 0);
|
||
|
|
const updated = row.updated_at ? formatDate(row.updated_at) : '-';
|
||
|
|
const hubCustomer = row.hub_customer_name
|
||
|
|
? `${escapeHtml(row.hub_customer_name)} (#${row.hub_customer_id})`
|
||
|
|
: (row.hub_customer_id ? `#${row.hub_customer_id}` : '-');
|
||
|
|
const sag = row.hub_sag_id ? `<a href="/sag/${row.hub_sag_id}">#${row.hub_sag_id}</a>` : '-';
|
||
|
|
return `
|
||
|
|
<tr>
|
||
|
|
<td>${row.id}</td>
|
||
|
|
<td>${escapeHtml(row.source_salesorder_no || '-')}</td>
|
||
|
|
<td>${escapeHtml(row.source_customer_name || '-')}</td>
|
||
|
|
<td>${hubCustomer}</td>
|
||
|
|
<td>${sag}</td>
|
||
|
|
<td>${escapeHtml(row.source_subject || '-')}</td>
|
||
|
|
<td>${amount}</td>
|
||
|
|
<td>${stagingStatusBadge(row.approval_status)}</td>
|
||
|
|
<td>${updated}</td>
|
||
|
|
</tr>
|
||
|
|
`;
|
||
|
|
}).join('');
|
||
|
|
} catch (err) {
|
||
|
|
tbody.innerHTML = `<tr><td colspan="9" class="text-danger text-center py-3">${escapeHtml(err.message)}</td></tr>`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function openStagingCustomerEncoded(encodedKey, safeName) {
|
||
|
|
const key = decodeURIComponent(encodedKey || '');
|
||
|
|
openStagingCustomer(key, safeName);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function openStagingCustomer(customerKey, customerName) {
|
||
|
|
currentStagingCustomerKey = customerKey;
|
||
|
|
document.getElementById('selectedStagingCustomerName').textContent = customerName || 'Ukendt';
|
||
|
|
document.getElementById('approveSelectedBtn').disabled = false;
|
||
|
|
|
||
|
|
const tbody = document.getElementById('stagingRowsBody');
|
||
|
|
tbody.innerHTML = '<tr><td colspan="7" class="text-muted text-center py-3">Indlæser...</td></tr>';
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await fetch(`/api/v1/simply-subscription-staging/customers/${encodeURIComponent(customerKey)}/rows`);
|
||
|
|
const rows = await response.json();
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(rows.detail || 'Kunne ikke hente rækker');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!rows || rows.length === 0) {
|
||
|
|
tbody.innerHTML = '<tr><td colspan="7" class="text-muted text-center py-3">Ingen rækker fundet</td></tr>';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody.innerHTML = rows.map(row => {
|
||
|
|
const approved = row.approval_status === 'approved';
|
||
|
|
const amount = formatCurrency(row.source_total_amount || 0);
|
||
|
|
const title = row.source_subject || row.source_salesorder_no || row.source_record_id || `#${row.id}`;
|
||
|
|
return `
|
||
|
|
<tr>
|
||
|
|
<td>
|
||
|
|
<input type="checkbox" class="form-check-input staging-row-check" data-row-id="${row.id}" ${approved ? 'disabled' : 'checked'}>
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
<div class="fw-semibold">${escapeHtml(title)}</div>
|
||
|
|
<div class="small text-muted">${escapeHtml(row.source_billing_frequency || '-')}</div>
|
||
|
|
</td>
|
||
|
|
<td>${amount}</td>
|
||
|
|
<td>
|
||
|
|
<input type="number" class="form-control form-control-sm" id="mapCustomer-${row.id}" value="${row.hub_customer_id || ''}" placeholder="kunde id">
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
<input type="number" class="form-control form-control-sm" id="mapSag-${row.id}" value="${row.hub_sag_id || ''}" placeholder="auto">
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
${stagingStatusBadge(row.approval_status)}
|
||
|
|
${row.approval_error ? `<div class="small text-danger mt-1">${escapeHtml(row.approval_error)}</div>` : ''}
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="saveStagingMap(${row.id})" ${approved ? 'disabled' : ''}>Gem map</button>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
`;
|
||
|
|
}).join('');
|
||
|
|
} catch (err) {
|
||
|
|
tbody.innerHTML = `<tr><td colspan="7" class="text-danger text-center py-3">${escapeHtml(err.message)}</td></tr>`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveStagingMap(rowId) {
|
||
|
|
const customerValue = document.getElementById(`mapCustomer-${rowId}`)?.value;
|
||
|
|
const sagValue = document.getElementById(`mapSag-${rowId}`)?.value;
|
||
|
|
|
||
|
|
if (!customerValue) {
|
||
|
|
alert('Angiv Hub kunde-ID før mapping');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await fetch(`/api/v1/simply-subscription-staging/${rowId}/map`, {
|
||
|
|
method: 'PATCH',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
hub_customer_id: parseInt(customerValue, 10),
|
||
|
|
hub_sag_id: sagValue ? parseInt(sagValue, 10) : null,
|
||
|
|
})
|
||
|
|
});
|
||
|
|
const result = await response.json();
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(result.detail || 'Kunne ikke gemme mapping');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (currentStagingCustomerKey) {
|
||
|
|
await openStagingCustomer(currentStagingCustomerKey, document.getElementById('selectedStagingCustomerName').textContent);
|
||
|
|
}
|
||
|
|
await loadStagingCustomers();
|
||
|
|
await loadStagingOverview();
|
||
|
|
} catch (err) {
|
||
|
|
alert(`❌ Mapping fejl: ${err.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function approveSelectedStagingRows() {
|
||
|
|
if (!currentStagingCustomerKey) {
|
||
|
|
alert('Vælg en kunde først');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const selectedRowIds = Array.from(document.querySelectorAll('.staging-row-check:checked'))
|
||
|
|
.map(el => parseInt(el.getAttribute('data-row-id'), 10))
|
||
|
|
.filter(Number.isInteger);
|
||
|
|
|
||
|
|
if (selectedRowIds.length === 0) {
|
||
|
|
alert('Vælg mindst én række');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await fetch(`/api/v1/simply-subscription-staging/customers/${encodeURIComponent(currentStagingCustomerKey)}/approve`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ row_ids: selectedRowIds })
|
||
|
|
});
|
||
|
|
const result = await response.json();
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(result.detail || 'Godkendelse fejlede');
|
||
|
|
}
|
||
|
|
|
||
|
|
alert(`✅ Godkendelse færdig\nApproved: ${result.approved_count}\nErrors: ${result.error_count}`);
|
||
|
|
await openStagingCustomer(currentStagingCustomerKey, document.getElementById('selectedStagingCustomerName').textContent);
|
||
|
|
await loadStagingCustomers();
|
||
|
|
await loadStagingOverview();
|
||
|
|
} catch (err) {
|
||
|
|
alert(`❌ Godkendelsesfejl: ${err.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
document.addEventListener('DOMContentLoaded', () => {
|
||
|
|
const overviewFilter = document.getElementById('stagingOverviewStatusFilter');
|
||
|
|
if (overviewFilter) {
|
||
|
|
overviewFilter.addEventListener('change', loadStagingOverview);
|
||
|
|
}
|
||
|
|
|
||
|
|
loadStagingCustomers();
|
||
|
|
loadStagingOverview();
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
{% endblock %}
|