1354 lines
43 KiB
HTML
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 %}
|