2026-02-08 01:45:00 +01:00
{% extends "shared/frontend/base.html" %}
{% block title %}Arkiverede Tickets - BMC Hub{% endblock %}
{% block extra_css %}
< style >
2026-07-03 20:20:22 +02:00
.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;
2026-02-08 01:45:00 +01:00
}
.filter-grid {
2026-07-03 20:20:22 +02:00
grid-template-columns: repeat(3, minmax(0, 1fr));
2026-02-08 01:45:00 +01:00
}
2026-07-03 20:20:22 +02:00
.filter-field.search-field {
grid-column: span 3;
2026-02-08 01:45:00 +01:00
}
2026-07-03 20:20:22 +02:00
.overview-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
2026-02-08 01:45:00 +01:00
}
2026-07-03 20:20:22 +02:00
}
2026-02-08 01:45:00 +01:00
2026-07-03 20:20:22 +02:00
@media (max-width: 991.98px) {
.sync-status-header,
.recon-header,
.recon-source-head,
.sync-source-top {
flex-direction: column;
align-items: flex-start;
2026-02-08 01:45:00 +01:00
}
2026-07-03 20:20:22 +02:00
.filter-card-header,
.results-header {
flex-direction: column;
align-items: flex-start;
2026-02-08 01:45:00 +01:00
}
2026-07-03 20:20:22 +02:00
.filter-actions {
justify-content: flex-start;
2026-02-08 01:45:00 +01:00
}
2026-07-03 20:20:22 +02:00
}
2026-02-08 01:45:00 +01:00
2026-07-03 20:20:22 +02:00
@media (max-width: 767.98px) {
.archived-list-page {
padding-bottom: 2rem;
2026-02-08 01:45:00 +01:00
}
2026-07-03 20:20:22 +02:00
.list-hero,
.filter-card,
.surface-card {
border-radius: 20px;
2026-02-08 01:45:00 +01:00
}
2026-07-03 20:20:22 +02:00
.list-hero,
.filter-card {
padding: 1.3rem;
2026-02-08 01:45:00 +01:00
}
2026-07-03 20:20:22 +02:00
.overview-strip,
.filter-grid,
.sync-metrics,
.recon-metrics {
grid-template-columns: 1fr;
2026-02-08 01:45:00 +01:00
}
2026-07-03 20:20:22 +02:00
.filter-field.search-field {
grid-column: span 1;
2026-02-08 01:45:00 +01:00
}
2026-07-03 20:20:22 +02:00
.ticket-table thead {
display: none;
}
.ticket-table,
.ticket-table tbody,
.ticket-table tr,
.ticket-table td {
display: block;
width: 100%;
2026-02-08 01:45:00 +01:00
}
2026-07-03 20:20:22 +02:00
.ticket-table tbody td {
padding: 0.6rem 1rem;
border-bottom: 0;
2026-02-08 01:45:00 +01:00
}
2026-07-03 20:20:22 +02:00
.ticket-row {
border-bottom: 1px solid rgba(15, 76, 117, 0.08);
padding: 0.5rem 0;
2026-02-08 01:45:00 +01:00
}
2026-07-03 20:20:22 +02:00
.ticket-row:last-child {
border-bottom: 0;
2026-02-08 01:45:00 +01:00
}
2026-07-03 20:20:22 +02:00
}
2026-02-08 01:45:00 +01:00
< / style >
{% endblock %}
{% block content %}
2026-07-03 20:20:22 +02:00
< 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 >
2026-02-08 01:45:00 +01:00
< / div >
< form method = "get" action = "/ticket/archived" id = "archivedFilters" >
< div class = "filter-grid" >
2026-07-03 20:20:22 +02:00
< div class = "filter-field search-field" >
< label class = "filter-label" for = "search" > Søg< / label >
2026-02-08 01:45:00 +01:00
< div class = "search-box" >
< i class = "bi bi-search" > < / i >
< input
type="text"
name="search"
id="search"
class="form-control"
2026-02-17 08:29:05 +01:00
placeholder="Ticket nr, titel, løsning eller kommentar..."
2026-02-08 01:45:00 +01:00
value="{{ search_query or '' }}">
< / div >
< / div >
2026-07-03 20:20:22 +02:00
< div class = "filter-field" >
< label class = "filter-label" for = "organization" > Organisation< / label >
2026-02-08 01:45:00 +01:00
< input
type="text"
name="organization"
id="organization"
class="form-control"
placeholder="fx Norva24"
value="{{ organization_query or '' }}">
< / div >
2026-07-03 20:20:22 +02:00
< div class = "filter-field" >
< label class = "filter-label" for = "contact" > Kontakt< / label >
2026-02-08 01:45:00 +01:00
< input
type="text"
name="contact"
id="contact"
class="form-control"
placeholder="fx Kennie"
value="{{ contact_query or '' }}">
< / div >
2026-07-03 20:20:22 +02:00
< div class = "filter-field" >
< label class = "filter-label" for = "date_from" > Dato fra< / label >
2026-02-08 01:45:00 +01:00
< input
type="date"
name="date_from"
id="date_from"
class="form-control"
value="{{ date_from or '' }}">
< / div >
2026-07-03 20:20:22 +02:00
< div class = "filter-field" >
< label class = "filter-label" for = "date_to" > Dato til< / label >
2026-02-08 01:45:00 +01:00
< input
type="date"
name="date_to"
id="date_to"
class="form-control"
value="{{ date_to or '' }}">
< / div >
2026-07-03 20:20:22 +02:00
2026-02-08 01:45:00 +01:00
< div class = "filter-actions" >
< button class = "btn btn-primary" type = "submit" >
2026-07-03 20:20:22 +02:00
< i class = "bi bi-funnel me-1" > < / i > Filtrer
2026-02-08 01:45:00 +01:00
< / button >
< a class = "btn btn-outline-secondary" href = "/ticket/archived" >
2026-07-03 20:20:22 +02:00
< i class = "bi bi-arrow-counterclockwise me-1" > < / i > Nulstil
2026-02-08 01:45:00 +01:00
< / a >
< / div >
< / div >
< / form >
2026-07-03 20:20:22 +02:00
< / section >
2026-02-08 01:45:00 +01:00
2026-07-03 20:20:22 +02:00
< 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
2026-02-08 01:45:00 +01:00
< / div >
< / div >
2026-07-03 20:20:22 +02:00
< div class = "sync-grid" id = "archivedSyncGrid" >
< div class = "sync-source-card" >
< div class = "sync-source-name" > Indlæser status…< / div >
< / div >
2026-02-08 01:45:00 +01:00
< / div >
2026-07-03 20:20:22 +02:00
< / 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 >
2026-02-08 01:45:00 +01:00
{% endif %}
< / div >
< / div >
{% endblock %}
{% block extra_js %}
< script >
const filterForm = document.getElementById('archivedFilters');
const debounceMs = 500;
let debounceTimer;
2026-07-03 20:20:22 +02:00
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('');
}
2026-02-08 01:45:00 +01:00
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);
}
});
2026-07-03 20:20:22 +02:00
reconButtons.forEach((button) => {
button.addEventListener('click', () => {
runReconciliation([button.dataset.reconSource]);
});
});
if (runAllReconciliationBtn) {
runAllReconciliationBtn.addEventListener('click', () => {
runReconciliation(['simplycrm', 'vtiger']);
});
}
loadArchivedSyncStatus();
2026-02-08 01:45:00 +01:00
< / script >
{% endblock %}