1699 lines
48 KiB
HTML
1699 lines
48 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<script>
|
|
(function(){var t=localStorage.getItem('theme');if(t)document.documentElement.setAttribute('data-bs-theme',t);})();
|
|
</script>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
|
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
|
<title id="page-title-text">{% block title %}IT Helpdesk{% endblock %} — {{ branding.app_name }}</title>
|
|
<style>
|
|
:root {
|
|
--accent: {{ branding.primary_color }};
|
|
--accent-h: {{ branding.primary_color }};
|
|
}
|
|
</style>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
<link
|
|
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,300&display=swap"
|
|
rel="stylesheet" />
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" />
|
|
<style>
|
|
:root {
|
|
--bg: #f0f4f8;
|
|
--surface: #ffffff;
|
|
--surface2: #f8fafc;
|
|
--border: #e2e8f0;
|
|
--border2: #cbd5e1;
|
|
--accent: #2563eb;
|
|
--accent-h: #1d4ed8;
|
|
--accent2: #7c3aed;
|
|
--accent3: #0891b2;
|
|
--text: #0f172a;
|
|
--text2: #334155;
|
|
--muted: #64748b;
|
|
--muted2: #94a3b8;
|
|
--success: #059669;
|
|
--success-bg: #ecfdf5;
|
|
--warning: #d97706;
|
|
--warning-bg: #fffbeb;
|
|
--danger: #dc2626;
|
|
--danger-bg: #fef2f2;
|
|
--info: #0284c7;
|
|
--info-bg: #f0f9ff;
|
|
--sidebar-w: 260px;
|
|
--sidebar-bg: #1e293b;
|
|
--sidebar-text: #94a3b8;
|
|
--sidebar-active: #ffffff;
|
|
--radius: 10px;
|
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, .08), 0 1px 2px rgba(0, 0, 0, .05);
|
|
--shadow: 0 4px 6px rgba(0, 0, 0, .07), 0 2px 4px rgba(0, 0, 0, .05);
|
|
--shadow-lg: 0 10px 25px rgba(0, 0, 0, .1), 0 4px 10px rgba(0, 0, 0, .06);
|
|
}
|
|
|
|
html[data-bs-theme="dark"] {
|
|
--bg: #0d1117;
|
|
--surface: #161b22;
|
|
--surface2: #1c2333;
|
|
--border: #30363d;
|
|
--border2: #3d444d;
|
|
--text: #e6edf3;
|
|
--text2: #adbac7;
|
|
--muted: #8b949e;
|
|
--muted2: #545d68;
|
|
--success: #3fb950;
|
|
--success-bg: #0b2a12;
|
|
--warning: #d29922;
|
|
--warning-bg: #1c1100;
|
|
--danger: #f85149;
|
|
--danger-bg: #210b0a;
|
|
--info: #388bfd;
|
|
--info-bg: #031d40;
|
|
--sidebar-bg: #0d1117;
|
|
--sidebar-text: #8b949e;
|
|
--sidebar-active: #e6edf3;
|
|
--shadow-sm: 0 1px 3px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.25);
|
|
--shadow: 0 4px 6px rgba(0,0,0,.3), 0 2px 4px rgba(0,0,0,.2);
|
|
--shadow-lg: 0 10px 25px rgba(0,0,0,.5), 0 4px 10px rgba(0,0,0,.3);
|
|
}
|
|
|
|
html[data-bs-theme="dark"] #topbar {
|
|
background: rgba(22, 27, 34, .95);
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
font-family: 'DM Sans', sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
a {
|
|
color: var(--accent);
|
|
text-decoration: none;
|
|
}
|
|
|
|
a:hover {
|
|
color: var(--accent-h);
|
|
}
|
|
|
|
code,
|
|
pre,
|
|
.mono {
|
|
font-family: 'Space Mono', monospace;
|
|
}
|
|
|
|
/* ── Scrollbar ── */
|
|
::-webkit-scrollbar {
|
|
width: 5px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: var(--bg);
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--border2);
|
|
border-radius: 3px;
|
|
}
|
|
|
|
/* ── Sidebar — dark panel on light page ── */
|
|
#sidebar {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: var(--sidebar-w);
|
|
height: 100vh;
|
|
background: var(--sidebar-bg);
|
|
display: flex;
|
|
flex-direction: column;
|
|
z-index: 100;
|
|
transition: transform .25s ease;
|
|
box-shadow: 2px 0 8px rgba(0, 0, 0, .12);
|
|
}
|
|
|
|
#sidebar .brand {
|
|
padding: 20px 18px 16px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, .07);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
#sidebar .brand .logo {
|
|
width: 36px;
|
|
height: 36px;
|
|
background: var(--accent);
|
|
border-radius: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-family: 'Space Mono', monospace;
|
|
font-weight: 700;
|
|
font-size: 13px;
|
|
color: #fff;
|
|
flex-shrink: 0;
|
|
box-shadow: 0 2px 8px rgba(37, 99, 235, .4);
|
|
}
|
|
|
|
#sidebar .brand h1 {
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
letter-spacing: .2px;
|
|
}
|
|
|
|
#sidebar .brand small {
|
|
font-size: 10px;
|
|
color: rgba(255, 255, 255, .35);
|
|
display: block;
|
|
margin-top: 1px;
|
|
}
|
|
|
|
.nav-section {
|
|
padding: 20px 16px 6px;
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
letter-spacing: 1.8px;
|
|
color: rgba(255, 255, 255, .3);
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.sidebar-nav {
|
|
list-style: none;
|
|
padding: 0 10px;
|
|
}
|
|
|
|
.sidebar-nav li a {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 9px 12px;
|
|
border-radius: 8px;
|
|
color: var(--sidebar-text);
|
|
font-size: 13.5px;
|
|
font-weight: 500;
|
|
transition: all .15s;
|
|
}
|
|
|
|
.sidebar-nav li a:hover {
|
|
background: rgba(255, 255, 255, .07);
|
|
color: #fff;
|
|
}
|
|
|
|
.sidebar-nav li a.active {
|
|
background: var(--accent);
|
|
color: #fff;
|
|
box-shadow: 0 2px 8px rgba(37, 99, 235, .35);
|
|
}
|
|
|
|
.sidebar-nav li a .bi {
|
|
font-size: 15px;
|
|
width: 18px;
|
|
text-align: center;
|
|
}
|
|
|
|
.sidebar-footer {
|
|
margin-top: auto;
|
|
padding: 14px;
|
|
border-top: 1px solid rgba(255, 255, 255, .07);
|
|
}
|
|
|
|
.sidebar-footer .user-card {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.sidebar-footer .avatar {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 50%;
|
|
background: var(--accent2);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.sidebar-footer .user-name {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #fff;
|
|
}
|
|
|
|
.sidebar-footer .user-role {
|
|
font-size: 10px;
|
|
color: rgba(255, 255, 255, .4);
|
|
text-transform: capitalize;
|
|
}
|
|
|
|
/* ── Main ── */
|
|
#main {
|
|
margin-left: var(--sidebar-w);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* ── Topbar ── */
|
|
#topbar {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 50;
|
|
background: rgba(255, 255, 255, .92);
|
|
backdrop-filter: blur(12px);
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 11px 28px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
#topbar .page-title {
|
|
font-size: 17px;
|
|
font-weight: 700;
|
|
flex: 1;
|
|
color: var(--text);
|
|
}
|
|
|
|
.notif-btn {
|
|
position: relative;
|
|
background: none;
|
|
border: none;
|
|
color: var(--muted);
|
|
font-size: 20px;
|
|
cursor: pointer;
|
|
padding: 4px 8px;
|
|
border-radius: 6px;
|
|
transition: color .15s;
|
|
}
|
|
|
|
.notif-btn:hover {
|
|
color: var(--accent);
|
|
background: var(--info-bg);
|
|
}
|
|
|
|
.notif-badge {
|
|
position: absolute;
|
|
top: -2px;
|
|
right: -2px;
|
|
min-width: 17px;
|
|
height: 17px;
|
|
background: var(--danger);
|
|
color: #fff;
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
border-radius: 9px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0 4px;
|
|
display: none;
|
|
}
|
|
|
|
.notif-badge.show {
|
|
display: flex;
|
|
}
|
|
|
|
/* ── Page content ── */
|
|
.page-body {
|
|
padding: 28px;
|
|
flex: 1;
|
|
}
|
|
|
|
/* ── Cards ── */
|
|
.card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.card-header {
|
|
background: var(--surface2);
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 13px 20px;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
color: var(--text2);
|
|
border-radius: var(--radius) var(--radius) 0 0;
|
|
}
|
|
|
|
.card-body {
|
|
padding: 20px;
|
|
}
|
|
|
|
/* ── Stat cards ── */
|
|
.stat-card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
transition: all .2s;
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.stat-card:hover {
|
|
box-shadow: var(--shadow);
|
|
border-color: var(--border2);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.stat-icon {
|
|
width: 46px;
|
|
height: 46px;
|
|
border-radius: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 21px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 26px;
|
|
font-weight: 700;
|
|
font-family: 'Space Mono', monospace;
|
|
line-height: 1;
|
|
color: var(--text);
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
margin-top: 4px;
|
|
}
|
|
|
|
/* ── Badges ── */
|
|
.badge {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
padding: 3px 9px;
|
|
border-radius: 20px;
|
|
font-family: 'Space Mono', monospace;
|
|
letter-spacing: .2px;
|
|
}
|
|
|
|
.badge-open {
|
|
background: var(--info-bg);
|
|
color: var(--info);
|
|
border: 1px solid #bae6fd;
|
|
}
|
|
|
|
.badge-in_progress {
|
|
background: #eff6ff;
|
|
color: #2563eb;
|
|
border: 1px solid #bfdbfe;
|
|
}
|
|
|
|
.badge-pending {
|
|
background: var(--warning-bg);
|
|
color: var(--warning);
|
|
border: 1px solid #fde68a;
|
|
}
|
|
|
|
.badge-resolved {
|
|
background: var(--success-bg);
|
|
color: var(--success);
|
|
border: 1px solid #a7f3d0;
|
|
}
|
|
|
|
.badge-closed {
|
|
background: #f1f5f9;
|
|
color: var(--muted);
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.badge-low {
|
|
background: var(--success-bg);
|
|
color: var(--success);
|
|
border: 1px solid #a7f3d0;
|
|
}
|
|
|
|
.badge-medium {
|
|
background: var(--warning-bg);
|
|
color: var(--warning);
|
|
border: 1px solid #fde68a;
|
|
}
|
|
|
|
.badge-high {
|
|
background: var(--danger-bg);
|
|
color: var(--danger);
|
|
border: 1px solid #fecaca;
|
|
}
|
|
|
|
.badge-critical {
|
|
background: #fff1f2;
|
|
color: #be123c;
|
|
border: 1px solid #fecdd3;
|
|
animation: pulse-badge 2s infinite;
|
|
}
|
|
|
|
@keyframes pulse-badge {
|
|
|
|
0%,
|
|
100% {
|
|
opacity: 1;
|
|
}
|
|
|
|
50% {
|
|
opacity: .65;
|
|
}
|
|
}
|
|
|
|
/* ── Buttons ── */
|
|
.btn {
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
padding: 8px 16px;
|
|
transition: all .15s;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--accent);
|
|
border-color: var(--accent);
|
|
color: #fff;
|
|
box-shadow: 0 1px 3px rgba(37, 99, 235, .3);
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: var(--accent-h);
|
|
border-color: var(--accent-h);
|
|
box-shadow: 0 2px 6px rgba(37, 99, 235, .4);
|
|
}
|
|
|
|
.btn-outline-primary {
|
|
border-color: var(--accent);
|
|
color: var(--accent);
|
|
background: transparent;
|
|
}
|
|
|
|
.btn-outline-primary:hover {
|
|
background: var(--accent);
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: #fff;
|
|
border-color: var(--border);
|
|
color: var(--text2);
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: var(--surface2);
|
|
border-color: var(--border2);
|
|
color: var(--text);
|
|
}
|
|
|
|
.btn-sm {
|
|
padding: 5px 12px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* ── Form controls ── */
|
|
.form-control,
|
|
.form-select {
|
|
background: #fff;
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
border-radius: 8px;
|
|
padding: 9px 14px;
|
|
font-size: 14px;
|
|
transition: border-color .15s, box-shadow .15s;
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.form-control:focus,
|
|
.form-select:focus {
|
|
background: #fff;
|
|
border-color: var(--accent);
|
|
color: var(--text);
|
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, .1);
|
|
}
|
|
|
|
.form-control::placeholder {
|
|
color: var(--muted2);
|
|
}
|
|
|
|
.form-label {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--text2);
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
textarea.form-control {
|
|
resize: vertical;
|
|
}
|
|
|
|
/* ── Tables ── */
|
|
.table {
|
|
color: var(--text);
|
|
--bs-table-bg: transparent;
|
|
}
|
|
|
|
.table thead th {
|
|
background: var(--surface2);
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
letter-spacing: .8px;
|
|
text-transform: uppercase;
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 11px 16px;
|
|
}
|
|
|
|
.table tbody td {
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 11px 16px;
|
|
vertical-align: middle;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.table tbody tr:hover td {
|
|
background: #f8fafc;
|
|
}
|
|
|
|
.table-hover>tbody>tr:hover>* {
|
|
background: #f8fafc;
|
|
}
|
|
|
|
/* ── Alerts / flashes ── */
|
|
.alert {
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
padding: 12px 16px;
|
|
}
|
|
|
|
.alert-success {
|
|
background: var(--success-bg);
|
|
color: var(--success);
|
|
border: 1px solid #a7f3d0;
|
|
}
|
|
|
|
.alert-danger {
|
|
background: var(--danger-bg);
|
|
color: var(--danger);
|
|
border: 1px solid #fecaca;
|
|
}
|
|
|
|
.alert-info {
|
|
background: var(--info-bg);
|
|
color: var(--info);
|
|
border: 1px solid #bae6fd;
|
|
}
|
|
|
|
.alert-warning {
|
|
background: var(--warning-bg);
|
|
color: var(--warning);
|
|
border: 1px solid #fde68a;
|
|
}
|
|
|
|
/* ── Dropdown ── */
|
|
.dropdown-menu {
|
|
background: #fff;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
box-shadow: var(--shadow-lg);
|
|
}
|
|
|
|
.dropdown-item {
|
|
color: var(--text2);
|
|
font-size: 13.5px;
|
|
padding: 8px 16px;
|
|
}
|
|
|
|
.dropdown-item:hover {
|
|
background: var(--surface2);
|
|
color: var(--text);
|
|
}
|
|
|
|
.dropdown-divider {
|
|
border-color: var(--border);
|
|
}
|
|
|
|
/* ── Notification panel ── */
|
|
#notif-panel {
|
|
position: fixed;
|
|
top: 58px;
|
|
right: 16px;
|
|
width: 360px;
|
|
max-height: 480px;
|
|
background: #fff;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
box-shadow: var(--shadow-lg);
|
|
z-index: 1000;
|
|
display: none;
|
|
overflow: hidden;
|
|
flex-direction: column;
|
|
}
|
|
|
|
#notif-panel.show {
|
|
display: flex;
|
|
}
|
|
|
|
#notif-panel .notif-head {
|
|
padding: 13px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
background: var(--surface2);
|
|
}
|
|
|
|
#notif-panel .notif-head h6 {
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
margin: 0;
|
|
color: var(--text);
|
|
}
|
|
|
|
#notif-panel .notif-list {
|
|
overflow-y: auto;
|
|
flex: 1;
|
|
}
|
|
|
|
.notif-item {
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
gap: 12px;
|
|
cursor: pointer;
|
|
transition: background .12s;
|
|
}
|
|
|
|
.notif-item:hover {
|
|
background: var(--surface2);
|
|
}
|
|
|
|
.notif-item.unread {
|
|
border-left: 3px solid var(--accent);
|
|
background: #eff6ff;
|
|
}
|
|
|
|
.notif-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--accent);
|
|
margin-top: 5px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.notif-title {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
margin-bottom: 2px;
|
|
color: var(--text);
|
|
}
|
|
|
|
.notif-msg {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.notif-time {
|
|
font-size: 11px;
|
|
color: var(--muted2);
|
|
margin-top: 2px;
|
|
font-family: 'Space Mono', monospace;
|
|
}
|
|
|
|
/* ── Chat widget ── */
|
|
#chat-fab {
|
|
position: fixed;
|
|
bottom: 28px;
|
|
right: 28px;
|
|
width: 52px;
|
|
height: 52px;
|
|
background: var(--accent);
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
box-shadow: 0 4px 16px rgba(37, 99, 235, .4);
|
|
z-index: 200;
|
|
transition: transform .2s, box-shadow .2s;
|
|
}
|
|
|
|
#chat-fab:hover {
|
|
transform: scale(1.08);
|
|
box-shadow: 0 6px 20px rgba(37, 99, 235, .5);
|
|
}
|
|
|
|
#chat-fab .bi {
|
|
font-size: 22px;
|
|
color: #fff;
|
|
}
|
|
|
|
#chat-window {
|
|
position: fixed;
|
|
bottom: 90px;
|
|
right: 28px;
|
|
width: 380px;
|
|
height: 520px;
|
|
background: #fff;
|
|
border: 1px solid var(--border);
|
|
border-radius: 16px;
|
|
box-shadow: var(--shadow-lg);
|
|
z-index: 200;
|
|
display: none;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
#chat-window.show {
|
|
display: flex;
|
|
}
|
|
|
|
.chat-head {
|
|
background: var(--accent);
|
|
padding: 14px 18px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.chat-head .bot-avatar {
|
|
width: 36px;
|
|
height: 36px;
|
|
background: rgba(255, 255, 255, .2);
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.chat-head .bot-name {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #fff;
|
|
}
|
|
|
|
.chat-head .bot-status {
|
|
font-size: 11px;
|
|
color: rgba(255, 255, 255, .75);
|
|
}
|
|
|
|
.chat-head .close-chat {
|
|
margin-left: auto;
|
|
background: none;
|
|
border: none;
|
|
color: rgba(255, 255, 255, .8);
|
|
font-size: 18px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.chat-messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
background: var(--bg);
|
|
}
|
|
|
|
.chat-bubble {
|
|
max-width: 85%;
|
|
padding: 10px 14px;
|
|
border-radius: 12px;
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.chat-bubble.bot {
|
|
background: #fff;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px 12px 12px 12px;
|
|
align-self: flex-start;
|
|
color: var(--text);
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.chat-bubble.user {
|
|
background: var(--accent);
|
|
color: #fff;
|
|
border-radius: 12px 4px 12px 12px;
|
|
align-self: flex-end;
|
|
}
|
|
|
|
.chat-bubble a {
|
|
color: var(--accent3);
|
|
}
|
|
|
|
.chat-input-area {
|
|
padding: 12px;
|
|
border-top: 1px solid var(--border);
|
|
display: flex;
|
|
gap: 8px;
|
|
background: #fff;
|
|
}
|
|
|
|
.chat-input-area input {
|
|
flex: 1;
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
border-radius: 8px;
|
|
padding: 9px 14px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.chat-input-area input:focus {
|
|
outline: none;
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, .1);
|
|
}
|
|
|
|
.chat-send {
|
|
background: var(--accent);
|
|
border: none;
|
|
color: #fff;
|
|
padding: 9px 14px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: background .15s;
|
|
}
|
|
|
|
.chat-send:hover {
|
|
background: var(--accent-h);
|
|
}
|
|
|
|
.typing-dot {
|
|
display: inline-block;
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--muted2);
|
|
animation: typing 1.2s infinite;
|
|
}
|
|
|
|
.typing-dot:nth-child(2) {
|
|
animation-delay: .2s;
|
|
}
|
|
|
|
.typing-dot:nth-child(3) {
|
|
animation-delay: .4s;
|
|
}
|
|
|
|
@keyframes typing {
|
|
|
|
0%,
|
|
60%,
|
|
100% {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
30% {
|
|
transform: translateY(-5px);
|
|
}
|
|
}
|
|
|
|
/* ── Pagination ── */
|
|
.pagination .page-link {
|
|
background: #fff;
|
|
border-color: var(--border);
|
|
color: var(--text2);
|
|
}
|
|
|
|
.pagination .page-link:hover {
|
|
background: var(--surface2);
|
|
border-color: var(--border2);
|
|
color: var(--text);
|
|
}
|
|
|
|
.pagination .active .page-link {
|
|
background: var(--accent);
|
|
border-color: var(--accent);
|
|
color: #fff;
|
|
}
|
|
|
|
/* ── Ticket detail ── */
|
|
.ticket-header {
|
|
background: #fff;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 24px;
|
|
margin-bottom: 20px;
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.comment-card {
|
|
background: #fff;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 16px;
|
|
margin-bottom: 12px;
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.comment-card.internal {
|
|
border-left: 3px solid var(--warning);
|
|
background: var(--warning-bg);
|
|
}
|
|
|
|
.comment-author {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
|
|
.comment-time {
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
font-family: 'Space Mono', monospace;
|
|
}
|
|
|
|
.comment-body {
|
|
font-size: 14px;
|
|
margin-top: 8px;
|
|
color: var(--text2);
|
|
line-height: 1.65;
|
|
}
|
|
|
|
.comment-body p {
|
|
margin: 0 0 8px;
|
|
}
|
|
|
|
.comment-body p:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.comment-body ul,
|
|
.comment-body ol {
|
|
margin: 0 0 8px;
|
|
padding-left: 20px;
|
|
}
|
|
|
|
.comment-body pre {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 10px 14px;
|
|
font-size: 12px;
|
|
overflow-x: auto;
|
|
margin: 0 0 8px;
|
|
}
|
|
|
|
.comment-body code {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 1px 5px;
|
|
font-size: 12px;
|
|
font-family: 'Space Mono', monospace;
|
|
}
|
|
|
|
.comment-body pre code {
|
|
background: none;
|
|
border: none;
|
|
padding: 0;
|
|
}
|
|
|
|
.comment-body blockquote {
|
|
border-left: 3px solid var(--border);
|
|
margin: 0 0 8px;
|
|
padding: 4px 12px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.comment-body a {
|
|
color: var(--accent3);
|
|
}
|
|
|
|
.comment-body hr {
|
|
border: none;
|
|
border-top: 1px solid var(--border);
|
|
margin: 10px 0;
|
|
}
|
|
|
|
.history-item {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
padding: 6px 0;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
/* ── Responsive ── */
|
|
@media(max-width:768px) {
|
|
#sidebar {
|
|
transform: translateX(-100%);
|
|
}
|
|
|
|
#sidebar.open {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
#main {
|
|
margin-left: 0;
|
|
}
|
|
|
|
#chat-window {
|
|
width: calc(100vw - 16px);
|
|
right: 8px;
|
|
}
|
|
}
|
|
</style>
|
|
{% block head %}{% endblock %}
|
|
</head>
|
|
|
|
<body>
|
|
|
|
{% if current_user.is_authenticated %}
|
|
<!-- ── Sidebar ── -->
|
|
<nav id="sidebar">
|
|
<div class="brand">
|
|
{% if branding.logo_stored_name %}
|
|
<img src="{{ url_for('auth.serve_logo', filename=branding.logo_stored_name) }}" alt="{{ branding.app_name }}"
|
|
style="width:36px;height:36px;border-radius:8px;object-fit:contain;flex-shrink:0;background:#fff;padding:2px;" />
|
|
{% else %}
|
|
<div class="logo">{{ branding.logo_initials[:2] }}</div>
|
|
{% endif %}
|
|
<div>
|
|
<h1>{{ branding.app_name }}</h1>
|
|
<small>{{ branding.app_subtitle }}</small>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="nav-section">Main</p>
|
|
<ul class="sidebar-nav">
|
|
<li><a href="{{ url_for('tickets.dashboard') }}"
|
|
class="{{ 'active' if request.endpoint == 'tickets.dashboard' }}">
|
|
<i class="bi bi-speedometer2"></i> Dashboard
|
|
</a></li>
|
|
<li><a href="{{ url_for('tickets.ticket_list') }}"
|
|
class="{{ 'active' if request.endpoint == 'tickets.ticket_list' }}">
|
|
<i class="bi bi-ticket-detailed"></i> My Tickets
|
|
</a></li>
|
|
<li><a href="{{ url_for('tickets.create_ticket') }}"
|
|
class="{{ 'active' if request.endpoint == 'tickets.create_ticket' }}">
|
|
<i class="bi bi-plus-circle"></i> New Ticket
|
|
</a></li>
|
|
</ul>
|
|
|
|
<p class="nav-section">Resources</p>
|
|
<ul class="sidebar-nav">
|
|
<li><a href="{{ url_for('tickets.knowledge_base') }}"
|
|
class="{{ 'active' if 'knowledge_base' in request.endpoint }}">
|
|
<i class="bi bi-book"></i> Knowledge Base
|
|
</a></li>
|
|
<li><a href="{{ url_for('tickets.notifications') }}"
|
|
class="{{ 'active' if request.endpoint == 'tickets.notifications' }}">
|
|
<i class="bi bi-bell"></i> Notifications
|
|
{% if unread_notifications > 0 %}<span class="badge badge-open ms-auto">{{ unread_notifications }}</span>{%
|
|
endif %}
|
|
</a></li>
|
|
</ul>
|
|
|
|
{% if current_user.is_it_staff %}
|
|
<p class="nav-section">IT Management</p>
|
|
<ul class="sidebar-nav">
|
|
<li><a href="{{ url_for('admin.index') }}" class="{{ 'active' if request.endpoint == 'admin.index' }}">
|
|
<i class="bi bi-bar-chart-line"></i> IT Overview
|
|
</a></li>
|
|
<li><a href="{{ url_for('admin.all_tickets') }}"
|
|
class="{{ 'active' if request.endpoint == 'admin.all_tickets' }}">
|
|
<i class="bi bi-collection"></i> All Tickets
|
|
</a></li>
|
|
<li><a href="{{ url_for('admin.kb_list') }}" class="{{ 'active' if 'admin.kb' in request.endpoint }}">
|
|
<i class="bi bi-journal-text"></i> Manage KB
|
|
</a></li>
|
|
{% if current_user.is_admin %}
|
|
<li><a href="{{ url_for('admin.users') }}" class="{{ 'active' if request.endpoint == 'admin.users' }}">
|
|
<i class="bi bi-people"></i> Users
|
|
</a></li>
|
|
<li><a href="{{ url_for('admin.activity_logs') }}"
|
|
class="{{ 'active' if request.endpoint == 'admin.activity_logs' }}">
|
|
<i class="bi bi-list-ul"></i> Activity Logs
|
|
</a></li>
|
|
<li><a href="{{ url_for('admin.ticket_templates') }}"
|
|
class="{{ 'active' if 'ticket_template' in request.endpoint }}">
|
|
<i class="bi bi-layout-text-window-reverse"></i> Ticket Templates
|
|
</a></li>
|
|
<li><a href="{{ url_for('admin.satisfaction_report') }}"
|
|
class="{{ 'active' if request.endpoint == 'admin.satisfaction_report' }}">
|
|
<i class="bi bi-star-half"></i> Satisfaction
|
|
</a></li>
|
|
<li><a href="{{ url_for('admin.settings') }}" class="{{ 'active' if request.endpoint == 'admin.settings' }}">
|
|
<i class="bi bi-gear"></i> Settings
|
|
</a></li>
|
|
{% endif %}
|
|
</ul>
|
|
{% endif %}
|
|
|
|
<div class="sidebar-footer">
|
|
<div class="user-card">
|
|
<div class="avatar">
|
|
{% if current_user.avatar_url %}
|
|
<img src="{{ url_for('auth.serve_avatar', filename=current_user.avatar_url) }}"
|
|
alt="{{ current_user.full_name[0].upper() }}"
|
|
style="width:32px;height:32px;border-radius:50%;object-fit:cover;display:block;" />
|
|
{% else %}
|
|
{{ current_user.full_name[0].upper() }}
|
|
{% endif %}
|
|
</div>
|
|
<div>
|
|
<div class="user-name">{{ current_user.full_name }}</div>
|
|
<div class="user-role">{{ current_user.role.replace('_',' ') }}</div>
|
|
</div>
|
|
<div class="ms-auto dropdown">
|
|
<button class="btn btn-sm btn-secondary dropdown-toggle" data-bs-toggle="dropdown" style="padding:5px 8px;">
|
|
<i class="bi bi-three-dots-vertical"></i>
|
|
</button>
|
|
<ul class="dropdown-menu dropdown-menu-end">
|
|
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}"><i
|
|
class="bi bi-person me-2"></i>Profile</a></li>
|
|
<li>
|
|
<hr class="dropdown-divider" />
|
|
</li>
|
|
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}"><i
|
|
class="bi bi-box-arrow-right me-2"></i>Logout</a></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- ── Main ── -->
|
|
<div id="main">
|
|
<header id="topbar">
|
|
<button class="btn btn-sm btn-secondary d-md-none"
|
|
onclick="document.getElementById('sidebar').classList.toggle('open')">
|
|
<i class="bi bi-list"></i>
|
|
</button>
|
|
<div class="page-title">{% block page_title %}{% endblock %}</div>
|
|
<!-- Notification bell -->
|
|
<button class="notif-btn" onclick="toggleNotifPanel()">
|
|
<i class="bi bi-bell"></i>
|
|
<span class="notif-badge {% if unread_notifications > 0 %}show{% endif %}" id="notif-badge-count">
|
|
{{ unread_notifications if unread_notifications > 0 }}
|
|
</span>
|
|
</button>
|
|
<!-- Dark mode toggle -->
|
|
<button id="dark-toggle" class="btn btn-sm btn-secondary" title="Toggle dark mode" onclick="toggleDarkMode()" style="padding:4px 8px;">
|
|
<i class="bi bi-moon" id="dark-icon"></i>
|
|
</button>
|
|
<a href="{{ url_for('auth.profile') }}" class="btn btn-sm btn-secondary">
|
|
<i class="bi bi-person-circle me-1"></i>{{ current_user.full_name.split()[0] }}
|
|
</a>
|
|
</header>
|
|
|
|
<div class="page-body">
|
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
{% for cat, msg in messages %}
|
|
<div class="alert alert-{{ cat }} alert-dismissible mb-3" role="alert">
|
|
{{ msg }}
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
</div>
|
|
{% endfor %}
|
|
{% endwith %}
|
|
{% block content %}{% endblock %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Notification Panel ── -->
|
|
<div id="notif-panel">
|
|
<div class="notif-head">
|
|
<h6><i class="bi bi-bell me-2"></i>Notifications</h6>
|
|
<button class="btn btn-sm btn-secondary" onclick="markAllRead()">Mark all read</button>
|
|
</div>
|
|
<div class="notif-list" id="notif-list">
|
|
<div class="p-3 text-center" style="color:var(--muted);font-size:13px;">Loading...</div>
|
|
</div>
|
|
<div style="padding:10px 16px;border-top:1px solid var(--border);">
|
|
<a href="{{ url_for('tickets.notifications') }}" style="font-size:12px;color:var(--accent3);">View all
|
|
notifications →</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Chat FAB ── -->
|
|
<div id="chat-fab" onclick="toggleChat()" title="IT Assistant">
|
|
<i class="bi bi-robot"></i>
|
|
</div>
|
|
<div id="chat-window">
|
|
<div class="chat-head">
|
|
<div class="bot-avatar">🤖</div>
|
|
<div>
|
|
<div class="bot-name">IT Assistant</div>
|
|
<div class="bot-status">● Online</div>
|
|
</div>
|
|
<button class="close-chat" onclick="toggleChat()"><i class="bi bi-x-lg"></i></button>
|
|
</div>
|
|
<div class="chat-messages" id="chat-messages">
|
|
<div class="chat-bubble bot">
|
|
Hi! I'm your IT Assistant 👋 I can help you report issues, create tickets, or answer IT questions.<br><br>
|
|
What can I help you with today?
|
|
</div>
|
|
</div>
|
|
<div class="chat-input-area">
|
|
<input type="text" id="chat-input" placeholder="Describe your issue..."
|
|
onkeydown="if(event.key==='Enter')sendChat()" />
|
|
<button class="chat-send" onclick="sendChat()"><i class="bi bi-send-fill"></i></button>
|
|
</div>
|
|
</div>
|
|
|
|
{% else %}
|
|
<!-- Not authenticated — minimal layout -->
|
|
<div id="main" style="margin-left:0;">
|
|
<div class="page-body">
|
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
{% for cat, msg in messages %}
|
|
<div class="alert alert-{{ cat }} alert-dismissible mb-3" role="alert">
|
|
{{ msg }}
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
</div>
|
|
{% endfor %}
|
|
{% endwith %}
|
|
{{ self.content() }}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- ── Reusable confirmation modal ─────────────────────────────────────────── -->
|
|
<div class="modal fade" id="confirm-modal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered modal-sm">
|
|
<div class="modal-content">
|
|
<div class="modal-header" style="padding:14px 20px;">
|
|
<h5 class="modal-title" id="confirm-modal-title" style="font-size:15px;font-weight:600;"></h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body" style="padding:16px 20px;">
|
|
<p id="confirm-modal-body" style="font-size:14px;color:var(--text2);margin:0;"></p>
|
|
</div>
|
|
<div class="modal-footer" style="padding:12px 20px;gap:8px;">
|
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" id="confirm-modal-ok" class="btn btn-sm btn-danger">Confirm</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
|
<script>
|
|
// ── CSRF helper ───────────────────────────────────────────────────────────────
|
|
// All state-changing fetch() calls must include the CSRF token header.
|
|
// Use csrfPost(url, body) instead of raw fetch(..., {method:'POST'}) to ensure
|
|
// the token from the <meta> tag is sent automatically.
|
|
const _csrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
|
|
// HTML-escape helper — use whenever inserting server-supplied strings into innerHTML.
|
|
function _esc(s) {
|
|
const d = document.createElement('div');
|
|
d.textContent = String(s == null ? '' : s);
|
|
return d.innerHTML;
|
|
}
|
|
|
|
// ── Confirmation modal ────────────────────────────────────────────────────────
|
|
// Use instead of window.confirm() for all destructive actions.
|
|
// Forms: add data-confirm="message" (and optionally data-confirm-title, data-confirm-ok,
|
|
// data-confirm-class) — the interceptor below handles submission automatically.
|
|
// JS callers: call confirmModal(title, message, okText, okClass, callback) directly.
|
|
function confirmModal(title, message, okText, okClass, onConfirm) {
|
|
const modal = document.getElementById('confirm-modal');
|
|
if (!modal) { if (window.confirm(message)) onConfirm(); return; }
|
|
document.getElementById('confirm-modal-title').textContent = title || 'Confirm Action';
|
|
document.getElementById('confirm-modal-body').textContent = message || 'Are you sure?';
|
|
const okBtn = document.getElementById('confirm-modal-ok');
|
|
okBtn.textContent = okText || 'Confirm';
|
|
okBtn.className = 'btn btn-sm ' + (okClass || 'btn-danger');
|
|
// Clone to drop any previous click listener
|
|
const fresh = okBtn.cloneNode(true);
|
|
okBtn.replaceWith(fresh);
|
|
const bsModal = new bootstrap.Modal(modal);
|
|
fresh.addEventListener('click', () => { bsModal.hide(); onConfirm(); });
|
|
bsModal.show();
|
|
}
|
|
|
|
// Auto-intercept any <form data-confirm="..."> or submit button with data-confirm.
|
|
document.addEventListener('submit', function(e) {
|
|
const form = e.target;
|
|
const submitter = e.submitter; // the button that triggered submit
|
|
const source = submitter || form;
|
|
const msg = source.dataset.confirm || form.dataset.confirm;
|
|
if (!msg || form._confirmed) { if (form._confirmed) form._confirmed = false; return; }
|
|
e.preventDefault();
|
|
confirmModal(
|
|
source.dataset.confirmTitle || form.dataset.confirmTitle || 'Confirm Action',
|
|
msg,
|
|
source.dataset.confirmOk || form.dataset.confirmOk || 'Confirm',
|
|
source.dataset.confirmClass || form.dataset.confirmClass || 'btn-danger',
|
|
() => {
|
|
form._confirmed = true;
|
|
form.requestSubmit ? form.requestSubmit(submitter || null) : form.submit();
|
|
}
|
|
);
|
|
}, true);
|
|
|
|
async function csrfPost(url, body = null) {
|
|
const opts = {
|
|
method: 'POST',
|
|
headers: { 'X-CSRFToken': _csrfToken() },
|
|
};
|
|
if (body !== null) {
|
|
if (typeof body === 'object' && !(body instanceof FormData)) {
|
|
opts.headers['Content-Type'] = 'application/json';
|
|
opts.body = JSON.stringify(body);
|
|
} else {
|
|
opts.body = body;
|
|
}
|
|
}
|
|
return fetch(url, opts);
|
|
}
|
|
|
|
// ── WebSocket ────────────────────────────────────────────────────────────────
|
|
{% if current_user.is_authenticated %}
|
|
const socket = io({
|
|
path: '/socket.io/',
|
|
transports: ['polling', 'websocket'], // polling first → upgrade to WS after handshake
|
|
upgrade: true,
|
|
reconnection: true,
|
|
reconnectionAttempts: 10,
|
|
reconnectionDelay: 3000, // wait 3s before first reconnect attempt
|
|
reconnectionDelayMax: 15000, // cap exponential backoff at 15s
|
|
timeout: 20000, // connection timeout
|
|
// Note: pingTimeout and pingInterval are server-side only in Socket.IO v4.
|
|
// The server controls the heartbeat (ping_interval=25, ping_timeout=60).
|
|
});
|
|
socket.on('connect', () => { console.log('[Socket] connected'); });
|
|
socket.on('new_notification', (n) => {
|
|
updateBadge(1, true);
|
|
prependNotif(n);
|
|
showToast(n.title, n.message);
|
|
});
|
|
|
|
function updateBadge(delta, increment = false) {
|
|
const el = document.getElementById('notif-badge-count');
|
|
if (!el) return;
|
|
let cur = parseInt(el.textContent) || 0;
|
|
const next = increment ? cur + delta : delta;
|
|
el.textContent = next > 0 ? next : '';
|
|
el.classList.toggle('show', next > 0);
|
|
updateDocumentTitle(next);
|
|
}
|
|
|
|
// ── Document title notification counter ───────────────────────────────────────
|
|
// Displays the unread count as a prefix on the browser tab title so users can
|
|
// see pending notifications without switching back to this tab.
|
|
// e.g. "(3) Dashboard — TechDesk"
|
|
const _baseDocTitle = document.getElementById('page-title-text')
|
|
? document.getElementById('page-title-text').textContent
|
|
: document.title;
|
|
|
|
function updateDocumentTitle(unreadCount) {
|
|
if (unreadCount > 0) {
|
|
document.title = '(' + unreadCount + ') ' + _baseDocTitle;
|
|
} else {
|
|
document.title = _baseDocTitle;
|
|
}
|
|
}
|
|
|
|
// Initialise on page load with the server-rendered unread count
|
|
(function () {
|
|
const badge = document.getElementById('notif-badge-count');
|
|
const initial = badge ? (parseInt(badge.textContent) || 0) : 0;
|
|
updateDocumentTitle(initial);
|
|
})();
|
|
|
|
// ── Notification panel ────────────────────────────────────────────────────────
|
|
let panelOpen = false;
|
|
|
|
function toggleNotifPanel() {
|
|
const panel = document.getElementById('notif-panel');
|
|
panelOpen = !panelOpen;
|
|
panel.classList.toggle('show', panelOpen);
|
|
if (panelOpen) loadNotifications();
|
|
}
|
|
|
|
document.addEventListener('click', e => {
|
|
if (panelOpen && !e.target.closest('#notif-panel') && !e.target.closest('.notif-btn')) {
|
|
panelOpen = false;
|
|
document.getElementById('notif-panel').classList.remove('show');
|
|
}
|
|
});
|
|
|
|
function _notifItemHtml(n) {
|
|
const unreadClass = n.is_read ? '' : ' unread';
|
|
const dot = n.is_read ? '' : '<div class="notif-dot"></div>';
|
|
return (
|
|
`<div class="notif-item${unreadClass}" data-id="${n.id}" data-link="${n.link || ''}" ` +
|
|
`style="cursor:${n.link ? 'pointer' : 'default'}">` +
|
|
dot +
|
|
`<div style="flex:1;min-width:0;">` +
|
|
`<div class="notif-title">${_esc(n.title)}</div>` +
|
|
`<div class="notif-msg">${_esc(n.message)}</div>` +
|
|
`<div class="notif-time">${_esc(n.created_at)}</div>` +
|
|
`</div></div>`
|
|
);
|
|
}
|
|
|
|
function _bindNotifClick(el) {
|
|
el.addEventListener('click', async () => {
|
|
const id = el.dataset.id;
|
|
const link = el.dataset.link;
|
|
// Mark as read in DB
|
|
if (el.classList.contains('unread')) {
|
|
try { await csrfPost(`/api/notifications/${id}/read`); } catch (_) { }
|
|
el.classList.remove('unread');
|
|
const dot = el.querySelector('.notif-dot');
|
|
if (dot) dot.remove();
|
|
// Decrement badge
|
|
const badge = document.getElementById('notif-badge-count');
|
|
if (badge) {
|
|
const cur = parseInt(badge.textContent) || 0;
|
|
const next = Math.max(0, cur - 1);
|
|
badge.textContent = next > 0 ? next : '';
|
|
badge.classList.toggle('show', next > 0);
|
|
}
|
|
}
|
|
if (link) window.location.href = link;
|
|
});
|
|
}
|
|
|
|
async function loadNotifications() {
|
|
const list = document.getElementById('notif-list');
|
|
list.innerHTML = '<div class="p-3 text-center" style="color:var(--muted);font-size:13px;">Loading…</div>';
|
|
try {
|
|
const r = await fetch('/api/notifications');
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
const data = await r.json();
|
|
const notifs = data.notifications || [];
|
|
|
|
// Update badge from live count
|
|
const unreadCount = notifs.filter(n => !n.is_read).length;
|
|
updateBadge(unreadCount);
|
|
|
|
if (notifs.length === 0) {
|
|
list.innerHTML = '<div class="p-4 text-center" style="color:var(--muted);font-size:13px;">' +
|
|
'<i class="bi bi-bell-slash" style="font-size:24px;display:block;margin-bottom:8px;"></i>' +
|
|
'No notifications yet.</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = notifs.map(_notifItemHtml).join('');
|
|
list.querySelectorAll('.notif-item').forEach(_bindNotifClick);
|
|
|
|
} catch (e) {
|
|
list.innerHTML = '<div class="p-3 text-center" style="color:var(--muted);font-size:13px;">Unable to load notifications.</div>';
|
|
}
|
|
}
|
|
|
|
function prependNotif(n) {
|
|
// Add new real-time notification to the top of the panel if it's open
|
|
const list = document.getElementById('notif-list');
|
|
// Remove "no notifications" placeholder if present
|
|
if (list.querySelector('.bi-bell-slash')) list.innerHTML = '';
|
|
const tmp = document.createElement('div');
|
|
tmp.innerHTML = _notifItemHtml({
|
|
id: n.id, title: n.title, message: n.message || '',
|
|
link: n.link || '', is_read: false, created_at: 'Just now'
|
|
});
|
|
const el = tmp.firstElementChild;
|
|
_bindNotifClick(el);
|
|
list.prepend(el);
|
|
}
|
|
|
|
async function markAllRead() {
|
|
try { await csrfPost('/api/notifications/mark-all-read'); } catch (_) { }
|
|
updateBadge(0);
|
|
document.querySelectorAll('.notif-item.unread').forEach(el => {
|
|
el.classList.remove('unread');
|
|
const dot = el.querySelector('.notif-dot');
|
|
if (dot) dot.remove();
|
|
});
|
|
}
|
|
|
|
function showToast(title, msg) {
|
|
const t = document.createElement('div');
|
|
t.style.cssText = 'position:fixed;top:72px;right:16px;background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--accent3);border-radius:8px;padding:12px 16px;z-index:1090;max-width:320px;box-shadow:0 4px 16px rgba(0,0,0,.18);animation:slideIn .25s ease;';
|
|
t.innerHTML = `<div style="font-size:13px;font-weight:600;margin-bottom:4px;">${_esc(title)}</div><div style="font-size:12px;color:var(--muted);">${_esc(msg)}</div>`;
|
|
document.body.appendChild(t);
|
|
setTimeout(() => t.remove(), 5000);
|
|
}
|
|
|
|
// ── Chatbot ───────────────────────────────────────────────────────────────────
|
|
let chatHistory = [];
|
|
let chatOpen = false;
|
|
function toggleChat() {
|
|
chatOpen = !chatOpen;
|
|
document.getElementById('chat-window').classList.toggle('show', chatOpen);
|
|
if (chatOpen) document.getElementById('chat-input').focus();
|
|
}
|
|
async function sendChat() {
|
|
const input = document.getElementById('chat-input');
|
|
const msg = input.value.trim();
|
|
if (!msg) return;
|
|
input.value = '';
|
|
appendBubble(msg, 'user');
|
|
chatHistory.push({ role: 'user', content: msg });
|
|
const typing = appendTyping();
|
|
try {
|
|
const r = await csrfPost('/chatbot/message', { message: msg, history: chatHistory });
|
|
const d = await r.json();
|
|
typing.remove();
|
|
const reply = d.reply || 'Sorry, I encountered an error.';
|
|
appendBubble(reply, 'bot');
|
|
if (d.ticket) {
|
|
// Clear history after ticket creation — keeping the prior conversation
|
|
// (which contains the AI's JSON action block) causes the model to
|
|
// re-trigger ticket creation on every subsequent message.
|
|
chatHistory = [];
|
|
const notif = document.createElement('div');
|
|
notif.className = 'chat-bubble bot';
|
|
notif.style.cssText = 'background:rgba(45,212,191,.1);border:1px solid rgba(45,212,191,.3);';
|
|
notif.innerHTML = `🎫 <strong>${d.ticket.ticket_number}</strong> — <a href="${d.ticket.url}">View Ticket</a>`;
|
|
document.getElementById('chat-messages').appendChild(notif);
|
|
} else {
|
|
chatHistory.push({ role: 'assistant', content: reply });
|
|
}
|
|
} catch (e) { typing.remove(); appendBubble('Sorry, something went wrong.', 'bot'); }
|
|
scrollChat();
|
|
}
|
|
function appendBubble(text, role) {
|
|
const el = document.createElement('div');
|
|
el.className = `chat-bubble ${role}`;
|
|
el.innerHTML = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br>');
|
|
document.getElementById('chat-messages').appendChild(el);
|
|
scrollChat();
|
|
return el;
|
|
}
|
|
function appendTyping() {
|
|
const el = document.createElement('div');
|
|
el.className = 'chat-bubble bot';
|
|
el.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
|
|
document.getElementById('chat-messages').appendChild(el);
|
|
scrollChat();
|
|
return el;
|
|
}
|
|
function scrollChat() {
|
|
const m = document.getElementById('chat-messages');
|
|
m.scrollTop = m.scrollHeight;
|
|
}
|
|
{% endif %}
|
|
</script>
|
|
<style>
|
|
@keyframes slideIn {
|
|
from {
|
|
transform: translateX(100%);
|
|
opacity: 0;
|
|
}
|
|
|
|
to {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
#nprogress-bar {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
height: 3px;
|
|
width: 0%;
|
|
background: var(--accent);
|
|
z-index: 9999;
|
|
transition: width .2s ease, opacity .4s ease;
|
|
border-radius: 0 2px 2px 0;
|
|
}
|
|
</style>
|
|
<script>
|
|
(function () {
|
|
const bar = document.createElement('div');
|
|
bar.id = 'nprogress-bar';
|
|
document.body.appendChild(bar);
|
|
|
|
let _raf;
|
|
function startProgress() {
|
|
bar.style.opacity = '1';
|
|
bar.style.width = '0%';
|
|
bar.style.transition = 'width .2s ease';
|
|
let w = 0;
|
|
function tick() {
|
|
w = w < 70 ? w + (70 - w) * 0.08 : w < 90 ? w + 0.4 : w;
|
|
bar.style.width = Math.min(w, 90) + '%';
|
|
_raf = requestAnimationFrame(tick);
|
|
}
|
|
_raf = requestAnimationFrame(tick);
|
|
}
|
|
function finishProgress() {
|
|
cancelAnimationFrame(_raf);
|
|
bar.style.width = '100%';
|
|
bar.style.transition = 'width .15s ease, opacity .4s ease .15s';
|
|
setTimeout(() => { bar.style.opacity = '0'; setTimeout(() => { bar.style.width = '0%'; }, 400); }, 200);
|
|
}
|
|
|
|
document.addEventListener('click', function (e) {
|
|
const a = e.target.closest('a[href]');
|
|
if (!a) return;
|
|
const href = a.getAttribute('href');
|
|
if (!href || href.startsWith('#') || href.startsWith('javascript') || a.target === '_blank' || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
|
try { const u = new URL(href, location.href); if (u.origin !== location.origin) return; } catch (_) { return; }
|
|
startProgress();
|
|
});
|
|
window.addEventListener('pageshow', finishProgress);
|
|
})();
|
|
</script>
|
|
<script>
|
|
function toggleDarkMode() {
|
|
const html = document.documentElement;
|
|
const next = html.getAttribute('data-bs-theme') === 'dark' ? 'light' : 'dark';
|
|
html.setAttribute('data-bs-theme', next);
|
|
localStorage.setItem('theme', next);
|
|
_syncDarkIcon(next);
|
|
}
|
|
function _syncDarkIcon(theme) {
|
|
const icon = document.getElementById('dark-icon');
|
|
if (!icon) return;
|
|
icon.className = theme === 'dark' ? 'bi bi-sun' : 'bi bi-moon';
|
|
}
|
|
_syncDarkIcon(document.documentElement.getAttribute('data-bs-theme') || 'light');
|
|
</script>
|
|
{% block scripts %}{% endblock %}
|
|
</body>
|
|
|
|
</html> |