bmc_hub/app/subscriptions/frontend/simply_imports.html
Christian 3cddb71cec feat: Add Technician Dashboard V1, V2, and V3 with enhanced UI and functionality
- Introduced Technician Dashboard V1 (tech_v1_overview.html) with KPI cards and new cases overview.
- Implemented Technician Dashboard V2 (tech_v2_workboard.html) featuring a workboard layout for daily tasks and opportunities.
- Developed Technician Dashboard V3 (tech_v3_table_focus.html) with a power table for detailed case management.
- Created a dashboard selector page (technician_dashboard_selector.html) for easy navigation between dashboard versions.
- Added user dashboard preferences migration (130_user_dashboard_preferences.sql) to store default dashboard paths.
- Enhanced sag_sager table with assigned group ID (131_sag_assignment_group.sql) for better case management.
- Updated sag_subscriptions table to include cancellation rules and billing dates (132_subscription_cancellation.sql, 134_subscription_billing_dates.sql).
- Implemented subscription staging for CRM integration (136_simply_subscription_staging.sql).
- Added a script to move time tracking section in detail view (move_time_section.py).
- Created a test script for subscription processing (test_subscription_processing.py).
2026-02-17 08:29:05 +01:00

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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
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 %}