667 lines
31 KiB
HTML
667 lines
31 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
|
<meta name="csrf-token" content="{{ csrf_token() }}"/>
|
|
<title>{% block title %}IT Helpdesk{% endblock %} — TechDesk</title>
|
|
<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);
|
|
}
|
|
*{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;white-space:pre-wrap;color:var(--text2);}
|
|
.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">
|
|
<div class="logo">TD</div>
|
|
<div>
|
|
<h1>TechDesk</h1>
|
|
<small>IT Helpdesk System</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>
|
|
{% endif %}
|
|
</ul>
|
|
{% endif %}
|
|
|
|
<div class="sidebar-footer">
|
|
<div class="user-card">
|
|
<div class="avatar">{{ current_user.full_name[0].upper() }}</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>
|
|
<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 %}
|
|
|
|
<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 || '';
|
|
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);
|
|
}
|
|
|
|
// ── 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">${n.title}</div>` +
|
|
`<div class="notif-msg">${n.message || ''}</div>` +
|
|
`<div class="notif-time">${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;bottom:90px;right:28px;background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--accent3);border-radius:8px;padding:12px 16px;z-index:300;max-width:300px;box-shadow:0 4px 16px rgba(0,0,0,.4);animation:slideIn .25s ease;';
|
|
t.innerHTML=`<div style="font-size:13px;font-weight:600;margin-bottom:4px;">${title}</div><div style="font-size:12px;color:var(--muted);">${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');
|
|
chatHistory.push({role:'assistant',content:reply});
|
|
if(d.ticket){
|
|
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);
|
|
}
|
|
}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;}}</style>
|
|
{% block scripts %}{% endblock %}
|
|
</body>
|
|
</html> |