2026-07-03 20:20:22 +02:00
{% extends "shared/frontend/base.html" %}
{% block title %}ALSO Cloud Marketplace{% endblock %}
{% block extra_css %}
< style >
:root {
--also-accent: #0b5d5b;
--also-accent-soft: rgba(11, 93, 91, 0.1);
--also-warm: #b7791f;
--also-danger: #c53030;
--also-slate: #234e52;
}
.also-hero {
background:
radial-gradient(circle at top right, rgba(15, 118, 110, 0.18), transparent 38%),
linear-gradient(135deg, rgba(11, 93, 91, 0.14), rgba(35, 78, 82, 0.08));
border: 1px solid rgba(11, 93, 91, 0.16);
border-radius: 20px;
padding: 1.4rem;
margin-bottom: 1.25rem;
}
.also-kpi {
border: 1px solid var(--border-color);
border-radius: 16px;
background: var(--bg-card);
padding: 1rem;
height: 100%;
}
.also-kpi .label {
color: var(--text-secondary);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.also-kpi .value {
font-size: 1.75rem;
font-weight: 700;
line-height: 1.1;
color: var(--also-slate);
}
.also-card {
border: 1px solid var(--border-color);
border-radius: 18px;
background: var(--bg-card);
}
.also-card .card-header {
background: transparent;
border-bottom: 1px solid var(--border-color);
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status-new, .status-lines_imported {
background: rgba(35, 78, 82, 0.12);
color: var(--also-slate);
}
.status-ready_for_approval, .status-partial {
background: rgba(183, 121, 31, 0.14);
color: var(--also-warm);
}
.status-approved, .status-completed, .status-invoiced {
background: rgba(11, 93, 91, 0.14);
color: var(--also-accent);
}
.status-error {
background: rgba(197, 48, 48, 0.14);
color: var(--also-danger);
}
.status-matching_customers, .status-matching_products {
background: rgba(56, 161, 105, 0.13);
color: #276749;
}
.dropzone {
border: 1.5px dashed rgba(11, 93, 91, 0.28);
border-radius: 16px;
padding: 1rem;
background: var(--also-accent-soft);
}
.table-tight td, .table-tight th {
vertical-align: middle;
font-size: 0.92rem;
}
.table-clickable tbody tr {
cursor: pointer;
}
.table-clickable tbody tr.active-row {
background: rgba(11, 93, 91, 0.09);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.83rem;
}
.metric-chip {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.4rem 0.65rem;
border-radius: 999px;
background: rgba(35, 78, 82, 0.08);
margin: 0 0.45rem 0.45rem 0;
}
.metric-chip strong {
font-size: 0.88rem;
}
.upload-result {
white-space: pre-wrap;
font-size: 0.9rem;
min-height: 4rem;
}
.panel-muted {
color: var(--text-secondary);
}
.sticky-detail {
position: sticky;
top: 1rem;
}
.map-editor {
margin-top: 0.75rem;
padding: 0.75rem;
border: 1px solid rgba(11, 93, 91, 0.16);
border-radius: 12px;
background: rgba(11, 93, 91, 0.04);
}
.map-results {
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-card);
max-height: 220px;
overflow: auto;
}
.map-result-item {
width: 100%;
border: 0;
border-bottom: 1px solid var(--border-color);
background: transparent;
text-align: left;
padding: 0.65rem 0.8rem;
}
.map-result-item:last-child {
border-bottom: 0;
}
.map-result-item:hover {
background: rgba(11, 93, 91, 0.08);
}
< / style >
{% endblock %}
{% block content %}
< div class = "also-hero d-flex flex-wrap justify-content-between align-items-start gap-3" >
< div >
< h2 class = "mb-1" > ALSO Cloud Marketplace< / h2 >
< p class = "mb-0 text-muted" > Upload billing-grundlag, gennemse importhistorik og følg status fra rå linjer til ordrekladder.< / p >
< / div >
< div class = "d-flex align-items-center gap-2" >
< span id = "alsoConfigStatus" class = "status-pill status-new" > Indlæser...< / span >
< button class = "btn btn-outline-secondary" id = "btnRefreshAll" >
< i class = "bi bi-arrow-repeat me-1" > < / i > Opdater
< / button >
< / div >
< / div >
< div class = "row g-3 mb-4" id = "kpiRow" >
< div class = "col-6 col-xl-2" >
< div class = "also-kpi" >
< div class = "label" > Omsætning< / div >
< div class = "value" id = "kpiRevenue" > 0 kr.< / div >
< / div >
< / div >
< div class = "col-6 col-xl-2" >
< div class = "also-kpi" >
< div class = "label" > Kost< / div >
< div class = "value" id = "kpiCost" > 0 kr.< / div >
< / div >
< / div >
< div class = "col-6 col-xl-2" >
< div class = "also-kpi" >
< div class = "label" > Margin< / div >
< div class = "value" id = "kpiMargin" > 0 kr.< / div >
< / div >
< / div >
< div class = "col-6 col-xl-2" >
< div class = "also-kpi" >
< div class = "label" > Afventer godkendelse< / div >
< div class = "value" id = "kpiPending" > 0< / div >
< / div >
< / div >
< div class = "col-6 col-xl-2" >
< div class = "also-kpi" >
< div class = "label" > Manglende kunder< / div >
< div class = "value" id = "kpiMissingCustomers" > 0< / div >
< / div >
< / div >
< div class = "col-6 col-xl-2" >
< div class = "also-kpi" >
< div class = "label" > Manglende produkter< / div >
< div class = "value" id = "kpiMissingProducts" > 0< / div >
< / div >
< / div >
< / div >
< div class = "row g-3 mb-4" >
< div class = "col-12 col-xl-5" >
< div class = "also-card card h-100" >
< div class = "card-header" >
< h5 class = "mb-0" > Upload Billing-Grundlag< / h5 >
< / div >
< div class = "card-body" >
< div class = "dropzone" >
< div class = "mb-3" >
< label for = "alsoZipFile" class = "form-label fw-semibold" > Excel-fil fra ALSO (.xlsx)< / label >
< input class = "form-control" type = "file" id = "alsoZipFile" accept = ".xlsx,.zip,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/zip" >
< / div >
< div class = "d-flex flex-wrap gap-2" >
< button class = "btn btn-primary" id = "btnUploadZip" >
< i class = "bi bi-upload me-1" > < / i > Upload og Behandl
< / button >
< button class = "btn btn-outline-secondary" id = "btnClearUploadResult" > Ryd status< / button >
< / div >
< div class = "small text-muted mt-2" > Workflow: upload af ALSO Excel, matching, validering og automatisk oprettelse af ordrekladder for klare linjer. ZIP kan stadig bruges som fallback.< / div >
< / div >
< div class = "upload-result mt-3" id = "uploadResult" > Ingen upload kørt endnu.< / div >
< / div >
< / div >
< / div >
< div class = "col-12 col-xl-7" >
< div class = "also-card card h-100" >
< div class = "card-header" >
< h5 class = "mb-0" > Statusfordeling< / h5 >
< / div >
< div class = "card-body" >
< div class = "mb-3" >
< div class = "fw-semibold mb-2" > Linjer< / div >
< div id = "lineStatusChips" > < / div >
< / div >
< div >
< div class = "fw-semibold mb-2" > Importjobs< / div >
< div id = "jobStatusChips" > < / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< div class = "row g-3 mb-4" >
< div class = "col-12 col-xl-7" >
< div class = "also-card card h-100" >
< div class = "card-header d-flex justify-content-between align-items-center" >
< h5 class = "mb-0" > Månedshistorik< / h5 >
< select class = "form-select form-select-sm" id = "historyMonths" style = "width: auto;" >
< option value = "6" selected > 6 mdr.< / option >
< option value = "12" > 12 mdr.< / option >
< option value = "24" > 24 mdr.< / option >
< / select >
< / div >
< div class = "card-body p-0" >
< div class = "table-responsive" >
< table class = "table table-tight mb-0" >
< thead >
< tr >
< th > Måned< / th >
< th > Jobs< / th >
< th > Linjer< / th >
< th > Kunder< / th >
< th > Omsætning< / th >
< th > Kost< / th >
< th > Margin< / th >
< / tr >
< / thead >
< tbody id = "monthlyHistoryBody" >
< tr > < td colspan = "7" class = "text-center py-4 text-muted" > Indlæser...< / td > < / tr >
< / tbody >
< / table >
< / div >
< / div >
< / div >
< / div >
< div class = "col-12 col-xl-5" >
< div class = "also-card card h-100" >
< div class = "card-header" >
< h5 class = "mb-0" > Månedlige Afvigelser< / h5 >
< / div >
< div class = "card-body p-0" >
< div class = "table-responsive" >
< table class = "table table-tight mb-0" >
< thead >
< tr >
< th > Kunde< / th >
< th > Produkt< / th >
< th > Sidste< / th >
< th > Nu< / th >
< th > Ændring< / th >
< / tr >
< / thead >
< tbody id = "differencesBody" >
< tr > < td colspan = "5" class = "text-center py-4 text-muted" > Indlæser...< / td > < / tr >
< / tbody >
< / table >
< / div >
< / div >
< / div >
< / div >
< / div >
< div class = "row g-3" >
< div class = "col-12 col-xl-7" >
< div class = "also-card card mb-3" >
< div class = "card-header d-flex justify-content-between align-items-center" >
< h5 class = "mb-0" > Importhistorik< / h5 >
< button class = "btn btn-sm btn-outline-secondary" id = "btnRefreshJobs" > Opdater jobs< / button >
< / div >
< div class = "card-body p-0" >
< div class = "table-responsive" >
< table class = "table table-hover table-tight table-clickable mb-0" >
< thead >
< tr >
< th > Job< / th >
< th > Status< / th >
< th > Fil< / th >
< th > Importeret< / th >
< th > Linjer< / th >
< / tr >
< / thead >
< tbody id = "jobsBody" >
< tr > < td colspan = "5" class = "text-center py-4 text-muted" > Indlæser...< / td > < / tr >
< / tbody >
< / table >
< / div >
< / div >
< / div >
< div class = "also-card card" >
< div class = "card-header d-flex flex-wrap justify-content-between align-items-center gap-2" >
< h5 class = "mb-0" > Queue Snapshot< / h5 >
< div class = "d-flex gap-2" >
< select class = "form-select form-select-sm" id = "queueStatusFilter" style = "width: auto;" >
< option value = "" > Alle statusser< / option >
< option value = "new" > Ny< / option >
< option value = "matching_customers" > Matcher kunder< / option >
< option value = "matching_products" > Matcher produkter< / option >
< option value = "ready_for_approval" > Klar til godkendelse< / option >
< option value = "approved" > Godkendt< / option >
< option value = "invoiced" > Faktureret< / option >
< option value = "error" > Fejl< / option >
< / select >
< button class = "btn btn-sm btn-outline-secondary" id = "btnRefreshQueue" > Opdater< / button >
< / div >
< / div >
< div class = "card-body p-0" >
< div class = "table-responsive" >
< table class = "table table-tight mb-0" >
< thead >
< tr >
< th > ID< / th >
< th > Status< / th >
< th > Kunde< / th >
< th > Produkt< / th >
< th > Beløb< / th >
< th > Kost< / th >
< th > Margin< / th >
< th > Draft< / th >
< / tr >
< / thead >
< tbody id = "queueBody" >
< tr > < td colspan = "8" class = "text-center py-4 text-muted" > Indlæser...< / td > < / tr >
< / tbody >
< / table >
< / div >
< / div >
< / div >
< / div >
< div class = "col-12 col-xl-5" >
< div class = "also-card card sticky-detail" >
< div class = "card-header d-flex justify-content-between align-items-center" >
< h5 class = "mb-0" > Job Detaljer< / h5 >
< div class = "d-flex align-items-center gap-2" >
< span class = "small text-muted" id = "selectedJobHint" > Vælg et job< / span >
< button class = "btn btn-sm btn-outline-primary" id = "btnAutoMapJob" disabled >
< i class = "bi bi-magic me-1" > < / i > Auto-map
< / button >
< button class = "btn btn-sm btn-outline-danger" id = "btnDeleteJob" disabled >
< i class = "bi bi-trash me-1" > < / i > Slet import
< / button >
< / div >
< / div >
< div class = "card-body" >
< div id = "jobMeta" class = "panel-muted mb-3" > Ingen job valgt endnu.< / div >
< div class = "table-responsive" >
< table class = "table table-sm table-tight" >
< thead >
< tr >
< th > Linje< / th >
< th > Status< / th >
< th > Kunde / Produkt< / th >
< th > Beløb< / th >
< / tr >
< / thead >
< tbody id = "jobLinesBody" >
< tr > < td colspan = "4" class = "text-center py-4 text-muted" > Vælg et job for at se linjer.< / td > < / tr >
< / tbody >
< / table >
< / div >
< / div >
< / div >
< / div >
< / div >
{% endblock %}
{% block extra_js %}
< script >
(() => {
const state = {
selectedJobId: null,
jobs: [],
selectedJobLines: [],
activeMapLineId: null,
activeProductMapLineId: null,
lineCustomerSelections: {},
lineCustomerSearchResults: {},
lineCustomerSearchQueries: {},
lineSearchTimers: {},
lineProductSelections: {},
lineProductSearchResults: {},
lineProductSearchQueries: {},
lineProductSearchTimers: {},
};
const el = (id) => document.getElementById(id);
const statusBadge = (status) => {
const s = String(status || 'unknown').toLowerCase();
return `< span class = "status-pill status-${s}" > ${s.replaceAll('_', ' ')}< / span > `;
};
const formatCurrency = (value) => {
const n = Number(value || 0);
return n.toLocaleString('da-DK', { style: 'currency', currency: 'DKK', maximumFractionDigits: 2 });
};
const escapeHtml = (value) => String(value ?? '')
.replaceAll('& ', '& ')
.replaceAll('< ', '< ')
.replaceAll('>', '> ')
.replaceAll('"', '" ')
.replaceAll("'", '' ');
const formatNumber = (value) => Number(value || 0).toLocaleString('da-DK');
const formatDateTime = (value) => value ? new Date(value).toLocaleString('da-DK') : '-';
const formatMonth = (value) => value ? new Date(value).toLocaleDateString('da-DK', { year: 'numeric', month: 'short' }) : '-';
const fetchJson = async (url, options = {}) => {
const res = await fetch(url, options);
2026-09-09 10:34:49 +02:00
if (res.status === 401) {
const next = `${window.location.pathname}${window.location.search}`;
window.location.assign(`/login?next=${encodeURIComponent(next)}`);
throw new Error('Sessionen mangler eller er udløbet. Du sendes til login.');
}
2026-07-03 20:20:22 +02:00
if (!res.ok) {
const text = await res.text();
let message = text || `HTTP ${res.status}`;
try {
const parsed = JSON.parse(text);
if (typeof parsed?.detail === 'string' & & parsed.detail.trim()) {
message = parsed.detail.trim();
} else if (parsed?.detail?.message) {
message = parsed.detail.message;
} else if (parsed?.message) {
message = parsed.message;
}
} catch {}
throw new Error(message);
}
return res.json();
};
const renderUploadResult = (payload) => {
if (!payload) {
el('uploadResult').textContent = 'Ingen upload kørt endnu.';
return;
}
const lines = [
`Job #${payload.job_id} (${payload.status})`,
`Linjer modtaget: ${formatNumber(payload.import_result?.received || 0)}`,
`Linjer indsat: ${formatNumber(payload.import_result?.inserted || 0)}`,
`Dubletter: ${formatNumber(payload.import_result?.duplicates || 0)}`,
`Klar til approval efter validering: ${formatNumber(payload.validation_result?.ready_for_approval || 0)}`,
`Oprettede ordrekladder: ${formatNumber(payload.approval_result?.created_drafts || 0)}`,
`Approved linjer: ${formatNumber(payload.queue_totals?.approved_lines || 0)}`,
`Fejllinjer: ${formatNumber(payload.queue_totals?.error_lines || 0)}`
];
el('uploadResult').textContent = lines.join('\n');
};
const loadConfig = async () => {
2026-09-09 10:34:49 +02:00
const config = await fetchJson('/api/v1/also/config', { cache: 'no-store' });
2026-07-03 20:20:22 +02:00
const label = !config.enabled
? 'ALSO deaktiveret'
: config.read_only
? 'Read-only'
: config.dry_run
? 'Dry-run'
: 'Aktiv';
const css = !config.enabled ? 'status-error' : (config.read_only || config.dry_run ? 'status-ready_for_approval' : 'status-approved');
const node = el('alsoConfigStatus');
node.className = `status-pill ${css}`;
node.textContent = label;
};
const loadSummary = async () => {
const summary = await fetchJson('/api/v1/also/dashboard/summary');
el('kpiRevenue').textContent = formatCurrency(summary.monthly_revenue);
el('kpiCost').textContent = formatCurrency(summary.monthly_cost);
el('kpiMargin').textContent = formatCurrency(summary.monthly_margin);
el('kpiPending').textContent = formatNumber(summary.pending_approvals);
el('kpiMissingCustomers').textContent = formatNumber(summary.unmatched_customers);
el('kpiMissingProducts').textContent = formatNumber(summary.unmatched_products);
};
const loadBreakdown = async () => {
const data = await fetchJson('/api/v1/also/dashboard/status-breakdown');
const renderChips = (rows) => rows.length
? rows.map(row => `
< span class = "metric-chip" >
${statusBadge(row.queue_status || row.status)}
< strong > ${formatNumber(row.current_month_count ?? row.total_count)}< / strong >
< span class = "small text-muted" > ${row.current_month_count != null ? `denne måned / ${formatNumber(row.total_count)} total` : `${formatNumber(row.total_count)} total`}< / span >
< / span >
`).join('')
: '< div class = "text-muted" > Ingen data< / div > ';
el('lineStatusChips').innerHTML = renderChips(data.line_statuses || []);
el('jobStatusChips').innerHTML = renderChips(data.job_statuses || []);
};
const loadMonthlyHistory = async () => {
const months = el('historyMonths').value;
const rows = await fetchJson(`/api/v1/also/dashboard/monthly-history?months=${months}`);
el('monthlyHistoryBody').innerHTML = rows.length
? rows.map(row => `
< tr >
< td > ${formatMonth(row.month_start)}< / td >
< td > ${formatNumber(row.job_count)}< / td >
< td > ${formatNumber(row.line_count)}< / td >
< td > ${formatNumber(row.customer_count)}< / td >
< td > ${formatCurrency(row.revenue)}< / td >
< td > ${formatCurrency(row.cost)}< / td >
< td > ${formatCurrency(row.margin)}< / td >
< / tr >
`).join('')
: '< tr > < td colspan = "7" class = "text-center py-4 text-muted" > Ingen historik< / td > < / tr > ';
};
const loadDifferences = async () => {
const rows = await fetchJson('/api/v1/also/dashboard/differences?limit=20');
el('differencesBody').innerHTML = rows.length
? rows.map(row => `
< tr >
< td > ${row.customer_name || '-'}< / td >
< td >
< div > ${row.product_name || '-'}< / div >
< div class = "small text-muted mono" > ${row.material_number || '-'}< / div >
< / td >
< td > ${formatNumber(row.previous_month_qty)}< / td >
< td > ${formatNumber(row.current_month_qty)}< / td >
< td class = "${row.warning ? 'text-danger fw-semibold' : ''}" > ${row.change_percent == null ? '-' : `${Number(row.change_percent).toLocaleString('da-DK')}%`}< / td >
< / tr >
`).join('')
: '< tr > < td colspan = "5" class = "text-center py-4 text-muted" > Ingen afvigelser< / td > < / tr > ';
};
const loadJobs = async () => {
const rows = await fetchJson('/api/v1/also/import-jobs?limit=50');
state.jobs = rows;
if (state.selectedJobId & & !rows.some(row => row.id === state.selectedJobId)) {
state.selectedJobId = null;
}
el('jobsBody').innerHTML = rows.length
? rows.map(row => `
< tr data-job-row = "${row.id}" class = "${state.selectedJobId === row.id ? 'active-row' : ''}" >
< td class = "fw-semibold" > #${row.id}< / td >
< td > ${statusBadge(row.status)}< / td >
< td > ${row.file_name || '-'}< / td >
< td > ${formatDateTime(row.imported_at)}< / td >
< td > ${formatNumber(row.line_count)}< / td >
< / tr >
`).join('')
: '< tr > < td colspan = "5" class = "text-center py-4 text-muted" > Ingen importjobs endnu.< / td > < / tr > ';
if (!state.selectedJobId & & rows.length) {
state.selectedJobId = rows[0].id;
}
if (!rows.length) {
el('btnAutoMapJob').disabled = true;
el('btnDeleteJob').disabled = true;
el('selectedJobHint').textContent = 'Vælg et job';
el('jobMeta').textContent = 'Ingen job valgt endnu.';
el('jobLinesBody').innerHTML = '< tr > < td colspan = "4" class = "text-center py-4 text-muted" > Vælg et job for at se linjer.< / td > < / tr > ';
}
if (state.selectedJobId) {
await loadJobDetails(state.selectedJobId);
}
};
const loadJobDetails = async (jobId) => {
state.selectedJobId = jobId;
const [job, lines] = await Promise.all([
fetchJson(`/api/v1/also/import-jobs/${jobId}`),
fetchJson(`/api/v1/also/import-jobs/${jobId}/lines?limit=250`)
]);
document.querySelectorAll('[data-job-row]').forEach(row => {
row.classList.toggle('active-row', Number(row.getAttribute('data-job-row')) === jobId);
});
el('selectedJobHint').textContent = `Job #${jobId}`;
el('btnAutoMapJob').disabled = false;
el('btnDeleteJob').disabled = false;
const payload = job.raw_payload_json || {};
const uploadedEntries = Array.isArray(payload.uploaded_entries)
? payload.uploaded_entries
: (Array.isArray(payload.zip_entries) ? payload.zip_entries : []);
const entryLabel = payload.upload_format === 'xlsx' ? 'Upload' : 'ZIP entries';
const entryList = uploadedEntries.slice(0, 6).join(', ');
el('jobMeta').innerHTML = `
< div class = "mb-2" > < strong > Fil:< / strong > ${job.file_name || '-'}< / div >
< div class = "mb-2" > < strong > Status:< / strong > ${statusBadge(job.status)}< / div >
< div class = "mb-2" > < strong > Importeret:< / strong > ${formatDateTime(job.imported_at)}< / div >
< div class = "mb-2" > < strong > Source:< / strong > ${job.source_label || '-'} / ${job.source_type || '-'}< / div >
< div class = "mb-2" > < strong > Linjer:< / strong > ${formatNumber(job.line_count)}< / div >
< div > < strong > ${entryLabel}:< / strong > < span class = "mono" > ${entryList || '-'}< / span > < / div >
`;
state.selectedJobLines = lines;
renderJobLines();
};
const renderMapEditor = (line) => {
const lineId = Number(line.id);
const selected = state.lineCustomerSelections[lineId];
const results = state.lineCustomerSearchResults[lineId] || [];
const query = state.lineCustomerSearchQueries[lineId] || '';
return `
< div class = "map-editor" >
< div class = "small fw-semibold mb-2" > Manuel company-mapping< / div >
< div class = "input-group input-group-sm mb-2" >
< input
type="text"
class="form-control js-company-search-input"
data-line-id="${lineId}"
value="${escapeHtml(query)}"
placeholder="Søg kunde (min. 2 tegn)"
autocomplete="off"
>
< button class = "btn btn-outline-secondary js-company-map-cancel" data-line-id = "${lineId}" type = "button" > Luk< / button >
< / div >
${selected ? `
< div class = "small text-success mb-2" >
Valgt kunde: < strong > ${escapeHtml(selected.name)}< / strong > (${escapeHtml(selected.id)})
< / div >
` : `
< div class = "small text-muted mb-2" > Vælg kunde og gem mappingen for denne ALSO-company.< / div >
`}
< div class = "map-results mb-2" >
${results.length
? results.map(customer => `
< button type = "button" class = "map-result-item js-company-result" data-line-id = "${lineId}" data-customer-id = "${customer.id}" data-customer-name = "${escapeHtml(customer.name)}" >
< strong > ${escapeHtml(customer.name)}< / strong >
< div class = "small text-muted" > ID ${escapeHtml(customer.id)}${customer.cvr_nummer ? ` · CVR ${escapeHtml(customer.cvr_nummer)}` : ''}< / div >
< / button >
`).join('')
: `< div class = "small text-muted p-3" > ${query.trim().length >= 2 ? 'Ingen kunder fundet.' : 'Skriv mindst 2 tegn for at søge.'}< / div > `
}
< / div >
< div class = "d-flex gap-2" >
< button class = "btn btn-sm btn-primary js-company-map-save" data-line-id = "${lineId}" type = "button" $ { selected ? ' ' : ' disabled ' } >
Gem company-mapping
< / button >
${line.matched_customer_name ? `< div class = "small text-muted align-self-center" > Nuværende: ${escapeHtml(line.matched_customer_name)}< / div > ` : ''}
< / div >
< / div >
`;
};
const renderProductMapEditor = (line) => {
const lineId = Number(line.id);
const selected = state.lineProductSelections[lineId];
const results = state.lineProductSearchResults[lineId] || [];
const query = state.lineProductSearchQueries[lineId] || '';
return `
< div class = "map-editor" >
< div class = "small fw-semibold mb-2" > Manuel produkt-mapping< / div >
< div class = "input-group input-group-sm mb-2" >
< input
type="text"
class="form-control js-product-search-input"
data-line-id="${lineId}"
value="${escapeHtml(query)}"
placeholder="Søg vare eller SKU (min. 2 tegn)"
autocomplete="off"
>
< button class = "btn btn-outline-secondary js-product-map-cancel" data-line-id = "${lineId}" type = "button" > Luk< / button >
< / div >
${selected ? `
< div class = "small text-success mb-2" >
Valgt vare: < strong > ${escapeHtml(selected.name)}< / strong > (${escapeHtml(selected.id)})
< / div >
` : `
< div class = "small text-muted mb-2" > Vælg vare og gem mappingen for dette ALSO-produkt.< / div >
`}
< div class = "map-results mb-2" >
${results.length
? results.map(product => `
< button type = "button" class = "map-result-item js-product-result" data-line-id = "${lineId}" data-product-id = "${product.id}" data-product-name = "${escapeHtml(product.name)}" >
< strong > ${escapeHtml(product.name)}< / strong >
< div class = "small text-muted" > ID ${escapeHtml(product.id)}${product.sku_internal ? ` · SKU ${escapeHtml(product.sku_internal)}` : ''}${product.supplier_sku ? ` · Leverandør-SKU ${escapeHtml(product.supplier_sku)}` : ''}< / div >
< / button >
`).join('')
: `< div class = "small text-muted p-3" > ${query.trim().length >= 2 ? 'Ingen varer fundet.' : 'Skriv mindst 2 tegn for at søge.'}< / div > `
}
< / div >
< div class = "d-flex gap-2" >
< button class = "btn btn-sm btn-primary js-product-map-save" data-line-id = "${lineId}" type = "button" $ { selected ? ' ' : ' disabled ' } >
Gem produkt-mapping
< / button >
${line.matched_product_name ? `< div class = "small text-muted align-self-center" > Nuværende: ${escapeHtml(line.matched_product_name)}< / div > ` : ''}
< / div >
< / div >
`;
};
const renderJobLines = () => {
const lines = state.selectedJobLines || [];
el('jobLinesBody').innerHTML = lines.length
? lines.map(line => `
< tr >
< td class = "mono" > #${line.id}< br > < span class = "text-muted" > L${line.line_no || '-'}< / span > < / td >
< td > ${statusBadge(line.queue_status)}< / td >
< td >
< div > ${escapeHtml(line.matched_customer_name || line.company || '-')}< / div >
< div class = "small text-muted" > ${escapeHtml(line.matched_product_name || line.product_name || '-')}< / div >
< div class = "small text-muted mono" > ${escapeHtml(line.vendor || '-')} / ${escapeHtml(line.material_number || '-')}< / div >
${(line.validation_errors_json || []).length ? `< div class = "small text-danger" > ${escapeHtml(line.validation_errors_json.map(err => err.message || err.code || 'Fejl').join(', '))}< / div > ` : ''}
${line.order_draft_id ? `< div class = "small mono" > Draft #${line.order_draft_id} / ${escapeHtml(line.order_draft_sync_status || 'pending')}< / div > ` : ''}
${line.also_company_id ? `
< div class = "small text-muted mt-1" >
ALSO company: < span class = "mono" > ${escapeHtml(line.also_company_id)}< / span >
< / div >
< div class = "mt-2" >
< button class = "btn btn-sm btn-outline-secondary js-company-map-toggle" data-line-id = "${line.id}" type = "button" >
${state.activeMapLineId === Number(line.id) ? 'Luk mapping' : 'Ret kunde'}
< / button >
< / div >
${state.activeMapLineId === Number(line.id) ? renderMapEditor(line) : ''}
` : ''}
${line.material_number ? `
< div class = "small text-muted mt-1" >
ALSO produkt: < span class = "mono" > ${escapeHtml(line.vendor || 'ALSO')} / ${escapeHtml(line.material_number)}< / span >
< / div >
< div class = "mt-2" >
< button class = "btn btn-sm btn-outline-secondary js-product-map-toggle" data-line-id = "${line.id}" type = "button" >
${state.activeProductMapLineId === Number(line.id) ? 'Luk produkt' : 'Ret produkt'}
< / button >
2026-08-12 07:20:04 +02:00
${!line.matched_product_id ? `
< button class = "btn btn-sm btn-primary js-product-create ms-1" data-line-id = "${line.id}" type = "button" >
< i class = "bi bi-plus-circle me-1" > < / i > Opret produkt i Hub
< / button >
` : ''}
2026-07-03 20:20:22 +02:00
< / div >
${state.activeProductMapLineId === Number(line.id) ? renderProductMapEditor(line) : ''}
` : ''}
< / td >
< td class = "text-end" >
< div > ${formatCurrency(line.total_price || line.sales_price || 0)}< / div >
< div class = "small text-muted" > Kost ${formatCurrency(line.effective_cost_amount || line.cost_amount || 0)}< / div >
< div class = "small text-muted" > Margin ${formatCurrency(line.effective_margin_amount || (Number(line.total_price || line.sales_price || 0) - Number(line.effective_cost_amount || line.cost_amount || 0)))}< / div >
< div class = "small text-muted" > ${formatNumber(line.billable_parameters || 1)} stk< / div >
< / td >
< / tr >
`).join('')
: '< tr > < td colspan = "4" class = "text-center py-4 text-muted" > Ingen linjer fundet.< / td > < / tr > ';
};
const searchCustomersForLine = async (lineId, query) => {
state.lineCustomerSearchQueries[lineId] = query;
if ((query || '').trim().length < 2 ) {
state.lineCustomerSearchResults[lineId] = [];
renderJobLines();
return;
}
const rows = await fetchJson(`/api/v1/search/customers?q=${encodeURIComponent(query.trim())}`);
state.lineCustomerSearchResults[lineId] = rows || [];
renderJobLines();
};
const saveCompanyMapping = async (lineId) => {
const selected = state.lineCustomerSelections[lineId];
if (!selected) {
alert('Vælg en kunde først.');
return;
}
el('uploadResult').textContent = `Gemmer company-mapping for linje #${lineId}...`;
const result = await fetchJson(`/api/v1/also/import-lines/${lineId}/map-company`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_id: Number(selected.id),
notes: 'Manual mapping from ALSO Cloud Marketplace UI',
}),
});
state.activeMapLineId = null;
delete state.lineCustomerSelections[lineId];
delete state.lineCustomerSearchResults[lineId];
delete state.lineCustomerSearchQueries[lineId];
el('uploadResult').textContent = [
`Linje #${result.line_id} mappet til ${result.customer_name} (#${result.customer_id})`,
`Berørte linjer i job: ${formatNumber(result.affected_line_count || 0)}`,
`Ready after validation: ${formatNumber(result.validation_result?.ready_for_approval || 0)}`,
`Approved linjer: ${formatNumber(result.approval_result?.approved_lines || 0)}`,
`Oprettede ordrekladder: ${formatNumber(result.approval_result?.created_drafts || 0)}`,
].join('\n');
await refreshAll();
if (state.selectedJobId) {
await loadJobDetails(state.selectedJobId);
}
};
const searchProductsForLine = async (lineId, query) => {
state.lineProductSearchQueries[lineId] = query;
if ((query || '').trim().length < 2 ) {
state.lineProductSearchResults[lineId] = [];
renderJobLines();
return;
}
const rows = await fetchJson(`/api/v1/search/products?q=${encodeURIComponent(query.trim())}`);
state.lineProductSearchResults[lineId] = rows || [];
renderJobLines();
};
const saveProductMapping = async (lineId) => {
const selected = state.lineProductSelections[lineId];
if (!selected) {
alert('Vælg en vare først.');
return;
}
el('uploadResult').textContent = `Gemmer produkt-mapping for linje #${lineId}...`;
const result = await fetchJson(`/api/v1/also/import-lines/${lineId}/map-product`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
product_id: Number(selected.id),
notes: 'Manual product mapping from ALSO Cloud Marketplace UI',
}),
});
state.activeProductMapLineId = null;
delete state.lineProductSelections[lineId];
delete state.lineProductSearchResults[lineId];
delete state.lineProductSearchQueries[lineId];
el('uploadResult').textContent = [
`Linje #${result.line_id} mappet til ${result.product_name} (#${result.product_id})`,
`Berørte linjer i job: ${formatNumber(result.affected_line_count || 0)}`,
`Ready after validation: ${formatNumber(result.validation_result?.ready_for_approval || 0)}`,
`Approved linjer: ${formatNumber(result.approval_result?.approved_lines || 0)}`,
`Oprettede ordrekladder: ${formatNumber(result.approval_result?.created_drafts || 0)}`,
].join('\n');
await refreshAll();
if (state.selectedJobId) {
await loadJobDetails(state.selectedJobId);
}
};
2026-08-12 07:20:04 +02:00
const createProductForLine = async (lineId) => {
const line = (state.selectedJobLines || []).find(item => Number(item.id) === Number(lineId));
const productName = line?.product_name || 'dette produkt';
if (!window.confirm(`Opret ${productName} som produkt i Hub og map ALSO-varen automatisk?`)) return;
el('uploadResult').textContent = `Opretter Hub-produkt for linje #${lineId}...`;
const result = await fetchJson(`/api/v1/also/import-lines/${lineId}/create-product`, {
method: 'POST',
});
el('uploadResult').textContent = [
`${result.product_created ? 'Hub-produkt oprettet og mappet' : 'Eksisterende Hub-produkt mappet'}: ${result.product_name} (#${result.product_id})`,
`Berørte linjer i job: ${formatNumber(result.affected_line_count || 0)}`,
`Oprettede ordrekladder: ${formatNumber(result.approval_result?.created_drafts || 0)}`,
].join('\n');
await refreshAll();
if (state.selectedJobId) await loadJobDetails(state.selectedJobId);
};
2026-07-03 20:20:22 +02:00
const autoMapSelectedJob = async () => {
if (!state.selectedJobId) return;
el('uploadResult').textContent = `Kører auto-map for job #${state.selectedJobId}...`;
const result = await fetchJson(`/api/v1/also/import-jobs/${state.selectedJobId}/auto-map`, {
method: 'POST',
});
const lines = [
`Job #${result.job_id} (${result.status})`,
`Oprettede company mappings: ${formatNumber(result.customer_mappings_created || 0)}`,
`Oprettede product mappings: ${formatNumber(result.product_mappings_created || 0)}`,
`Oprettede varer: ${formatNumber(result.products_created || 0)}`,
`Ready after validation: ${formatNumber(result.validation_result?.ready_for_approval || 0)}`,
`Approved linjer: ${formatNumber(result.queue_totals?.approved_lines || 0)}`,
`Fejllinjer: ${formatNumber(result.queue_totals?.error_lines || 0)}`,
`Oprettede ordrekladder: ${formatNumber(result.approval_result?.created_drafts || 0)}`,
];
el('uploadResult').textContent = lines.join('\n');
await refreshAll();
if (state.selectedJobId) {
await loadJobDetails(state.selectedJobId);
}
};
const deleteSelectedJob = async () => {
if (!state.selectedJobId) return;
const simpleConfirm = window.confirm(`Slet importjob #${state.selectedJobId}?\n\nDet sletter jobbet og alle importerede ALSO-linjer for jobbet.`);
if (!simpleConfirm) return;
try {
const result = await fetchJson(`/api/v1/also/import-jobs/${state.selectedJobId}`, {
method: 'DELETE',
});
renderUploadResult({
job_id: result.deleted_job_id,
status: 'deleted',
import_result: { received: 0, inserted: 0, duplicates: 0 },
validation_result: { ready_for_approval: 0 },
approval_result: { created_drafts: result.deleted_order_draft_ids?.length || 0 },
queue_totals: { approved_lines: 0, error_lines: 0 },
});
state.selectedJobId = null;
el('selectedJobHint').textContent = 'Vælg et job';
el('btnDeleteJob').disabled = true;
el('jobMeta').textContent = 'Importjob slettet.';
el('jobLinesBody').innerHTML = '< tr > < td colspan = "4" class = "text-center py-4 text-muted" > Vælg et job for at se linjer.< / td > < / tr > ';
await refreshAll();
return;
} catch (err) {
let detail = null;
try {
detail = JSON.parse(err.message);
} catch {
detail = null;
}
if (!detail || !detail.detail || typeof detail.detail !== 'object') {
console.error(err);
alert('Sletning af import fejlede.');
return;
}
const info = detail.detail;
if (!info.drafts || !Array.isArray(info.drafts) || !info.drafts.length) {
alert(info.message || 'Sletning af import fejlede.');
return;
}
if (info.blocked_draft_count > 0) {
alert('Importen har ordrekladder som allerede er eksporteret eller bogført. De skal håndteres manuelt først.');
return;
}
const confirmDraftDelete = window.confirm(
`Importjob #${state.selectedJobId} har ${info.draft_count} tilknyttede ordrekladder.\n\n` +
'Vil du slette både importjob og disse ikke-eksporterede ordrekladder?'
);
if (!confirmDraftDelete) return;
const result = await fetchJson(`/api/v1/also/import-jobs/${state.selectedJobId}?delete_order_drafts=true`, {
method: 'DELETE',
});
renderUploadResult({
job_id: result.deleted_job_id,
status: 'deleted',
import_result: { received: 0, inserted: 0, duplicates: 0 },
validation_result: { ready_for_approval: 0 },
approval_result: { created_drafts: result.deleted_order_draft_ids?.length || 0 },
queue_totals: { approved_lines: 0, error_lines: 0 },
});
state.selectedJobId = null;
el('selectedJobHint').textContent = 'Vælg et job';
el('btnDeleteJob').disabled = true;
el('jobMeta').textContent = 'Importjob og tilknyttede ordrekladder slettet.';
el('jobLinesBody').innerHTML = '< tr > < td colspan = "4" class = "text-center py-4 text-muted" > Vælg et job for at se linjer.< / td > < / tr > ';
await refreshAll();
}
};
const loadQueue = async () => {
const status = el('queueStatusFilter').value;
const qs = new URLSearchParams({ limit: '80' });
if (status) qs.set('status', status);
const rows = await fetchJson(`/api/v1/also/queue?${qs.toString()}`);
el('queueBody').innerHTML = rows.length
? rows.map(row => `
< tr >
< td class = "mono" > #${row.id}< / td >
< td > ${statusBadge(row.queue_status)}< / td >
< td > ${row.matched_customer_id || row.company || '-'}< / td >
< td >
< div > ${row.product_name || '-'}< / div >
< div class = "small text-muted mono" > ${row.material_number || '-'}< / div >
< / td >
< td > ${formatCurrency(row.total_price || 0)}< / td >
< td > ${formatCurrency(row.effective_cost_amount || row.cost_amount || 0)}< / td >
< td > ${formatCurrency(row.effective_margin_amount || (Number(row.total_price || 0) - Number(row.effective_cost_amount || row.cost_amount || 0)))}< / td >
< td > ${row.order_draft_id ? `< a href = "/ordre/${row.order_draft_id}" class = "mono" > #${row.order_draft_id}< / a > ` : '-'}< / td >
< / tr >
`).join('')
: '< tr > < td colspan = "8" class = "text-center py-4 text-muted" > Ingen linjer i dette filter.< / td > < / tr > ';
};
const uploadZip = async () => {
const file = el('alsoZipFile').files?.[0];
if (!file) {
alert('Vælg en ZIP-fil først.');
return;
}
const formData = new FormData();
formData.append('file', file);
el('uploadResult').textContent = 'Uploader og behandler...';
const payload = await fetchJson('/api/v1/also/import-upload', {
method: 'POST',
body: formData,
});
renderUploadResult(payload);
state.selectedJobId = payload.job_id || state.selectedJobId;
await refreshAll();
};
const refreshAll = async () => {
await Promise.all([
loadConfig(),
loadSummary(),
loadBreakdown(),
loadMonthlyHistory(),
loadDifferences(),
loadQueue(),
]);
await loadJobs();
};
document.addEventListener('click', async (event) => {
const mapToggle = event.target.closest('.js-company-map-toggle');
if (mapToggle) {
const lineId = Number(mapToggle.getAttribute('data-line-id'));
state.activeMapLineId = state.activeMapLineId === lineId ? null : lineId;
renderJobLines();
return;
}
const productMapToggle = event.target.closest('.js-product-map-toggle');
if (productMapToggle) {
const lineId = Number(productMapToggle.getAttribute('data-line-id'));
state.activeProductMapLineId = state.activeProductMapLineId === lineId ? null : lineId;
renderJobLines();
return;
}
const mapCancel = event.target.closest('.js-company-map-cancel');
if (mapCancel) {
state.activeMapLineId = null;
renderJobLines();
return;
}
const productMapCancel = event.target.closest('.js-product-map-cancel');
if (productMapCancel) {
state.activeProductMapLineId = null;
renderJobLines();
return;
}
const resultButton = event.target.closest('.js-company-result');
if (resultButton) {
const lineId = Number(resultButton.getAttribute('data-line-id'));
state.lineCustomerSelections[lineId] = {
id: Number(resultButton.getAttribute('data-customer-id')),
name: resultButton.getAttribute('data-customer-name') || '',
};
renderJobLines();
return;
}
const productResultButton = event.target.closest('.js-product-result');
if (productResultButton) {
const lineId = Number(productResultButton.getAttribute('data-line-id'));
state.lineProductSelections[lineId] = {
id: Number(productResultButton.getAttribute('data-product-id')),
name: productResultButton.getAttribute('data-product-name') || '',
};
renderJobLines();
return;
}
const saveButton = event.target.closest('.js-company-map-save');
if (saveButton) {
const lineId = Number(saveButton.getAttribute('data-line-id'));
try {
await saveCompanyMapping(lineId);
} catch (err) {
console.error(err);
el('uploadResult').textContent = `Company-mapping fejlede:\n${err.message}`;
alert(`Company-mapping fejlede.\n\n${err.message}`);
}
return;
}
const saveProductButton = event.target.closest('.js-product-map-save');
if (saveProductButton) {
const lineId = Number(saveProductButton.getAttribute('data-line-id'));
try {
await saveProductMapping(lineId);
} catch (err) {
console.error(err);
el('uploadResult').textContent = `Produkt-mapping fejlede:\n${err.message}`;
alert(`Produkt-mapping fejlede.\n\n${err.message}`);
}
return;
}
2026-08-12 07:20:04 +02:00
const createProductButton = event.target.closest('.js-product-create');
if (createProductButton) {
const lineId = Number(createProductButton.getAttribute('data-line-id'));
const originalHtml = createProductButton.innerHTML;
createProductButton.disabled = true;
createProductButton.innerHTML = '< span class = "spinner-border spinner-border-sm me-1" > < / span > Opretter...';
try {
await createProductForLine(lineId);
} catch (err) {
console.error(err);
el('uploadResult').textContent = `Produktoprettelse fejlede:\n${err.message}`;
alert(`Kunne ikke oprette produktet.\n\n${err.message}`);
} finally {
createProductButton.disabled = false;
createProductButton.innerHTML = originalHtml;
}
return;
}
2026-07-03 20:20:22 +02:00
const row = event.target.closest('[data-job-row]');
if (!row) return;
await loadJobDetails(Number(row.getAttribute('data-job-row')));
});
document.addEventListener('input', (event) => {
const input = event.target.closest('.js-company-search-input');
if (!input) return;
const lineId = Number(input.getAttribute('data-line-id'));
const query = input.value || '';
state.lineCustomerSearchQueries[lineId] = query;
if (state.lineSearchTimers[lineId]) {
clearTimeout(state.lineSearchTimers[lineId]);
}
state.lineSearchTimers[lineId] = setTimeout(() => {
searchCustomersForLine(lineId, query).catch((err) => {
console.error(err);
state.lineCustomerSearchResults[lineId] = [];
renderJobLines();
});
}, 200);
return;
});
document.addEventListener('input', (event) => {
const input = event.target.closest('.js-product-search-input');
if (!input) return;
const lineId = Number(input.getAttribute('data-line-id'));
const query = input.value || '';
state.lineProductSearchQueries[lineId] = query;
if (state.lineProductSearchTimers[lineId]) {
clearTimeout(state.lineProductSearchTimers[lineId]);
}
state.lineProductSearchTimers[lineId] = setTimeout(() => {
searchProductsForLine(lineId, query).catch((err) => {
console.error(err);
state.lineProductSearchResults[lineId] = [];
renderJobLines();
});
}, 200);
});
el('btnUploadZip').addEventListener('click', async () => {
try {
await uploadZip();
} catch (err) {
console.error(err);
el('uploadResult').textContent = `Upload fejlede:\n${err.message}`;
alert(`Upload af ALSO ZIP fejlede.\n\n${err.message}`);
}
});
el('btnClearUploadResult').addEventListener('click', () => renderUploadResult(null));
el('btnAutoMapJob').addEventListener('click', () => autoMapSelectedJob().catch((err) => {
console.error(err);
el('uploadResult').textContent = `Auto-map fejlede:\n${err.message}`;
alert(`Auto-map fejlede.\n\n${err.message}`);
}));
el('btnDeleteJob').addEventListener('click', () => deleteSelectedJob().catch(console.error));
el('btnRefreshAll').addEventListener('click', () => refreshAll().catch(console.error));
el('btnRefreshJobs').addEventListener('click', () => loadJobs().catch(console.error));
el('btnRefreshQueue').addEventListener('click', () => loadQueue().catch(console.error));
el('queueStatusFilter').addEventListener('change', () => loadQueue().catch(console.error));
el('historyMonths').addEventListener('change', () => loadMonthlyHistory().catch(console.error));
refreshAll().catch((err) => {
console.error(err);
alert('Kunne ikke indlæse ALSO Cloud Marketplace siden.');
});
})();
< / script >
{% endblock %}