2026-07-09 23:44:30 +02:00
{% extends "shared/frontend/base.html" %}
{% block title %}Internet Wizard v2{% endblock %}
{% block extra_css %}
< style >
.wiz-hero {
background:
radial-gradient(circle at top right, rgba(15, 76, 117, 0.16), transparent 36%),
linear-gradient(135deg, rgba(15, 76, 117, 0.1), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 24px;
padding: 1.5rem;
box-shadow: 0 14px 34px rgba(15, 76, 117, 0.06);
}
.wiz-panel {
background: var(--bg-card);
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 20px;
box-shadow: 0 14px 30px rgba(15, 76, 117, 0.05);
}
.wiz-kpi {
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 16px;
padding: 1rem;
background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01));
}
.wiz-kpi-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
font-weight: 700;
}
.wiz-kpi-value {
font-size: 1.4rem;
font-weight: 700;
color: var(--text-primary);
}
.wiz-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 1rem;
}
.wiz-card {
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 16px;
padding: 1rem;
background: rgba(15, 76, 117, 0.03);
}
.wiz-card h3 {
font-size: 1rem;
margin-bottom: 0.35rem;
}
.wiz-meta {
color: var(--text-secondary);
font-size: 0.85rem;
}
.wiz-snippet {
border-left: 3px solid rgba(15, 76, 117, 0.25);
padding-left: 0.8rem;
margin-top: 0.75rem;
color: var(--text-primary);
}
.wiz-upload-box {
border: 1px dashed rgba(15, 76, 117, 0.24);
border-radius: 16px;
padding: 1rem;
background: rgba(15, 76, 117, 0.025);
}
.wiz-empty {
color: var(--text-secondary);
font-style: italic;
}
.wiz-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: 999px;
padding: 0.35rem 0.7rem;
background: rgba(15, 76, 117, 0.08);
color: #0f4c75;
font-size: 0.78rem;
font-weight: 700;
}
.wiz-list {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.wiz-summary {
min-height: 110px;
white-space: pre-wrap;
}
2026-07-17 01:58:02 +02:00
.wiz-segment-button {
width: 100%;
border: 0;
padding: 0;
text-align: left;
color: inherit;
background: transparent;
}
.wiz-segment-button:hover .wiz-card,
.wiz-segment-button:focus-visible .wiz-card {
border-color: rgba(15, 76, 117, 0.45);
box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.1);
}
.wiz-full-block {
white-space: pre-wrap;
max-height: 60vh;
overflow: auto;
margin: 0;
}
2026-07-09 23:44:30 +02:00
@media (max-width: 991px) {
.wiz-grid {
grid-template-columns: 1fr;
}
}
< / style >
{% endblock %}
{% block content %}
< div class = "container-fluid py-4" >
< div class = "wiz-hero mb-4" >
< div class = "d-flex flex-column flex-xl-row justify-content-between align-items-xl-center gap-3" >
< div >
< div class = "small text-uppercase fw-semibold text-muted mb-2" > Data migration / Internet< / div >
< h2 class = "h3 mb-1" > Internet Wizard v2< / h2 >
< div class = "text-muted" > Midlertidig research-wizard til gamle kundetekster, internetnoter og leverandørfakturaer fra de sidste 18 måneder.< / div >
< / div >
< div class = "wiz-pill" >
< i class = "bi bi-magic" > < / i >
Temp version
< / div >
< / div >
< / div >
< div class = "wiz-panel p-4 mb-4" >
< div class = "row g-3 align-items-end" >
< div class = "col-lg-5" >
< label class = "form-label fw-semibold" > Kunde< / label >
< input type = "text" class = "form-control" id = "customerSearchInput" list = "customerOptions" placeholder = "Søg kunde og vælg fra listen" >
< datalist id = "customerOptions" > < / datalist >
< input type = "hidden" id = "selectedCustomerId" >
< div class = "wiz-meta mt-2" id = "selectedCustomerMeta" > Ingen kunde valgt endnu.< / div >
< / div >
< div class = "col-lg-5" >
< label class = "form-label fw-semibold" > Søgning / fokus< / label >
< input type = "text" class = "form-control" id = "queryInput" placeholder = "fx fiber, MPLS, Lejrvej, public IP, gammel aftale" >
< / div >
< div class = "col-lg-2 d-grid" >
< button class = "btn btn-primary" type = "button" onclick = "loadWizardContext()" >
< i class = "bi bi-search me-1" > < / i > Vis info
< / button >
< / div >
< / div >
< / div >
< div class = "row g-3 mb-4" >
< div class = "col-6 col-xl-3" >
< div class = "wiz-kpi" >
< div class = "wiz-kpi-label" > Kundefiler< / div >
< div class = "wiz-kpi-value" id = "metricDocuments" > 0< / div >
< / div >
< / div >
< div class = "col-6 col-xl-3" >
< div class = "wiz-kpi" >
< div class = "wiz-kpi-label" > Blokfund< / div >
< div class = "wiz-kpi-value" id = "metricSnippets" > 0< / div >
< / div >
< / div >
< div class = "col-6 col-xl-3" >
< div class = "wiz-kpi" >
< div class = "wiz-kpi-label" > Internetfakturaer< / div >
< div class = "wiz-kpi-value" id = "metricInvoices" > 0< / div >
< / div >
< / div >
< div class = "col-6 col-xl-3" >
< div class = "wiz-kpi" >
< div class = "wiz-kpi-label" > Status< / div >
< div class = "wiz-kpi-value fs-6" id = "wizardStatusText" > Venter< / div >
< / div >
< / div >
< / div >
< div class = "wiz-grid mb-4" >
< div class = "wiz-panel p-4" >
< div class = "d-flex justify-content-between align-items-center mb-3" >
< div >
< h3 class = "mb-1" > AI-overblik< / h3 >
< div class = "wiz-meta" > Kort opsummering fra kundefiler og relevante internetfakturaer.< / div >
< / div >
< / div >
< div class = "wiz-card wiz-summary" id = "aiSummaryBox" > Vælg en kunde for at hente kontekst.< / div >
< / div >
< div class = "wiz-panel p-4" >
< div class = "d-flex justify-content-between align-items-center mb-3" >
< div >
< h3 class = "mb-1" > Upload tekstfiler< / h3 >
< div class = "wiz-meta" > Upload delte batch-filer med flere kunder. Systemet splitter dem i søgbare blokke med fx IP, CIDR, stiknr og referencer.< / div >
< / div >
< / div >
< div class = "wiz-upload-box" >
< div class = "mb-2" >
< input type = "file" class = "form-control" id = "customerFileInput" accept = ".txt,.csv,.log,.md" >
< / div >
< div class = "mb-2" >
< textarea class = "form-control" id = "customerFileNotes" rows = "3" placeholder = "Noter om filen eller kontekst" > < / textarea >
< / div >
< div class = "d-grid" >
< button class = "btn btn-outline-primary" type = "button" onclick = "uploadCustomerFile()" >
< i class = "bi bi-upload me-1" > < / i > Upload fil
< / button >
< / div >
< div class = "wiz-meta mt-2" id = "uploadStatus" > Ingen upload kørt endnu.< / div >
< / div >
< / div >
< / div >
< div class = "row g-4" >
< div class = "col-xl-6" >
< div class = "wiz-panel p-4 h-100" >
< div class = "d-flex justify-content-between align-items-center mb-3" >
< div >
< h3 class = "mb-1" > Kundetekster< / h3 >
< div class = "wiz-meta" > Uploadede filer og de bedste fund fra dem.< / div >
< / div >
< / div >
< div class = "wiz-list" id = "documentList" >
< div class = "wiz-empty" > Ingen dokumenter endnu.< / div >
< / div >
< / div >
< / div >
< div class = "col-xl-6" >
< div class = "wiz-panel p-4 mb-4" >
< div class = "d-flex justify-content-between align-items-center mb-3" >
< div >
< h3 class = "mb-1" > Præcise blokfund< / h3 >
< div class = "wiz-meta" > Her finder du den konkrete tekstblok med fx IP, stiknr eller reference.< / div >
< / div >
< / div >
< div class = "wiz-list" id = "segmentList" >
< div class = "wiz-empty" > Ingen blokfund endnu.< / div >
< / div >
< / div >
< / div >
< div class = "col-xl-12" >
< div class = "wiz-panel p-4 h-100" >
< div class = "d-flex justify-content-between align-items-center mb-3" >
< div >
< h3 class = "mb-1" > Relaterede fakturaer< / h3 >
< div class = "wiz-meta" > De sidste 18 måneder med internet/bredbånd-relateret tekst.< / div >
< / div >
< / div >
< div class = "wiz-list" id = "invoiceList" >
< div class = "wiz-empty" > Ingen fakturafund endnu.< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
2026-07-17 01:58:02 +02:00
< div class = "modal fade" id = "segmentModal" tabindex = "-1" aria-labelledby = "segmentModalTitle" aria-hidden = "true" >
< div class = "modal-dialog modal-lg modal-dialog-scrollable" >
< div class = "modal-content" >
< div class = "modal-header" >
< div >
< h5 class = "modal-title" id = "segmentModalTitle" > Tekstblok< / h5 >
< div class = "wiz-meta" id = "segmentModalMeta" > < / div >
< / div >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" aria-label = "Luk" > < / button >
< / div >
< div class = "modal-body" > < pre class = "wiz-full-block" id = "segmentModalContent" > Henter tekstblok…< / pre > < / div >
< / div >
< / div >
< / div >
2026-07-09 23:44:30 +02:00
{% endblock %}
{% block extra_js %}
< script >
let customerSearchTimer = null;
2026-07-17 01:58:02 +02:00
let customerSearchRequest = 0;
2026-07-09 23:44:30 +02:00
let customerOptions = [];
let localUploadedDocuments = [];
function escapeHtml(value) {
return String(value ?? '')
.replace(/& /g, '& ')
.replace(/< /g, '< ')
.replace(/>/g, '> ')
.replace(/"/g, '" ')
.replace(/'/g, '' ');
}
function formatDate(value) {
if (!value) return '-';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleDateString('da-DK');
}
function formatBytes(value) {
const size = Number(value || 0);
if (!size) return '0 B';
if (size < 1024 ) return ` $ { size } B ` ;
if (size < 1024 * 1024 ) return ` $ { ( size / 1024 ) . toFixed ( 1 ) } KB ` ;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function formatMoney(value, currency = 'DKK') {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: currency || 'DKK',
minimumFractionDigits: 2,
}).format(Number(value || 0));
}
function setWizardStatus(text) {
document.getElementById('wizardStatusText').textContent = text;
}
function renderLocalDocumentsOnly(documents = []) {
document.getElementById('metricDocuments').textContent = String(documents.length);
document.getElementById('metricSnippets').textContent = '0';
document.getElementById('metricInvoices').textContent = '0';
document.getElementById('aiSummaryBox').textContent = 'Viser uploadede filer. Vælg kunde eller genindlæs siden efter backend-genstart for fuld kontekst.';
const documentList = document.getElementById('documentList');
if (!documents.length) {
documentList.innerHTML = '< div class = "wiz-empty" > Ingen dokumenter i det delte arkiv endnu.< / div > ';
} else {
documentList.innerHTML = documents.map(item => `
< div class = "wiz-card" >
< div class = "d-flex justify-content-between align-items-start gap-3" >
< div >
< h3 > ${escapeHtml(item.original_filename || item.filename || 'Uploadet fil')}< / h3 >
< div class = "wiz-meta" > ${item.created_at ? formatDate(item.created_at) + ' · ' : ''}${item.file_size ? formatBytes(item.file_size) + ' · ' : ''}${escapeHtml(item.mime_type || '-')}< / div >
< / div >
< span class = "wiz-pill" > ${item.segment_count || 0} blokke< / span >
< / div >
${(item.notes ? `< div class = "wiz-meta mt-2" > ${escapeHtml(item.notes)}< / div > ` : '')}
< / div >
`).join('');
}
document.getElementById('segmentList').innerHTML = '< div class = "wiz-empty" > Vælg kunde eller søg på IP/stiknr/reference for konkrete blokfund.< / div > ';
document.getElementById('invoiceList').innerHTML = '< div class = "wiz-empty" > Vælg kunde for at se relaterede internetfakturaer.< / div > ';
}
function renderCustomerOptions(items) {
customerOptions = items || [];
const list = document.getElementById('customerOptions');
list.innerHTML = customerOptions.map(item => (
`< option value = "${escapeHtml(item.name)}" data-id = "${item.id}" > < / option > `
)).join('');
}
async function searchCustomers(query) {
const response = await fetch(`/api/v1/customers?search=${encodeURIComponent(query)}&limit=15&is_active=true`);
if (!response.ok) return [];
const payload = await response.json();
return Array.isArray(payload) ? payload : (payload.customers || payload.items || []);
}
async function onCustomerInputChanged() {
const input = document.getElementById('customerSearchInput');
const query = input.value.trim();
2026-07-17 01:58:02 +02:00
const requestId = ++customerSearchRequest;
2026-07-09 23:44:30 +02:00
document.getElementById('selectedCustomerId').value = '';
document.getElementById('selectedCustomerMeta').textContent = 'Vælg kunde fra listen.';
const exact = customerOptions.find(item => item.name === query);
if (exact) {
document.getElementById('selectedCustomerId').value = exact.id;
document.getElementById('selectedCustomerMeta').textContent = `Kunde-ID ${exact.id} · ${exact.name}`;
return;
}
if (query.length < 2 ) return ;
const items = await searchCustomers(query);
2026-07-17 01:58:02 +02:00
// Ignore a slower response from an earlier, shorter search phrase.
if (requestId !== customerSearchRequest || input.value.trim() !== query) return;
2026-07-09 23:44:30 +02:00
renderCustomerOptions(items);
}
async function loadWizardContext() {
const customerId = document.getElementById('selectedCustomerId').value;
const query = document.getElementById('queryInput').value.trim();
setWizardStatus('Henter');
if (!customerId & & !query) {
try {
const docRes = await fetch('/api/v1/internet-connections/customer-documents');
if (docRes.ok) {
const docPayload = await docRes.json();
const docs = docPayload.documents || [];
renderLocalDocumentsOnly(docs);
setWizardStatus('Klar');
return;
}
} catch (error) {
console.warn('Could not load shared document list', error);
}
if (localUploadedDocuments.length) {
renderLocalDocumentsOnly(localUploadedDocuments);
setWizardStatus('Klar');
return;
}
}
const params = new URLSearchParams();
if (customerId) {
params.set('customer_id', customerId);
}
if (query) {
params.set('query', query);
}
const response = await fetch(`/api/v1/internet-connections/migration-wizard-v2/context?${params.toString()}`);
if (!response.ok) {
let payload = {};
try {
payload = await response.json();
} catch (error) {
payload = {};
}
const detail = JSON.stringify(payload.detail || '');
if (!customerId & & detail.includes('customer_id')) {
if (localUploadedDocuments.length) {
renderLocalDocumentsOnly(localUploadedDocuments);
setWizardStatus('Klar');
return;
}
setWizardStatus('Venter');
return;
}
setWizardStatus('Fejl');
const errorText = payload.detail ? JSON.stringify(payload.detail) : await response.text();
alert(`Kunne ikke hente kontekst: ${errorText}`);
return;
}
const payload = await response.json();
renderWizardContext(payload);
setWizardStatus('Klar');
}
function renderWizardContext(payload) {
const documents = payload.documents || [];
const invoiceHits = payload.invoice_hits || [];
const segmentHits = payload.segment_hits || [];
const customer = payload.customer || null;
document.getElementById('metricDocuments').textContent = String(documents.length);
document.getElementById('metricSnippets').textContent = String(segmentHits.length);
document.getElementById('metricInvoices').textContent = String(invoiceHits.length);
document.getElementById('aiSummaryBox').textContent = payload.ai_summary || (customer ? 'Ingen AI-opsummering endnu.' : 'Viser delt arkiv. Vælg kunde eller søg på IP/stiknr/reference for mere præcise fund.');
document.getElementById('selectedCustomerMeta').textContent = customer
? `Kunde-ID ${customer.id} · ${customer.name}`
: 'Delt arkiv uden valgt kunde.';
const documentList = document.getElementById('documentList');
if (!documents.length) {
documentList.innerHTML = `< div class = "wiz-empty" > ${customer ? 'Ingen kundefiler fundet for denne kunde.' : 'Ingen dokumenter i det delte arkiv endnu.'}< / div > `;
} else {
documentList.innerHTML = documents.map(item => `
< div class = "wiz-card" >
< div class = "d-flex justify-content-between align-items-start gap-3" >
< div >
< h3 > ${escapeHtml(item.original_filename)}< / h3 >
< div class = "wiz-meta" > ${formatDate(item.created_at)} · ${formatBytes(item.file_size)} · ${escapeHtml(item.mime_type || '-')}< / div >
< / div >
< span class = "wiz-pill" > ${item.snippet_count || 0} fund< / span >
< / div >
${(item.notes ? `< div class = "wiz-meta mt-2" > ${escapeHtml(item.notes)}< / div > ` : '')}
${(item.snippets || []).map(snippet => `< div class = "wiz-snippet" > ${escapeHtml(snippet)}< / div > `).join('')}
< / div >
`).join('');
}
const segmentList = document.getElementById('segmentList');
if (!segmentHits.length) {
segmentList.innerHTML = `< div class = "wiz-empty" > ${customer ? 'Ingen blokke matcher kunden og søgningen endnu.' : 'Ingen blokfund i arkivet endnu.'}< / div > `;
} else {
segmentList.innerHTML = segmentHits.map(item => `
2026-07-17 01:58:02 +02:00
< button class = "wiz-segment-button" type = "button" onclick = "openSegment(${Number(item.segment_id)})" aria-label = "Vis hele tekstblokken: ${escapeHtml(item.title || 'Blok')}" >
< div class = "wiz-card" >
< div class = "d-flex justify-content-between align-items-start gap-3" >
< div >
< h3 > ${escapeHtml(item.title || 'Blok')}< / h3 >
< div class = "wiz-meta" > Dokument #${item.document_id} · score ${item.score} · Klik for hele blokken< / div >
< / div >
< span class = "wiz-pill" > blok ${Number(item.block_index || 0) + 1}< / span >
< / div >
< div class = "wiz-snippet" > ${escapeHtml(item.snippet || '-')}< / div >
< div class = "wiz-meta mt-2" >
${(item.ip_addresses || []).length ? `IP: ${escapeHtml(item.ip_addresses.join(', '))}` : ''}
${(item.cidr_blocks || []).length ? `${(item.ip_addresses || []).length ? ' · ' : ''}CIDR: ${escapeHtml(item.cidr_blocks.join(', '))}` : ''}
${(item.references || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length) ? ' · ' : ''}Ref: ${escapeHtml(item.references.join(', '))}` : ''}
${(item.socket_numbers || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length || (item.references || []).length) ? ' · ' : ''}Stik: ${escapeHtml(item.socket_numbers.join(', '))}` : ''}
2026-07-09 23:44:30 +02:00
< / div >
< / div >
2026-07-17 01:58:02 +02:00
< / button >
2026-07-09 23:44:30 +02:00
`).join('');
}
const invoiceList = document.getElementById('invoiceList');
if (!invoiceHits.length) {
invoiceList.innerHTML = `< div class = "wiz-empty" > ${customer ? 'Ingen internetrelaterede fakturafund de sidste 18 måneder.' : 'Vælg kunde for at se relaterede internetfakturaer.'}< / div > `;
} else {
invoiceList.innerHTML = invoiceHits.map(item => `
< div class = "wiz-card" >
< div class = "d-flex justify-content-between align-items-start gap-3" >
< div >
< h3 > ${escapeHtml(item.vendor_name)} · ${escapeHtml(item.invoice_number || '-')}< / h3 >
< div class = "wiz-meta" > ${formatDate(item.invoice_date)} · ${formatMoney(item.total_amount, item.currency)}${item.directly_linked ? ' · koblet til kunde' : ''}< / div >
< / div >
< span class = "wiz-pill" > score ${item.score}< / span >
< / div >
${(item.source_filename ? `< div class = "wiz-meta mt-2" > Fil: ${escapeHtml(item.source_filename)}< / div > ` : '')}
${(item.snippets || []).map(snippet => `< div class = "wiz-snippet" > ${escapeHtml(snippet)}< / div > `).join('')}
< / div >
`).join('');
}
}
2026-07-17 01:58:02 +02:00
async function openSegment(segmentId) {
const modalElement = document.getElementById('segmentModal');
const modal = bootstrap.Modal.getOrCreateInstance(modalElement);
document.getElementById('segmentModalTitle').textContent = 'Tekstblok';
document.getElementById('segmentModalMeta').textContent = '';
document.getElementById('segmentModalContent').textContent = 'Henter tekstblok…';
modal.show();
try {
const response = await fetch(`/api/v1/internet-connections/customer-documents/segments/${segmentId}`);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || 'Kunne ikke hente tekstblokken');
document.getElementById('segmentModalTitle').textContent = payload.title || 'Tekstblok';
document.getElementById('segmentModalMeta').textContent = `${payload.original_filename || 'Tekstfil'} · blok ${Number(payload.block_index || 0) + 1}`;
document.getElementById('segmentModalContent').textContent = payload.content || 'Blokken er tom.';
} catch (error) {
document.getElementById('segmentModalContent').textContent = error.message || 'Kunne ikke hente tekstblokken.';
}
}
2026-07-09 23:44:30 +02:00
async function uploadCustomerFile() {
const fileInput = document.getElementById('customerFileInput');
const notes = document.getElementById('customerFileNotes').value.trim();
if (!fileInput.files.length) {
alert('Vælg en fil først.');
return;
}
const formData = new FormData();
const customerId = document.getElementById('selectedCustomerId').value;
if (customerId) {
formData.append('customer_id', customerId);
}
formData.append('notes', notes);
formData.append('file', fileInput.files[0]);
document.getElementById('uploadStatus').textContent = 'Uploader...';
const response = await fetch('/api/v1/internet-connections/customer-documents/upload', {
method: 'POST',
body: formData,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
document.getElementById('uploadStatus').textContent = 'Upload fejlede.';
alert(payload.detail || 'Upload fejlede');
return;
}
document.getElementById('uploadStatus').textContent = payload.message || `Uploadet: ${payload.filename || fileInput.files[0].name} · ${payload.segment_count || 0} blokke indekseret`;
localUploadedDocuments.unshift({
document_id: payload.document_id,
original_filename: payload.filename || fileInput.files[0].name,
mime_type: fileInput.files[0].type || 'text/plain',
file_size: fileInput.files[0].size || 0,
notes,
segment_count: payload.segment_count || 0,
created_at: new Date().toISOString(),
});
fileInput.value = '';
document.getElementById('customerFileNotes').value = '';
await loadWizardContext();
}
document.getElementById('customerSearchInput').addEventListener('input', () => {
clearTimeout(customerSearchTimer);
customerSearchTimer = setTimeout(onCustomerInputChanged, 250);
});
document.getElementById('customerSearchInput').addEventListener('change', onCustomerInputChanged);
document.getElementById('queryInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
loadWizardContext();
}
});
loadWizardContext();
< / script >
{% endblock %}