1216 lines
51 KiB
HTML
1216 lines
51 KiB
HTML
|
|
{% 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);
|
||
|
|
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 () => {
|
||
|
|
const config = await fetchJson('/api/v1/also/config');
|
||
|
|
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>
|
||
|
|
</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);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
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 %}
|