bmc_hub/app/ticket/frontend/archived_ticket_list.html
2026-07-03 20:20:22 +02:00

1354 lines
43 KiB
HTML

{% extends "shared/frontend/base.html" %}
{% block title %}Arkiverede Tickets - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.archived-list-page {
max-width: 1560px;
margin: 0 auto;
padding-bottom: 3rem;
}
.list-hero {
position: relative;
overflow: hidden;
background:
radial-gradient(circle at top right, rgba(15, 76, 117, 0.18), transparent 28rem),
linear-gradient(150deg, #ffffff 0%, #f5f8fb 54%, #e9f0f5 100%);
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 28px;
box-shadow: 0 24px 60px rgba(15, 76, 117, 0.12);
padding: 2rem;
margin-bottom: 1.5rem;
}
.list-hero::after {
content: "";
position: absolute;
width: 18rem;
height: 18rem;
right: -4rem;
bottom: -5rem;
border-radius: 999px;
background: rgba(15, 76, 117, 0.07);
filter: blur(20px);
pointer-events: none;
}
.list-kicker {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.85rem;
border-radius: 999px;
background: rgba(15, 76, 117, 0.08);
color: var(--accent);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.list-title {
font-size: clamp(2rem, 3vw, 3rem);
line-height: 1.06;
letter-spacing: -0.03em;
font-weight: 800;
color: #14344a;
margin: 1rem 0 0.8rem;
}
.list-subtitle {
max-width: 58rem;
margin: 0;
color: #5c7387;
font-size: 1rem;
line-height: 1.72;
}
.overview-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.overview-card {
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 18px;
padding: 1rem 1.1rem;
backdrop-filter: blur(10px);
}
.overview-label {
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #75899b;
margin-bottom: 0.45rem;
}
.overview-value {
color: #16384d;
font-size: 1.1rem;
font-weight: 800;
line-height: 1.35;
}
.surface-card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 251, 253, 0.98));
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 24px;
box-shadow: 0 20px 48px rgba(15, 76, 117, 0.08);
}
.sync-status-card {
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.sync-status-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.25rem;
}
.sync-status-title {
margin: 0;
color: #16384d;
font-size: 1.1rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.sync-status-subtitle {
margin: 0.25rem 0 0;
color: #7a91a2;
font-size: 0.92rem;
}
.sync-status-overall {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: 999px;
padding: 0.55rem 0.9rem;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
background: rgba(15, 76, 117, 0.08);
color: var(--accent);
border: 1px solid rgba(15, 76, 117, 0.1);
}
.sync-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.sync-source-card {
border-radius: 20px;
border: 1px solid rgba(15, 76, 117, 0.1);
background: rgba(255, 255, 255, 0.78);
padding: 1.2rem;
}
.sync-source-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.sync-source-name {
margin: 0;
color: #16384d;
font-size: 1rem;
font-weight: 800;
}
.sync-source-module {
margin-top: 0.2rem;
color: #7c92a3;
font-size: 0.82rem;
}
.sync-pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.48rem 0.72rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
border: 1px solid transparent;
white-space: nowrap;
}
.sync-pill.is-ok {
background: rgba(25, 135, 84, 0.12);
border-color: rgba(25, 135, 84, 0.15);
color: #146c43;
}
.sync-pill.is-warn {
background: rgba(255, 193, 7, 0.16);
border-color: rgba(255, 193, 7, 0.18);
color: #8a6510;
}
.sync-pill.is-error {
background: rgba(220, 53, 69, 0.12);
border-color: rgba(220, 53, 69, 0.15);
color: #b02a37;
}
.sync-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
margin-bottom: 1rem;
}
.sync-metric {
border-radius: 16px;
background: rgba(15, 76, 117, 0.04);
padding: 0.85rem 0.9rem;
}
.sync-metric-label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #7a91a2;
margin-bottom: 0.35rem;
}
.sync-metric-value {
color: #16384d;
font-size: 1.02rem;
font-weight: 800;
line-height: 1.3;
}
.sync-meta-list {
display: grid;
gap: 0.45rem;
color: #6c8396;
font-size: 0.86rem;
}
.sync-meta-list strong {
color: #29495d;
}
.sync-error {
margin-top: 0.9rem;
padding: 0.8rem 0.9rem;
border-radius: 14px;
background: rgba(220, 53, 69, 0.08);
color: #9d2d39;
font-size: 0.85rem;
}
.recon-card {
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.recon-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.25rem;
}
.recon-title {
margin: 0;
color: #16384d;
font-size: 1.1rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.recon-subtitle {
margin: 0.25rem 0 0;
color: #7a91a2;
font-size: 0.92rem;
}
.recon-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.recon-actions .btn {
border-radius: 14px;
font-weight: 700;
padding: 0.72rem 1rem;
}
.recon-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.recon-source-card {
border-radius: 20px;
border: 1px solid rgba(15, 76, 117, 0.1);
background: rgba(255, 255, 255, 0.78);
padding: 1.2rem;
}
.recon-source-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.recon-source-name {
margin: 0;
color: #16384d;
font-size: 1rem;
font-weight: 800;
}
.recon-source-meta {
margin-top: 0.2rem;
color: #7b91a2;
font-size: 0.82rem;
}
.recon-badge {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.48rem 0.72rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
white-space: nowrap;
}
.recon-badge.is-ok {
background: rgba(25, 135, 84, 0.12);
color: #146c43;
border: 1px solid rgba(25, 135, 84, 0.15);
}
.recon-badge.is-warn {
background: rgba(255, 193, 7, 0.16);
color: #8a6510;
border: 1px solid rgba(255, 193, 7, 0.18);
}
.recon-badge.is-error {
background: rgba(220, 53, 69, 0.12);
color: #b02a37;
border: 1px solid rgba(220, 53, 69, 0.15);
}
.recon-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
margin-bottom: 1rem;
}
.recon-metric {
border-radius: 16px;
background: rgba(15, 76, 117, 0.04);
padding: 0.85rem 0.9rem;
}
.recon-metric-label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #7a91a2;
margin-bottom: 0.35rem;
}
.recon-metric-value {
color: #16384d;
font-size: 1rem;
font-weight: 800;
line-height: 1.3;
}
.recon-list-block + .recon-list-block {
margin-top: 1rem;
}
.recon-list-title {
color: #29495d;
font-size: 0.86rem;
font-weight: 800;
margin-bottom: 0.55rem;
}
.recon-list {
display: grid;
gap: 0.55rem;
}
.recon-item {
border-radius: 14px;
border: 1px solid rgba(15, 76, 117, 0.08);
background: rgba(15, 76, 117, 0.03);
padding: 0.75rem 0.85rem;
}
.recon-item-main {
color: #16384d;
font-weight: 700;
font-size: 0.9rem;
line-height: 1.35;
}
.recon-item-sub {
color: #708798;
font-size: 0.82rem;
margin-top: 0.25rem;
}
.recon-empty {
color: #708798;
font-size: 0.86rem;
padding: 0.75rem 0;
}
.filter-card {
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.filter-card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1.25rem;
}
.filter-title {
margin: 0;
color: #16384d;
font-size: 1.1rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.filter-subtitle {
margin: 0.2rem 0 0;
color: #7a91a2;
font-size: 0.92rem;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 1rem;
align-items: end;
}
.filter-field {
display: grid;
gap: 0.45rem;
}
.filter-field.search-field {
grid-column: span 2;
}
.filter-label {
font-size: 0.76rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #7d92a2;
}
.search-box {
position: relative;
}
.search-box i {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: #6e8597;
}
.search-box input,
.filter-field input {
height: 3rem;
border-radius: 14px;
border: 1px solid rgba(15, 76, 117, 0.12);
background: rgba(255, 255, 255, 0.82);
color: #193a50;
box-shadow: inset 0 1px 2px rgba(15, 76, 117, 0.03);
}
.search-box input {
padding-left: 2.65rem;
}
.search-box input:focus,
.filter-field input:focus {
border-color: rgba(15, 76, 117, 0.32);
box-shadow: 0 0 0 0.2rem rgba(15, 76, 117, 0.08);
}
.filter-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.filter-actions .btn {
min-height: 3rem;
border-radius: 14px;
padding: 0.75rem 1rem;
font-weight: 700;
}
.results-card {
overflow: hidden;
}
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1.35rem 1.5rem 1rem;
border-bottom: 1px solid rgba(15, 76, 117, 0.08);
}
.results-title {
margin: 0;
color: #16384d;
font-size: 1.08rem;
font-weight: 800;
}
.results-meta {
color: #7890a1;
font-size: 0.9rem;
}
.ticket-table {
margin: 0;
}
.ticket-table thead th {
padding: 1rem 1rem 0.9rem;
border-bottom: 1px solid rgba(15, 76, 117, 0.12);
color: #6a8294;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(15, 76, 117, 0.03);
white-space: nowrap;
}
.ticket-table tbody td {
padding: 1rem;
vertical-align: top;
border-bottom: 1px solid rgba(15, 76, 117, 0.08);
color: #2e4558;
}
.ticket-row {
cursor: pointer;
transition: background-color 0.18s ease, transform 0.18s ease;
}
.ticket-row:hover {
background: rgba(15, 76, 117, 0.04);
}
.ticket-cell-main {
min-width: 280px;
}
.ticket-number {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.38rem 0.7rem;
border-radius: 999px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.76rem;
font-weight: 700;
color: var(--accent);
background: rgba(15, 76, 117, 0.09);
border: 1px solid rgba(15, 76, 117, 0.09);
margin-bottom: 0.75rem;
}
.ticket-title-cell {
color: #16384d;
font-size: 1rem;
font-weight: 800;
line-height: 1.35;
margin-bottom: 0.35rem;
}
.ticket-description-preview {
color: #738b9d;
font-size: 0.9rem;
line-height: 1.5;
}
.status-pill,
.count-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.75rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
border: 1px solid transparent;
}
.count-pill {
background: rgba(15, 76, 117, 0.08);
color: var(--accent);
}
.status-pill.status-open,
.status-pill.status-new,
.status-pill.status-created {
background: rgba(25, 135, 84, 0.12);
border-color: rgba(25, 135, 84, 0.15);
color: #146c43;
}
.status-pill.status-pending,
.status-pill.status-waiting,
.status-pill.status-pending_customer {
background: rgba(255, 193, 7, 0.16);
border-color: rgba(255, 193, 7, 0.18);
color: #8a6510;
}
.status-pill.status-closed,
.status-pill.status-resolved,
.status-pill.status-done {
background: rgba(13, 110, 253, 0.12);
border-color: rgba(13, 110, 253, 0.16);
color: #0b5ed7;
}
.status-pill.status-generic {
background: rgba(15, 76, 117, 0.08);
border-color: rgba(15, 76, 117, 0.1);
color: var(--accent);
}
.time-spent {
font-weight: 800;
color: #16384d;
white-space: nowrap;
}
.muted-dash {
color: #8aa0af;
}
.empty-state {
padding: 4rem 2rem;
text-align: center;
color: #6c8396;
}
.empty-state i {
display: inline-flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
border-radius: 999px;
background: rgba(15, 76, 117, 0.08);
color: var(--accent);
font-size: 2.1rem;
margin-bottom: 1.25rem;
}
@media (max-width: 1199.98px) {
.sync-grid {
grid-template-columns: 1fr;
}
.recon-grid {
grid-template-columns: 1fr;
}
.filter-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.filter-field.search-field {
grid-column: span 3;
}
.overview-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 991.98px) {
.sync-status-header,
.recon-header,
.recon-source-head,
.sync-source-top {
flex-direction: column;
align-items: flex-start;
}
.filter-card-header,
.results-header {
flex-direction: column;
align-items: flex-start;
}
.filter-actions {
justify-content: flex-start;
}
}
@media (max-width: 767.98px) {
.archived-list-page {
padding-bottom: 2rem;
}
.list-hero,
.filter-card,
.surface-card {
border-radius: 20px;
}
.list-hero,
.filter-card {
padding: 1.3rem;
}
.overview-strip,
.filter-grid,
.sync-metrics,
.recon-metrics {
grid-template-columns: 1fr;
}
.filter-field.search-field {
grid-column: span 1;
}
.ticket-table thead {
display: none;
}
.ticket-table,
.ticket-table tbody,
.ticket-table tr,
.ticket-table td {
display: block;
width: 100%;
}
.ticket-table tbody td {
padding: 0.6rem 1rem;
border-bottom: 0;
}
.ticket-row {
border-bottom: 1px solid rgba(15, 76, 117, 0.08);
padding: 0.5rem 0;
}
.ticket-row:last-child {
border-bottom: 0;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4 archived-list-page">
<section class="list-hero">
<span class="list-kicker">
<i class="bi bi-archive-fill"></i>
Simply-CRM arkiv
</span>
<h1 class="list-title">Arkiverede tickets med bedre overblik</h1>
<p class="list-subtitle">
Søg i historiske tickets, filtrer på organisation og kontakt, og gå direkte fra tabellen til et mere læsbart detail-view.
</p>
<div class="overview-strip">
<div class="overview-card">
<div class="overview-label">Viser nu</div>
<div class="overview-value">{{ tickets|length }} ticket{% if tickets|length != 1 %}s{% endif %}</div>
</div>
<div class="overview-card">
<div class="overview-label">Søgning</div>
<div class="overview-value">{{ search_query or 'Ingen fri søgning' }}</div>
</div>
<div class="overview-card">
<div class="overview-label">Organisation</div>
<div class="overview-value">{{ organization_query or 'Alle organisationer' }}</div>
</div>
<div class="overview-card">
<div class="overview-label">Kontakt</div>
<div class="overview-value">{{ contact_query or 'Alle kontakter' }}</div>
</div>
</div>
</section>
<section class="surface-card filter-card">
<div class="filter-card-header">
<div>
<h2 class="filter-title">Filtrer arkivet</h2>
<p class="filter-subtitle">Resultatlisten opdateres automatisk, mens du søger eller ændrer datoer.</p>
</div>
</div>
<form method="get" action="/ticket/archived" id="archivedFilters">
<div class="filter-grid">
<div class="filter-field search-field">
<label class="filter-label" for="search">Søg</label>
<div class="search-box">
<i class="bi bi-search"></i>
<input
type="text"
name="search"
id="search"
class="form-control"
placeholder="Ticket nr, titel, løsning eller kommentar..."
value="{{ search_query or '' }}">
</div>
</div>
<div class="filter-field">
<label class="filter-label" for="organization">Organisation</label>
<input
type="text"
name="organization"
id="organization"
class="form-control"
placeholder="fx Norva24"
value="{{ organization_query or '' }}">
</div>
<div class="filter-field">
<label class="filter-label" for="contact">Kontakt</label>
<input
type="text"
name="contact"
id="contact"
class="form-control"
placeholder="fx Kennie"
value="{{ contact_query or '' }}">
</div>
<div class="filter-field">
<label class="filter-label" for="date_from">Dato fra</label>
<input
type="date"
name="date_from"
id="date_from"
class="form-control"
value="{{ date_from or '' }}">
</div>
<div class="filter-field">
<label class="filter-label" for="date_to">Dato til</label>
<input
type="date"
name="date_to"
id="date_to"
class="form-control"
value="{{ date_to or '' }}">
</div>
<div class="filter-actions">
<button class="btn btn-primary" type="submit">
<i class="bi bi-funnel me-1"></i> Filtrer
</button>
<a class="btn btn-outline-secondary" href="/ticket/archived">
<i class="bi bi-arrow-counterclockwise me-1"></i> Nulstil
</a>
</div>
</div>
</form>
</section>
<section class="surface-card sync-status-card">
<div class="sync-status-header">
<div>
<h2 class="sync-status-title">Importstatus for gamle sager</h2>
<p class="sync-status-subtitle">Fjernkontrol mod Simply og vTiger samt lokale tællere for tickets og beskeder.</p>
</div>
<div class="sync-status-overall" id="archivedSyncOverall">
<i class="bi bi-hourglass-split"></i>
Henter status
</div>
</div>
<div class="sync-grid" id="archivedSyncGrid">
<div class="sync-source-card">
<div class="sync-source-name">Indlæser status…</div>
</div>
</div>
</section>
<section class="surface-card recon-card">
<div class="recon-header">
<div>
<h2 class="recon-title">Reconciliation</h2>
<p class="recon-subtitle">Sammenlign remote `external_id`s mod lokale imports og se konkrete mangler.</p>
</div>
<div class="recon-actions">
<button type="button" class="btn btn-outline-primary" data-recon-source="simplycrm">
<i class="bi bi-search me-1"></i> Check Simply
</button>
<button type="button" class="btn btn-outline-primary" data-recon-source="vtiger">
<i class="bi bi-search me-1"></i> Check vTiger
</button>
<button type="button" class="btn btn-primary" id="runAllReconciliation">
<i class="bi bi-arrow-repeat me-1"></i> Check begge
</button>
</div>
</div>
<div class="recon-grid" id="archivedReconGrid">
<div class="recon-source-card">
<div class="recon-empty">Kør reconciliation for at se, hvilke gamle sager der evt. mangler lokalt.</div>
</div>
</div>
</section>
<div id="archivedResults">
{% if tickets %}
<section class="surface-card results-card">
<div class="results-header">
<h3 class="results-title">Resultater</h3>
<div class="results-meta">Klik på en række for at åbne ticketens detaljeside.</div>
</div>
<div class="table-responsive">
<table class="table ticket-table align-middle">
<thead>
<tr>
<th>Ticket</th>
<th>Organisation</th>
<th>Kontakt</th>
<th>Email</th>
<th>Løsning</th>
<th>Beskeder</th>
<th>Tid</th>
<th>Status</th>
<th>Oprettet</th>
</tr>
</thead>
<tbody>
{% for ticket in tickets %}
{% set status_class = (ticket.status or 'generic')|lower|replace(' ', '_') %}
<tr class="ticket-row" onclick="window.location='/ticket/archived/{{ ticket.id }}'">
<td class="ticket-cell-main">
{% if ticket.ticket_number %}
<div class="ticket-number">
<i class="bi bi-ticket-perforated"></i>
{{ ticket.ticket_number }}
</div>
{% endif %}
<div class="ticket-title-cell">{{ ticket.title or '-' }}</div>
<div class="ticket-description-preview">
{{ (ticket.description or ticket.solution or 'Ingen forhåndsvisning')[:130] }}{% if (ticket.description or ticket.solution or '')|length > 130 %}...{% endif %}
</div>
</td>
<td>{{ ticket.organization_name or '-' }}</td>
<td>{{ ticket.contact_name or '-' }}</td>
<td>{{ ticket.email_from or '-' }}</td>
<td>
{% if ticket.solution and ticket.solution.strip() %}
{{ ticket.solution[:120] }}{% if ticket.solution|length > 120 %}...{% endif %}
{% else %}
<span class="muted-dash">-</span>
{% endif %}
</td>
<td>
<span class="count-pill">
<i class="bi bi-chat-left-text"></i>
{{ ticket.message_count or 0 }}
</span>
</td>
<td class="time-spent">
{% if ticket.time_spent_hours is not none %}
{{ '%.2f'|format(ticket.time_spent_hours) }} t
{% else %}
<span class="muted-dash">-</span>
{% endif %}
</td>
<td>
{% if ticket.status %}
<span class="status-pill status-{{ status_class }}">
<i class="bi bi-circle-fill"></i>
{{ ticket.status.replace('_', ' ').title() }}
</span>
{% else %}
<span class="status-pill status-generic">Ukendt</span>
{% endif %}
</td>
<td>
{% if ticket.source_created_at %}
{{ ticket.source_created_at.strftime('%d/%m/%Y') }}
{% else %}
<span class="muted-dash">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% else %}
<section class="surface-card empty-state">
<i class="bi bi-archive"></i>
<h4>Ingen arkiverede tickets fundet</h4>
<p>Prøv at justere søgning, kontakt, organisation eller datointerval.</p>
</section>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const filterForm = document.getElementById('archivedFilters');
const debounceMs = 500;
let debounceTimer;
const syncGrid = document.getElementById('archivedSyncGrid');
const syncOverall = document.getElementById('archivedSyncOverall');
const reconGrid = document.getElementById('archivedReconGrid');
const reconButtons = document.querySelectorAll('[data-recon-source]');
const runAllReconciliationBtn = document.getElementById('runAllReconciliation');
function formatNumber(value) {
if (value === null || value === undefined || value === '') return '-';
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric.toLocaleString('da-DK') : value;
}
function formatDateTime(value) {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('da-DK');
}
function renderSyncSource(source) {
const isError = Boolean(source.error);
const isOk = source.is_synced === true;
const pillClass = isError ? 'is-error' : (isOk ? 'is-ok' : 'is-warn');
const pillText = isError ? 'Fejl ved check' : (isOk ? 'I sync' : 'Kræver import');
const diffText = source.diff === null || source.diff === undefined
? '-'
: (source.diff > 0 ? `Mangler ${formatNumber(source.diff)}` : source.diff < 0 ? `${formatNumber(Math.abs(source.diff))} ekstra lokalt` : '0');
return `
<div class="sync-source-card">
<div class="sync-source-top">
<div>
<h3 class="sync-source-name">${source.source_label || 'Ukendt kilde'}</h3>
<div class="sync-source-module">Modul: ${source.source_module || '-'}</div>
</div>
<div class="sync-pill ${pillClass}">
<i class="bi ${isError ? 'bi-exclamation-triangle-fill' : (isOk ? 'bi-check-circle-fill' : 'bi-arrow-repeat')}"></i>
${pillText}
</div>
</div>
<div class="sync-metrics">
<div class="sync-metric">
<div class="sync-metric-label">Remote tickets</div>
<div class="sync-metric-value">${formatNumber(source.remote_total_tickets)}</div>
</div>
<div class="sync-metric">
<div class="sync-metric-label">Lokale tickets</div>
<div class="sync-metric-value">${formatNumber(source.local_total_tickets)}</div>
</div>
<div class="sync-metric">
<div class="sync-metric-label">Difference</div>
<div class="sync-metric-value">${diffText}</div>
</div>
<div class="sync-metric">
<div class="sync-metric-label">Lokale beskeder</div>
<div class="sync-metric-value">${formatNumber(source.local_total_messages)}</div>
</div>
</div>
<div class="sync-meta-list">
<div><strong>Tickets med beskeder:</strong> ${formatNumber(source.tickets_with_messages)} / ${formatNumber(source.local_total_tickets)}</div>
<div><strong>Kommentarer / emails:</strong> ${formatNumber(source.local_total_comments)} / ${formatNumber(source.local_total_emails)}</div>
<div><strong>Nyeste kildedato:</strong> ${formatDateTime(source.newest_source_created_at)}</div>
<div><strong>Sidst synket:</strong> ${formatDateTime(source.last_synced_at)}</div>
</div>
${source.error ? `<div class="sync-error">${source.error}</div>` : ''}
</div>
`;
}
async function loadArchivedSyncStatus() {
if (!syncGrid || !syncOverall) return;
try {
const response = await fetch('/api/v1/ticket/archived/status', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const sources = Object.values(data.sources || {});
if (!sources.length) {
syncGrid.innerHTML = '<div class="sync-source-card"><div class="sync-source-name">Ingen statusdata fundet</div></div>';
return;
}
syncGrid.innerHTML = sources.map(renderSyncSource).join('');
if (data.overall_synced) {
syncOverall.className = 'sync-status-overall';
syncOverall.innerHTML = '<i class="bi bi-check-circle-fill"></i> Alle kilder i sync';
} else {
syncOverall.className = 'sync-status-overall';
syncOverall.innerHTML = '<i class="bi bi-exclamation-circle-fill"></i> Der er afvigelser';
}
} catch (error) {
syncGrid.innerHTML = '<div class="sync-source-card"><div class="sync-source-name">Kunne ikke hente importstatus</div><div class="sync-error">Status-endpointet svarede ikke eller kræver højere rettigheder.</div></div>';
syncOverall.className = 'sync-status-overall';
syncOverall.innerHTML = '<i class="bi bi-x-circle-fill"></i> Status utilgængelig';
}
}
function renderReconItems(items) {
if (!items || !items.length) {
return '<div class="recon-empty">Ingen eksempler.</div>';
}
return `
<div class="recon-list">
${items.map((item) => `
<div class="recon-item">
<div class="recon-item-main">${item.ticket_number || item.external_id || 'Ukendt reference'}</div>
<div class="recon-item-sub">${item.title || 'Ingen titel'}${item.external_id ? ` · ${item.external_id}` : ''}</div>
</div>
`).join('')}
</div>
`;
}
function renderReconCard(data) {
const hasError = Boolean(data.error);
const isPerfect = !hasError && Number(data.missing_locally_count || 0) === 0 && Number(data.orphaned_locally_count || 0) === 0;
const badgeClass = hasError ? 'is-error' : (isPerfect ? 'is-ok' : 'is-warn');
const badgeText = hasError ? 'Fejl' : (isPerfect ? 'Match' : 'Afvigelser');
return `
<div class="recon-source-card">
<div class="recon-source-head">
<div>
<h3 class="recon-source-name">${data.source_label || data.source || 'Ukendt kilde'}</h3>
<div class="recon-source-meta">Modul: ${data.module_name || '-'} · Coverage: ${data.coverage_pct ?? '-'}%</div>
</div>
<div class="recon-badge ${badgeClass}">
<i class="bi ${hasError ? 'bi-x-circle-fill' : (isPerfect ? 'bi-check-circle-fill' : 'bi-exclamation-circle-fill')}"></i>
${badgeText}
</div>
</div>
<div class="recon-metrics">
<div class="recon-metric">
<div class="recon-metric-label">Remote unikke</div>
<div class="recon-metric-value">${formatNumber(data.remote_unique_external_ids)}</div>
</div>
<div class="recon-metric">
<div class="recon-metric-label">Matchende</div>
<div class="recon-metric-value">${formatNumber(data.matched_external_ids)}</div>
</div>
<div class="recon-metric">
<div class="recon-metric-label">Mangler lokalt</div>
<div class="recon-metric-value">${formatNumber(data.missing_locally_count)}</div>
</div>
</div>
<div class="recon-list-block">
<div class="recon-list-title">Manglende lokalt</div>
${renderReconItems(data.missing_locally_examples)}
</div>
<div class="recon-list-block">
<div class="recon-list-title">Ekstra lokalt</div>
${renderReconItems(data.orphaned_locally_examples)}
</div>
${hasError ? `<div class="sync-error">${data.error}</div>` : ''}
</div>
`;
}
async function fetchReconciliation(source) {
const response = await fetch(`/api/v1/ticket/archived/reconciliation/${source}?limit=50000&sample_size=10`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (!response.ok) {
let message = `HTTP ${response.status}`;
try {
const body = await response.json();
if (body && body.detail) {
message = body.detail;
}
} catch (error) {
// ignore parse error
}
throw new Error(message);
}
return response.json();
}
async function runReconciliation(sources) {
if (!reconGrid) return;
reconGrid.innerHTML = sources.map((source) => `
<div class="recon-source-card">
<div class="recon-source-name">Checker ${source === 'simplycrm' ? 'Simply-CRM' : 'vTiger'}…</div>
<div class="recon-empty">Sammenligner remote og lokale external_id'er.</div>
</div>
`).join('');
const results = await Promise.all(sources.map(async (source) => {
try {
return await fetchReconciliation(source);
} catch (error) {
return {
source,
source_label: source === 'simplycrm' ? 'Simply-CRM' : 'vTiger',
module_name: source === 'simplycrm' ? 'HelpDesk' : 'Cases',
error: error.message || 'Ukendt fejl',
missing_locally_count: null,
orphaned_locally_count: null,
matched_external_ids: null,
remote_unique_external_ids: null,
coverage_pct: null,
missing_locally_examples: [],
orphaned_locally_examples: []
};
}
}));
reconGrid.innerHTML = results.map(renderReconCard).join('');
}
async function fetchResults() {
if (!filterForm) {
return;
}
const formData = new FormData(filterForm);
const params = new URLSearchParams(formData);
const url = `/ticket/archived?${params.toString()}`;
history.replaceState(null, '', url);
try {
const response = await fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (response.redirected) {
window.location.href = response.url;
return;
}
if (!response.ok) {
filterForm.submit();
return;
}
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newResults = doc.getElementById('archivedResults');
const currentResults = document.getElementById('archivedResults');
if (newResults && currentResults) {
currentResults.replaceWith(newResults);
} else {
filterForm.submit();
}
} catch (error) {
filterForm.submit();
}
}
function submitFilters() {
fetchResults();
}
function debounceSubmit() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(submitFilters, debounceMs);
}
const inputs = [
document.getElementById('search'),
document.getElementById('organization'),
document.getElementById('contact'),
document.getElementById('date_from'),
document.getElementById('date_to')
];
inputs.forEach((input) => {
if (!input) {
return;
}
if (input.type === 'date') {
input.addEventListener('change', debounceSubmit);
} else {
input.addEventListener('input', debounceSubmit);
}
});
reconButtons.forEach((button) => {
button.addEventListener('click', () => {
runReconciliation([button.dataset.reconSource]);
});
});
if (runAllReconciliationBtn) {
runAllReconciliationBtn.addEventListener('click', () => {
runReconciliation(['simplycrm', 'vtiger']);
});
}
loadArchivedSyncStatus();
</script>
{% endblock %}